Learning Platform
Глоссарий Troubleshooting
Урок 06.03 · 28 мин
Продвинутый
RocksDBLSM-treeMemTableSSTCompactionWAL

EmbeddedRocksDBStateBackend использует RocksDB — embedded key-value store, который изначально написан в Facebook (форк от LevelDB) на C++. Это не «база данных» в обычном смысле — это библиотека, встраиваемая в процесс через JNI. У Flink JVM подгружает её через librocksdbjni.so, и все операции state идут через native calls.

Чтобы понимать, что происходит с твоим state в RocksDB-режиме — нужно понимать LSM tree. Это структура, оптимизированная для write-heavy workload, с фундаментально другой моделью чтения и записи, чем у B-tree (см. также модуль 02 в SQL Internals для сравнения).

State stores в Kafka Streams — тоже RocksDB под капотом fsync и durability: когда write на самом деле записан

Зачем LSM, а не B-tree

B-tree (как в Postgres) оптимизирован для read-after-write: данные сразу попадают в правильное место в индексе, чтение быстрое. Цена — каждая запись делает random IO для обновления страницы в нужном месте дерева.

LSM tree (RocksDB, Cassandra, HBase, LevelDB) идёт противоположным путём: все записи append-only, в порядке прихода. Random IO в индекс нет. Цена — чтение становится сложнее, потому что данные размазаны по нескольким файлам разных версий.

В streaming workload (Flink): записей >> чтений. Каждое событие — это state.update(...) (запись), state.value() (чтение) обычно делается перед update’ом. Соотношение read/write ~1:1, но read дешёвый, потому что часто bait’ится в кэш. LSM поэтому — естественный выбор.

Three-tier структура: MemTable, WAL, SST

В RocksDB данные живут в трёх слоях:

  1. MemTable — in-memory структура (skip-list или vector), куда сначала идут все write’ы. Ограничена по размеру (write_buffer_size, по умолчанию 64 MiB).
  2. WAL (Write-Ahead Log) — append-only лог на диске. Каждая запись сначала идёт сюда (для durability), потом в MemTable.
  3. SST (Sorted String Table) — immutable файлы на диске, отсортированные по ключу. Когда MemTable заполнен — он flush’ится в новый SST файл.
Запись в RocksDB: MemTable + WAL

Каждая Put операция: 1) append в WAL для durability, 2) insert в MemTable. Когда MemTable достигает write_buffer_size, он становится immutable, новый создаётся, старый запланирован на flush.

Put(key, value)вызов через JNI из Flink
1. WAL (write-ahead log)append к .log файлу на дискеDurability: если процесс упадёт, при restore RocksDB переиграет WAL и восстановит MemTable.
Можно отключить (DisableWAL)Flink делает это — checkpoint обеспечивает durabilityВ Flink workload отдельный WAL не нужен: durability обеспечивает Flink checkpoint, который сериализует ВЕСЬ state. WAL только бы дублировал работу. Flink отключает.
2. Active MemTable (in-memory)skip-list, отсортированная по key
Когда > write_buffer_size (64 MiB)MemTable становится immutable, создаётся новый active
Read(key)ищет в active MemTable -> в immutable MemTable -> в SST файлах от свежего к старому

Главный insight: запись не идёт сразу на диск в финальное место. Она идёт в MemTable (быстро) и через какое-то время в SST. Это и есть «log-structured» часть — на диск пишется только последовательная аппенд-запись.

Flush: MemTable -> SST

Когда active MemTable заполняется, происходит flush:

  1. Active MemTable «замораживается» (становится immutable).
  2. Создаётся новый active MemTable, и продакшен продолжается без задержки.
  3. Background thread проходит по immutable MemTable, сортирует данные (если ещё не отсортированы) и пишет в новый SST файл уровня 0 (L0).
  4. Когда flush завершён, immutable MemTable освобождается.

SST файлы — immutable. Их никто не модифицирует, только читает. Чтобы изменить значение для ключа — пишется новая запись (в новый SST), и алгоритмы compaction со временем «договорятся», какая запись актуальна.

