Workspace IndexAlgorithms › Day 71

Tuning LSM Trees — Compaction, Write Amplification, and Read Amplification TODO

Algorithms · Day 71 / 100 · E. Data & Storage Engines (Day 69-81)

Concept

An LSM tree buffers writes into an in-memory memtable, flushes it as a sorted, immutable file (an SSTable), and periodically merges the files piled up across levels via compaction — turning random writes into sequential ones to gain write throughput. The tradeoff is three kinds of amplification. Write amplification is the multiple by which one logical write ends up being rewritten to disk repeatedly across compactions. Read amplification is the cost of a single lookup having to check multiple levels and files. Space amplification is the ratio by which stored data exceeds the actual live data, because old versions and delete markers haven't been cleaned up yet. Leveled compaction keeps key ranges non-overlapping within each level, which keeps read and space amplification low at the cost of high write amplification; tiered (size-tiered) compaction does the opposite — low write amplification, but many overlapping files driving up read and space amplification. In other words, the three amplifications can't all be minimized simultaneously — tuning knobs like Bloom filters, block cache, file size, and level fan-out are all ways of choosing which corner of that triangle to sacrifice.

A chain node's state database or an indexer's backend is typically LSM-based, so when you hit a disk-write blowup or a read-latency spike from lagging compaction, you can't diagnose the cause without this three-way-amplification lens.

Code & Formula

# LSM 트리 튜닝 — 컴팩션·쓰기 증폭·읽기 증폭 — 레벨을 쌓고 병합할 때마다 같은 데이터가 몇 번씩 다시 쓰이는지 측정한다.
# memtable → SSTable flush → 레벨 컴팩션 과정에서 논리적 쓰기량 대비 실제 디스크 기록량(쓰기 증폭)을 계산한다.

class LSMTree:
    def __init__(self, level_multiplier=4, level0_size=4):
        self.levels = []                      # levels[i] = 그 레벨에 쌓인 바이트 수
        self.level_multiplier = level_multiplier
        self.level0_size = level0_size
        self.logical_bytes_written = 0
        self.actual_bytes_written = 0

    def flush_memtable(self, size):
        self.logical_bytes_written += size
        self.actual_bytes_written += size     # memtable → L0 flush (1회 기록)
        if not self.levels:
            self.levels.append(0)
        self.levels[0] += size
        self._maybe_compact(0)

    def _maybe_compact(self, i):
        capacity = self.level0_size * (self.level_multiplier ** i)
        if self.levels[i] <= capacity:
            return
        moved = self.levels[i]
        self.levels[i] = 0
        if i + 1 >= len(self.levels):
            self.levels.append(0)
        self.levels[i + 1] += moved
        self.actual_bytes_written += moved    # 컴팩션도 디스크에 다시 쓰는 비용이다
        self._maybe_compact(i + 1)

tree = LSMTree()
for _ in range(50):
    tree.flush_memtable(size=1)

write_amp = tree.actual_bytes_written / tree.logical_bytes_written
print("레벨별 현재 크기:", tree.levels)
print(f"논리 쓰기량={tree.logical_bytes_written}, 실제 디스크 기록량={tree.actual_bytes_written}")
print(f"쓰기 증폭(write amplification) = {write_amp:.2f}x")

# 읽기 증폭: 하나의 키를 찾으려면 최악의 경우 각 레벨을 다 확인해야 한다
levels_to_check = len([lv for lv in tree.levels if lv > 0])
print(f"읽기 증폭(최악의 경우 확인해야 할 레벨 수) ≈ {levels_to_check}")

Exercise

Load a random-key write workload onto RocksDB or a similar engine, vary the compaction style and level fan-out, and table out actual disk bytes written against logical bytes written (write amplification) alongside lookup p99 latency.

Practical Connection

If Verex's event indexer writes logs mostly as time-ordered appends and queries them per-market by range, key design and compaction policy alone can shift query latency and disk lifetime substantially on the same hardware.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

LSM 트리 튜닝 TODO

Algorithms · Day 71 / 100 · E. 데이터·스토리지 엔진 (Day 69–81)

컴팩션·쓰기 증폭·읽기 증폭

개념

