Мотивация
B-tree прекрасно работает на чтение, но каждая вставка потенциально вызывает split и случайную запись на диск. На HDD это катастрофа; на SSD -- истирание ячеек (write wear). Time-series и аналитические нагрузки пишут в тысячи раз больше, чем читают.
LSM tree (Log-Structured Merge tree, O'Neil et al., 1996) меняет правила:
- Все записи сначала идут в память (MemTable) и append-only в WAL.
- Когда MemTable заполняется, она дампится на диск как SSTable (Sorted String Table) -- отсортированный immutable файл.
- Старые SSTables периодически сливаются (compaction) в более крупные.
Результат: запись становится последовательной. Sequential writes на SSD быстрее random на 2-3 порядка, на HDD -- на 4+.
Схема работы
Clients writes
│
▼
┌─────────┐ ┌─────────────┐
│ MemTable│ ──flush→│ Level 0 SST │ immutable
│ (RAM, │ └──────┬──────┘
│ sorted)│ │ compact
│ │ ▼
└────┬────┘ ┌─────────────┐
│ │ Level 1 SST │
│ └──────┬──────┘
▼ │ compact
┌─────────┐ ▼
│ WAL │ ┌─────────────┐
│ (append)│ │ Level 2 SST │
└─────────┘ └─────────────┘
...
┌─────────────┐
│ Level N SST │ largest, oldest
└─────────────┘
MemTable
In-memory структура для буфера записей. Чаще всего -- skip list (LevelDB, RocksDB) или red-black tree. Поддерживает concurrent inserts и sorted iteration.
Write path:
- Запись в WAL (append, fsync при durability-требовании).
- Запись в active MemTable.
- Если MemTable достигла порога (~64 MB) -- становится immutable, создаётся новая.
- Фоновый flush пишет immutable MemTable на диск как SSTable.
SSTable (Sorted String Table)
Immutable файл из отсортированных пар (key, value). Структура:
SSTable layout (simplified):
+--------------------------+
| Data blocks (sorted KVs) | ← main content, ~4 KB each
+--------------------------+
| Filter block (Bloom) | ← skip SSTable if key absent
+--------------------------+
| Index block | ← key → offset of data block
+--------------------------+
| Footer (metadata offsets)|
+--------------------------+
- Data blocks: последовательные KV, сжатые (snappy, lz4, zstd).
- Index: sparse -- хранит первый ключ каждого блока, чтобы найти блок за O(log).
- Bloom filter: для каждого SSTable -- позволяет за O(1) сказать "ключа точно здесь нет".
- Footer: оффсеты index и filter блоков.
Потому что SSTable immutable, к ней можно безопасно делать concurrent reads без блокировок.
Compaction
Compaction -- процесс слияния SSTables. Решает три проблемы:
- Дубликаты ключей: запись K=1, затем K=2 в разных SSTable -- при compaction побеждает более новая.
- Tombstones: удаление -- это запись специального маркера "K удалён". При compaction реальная запись исчезает.
- Read amplification: чем меньше SSTable, тем быстрее lookup.
Без compaction read amplification растёт линейно со временем.
Стратегии compaction
| Стратегия | Как работает | Плюсы | Минусы | Использование |
|---|---|---|---|---|
| Size-tiered (STCS) | SSTables группируются по размеру; когда набирается K одинаковых -- сливаются в один большой | Низкая write amp | Высокая space amp (до 2×), read amp | Cassandra default (до 3.0) |
| Leveled (LCS) | Уровни L0, L1, ..., Lⁿ; размер Lⁿ = 10× Lⁿ⁻¹. Каждый уровень (кроме L0) содержит non-overlapping SSTables | Низкая read amp, низкая space amp | Высокая write amp | RocksDB, Cassandra modern, LevelDB |
| Time-window (TWCS) | SSTables группируются по временным окнам | Отлично для TTL/time-series | Только для time-series | InfluxDB, Cassandra для TS |
| Universal | Гибрид STCS + ограничение на число файлов | Баланс amp | Сложная конфигурация | RocksDB option |
Leveled compaction подробнее
L0: [SST][SST][SST][SST] ← overlapping, from recent flushes
│ compact to L1
▼
L1: [A-D][E-H][I-L][M-P] ← non-overlapping, sorted by range, ~10× L0 size
│ merge selected SST with overlapping from L2
▼
L2: [A-B][C-D][E-F]... ← ~10× L1 size
...
При compaction выбирается один SSTable с уровня Lᵢ и все overlapping с Lᵢ₊₁, сливаются. Это гарантирует, что lookup на любом уровне ≥ 1 = 1 файл.
Tombstones и GC
Удаление в LSM = запись tombstone. Реальные данные исчезнут только при compaction, которая пройдёт через обе записи (живую и tombstone). До этого lookup увидит tombstone и вернёт "не найдено".
Проблема TTL: если данные имеют TTL, tombstones накапливаются и могут занимать значительную часть диска. Cassandra даёт параметр gc_grace_seconds (default 10 дней) -- после него tombstone может быть физически удалён.
Проблема распределённых удалений: tombstone должна дожить, пока не реплицируется на все узлы. Иначе -- "voodoo resurrection" удалённых записей.
Bloom filter в LSM
При point lookup GET key:
- Проверить MemTable.
- Проверить immutable MemTable(ы).
- Для каждого SSTable (от нового к старому):
- Проверить Bloom filter → если "точно нет", skip.
- Иначе: binary search в index block, прочитать data block.
Bloom filter с FP rate = 1% сокращает ложные чтения SSTable в 100 раз. Для millions of SSTables это критично -- без Bloom LSM была бы непрактична для point lookups.
Амплификации
Это три главные метрики LSM, и они в конфликте.
- Write amplification (W) -- байт записано на диск / байт от пользователя.
- STCS: ~O(log n), обычно 10-30×.
- LCS: ~O(N × L) при L уровнях, обычно 20-50×.
- Read amplification (R) -- блоков прочитано / блоков запрошено.
- STCS: ~O(log n × files_per_level).
- LCS: ~O(L).
- Space amplification (S) -- диск занят / логический размер данных.
- STCS: до 2× из-за дубликатов до compaction.
- LCS: близко к 1× (максимум 1.11× при ratio 10).
Вывод: нельзя минимизировать все три одновременно. LCS жертвует write amp ради read/space. STCS -- обратное.
Read path в деталях
Чтение ключа K:
- Check MemTable → если есть, вернуть.
- Check immutable MemTables.
- Для каждого SSTable (от L0 -- новейшие -- к Lⁿ):
- Bloom filter? Если "нет" → skip.
- Index block: найти data block содержащий K.
- Прочитать + distress data block, найти K.
- Если нашли tombstone → "не найдено".
- Если нашли значение → вернуть (первое найденное -- самое свежее).
Для оптимизации RocksDB использует block cache (LRU блоков в RAM).
LSM vs B-tree
| Свойство | B+ tree | LSM tree |
|---|---|---|
| Write speed | Медленнее (random I/O) | Быстрее (sequential) |
| Read speed | Быстрее (1 путь) | Медленнее (много SSTables) |
| Range scan | Хороший | Хороший (все SSTable сортированы) |
| Write amp | ~1-3× | 10-50× |
| Read amp | ~h (3-5) | O(L × log) |
| Space amp | 1.1-1.3× | 1-2× |
| Workload | Balanced read/write | Write-heavy |
| Примеры | PostgreSQL, MySQL | Cassandra, RocksDB, LevelDB |
Кто использует LSM
| Продукт | Движок | Заметки |
|---|---|---|
| Cassandra | LSM + Bloom + Merkle | Родоначальник в NoSQL |
| ScyllaDB | LSM на C++, shard-per-core | Тот же интерфейс, что Cassandra |
| RocksDB | LSM, embed KV | Facebook, база для многих сервисов |
| LevelDB | LSM + skip list memtable | Google, прообраз RocksDB |
| HBase | LSM + HDFS | Hadoop ecosystem |
| BigTable | LSM | Оригинальная работа (2006) |
| InfluxDB | LSM (TSM engine) | Time-series |
| TiKV | RocksDB + Raft | TiDB, distributed KV |
| CockroachDB | Pebble (LSM на Go) | Inspired by RocksDB |
| Kafka | Не совсем LSM, но append-only log + sparse index | Похожий дизайн |
Минимальная структура SSTable в коде
<?php
declare(strict_types=1);
/**
* Simplified MemTable + SSTable flush for teaching.
* Real SSTable includes bloom filter, sparse index, block-level compression.
*/
final class MemTable
{
/** @var array<string, array{value: ?string, tombstone: bool}> */
private array $data = [];
public function put(string $key, string $value): void
{
$this->data[$key] = ['value' => $value, 'tombstone' => false];
}
public function delete(string $key): void
{
$this->data[$key] = ['value' => null, 'tombstone' => true];
}
public function flush(string $path): void
{
ksort($this->data);
$fp = fopen($path, 'wb');
if ($fp === false) {
throw new \RuntimeException("Cannot open $path");
}
foreach ($this->data as $key => $entry) {
$value = $entry['value'] ?? '';
$header = pack('VVC', strlen($key), strlen($value), $entry['tombstone'] ? 1 : 0);
fwrite($fp, $header);
fwrite($fp, $key);
fwrite($fp, $value);
}
fclose($fp);
}
}
- Read-heavy без write: B-tree проще и быстрее.
- Очень маленькие датасеты: оверхед compaction не оправдан.
- Строгие p99 latency требования: compaction может вызывать spike'и (решается pacing, но полностью не устраняется).
Выводы
- LSM tree превращает random writes в sequential через буферизацию в MemTable + flush в immutable SSTable.
- Compaction сливает SSTables, убирает дубликаты и tombstones; стратегии -- size-tiered, leveled, time-window.
- Bloom filter на каждой SSTable -- обязателен для нормального read latency.
- Три амплификации (write, read, space) в трёх-стороннем конфликте -- выбор стратегии = выбор трейд-оффа.
- LSM -- стандарт для write-heavy и time-series нагрузок: Cassandra, RocksDB, InfluxDB, HBase.
- Для balanced workload'а B+ tree часто проще и быстрее на чтение.