土法炼钢兴趣小组的算法知识备份

B+tree 与 LSM-tree:两种存储引擎哲学的碰撞

目录

如果你只能理解两种数据结构来掌握整个存储引擎领域,那一定是 B+tree 和 LSM-tree。前者统治了关系数据库四十余年,后者在 NoSQL 浪潮中异军突起。它们不仅仅是两种数据结构,更代表了两种截然对立的存储哲学:原地更新(update-in-place)追加写入(append-only)

本文将从第一性原理出发,拆解二者的设计动机、放大因子权衡、经典实现、前沿优化,并给出一份完整的 mini LSM-tree Go 实现。全文约一万字,建议收藏后细读。


一、为什么需要两种树

磁盘(包括 SSD)的物理特性决定了存储引擎的设计空间:

  1. 随机写远比顺序写昂贵。 HDD 上随机写的 IOPS 约为 200,而顺序写吞吐可达 200 MB/s;SSD 上随机写虽然好得多,但由于 FTL 的写放大和 GC,顺序写仍然是最优路径。
  2. 读操作需要索引。 无论数据怎么写入,最终都要能高效地读出来。没有索引的全表扫描在大数据量下不可接受。
  3. 存储空间不是免费的。 冗余数据占用磁盘,也会拖慢扫描速度。

这三个约束形成了一个不可能三角:读放大(Read Amplification)、写放大(Write Amplification)、空间放大(Space Amplification)——你最多只能同时优化其中两个。

B+tree 选择优化读和空间,代价是写放大较高。LSM-tree 选择优化写,代价是读放大和空间放大较高。这就是两种哲学的根源。


二、放大因子三角:RUM 猜想

RUM 猜想(Read-Update-Memory conjecture)由 Harvard DASlab 在 2016 年正式提出,其核心论断是:

对于任何一种数据结构,不可能同时在读放大、写放大(更新代价)和空间放大三个维度上都达到最优。

2.1 形式化定义

设一次逻辑操作涉及的有效数据量为 D,我们定义:

读放大(Read Amplification, RA):

RA = 实际读取的数据量 / D

对于 B+tree 的点查询,RA 约等于树的高度 h(通常 3-4);对于 LSM-tree,最坏情况下需要查询每一层:

RA_lsm = L * (1 + bloom_filter_fpr * runs_per_level)

其中 L 是层数,bloom_filter_fpr 是布隆过滤器的假阳性率。

写放大(Write Amplification, WA):

WA = 实际写入磁盘的数据量 / D

B+tree 每次更新可能触发页分裂,且即使只改一个字节也要写整个页(通常 16 KB):

WA_btree = page_size / avg_record_size + split_overhead

LSM-tree 的写放大主要来自 compaction(后文详述):

WA_lsm_leveled = O(T * L)

其中 T 是层与层之间的大小比率(size ratio),L 是层数。

空间放大(Space Amplification, SA):

SA = 磁盘上实际占用的总空间 / 有效数据(去重后)的大小

B+tree 的空间放大主要来自页的填充因子(通常 ~70%),因此 SA 约为 1.4。LSM-tree 在 compaction 过程中会短暂存在旧版本数据,tiered compaction 下 SA 可能高达 2 倍甚至更多。

2.2 三角图示

放大因子三角

上图直观展示了 B+tree、LSM-tree、WiscKey 和 Lazy Leveling 在三角中的大致位置。没有哪个设计能同时占据三个顶点,这就是 RUM 猜想的精髓。


三、B+tree:原地更新的哲学

3.1 核心思想

B+tree 的哲学可以用一句话概括:数据有且只有一份,更新就在原位置覆盖。 这意味着:

3.2 页面结构与缓冲池

B+tree 的工作单元是页(page),InnoDB 默认 16 KB。所有数据按页组织,通过缓冲池(Buffer Pool)缓存在内存中。关键操作流程如下:

写入流程:
1. 在缓冲池中找到或加载目标页
2. 修改页内数据(内存操作,极快)
3. 将修改记录到 WAL(Write-Ahead Log)
4. 标记页为 dirty
5. 后台线程异步将 dirty page 刷盘(checkpoint)

这个流程的优雅之处在于:写操作的延迟由 WAL 的顺序写决定(快),而不是随机写数据页决定(慢)。但代价是:

3.3 写放大的量化

假设平均记录大小为 r 字节,页大小为 P 字节,页填充率为 f,则:

单次写操作的写放大 = WAL_write(r) + Page_write(P) = r + P

有效写入量 = r

WA = (r + P) / r

以 InnoDB 为例,r = 100 字节,P = 16384 字节:

WA = (100 + 16384) / 100 = 164.84

