Skip to content

Split View: LSM-Tree 완벽 가이드 — RocksDB, LevelDB, Compaction, Bloom Filter, Write Amplification 모든 것 (2025)

|

LSM-Tree 완벽 가이드 — RocksDB, LevelDB, Compaction, Bloom Filter, Write Amplification 모든 것 (2025)

들어가며 — 왜 또 다른 저장 엔진인가

앞 글에서 PostgreSQL의 내부를 파헤치면서 B-tree의 우아함을 봤다. 수십 년 동안 DB를 지배한 자료구조다. 그런데 2010년대 들어 또 다른 계열이 조용히 세상을 장악하기 시작했다. Cassandra, HBase, LevelDB, RocksDB, ScyllaDB, InfluxDB, TiKV, CockroachDB, FoundationDB, ClickHouse의 MergeTree. 전부 LSM-Tree (Log-Structured Merge Tree) 또는 그 변종 위에서 돌아간다.

왜 LSM인가? B-tree가 완벽했다면 이 질문은 없었을 것이다. B-tree의 약점은 명확하다 — 랜덤 쓰기. 매 업데이트가 페이지 하나를 찾고, 수정하고, WAL을 쓰고, 결국 디스크의 임의 위치에 fsync한다. HDD 시대에는 치명적이었고, SSD 시대에도 write amplification과 wear leveling 문제가 남는다.

LSM의 답: 디스크에는 순차적으로만 쓴다. 기존 데이터는 건드리지 않는다. 업데이트는 새 레코드 추가로 표현한다. 삭제도 tombstone 추가로 표현한다. 시간이 지나면 백그라운드에서 조용히 병합(compaction)한다.

이 간단한 아이디어가 쓰기 처리량을 10–100배 끌어올리고, 현대 NoSQL의 기반이 됐다. 이 글은 LSM의 모든 것을 1,500줄에 걸쳐 다룬다. 1996년 O'Neil의 원래 논문부터 RocksDB의 최신 compaction 전략, Bloom filter, RUM 추측, 실제 시스템(Cassandra, ScyllaDB, TiKV)에서의 응용, 그리고 B-tree와의 공존 전략까지.

이 글은 PostgreSQL 내부 구조 글의 자연스러운 대척점이다. 같은 "키-값 저장"이라는 문제를 두 세계가 얼마나 다르게 푸는지 느껴보자.


1. LSM의 기원 — 1996년의 천재적 통찰

1.1 O'Neil 논문

Patrick O'Neil, Edward Cheng, Dieter Gawlick, Elizabeth O'Neil이 1996년 "The Log-Structured Merge-Tree (LSM-Tree)" 논문에서 제안했다. 당시 문제 의식:

  • B-tree는 매 업데이트가 랜덤 I/O.
  • HDD의 시크 타임은 10ms 수준 → 초당 100번이 한계.
  • TPC-B 같은 벤치마크가 거대해지면서 B-tree로는 따라갈 수 없음.

논문의 핵심 아이디어: 쓰기를 메모리에 모았다가 큰 덩어리로 순차 디스크에 뿌린다. 디스크의 순차 I/O는 랜덤 I/O보다 100–1000배 빠르다. 메모리에 모으는 동안 이미 디스크에 있는 데이터와 병합해야 하는데, 이것도 순차 merge로 풀 수 있다.

당시엔 학술적 호기심이었다. 실제 응용은 2003년 Google의 Bigtable 논문에서 등장. 그 이후 LevelDB (Google의 오픈소스), RocksDB (Facebook의 LevelDB 포크), Cassandra, HBase로 이어졌다.

1.2 근본 공식: Read-Update-Memory 추측 (RUM)

LSM을 이해하려면 먼저 RUM conjecture를 알아야 한다. Athanassoulis et al. (2016) 논문.

모든 저장 엔진은 세 가지를 최적화하려 한다:

  • R (Read amplification): 한 논리적 읽기가 실제 몇 번의 디스크 읽기를 유발하는가.
  • U (Update amplification = Write amplification): 한 논리적 쓰기가 실제 몇 바이트를 디스크에 쓰는가.
  • M (Memory amplification = Space amplification): 논리적 데이터 크기 대비 디스크 사용량.

RUM conjecture: 이 셋을 동시에 최소화할 수 없다. 하나를 줄이면 다른 하나가 늘어난다.

  • B-tree: R 우수 (log N), U 나쁨 (랜덤 I/O), M 보통 (fill factor 70%).
  • LSM: R 나쁨 (여러 레벨 조회), U 좋음 (순차 쓰기), M 상황에 따라.
  • Hash table: R 최고 (O(1)), U 나쁨 (rehashing), M 나쁨.

엔진의 설계는 이 trade-off 중 어디에 앉을지 선택하는 일이다.


2. LSM의 구조 — 3층 구조의 단순함

2.1 기본 구성

+----------+
| Memtable |  <- in-memory, sorted (skiplist)
+----------+
     |
     | flush
     v
+------------+------------+------------+
| SSTable L0 | SSTable L0 | SSTable L0 |  <- 디스크, 여러 파일
+------------+------------+------------+
     |
     | compaction
     v
+----------+----------+----------+
| SST L1   | SST L1   | SST L1   |
+----------+----------+----------+
     |
     v
+-----+-----+-----+
| L2  | L2  | L2  | ...
+-----+-----+-----+
  • Memtable: 메모리 내 정렬 자료구조. 보통 skiplist (RocksDB, LevelDB) 또는 B+tree.
  • WAL (Write-Ahead Log): 메모리 내 memtable의 유실을 막기 위한 디스크 로그.
  • SSTable (Sorted String Table): 불변(immutable) 정렬 파일. 한 번 쓰면 수정되지 않음.
  • Compaction: 낮은 레벨의 SSTable을 모아 높은 레벨로 병합.

2.2 쓰기 경로 (Write Path)

PUT(key, value)
  1. WAL (key, value) append + fsync (옵션)
  2. Memtable에 insert
  3. Memtable이 임계치 넘으면:
       -Memtable 할당 (쓰기 계속 가능)
       - 기존 Memtable을 immutable로 마킹 (= memtable flush 대상)
       - 백그라운드 thread가 disk로 flush → 새 L0 SSTable 생성
       - WAL 중 이 memtable에 해당하는 부분 폐기

핵심: 쓰기는 메모리에만 간다. 디스크 쓰기는 WAL append (순차) + 나중의 SSTable flush (순차) — 모두 순차 I/O. 그래서 빠르다.

2.3 WAL — 익숙한 이름, 같은 역할

Postgres의 WAL과 개념은 동일하다. 크래시 시 memtable을 재구성하기 위해 쓴다. 차이:

  • LSM의 WAL은 보통 memtable flush 후 즉시 폐기됨 (Postgres는 checkpoint까지 유지).
  • SSTable 자체가 "과거 상태"를 보존하므로 WAL은 memtable만 보호.

