想象一下,你是一家快递公司的数据管家,每天要记录几百万个包裹的状态更新:“已揽收”、“运输中”、“已签收”……
老板时不时会问:“包裹 PX123456 现在到哪儿了?”
你怎么存这些数据,才能写得飞快、查得也飞快?
今天,我们就来聊聊数据库存储引擎的两种“收纳哲学”——LSM 树(Log-Structured Merge-Tree) 和 B 树(B-Tree)。
它们一个像“先堆再整”的懒人仓库,一个像“随手归档”的强迫症货架。
一、业务场景:快递追踪日志
我们假设每条记录长这样:
包裹编号 | 状态 | 时间戳
PX123456 | 已揽收 | 2023-10-01 09:00:00
PX123456 | 运输中 | 2023-10-02 14:30:00
PX123456 | 已签收 | 2023-10-04 10:15:00
特点:
- 写入极频繁(每次状态变更都要记)
- 读通常只查最新状态(“这个包裹现在在哪儿?”)
- 偶尔也要查历史(“这个包裹经历过哪些状态?”)
二、LSM树:懒人收纳法,先堆再整
1. 核心思想:日志即存储
LSM 树的做法很像我们平时记流水账:来一条写一条,绝不回头改。
新到一条“PX123456 已签收”?直接追加到日志末尾。
2. 写入流程:从 Memtable 到 SSTable
-
Memtable(内存表):新数据先放进一个有序内存结构(比如跳表)。
就像快递员刚回仓库,把今天包裹状态更新先记在小本本上。内存小本本: PX123456 -> “已签收” (2023-10-04 10:15:00) PX654321 -> “运输中” (2023-10-04 11:20:00) -
刷盘(Flush):小本本写满了(比如到 10MB),就按包裹编号排序后写入磁盘,成为一个 SSTable(Sorted String Table) 文件。
[图示:Memtable 刷盘到 SSTable] 内存 Memtable: ┌─────────────┬─────────────────────┐ │ PX123456 │ 已签收 (10:15:00) │ │ PX654321 │ 运输中 (11:20:00) │ └─────────────┴─────────────────────┘ ↓ 刷盘(按键排序) 磁盘 SSTable: ┌──────────────────────────────────┐ │ 块1: PX123456,已签收,10:15:00 │ │ 块2: PX654321,运输中,11:20:00 │ │ ... │ └──────────────────────────────────┘这个文件就像一册装订好的日志,按键排序,且不可变。
-
分层合并(Compaction):磁盘上会有很多 SSTable 文件。后台进程会不断把旧文件合并成新文件,并丢弃重复的旧数据。
例如,我们有两个 SSTable:
[图示:两个 SSTable 合并] SSTable1(较旧): ┌──────────────────────────────┐ │ PX123456,已揽收,09:00:00 │ │ PX123456,运输中,14:30:00 │ ← 旧状态 └──────────────────────────────┘ SSTable2(较新): ┌──────────────────────────────┐ │ PX123456,已签收,10:15:00 │ ← 最新状态 │ PX654321,运输中,11:20:00 │ └──────────────────────────────┘ ↓ 合并(保留最新状态) 新 SSTable: ┌──────────────────────────────┐ │ PX123456,已签收,10:15:00 │ │ PX654321,运输中,11:20:00 │ └──────────────────────────────┘旧的“已揽收”和“运输中”记录在合并后被丢弃,只保留最新的“已签收”。
3. 读取:从新到旧层层找
查“PX123456”状态?
- 先看内存小本本(Memtable)
- 再看最新的 SSTable
- 依次往旧文件找
[图示:LSM树读取流程]
查询:PX123456
↓
┌─────────────────┐
│ 内存 Memtable │ ← 1. 先查这里(最新数据)
│ 没有 PX123456 │
└─────────────────┘
↓
┌─────────────────┐
│ SSTable(最新) │ ← 2. 再查最新SSTable
│ 找到:已签收 │
└─────────────────┘
↓
完成!
(如果最新SSTable没找到,继续查更旧的SSTable)
4. 布隆过滤器:快递仓库的“快速安检门”
为了避免每次查询都要扫描整个SSTable文件,LSM树使用布隆过滤器(Bloom Filter) 为每个SSTable创建一个“快速安检门”。
布隆过滤器原理:
- 它是一个位数组(如16位)和一组哈希函数
- 添加元素时,用多个哈希函数计算元素的位置,将对应位设为1
- 查询元素时,用相同的哈希函数检查对应位是否都为1
快递数据示例: 假设我们有一个包含3个包裹的SSTable:
包裹列表:
PX123456,已签收,10:15:00
PX654321,运输中,11:20:00
PX789012,已揽收,09:30:00
我们为这个SSTable创建一个16位的布隆过滤器(为简化,使用3个哈希函数):
布隆过滤器位数组(16位):
索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
初始: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
添加 PX123456:
哈希1(PX123456) = 2 → 位2设为1
哈希2(PX123456) = 9 → 位9设为1
哈希3(PX123456) = 4 → 位4设为1
添加 PX654321:
哈希1(PX654321) = 6 → 位6设为1
哈希2(PX654321) = 11 → 位11设为1
哈希3(PX654321) = 2 → 位2已为1,保持1
添加 PX789012:
哈希1(PX789012) = 3 → 位3设为1
哈希2(PX789012) = 9 → 位9已为1,保持1
哈希3(PX789012) = 14 → 位14设为1
最终布隆过滤器:
索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
值: 0 0 1 1 1 0 1 0 0 1 0 1 0 0 1 0
查询时如何使用:
查询 PX123456 是否在这个SSTable中:
哈希1(PX123456)=2 → 位2是1 ✓
哈希2(PX123456)=9 → 位9是1 ✓
哈希3(PX123456)=4 → 位4是1 ✓
所有位都是1 → "可能存在" → 继续在SSTable中精确查找
查询 PX888888(实际不存在的包裹):
哈希1(PX888888)=7 → 位7是0 ✗
发现一个位是0 → "肯定不存在" → 跳过这个SSTable
布隆过滤器的特点:
- 绝对不会有假阴性(说"肯定不存在"就真的不存在)
- 可能有假阳性(说"可能存在"但实际不存在,概率可控制)
- 对于快递查询场景:如果布隆过滤器说包裹不在某个仓库(SSTable),我们就完全跳过它,节省大量时间
5. 合并策略:何时整理仓库?
- 大小分级(Size-Tiered):攒够几本 10MB 的小册子,就合并成一册 100MB 的大本子。适合写入洪峰。
- 分级(Leveled):仓库设多层货架(L0, L1, L2…),每层货架上的册子都是不同包裹编号区间,且每层容量有限。整理时只是把上层册子的某些页挪到下层。适合频繁查询。
LSM 树像极了懒人收纳:包裹(数据)先往仓库里堆(写 Memtable 和 SSTable),周末再统一整理合并(Compaction)。写入飞快,但读可能得翻好几本册子。
三、B树:强迫症收纳法,每件都归位
1. 核心思想:原地更新,保持整齐
B 树则像一本永远保持整洁的活页目录册。
每个活页(Page)大小固定(如 4KB),记录着按包裹编号排序的索引和状态。
2. 结构:平衡多叉树
整本目录册是一棵 平衡树:
[图示:B树结构示例]
根页(Root Page):
┌─────────────────────────────────────┐
│ PX000001-PX400000 → 页2 │
│ PX400001-PX800000 → 页3 │
│ PX800001-PX999999 → 页4 │
└─────────────────────────────────────┘
↓
中间页(页2):
┌─────────────────────────────────────┐
│ PX000001-PX200000 → 页5 │
│ PX200001-PX400000 → 页6 │
└─────────────────────────────────────┘
↓
叶子页(页5):
┌─────────────────────────────────────┐
│ PX000001,已签收,10:15:00 │
│ PX000002,运输中,11:20:00 │
│ ...(更多包裹记录) │
└─────────────────────────────────────┘
-
中间页进一步细分:像页2这样的中间页,只包含“包裹编号范围”和“指向子页的指针”,不包含具体包裹状态。
例如,页2知道PX000001-PX200000的包裹在页5,PX200001-PX400000的包裹在页6。 -
叶子页才存放具体包裹的最新状态:像页5这样的叶子页,才真正存储每个包裹的最新状态记录。
3. 写入:直接找到位置,可能"分裂"
当"PX123456 已签收"到来时:
- 从根页开始,一路找到对应的叶子页。
- 如果叶子页已满,则将其分裂成两页,并更新父页的指引。
这就像目录册的某一页写满了,你得把它撕成两半,并更新目录条目。 - 为了防崩溃,所有改动会先记入 写前日志(Write-Ahead Log, WAL),相当于操作前先拍个快照。
4. 读取:三步直达
查"PX123456"状态?
[图示:B树查找路径]
查询:PX123456
↓
根页(Root)
↓ 判断:PX123456 在 PX000001-PX400000 范围
↓
页2(中间页)
↓ 判断:PX123456 在 PX000001-PX200000 范围
↓
页5(叶子页)
↓ 扫描找到 PX123456
↓
返回:已签收,10:15:00
从根页→中间页→叶子页,通常只需 3–4 次磁盘访问,就能精准定位。
B 树像极了强迫症收纳:每收到一个新包裹状态,就必须立即把它归到目录册的正确位置,并保持整本册子始终整齐有序。写入稍慢,但读取稳定快速。
四、对比:懒人 vs 强迫症,谁赢了?
| 维度 | LSM树(懒人收纳) | B树(强迫症收纳) |
|---|---|---|
| 写入吞吐 | 高(顺序追加,批量合并) | 中(随机写入,可能触发页分裂) |
| 读取延迟 | 不稳定(可能要查多层SSTable) | 稳定且低(树的高度固定) |
| 空间放大 | 较低(压缩友好,合并才写) | 较高(页内可能有空闲,需定期整理) |
| 写放大 | 较高(一次写入可能引发多次合并) | 中(每次写入至少写WAL+数据页) |
| 布隆过滤器 | 每个SSTable都有,加速"不存在"判断 | 不需要(直接树形查找) |
| 适用场景 | 写多读少,容忍读稍慢(如快递日志、监控数据) | 读多写少,要求稳定快读(如订单查询) |
五、怎么选?看你的"收纳性格"
- 如果你的业务像快递日志,每天写入海量,偶尔才查一下——选 LSM 树(Cassandra, RocksDB, HBase)。
- 如果你的业务像订单系统,随时要快速查最新状态,且写量可控——选 B 树(MySQL, PostgreSQL)。
当然,如今很多数据库已经"混血":
比如在 LSM 树中引入 B 树的索引优化,或在 B 树基础上添加日志合并特性。
毕竟,最好的收纳系统,往往是懒人和强迫症的智慧结合。
结语
存储引擎没有绝对的好坏,只有合不合适。
下次当你设计系统时,不妨问问自己:
我更像一个"先堆再整"的懒人,还是一个"随手归档"的强迫症?
答案会指引你找到对的存储引擎。