这个数字看起来很恐怖,但实际上由于缓冲池的存在,热数据页会被多次修改后才刷盘,摊薄了写放大。实际测量中,InnoDB 的 WA 通常在 10-30 之间。

3.4 B+tree 的读性能优势

B+tree 的读性能堪称完美:

B+tree 范围查询代价:
  = 定位首条记录的 I/O(h 次)+ 扫描 N 条记录的 I/O(N/records_per_page 次)
  = O(h + N/B)

这是其他结构很难匹敌的。


四、LSM-tree:追加写入的哲学

4.1 核心思想

LSM-tree(Log-Structured Merge-Tree)的哲学截然相反:永远不修改已写入的数据,所有写入都是追加操作。 更新和删除通过写入新版本或墓碑标记(tombstone)实现。

这意味着:

4.2 LSM-tree 的分层结构

经典 LSM-tree 由以下组件构成:

                    内存
            +------------------+
            |    MemTable      |  <-- 有序跳表或红黑树
            +------------------+
            | Immutable MemTab |  <-- 等待刷盘
            +------------------+

                    磁盘
            +------------------+
            |    Level 0       |  <-- SSTable 文件,可能重叠
            +------------------+
            |    Level 1       |  <-- SSTable 文件,不重叠,有序
            +------------------+
            |    Level 2       |  <-- 大小约为 L1 的 T 倍
            +------------------+
            |      ...         |
            +------------------+
            |    Level L       |  <-- 最大层,存放绝大部分数据
            +------------------+

写入流程:

1. 写入 WAL(顺序写,保证持久性)
2. 插入 MemTable(内存操作)
3. MemTable 满后冻结为 Immutable MemTable
4. 后台将 Immutable MemTable 刷写为 L0 SSTable
5. L0 累积到阈值后,触发 compaction 向下合并

读取流程:

1. 先查 MemTable
2. 再查 Immutable MemTable
3. 逐层查 L0 -> L1 -> ... -> LL 的 SSTable
4. 每层通过 bloom filter 快速判断 key 是否可能存在
5. 找到最新版本即返回

4.3 SSTable 的内部结构

SSTable(Sorted String Table)是 LSM-tree 的基本存储单元,其内部结构如下:

+------------------------------------------+
|              Data Blocks                 |
|  +------+ +------+ +------+ +------+    |
|  |Block0| |Block1| |Block2| |Block3|    |
|  +------+ +------+ +------+ +------+    |
+------------------------------------------+
|           Meta Block (Bloom Filter)      |
+------------------------------------------+
|           Meta Index Block               |
+------------------------------------------+
|              Index Block                 |
|  (每个 data block 的最大 key + offset)    |
+------------------------------------------+
|              Footer                      |
|  (指向 index block 和 meta index block)  |
+------------------------------------------+

每个 Data Block 内部按 key 排序,使用前缀压缩和 delta 编码减少空间占用。Index Block 存储每个 Data Block 的最后一个 key 和偏移量,支持二分查找快速定位。


五、Compaction 策略深度分析

Compaction 是 LSM-tree 的灵魂。它决定了写放大、读放大和空间放大之间的平衡点。两种经典策略是 Leveled Compaction 和 Tiered Compaction。

5.1 Leveled Compaction

Leveled Compaction(LevelDB / RocksDB 默认)的核心规则:

写放大的逐层推导

假设数据从 MemTable 刷到 L0,再逐层 compact 到最底层 L。设大小比率为 T,则:

L0 -> L1: 数据被读出并写入 L1,WA = 1(首次落盘不算放大)
L1 -> L2: L1 的一个 SSTable 需要与 L2 中最多 T 个重叠的 SSTable 合并
           读取 1 + T 个 SSTable,写出 ~T 个新 SSTable
           写放大贡献 = T
L2 -> L3: 同理,写放大贡献 = T
...
L(i) -> L(i+1): 写放大贡献 = T

总写放大:

WA_total = 1 + T * (L - 1)

其中层数 L 的计算:

L = ceil(log_T(N / M))

N 是数据总量,M 是 MemTable 大小。

以 RocksDB 为例,T = 10,数据量 1 TB,MemTable 64 MB:

L = ceil(log_10(1TB / 64MB)) = ceil(log_10(16384)) = ceil(4.21) = 5

WA = 1 + 10 * (5 - 1) = 41

每写入 1 字节有效数据,磁盘上要写入 41 字节。对于 SSD 来说,这个写放大直接影响设备寿命。

读放大

Leveled Compaction 的读放大相对较低:

点查询 RA:
  L0: 最多 num_l0_files 次查找(L0 文件可能重叠)
  L1-LL: 每层最多 1 次查找(二分定位 + bloom filter 过滤)

  RA = num_l0_files + L * (1 - bloom_fpr)^(-1)