fsync 정책은 tunable:

  • sync=true: 매 커밋마다 fsync. Postgres와 같은 durability.
  • sync=false: buffered write. 빠르지만 OS 크래시 시 유실 가능.
  • group commit: 여러 커밋을 묶어 한 번 fsync.

RocksDB의 WriteOptions.sync가 이 값.

2.4 SSTable — 불변의 미덕

SSTable 파일 하나의 구조 (LevelDB/RocksDB 형식):

+-----------------+
| Data Block 1    |  key-value pairs (sorted)
+-----------------+
| Data Block 2    |
+-----------------+
| ...             |
+-----------------+
| Meta Block      |  Bloom filter, stats
+-----------------+
| Meta Index      |
+-----------------+
| Data Index      |Data Block의 첫 key
+-----------------+
| Footer          |  fixed size, meta index 위치 등
+-----------------+
  • Data Block: 4KB 정도. 블록 내에서 key는 prefix compression (restart interval).
  • Index: 각 블록의 첫 key만 저장 → 이진 탐색으로 블록 찾기.
  • Bloom filter: 블록별 또는 파일 전체. "이 key가 있나?" 를 빠르게 no로 답함.

SSTable이 불변이라는 점이 결정적이다. 읽는 동안 아무도 쓰지 않는다 → lock 없이 안전한 읽기. 백업도 단순 — 파일 복사만 하면 됨. 캐싱도 효율적 — 한번 읽은 블록은 불변.

2.5 Memtable — 왜 Skiplist인가

RocksDB의 기본 memtable은 skiplist. 왜 B+tree나 red-black tree가 아닌가?

  1. Lock-free 동시성: skiplist는 CAS로 lock-free 삽입이 쉬움. 고동시성에 유리.
  2. 단순한 구현: B-tree의 rebalancing보다 코드가 짧고 버그가 적음.
  3. 범위 스캔 친화적: leaf level이 이미 linked list.

단점도 있다. 랜덤 접근 시 캐시 효율이 B+tree보다 낮다. 그래서 RocksDB는 여러 memtable 구현을 제공한다:

  • skip_list: 기본.
  • hash_skip_list: point lookup이 압도적으로 많으면.
  • vector: prefix scan 위주면.

3. 읽기 경로 (Read Path) — LSM의 아킬레스건

3.1 여러 곳을 뒤져야 한다

GET(key)
  1. Active memtable 검사
  2. Immutable memtables 검사 (flush 중인 것들)
  3. L0 SSTables 검사 — 모두! (overlapping key range)
  4. L1, L2, ... 검사 — 각 레벨당 하나의 SSTable만 (non-overlapping)

최악의 경우 L0가 5개, L1부터 L6까지 레벨이 6개면 검사할 곳이 12곳. 각 곳은 메모리 아니면 디스크 접근. B-tree가 O(log N) 페이지만 읽는 것과 비교하면 끔찍하다.

3.2 Bloom Filter — 없는 것을 빠르게 판별

Bloom filter는 확률적 자료구조로 "없음을 확실히, 있음을 확률적으로" 판단한다.

- k개의 해시 함수
- m 비트 배열
- 삽입: k개 해시값 위치에 1
- 질의: k개 위치 모두 1이면 "있음 (확률적)", 아니면 "없음 (확실)"

False positive 확률은 P = (1 - e^(-kn/m))^k. 적절히 설정하면 1–2%. SSTable마다 1KB 정도의 bloom filter로 대부분의 "없음"을 즉시 판별.

RocksDB 설정:

# 보통 10 bits per key → FPR ~1%
bloom_bits = 10

10억 key 테이블에 12.5GB. 메모리 비용이지만 아낄 가치가 있다.

3.3 Block Cache

자주 읽히는 블록은 메모리에 캐시. RocksDB의 block_cache (기본 8MB, 프로덕션에서는 수 GB).

block_cache_size = 16GB
cache_index_and_filter_blocks = true  # 인덱스도 캐시
pin_l0_filter_and_index_blocks_in_cache = true  # L0는 항상 핫

OS 페이지 캐시와 별도로 관리. 블록이 압축되어 있어도 캐시에는 압축 해제 후 저장.

3.4 Index와 Partition Index

인덱스 자체가 커지면 문제다. 100GB DB에 인덱스가 1GB면 메모리에 다 못 올린다.

해결: partitioned index (RocksDB). 인덱스를 다시 인덱싱. 2단 인덱스 구조:

  • Top-level index: "어느 index block을 볼지"
  • Index block: 실제 key → data block offset

수GB 인덱스를 수MB의 top-level만 메모리에 유지.

3.5 Point Lookup vs Range Scan

Bloom filter는 point lookup에만 쓸모 있다. range scan(WHERE id BETWEEN 100 AND 1000)은 bloom filter가 도움 안 됨 — 범위 내 모든 key를 다 봐야 함.

그래서 LSM은 range-heavy 워크로드에서 B-tree 대비 약하다. TSDB (InfluxDB) 같은 시계열은 "최근 데이터"에 집중된 query 패턴으로 이 약점을 부분 상쇄.


4. Compaction — LSM의 심장이자 악몽

4.1 왜 Compaction이 필요한가

Compaction 없이는:

  1. Read amplification 폭발: L0가 무한 성장 → 모든 읽기가 엄청 느려짐.
  2. Space amplification: 삭제된 key나 오래된 버전이 영원히 남음.
  3. 파일 수 폭발: file descriptor 고갈.

Compaction은 여러 SSTable을 모아 새 SSTable 하나 또는 여러 개로 병합한다. 그 과정에서:

  • 중복된 key는 최신 것만 남김.
  • tombstone이 시간 경과 후 실제 삭제.
  • 같은 레벨 내 overlapping을 해소.

4.2 Size-Tiered Compaction (STCS) — Cassandra의 기본

같은 크기 등급의 SSTable이 N개 (기본 4) 쌓이면 하나로 합침.

처음:  [10MB] [10MB] [10MB] [10MB]
합침:  [40MB]
나중:  [40MB] [40MB] [40MB] [40MB]
합침:  [160MB]
...

장점: Write amplification이 낮다. 각 key가 몇 번만 rewrite됨.

단점: Space amplification이 크다 — 최악의 경우 삭제된 데이터를 포함한 거대한 SSTable이 오래 남음. 일시적으로 디스크 사용량이 2배가 될 수 있다. (compaction 중에는 입력과 출력이 동시 존재.)

4.3 Leveled Compaction (LCS) — LevelDB/RocksDB의 기본

