惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Cisco Blogs
Cyberwarzone
Cyberwarzone
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
SecWiki News
SecWiki News
Martin Fowler
Martin Fowler
T
Tor Project blog
N
Netflix TechBlog - Medium
C
Cybersecurity and Infrastructure Security Agency CISA
V
Vulnerabilities – Threatpost
V
Visual Studio Blog
GbyAI
GbyAI
PCI Perspectives
PCI Perspectives
D
DataBreaches.Net
Jina AI
Jina AI
H
Heimdal Security Blog
云风的 BLOG
云风的 BLOG
P
Privacy International News Feed
A
About on SuperTechFans
J
Java Code Geeks
美团技术团队
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
News | PayPal Newsroom
有赞技术团队
有赞技术团队
MyScale Blog
MyScale Blog
博客园 - 司徒正美
C
Check Point Blog
T
Threat Research - Cisco Blogs
Attack and Defense Labs
Attack and Defense Labs
宝玉的分享
宝玉的分享
AI
AI
Simon Willison's Weblog
Simon Willison's Weblog
C
Cyber Attacks, Cyber Crime and Cyber Security
I
Intezer
P
Proofpoint News Feed
Blog — PlanetScale
Blog — PlanetScale
Apple Machine Learning Research
Apple Machine Learning Research
Hugging Face - Blog
Hugging Face - Blog
The Last Watchdog
The Last Watchdog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Vercel News
Vercel News
I
InfoQ
阮一峰的网络日志
阮一峰的网络日志
Cisco Talos Blog
Cisco Talos Blog
W
WeLiveSecurity
Hacker News: Ask HN
Hacker News: Ask HN
Recent Commits to openclaw:main
Recent Commits to openclaw:main
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
D
Docker
博客园 - Franky
Security Archives - TechRepublic
Security Archives - TechRepublic

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Внутри метастора S3 в One-Cloud
HooinKema · 2026-06-24 · via Все публикации подряд на Хабре

Средний

16 мин

419

Как мы пришли к локальному скану, фильтру Блума и переезду очереди на Kafka — и почему это всё случилось 

Привет, Хабр. Я Данил Кислов, разработчик команды хранилищ. У нас в One-cloud (внутренняя корпоративная облачная платформа) лежит собственная S3-совместимая реализация — one-object-storage. Хочу рассказать, как эволюционировал метастор S3 — та часть, что отвечает за метаданные объектов: списки версий, индексы, настройки бакетов и прочую служебную мелочь.

История начинается с того, что система, построенная под одни требования, перестаёт им соответствовать и постепенно адаптируется к новым. Почти каждое дальнейшее решение — компромисс, который приехал под конкретную боль на конкретном масштабе. И почти у каждого есть свои плюсы и минусы.

Что мы держим

Чтобы было понятно, о каком масштабе речь. На сегодня one-object-storage обслуживает десятки кластеров и хранит миллиарды объектов с совокупным объёмом в диапазоне десятков петабайт. Ежедневно система обрабатывает несколько петабайт на чтение и сотни терабайт на запись и удаление. Метастор развёрнут примерно на тысяче хостов с десятками тысяч ядер процессоров. Решением пользуется множество внутренних продуктов и сервисов группы компаний.

Что такое S3 и из чего он состоит

S3 — это объектное хранилище данных от Amazon. Формальной спецификации в привычном смысле у него нет, есть только референс — поведение оригинального Amazon S3, под которое подстраиваются альтернативные реализации. Те, что повторяют этот референс, называются S3-совместимыми. One-object-storage — одна из таких реализаций.

Базовая клиентская часть S3 описывается через HTTP API: GetObject, PutObject, DeleteObject и DeleteObjects для массовых удалений, CopyObject, ListObjects. То есть это не файловая система, а именно API над объектами: положить, забрать, удалить, скопировать, отдать список.

Помимо клиентских HTTP-запросов, у S3 есть фоновые задачи. Главная — lifecycle rules, правила жизненного цикла объектов: Transition action, Expiration action, Expire noncurrent, Expire after days и after date, AbortIncompleteMultipartUpload, ExpiredObjectDeleteMarker. И отдельно идёт статистика, нужная для мониторинга, биллинга и квотирования. Сразу отмечу: в нашей истории статистика не декоративная фича и станет одной из причин, по которой одного периодического снапшота окажется мало.

