Как мы пришли к локальному скану, фильтру Блума и переезду очереди на 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 — это антипаттерн, и при этом в нашем случае именно она позволила быстро мигрировать на новую схему; иногда антипаттерн оправдан в моменте, важно понимать, когда его пора менять. Статистические структуры вроде фильтра Блума способны сильно сэкономить ресурсы, если для задачи можно пожертвовать абсолютной точностью. Схема партиционирования задаёт ограничения всей системе — у нас это очень хорошо видно по тому, сколько решений выше упирается именно в неё.