레벨별 크기 제한. 각 레벨은 전체적으로 key range가 겹치지 않음 (L0 제외).

L0: [a-k] [f-p] [l-z]   (overlapping 가능)
L1: 10MB 한도 — [a-c] [c-g] [g-m] ...   (non-overlapping)
L2: 100MB 한도
L3: 1GB 한도
L4: 10GB 한도

L_n이 한도 초과 → 그중 하나를 골라 L_(n+1)의 overlap되는 SSTable들과 병합.

장점: Space amplification이 작다 — 각 레벨의 삭제된 key는 다음 compaction에서 제거. 일반적으로 데이터의 1.1배 디스크 사용.

단점: Write amplification이 크다 — 같은 key가 레벨마다 rewrite. 최악 10–30배.

4.4 Universal Compaction (UCS) — RocksDB 전용

STCS와 비슷하지만 더 정교. 모든 SSTable을 L0에 두고, 크기 조건 만족 시 병합.

Write-heavy + read-light 워크로드(캐싱 레이어, 로그 수집)에 적합. Space amplification이 2–3배 가능.

4.5 FIFO Compaction

Time-series 전용. 오래된 SSTable을 그냥 삭제. compaction 없음.

로그/메트릭 같이 "오래되면 의미 없는" 데이터에 완벽. RocksDB의 TTL 기능과 결합.

4.6 Tombstone과 Compaction의 까다로운 춤

Tombstone: 삭제를 나타내는 특수 마커. 실제 삭제는 compaction 때 일어남.

문제: tombstone은 "자신보다 아래 레벨에 있는 key를 가리는" 역할을 하므로, 모든 하위 레벨의 해당 key가 없어질 때까지 tombstone도 남아야 한다. 그렇지 않으면 delete된 key가 "부활"한다.

Cassandra에서 유명한 zombie rows 문제: tombstone이 gc_grace_seconds 지나서 GC됐는데, 해당 key의 오래된 버전을 가진 노드가 뒤늦게 동기화되면 삭제된 데이터가 되살아남. 해결: gc_grace_seconds를 노드 다운 허용 시간 이상으로 설정 (기본 10일).

4.7 Subcompaction — 병렬 Compaction

큰 compaction을 여러 스레드로 병렬 처리. RocksDB의 max_subcompactions.

입력 SSTable들의 key range를 N등분 → 각 서브레인지를 독립된 스레드로 병합. compaction 완료 시간이 거의 선형으로 줄어든다.

단점: CPU 사용량 증가. 워크로드가 read-heavy면 compaction이 CPU를 뺏어가 쿼리가 느려짐.

4.8 Compaction Scheduling — 예술의 영역

실전에서는 compaction이 시스템 리소스를 가장 많이 쓴다. 잘못된 스케줄링은:

  • compaction이 쓰기 속도를 못 따라잡음 → L0가 쌓임 → read가 느려짐 → 앱 스로틀링 → write stall.
  • compaction이 너무 공격적 → I/O 경쟁 → 레이턴시 스파이크.

RocksDB의 tunables:

max_background_compactions = 4     # 동시 compaction 수
level0_file_num_compaction_trigger = 4
level0_slowdown_writes_trigger = 20  # 쓰기 슬로우다운
level0_stop_writes_trigger = 36      # 쓰기 완전 스탑
compaction_readahead_size = 2MB
rate_limiter_bytes_per_sec = 100MB  # I/O 한도

rate_limiter가 핵심. 읽기/쓰기 I/O를 compaction이 훔쳐가지 않도록 방지.


5. Write/Space/Read Amplification — 숫자로 보기

5.1 Write Amplification

LSM의 가장 악명 높은 지표. Leveled compaction의 write amp는 대략 WA = 1 + (L-1) * T. 여기서 L은 레벨 수, T는 레벨 간 크기 비율 (기본 10).

레벨 6개, 배율 10 → WA = 51. 즉 1바이트 쓰면 실제 디스크에는 51바이트가 쓰인다. SSD 수명에 치명적.

Size-tiered에서는 WA = L + 1 로 훨씬 작다 — 레벨 6이면 7배. 왜 Cassandra가 STCS를 기본으로 두는지 알 수 있다.

5.2 Space Amplification

Leveled:

  • 정상 상태: 1.1배 정도.
  • Compaction 중: 일시적으로 최대 2배.

Size-tiered:

  • 최악: 2배 (compaction 직전).
  • 평균: 1.5배 정도.

5.3 Read Amplification

Leveled:

  • Best: bloom filter가 모두 negative → 파일 접근 없이 L_n까지 bloom만 본다.
  • Average: 1–2개 파일 접근.
  • Worst: 레벨 수만큼 + L0 파일 수만큼.

B-tree:

  • O(log N) 페이지 접근. 상수가 더 작다.

5.4 RocksDB의 Tiered + Leveled 하이브리드

최근 추세: 낮은 레벨은 tiered (쓰기 속도), 높은 레벨은 leveled (공간 효율). HyperLevelDB 같은 시스템이 이 방향. RocksDB도 level_compaction_dynamic_level_bytes 옵션으로 비슷한 효과.


6. RocksDB 내부 — LSM의 현대적 정점

6.1 아키텍처

Facebook이 LevelDB를 포크해 만든 embedded KV store. "KV 저장 엔진"만 담당하며, 네트워크/분산은 위 레이어에 맡김. 그래서:

  • MyRocks: MySQL storage engine으로 RocksDB.
  • TiKV: CockroachDB-like 분산 KV, 각 샤드가 RocksDB.
  • CockroachDB: Pebble (RocksDB의 Go 포팅)로 전환했지만 철학은 동일.
  • Kafka Streams: state store로 RocksDB.

6.2 Column Family — 논리적 분리

하나의 RocksDB 인스턴스 안에 여러 column family. 각 CF는 독립된 LSM:

rocksdb::ColumnFamilyOptions cf_opts;
rocksdb::ColumnFamilyHandle* cf;
db->CreateColumnFamily(cf_opts, "metadata", &cf);
db->Put(WriteOptions(), cf, "key1", "value1");

서로 다른 CF는 서로 다른 compaction 전략, bloom filter, block size를 가질 수 있다. "테이블"에 대응되는 개념.

6.3 Write Batch — 원자성

WriteBatch batch;
batch.Put("key1", "value1");
batch.Put("key2", "value2");
batch.Delete("key3");
db->Write(WriteOptions(), &batch);

여러 작업이 하나의 WAL 레코드로 쓰여 원자적으로 적용. ACID의 A를 제공.

6.4 Snapshot — 일관된 읽기

const Snapshot* snap = db->GetSnapshot();
ReadOptions ro;
ro.snapshot = snap;
// 이 snapshot은 그 시점의 상태를 본다
db->Get(ro, "key1", &value);
db->ReleaseSnapshot(snap);