Шаблон S3-хранилища и кто в роли кого

Архитектурно S3-хранилище удобно представить как четыре блока. Client стучится в API S3 — это сервис, отвечающий за поведение S3. За ним Block Storage, который содержит блобы, то есть данные. Рядом — метастор, где живут метаданные объектов. И отдельно Scheduler, исполнитель асинхронных фоновых операций.

Этот шаблон вполне натягивается на разные реализации. Например, на Ceph: его внутренняя база RocksDB играет роль метастора, а сам Ceph как блоковое хранилище — это Block Storage. Метастор может быть написан и на PostgreSQL, только не обычном, а шардированном под нагрузки S3. У нас же роль метастора выполняет NewSQL, а роль Block Storage — One Blob Storage и One Cold Storage. Про сами блок-хранилища подробно говорить не буду, если интересно, смотрите доклад Александра Христофорова, там они затронуты.

Почему мы пилили своё?

Вопрос законный. Любой готовый вариант, что мы рассматривали, упирался в одну или несколько проблем. Первое: сложно поддерживать и масштабировать. Второе: нет достоверных данных о крупных инсталляциях, и можно сильно удивиться на наших объёмах, особенно с open-source проектами. Третье: незнакомый стек технологий. В частности, не Java, на котором мы работаем, а что-то другое.

Хотя бы одна из этих проблем стабильно вылезала, поэтому в итоге мы пошли в свою реализацию. Подробнее историю и контекст разбирал в своём докладе Вадим Цесько («OK S3: Строим систему сами») — его выступление можно смотреть как приквел к этому рассказу.

NewSQL под капотом метастора

NewSQL у нас — это решение на основе Apache Cassandra с координатором транзакций. Поддерживает ACID-транзакции (классические гарантии атомарности, согласованности, изолированности и устойчивости) в рамках одной партиции; шардирован; легко масштабируется; даёт высокую скорость запросов и доступность. Под партицией здесь имеется в виду логическая группа строк, привязанных к одному ключу партиционирования — внутри одной партиции Cassandra может работать предсказуемо и быстро.

Важное уточнение: транзакции тут именно в рамках одной партиции, а не произвольные распределённые между любыми данными. Это и есть тот набор требований к базе, который нам нужен для онлайн-запросов S3 API: шардирование, транзакции, эффективные OLTP-запросы (то есть обработка коротких транзакций в реальном времени).

Что содержит метастор

Всего в метасторе шесть типов данных. Базовая таблица — список версий и маппинг версий на блоки. У записи поля Parent, Name, Version и Blocks: хранилище знает, какая версия какого объекта собрана из каких блоков. 

Дальше — пользовательские метаданные версии: Content-Type, Content-Encoding, Cache-Control. В табличном примере поля cache_control, encoding, type и Version; type может быть, например, image/jpeg.

Третий тип данных — uploads и upload parts. Это про multipart upload, сценарий, когда объект загружается не одним запросом, а частями. В примерах фигурируют Parent, Name, Upload, meta, Part, Blocks. 

Дальше — индексные таблицы (objects и folder). Отдельной сущности они не представляют, но позволяют ускорять запросы, в первую очередь листинг. Они отражают иерархию директорий вида images/pets/cats/black.

Пятое — настройки бакета. На иллюстрации это LIFECYCLE и VERSIONING. XML-пример lifecycle-правила удаляет старые версии после одного года (NoncurrentDays = 365) и незавершённые multipart uploads после трёх дней (DaysAfterInitiation = 3). 

И наконец, внутренние служебные таблицы: locks, block status, block reference. У таблицы block status поля block, md5 и status нужны, чтобы при загрузке блоков согласованно держать их состояние.

С этими таблицами одновременно работают и онлайн-запросы из S3 API, и фоновые операции. Дальше речь пойдёт в основном про последние.

Lifecycle-правила: скан, фильтр, применение