LSM 트리는 쓰기를 메모리의 memtable에 모았다가 정렬된 불변 파일(SSTable)로 flush하고, 레벨별로 쌓인 파일들을 컴팩션으로 병합해 정리하는 구조로, 랜덤 쓰기를 순차 쓰기로 바꾸어 쓰기 처리량을 얻는다. 대가로 세 가지 증폭이 생긴다. 쓰기 증폭은 하나의 논리적 쓰기가 컴팩션을 거치며 여러 번 디스크에 다시 쓰이는 배수이고, 읽기 증폭은 하나의 조회가 여러 레벨·파일을 확인해야 하는 비용이며, 공간 증폭은 아직 정리되지 않은 옛 버전과 삭제 마커 때문에 실제 데이터보다 저장 공간이 커지는 비율이다. 레벨드 컴팩션은 레벨마다 키 범위가 겹치지 않게 유지해 읽기·공간 증폭을 낮추는 대신 쓰기 증폭이 크고, 티어드(사이즈 계층) 컴팩션은 반대로 쓰기 증폭이 작지만 겹치는 파일이 많아 읽기·공간 증폭이 커진다. 즉 세 증폭은 동시에 최소화할 수 없는 트레이드오프이며, 블룸 필터·블록 캐시·파일 크기·레벨 배수 같은 튜닝 손잡이는 이 삼각형 안에서 어느 쪽을 희생할지 고르는 수단이다.

체인 노드의 상태 DB나 인덱서 백엔드가 대개 LSM 기반이라, 디스크 쓰기량이 폭증하거나 컴팩션이 밀려 읽기 지연이 튀는 장애를 만나면 이 세 증폭의 관점 없이는 원인을 못 잡는다.

코드 · 수식

# LSM 트리 튜닝 — 컴팩션·쓰기 증폭·읽기 증폭 — 레벨을 쌓고 병합할 때마다 같은 데이터가 몇 번씩 다시 쓰이는지 측정한다.
# memtable → SSTable flush → 레벨 컴팩션 과정에서 논리적 쓰기량 대비 실제 디스크 기록량(쓰기 증폭)을 계산한다.

class LSMTree:
    def __init__(self, level_multiplier=4, level0_size=4):
        self.levels = []                      # levels[i] = 그 레벨에 쌓인 바이트 수
        self.level_multiplier = level_multiplier
        self.level0_size = level0_size
        self.logical_bytes_written = 0
        self.actual_bytes_written = 0

    def flush_memtable(self, size):
        self.logical_bytes_written += size
        self.actual_bytes_written += size     # memtable → L0 flush (1회 기록)
        if not self.levels:
            self.levels.append(0)
        self.levels[0] += size
        self._maybe_compact(0)

    def _maybe_compact(self, i):
        capacity = self.level0_size * (self.level_multiplier ** i)
        if self.levels[i] <= capacity:
            return
        moved = self.levels[i]
        self.levels[i] = 0
        if i + 1 >= len(self.levels):
            self.levels.append(0)
        self.levels[i + 1] += moved
        self.actual_bytes_written += moved    # 컴팩션도 디스크에 다시 쓰는 비용이다
        self._maybe_compact(i + 1)

tree = LSMTree()
for _ in range(50):
    tree.flush_memtable(size=1)

write_amp = tree.actual_bytes_written / tree.logical_bytes_written
print("레벨별 현재 크기:", tree.levels)
print(f"논리 쓰기량={tree.logical_bytes_written}, 실제 디스크 기록량={tree.actual_bytes_written}")
print(f"쓰기 증폭(write amplification) = {write_amp:.2f}x")

# 읽기 증폭: 하나의 키를 찾으려면 최악의 경우 각 레벨을 다 확인해야 한다
levels_to_check = len([lv for lv in tree.levels if lv > 0])
print(f"읽기 증폭(최악의 경우 확인해야 할 레벨 수) ≈ {levels_to_check}")

연습

RocksDB나 유사 엔진에 랜덤 키 쓰기 부하를 넣고 컴팩션 스타일과 레벨 배수를 바꿔가며 실제 디스크 기록량 대비 논리 쓰기량(쓰기 증폭)과 조회 p99를 표로 비교해 보라.

실무 · Verex 연결

Verex의 이벤트 인덱서가 로그를 시간순 append 위주로 쓰고 마켓별로 범위 조회한다면, 키 설계와 컴팩션 정책만 바꿔도 같은 하드웨어에서 조회 지연과 디스크 수명이 크게 달라진다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 70. WAL·그룹 커밋·fsync 비용72. B+트리 vs LSM →