LSM은 SSTable이 불변이므로 snapshot 구현이 쉽다. "그 시점의 sequence number보다 작은 것만 보라." Compaction은 살아있는 snapshot이 있으면 해당 sequence 이하 데이터를 지우지 못한다.

6.5 Transaction API (OptimisticTransactionDB / PessimisticTransactionDB)

RocksDB는 기본적으로 KV지만 transaction 계층도 제공:

  • OptimisticTransaction: write-time에 충돌 검사. 충돌률 낮을 때 빠름.
  • PessimisticTransaction: 2PL. 충돌률 높을 때 유리.

MyRocks, TiKV 같은 시스템은 자체 TX 레이어를 올린다.

6.6 Merge Operator — Increment 최적화

INCR key 같은 연산은 보통 Get → 계산 → Put (3 round). Merge operator로 한 번에:

db->Merge(WriteOptions(), "counter", "1");
// 다음 Get 또는 compaction 시 머지

Redis의 INCR과 유사. Cassandra의 counter column이 내부적으로 이 아이디어를 쓴다.

6.7 TTL (Time-To-Live)

SSTable에 expiration 정보를 메타로 추가. 만료된 key는 read 시 필터, compaction 시 실제 제거.

TSDB, 세션 저장소 등에서 자동 정리.


7. Cassandra — 분산 LSM의 원형

7.1 Dynamo + Bigtable = Cassandra

2008년 Facebook에서 시작. Amazon Dynamo의 partitioning/replication + Google Bigtable의 데이터 모델 (column family, SSTable). 지금은 Apache 재단.

  • Partitioning: consistent hashing으로 key를 노드에 분산.
  • Replication: 각 key를 N개 노드에 복제 (RF = replication factor).
  • Storage: 노드마다 로컬 LSM.

7.2 Write Path

  1. 코디네이터 노드가 받음.
  2. RF 노드에 병렬 send.
  3. 각 노드가 로컬 commit log + memtable에 씀.
  4. 노드 N개 중 W개 ack → 코디네이터가 클라이언트에 ack.

W가 consistency level. ONE, QUORUM, ALL. QUORUM은 (RF+1)/2. 대부분 QUORUM + read도 QUORUM으로 strong consistency 달성.

7.3 Read Path (Cassandra-specific)

Cassandra는 read repair를 내장:

  1. R개 replica에서 읽기.
  2. 다르면 최신 데이터로 전부 업데이트.
  3. 클라이언트에는 최신 반환.

덕분에 eventual consistency 시스템이지만 점진적으로 수렴.

7.4 Bloom Filter + Row Cache + Key Cache

레벨별 캐시가 있다:

  • Row cache: 실제 row 데이터.
  • Key cache: key → SSTable 위치.
  • Bloom filter: 노드의 각 SSTable에 존재.

튜닝:

  • read-heavy면 row cache 키워야 하지만 메모리 많이 먹음.
  • key cache는 기본 활성화, 보통 건드릴 필요 없음.
  • bloom filter FP rate는 bloom_filter_fp_chance로 조정. 기본 STCS 0.01, LCS 0.1.

7.5 Compaction 선택

Cassandra는 여러 compaction strategy 지원:

  • SizeTieredCompactionStrategy (STCS): 기본. 쓰기 중심.
  • LeveledCompactionStrategy (LCS): 읽기 중심. SSD 필수.
  • TimeWindowCompactionStrategy (TWCS): 시계열. 윈도우별 compaction.
  • DateTieredCompactionStrategy: TWCS의 조상. 지금은 deprecated.

Time-series 데이터면 TWCS가 사실상 필수. 윈도우별 SSTable이라 오래된 윈도우는 통째 삭제 가능.

7.6 Cassandra의 슬픈 점

  • JVM: GC pause가 레이턴시에 직결.
  • Thread-per-request: 수천 스레드 context switch.
  • 복잡한 compaction: 운영자의 지속적 튜닝 필요.
  • read-before-write 없음: upsert만 빠름. transaction 없음 (LWT가 있지만 비쌈).

이 슬픔이 ScyllaDB를 낳았다.


8. ScyllaDB — Cassandra를 C++로 다시 쓰기

8.1 Shard-per-core Architecture

ScyllaDB는 Cassandra 프로토콜 호환, C++로 재구현. 가장 큰 차이는 shard-per-core:

  • 노드의 각 CPU 코어가 독립된 샤드.
  • 샤드 간 통신은 메시지 패싱.
  • 스레드 context switch 거의 없음.
  • Lock도 거의 없음 (샤드 내부에서만).

이 아키텍처는 Seastar 프레임워크 위에 구축. DPDK, io_uring, NUMA awareness까지 활용한 극한 최적화.

8.2 성능

같은 하드웨어에서 Cassandra 대비 10배 처리량이 흔하다. 특히 tail latency (p99)가 극적으로 낮음 — GC pause가 없으므로.

8.3 LSM의 재구현

Cassandra의 LSM 로직을 그대로 이식하되:

  • C++ allocator 최적화.
  • 샤드별 독립 LSM.
  • 비동기 I/O (io_uring).

SSTable 파일 포맷은 Cassandra와 호환 — 마이그레이션이 쉬움.


9. TiKV와 CockroachDB — LSM 위의 분산 트랜잭션

9.1 TiKV

CNCF 졸업 프로젝트. Rust로 작성. TiDB의 스토리지 레이어.

아키텍처:

  • 데이터를 Region (기본 96MB)으로 샤딩.
  • 각 Region은 Raft 그룹으로 복제 (3 copies).
  • 각 노드의 각 Region 데이터는 RocksDB에 저장.

트랜잭션은 Percolator 알고리즘 (Google 논문) 기반. 2-phase commit + optimistic concurrency.

9.2 CockroachDB

Go로 작성. Spanner 스타일 multi-region DB. 초기에는 RocksDB를 썼으나 2022년 Pebble로 전환.

Pebble은 Go로 작성된 RocksDB 호환 엔진. 같은 LSM 구조지만:

  • CGo 호출 오버헤드 없음.
  • Go 런타임과의 더 좋은 통합 (scheduler, GC).
  • CockroachDB-specific 최적화.

9.3 왜 LSM인가 (분산 DB 관점)

분산 DB는 write-heavy인 경우가 많다. 여러 노드의 Raft 로그를 적용, 메타데이터 업데이트, 컴팩션 등. LSM의 순차 쓰기 특성이 이와 잘 맞는다.

또한 snapshot 생성이 LSM에서 쉽다. Region 이동, replica 추가 시 저렴한 snapshot 복사.


10. B-tree와 LSM의 철학 비교

10.1 한 표로