Любое lifecycle-правило раскладывается на три шага. Сначала — сканирование метаданных. Запрос диапазона версий превращается в CQL-запрос (CQL — Cassandra Query Language, диалект Cassandra, близкий к SQL). Данные в Cassandra шардированы по токену, токен считается как хеш от Partition Key. Диапазоны токенов вручную раздают между шедулерами, благодаря этому шедулеры не толкаются между собой и не лезут в одни и те же данные.

Дальше — фильтр на соответствие правилу. После запроса у шедулера в памяти уже есть список версий, и можно проверить, какие из них подходят под правило. Условия бывают хитрее даты: например, NoncurrentVersionExpiration с параметром NewerNoncurrentVersions: 2 говорит о том, что версию надо проверять не только по дате, но и по количеству более новых noncurrent-версий. То есть для одного объекта приходится смотреть на группу его версий целиком.

Последний шаг — применение правила. В примере это удаление версии, и делается оно на шедулере уже с транзакциями, чтобы держать консистентность. Здесь же стоит зафиксировать промежуточный итог: метастор должен одновременно эффективно отдавать данные для клиентских HTTP-запросов и тянуть фоновые операции — сканирование, фильтрацию, применение правил, сбор статистики.

Зачем lifecycle вообще нужны на практике

Может показаться, что lifecycle — это какая-то вторичная фича. На самом деле для пользователя это основной способ освободить место в бакете. Причин несколько. В S3 хранится огромное количество журналов, дампов, бэкапов, кешей и прочих временных данных, от которых пользователь хочет избавляться периодически и автоматически. Клиенты редко удаляют по номеру версии, а в версионированных бакетах без удаления по версии место не освобождается — остаётся только delete-маркер, а сами данные продолжают занимать диск. И массовое удаление через S3 API менее оптимизировано, чем то, что можно сделать внутренним сервисом. Так что lifecycle — это не приятный бонус, а боевой инструмент.

Tombstone в Cassandra

Дальше начинается главный сюжетный антагонист всей истории — tombstone в Cassandra. Если коротко, в Cassandra данные хранятся в отсортированных неизменяемых таблицах. Просто так изменить таблицу и удалить из неё запись нельзя, потому что таблицы неизменяемые. Удаление в Cassandra — это запись с особой пометкой. Такая запись называется tombstone, или могила на жаргоне.

Главная проблема не в самом факте, что у нас остаётся след от удаления. Главная проблема в том, что range-запросы (запросы диапазона по ключу) читают могилы наравне с живыми данными и фильтруют их уже в памяти. 

Например: SELECT * FROM cf WHERE k >= 0 and k < 1000, а в партиции 10 живых записей и 100 000 могил. Понятно, что запрос прочитает гораздо больше, чем реально вернёт

Когда в одной партиции таких могил много, range-запрос либо подвисает, либо вообще падает по лимиту на количество прочитанных tombstone. Это центральная техническая проблема, на которой потом строится вся история про переход к локальному скану и пересмотр очереди.

Партиционирование и его потолок

Дальше нюанс, который мешает нам просто избегать больших партиций волевым решением. Для быстрого листинга объекты у нас партиционированы по родительской директории: одна директория соответствует одной партиции. Это позволяет одним запросом достать все объекты из одного места на диске и отдать.

Из этого следует ограничение: нельзя класть в одну директорию слишком много объектов, версий или подкаталогов. Оптимальный ориентир — тысяча объектов, версий или подкаталогов на каталог. В трёхуровневую иерархию с такой нормой укладывается до миллиарда объектов, чего на старте более чем хватало.

Жёсткая граница — 100 тыс. Если в префиксе становится больше 100 тыс. объектов, то листинг этого префикса начинает деградировать при удалениях. Причина — те же tombstone и range-запросы.

Когда стало невозможно договариваться

Пока one-object-storage был S3 для Одноклассников и обслуживал ограниченный набор внутренних сервисов вроде registry и TeamCity, с ограничением в одну тысячу объектов на каталог можно было жить. В случае проблемы достаточно было прийти к команде клиента и объяснить, как правильно использовать, а где-то даже что-то им законтрибьютить. Это работало.

