Compaction, tombstones и repair в ScyllaDB: жизненный цикл данных под капотом

Как TWCS группирует sstable по времени записи, почему исторический backfill без USING TIMESTAMP ломает окна компакции, как реально считаются tombstones и почему repair на tablet-keyspace — не nodetool repair -pr

Запись в ScyllaDB — это всегда добавление, а не изменение на месте: memtable сбрасывается в неизменяемый sstable, UPDATE дописывает новую версию строки, DELETE не стирает данные физически, а дописывает надгробие — tombstone, которое должно пережить все реплики, прежде чем компакция получит право выбросить его насовсем. Три механизма держат этот цикл живым: compaction решает, какие sstable сливать и когда, tombstones + gc_grace решают, когда удалённые данные можно безопасно забыть, а repair чинит расхождения между репликами, которые неизбежно накапливаются между собой. Ниже — не общая теория LSM (она уже разобрана отдельно), а то, что оказалось специфично для ScyllaDB на живом кластере: один подводный камень времени записи, один — арифметики QUORUM, и один — новой архитектуры tablets поверх старого nodetool.

Это третья статья серии «ScyllaDB: глубокое погружение», продолжение статьи о моделировании данных и статьи об архитектуре shard-per-core. Дальше в серии — consistency levels и LWT, от token ring к tablets и multi-DC, shard-aware драйверы на Go и Java и эксплуатация и итоговый выбор.

Числа ниже — с того же живого трёхузлового кластера scylladb/scylla:2026.2.0, что и в первых двух статьях. Стенд — в публичном репозитории примеров (digital-cookbook, каталог scylladb/compaction + scylladb/ops/repair-demo.sh).

Compaction, tombstones и repair в ScyllaDB: TWCS группирует по времени записи, tombstone-счёт QUORUM 48×2, repair через nodetool cluster repair на tablet-keyspace

В статье

Стратегии compaction: не одна на все случаи

Сам механизм LSM-дерева — запись только последовательным добавлением, sstable как неизменяемый артефакт, компакция как фоновое слияние — общий для всего семейства таких хранилищ и подробно разобран в статье про LSM-tree и B-treeСкоро. Здесь важнее другое: у ScyllaDB (как и у Cassandra, чей движок она унаследовала архитектурно) есть выбор из нескольких стратегий компакции на уровне таблицы, и выбор не косметический — он определяет профиль записи, чтения и всплесков места на диске.

STCS (SizeTieredCompactionStrategy) — классика по умолчанию: sstable группируются по похожему размеру и сливаются, когда таких sstable набирается достаточно. Простая и предсказуемая на запись, но с обратной стороной — компакция крупных sstable требует временно держать на диске и старую, и новую копию данных (всплеск занятого места), а чтение партиции, разбросанной по многим несмёрженным sstable, читает больше файлов, чем хотелось бы.

LCS (LeveledCompactionStrategy) — sstable организованы по уровням с ограниченным размером каждого; компакция чаще, но мельче, зато чтение почти всегда попадает в ограниченное число sstable на уровень. Годится там, где чтение важнее записи и данные часто перезаписываются одним и тем же ключом — цена этого выбора выше суммарный I/O на компакцию.

ICS (IncrementalCompactionStrategy) — доработка STCS, специфичная для ScyllaDB: тот же паттерн слияния по размеру, но результат компакции хранится не одним монолитным sstable, а набором иммутабельных фрагментов («run»), что убирает характерный для STCS всплеск свободного места при слиянии больших sstable. Рекомендуемая ScyllaDB стратегия по умолчанию для нагрузок общего профиля без TWCS-специфики.

TWCS (TimeWindowCompactionStrategy) — стратегия для time-series-нагрузок и главная тема этой статьи: sstable группируются не по размеру, а по временным окнам фиксированной длины, и sstable из одного окна никогда не сливаются с sstable из другого — только внутри окна. Как только окно и всё, что в нём лежит, истекает по TTL или gc_grace, оно может целиком уйти с диска одним удалением файлов, без единой операции слияния. Именно этот профиль подходит датасету телеметрии этого стенда ((device_id, day), 96 замеров в сутки) — и именно на нём обнаружилась находка, ради которой стоит читать дальше.