특성B-tree (Postgres, MySQL/InnoDB)LSM (RocksDB, Cassandra)
쓰기in-place updateappend-only
쓰기 속도낮음 (랜덤 I/O)높음 (순차 I/O)
읽기 속도높음 (O log N)낮음 (여러 레벨 조회)
Range scan우수 (leaf linked list)보통 (merge iterator)
Write amplification~3x (WAL + page)10–30x (leveled)
Space amplification1.2–1.4x1.1–2x
백업어려움 (in-place)쉬움 (불변 파일 복사)
Compaction/VACUUM필요 (autovacuum)필요 (더 무거움)
SSD 수명유리불리 (WA 크면)
운영 복잡도낮음높음

10.2 각자의 스위트 스팟

  • B-tree: 읽기/쓰기 균형, 복잡한 쿼리, ACID 트랜잭션, OLTP.
  • LSM: 쓰기 폭주, 시계열, 로그 저장, KV, 분산 shard.

Postgres + RocksDB를 같은 시스템에서 쓰기도 한다. 최근 추세인 hybrid storage: hot 데이터는 B-tree, cold 데이터는 LSM.

10.3 현대의 수렴

재미있게도 두 세계는 서로를 닮아가고 있다:

  • B-tree 진영: "copy-on-write B-tree" (LMDB, WiredTiger) — 더 이상 in-place 아님.
  • LSM 진영: "hybrid compaction" — 상위 레벨만 tiered, 하위는 leveled.

결국 "순차 I/O 친화적이되 읽기 효율도 챙기기" 가 공통 목표.


11. 운영의 실제

11.1 RocksDB 튜닝 체크리스트

write_buffer_size = 256MB         # memtable 크기
max_write_buffer_number = 4       # memtable 개수
min_write_buffer_number_to_merge = 2
target_file_size_base = 128MB     # L1 SSTable 크기
max_bytes_for_level_base = 1GB    # L1 총 크기
max_bytes_for_level_multiplier = 10
compression_per_level = [none, none, zstd, zstd, zstd, zstd, zstd]
bloom_filter_bits_per_key = 10
cache_index_and_filter_blocks = true
block_cache = 16GB
rate_limiter = 100MB/s

compression_per_level이 재미있다. L0/L1은 압축 안 함 (flush/compaction 속도). L2+는 zstd (저장 효율).

11.2 모니터링 지표

rocksdb.compaction.write.bytes       # compaction write amp
rocksdb.estimate.num.keys
rocksdb.block.cache.hit
rocksdb.block.cache.miss
rocksdb.bloom.filter.useful          # bloom filter가 걸러낸 횟수
rocksdb.write.stall                  # 쓰기 스톨 발생
rocksdb.pending.compaction.bytes     # 밀린 compaction

pending.compaction.bytesshared_buffers만큼 쌓이면 경고. 두 배 넘으면 곧 write stall.

11.3 운영 사고 패턴

  • Compaction이 따라잡지 못함: 쓰기 폭주 + compaction 느림 → L0 쌓임 → write stall. 해법: max_background_compactions 증가, 쓰기 throttle.
  • Bloom filter 없는 range scan 폭주: p99 폭등. 해법: 쿼리 패턴 재검토, prefix bloom 고려.
  • Snapshot 유지 → compaction 막힘: long-running iterator가 snapshot 유지하면 compaction이 오래된 데이터를 못 지움. "LSM 버전의 idle in transaction."

11.4 백업 전략

SSTable은 불변 → hard link로 즉시 백업 가능:

# RocksDB backup engine은 이 원리를 자동화
BackupEngine::CreateNewBackup(db)

새 SSTable이 만들어지면 백업 엔진에 link 추가. 오래된 backup이 삭제되면 link 해제. 아주 낮은 오버헤드.

11.5 재해 복구

LSM은 WAL + SSTable만 있으면 재구성 가능.

pg_basebackup 같은 개념:
1. Checkpoint 강제
2. WAL + SSTable 디렉토리 copy
3. 복구 노드에서 로드 + WAL replay

Cassandra는 nodetool snapshot이 이 역할.


12. 고급 주제

12.1 Wisckey — Key-Value 분리

2016년 논문. LSM의 문제: compaction이 value까지 rewrite. 큰 value(1KB+)는 write amp의 주범.

해결: key만 LSM에 두고, value는 별도 append-only log에. LSM의 entry는 (key, value_pointer).

장점:

  • Compaction이 작은 key-pointer 쌍만 rewrite → write amp 대폭 감소.
  • Read는 추가 I/O 한 번 — pointer follow.

RocksDB의 BlobDB가 이 원리. Pebble도 채택. Badger DB는 처음부터 Wisckey 기반.

12.2 Monkey — Bloom Filter 최적화

2017년 논문. 모든 레벨에 같은 FPR의 bloom filter를 쓰는 건 비효율. 상위 레벨(작고 자주 읽힘)에는 촘촘히, 하위 레벨(크고 덜 읽힘)에는 성기게.

Monkey는 레벨별 최적 FPR 할당을 수학적으로 도출. 같은 메모리로 read 처리량 2배.

12.3 REMIX / Dostoevsky

Compaction 전략을 수학적으로 최적화. Leveled와 tiered의 hybrid. RocksDB의 최근 버전이 이 방향을 채택.

12.4 Learned Index

Kraska et al. 2018. 인덱스를 ML 모델로 대체. key → position을 학습.

현실적 응용은 제한적이지만, **성공적 데이터(정렬된, 예측 가능한 분포)**에서는 B-tree/Bloom filter 대비 메모리 절감 가능.


13. 어느 엔진을 언제 쓸까

13.1 결정 트리

  1. ACID가 필요한가? YES → Postgres/MySQL (B-tree).
  2. 시계열/로그인가? YES → InfluxDB/ClickHouse/TimescaleDB (LSM-like).
  3. 분산 KV 또는 wide-column? YES → Cassandra/ScyllaDB/DynamoDB (LSM).
  4. 대규모 분석? YES → ClickHouse (MergeTree는 LSM의 친척).
  5. 임베디드 KV? YES → RocksDB/LevelDB (LSM).
  6. 기타: Postgres부터 시작해도 대부분 괜찮음.

13.2 신흥 대안들

  • FoundationDB: ACID 분산 KV. Apple이 오픈소스화.
  • ScyllaDB: Cassandra 대체, 10배 성능.
  • TiKV/TiDB: HTAP, MySQL 호환.
  • CockroachDB: Spanner-like 분산 Postgres.
  • Neon / PlanetScale / Crunchy: Postgres/MySQL의 클라우드 네이티브 재해석.

모두 내부에 LSM 또는 B-tree (또는 변형)를 쓴다. 근본 원리는 30년 전과 같다.


맺음 — 순차 I/O의 예찬

