00 · 设计推演:从零造一个存储引擎

“这一节没有一个’正确’的最终代码。有的是八次失败,和每一次失败逼出来的设计决策。“


开场:我要你忘记所有已知的方案

老陈:给你一个需求,最朴素的那种。

需求:做一个 KV 存储。支持 Put(key, value) 和 Get(key)。数据要持久化——进程重启后数据还在。

现在,不许用 MySQL、不许用 BoltDB、不许用任何现成的东西。你来设计。

小林:……那先想最简单的。内存里放个 map?

老陈:好。写下来。 然后我问你:这个设计有什么问题?

小林:进程退出数据就没了。

老陈:对。这就是第一次失败。我们开始,一步一步改。


v0:内存 map

type DB struct {
	data map[string]string
}
 
func (db *DB) Put(k, v string) { db.data[k] = v }
func (db *DB) Get(k string) (string, bool) {
	v, ok := db.data[k]
	return v, ok
}

这一版能工作。 快、简单、五十行以内。

失败原因:进程退出,数据全丢。

老陈:注意,这是最重要的一步——先写一个”能工作但很差”的版本。

很多人跳过这一步,直接想”最终方案”,结果是想出来的东西过度设计。 先有 v0,你才知道 v1 要改什么。


v1:内存 map + 定时全量落盘

思路:内存里维护 map,每隔一段时间把整个 map 序列化写到磁盘。

func (db *DB) Put(k, v string) {
	db.data[k] = v
}
 
// 每隔 60 秒,或者每 1000 次写入
func (db *DB) snapshot() error {
	// 全量序列化
	buf, _ := json.Marshal(db.data)
	return os.WriteFile("dump.db", buf, 0644)
}

改进了:数据能存下来了。

但遇到四个问题:

#问题后果
1上次快照之后的写入会丢60 秒的快照间隔 = 最多丢 60 秒数据
2快照期间怎么办?一边写文件一边改 map → 数据不一致;加锁 → 服务停顿
3数据量 > 内存就完蛋100GB 数据需要 100GB 内存
4每次全量写入改 1 个字节要重写整个文件(写放大 N 倍)

小林:那我缩短快照间隔?每秒一次?

老陈:问题 4 会更严重。 每秒重写全量文件,磁盘扛不住。

小林:那……只写变化的部分?

老陈:对。这是我们下一个版本的唯一改动。但你要想清楚:“只写变化的部分”意味着什么文件格式?


v2:追加日志(Append-Only Log)

核心改动:不重写全量,只把”这次做了什么”追加到文件末尾。

type DB struct {
	data   map[string][]byte   // 内存索引:key → 文件偏移
	file   *os.File
	offset int64
}
 
func (db *DB) Put(k, v string) error {
	// 1. 序列化这条记录
	rec := encode(k, v)
 
	// 2. ★ 追加到文件末尾(顺序写!)
	if _, err := db.file.Write(rec); err != nil {
		return err
	}
 
	// 3. 更新内存索引
	db.data[k] = db.offset
	db.offset += int64(len(rec))
	return nil
}
 
func (db *DB) Get(k string) (string, bool) {
	offset, ok := db.data[k]
	if !ok {
		return "", false
	}
	// 从偏移处读一条记录
	rec := readAt(db.file, offset)
	return decode(rec)
}

这是一个巨大的进步:

写入: 从"随机写全量"变成"顺序追加"
      HDD 上快 100 倍,SSD 上快 10 倍

这是 Bitcask(Riak 的存储引擎)的核心思想

但遇到四个新问题:

#问题后果
1文件无限增长反复 Put 同一个 key,文件里全是旧版本
2空间浪费一个 key 更新 1000 次,磁盘上就有 1000 条记录
3启动慢重启要扫描整个文件重建索引(100GB 文件要几分钟)
4删除怎么做?直接从 map 删?重启后又回来了

问题 4 的解法:墓碑(Tombstone)——删除也是追加一条记录,只是标记为”已删除”。

func (db *DB) Delete(k string) error {
	rec := encodeTombstone(k)
	db.file.Write(rec)
	delete(db.data, k)   // 内存里删掉
	return nil
}

老陈:注意”墓碑”这个思路——它不是”把东西拿走”,而是”记下一笔’拿走了’”。

这是日志结构(log-structured)的核心思维:所有操作都是追加,包括删除。

这个思路后面会反复出现:LSM 的删除、MVCC 的旧版本、Raft 的日志压缩、数据库的 MVCC vacuum。


v3:分段 + 合并(Compaction)

问题 1、2 的解法:把日志切成段,段满了就冻结,后台合并。

data.log  (当前写入的段)
data.001  (已冻结)
data.002
data.003
...