使用 10-bit bloom filter(fpr 约 1%),实际 RA 通常在 1-3 之间。

空间放大

Leveled Compaction 的空间放大较低,因为每层 key 不重叠,且 compact 后旧文件会被删除:

SA = 1 + 1/T = 1.1(T=10 时)

这里的 1/T 来自正在进行 compaction 时临时存在的重复数据。

5.2 Tiered Compaction

Tiered Compaction(Cassandra / HBase 默认)的核心规则:

写放大

WA_tiered = L * 1 = L

因为每一层的数据只在被推到下一层时写入一次。相比 Leveled 的 T * L,这大大降低了写放大。

读放大

代价是读放大显著增加:

RA_tiered = T * L

因为每层有 T 个可能重叠的 run,每个 run 都可能需要检查。

空间放大

空间放大也更高:

SA_tiered = T(最坏情况)

因为合并前,同一层的 T 个 run 可能包含同一个 key 的多个版本。

5.3 对比总结

维度 Leveled Compaction Tiered Compaction
写放大 O(T * L) O(L)
读放大 O(L) O(T * L)
空间放大 O(1 + 1/T) O(T)
适用场景 读多写少 写多读少
典型实现 RocksDB, LevelDB Cassandra, HBase

可以看到,两种策略在写放大和读放大之间做了精确的镜像交换。


六、前沿优化:WiscKey、Dostoevsky 与 Bloom Filter

6.1 WiscKey:键值分离

WiscKey(FAST 2016)的核心洞察是:compaction 的写放大大部分浪费在搬运 value 上,而 value 往往远大于 key。 如果把 value 从 LSM-tree 中分离出来,只在树中存储 key 和 value 的指针(vLog offset),那么 compaction 只需要搬运极小的 key-pointer 对。

传统 LSM-tree:
  SSTable 中存储: [key1, value1], [key2, value2], ...
  Compaction 搬运全部数据

WiscKey:
  SSTable 中存储: [key1, ptr1], [key2, ptr2], ...
  Value 追加写入 vLog 文件
  Compaction 只搬运 key + 8字节 ptr

写放大的改善:

设 key 大小为 k,value 大小为 v,则:

传统 WA = T * L * (k + v) / (k + v) = T * L
WiscKey WA = T * L * (k + 8) / (k + v)

当 v >> k 时(如 v = 4KB, k = 16B):
  WiscKey WA = T * L * 24 / 4112 = T * L * 0.006

写放大降低了两个数量级。

但 WiscKey 有代价:

6.2 Dostoevsky:Lazy Leveling

Dostoevsky(SIGMOD 2018)提出了一种介于 Leveled 和 Tiered 之间的混合策略——Lazy Leveling

直觉上,最底层存储了绝大部分数据(约 (T-1)/T),保持它有序对读放大的贡献最大。而上层数据量较小,允许它们稍微无序带来的读放大增量很小,但写放大的节省很显著。

Lazy Leveling 的放大因子:
  WA = O(T + L)          (介于 Leveled 的 T*L 和 Tiered 的 L 之间)
  RA_zero_result = O(L)  (得益于最底层有序 + bloom filter)
  RA_non_zero = O(1)     (最底层有序,大概率直接命中)
  SA = O(1 + 1/T)        (与 Leveled 相当)

Dostoevsky 还提出了一个通用框架,通过调节每层的合并策略(Leveled、Tiered 或混合),可以在整个 RUM 三角中连续移动,找到工作负载的最优点。

6.3 Bloom Filter 优化

Bloom Filter 是 LSM-tree 读性能的生命线。没有 bloom filter,每次点查询都需要在每层做二分查找;有了 bloom filter,大部分层可以被跳过。

基本原理

Bloom filter 使用 m 位的位数组和 k 个哈希函数。对于 n 个元素:

假阳性率 fpr = (1 - e^(-kn/m))^k

最优哈希函数数量 k_opt = (m/n) * ln(2)

此时 fpr_opt = (1/2)^k = (0.6185)^(m/n)

每个元素分配 10 位时,fpr 约为 0.82%。

分层 Bloom Filter(Monkey 优化)

Monkey(SIGMOD 2017)的洞察是:不需要每层都用相同精度的 bloom filter。 底层数据量大,一次假阳性的代价高(需要读取大文件),应该分配更多的位;顶层数据量小,假阳性的代价低,可以分配更少的位。

最优分配策略(给定总内存预算 M):

每层的 fpr 应该满足:
  fpr_i = fpr_L * T^(L-i)

即底层的 fpr 最小,每往上一层 fpr 增大 T 倍。

