Workspace IndexAlgorithms › Day 72

B+Trees vs. LSM Trees: How Ethereum Clients Choose Their Database TODO

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

Concept

A B+tree keeps sorted keys in page-sized nodes and updates them in place, so read amplification is low and range scans come naturally, but random writes turn into random page writes, driving up write amplification and page-split cost. An LSM tree buffers writes into an in-memory table and flushes them sequentially as SSTables, cleaning up with background compaction — giving high write throughput, at the cost of a single key being scattered across multiple levels (raising read amplification, mitigated with Bloom filters) and compaction causing latency spikes and space amplification. Ethereum's state sits on top of a key-value store as a Merkle Patricia Trie, which produces a random, write-skewed access pattern, since every block rewrites the whole path of nodes up to the root. That's why the Geth line has relied on LSM-based storage (LevelDB, later Pebble), while Erigon chose to restructure state around flat key layout with the B+tree-based MDBX, trading toward lower read amplification and disk usage. In the end the choice comes down to the workload's read/write ratio, access locality, and how much space amplification is tolerable.

Slow node sync or exploding disk usage is usually not an application-logic problem — it's a mismatch between the storage engine's amplification characteristics and the actual access pattern.

Code & Formula

# B+트리 vs LSM → 이더리움 클라이언트의 DB 선택 — 랜덤 쓰기 워크로드에서 두 구조의 실제 디스크 비용을 비교한다.
# B+트리는 갱신마다 랜덤 페이지 쓰기, LSM은 순차 flush + 백그라운드 컴팩션으로 대가를 뒤로 미룬다.

import math
import random
random.seed(1)

PAGE_SIZE = 4096
N_WRITES = 2000
KEY_SPACE = 500   # 키 공간이 작을수록 같은 페이지가 자주 재기록된다 (지역성 ↑)

def bplus_tree_cost(n_writes, key_space, page_size=PAGE_SIZE, keys_per_page=64):
    # 단순화: 랜덤 키 쓰기마다 그 키가 속한 페이지 하나를 통째로 다시 쓴다 (in-place 갱신)
    total_page_writes = 0
    for _ in range(n_writes):
        key = random.randint(0, key_space - 1)
        _page = key // keys_per_page
        total_page_writes += 1
    return total_page_writes * page_size

