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 данные живут в трёх слоях:
- MemTable — in-memory структура (skip-list или vector), куда сначала идут все write’ы. Ограничена по размеру (
write_buffer_size, по умолчанию 64 MiB). - WAL (Write-Ahead Log) — append-only лог на диске. Каждая запись сначала идёт сюда (для durability), потом в MemTable.
- SST (Sorted String Table) — immutable файлы на диске, отсортированные по ключу. Когда MemTable заполнен — он flush’ится в новый SST файл.
Каждая Put операция: 1) append в WAL для durability, 2) insert в MemTable. Когда MemTable достигает write_buffer_size, он становится immutable, новый создаётся, старый запланирован на flush.
Главный insight: запись не идёт сразу на диск в финальное место. Она идёт в MemTable (быстро) и через какое-то время в SST. Это и есть «log-structured» часть — на диск пишется только последовательная аппенд-запись.
Flush: MemTable -> SST
Когда active MemTable заполняется, происходит flush:
- Active MemTable «замораживается» (становится immutable).
- Создаётся новый active MemTable, и продакшен продолжается без задержки.
- Background thread проходит по immutable MemTable, сортирует данные (если ещё не отсортированы) и пишет в новый SST файл уровня 0 (L0).
- Когда 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. И так далее.
Свежие данные внизу пирамиды (L0 = маленький, быстро меняется). Старые данные внизу пирамиды (L6 = большой, редко меняется). Compaction катит данные сверху вниз, объединяя.
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):
- Проверяет active MemTable. Если есть — возвращает.
- Проверяет immutable MemTable’ы (если идёт flush, их может быть несколько). Если есть — возвращает.
- Проверяет L0 SST’ы по убыванию timestamp (так как они могут пересекаться).
- Для каждого следующего level (L1, L2, …) — binary search в SST’ах этого уровня.
- Если 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’ы.