LSM-Tree의 모든 설계 선택은 순차 I/O는 랜덤 I/O보다 압도적으로 빠르다는 한 문장에서 출발한다. 쓰기를 메모리에 모으기, 디스크에 순차적으로 flush하기, 백그라운드에서 merge하기. 이 단순한 아이디어 하나가 NoSQL 혁명의 기반이 됐다.

하지만 LSM은 공짜가 아니다. Read amp, write amp, compaction 오버헤드, 복잡한 튜닝. 그래서 B-tree가 죽지 않았다. Postgres는 여전히 건재하고, MySQL/InnoDB도 건재하다.

정답은 워크로드를 이해하는 것이다. 쓰기 폭주 시스템이라면 LSM을 생각해 보자. 복잡한 쿼리와 트랜잭션이 많다면 B-tree가 여전히 최고다. 둘 다 필요하면 hybrid 아키텍처 — hot은 Postgres, cold는 ClickHouse, state store는 RocksDB 같은 조합.

운영자로서 세 가지 본능을 정리한다:

  1. Write amplification을 측정하라. SSD 수명이 걸려있다.
  2. Compaction의 속도를 감시하라. 쓰기 속도를 못 따라가면 재앙.
  3. Bloom filter를 신뢰하되 검증하라. FPR이 튜닝의 첫 걸음.

다음 글은 ClickHouse의 MergeTree 내부 구조를 다룬다. LSM의 사촌이자 OLAP의 현재 챔피언. 컬럼 저장, sparse index, materialized view가 LSM 원리 위에 어떻게 쌓이는지 본다. 저장 엔진의 세계는 넓다.

LSM-Tree Deep Dive — RocksDB, LevelDB, Compaction, Bloom Filter, Write Amplification (2025)

Why yet another storage engine

The previous post dove into PostgreSQL and the elegance of B-trees — the data structure that dominated databases for decades. Then in the 2010s a different family quietly took over: Cassandra, HBase, LevelDB, RocksDB, ScyllaDB, InfluxDB, TiKV, CockroachDB, FoundationDB, ClickHouse MergeTree. All built on LSM-Tree (Log-Structured Merge Tree) or a variant.

Why LSM? B-tree's weakness is clear — random writes. Every update finds a page, modifies it, writes WAL, and fsyncs to an arbitrary disk location. Deadly on HDDs; still painful on SSDs because of write amplification and wear leveling.

LSM's answer: write only sequentially to disk. Never touch existing data. Express updates as new records, deletes as tombstones. Merge (compact) later in the background.

This simple idea boosts write throughput 10–100x and powers modern NoSQL. This post covers LSM from the 1996 paper through RocksDB's latest compaction strategies, Bloom filters, the RUM conjecture, real systems (Cassandra, ScyllaDB, TiKV), and coexistence with B-trees.


1. Origins — the 1996 insight

1.1 The O'Neil paper

O'Neil, Cheng, Gawlick, and O'Neil proposed LSM-Tree in 1996. Problem: B-tree updates were random I/O, HDD seeks were ~10ms (100 IOPS), TPC-B benchmarks outgrew B-trees.

Key idea: batch writes in memory, flush to disk as large sequential chunks. Sequential I/O is 100–1000x faster than random on HDDs. Merging with on-disk data is itself a sequential merge.

Applied in 2003 via Google's Bigtable, then LevelDB (Google OSS), RocksDB (Facebook fork), Cassandra, HBase.

1.2 The RUM conjecture

Athanassoulis et al. (2016). Every storage engine tries to optimize:

  • R (Read amp): disk reads per logical read.
  • U (Update amp = Write amp): bytes written per logical write.
  • M (Memory amp = Space amp): disk usage vs logical size.

You cannot minimize all three simultaneously. Reducing one increases another.

  • B-tree: good R (log N), bad U (random), medium M.
  • LSM: bad R (multiple levels), good U (sequential), variable M.
  • Hash table: best R (O(1)), bad U, bad M.

Engine design is picking where to sit in this triangle.


2. Structure — three simple layers

2.1 Basic anatomy

+----------+
| Memtable |  <- in-memory, sorted (skiplist)
+----------+
     |
     | flush
     v
+------------+------------+------------+
| SSTable L0 | SSTable L0 | SSTable L0 |
+------------+------------+------------+
     |
     | compaction
     v
+----------+----------+----------+
| SST L1   | SST L1   | SST L1   |
+----------+----------+----------+
     |
     v
+-----+-----+-----+
| L2  | L2  | L2  | ...
+-----+-----+-----+
  • Memtable: in-memory sorted structure, typically a skiplist (RocksDB, LevelDB).
  • WAL: disk log protecting memtable against crashes.
  • SSTable (Sorted String Table): immutable sorted file.
  • Compaction: merges lower-level SSTables into higher levels.

2.2 Write path

PUT(key, value)
  1. Append (key, value) to WAL + optional fsync
  2. Insert into Memtable
  3. If Memtable exceeds threshold:
       - Allocate new Memtable (writes continue)
       - Mark old one immutable (flush candidate)
       - Background thread flushes to new L0 SSTable
       - Discard WAL portion for that memtable

Key insight: writes only touch memory. Disk writes are sequential (WAL append, then SSTable flush).

2.3 WAL

Same concept as Postgres — reconstructs memtable on crash. Differences:

  • LSM's WAL is discarded after memtable flush (Postgres keeps until checkpoint).
  • SSTables themselves preserve past state; WAL only protects memtable.

RocksDB WriteOptions.sync tunes durability. group commit batches fsyncs.

2.4 SSTable — the virtue of immutability

+-----------------+
| Data Block 1    |  sorted key-value pairs
+-----------------+
| Data Block 2    |
+-----------------+
| ...             |
+-----------------+
| Meta Block      |  Bloom filter, stats
+-----------------+
| Meta Index      |
+-----------------+
| Data Index      |  first key of each data block
+-----------------+
| Footer          |  fixed size, points to meta index
+-----------------+

Immutability is decisive. Nothing writes while you read → lock-free reads. Backup is trivial file copy. Caching is efficient — blocks never change.

2.5 Why a skiplist memtable

RocksDB's default memtable is a skiplist. Why not B+tree or red-black tree?

  1. Lock-free concurrency: CAS-based inserts work naturally.
  2. Simple implementation: no rebalancing.
  3. Range-scan friendly: leaf already a linked list.

Downside: worse cache locality than B+tree for random lookups. RocksDB offers alternatives: skip_list, hash_skip_list (point-heavy), vector (prefix scan).


3. Read path — LSM's Achilles heel

3.1 Search many places

GET(key)
  1. Check active memtable
  2. Check immutable memtables
  3. Check ALL L0 SSTables (overlapping ranges)
  4. Check L1, L2, ... — one SSTable per level (non-overlapping)