TWCS и подводный камень write-timestamp

Формулировка «TWCS группирует sstable по времени» звучит однозначно, но скрывает развилку: по времени чего? По значению столбца с временной меткой данных (в этом датасете — event_time) или по времени, когда сервер физически принял мутацию? Первый прогон стенда, ещё без исправления, дал однозначный ответ на живом кластере: TWCS группирует sstable по WRITE-timestamp мутации — тому же самому значению, что возвращает WRITETIME(column) в CQL и что попадает в поле max_timestamp вывода scylla sstable dump-statistics, — а не по значению какого-либо столбца данных.

Датасет — 14 суток симулированной телеметрии (500 устройств × 96 замеров/сутки, event_time размазан по 2026-07-012026-07-14). Первая загрузка использовала обычный INSERT без явного USING TIMESTAMP — вся историческая телеметрия физически записалась в кластер за секунды реального времени, и TWCS честно сделал то, что ему сказали: положил все 14 суток данных в одно и то же окно, текущее на момент загрузки, полностью проигнорировав то, что event_time внутри строк размазан на две недели. Байтовое подтверждение — scylla sstable dump-statistics по раннему sstable:

min_timestamp: 1783727192443968
max_timestamp: 1783727231918502

Разница max_timestamp - min_timestamp — около 39,5 секунды: это реальное время загрузки батча, а не 14 суток event_time, которые в этих данных якобы должны были лечь в 14 разных окон.

Фикс — тривиальный по формулировке и принципиальный по эффекту: INSERT ... USING TIMESTAMP ? с явным значением event_time.UnixMicro(). Тот же приём, которым реальные бэкафилы исторических time-series данных заставляют TWCS группировать sstable по историческому времени события, а не по времени миграции — без него любой перенос архива в TWCS-таблицу молча схлопывает всю историю в одно окно. После фикса живой прогон (500 устройств × 96 замеров/сутки, flush после каждых суток, REST live_ss_table_count на всех трёх узлах, воспроизведено дважды подряд с идентичным результатом):

день  0 (2026-07-01): 0 -> 32   (delta=32)
день  1 (2026-07-02): 32 -> 64  (delta=32)
...
день 13 (2026-07-14): 416 -> 448 (delta=32)

Итог: окон с новым sstable — 14/14, новых sstable — 448 всего
(avg/окно = 32.00 РОВНО, без единого отклонения)

14 суток дали 14 из 14 окон, и каждое окно — ровно 32 новых sstable, стабильно и без единого отклонения при повторном прогоне; финальный счётчик — live_ss_table_count = 448. Число 32 стоит зафиксировать честно как наблюдаемый факт (стабильно воспроизводимый дважды подряд байт-в-байт), а не подкреплять его причиной — механизм, откуда взялась именно эта цифра (а не 1 или иное значение), в рамках стенда не диагностирован, и додумывать его не стоит.

Байтовое подтверждение того, что окна действительно не смешиваются — не только по числу файлов, но и по содержимому — тот же dump-statistics, теперь на первом и последнем по времени sstable:

первый sstable: min_timestamp 1782864000000000, max_timestamp 1782949500000000
  → 2026-07-01 00:00:00 UTC .. 2026-07-01 23:45:00 UTC   (ровно сутки 0)

последний sstable: min_timestamp 1783987200000000, max_timestamp 1784072700000000
  → 2026-07-14 00:00:00 UTC .. 2026-07-14 23:45:00 UTC   (ровно сутки 13)

Ни один проверенный sstable не содержит данные двух разных суток — именно это TWCS обещает архитектурно, и именно это подтверждено байтами, а не догадкой по числу файлов.