后台任务: 把多个冻结段合并成一个,每个 key 只保留最新的值
func (db *DB) compact(segments []string) error {
	// 1. 顺序读所有段,后面的段优先级更高
	merged := make(map[string][]byte)
	for _, seg := range segments {   // 从旧到新
		for rec := range readSegment(seg) {
			if rec.IsTombstone {
				delete(merged, rec.Key)
			} else {
				merged[rec.Key] = rec.Value
			}
		}
	}
 
	// 2. 写一个新的段
	// 3. 删掉旧段
	return nil
}

效果:空间被回收了。

但还有两个问题:

#问题后果
1启动还是要重建索引要扫描所有段
2查询要查多个段段越多越慢,从新到旧逐个找

小林:查询要逐段找,那如果 key 不存在,就要把所有段都翻一遍?

老陈:对。这是最坏情况。 你想想怎么优化。

小林:……给每个段建个索引?

老陈:好主意。但先想:索引建在什么粒度上?每个 key 都建?


v4:段内有序 + 稀疏索引(SSTable 雏形)

关键改动:写入段的时候,让段内的 key 有序。

怎么做?内存里先排序,再一次性写入。

func (db *DB) flushMemTable() error {
	// 1. 把内存里的 map 按 key 排序
	keys := sortedKeys(db.memtable)
 
	// 2. 顺序写入新段
	f, _ := os.Create(nextSegmentName())
	for _, k := range keys {
		write(f, encode(k, db.memtable[k]))
	}
 
	// 3. ★ 同时写稀疏索引
	//    每 N 条记录记一个 (key, offset)
	return nil
}

这个改动带来三个好处,都是”免费”的:

好处 1 · 合并可以用归并排序

两个有序段合并 = O(n+m),不需要在内存里建完整 map
→ 可以合并远大于内存的段

好处 2 · 可以建稀疏索引

段内有序 → 查找 "user:9527" 时:
  1. 查稀疏索引(在内存里,二分查找)
     找到最后一个 <= "user:9527" 的索引项
     比如 "user:9000" → offset 4096
  2. 从 offset 4096 开始顺序扫描
     ★ 最多扫 N 条(N = 稀疏索引的间隔)

★ 不需要把每个 key 都放进索引
  1000 个 key 的段,可能只要 10 个索引项

好处 3 · 范围查询变快

查询 "user:1000" 到 "user:2000":
  1. 定位起点(二分)
  2. ★ 顺序扫描到终点
  
  不需要查每个 key

这就是 SSTable(Sorted String Table)。 Google Bigtable 论文里的核心数据结构。

还差什么?

#问题现状
1不存在的 key 查询慢要二分 + 扫描,每个段都要试
2段多了查询还是慢10 个段 = 10 次查找

小林:段多了慢,那就少点段?

老陈:但段少了,合并就频繁,写放大就大。 你又遇到一对矛盾。

小林:……那先解决问题 1?不存在的 key 能不能快速排除?

老陈:对。想想:你只需要一个”可能存在 / 一定不存在”的判断。


v5:布隆过滤器 + 分层(LSM-Tree 成型)

问题 1 的解法:每个段配一个布隆过滤器。

func (db *DB) Get(k string) (string, bool) {
	for i := len(db.segments) - 1; i >= 0; i-- {   // 从新到旧
		seg := db.segments[i]
 
		// ★ 布隆过滤器快速排除:0 次磁盘 IO
		if !seg.bloom.MayContain([]byte(k)) {
			continue
		}
 
		// 可能存在,真正去查
		if v, ok := seg.get(k); ok {
			return v, true
		}
	}
	return "", false
}

效果:不存在的 key,几乎零 IO 就能返回。

问题 2 的解法:分层(Leveled)

L0: 刚从内存刷下来的段,key 范围可能重叠
L1: 合并后的段,key 范围不重叠
L2: 更大
...

★ 好处:L1 及以下,一个 key 只可能在某一个段里
  → 查询从 O(段数) 降到 O(层数)

到这里,你已经重新发明了 LSM-Tree。

日志追加 → 分段 → 排序 → 稀疏索引 → 布隆过滤器 → 分层合并
   v2       v3      v4       v4          v5          v5

这就是 LevelDB / RocksDB / Cassandra / InfluxDB 的核心
★ 也是 Elasticsearch 的 Lucene 段机制的本质

但 LSM 有两个绕不开的问题:

① 写放大(Write Amplification)
   一条记录可能被 compaction 重写 10-30 次
   → SSD 寿命损耗,磁盘带宽浪费

② 读放大(Read Amplification)
   一次查询可能要查多个层、多个段

老陈:现在,我要你换个思路。

LSM 的所有问题,根子在哪?

小林:……段不可变?

老陈:对。段不可变 → 更新必须追加新版本 → 必须合并 → 写放大。