Worst case: 5 L0 + 6 levels = 12 lookups. Compare to B-tree's O(log N) pages — brutal.

3.2 Bloom filter — fast "not here"

Probabilistic: "definitely absent" or "probably present".

- k hash functions
- m-bit array
- insert: set k hashed positions to 1
- query: all k positions 1"maybe", else "no"

False-positive rate P = (1 - e^(-kn/m))^k. Typical: 10 bits/key → ~1% FPR. 1B keys costs 12.5GB memory — worth it.

3.3 Block cache

Hot blocks cached separately from OS page cache. RocksDB:

block_cache_size = 16GB
cache_index_and_filter_blocks = true
pin_l0_filter_and_index_blocks_in_cache = true

Blocks stored decompressed in cache even if compressed on disk.

3.4 Partitioned index

A 100GB DB may have 1GB of index. Solution: two-level index — a small top-level in memory, detailed index blocks on disk.

3.5 Point lookup vs range scan

Bloom filters help point lookups only. Range scans must visit every level. LSM is weaker for range-heavy workloads; TSDBs partially offset this via hot-recent query patterns.


4. Compaction — LSM's heart and nightmare

4.1 Why compact

Without compaction:

  1. Read amp explodes — L0 grows unbounded.
  2. Space amp — deleted/old versions linger forever.
  3. File-descriptor exhaustion.

Compaction merges SSTables, keeping only latest versions, removing tombstones eventually, and resolving overlaps.

4.2 Size-Tiered (STCS) — Cassandra default

When N (default 4) same-size-class SSTables accumulate, merge into one.

start:  [10MB] [10MB] [10MB] [10MB]
merge:  [40MB]
later:  [40MB] [40MB] [40MB] [40MB]
merge:  [160MB]

Pros: low write amp. Cons: high space amp — up to 2x during compaction.

4.3 Leveled (LCS) — LevelDB/RocksDB default

Size limits per level; each level has non-overlapping key ranges (except L0).

L0: [a-k] [f-p] [l-z]   (can overlap)
L1: 10MB cap — [a-c] [c-g] [g-m] ...   (non-overlapping)
L2: 100MB cap
L3: 1GB cap

When Ln exceeds cap, pick an SSTable and merge with overlapping SSTables in L(n+1).

Pros: low space amp (~1.1x). Cons: high write amp (10–30x worst case).

4.4 Universal (UCS) — RocksDB-only

Like STCS, more sophisticated. All SSTables in L0; merge by size conditions. Suited for write-heavy, read-light (caching, log ingest). Space amp 2–3x.

4.5 FIFO

Time-series only. Old SSTables just deleted, no compaction. Perfect for logs/metrics with TTL.

4.6 Tombstones and the awkward dance

Tombstones mark deletions. They must persist until every lower level with that key is purged, or deleted data "resurrects."

Cassandra's famous zombie rows: tombstone GC'd after gc_grace_seconds, a late-syncing node replays old data → resurrection. Fix: set gc_grace_seconds larger than max node downtime (default 10 days).

4.7 Subcompaction

Split big compactions across threads. RocksDB's max_subcompactions. Partition input key ranges into N sub-ranges, merge in parallel. Downside: CPU contention with queries.

4.8 Scheduling — an art

Bad scheduling:

  • Compaction too slow → L0 piles up → write stall (app-level throttle).
  • Compaction too aggressive → I/O contention → latency spikes.

RocksDB tunables:

max_background_compactions = 4
level0_file_num_compaction_trigger = 4
level0_slowdown_writes_trigger = 20
level0_stop_writes_trigger = 36
compaction_readahead_size = 2MB
rate_limiter_bytes_per_sec = 100MB

rate_limiter prevents compaction from stealing foreground I/O.


5. Write / Space / Read amplification by the numbers

5.1 Write amp

Leveled: WA ≈ 1 + (L-1) * T, where L=levels, T=level multiplier (default 10). Six levels, T=10 → WA=51. One byte written = 51 bytes on disk. Hard on SSDs.

Size-tiered: WA = L + 1 — much smaller. That's why Cassandra defaults to STCS.

5.2 Space amp

Leveled: ~1.1x steady, up to 2x during compaction. Size-tiered: ~1.5x avg, up to 2x worst.

5.3 Read amp

Leveled: best case Bloom filters catch all negatives; typical 1–2 file accesses; worst = levels + L0 files. B-tree: O(log N) page accesses with smaller constants.

5.4 Hybrid tiered + leveled

Modern trend: lower levels tiered (write speed), upper levels leveled (space). level_compaction_dynamic_level_bytes gives a similar effect in RocksDB.


6. RocksDB internals

6.1 Architecture

Facebook's LevelDB fork — embedded KV only; networking/distribution is layered above. Uses:

  • MyRocks: MySQL storage engine.
  • TiKV: distributed KV using RocksDB per shard.
  • CockroachDB: migrated to Pebble (Go port).
  • Kafka Streams: RocksDB state store.

6.2 Column Families

Multiple logical LSMs inside one DB instance:

rocksdb::ColumnFamilyOptions cf_opts;
rocksdb::ColumnFamilyHandle* cf;
db->CreateColumnFamily(cf_opts, "metadata", &cf);
db->Put(WriteOptions(), cf, "key1", "value1");

Each CF can have its own compaction, Bloom filter, block size — analogous to "tables."

6.3 Write batch — atomicity

WriteBatch batch;
batch.Put("key1", "value1");
batch.Put("key2", "value2");
batch.Delete("key3");
db->Write(WriteOptions(), &batch);

Atomically applied — one WAL record.

6.4 Snapshot

const Snapshot* snap = db->GetSnapshot();
ReadOptions ro;
ro.snapshot = snap;
db->Get(ro, "key1", &value);
db->ReleaseSnapshot(snap);

Snapshots are cheap in LSM due to immutable SSTables. Compaction won't drop sequence numbers beneath a live snapshot.

6.5 Transactions

Optimistic (check at write-time) or pessimistic (2PL). Upper layers like MyRocks/TiKV build richer TX semantics.

6.6 Merge operator

Turn INCR key from 3 round trips into 1:

db->Merge(WriteOptions(), "counter", "1");

Resolved at Get/compaction time. Cassandra counters use this idea.

6.7 TTL

Expiry metadata in SSTables; filtered at read, removed at compaction.


7. Cassandra — distributed LSM archetype

7.1 Dynamo + Bigtable

2008, Facebook. Dynamo's partitioning/replication + Bigtable's data model (column family, SSTable). Consistent hashing partitions keys; each key replicated to RF nodes; each node runs a local LSM.