Потом one-object-storage начали использовать другие продукты группы компаний — в частности, на него переехал Дзен. Сценариев использования стало больше, продуктов — тоже. На таком масштабе договариваться со всеми клиентами о правильном использовании уже не получится: люди будут раскладывать данные так, как им удобно, и это надо принять как факт. Сейчас у нас 43 директории с более чем 100 тыс. объектов в 43 разных бакетах.

И тут включается первая ломающаяся вещь — сканирование. Оно построено на range-запросах. Из-за неправильного использования образовались широкие партиции. В них копятся могилы, range-запросы встают, чистка останавливается, место в бакетах не освобождается. Проблема наиболее болезненна там, где чистка нужна больше всего: в крупных бакетах. И решать её нужно было быстро.

Первое, что напрашивается: если могилы — это особенность Cassandra, то давайте Cassandra и уберём. Логично. Но дальше начинается длинный список «но». Новый метастор должен так же хорошо масштабироваться, так же эффективно держать S3 API, под него нужно заново выстраивать шардирование. И, главное, быстро такой переход не сделать — это работа на месяцы, а проблема горит сейчас.

Вывод формулировался прямо: отказ от Cassandra — пока не вариант. Это не значит, что мы считаем Cassandra идеальной для нашего сценария. Это значит, что в тот момент быстрый переход с неё не подходил по срокам и рискам.

Локальное сканирование как компромисс

Раз снаружи запрашивать данные для сканирования дорого — из-за могил и широких партиций, — то давайте сканировать изнутри. То есть приблизить сканирование к месту хранения данных, чтобы не гонять проблемные range-запросы снаружи.

В рассуждении можно зайти дальше и полностью перенести шедулер в метастор. Это решит проблему могил, но заведёт несколько новых: 

  • неконтролируемое потребление ресурсов фоновой задачей (а основная работа метастора — обрабатывать S3 API)

  • усложнение развёртывания в Stateful-базу

  • замедление миграции с NewSQL на новую схему

  • общая непрозрачность того, как применяются правила

Поэтому компромисс — фильтровать локально, а правила применять на шедулере. Локальное сканирование на стороне метастора находит кандидатов, а применение правила с транзакциями остаётся там же, где было.

Реализация локального сканирования идёт пошагово. Сначала версии читаются из таблицы прямо с диска на хосте Cassandra. Затем они группируются по объектам — это работает, потому что Primary Key версии содержит ключ объекта, и все версии одного объекта лежат подряд на одном хосте. Дальше для группы выполняется lifecycle rule с параметром dryRun=true. Цель этого шага — не сразу удалять, а найти кандидата, которого потом отдадим шедулеру.

Что делать с объектами на 100 тысяч версий?

У локального сканирования быстро вылез отдельный неприятный момент. Наша реализация S3, в отличие от оригинальной Amazon, не ограничивает количество версий одного объекта. И если у него, скажем, 100 тыс. версий, то читать их все в память слишком дорого.

При этом для проверки dryRun=true все версии и не нужны. Достаточно найти хотя бы одну, которая подходит под правило, а дальше шедулер сам разберётся, что и как удалять, наша задача — отдать ему кандидата. Поэтому при локальном сканировании мы храним только 100 последних версий объекта — если в них нет подходящих под правило, то в более старых их тем более не будет. Правило NewerNoncurrentVersions при этом ограничивается теми же 100 версиями.

Дополнительно нужно хранить актуальную версию объекта, чтобы избежать ложноположительных экспираций. Иначе оптимизация может привести к тому, что шедулер примет сотую версию за актуальную, хотя по факту это не она, и удалит что-то лишнее.

Очередь сначала сделали на Cassandra

После локального сканирования нужно где-то хранить кандидатов до того, как шедулер до них дойдёт. Это очередь: метастор пишет, шедулер читает. Первую реализацию очереди мы сделали через таблицу в Cassandra. Для масштабирования поддержали партиции, начали хранить offset для возобновления работы. По сути, получилось похожее на Kafka: продюсер и консюмер по своим табличкам.