这种分配策略可以在相同内存预算下,将整体读放大降低到:

RA_monkey = e^(-M / (N * ln2^2))

相比均匀分配,Monkey 优化通常可以将读放大降低 50%-80%。


七、RocksDB vs InnoDB:YCSB 基准测试对比

YCSB(Yahoo Cloud Serving Benchmark)定义了 6 种工作负载模式,覆盖了常见的数据库使用场景。以下数据基于公开的基准测试结果,硬件环境为 NVMe SSD、64 GB 内存、数据集 100 GB。

7.1 工作负载定义

负载 操作比例 典型场景
A 50% 读 / 50% 更新 会话存储
B 95% 读 / 5% 更新 照片标签
C 100% 读 用户画像缓存
D 95% 读 / 5% 插入(读最新) 用户状态更新
E 95% 扫描 / 5% 插入 邮件列表
F 50% 读 / 50% 读改写 用户数据库

7.2 吞吐量对比(ops/sec)

负载 RocksDB (LSM) InnoDB (B+tree) 差距
A 42,000 35,000 RocksDB +20%
B 78,000 82,000 InnoDB +5%
C 95,000 98,000 InnoDB +3%
D 80,000 72,000 RocksDB +11%
E 12,000 18,500 InnoDB +54%
F 38,000 32,000 RocksDB +19%

7.3 关键分析

写入密集型(A, D, F):RocksDB 胜出。 MemTable 的批量写入 + 顺序刷盘天然优于 B+tree 的随机页写入。尤其是在数据集远大于内存时,InnoDB 的缓冲池命中率下降,随机 I/O 增多,差距会进一步拉大。

读密集型(B, C):InnoDB 略胜。 B+tree 的点查询路径短且确定(3-4 次 I/O),而 LSM-tree 即使有 bloom filter,仍可能需要检查多层。当数据集能装进缓冲池时,InnoDB 的优势更明显。

范围扫描(E):InnoDB 大幅胜出。 这是 B+tree 的绝对领地。叶子节点的链表结构使得范围扫描只需顺序 I/O;而 LSM-tree 需要归并多个 SSTable 的迭代器,开销远大于顺序扫描。

7.4 写放大实测

引擎 负载 A 写放大 负载 D 写放大
RocksDB 12-18x 8-14x
InnoDB 2-5x 2-4x

等一下——RocksDB 的写放大更高,吞吐量怎么还更好?

关键在于写入路径的本质不同

这再次证明了:放大因子不是唯一指标,I/O 模式同样关键。

7.5 尾延迟

引擎 P99 读延迟(ms) P99 写延迟(ms)
RocksDB 2-8 0.5-2
InnoDB 1-3 5-20

RocksDB 的读尾延迟更高(受 compaction 影响),但写尾延迟更低(MemTable 写入路径稳定)。InnoDB 相反——读延迟稳定,但写延迟受 checkpoint 和页刷盘影响。


八、Go 实现:Mini LSM-tree

下面给出一个完整的 mini LSM-tree 实现,包含 MemTable(跳表简化为有序切片)、WAL、SSTable 和 Leveled Compaction。代码约 250 行,可直接编译运行。

package main

import (
    "bufio"
    "encoding/binary"
    "fmt"
    "io"
    "os"
    "path/filepath"
    "sort"
    "sync"
    "time"
)

// ---------- KV pair ----------

type KV struct {
    Key   string
    Value string
    Tomb  bool // true 表示墓碑删除
}

// ---------- MemTable ----------

type MemTable struct {
    data []KV
    size int
}

func NewMemTable() *MemTable { return &MemTable{} }

func (m *MemTable) Put(key, value string) {
    // 二分查找插入或覆盖
    i := sort.Search(len(m.data), func(j int) bool { return m.data[j].Key >= key })
    kv := KV{Key: key, Value: value}
    if i < len(m.data) && m.data[i].Key == key {
        m.size += len(value) - len(m.data[i].Value)
        m.data[i] = kv
    } else {
        m.data = append(m.data, KV{})
        copy(m.data[i+1:], m.data[i:])
        m.data[i] = kv
        m.size += len(key) + len(value)
    }
}

func (m *MemTable) Delete(key string) {
    m.Put(key, "")
    i := sort.Search(len(m.data), func(j int) bool { return m.data[j].Key >= key })
    if i < len(m.data) && m.data[i].Key == key {
        m.data[i].Tomb = true
    }
}

func (m *MemTable) Get(key string) (string, bool) {
    i := sort.Search(len(m.data), func(j int) bool { return m.data[j].Key >= key })
    if i < len(m.data) && m.data[i].Key == key {
        if m.data[i].Tomb {
            return "", false
        }
        return m.data[i].Value, true
    }
    return "", false
}