7.2 Write path

  1. Coordinator receives.
  2. Sends to RF replicas in parallel.
  3. Each writes local commit log + memtable.
  4. W acks → client ack.

W is consistency level (ONE, QUORUM, ALL). QUORUM reads + QUORUM writes → strong consistency.

7.3 Read repair

Read R replicas, reconcile differences, return latest — eventual consistency that converges.

7.4 Caches

  • Row cache: full row data.
  • Key cache: key → SSTable position.
  • Bloom filter: per SSTable.

bloom_filter_fp_chance: 0.01 default for STCS, 0.1 for LCS.

7.5 Compaction strategies

  • STCS: default, write-centric.
  • LCS: read-centric, needs SSDs.
  • TWCS: time-series; whole windows droppable.
  • DTCS: TWCS ancestor, deprecated.

7.6 Cassandra's pains

JVM GC pauses; thread-per-request overhead; intricate compaction tuning; no read-before-write (upserts-only; LWT exists but expensive). These motivated ScyllaDB.


8. ScyllaDB

8.1 Shard-per-core

Cassandra-protocol-compatible rewrite in C++:

  • Each CPU core = independent shard.
  • Message passing between shards.
  • Almost no context switches, few locks.

Built on Seastar, DPDK, io_uring, NUMA-aware.

8.2 Performance

~10x Cassandra throughput on identical hardware; dramatically lower p99 (no GC pauses).

8.3 LSM reimplementation

Same logic with C++ allocator tuning, per-shard LSM, async I/O. SSTable format remains Cassandra-compatible for easy migration.


9. TiKV and CockroachDB

9.1 TiKV

CNCF graduate, Rust. TiDB storage layer.

  • Data sharded into Regions (96MB default).
  • Each region replicated via Raft (3 copies).
  • Each node stores region data in RocksDB.
  • Transactions via Percolator (Google paper): 2PC + optimistic.

9.2 CockroachDB

Go. Spanner-style multi-region DB. Originally RocksDB, migrated to Pebble (Go) in 2022 — no CGo overhead, better Go runtime integration.

9.3 Why LSM for distributed DBs

Distributed systems are write-heavy (Raft log apply, metadata, compaction). Sequential-write LSM fits well. Snapshots are cheap — vital for region moves and replica bootstrap.


10. B-tree vs LSM

10.1 At a glance

TraitB-tree (Postgres, InnoDB)LSM (RocksDB, Cassandra)
Writesin-placeappend-only
Write speedlower (random)higher (sequential)
Read speedhigher (O log N)lower (multi-level)
Range scansexcellentdecent (merge iterator)
Write amp~3x10–30x (leveled)
Space amp1.2–1.4x1.1–2x
Backuphardereasy (copy immutable files)
Compaction/VACUUMneededneeded (heavier)
SSD wearfriendlierharsher
Ops complexitylowerhigher

10.2 Sweet spots

  • B-tree: balanced R/W, complex queries, ACID, OLTP.
  • LSM: write floods, time-series, logs, KV, distributed shards.

10.3 Convergence

B-tree camp adopts COW (LMDB, WiredTiger) — no longer in-place. LSM camp adopts hybrid compaction. Shared goal: sequential-friendly with good reads.


11. Running LSM in production

11.1 RocksDB tuning checklist

write_buffer_size = 256MB
max_write_buffer_number = 4
min_write_buffer_number_to_merge = 2
target_file_size_base = 128MB
max_bytes_for_level_base = 1GB
max_bytes_for_level_multiplier = 10
compression_per_level = [none, none, zstd, zstd, zstd, zstd, zstd]
bloom_filter_bits_per_key = 10
cache_index_and_filter_blocks = true
block_cache = 16GB
rate_limiter = 100MB/s

Note L0/L1 no compression (flush speed), L2+ zstd (space).

11.2 Metrics

rocksdb.compaction.write.bytes
rocksdb.estimate.num.keys
rocksdb.block.cache.hit
rocksdb.block.cache.miss
rocksdb.bloom.filter.useful
rocksdb.write.stall
rocksdb.pending.compaction.bytes

Watch pending.compaction.bytes — if it climbs, write stall looms.

11.3 Incident patterns

  • Compaction can't keep up → L0 piles → stall. Fix: more compaction threads, throttle writes.
  • Range scans without Bloom benefit → p99 spike. Fix: revisit query patterns, prefix Bloom.
  • Long-lived snapshot blocks compaction — "LSM's idle in transaction."

11.4 Backup

Immutable SSTables allow hardlink-based backups.

BackupEngine::CreateNewBackup(db)

11.5 DR

Need WAL + SSTables only: checkpoint, copy, load + WAL replay. Cassandra's nodetool snapshot does this.


12. Advanced

12.1 Wisckey — KV separation

  1. Big values dominate compaction write amp. Solution: keys in LSM, values in append-only log; LSM stores (key, value_pointer). RocksDB's BlobDB, Pebble, and Badger use this.

12.2 Monkey — optimized Bloom filters

  1. Same FPR across levels is suboptimal. Tight FPR for upper levels (small, hot), loose for lower. 2x read throughput at same memory.

12.3 REMIX / Dostoevsky

Mathematical hybrids of leveled/tiered; RocksDB is moving this way.

12.4 Learned indexes

Kraska 2018. Index as ML model. Practical gains for predictable distributions.


13. Choosing an engine

13.1 Decision tree

  1. Need ACID? → Postgres/MySQL.
  2. Time-series/logs? → InfluxDB/ClickHouse/TimescaleDB.
  3. Distributed KV/wide-column? → Cassandra/ScyllaDB/DynamoDB.
  4. Large-scale analytics? → ClickHouse.
  5. Embedded KV? → RocksDB/LevelDB.
  6. Otherwise: start with Postgres.

13.2 Emerging

FoundationDB, ScyllaDB, TiKV/TiDB, CockroachDB, Neon/PlanetScale/Crunchy — all use LSM, B-tree, or variants underneath. Fundamentals unchanged for 30 years.


Closing — in praise of sequential I/O

Every LSM choice flows from one sentence: sequential I/O is orders of magnitude faster than random. Batch in memory, flush sequentially, merge in background. That simple idea powered the NoSQL revolution.

LSM isn't free — read amp, write amp, compaction overhead, complex tuning. B-tree isn't dead — Postgres and InnoDB still thrive.

The answer is understanding your workload. Write floods → LSM. Complex queries and transactions → B-tree. Need both → hybrid (Postgres hot, ClickHouse cold, RocksDB as state store).

Three operator instincts:

  1. Measure write amplification — SSD life depends on it.
  2. Watch compaction speed — if writes outpace it, disaster.
  3. Trust Bloom filters, but verify — FPR is step one of tuning.

Next up: ClickHouse MergeTree internals — LSM's cousin and OLAP champion.