Структура такая: одна таблица с part, num и value (то есть партиция очереди, номер и сам ключ), вторая таблица с part, reader и offset (прогресс чтения для конкретного reader). Удаление делалось только по TTL (time-to-live — срок жизни записи), gc_grace_seconds выставили в 0, чтобы могилы удалялись при следующем уплотнении и не копились внутри очереди.

Параллельно к этой схеме добавили метрику на количество просроченных версий — тех, которые по правилу давно должны были удалиться, но почему-то не удалились. Метрику собирали прямо при локальном сканировании. И сразу стало видно: на многих бакетах накопилось аномально много просроченных версий. Значит, с чисткой что-то идёт совсем не так.

Быстро выяснилось, в чём дело: на запросы poll очереди шли массовые таймауты. Шедулер не мог нормально прочитать своих кандидатов — Cassandra на запросе очереди упиралась в лимит по прочитанным могилам.

Очередь подразумевает большое количество удалений и range-запросов. Из удалений рождается много могил, а из-за них range-запросы падают. То есть очередь на Cassandra в нашем сценарии — это плохая идея именно из-за её паттерна доступа, а не из-за того, что Cassandra сама по себе плохая.

Гамбит Кассандры

Тем не менее очередь на Cassandra не была бессмысленной ошибкой с самого начала. Она позволила быстро мигрировать на новую версию с локальным сканированием — порядок чисел такой, что один кластер мы могли мигрировать за один день. На большинстве бакетов с умеренной чисткой эта очередь работала нормально. Проблемы вылезали только там, где чистка шла особо активно — например, на бакетах-кешах, где в день удаляется условно полбакета.

Даже там какое-то время можно было держать ситуацию полумерами: truncate таблиц очереди, чтобы разово выкинуть могилы; троттлинг чистки, что, конечно, не решение, потому что мусор просто продолжит накапливаться. То есть это был временный компромисс, который своё дело сделал, но на больших объёмах упёрся в ограничения.

Переезд очереди на Kafka

Вместо того чтобы продолжать делать из Cassandra Kafka, мы перенесли очередь на саму Kafka. Её кластер у нас один на все примерно 50 кластеров метастора. Каждый топик Kafka соответствует одной очереди одного метастора.

Например, для метастора S3 Дзена есть топики s3-dzen-versions, s3-dzen, s3-dzen-uploads, s3-dzen-version-folders, s3-dzen-object-folders и s3-dzen-upload-folders. Для метастора S3 Rustore — аналогичный набор. Новые топики создаются автоматически, то есть и продюсер, и консюмер могут создать топик, не дёргая никого вручную. По умолчанию на топик приходится три партиции, на крупных кластерах с активной чисткой мы дотягивали до 24.

Сам кластер Kafka сделан с помощью One-сloud Managed Kafka (MDB). MDB появился сильно позже, чем первая реализация метастора, и это, в общем, дополнительный аргумент в пользу того, что очередь на Cassandra в момент её рождения была более удобным решением, чем сейчас. Сегодня кластер Kafka собирается накликиванием, с готовыми графиками и оповещениями.

На уровне кода у нас две реализации очереди — на Cassandra и на Kafka, — а переключение между ними сделано через feature toggle (флаг переключения функциональности). Для переезда отдельного кластера достаточно сменить одну настройку — с поправкой на сетевые доступы.

Партиционирование очереди по хосту

Следующее узкое место всплыло уже после переезда на Kafka. Сканирование локально запускается на хостах метастора в разное время — мы их перезапускаем хаотично, и они в фазах расходятся. Если ставить стандартное равномерное партиционирование, то кандидаты, которые пишут на один хост, размазываются по всем партициям топика. А каждую партицию читает свой шедулер. В итоге все шедулеры одновременно идут чистить ключи с одного и того же хоста Cassandra.

Получается неприятная нагрузка: чистка тормозит, потому что упирается в этот один хост, и попутно страдают HTTP-запросы S3 API, которые ходят туда же.

Эффективнее партиционировать по источнику. Каждый хост метастора пишет всегда в одну и ту же партицию, каждую партицию читает не более одного шедулера. Нагрузка получается заметно ровнее, без точечных всплесков на отдельных хостах Cassandra.