那如果允许”原地更新”呢?


v6:换个思路——原地更新(B+Tree)

思路转变:不追加了,直接在原地修改。

但怎么组织? 如果所有记录乱序堆在文件里,查询要全表扫描。

第一次尝试:排序数组 + 二分

文件: [key1][key2][key3]...[keyN]  (有序)

查询: 二分,O(log N) 次磁盘 IO
插入: ★ 要移动后面所有记录!O(N)

失败:插入太慢。

第二次尝试:分页

把文件切成固定大小的页(4KB)
每页存若干有序记录
维护一个"页目录":每页的最小 key → 页号

查询: 二分页目录 → 定位页 → 页内二分
插入: ★ 只在页内移动,O(页大小)

好多了。但还有问题:

如果页满了怎么办?
  → 分裂成两个页
  → 页目录要更新
  → 页目录也会满
  → 页目录也要分裂
  → ★ 递归下去

这个结构就是 B+Tree

第三次尝试:B+Tree

        ┌──────────────┐
        │  内部节点      │  只存 key 和子页号
        │ [100 | 500]   │
        └───┬────┬──┬──┘
       ┌────┘    │  └────┐
       ▼         ▼       ▼
   ┌──────┐ ┌──────┐ ┌──────┐
   │叶子页 │ │叶子页 │ │叶子页 │   存真正的数据
   │<100  │ │100-500│ │>500  │   ★ 叶子之间用链表相连
   └──────┘ └──────┘ └──────┘
       └────────┴────────┘
          范围查询走链表

为什么数据库用 B+Tree 而不是 B-Tree 或红黑树? 这是三个约束逼出来的:

约束 1: 数据在磁盘上,IO 单位是页(4KB/16KB)
   → 节点大小 = 页大小(一次 IO 读一个完整节点)

约束 2: 要尽量减少 IO 次数
   → 节点要能存尽可能多的 key → 树要矮
   → ★ 内部节点只存 key,不存数据(B+Tree 的关键!)

约束 3: 要支持范围查询
   → 叶子节点用链表相连(B+Tree 独有)

算一下:
   页 16KB,key 8 字节 + 指针 6 字节 = 14 字节
   → 每节点 1170 个 key
   → 树高 3 能存 1170³ ≈ 16 亿条记录
   → ★ 16 亿数据,最多 3-4 次磁盘 IO

B+Tree 解决了 LSM 的写放大问题(原地更新,不需要 compaction)。

但引入了新问题:

#问题说明
1随机写更新一个 16KB 页里的 100 字节 → 要重写整个 16KB 页
2页分裂代价高要移动数据、更新父节点,可能级联
3★ 崩溃时页可能写了一半这是致命的

小林:问题 3……写了一半会怎样?

老陈:比你想的严重。不只是”这条数据丢了”。

小林:……还会怎样?

老陈:想想插入导致页分裂要几步。

小林:分配新页、移动一半数据、更新父节点、更新叶子链表……四步。

老陈:如果执行到第二步断电呢?

小林:……数据复制了一半?

老陈:不止这四个后果: ① 数据丢失(新页内容只在 page cache 里) ② 结构损坏(原页改了一半,新页没写完) ③ 空间泄漏(新页已分配但无内容,永久浪费) ④ 链表断裂(next 指针只更新了一半)

小林:那怎么办?

老陈:这就是 WAL 的由来——你下一节会学到。但先想:为什么追加日志的方案(v2-v5)不会有这个问题?

小林:……因为它是追加,不会破坏已有数据?

老陈:对!这就是 LSM 相对 B+Tree 的一个根本优势——不可变性带来的崩溃安全。

记住这个对比:

  • 不可变结构(LSM):崩溃安全,但有写放大
  • 可变结构(B+Tree):无写放大,但需要额外的崩溃恢复机制

这是一个贯穿整个存储领域的根本权衡。


v7:B+Tree + WAL(MySQL InnoDB 的做法)

崩溃问题的解法:在修改数据页之前,先把”要做什么”写进日志。

func (db *DB) Put(k, v string) error {
	// 1. ★ 先写 WAL(顺序追加,很快)
	db.wal.Append(&LogRecord{
		Type:     LogUpdate,
		Key:      k,
		OldValue: oldValue,   // 用于回滚(undo)
		NewValue: v,          // 用于重做(redo)
	})
	db.wal.Sync()            // fsync
 
	// 2. 再修改内存里的页(此时还没落盘)
	page := db.findPage(k)
	page.Put(k, v)
	page.MarkDirty()
 
	// 3. 返回成功(页会在后台刷盘)
	return nil
}

崩溃恢复:重启时读 WAL,把没落盘的修改重做一遍(redo),把没提交的事务回滚(undo)。