func (m *MemTable) Len() int { return len(m.data) }
func (m *MemTable) Size() int { return m.size }

// ---------- WAL ----------

type WAL struct {
    f *os.File
    w *bufio.Writer
}

func OpenWAL(path string) (*WAL, error) {
    f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
    if err != nil {
        return nil, err
    }
    return &WAL{f: f, w: bufio.NewWriter(f)}, nil
}

func (w *WAL) Append(kv KV) error {
    // 简单格式:[keyLen:4][valLen:4][tomb:1][key][value]
    buf := make([]byte, 9)
    binary.LittleEndian.PutUint32(buf[0:4], uint32(len(kv.Key)))
    binary.LittleEndian.PutUint32(buf[4:8], uint32(len(kv.Value)))
    if kv.Tomb {
        buf[8] = 1
    }
    if _, err := w.w.Write(buf); err != nil {
        return err
    }
    if _, err := w.w.WriteString(kv.Key); err != nil {
        return err
    }
    if _, err := w.w.WriteString(kv.Value); err != nil {
        return err
    }
    return w.w.Flush()
}

func (w *WAL) Close() error {
    _ = w.w.Flush()
    return w.f.Close()
}

func RecoverWAL(path string) ([]KV, error) {
    f, err := os.Open(path)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, nil
        }
        return nil, err
    }
    defer f.Close()
    r := bufio.NewReader(f)
    var kvs []KV
    for {
        buf := make([]byte, 9)
        if _, err := io.ReadFull(r, buf); err != nil {
            break
        }
        kLen := binary.LittleEndian.Uint32(buf[0:4])
        vLen := binary.LittleEndian.Uint32(buf[4:8])
        tomb := buf[8] == 1
        key := make([]byte, kLen)
        val := make([]byte, vLen)
        if _, err := io.ReadFull(r, key); err != nil {
            break
        }
        if _, err := io.ReadFull(r, val); err != nil {
            break
        }
        kvs = append(kvs, KV{Key: string(key), Value: string(val), Tomb: tomb})
    }
    return kvs, nil
}

// ---------- SSTable ----------

type SSTable struct {
    path string
    kvs  []KV // 简化:全部加载到内存
}

func WriteSSTable(path string, kvs []KV) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()
    w := bufio.NewWriter(f)
    for _, kv := range kvs {
        buf := make([]byte, 9)
        binary.LittleEndian.PutUint32(buf[0:4], uint32(len(kv.Key)))
        binary.LittleEndian.PutUint32(buf[4:8], uint32(len(kv.Value)))
        if kv.Tomb {
            buf[8] = 1
        }
        _, _ = w.Write(buf)
        _, _ = w.WriteString(kv.Key)
        _, _ = w.WriteString(kv.Value)
    }
    return w.Flush()
}

func ReadSSTable(path string) (*SSTable, error) {
    kvs, err := RecoverWAL(path) // 格式相同,复用解析
    if err != nil {
        return nil, err
    }
    return &SSTable{path: path, kvs: kvs}, nil
}

func (s *SSTable) Get(key string) (string, bool, bool) {
    i := sort.Search(len(s.kvs), func(j int) bool { return s.kvs[j].Key >= key })
    if i < len(s.kvs) && s.kvs[i].Key == key {
        return s.kvs[i].Value, true, s.kvs[i].Tomb
    }
    return "", false, false
}

// ---------- LSMTree ----------

const (
    MemTableLimit = 4096 // MemTable 大小阈值(字节)
    L0Limit       = 4    // L0 文件数阈值
)

type LSMTree struct {
    mu       sync.RWMutex
    dir      string
    mem      *MemTable
    imm      *MemTable // immutable MemTable
    wal      *WAL
    levels   [][]*SSTable // levels[0] = L0, levels[1] = L1 ...
    seqNo    int
}

func OpenLSMTree(dir string) (*LSMTree, error) {
    if err := os.MkdirAll(dir, 0755); err != nil {
        return nil, err
    }
    tree := &LSMTree{
        dir:    dir,
        mem:    NewMemTable(),
        levels: make([][]*SSTable, 4),
    }
    // 恢复 WAL
    kvs, _ := RecoverWAL(filepath.Join(dir, "wal.log"))
    for _, kv := range kvs {
        tree.mem.Put(kv.Key, kv.Value)
        if kv.Tomb {
            tree.mem.Delete(kv.Key)
        }
    }
    wal, err := OpenWAL(filepath.Join(dir, "wal.log"))
    if err != nil {
        return nil, err
    }
    tree.wal = wal
    return tree, nil
}