Статистика и снапшоты

С такими решениями для lifecycle-правил мы вышли на следующие показатели:

  • Скорость сканирования 18 хостов метастора — 14 млн RPM.

  • Скорость чистки 24 хостов шедулеров — 500 тыс. RPM, в пике 1,1 млн RPM.

Сбор статистики у нас устроен через локальное сканирование метаданных и запись снапшотов в Cassandra. Снапшот — это запомненное состояние кластера на момент окончания сканирования.

В таблице снапшотов есть поля source, ks, ts, handler, stat и status. Source — ID хоста. Handler — таблица, которую сканировали. Status — либо ключ, на котором закончился скан, либо completed. Stat хранит саму статистику в сериализованном виде, и туда может попадать многое: 

  • размер всех версий бакета

  • топ объектов по размеру

  • топ объектов по количеству версий

  • топ директорий по количеству объектов

  • блоки с наибольшим числом ссылок

  • прочие агрегаты — некоторые из них используются и для поиска аномалий

Ограничение размера бакета и статистика в реальном времени

Отдельная задача внутри статистики — ограничивать размер бакета. Раньше у S3 было немного клиентов, и можно было каждому бакету отдавать своё хранилище, клиенты не мешали друг другу. С ростом количества клиентов это перестало работать: они стали жить в общих, коммунальных хранилищах. А в коммунальной квартире, если один сосед начинает занимать слишком много места, страдают все.

Решать проблему в лоб через снапшоты не получается. Сканирование длится до часа, и за это время пользователь успевает налить себе, скажем, 5 ТБ — пока статистика обновится, ограничение уже не сработает вовремя. Хуже того, ему самому будет непонятно, что и сколько чистить: данные о размере бакета устарели, и пользователь принимает решения на основе старой информации.

Поэтому появилась статистика в реальном времени. Идея простая: размер бакета считается как результат из последнего снапшота плюс сумма изменений, прошедших с момента этого снапшота. Изменения, которые влияют на размер бакета, — это запись нового блока, удаление существующего и копирование. На схеме они подписаны как +blkStore, −blkRemove и +blkCopy.

Чтобы постоянный поток отдельных событий не приходилось каждый раз пересуммировать с нуля, отдельная фоновая задача группирует операции по пятиминутным интервалам. То есть на запрос текущего размера бакета мы тратим предсказуемое и небольшое время, а не пересчитываем всё с начала времён.

Индекс директорий и пустые директории

Никаких настоящих директорий в S3 нет, но у нас есть отдельная индексная таблица, которая отражает иерархию префиксов и нужна для быстрого листинга. И таблица версий, и таблица директорий партиционированы по родительской директории. Для объекта вида images/pets/cats/black/blackCat.png в индексной таблице директорий появляются записи типа images/ → pets, images/pets/ → cats, images/pets/cats/ → black.

Очевидный подход — при удалении объекта тут же обновить индекс: удалить объект, проверить запросом вида SELECT * FROM objects WHERE parent='images/pets' limit 1, стала ли родительская директория пустой, и если стала, то удалить её из таблицы директорий. Проблема в том, что большинство директорий содержат больше одного объекта, и проверки эти будут уходить впустую — мы будем тратить ресурсы на постоянные ложные срабатывания.

Альтернатива — ленивое обновление индекса: при удалении объекта индекс не трогаем. Дальше, когда приходит рекурсивный листинг и проходит по дереву, он сам по ходу собирает список директорий и проверяет, какие из них пустые, и их удаляем.

Но и здесь не всё хорошо: под одним родителем со временем может скопиться большое количество пустых директорий. Если потом удалить их все одновременно при первом рекурсивном листинге, то мы замогилим партицию родительской директории. И, как уже было выше, большое количество могил в одной партиции делает её больной точкой: листинг этой директории будет тормозить, и не только рекурсивный.

Значит, нужна отдельная фоновая задача, которая чистит пустые директории мягко и заранее, не давая им копиться большими пачками.

Почему пустоту нельзя проверить локально