flowchart TB subgraph BEFORE["До фикса: INSERT без USING TIMESTAMP"] direction TB B0["14 суток event_time
2026-07-01 .. 2026-07-14"] B1["write-timestamp = момент INSERT
(секунды одного прогона)"] BW["Одно окно TWCS
Δ(max−min) ≈ 39.5s"] B0 -.->|"event_time проигнорирован TWCS"| BW B1 --> BW end subgraph AFTER["После фикса: INSERT ... USING TIMESTAMP event_time.UnixMicro()"] direction TB A0["14 суток event_time"] AW0["окно: сутки 0
32 новых sstable"] AW1["окно: сутки 1
32 новых sstable"] AWn["... окно: сутки 13
32 новых sstable"] A0 --> AW0 A0 --> AW1 A0 --> AWn end style BEFORE fill:#f4c6c6,stroke:#b4552f style AFTER fill:#c9e4c5,stroke:#5b8a5e

flowchart TB
  subgraph BEFORE["До фикса: INSERT без USING TIMESTAMP"]
    direction TB
    B0["14 суток event_time
2026-07-01 .. 2026-07-14"] B1["write-timestamp = момент INSERT
(секунды одного прогона)"] BW["Одно окно TWCS
Δ(max−min) ≈ 39.5s"] B0 -.->|"event_time проигнорирован TWCS"| BW B1 --> BW end subgraph AFTER["После фикса: INSERT ... USING TIMESTAMP event_time.UnixMicro()"] direction TB A0["14 суток event_time"] AW0["окно: сутки 0
32 новых sstable"] AW1["окно: сутки 1
32 новых sstable"] AWn["... окно: сутки 13
32 новых sstable"] A0 --> AW0 A0 --> AW1 A0 --> AWn end style BEFORE fill:#f4c6c6,stroke:#b4552f style AFTER fill:#c9e4c5,stroke:#5b8a5e
TWCS группирует sstable по write-timestamp мутации, не по значению event_time

Tombstones и gc_grace: считать честно

DELETE в ScyllaDB не убирает строку с диска — он дописывает tombstone, специальную запись «эта строка/ячейка удалена», которая должна дожить до тех пор, пока repair не разнесёт факт удаления по всем репликам, иначе восстановленная из отставшей реплики строка «воскреснет» после удаления. Окно этой отсрочки — gc_grace_seconds, снятый живьём из system_schema.tables для таблицы стенда: 864000 секунд, ровно 10,0 суток. До истечения этого срока compaction обязана хранить tombstone, а не выбрасывать его вместе со сжатыми sstable.

Массовый DELETE по clustering-диапазону (12-часовой интервал одних суток) прошёлся по всем 500 партициям датасета; на выборке из 20 проверенных партиций число строк упало ровно с 96 до 48 на каждой — половина суток вычищена, вторая половина осталась нетронутой.

Дальше — то место, где живое измерение разошлось с наивным ожиданием. Трассированное чтение того же удалённого диапазона (gocql.Tracersystem_traces.events) вернуло tombstones_scanned = 96 — вдвое больше, чем реально удалённых строк (48). Разгадка — не «96 разных tombstone», а consistency level: чтение выполняется на QUORUM при RF=3, значит координатор опрашивает 2 из 3 реплик, и каждая опрошенная реплика независимо пишет свою строку Page stats в system_traces.events. Живой прогон это подтверждает напрямую по полю TraceEntry.Source — два разных адреса, 172.22.0.2 и 172.22.0.3, по 48 dead-ячеек на каждый:

[source=172.22.0.2] Page stats: ... 96 clustering row(s) (48 live, 48 dead), ...
[source=172.22.0.3] Page stats: ... 96 clustering row(s) (48 live, 48 dead), ...

tombstones_scanned (сумма dead по ВСЕМ опрошенным репликам QUORUM) = 96

Стоит явно закрыть альтернативное объяснение, которое напрашивается первым: это не спекулятивное чтение (speculative execution). Используемый форк драйвера по умолчанию использует NonSpeculativeExecution (ноль дополнительных попыток), и стенд нигде не включает политику спекулятивных запросов — удвоение целиком объясняется тем, что QUORUM физически опрашивает две реплики, и обе честно репортуют своё собственное состояние. Реальное число tombstone-ячеек на диапазон — 48, столько же, сколько живых строк осталось после удаления половины окна; 96 — это 48 dead × 2 опрошенные реплики, не число различных надгробий.

Отдельная честная находка касается не арифметики, а наблюдаемости: ни nodetool tablestats ... tombstone (счётчики «Average/Maximum tombstones per slice»), ни REST-гистограмма tombstone_scanned_histogram не сдвинулись с 0 ни разу за все проверки — при том что 48 dead-строк на чтение подтверждены многократно. Рабочим, воспроизводимым источником числа tombstones оказался не штатный счётчик метрик, а трейс запроса — строка Page stats: ... (X live, Y dead) из system_traces.events, доступная и через cqlsh (TRACING ON), и программно через gocql.NewTracer. Для диагностики накопления tombstones на живом кластере это стоит держать в голове раньше, чем упереться в молчащие счётчики nodetool.

Repair на tablet-keyspace: не nodetool repair -pr

Классическая команда синхронизации реплик, знакомая по любому руководству по Cassandra-совместимым СУБД, — nodetool repair -pr <keyspace>. На этом кластере она не работает вовсе, и не из-за опечатки, а по архитектурной причине: keyspace telemetry создан с tablets = {'enabled': true} — дефолтом для новых keyspace в 2026.2.0, — а классический -pr/--partitioner-range написан для vnode-репликации и с tablets несовместим. Живая ошибка честно называет причину и рабочую замену:

$ nodetool repair -pr telemetry
error processing arguments: nodetool repair repairs only vnode keyspaces!
To repair tablet keyspaces use nodetool cluster repair.

Рабочая команда — nodetool cluster repair --keyspace telemetry: она обходит все таблицы keyspace одним вызовом с одного узла и не нуждается в -pr — этого одного прогона достаточно, чтобы синхронизировать все tablets всего кластера, а не только диапазон одного узла. Про то, как устроены сами tablets и чем они отличаются от классического token ring, — отдельная тема статьи про tablets и multi-DC.

Живой прогон демонстрационного сценария (ops/repair-demo.sh, воспроизведено дважды подряд): один узел останавливается, во время простоя в кластер пишутся данные напрямую (кворум всё ещё достижим двумя оставшимися репликами), узел поднимается обратно, и сразу — с минимальной задержкой — выборочные партиции проверяются напрямую на этом узле с CONSISTENCY ONE (только локальная реплика, без координации с соседями). Один из двух прогонов поймал реальное расхождение до того, как hinted handoff успел его закрыть:

ДО repair (CONSISTENCY ONE на восстановленном узле):
  dev-repair-demo-0001-00000: 20
  dev-repair-demo-0001-00250: 20
  dev-repair-demo-0001-00499: 0      <- расхождение: hint ещё не долетел

nodetool cluster repair --keyspace telemetry: 4 таблицы, свой task_id на каждую

ПОСЛЕ repair (CONSISTENCY ONE на том же узле):
  dev-repair-demo-0001-00000: 20
  dev-repair-demo-0001-00250: 20
  dev-repair-demo-0001-00499: 20     <- repair синхронизировал

Партиция dev-repair-demo-0001-00499 до repair видела на восстановленном узле 0 строк из 20 — расхождение реальное, не гипотетическое; после nodetool cluster repair --keyspace telemetry та же партиция на том же узле отдаёт все 20 из 20. Кластер в обоих прогонах вернулся к штатному 3×UN. Второй прогон того же сценария расхождения не поймал — hinted handoff успел доставить данные раньше первой проверки, и это тоже честный, ожидаемый результат: repair как процедура anti-entropy идемпотентен, «нечего чинить» — валидный и быстро завершающийся итог, а не признак того, что сценарий не сработал.

Три находки этой статьи об одном: инструменты и умолчания, унаследованные ScyllaDB от классической Cassandra-экосистемы (семантика USING TIMESTAMP, арифметика QUORUM в трейсах, синтаксис nodetool repair), на практике ведут себя не совсем так, как ожидает опыт работы с vnode-кластерами — везде, где это разошлось, разница задокументирована здесь по живым байтам, а не по документации. Дальше в серии — что QUORUM даёт и чего стоит на операциях записи и compare-and-swap, та же арифметика двух реплик из трейса выше, но уже применительно к LWT.

Версии на стенде: образ scylladb/scylla:2026.2.0, CQL/release_version3.0.8, датасет — та же телеметрия из первой статьи серии (672 000 строк, -seed 42).

Обсуждение в Telegram

Присоединиться →

Комментарии