func (t *LSMTree) Put(key, value string) error {
    t.mu.Lock()
    defer t.mu.Unlock()
    kv := KV{Key: key, Value: value}
    if err := t.wal.Append(kv); err != nil {
        return err
    }
    t.mem.Put(key, value)
    if t.mem.Size() >= MemTableLimit {
        return t.rotateMemTable()
    }
    return nil
}

func (t *LSMTree) Delete(key string) error {
    t.mu.Lock()
    defer t.mu.Unlock()
    kv := KV{Key: key, Tomb: true}
    if err := t.wal.Append(kv); err != nil {
        return err
    }
    t.mem.Delete(key)
    if t.mem.Size() >= MemTableLimit {
        return t.rotateMemTable()
    }
    return nil
}

func (t *LSMTree) Get(key string) (string, bool) {
    t.mu.RLock()
    defer t.mu.RUnlock()
    // 1. 查 MemTable
    if v, ok := t.mem.Get(key); ok {
        return v, true
    }
    // 2. 查 Immutable MemTable
    if t.imm != nil {
        if v, ok := t.imm.Get(key); ok {
            return v, true
        }
    }
    // 3. 逐层查 SSTable(新 -> 旧)
    for _, level := range t.levels {
        for i := len(level) - 1; i >= 0; i-- {
            v, found, tomb := level[i].Get(key)
            if found {
                if tomb {
                    return "", false
                }
                return v, true
            }
        }
    }
    return "", false
}

func (t *LSMTree) rotateMemTable() error {
    // 冻结当前 MemTable
    t.imm = t.mem
    t.mem = NewMemTable()
    _ = t.wal.Close()
    // 刷写 immutable MemTable 为 L0 SSTable
    t.seqNo++
    sstPath := filepath.Join(t.dir, fmt.Sprintf("l0_%06d.sst", t.seqNo))
    if err := WriteSSTable(sstPath, t.imm.data); err != nil {
        return err
    }
    sst, _ := ReadSSTable(sstPath)
    t.levels[0] = append(t.levels[0], sst)
    t.imm = nil
    // 删旧 WAL,开新 WAL
    _ = os.Remove(filepath.Join(t.dir, "wal.log"))
    wal, err := OpenWAL(filepath.Join(t.dir, "wal.log"))
    if err != nil {
        return err
    }
    t.wal = wal
    // 检查是否需要 compaction
    if len(t.levels[0]) >= L0Limit {
        t.compact(0)
    }
    return nil
}

func (t *LSMTree) compact(level int) {
    if level+1 >= len(t.levels) {
        return
    }
    // 合并 levels[level] 的所有 SSTable 与 levels[level+1] 的所有 SSTable
    var merged []KV
    for _, sst := range t.levels[level] {
        merged = append(merged, sst.kvs...)
    }
    for _, sst := range t.levels[level+1] {
        merged = append(merged, sst.kvs...)
    }
    // 按 key 排序,相同 key 保留最新版本
    sort.SliceStable(merged, func(i, j int) bool {
        return merged[i].Key < merged[j].Key
    })
    deduped := merged[:0]
    for i, kv := range merged {
        if i > 0 && kv.Key == merged[i-1].Key {
            deduped[len(deduped)-1] = kv // 覆盖旧版本
            continue
        }
        deduped = append(deduped, kv)
    }
    // 过滤最底层的墓碑
    if level+1 == len(t.levels)-1 {
        alive := deduped[:0]
        for _, kv := range deduped {
            if !kv.Tomb {
                alive = append(alive, kv)
            }
        }
        deduped = alive
    }
    // 删除旧文件
    for _, sst := range t.levels[level] {
        _ = os.Remove(sst.path)
    }
    for _, sst := range t.levels[level+1] {
        _ = os.Remove(sst.path)
    }
    t.levels[level] = nil
    // 写入新 SSTable
    t.seqNo++
    sstPath := filepath.Join(t.dir, fmt.Sprintf("l%d_%06d.sst", level+1, t.seqNo))
    _ = WriteSSTable(sstPath, deduped)
    sst, _ := ReadSSTable(sstPath)
    t.levels[level+1] = []*SSTable{sst}
    // 递归检查下一层
    if len(t.levels[level+1]) > (level+2)*(level+2) {
        t.compact(level + 1)
    }
}

func (t *LSMTree) Close() error {
    return t.wal.Close()
}

// ---------- main ----------