Каждый SST файл содержит:

  • Отсортированные key-value пары.
  • Index block — индекс по ключам внутри файла (для бинарного поиска).
  • Bloom filter (опционально) — для быстрого «нет, такого ключа нет» ответа.

Compaction: иерархия уровней

Если бы Flink просто сбрасывал MemTable в L0 без больше ничего — на 1 GiB state накопились бы тысячи мелких SST, и чтение стало бы катастрофой (для каждого read нужно проверить каждый SST).

Решение — compaction: периодическое слияние SST’ов из разных уровней в один большой отсортированный файл.

RocksDB по умолчанию использует leveled compaction:

  • L0: 4-8 файлов, могут пересекаться по ключам (только что слитые из MemTable).
  • L1: один большой sorted run, ~10x от размера L0 трафика.
  • L2: ещё в 10 раз больше L1.
  • L3, L4, L5, L6: каждый в 10 раз больше предыдущего.

Compaction идёт background thread’ами: когда L0 переполнен, compaction trigger’ит слияние L0 -> L1. Когда L1 переполнен — L1 -> L2. И так далее.

LSM levels: leveled compaction

Свежие данные внизу пирамиды (L0 = маленький, быстро меняется). Старые данные внизу пирамиды (L6 = большой, редко меняется). Compaction катит данные сверху вниз, объединяя.

L0 (~4 files × 64 MiB = 256 MiB)freshly flushed, могут пересекаться, дороже всего читатьЕсли в L0 десять файлов, при point read'е нужно проверить каждый — это 10 disk seek'ов. Поэтому L0 жёстко ограничен (max ~12 файлов).
L1 (~256 MiB, один sorted run)результат compaction L0 -> L1, не пересекаются по ключу
L2 (~2.5 GiB)в 10x больше L1
L3 (~25 GiB)в 10x больше L2
L4, L5, L6растут на порядок каждый, дно пирамиды — много старого state

Read amplification: при point read RocksDB проверяет MemTable, immutable MemTable, и SST’ы каждого уровня. На worst case это O(L) seek’ов. Bloom filters снижают это до O(1) для большинства негативных запросов («такого ключа нет»).

Write amplification: каждая запись попадает не только в active MemTable, но потом через compaction может быть переписана несколько раз (L0 -> L1 -> L2 -> …). Для leveled compaction типичное WA = 10-30x.

Space amplification: устаревшие версии ключей живут в SST’ах до compaction. На бойком workload SA = 1.1-1.3x.

Это три кита LSM trade-offs, на которые крутят настройки тюнинга (см. урок 5 модуля).

Read path: как RocksDB ищет ключ

При Get(key):

  1. Проверяет active MemTable. Если есть — возвращает.
  2. Проверяет immutable MemTable’ы (если идёт flush, их может быть несколько). Если есть — возвращает.
  3. Проверяет L0 SST’ы по убыванию timestamp (так как они могут пересекаться).
  4. Для каждого следующего level (L1, L2, …) — binary search в SST’ах этого уровня.
  5. Если bloom filter говорит «нет» — пропускает SST без чтения.

Каждый SST хранит block-level index. Чтение SST = чтение index block (часто в кэше) + чтение data block (часто disk seek).

Block cache — RocksDB-внутренняя кэш для часто читаемых блоков. Размер задаётся через block_cache_size. В Flink это часть managed memory (см. урок 5 модуля 05).

Tombstones: как делается delete

Чтобы удалить ключ, RocksDB не идёт искать его на диске — это слишком дорого. Вместо этого записывается tombstone — специальная запись «этот ключ удалён».

При read tombstone’ы интерпретируются как «значение отсутствует». При compaction tombstone’ы и data в более низких уровнях с тем же ключом — удаляются вместе.

Tombstones — причина одного из самых неприятных RocksDB-issue: range tombstone hell. Если ты много раз удалял ключи в одном диапазоне, при range scan чтение тонет в tombstones, и performance падает в 10-100x. См. урок 5 для mitigation strategies.

