存储引擎的“收纳哲学”:LSM树与B树的快递仓库大战

21 阅读10分钟

想象一下,你是一家快递公司的数据管家,每天要记录几百万个包裹的状态更新:“已揽收”、“运输中”、“已签收”……
老板时不时会问:“包裹 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”状态?

  1. 先看内存小本本(Memtable)
  2. 再看最新的 SSTable
  3. 依次往旧文件找
[图示: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 → 位21 ✓
哈希2(PX123456)=9 → 位91 ✓  
哈希3(PX123456)=4 → 位41 ✓
所有位都是1 → "可能存在" → 继续在SSTable中精确查找

查询 PX888888(实际不存在的包裹):
哈希1(PX888888)=7 → 位70 ✗
发现一个位是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 已签收"到来时:

  1. 从根页开始,一路找到对应的叶子页。
  2. 如果叶子页已满,则将其分裂成两页,并更新父页的指引。
    这就像目录册的某一页写满了,你得把它撕成两半,并更新目录条目。
  3. 为了防崩溃,所有改动会先记入 写前日志(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 树基础上添加日志合并特性。
毕竟,最好的收纳系统,往往是懒人和强迫症的智慧结合。


结语

存储引擎没有绝对的好坏,只有合不合适。
下次当你设计系统时,不妨问问自己:
我更像一个"先堆再整"的懒人,还是一个"随手归档"的强迫症?
答案会指引你找到对的存储引擎。