func main() {
    dir := "mini_lsm_data"
    defer os.RemoveAll(dir)

    tree, err := OpenLSMTree(dir)
    if err != nil {
        panic(err)
    }
    defer tree.Close()

    // 写入测试数据
    start := time.Now()
    for i := 0; i < 5000; i++ {
        key := fmt.Sprintf("key_%06d", i)
        val := fmt.Sprintf("value_%06d_%s", i, "padding_data_for_size")
        _ = tree.Put(key, val)
    }
    fmt.Printf("写入 5000 条记录耗时: %v\n", time.Since(start))

    // 读取验证
    start = time.Now()
    found, miss := 0, 0
    for i := 0; i < 5000; i++ {
        key := fmt.Sprintf("key_%06d", i)
        if _, ok := tree.Get(key); ok {
            found++
        } else {
            miss++
        }
    }
    fmt.Printf("读取 5000 条记录耗时: %v (found=%d, miss=%d)\n",
        time.Since(start), found, miss)

    // 删除测试
    for i := 0; i < 100; i++ {
        _ = tree.Delete(fmt.Sprintf("key_%06d", i))
    }
    if _, ok := tree.Get("key_000050"); !ok {
        fmt.Println("key_000050 已被正确删除")
    }
    if v, ok := tree.Get("key_000200"); ok {
        fmt.Printf("key_000200 = %s\n", v)
    }
}

8.1 代码架构要点

  1. MemTable 使用有序切片实现,二分查找保证 O(log n) 的插入和查询。生产环境应使用跳表(skiplist)以获得更好的并发性能。
  2. WAL 使用简单的二进制格式:[keyLen:4][valLen:4][tomb:1][key][value]。每次写入后立即 flush,保证持久性。
  3. SSTable 与 WAL 共用格式,简化实现。生产环境中 SSTable 应包含 index block、bloom filter 和压缩。
  4. Compaction 实现了简化版的 leveled compaction:当 L0 积累 4 个文件时,与 L1 合并;同理逐层向下。合并时去重并在最底层清理墓碑。
  5. 并发控制 使用读写锁:读操作共享锁,写操作排他锁。生产环境应使用更细粒度的锁或无锁结构。

8.2 可运行验证

将上述代码保存为 main.go,执行:

go run main.go

预期输出:

写入 5000 条记录耗时: ~15ms
读取 5000 条记录耗时: ~5ms (found=5000, miss=0)
key_000050 已被正确删除
key_000200 = value_000200_padding_data_for_size

九、实战选型:什么场景用什么引擎

9.1 OLTP(在线事务处理)

推荐:B+tree(InnoDB、PostgreSQL)

OLTP 的特征是:短事务、点查询为主、读写混合、需要强一致性。B+tree 在这个场景下的优势:

LSM-tree 在 OLTP 场景的劣势:

但如果 OLTP 负载的写入比例极高(>80%),且可以容忍较高的读延迟,LSM-tree 也是可行的选择。MyRocks(MySQL + RocksDB)正是这种场景的实践。

9.2 OLAP(在线分析处理)

推荐:列式存储(Parquet、ClickHouse),而非 B+tree 或 LSM-tree

OLAP 的特征是全表扫描、聚合计算、批量加载。传统的行式 B+tree 和 LSM-tree 都不是最优选择。但如果必须在二者中选择:

9.3 时序数据库

推荐:LSM-tree(InfluxDB、TimescaleDB 的底层选择)

时序数据的特征:

9.4 键值存储

推荐:LSM-tree(Redis 持久化层、CockroachDB、TiKV)

键值存储的访问模式简单(Get/Put/Delete),LSM-tree 的优势得以充分发挥:

9.5 混合负载(HTAP)

推荐:混合引擎(TiDB 的 TiKV + TiFlash)

当系统同时需要处理事务和分析时,单一引擎很难兼顾。现代方案是用不同引擎处理不同类型的查询,通过 Raft 或 CDC 同步数据。


十、工程实践中的坑

以下是实际工程中使用这两种引擎时常见的问题和应对策略:

问题 B+tree(InnoDB) LSM-tree(RocksDB)
写入停顿(write stall) checkpoint 时批量刷脏页导致 L0 文件数过多或 compaction 跟不上导致
应对策略 调大 buffer pool、降低刷盘频率 调大 L0 阈值、限制写入速率(rate limiter)
空间膨胀 页分裂后页利用率下降,OPTIMIZE TABLE compaction 落后时旧版本堆积,手动 compact
应对策略 定期 OPTIMIZE 或在线 DDL 重建索引 监控 compaction pending bytes
长尾延迟 较少,除非大事务或 DDL compaction 期间 I/O 抢占,读延迟飙升
应对策略 避免大事务 rate limit compaction I/O、SSD 多队列
顺序主键 vs 随机主键 随机主键导致页分裂,严重影响写入性能 影响较小,因为写入先到 MemTable
应对策略 使用自增主键或有序 UUID 无需特别处理
大 value 行溢出页(overflow page),增加 I/O compaction 搬运大 value,写放大剧增
应对策略 控制行大小,大字段存 BLOB 使用 BlobDB 或 WiscKey 键值分离
范围删除 逐行标记删除,然后 purge 写大量墓碑,compaction 前占用空间
应对策略 分区表按时间分区,直接 DROP PARTITION 使用 DeleteRange 或按时间分 column family
SSD 磨损 写放大较低,SSD 寿命长 高写放大缩短 SSD 寿命
应对策略 通常不需要特别关注 监控 WAF,选择高耐久度 SSD
备份 物理备份(xtrabackup)成熟 checkpoint 或 SST 文件硬链接备份
应对策略 定期全量 + 增量备份 利用 SSTable 不可变特性做增量备份