Snapshot RocksDB: incremental

Когда Flink делает checkpoint, RocksDB предоставляет ему snapshot — immutable вид всех SST файлов на момент времени. Это очень дешёвая операция, потому что SST’ы immutable — просто фиксируется список «вот эти 234 SST файла — это твой snapshot».

Flink затем копирует только новые SST файлы в S3 (incremental checkpoint). Те, что уже были в предыдущем checkpoint’е, остаются. Это даёт огромную экономию: даже на 100 GiB state каждый checkpoint = только 100-500 MiB новых данных.

В уроке 06 модуля (Checkpoint internals) разберём incremental checkpoint детально. Здесь главное: incremental — это родная capability LSM, и она недоступна HashMap backend’у.

WAL: что про него знать

RocksDB по умолчанию пишет каждый Put в WAL до того, как обновить MemTable. Это для durability — если процесс упадёт, WAL replay восстановит MemTable.

Flink отключает WAL. Зачем? Потому что Flink даёт собственную durability через checkpoint:

  • Между checkpoint’ами state мог измениться, но в случае failover Flink восстановит state из последнего успешного checkpoint’а и переиграет все события после него из source (Kafka offset back).
  • RocksDB WAL только дублировал бы эту работу.
  • Отключение WAL даёт ~30% ускорения по write throughput.

В коде: WriteOptions().setDisableWAL(true).

Чтение source

  • flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBKeyedStateBackend.java — точка входа.
  • flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBOperationUtils.java — детали создания RocksDB instance.
  • Внешнее: документация RocksDB Wiki — https://github.com/facebook/rocksdb/wiki/Leveled-Compaction, RocksDB-Tuning-Guide. Это must-read.

Чек-лист

  • RocksDB = embedded LSM-tree (log-structured merge), Facebook fork of LevelDB.
  • Запись: WAL -> MemTable. Flink отключает WAL — checkpoint обеспечивает durability.
  • Flush: full MemTable -> новый SST в L0 (background thread, non-blocking).
  • Compaction: периодическое слияние SST’ов сверху вниз по уровням. Leveled compaction — каждый level в 10x больше предыдущего.
  • Read: MemTable -> immutable MemTables -> SST’ы каждого level по очереди. Bloom filters снижают cost негативных запросов.
  • Write amplification: ~10-30x на leveled. Read amplification: O(L) в worst case.
  • Tombstones — записи «ключ удалён». Range tombstone hell — известный pain point.
  • Snapshot — список immutable SST’ов, дешёвая операция. Incremental checkpoint — копировать только новые SST’ы.
Проверка знанийKnowledge check
У RocksDB SST на L0 = 60 MiB, L1 = 600 MiB, L2 = 6 GiB. Job делает write 100 MiB/sec. Сколько IO write throughput RocksDB производит на диск в установившемся режиме, учитывая write amplification от compaction?
ОтветAnswer
При leveled compaction write amplification = sum amplification на каждом уровне. Между уровнями коэффициент 10, и при compaction L_n -> L_{n+1} надо переписать L_n + перекрытие с L_{n+1}. В среднем для leveled WA = ~T (target multiplier, 10) на каждый level. Если у тебя L0 -> L1 -> L2, то WA = 1 (MemTable -> L0) + 10 (L0 -> L1, с учётом merge) + 10 (L1 -> L2) = ~21x. На write 100 MiB/sec логических данных, на диск идёт ~21 × 100 = 2.1 GiB/sec. Это объясняет почему disk IO для RocksDB-heavy job столь высокий. Mitigation: универсальный compaction вместо leveled даёт WA ~5-10x ценой большего space amplification. Также — увеличить max_bytes_for_level_base (размер L1) — будет меньше уровней, меньше WA.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Зачем RocksDB использует LSM-tree вместо B-tree (как Postgres), если данные всё равно лежат на диске?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5