这就是 ARIES 算法,现代数据库的恢复基石。

而且 WAL 不只是为了恢复——它让写入变快了:

不用 WAL: 每次写入 → 随机读写数据页 (100μs) + fsync (500μs)
用 WAL:   每次写入 → 顺序追加日志 (10μs) + 批量 fsync

★ 快几十倍,因为把"随机"变成了"顺序"

老陈:看到这里,你有没有发现一个有趣的事情?

B+Tree 为了解决崩溃问题,又引入了日志——也就是说,它又变回了”日志 + 索引”的结构。

LSM 是”日志就是数据”,B+Tree 是”数据 + 辅助日志”。

两者殊途同归。


v8:你现在能做选择了

两条路的完整对比

LSM-TreeB+Tree
写入顺序追加,极快随机写,慢 10-100 倍(HDD)
写入放大10-30 倍(compaction)页级(16KB/次)
点查询要查多层多段3-4 次 IO
范围查询要合并多层★ 叶子链表,极快
崩溃安全★ 天然安全(段不可变)需要 WAL
空间放大1.1-2 倍页内碎片
延迟稳定性★ compaction 有尖刺稳定
事务支持复杂★ 天然(原地更新 + 锁)
代表RocksDB、Cassandra、HBase、LuceneMySQL、PostgreSQL、SQLite

怎么选

选 LSM 的信号:
  · 写多读少(写:读 > 1:1)
  · 数据量巨大(TB 级)
  · 能接受压缩(SSTable 压缩率高)
  · 不需要强事务

选 B+Tree 的信号:
  · 读多写少
  · 需要事务
  · 延迟要求稳定(不能有 compaction 尖刺)
  · 范围查询多

★ 这一节真正的收获

不是”LSM 长这样”或”B+Tree 长那样”。而是这条推导链:

需求: 持久化 KV
  │
  ├─ v0 内存 map
  │    失败: 数据丢失
  ↓
  ├─ v1 全量落盘
  │    失败: 写放大、数据量受限
  ↓
  ├─ v2 追加日志
  │    失败: 文件无限增长、启动慢
  ↓
  ├─ v3 分段合并
  │    失败: 查询要查多段、启动慢
  ↓
  ├─ v4 段内有序 + 稀疏索引     ← 关键洞察:有序带来三个免费好处
  │    失败: 段多仍慢、不存在 key 慢
  ↓
  ├─ v5 布隆过滤器 + 分层        ← LSM-Tree 成型
  │    失败: 写放大、读放大
  ↓
  ├─ v6 转向原地更新 → B+Tree    ← 关键洞察:段不可变是写放大的根
  │    失败: 随机写、崩溃损坏
  ↓
  └─ v7 B+Tree + WAL             ← 又回到日志
       剩下: 写放大 vs 读放大的权衡

每一个箭头上,都写着”上一个方案遇到了什么具体问题”。

这就是设计的过程。不是”我知道该用 B+Tree”,而是”我试过了别的,它们在这些地方不行,所以我需要这样一个结构”。

三个可以迁移的思维模式

① 先做能工作的最差版本

v0 很烂,但没有 v0,你不知道 v1 该改什么。跳过 v0 直接设计 v7,你一定会过度设计。

② 每个失败都要精确归因

❌ "v1 性能不好"
✅ "v1 有四个问题:
    ① 快照间隔内的数据会丢
    ② 快照期间一致性/可用性二选一
    ③ 数据量超内存就崩
    ④ 每次全量写入导致 N 倍写放大"

★ 不精确归因,就找不到下一步的方向

③ 注意”免费的”好处

v4 把段内改成有序,一次性得到三个好处(归并合并、稀疏索引、范围查询)。

这种”一个改动带来多个收益”的点,是设计中的杠杆。 发现它,你就找到了关键决策。


现在,轮到你了

练习:用同样的推导方式,设计一个分布式的 KV 存储。

起点(v0):一台机器上跑我们刚做的存储引擎。

v0: 单机存储引擎
     失败: 机器挂了服务不可用;容量受限

v1: ???
     失败: ???

v2: ???
     ...

提示(不要用,先自己想):

  • 数据怎么分到多台机器?(想想第 5 章的一致性哈希)
  • 副本怎么保持一致?(想想 Raft)
  • 一台机器挂了怎么办?
  • 网络分区了怎么办?

老陈:第 5 章的 Raft,就是这条推导链的下一环。但这次,我希望你是”自己推出来”,而不是”记住它”。


下一节

01 · B+Tree 存储引擎:从内存到磁盘

这一节我们走完了”为什么”,下一节开始走”怎么做”——把 v7 的方案真正实现出来。

带着这个问题去读:v2-v7 的每一个失败,在最终实现里分别由哪个机制解决?