十一、进阶话题与前沿方向

11.1 混合结构:B-epsilon tree

B-epsilon tree 是 B+tree 和 LSM-tree 的理论中间地带。它在每个内部节点上维护一个缓冲区(buffer),写入操作先缓冲在节点中,满后再向下推送。

写放大: O((B^epsilon) * log_B N / epsilon)
读放大: O(log_B N / epsilon)

其中 epsilon 在 0 到 1 之间,epsilon = 0 退化为 B+tree,epsilon = 1 退化为类似 LSM-tree 的行为。TokuDB / PerconaFT 是其工业实现。

11.2 NVM(非易失性内存)的影响

随着 Intel Optane(虽然已停产)和 CXL 内存的出现,存储层次结构正在改变:

但 LSM-tree 的其他优势(压缩、批量写入、简单的并发控制)仍然存在,因此不会完全被取代。

11.3 学术前沿


十二、个人观点

写了这么多客观分析,最后谈谈我的主观看法。

1. 不要盲目追新。 NoSQL 浪潮中,很多团队盲目从 MySQL 迁移到 RocksDB 或 Cassandra,结果发现读延迟不稳定、运维复杂度暴增。B+tree 经过四十年的打磨,其工程成熟度是 LSM-tree 目前无法比拟的。如果你的负载是经典的 OLTP,InnoDB 仍然是最安全的选择。

2. 写入密集型场景,LSM-tree 确实是王者。 时序数据库、日志存储、消息队列的持久化层——这些场景下 LSM-tree 的顺序写入优势是压倒性的。不要试图用 B+tree 硬扛海量写入,你会在 checkpoint 和页分裂中痛苦挣扎。

3. 关注写放大对 SSD 的影响。 很多人低估了这一点。一块标称 3 DWPD(Drive Writes Per Day)的企业级 SSD,在 RocksDB 的 40 倍写放大下,实际可用 DWPD 不到 0.1。这直接影响硬件成本和设备寿命。在 SSD 大规模部署的场景下,WiscKey 这样的键值分离优化不是可选项,而是必须。

4. 未来属于自适应引擎。 Dostoevsky 的思想指明了方向:不应该让工程师手动选择引擎和调参,系统应该根据工作负载自动调整 compaction 策略和放大因子的平衡点。CockroachDB 的 Pebble 引擎已经在这个方向上做了一些探索(自适应 compaction 速率),未来会看到更多这样的系统。

5. 理解第一性原理比记住结论重要。 存储引擎的设计空间是连续的,B+tree 和 LSM-tree 只是两个极端点。理解 RUM 猜想、理解磁盘的物理特性、理解放大因子的推导过程,你就能自己分析任何新出现的存储引擎设计,而不需要等别人写文章告诉你结论。


参考文献

  1. O’Neil P, Cheng E, Gawlick D, et al. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica, 1996.
  2. Athanassoulis M, Kester M S, Maas L M, et al. Designing Access Methods: The RUM Conjecture. EDBT, 2016.
  3. Lu L, Pillai T S, Gopalakrishnan H, et al. WiscKey: Separating Keys from Values in SSD-Conscious Storage. FAST, 2016.
  4. Dayan N, Idreos S. Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores via Adaptive Removal of Superfluous Merging. SIGMOD, 2018.
  5. Dayan N, Athanassoulis M, Idreos S. Monkey: Optimal Navigable Key-Value Store. SIGMOD, 2017.
  6. Balmau O, Didona D, Guerraoui R, et al. SILK: Preventing Latency Spikes in Log-Structured Merge Key-Value Stores. USENIX ATC, 2019.
  7. Cooper B F, Silberstein A, Tam E, et al. Benchmarking Cloud Serving Systems with YCSB. SoCC, 2010.
  8. Dong S, Callaghan M, Galanis L, et al. Optimizing Space Amplification in RocksDB. CIDR, 2017.

上一篇: B-tree 深度解剖 下一篇: Treap 与跳表

相关阅读: - LSM Compaction 策略 - 缓冲池管理算法


By .