Решение в лоб опять напрашивается через локальное сканирование, хорошо себя зарекомендовавшее в lifecycle. Идея такая: локально сканируем таблицу с директориями, для каждой проверяем, есть ли в ней объекты, пустые кладём в очередь на удаление.

Но это не работает. Объекты и директории партиционированы по родителю. Возьмём объект с ключом a/b/c/obj. Запись с директорией a/b/c партиционируется по родителю a/b, а сам объект a/b/c/obj — по родителю a/b/c. То есть директория и её содержимое могут физически лежать на разных хостах.

Из этого следует простой, но обидный вывод: локально проверить, пуста ли директория, нельзя. Сама директория и объекты внутри неё в общем случае не лежат рядом.

Делать для каждой проверки реальный запрос в базу слишком дорого, у нас выполняется около 30 миллионов проверок при сканировании, большинство из которых ложные. Значит, всю информацию нужно держать в памяти. Простой hash set тоже не выходит — речь идёт примерно про 16 миллионов строк, без частичной потери информации они в разумную память не лезут.

Фильтр Блума под директории

Подходящий вариант — вероятностное множество на основе фильтра Блума. Если совсем коротко, это компактная структура, которая позволяет проверять наличие элемента в множестве. Она может ошибаться в одну сторону: давать false positive, то есть сказать, что элемент есть, когда его на самом деле нет. Но для добавленных элементов false negative она не даёт.

Реализация устроена так. Сначала запускается отдельная задача — локальное сканирование объектов. В памяти создаётся фильтр Блума, размер оценивается по общему количеству директорий в индексной таблице. Для каждого объекта в фильтр складываются все его родительские директории — то есть фильтр в итоге хранит непустые директории. 

Для объекта pets/cats/black/image1.png в фильтр попадают pets/, pets/cats/ и pets/cats/black/. В конце сканирования сериализованный фильтр записывается в отдельную таблицу. Размером он на больших бакетах может доходить до 800 МБ, но hash set занимал бы существенно больше.

Когда запускается чистка пустых директорий, перед сканом таблицы директорий мы читаем фильтр в память. Каждую директорию проверяем по фильтру: если её в нём нет — она пустая, кладём в очередь на удаление. Если случается false positive, то мы пропустим какую-то реально пустую директорию. На отдельную ошибку можно не обратить внимания: цель этой задачи — не выловить каждую пустую директорию в моменте, а не дать им накопиться в большом количестве под одним родителем. В следующем цикле сканирования подхватим пропущенную директорию, и вероятность не почистить её падает в геометрической прогрессии.

Транзакционность тут не теряется, как может показаться: локальное сканирование без транзакций просто собирает кандидатов, а реальное удаление пустых директорий выполняется на шедулере и уже с транзакциями. Это страхует от ситуации, когда между построением фильтра и удалением кто-то всё-таки положил в директорию объект.

Выводы

Обычная база данных в её привычной роли не справлялась с нашими фоновыми задачами — пришлось делать локальный скан, чтобы фильтровать кандидатов ближе к данным и не гонять снаружи проблемные range-запросы. Добавили статистику в реальном времени — последний снапшот плюс агрегированные пятиминутные интервалы изменений, — чтобы реально ограничивать размер бакета и давать пользователю актуальную картину. Научились фоном чистить пустые директории через предварительный сбор данных в фильтр Блума, потому что локально определить пустоту директории мы не можем. И упёрлись в ограничения Cassandra как очереди — перенесли очередь на Kafka, оставив на Cassandra основную онлайн-нагрузку.

И несколько общих наблюдений. Tombstone в Cassandra — это лимит, в который вы упрётесь, как только начнёте массово удалять в широких партициях. Очередь на Cassandra — это антипаттерн, и при этом в нашем случае именно она позволила быстро мигрировать на новую схему; иногда антипаттерн оправдан в моменте, важно понимать, когда его пора менять. Статистические структуры вроде фильтра Блума способны сильно сэкономить ресурсы, если для задачи можно пожертвовать абсолютной точностью. Схема партиционирования задаёт ограничения всей системе — у нас это очень хорошо видно по тому, сколько решений выше упирается именно в неё.