def lsm_cost(n_writes, record_size=64, level_multiplier=4):
    logical = n_writes * record_size
    # flush 1회 + 레벨을 타고 내려가며 평균 level_multiplier배 정도 재기록된다고 근사
    amplification = 1 + math.log(max(n_writes // 100, 1), level_multiplier)
    return logical * amplification

bplus_bytes = bplus_tree_cost(N_WRITES, KEY_SPACE)
lsm_bytes = lsm_cost(N_WRITES)

print(f"랜덤 쓰기 {N_WRITES}건, 키 공간 {KEY_SPACE} (지역성 낮음)")
print(f"B+트리 예상 디스크 기록량: {bplus_bytes:,} bytes (페이지 단위 랜덤 쓰기)")
print(f"LSM 예상 디스크 기록량:   {lsm_bytes:,.0f} bytes (순차 flush + 컴팩션 증폭)")
print("→ 랜덤 쓰기 편중 워크로드일수록 LSM이 유리한 이유: 쓰기를 순차화해 증폭을 뒤로 미룬다")

Exercise

Run the same key-value workload — random-write-heavy vs. range-read-heavy — against both an LSM-based store and a B+tree-based store, and compare throughput, disk usage, and latency distribution.

Practical Connection

Append-heavy data with lots of time-range queries, like prediction-market event logs and execution history, favors the LSM family; random-read-heavy data, like per-account position lookups, calls for a different index structure.

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/.


한국어

B+트리 vs LSM TODO

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

개념

B+트리는 정렬된 키를 페이지 단위 노드에 유지하며 갱신을 제자리(in-place)로 수행하므로 읽기 증폭이 작고 범위 스캔이 자연스럽지만, 랜덤 쓰기가 랜덤 페이지 쓰기로 이어져 쓰기 증폭과 페이지 분할 비용이 크다. LSM 트리는 쓰기를 메모리 테이블에 모아 순차적으로 SSTable로 flush하고 백그라운드 compaction으로 정리하므로 쓰기 처리량이 높은 대신, 하나의 키가 여러 레벨에 흩어져 읽기 증폭이 생기고(블룸 필터로 완화) compaction이 지연 스파이크와 공간 증폭을 만든다. 이더리움의 상태는 Merkle Patricia 트리를 키-값 저장소 위에 얹는 구조라, 블록마다 루트까지의 경로 노드가 통째로 새로 쓰이는 무작위·쓰기 편중 패턴이 나온다. 그래서 Geth 계열은 LSM 기반 저장소(LevelDB, 이후 Pebble)를 써 왔고, Erigon은 평탄한 키 배치와 B+트리 기반 MDBX로 상태를 재구성해 읽기 증폭과 디스크 사용량을 줄이는 방향을 택했다. 결국 선택은 워크로드의 읽기/쓰기 비율과 접근 지역성, 그리고 감당 가능한 공간 증폭에 달려 있다.

노드 동기화가 느리거나 디스크가 폭증하는 문제는 대개 애플리케이션 로직이 아니라 스토리지 엔진의 증폭 특성과 접근 패턴의 불일치에서 온다.

코드 · 수식

# B+트리 vs LSM → 이더리움 클라이언트의 DB 선택 — 랜덤 쓰기 워크로드에서 두 구조의 실제 디스크 비용을 비교한다.
# B+트리는 갱신마다 랜덤 페이지 쓰기, LSM은 순차 flush + 백그라운드 컴팩션으로 대가를 뒤로 미룬다.

import math
import random
random.seed(1)

PAGE_SIZE = 4096
N_WRITES = 2000
KEY_SPACE = 500   # 키 공간이 작을수록 같은 페이지가 자주 재기록된다 (지역성 ↑)

def bplus_tree_cost(n_writes, key_space, page_size=PAGE_SIZE, keys_per_page=64):
    # 단순화: 랜덤 키 쓰기마다 그 키가 속한 페이지 하나를 통째로 다시 쓴다 (in-place 갱신)
    total_page_writes = 0
    for _ in range(n_writes):
        key = random.randint(0, key_space - 1)
        _page = key // keys_per_page
        total_page_writes += 1
    return total_page_writes * page_size

def lsm_cost(n_writes, record_size=64, level_multiplier=4):
    logical = n_writes * record_size
    # flush 1회 + 레벨을 타고 내려가며 평균 level_multiplier배 정도 재기록된다고 근사
    amplification = 1 + math.log(max(n_writes // 100, 1), level_multiplier)
    return logical * amplification

bplus_bytes = bplus_tree_cost(N_WRITES, KEY_SPACE)
lsm_bytes = lsm_cost(N_WRITES)

print(f"랜덤 쓰기 {N_WRITES}건, 키 공간 {KEY_SPACE} (지역성 낮음)")
print(f"B+트리 예상 디스크 기록량: {bplus_bytes:,} bytes (페이지 단위 랜덤 쓰기)")
print(f"LSM 예상 디스크 기록량:   {lsm_bytes:,.0f} bytes (순차 flush + 컴팩션 증폭)")
print("→ 랜덤 쓰기 편중 워크로드일수록 LSM이 유리한 이유: 쓰기를 순차화해 증폭을 뒤로 미룬다")

연습

동일한 키-값 워크로드(랜덤 쓰기 위주 vs 범위 읽기 위주)를 LSM 기반 저장소와 B+트리 기반 저장소에 각각 넣고 처리량·디스크 사용량·지연 분포를 비교해 볼 것.

실무 · Verex 연결

예측시장 이벤트 로그와 체결 이력처럼 append 위주에 시간 범위 조회가 많은 데이터는 LSM 계열이 유리하고, 계정별 포지션 조회처럼 랜덤 읽기 중심 데이터는 다른 인덱스 구조가 필요하다.

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

← 71. LSM 트리 튜닝73. 상태 트리 저장 문제 →