P.Pages

Storage internals / Engineering field guide

B+Tree vs. LSM Tree

一次修改,应该落到已有的数据页,还是写成一个新版本?从这个分歧出发,拆开两种存储结构的读写路径、维护成本与工程边界。

资料核对于 2026.09.11 · 图中容量均为教学假设,不是性能测试结果

B+Tree / 维护有序页面

一次更新定位到少量目标页。数据页可以留在缓存中,多次修改之后再写回;空间不足时分裂。

定位叶子页 → 修改缓存页 → 稍后刷盘

LSM Tree / 维护有序批次

一次更新进入内存中的新版本。刷盘生成不可变文件,后台合并控制文件重叠和历史版本数量。

写内存表 → 刷成 SST → 后台合并

01 / Mental model

先把比较对象对齐

B+Tree 是按页组织的多路平衡搜索树;LSM Tree 是把更新分批排序、逐步合并的存储组织方式。 两者都能提供按 key 排序的访问,都能支持点查与范围扫描。LSM 的全称是 Log-Structured Merge Tree,它通常不是一棵由父子指针连接起来的树。

本文的 B+Tree 指典型的可变页实现;LSM 主要以 RocksDB / LevelDB 风格的 MemTable + SSTable + Leveled Compaction 为例。写时复制的 B+Tree、键值分离的 LSM 等变体会改变成本,不能把本文的倾向当成所有实现的定律。

数据结构不等于数据库

  • B+Tree 管 key 的顺序与定位,叶子既可以放行数据,也可以放指向行的标识。
  • LSM 管多个有序数据源的写入、查找与合并,不自动提供 SQL、复制或分布式一致性。
  • 事务、隔离和持久性由完整存储引擎实现。WAL、MVCC、锁与恢复协议都不能只靠一个结构名称推断。

例如,InnoDB 的聚簇索引叶子承载行记录;二级索引记录包含主键,必要时再用主键查聚簇索引。PostgreSQL 的 B-tree 叶子则保存指向 heap 表元组的 TID,不能把两者的“回表”成本混为一谈。官方文档常统称 B-tree,本文沿用教学语境中的 B+Tree 来强调记录位于叶子的组织方式。索引布局来源:1、2、3

02 / B+Tree internals

从一个页,到整棵树

2.1 页面里放什么

内部页存分隔键与子页指针,用一个比较结果缩小搜索范围;叶子页存有序索引条目,并通常通过兄弟指针连接。所有叶子位于同一深度。页头还要保存条目数、空间信息等元数据,具体布局由引擎决定。

数据库处理的是字节容量,不是课本里固定的“每个节点三个 key”。变长 key、记录头、页目录、页内碎片、前缀压缩都会影响一个页能放多少条目。页内查找也不要求记录在物理字节上紧密排序,可以通过目录或偏移表定位。3、10

根页以 30、60 分隔三个叶子页。Get(40) 走中间分支,范围查询 20 到 60 从左叶子沿兄弟指针向右读取。

图 01 · 独立的结构示例。分隔键采用“右子树下界”约定;不同实现的边界约定可不同。手机上可横向滚动查看完整图。

2.2 为什么树通常很矮

内部页不放完整行,能容纳很多子页指针,这个数量叫扇出(fanout)。做一个明确的容量估算:假设页大小 16 KiB,扣除开销后可用 12,000 B;每组分隔键与指针平均 24 B,则内部页扇出约为 500。若每个叶子页能放 80 条行记录:

根页 → 一层内部页 → 叶子页:500 × 500 × 80 ≈ 2,000 万条

这是三层树的理想化容量,不是 InnoDB 的容量承诺。长主键和宽行会降低容量;真实填充率也不会一直是 100%。InnoDB 默认页大小为 16 KiB,但不是所有 B+Tree 都采用这个大小。2

点查通常访问一条根到叶子的路径。树高不等于物理读盘次数:根页和上层内部页容易留在缓存中,热查询甚至不需要读盘;二级索引回表、MVCC 可见性检查又可能增加访问。

2.3 插入:先找位置,空间不足才分裂

  1. 沿分隔键下降到目标叶子,把需要的页读入缓冲池。
  2. 在短期页锁存器(latch)保护下插入条目,维护页内顺序,并记录恢复所需的日志。
  3. 空间不足时,可能先整理页面或清理可回收条目;仍放不下才分裂。
  4. 新建兄弟页,分配条目,修正链接,并在父页加入分隔键和新子页指针。
  5. 父页也放不下则继续向上分裂;根分裂使树高增加一层。
向已满的叶子 30、40、50 插入 45,分裂成 30、40 和 45、50。父页由 30、60 变成 30、45、60,45 仍留在叶子中。

图 02 · 假设叶子最多容纳三个等长条目,省略未变化的子树。叶子分裂把 45 作为分隔键复制到父页;真实引擎按字节与插入模式决定分裂点,不一定对半。

顺序递增插入集中在树的一端,往往有较好的缓存局部性和页面利用率,但高并发下可能形成热点页争用。随机 key 把修改分散到更多页,缓存之外的读改写压力可能更大。“B+Tree 写入都是随机 I/O”不成立,WAL 是追加写,脏页也可以合并修改、批量刷出。

2.4 更新与删除不是简单的原地覆盖

如果是不改变 key 的定长字段更新,可变页引擎有机会只修改当前记录所在页。但变长 value 可能放不下,改变索引键通常意味着删除旧索引项、插入新索引项;涉及多个索引时会放大维护工作。

MVCC 还要求保留旧读者需要的版本。PostgreSQL 的更新可能在 heap 中产生新版本,并带来新索引条目;HOT 优化和索引垃圾清理会改变这一成本。因此,“B+Tree 每个 key 永远只保留一个物理版本”是错的。3

删除也可能先做逻辑标记,待可见性条件满足后再回收。课本中的借位、合并、递归修复是算法模型;生产引擎常延迟处理,不能假设每次删除都立即合并半空页。InnoDB 有页面合并阈值,PostgreSQL 有 bottom-up deletion 和 VACUUM 等清理机制。页内空间可复用,也不等于表文件立即缩小并归还操作系统。2、3

03 / LSM internals

从一次写入,到一组文件

3.1 前台写入的四个环节

以启用 WAL 的普通 RocksDB 写入为例,概念路径如下。并发写、分组提交和流水线优化可以调整执行细节,但不能破坏可见性与恢复顺序。4

  1. 分配序列号,让更新具有确定的版本顺序。教学上可把内部 key 理解为 user_key + sequence + type;同一 user key 的新版本排在旧版本前面。
  2. 追加 WAL,记录这次写入或整个 WriteBatch。WAL 按写入顺序组织,不按 key 排序。要求同步持久化时,返回成功前还要完成相应同步。
  3. 插入活跃 MemTable,使读取能看到已发布的更新。RocksDB 默认使用跳表维护顺序,也支持其他 MemTable 实现;“内存表就是哈希表”不是通用结论。
  4. 切换与刷盘,活跃 MemTable 达到阈值后冻结,新的 MemTable 接收写入;后台把冻结表刷成 SST 文件。
追加 WAL 后写入 MemTable,冻结表刷成 L0 文件。L0 的 10 到 50 与 30 到 80 两个范围重叠,L1 和 L2 各层内部的文件范围不重叠;合并处理相邻层的重叠部分。

图 03 · 经典 Leveled 布局示意。横轴表示 key 范围,不表示文件字节数。动态层容量配置可能让 L0 跳过空层,直接合并到实际的基础层。

3.2 SSTable 不是“把日志改个文件名”

SSTable 是 Sorted String Table,在这里可理解为不可变的有序键值文件。刷盘遍历 MemTable 的有序内容生成它;WAL 则保留操作顺序,两者用途与格式都不同。

RocksDB 默认的 BlockBasedTable 主要包含:5

  • 数据块:按 key 排序的条目分块存储,块可以独立压缩并带校验信息。
  • 索引块:把 key 范围映射到数据块的偏移与大小,不必把整个 SST 读入内存。
  • 过滤器:配置 Bloom Filter 等过滤策略后,帮助跳过不可能包含目标 key 的文件或分区。
  • 元数据与 footer:记录属性、元块位置等信息,用于打开和解释文件。

SST 不可变意味着更新不需要在它内部移动已有记录,压缩与校验也更容易以块为单位处理。代价是新版本要在别处存放,之后通过合并消除冗余。

3.3 层级、文件与有序段不要混淆

一个 **sorted run(有序段)**是逻辑上连续有序的一组数据,可以由一个文件或多个按范围切开的文件组成。在经典 Leveled 模型里:

  • L0 接收刷盘文件。不同批次的 key 范围可以重叠,所以一个 key 可能对应多个候选文件。
  • L1 及更深层通常各自构成一个有序段,层内文件按 key 范围分区,跨层仍然可以重叠。
  • 下层的目标总容量通常按倍率增长,不等于每个文件都按相同倍率变大,也不等于层内所有记录都比上层的每条记录更旧。

读取需要根据文件元数据缩小候选范围。层数、文件数和数据块读取次数是三个不同指标;“LSM 查询扫描所有 SST”同样不准确。6

3.4 谁记得哪些文件有效

MANIFEST 记录文件集合及其变更,CURRENT 指向当前 MANIFEST。 合并写出新文件后,需要可靠地发布“加入这些文件、移除那些文件”的元数据变更,才能切换读视图。旧文件还要等不再被读者引用后才能删除。

恢复不能简单地扫描目录,把所有 .sst 当作有效数据。崩溃时可能存在尚未发布的合并输出,也可能存在尚未物理删除的旧文件。WAL 解决未刷盘更新的恢复,MANIFEST 解决文件集合的一致性,这两个职责不同。9

04 / Reads & versions

读取的是可见版本

4.1 同一组操作,两种物理组织

从空库开始,执行下面五次已成功提交的单 key 操作。在序列号 101 之后保存一个读快照。这里的 s 是教学上的全局操作序号,不表示所有数据库都用 RocksDB 的序列号实现 MVCC。

共同的逻辑历史
序号操作作用
100Put(10, A)插入 key 10
101Put(20, X)插入 key 20,然后创建快照 S101
102Put(10, B)更新 key 10
103Delete(20)删除 key 20
104Put(30, C)插入 key 30

最新视图应返回 10=B、30=C,20 不存在;快照 S101 应返回 10=A、20=X,30 不存在。查询结果由隔离语义决定,不能因为换了存储结构就改变。

B+Tree 引擎先用索引找到候选记录,再根据自身 MVCC 机制判断可见性或访问旧版本。LSM 可能在内存与不同 SST 中保存这些内部条目,读取时选择快照可见的最新版本;普通序列号模型下,就是同一 key 中最大的 s ≤ snapshot_seq。若选中删除标记,就返回不存在。3、8

4.2 LSM 点查如何减少搜索

在没有 Merge Operand、范围删除等扩展的普通点查模型中,可先查活跃与冻结 MemTable,再按版本优先关系检查候选 SST。不能把“内存找到同名 key”直接等同于“找到可见版本”。旧快照可能需要继续查找。

对一个磁盘候选文件,依次利用 key 范围、过滤器、块索引和块缓存缩小工作量。经典 Leveled 的非 L0 层通常只需定位一个候选文件;同一 user key 的内部版本跨文件边界等实现细节,可能让“每层恰好一个文件”不再是严格上限。

Bloom Filter 只回答“肯定不在集合里”或“可能在”。 正确构造和使用时没有假阴性,但可能误报;它既不返回 value,也不判断事务可见性。带旧版本或删除标记的 key 仍可能让过滤器返回阳性。11

4.3 范围扫描的区别

B+Tree 定位到第一个满足下界的叶子条目后,沿叶子页向右扫描,直到越过上界。若扫描二级索引还要访问行数据,回表可能成为主成本;叶子逻辑相邻也不保证磁盘物理相邻。

LSM 则为相关 MemTable 与 sorted run 建立迭代器,分别 seek 到下界,再做多路归并:取最小 key,收集该 key 的版本,选出当前读视图可见的值,跳过被删除的 key,再推进迭代器。上面的 [10, 30] 扫描在最新视图只输出两条,但可能检查超过两条物理记录。

普通的整 key Bloom Filter 不能直接证明任意范围里都没有数据;前缀过滤器可优化符合前缀约束的扫描,不等于所有范围查询都有同样效果。4、11

05 / Compaction

合并决定长期成本

5.1 Leveled 与 Tiered 的差别

Leveled Compaction 会选择某层的一些文件,与下一层 key 范围重叠的文件归并,再把输出切成新文件。这样控制层内重叠,但上层的小批更新可能带动下层大量已有数据重写。没有重叠且满足其他条件时,有些文件可以只做元数据上的迁移,不必重写内容。6

Tiered / Size-tiered 倾向于积攒多个大小相近的有序段,再一起合成更大的段,减少反复把小段并入大段的重写;查询却可能需要检查更多段,也常需要更大的临时磁盘余量。RocksDB 的 Universal 属于这一类,不能把它的参数和所有系统的 Size-tiered 规则视为完全相同。7

同类负载下的典型倾向,不是固定性能比例
策略优先控制承担的成本
Leveled候选有序段数量与空间放大重叠范围的重复读写;合并消耗带宽与 CPU
Tiered数据被反复重写的次数更多候选段、旧版本与更突出的合并空间峰值

5.2 删除标记为什么不能随便丢

继续使用上一节的 key 和序列号。假设快照 S101 已释放,合并只覆盖图中的 L0 与 L1;未参与的 L2 里还有 20@101=X。

L0 与 L1 合并时可清理 10@100=A,但必须保留 20@103 的删除标记,因为未参与合并的 L2 仍有 20@101=X,过早丢弃标记会让 X 重新可见。

图 04 · 文件放置仅用于展示回收边界,不表示这五次写入必然产生这些文件。判断能否丢弃标记,必须查看合并输入之外是否还有被遮蔽的版本。

回收必须同时守住两个条件:

  1. 不能破坏活跃快照。 若 S101 仍存在,10@100=A 和 20@101=X 仍可能被它读取,不能仅因出现新版本就删除。
  2. 不能让合并范围外的旧值重新可见。 即使没有旧快照,只要更深层还可能有被删除标记遮蔽的值,就要保留标记,或把相关数据纳入能证明安全的清理过程。

“只有到最底层才可以删除”是一种保守记法,不是所有实现的必要条件。对普通点删除,若能证明更深层相关范围没有旧值,也可能更早丢弃标记。反过来,即使已到最底层,活跃快照仍可能阻止旧版本清理。8、12

5.3 合并赶不上写入,会怎样

待刷盘 MemTable、L0 文件或待合并字节不断累积,内存、读放大和磁盘占用随之上涨。RocksDB 会触发减速甚至 write stall(暂停写入),让后台维护有机会追上。13

加大缓冲区或调高暂停阈值,可以吸收短时突发,不能解决长期输入速率高于处理能力的问题。增加合并线程也只有在 CPU、存储队列和带宽仍有余量时才可能有效;否则只是让前台读和后台合并竞争得更激烈。

06 / Correctness

日志、可见性与并发

6.1 WAL 约束的是持久化顺序

典型可变页 B+Tree 引擎采用 write-ahead 规则:数据页的某次修改持久化之前,能够恢复该修改的日志必须先持久化。 这不要求每次修改内存页之前都单独同步一次日志,也不要求提交时把所有脏页刷盘。14

LSM 的 MemTable 同样易失。启用 WAL 后,恢复可以重放尚未反映在有效 SST 集合中的更新;只把更新放进内存并不能保证掉电不丢。两类引擎都可用 group commit 让多个写入共享一次日志同步,从而摊薄成本。

写入成功的含义必须与配置一起说明
返回前达到的状态能推断什么不能推断什么
只进入内存当前进程可能已能读到进程崩溃或掉电后还能恢复
WAL 交给操作系统,未同步可能承受进程崩溃,取决于实际缓冲配置系统崩溃或掉电不丢已确认写入
WAL 按同步协议持久化可按引擎恢复协议重建已确认更新介质永久损坏后仍可恢复,或远端副本也已确认

上述同步保证还依赖操作系统、文件系统和设备遵守持久化契约。本机日志同步不等于复制确认,也不等于备份。 比较性能时必须对齐这些边界。

6.2 崩溃发生在维护中间

B+Tree 页分裂涉及多个页和父子关系,日志与恢复协议必须能处理分裂中断。页写入也可能只完成一部分;例如 PostgreSQL 的 full_page_writes 会在 checkpoint 后第一次修改页面时,把完整页写入 WAL,避免仅靠局部修改日志无法恢复撕裂页的问题。16

LSM 合并必须先可靠完成新输出,再可靠记录文件集合变更,最后安全回收旧文件。读者只应该看到一个一致的文件集合。SST 不可变简化了单文件并发访问,但没有消除文件发布和恢复的复杂度。9、10、14

6.3 Latch、事务锁和 MVCC 是三件事

Latch 保护内存中的页或共享结构不被并发修改破坏,通常持有很短;事务锁约束逻辑读写冲突;MVCC 决定某个读视图能看到哪个版本。能一致地读取一个页,不代表已经实现事务隔离。

B+Tree 的分裂不能要求所有读者等待整棵树重建。PostgreSQL 的 B-link 风格实现用页的 high key 和右兄弟指针帮助搜索在遇到并发分裂时继续向右定位,读取单页时仍需保护共享缓冲区。10

LSM 的不可变 SST 不需要对原记录做页内修改,但仍要协调 MemTable 写入、序列号发布、版本集合切换与旧文件回收。RocksDB 另外提供悲观和乐观事务接口;普通 Get 后 Put 不是自动的原子读改写,快照也不自动提供可串行化隔离。8、15

07 / Cost model

把放大算到同一口径

7.1 写放大:每写入 1 B,设备收到多少 B

引擎写放大 WA = 观察窗口内引擎写出字节 / 应用逻辑写入字节

分子是否包含 WAL、索引、合并输出和元数据,必须写清楚。设备内部的 FTL 垃圾回收还会产生额外写放大,不能把数据库统计的写出量直接当成 NAND 写入量。压缩也会影响按字节计算的比值。

B+Tree 的页粒度成本: 假设更新 100 B 最终独占一次 16 KiB 页写回,只计该数据页,比例为 16,384 / 100 ≈ 164。但如果同一页的 100 次修改合并成一次写回,这一项就降为约 1.64。WAL、其他页、索引和版本维护还没计入;不能拿这个假设当实测写放大。

LSM 的重写成本: 假设应用写入 1 GiB,窗口内 WAL 写出 1 GiB、flush 写出 1 GiB、compaction 写出 8 GiB,则上述口径的 WA 为 10。若这段时间只把数据堆在内存和 L0,数字会暂时偏低,后续合并会补上成本。Leveled 与 Tiered 的策略选择会直接影响这一项。6、7

7.2 读放大:候选文件不等于磁盘 I/O

读放大可以按探测的有序段数、物理读取字节或块读取次数计量。对不存在的 key,返回字节为零,用“读取字节 / 返回字节”没有意义,此时更适合看每次请求的 I/O 数和 CPU 时间。

假设一次 LSM 查空有 6 个候选 SST,每个过滤器的误报率都是 1%,过滤器和索引已在内存,且每次误报只触发一次数据块读取:

期望的额外数据块读取数 = 6 × 1% = 0.06 次 / 查询

这解释了 LSM 的查空为什么不一定慢。但 6 次过滤器探测及其 CPU 开销仍在,冷过滤器会增加元数据 I/O,真实数据布局也未必满足“一次误报只读一块”。RocksDB 文档给出的常用量级是约 10 bits/key 对应约 1% 的误报率,具体实现和规模会影响结果。11

7.3 空间放大:平时占用与维护峰值分开看

空间放大 SA = 实际占用的存储字节 / 当前逻辑存活数据字节

同样要说明压缩、索引、WAL 和副本是否计入。B+Tree 的来源包括未填满的页、碎片和未回收版本;LSM 的来源包括旧版本、删除标记、多个有序段,以及合并期间新旧文件同时存在。

Leveled 稳态下,如果层容量倍率为 T=10,各层恰好达到几何比例,则总容量相对最底层约为 1 + 1/10 + 1/100 + … ≈ 1.11。分母是最底层数据量,不是逻辑存活数据量,所以不能据此承诺“空间放大最多 1.11”。L0 积压、快照、压缩差异和合并临时文件都没有被这个公式覆盖。Universal 的全量合并甚至可能暂时同时保留接近一整份输入和一整份输出。6、7

7.4 复杂度只能解释结构,不能代替压测

B+Tree 点查的页面路径约为 O(log_F N),F 为有效扇出;理想的叶子范围扫描还要加上输出所跨越的页数。LSM 的内存写入可能是 O(log M),后台成本需要按整个合并周期摊销;查询成本取决于候选段、过滤器、缓存和版本数量,不能概括成“写 O(1)、读 O(log N)”。

对 k 路有序输入,使用堆归并的朴素扫描模型约需 O(P log k) 次比较,其中 P 是实际消费的物理条目数,不是最终返回条数;初始化 seek 和读取数据块的 I/O 另算。真实引擎可能优化迭代器切换,不能把模型当作实现的精确指令数。

08 / Trade-offs

按负载选,不按口号选

典型可变页 B+Tree 与 Leveled LSM 的工程倾向
维度B+TreeLSM Tree
随机写入可能先读目标页,再修改;缓存命中时可摊薄多次写入普通 Put 无需先读旧 SST;顺序日志与批量刷盘,但有后续合并
热点小更新热页反复修改后写回,可能有优势;需留意页争用新版本会增加维护量;flush 或 compaction 可提前消除部分冗余
点查导航路径较确定;回表与可见性检查可能占主成本多数据源探测;过滤器、缓存和批量查询可显著降低成本
范围扫描一个有序叶子序列;未覆盖查询可能反复回表多个有序段归并,旧版本与 tombstone 增加扫描工作
空间与压缩页空隙与版本回收影响占用;也能做页压缩不可变块适合压缩;需为旧版本和合并峰值留空间
尾延迟缺页、分裂、日志同步、争用与后台刷盘可能形成尖峰日志同步、L0 积压、合并资源竞争和 write stall 可能形成尖峰
内存预算缓冲池中的索引页与数据页MemTable、块缓存、索引、过滤器与后台任务,不能只数 write buffer
旧版本回收依赖引擎的 MVCC 清理、页空间回收等机制主要随 flush / compaction 处理,但受快照和覆盖范围约束
事务能力取决于锁、MVCC、日志和恢复协议同样取决于完整引擎,LSM 不意味着最终一致性

更值得从 B+Tree 开始验证的负载

  • 短范围扫描、排序分页、频繁索引定位,并且希望扫描路径尽量直接;先确认索引是否覆盖查询。
  • 热数据能装入缓冲池的小幅反复更新,修改合并可以显著降低数据页写回量。
  • 业务需要成熟的关系型数据库能力,现有 PostgreSQL / MySQL 已满足要求;不要只为“LSM 写得快”重做 SQL、约束和运维能力。

更值得从 LSM 开始验证的负载

  • 大量超出缓存工作集的随机键写入,普通 Put 不必先读取旧数据页,批量落盘可能更有利。
  • 持续摄入的 KV、状态存储或写密集服务,且能预留合并 CPU、I/O 与磁盘余量。
  • 应用本来就需要嵌入式有序 KV 引擎,能够自行负责上层数据模型、事务使用方式和备份策略。

这些是起点,不是判决。读多的服务可能因 Bloom Filter 和缓存而在 LSM 上表现很好;写多的业务也可能受益于 B+Tree 的热页更新合并。读写比例相同,key 分布、记录大小、索引数量不同,结论就可能反转。

SSD 上还需要考虑这些差异吗?

需要。SSD 缩小了随机访问与顺序访问的差距,但并没有消除带宽、读写干扰、擦写寿命、设备内部垃圾回收和同步延迟。LSM 合并也会读取输入、解压、归并、再压缩,并非“全是免费顺序写”。

LSM 是否天然适合所有时序与 TTL 数据?

不天然成立。时间是否进入排序键、是否按时间分区、过期数据能否整文件回收,以及是否还保留长快照,都影响删除成本。一个业务标签不能代替 key 设计和回收策略。

LSM 的大 value 是否一定更划算?

不一定。较大的 value 反复参与合并可能消耗大量带宽。键值分离可以让合并主要移动 key 和 value 指针,但会引入独立的 value 垃圾回收及额外读取路径;只有测出 value 重写成为主成本后,才值得进一步比较这种变体。

09 / Verification

测试稳态,也测试故障

9.1 一次有意义的基准测试要固定什么

  1. 数据模型:key / value 长度分布、压缩率、存活数据量、二级索引数量、比较器与排序规则。
  2. 访问模式:插入 / 覆盖更新 / 删除比例,均匀随机或热点分布,查空比例,扫描长度与并发数。
  3. 同等语义:日志是否开启、何时同步、批量大小、隔离级别、复制确认策略。不能用异步 KV 写入对比同步 SQL 事务。
  4. 同等资源:总内存而非单个缓存参数,设备与文件系统、CPU、后台线程预算、磁盘剩余空间。
  5. 足够长的阶段:预装数据、热身,再运行到树或 LSM 层级充分形成;混合更新与删除必须覆盖足够多的维护周期。
  6. 真实的负载施加方式:记录发送速率、完成速率、超时与排队时间。只看阻塞客户端的平均响应,可能漏掉拥塞时本应到达的请求。

如果 L0 文件数或待合并字节持续增长,当前吞吐就是在积累维护任务,不能当作长期能力。B+Tree 也要观察脏页与清理积压,不能只跑一个全缓存、无清理压力的短测试。13

9.2 至少输出这组结果

  • 吞吐与 P50 / P95 / P99 延迟,分别统计读、写、扫描,不只给一个平均值。
  • 应用写入量、WAL / flush / compaction 写出量或数据页写回量,说明写放大的口径。
  • 总内存、CPU、物理读写量、缓存命中率、磁盘占用的稳态值与峰值。
  • 缺页、锁与 latch 等待、日志同步延迟;LSM 额外记录 L0 数量、pending compaction bytes 和 stall 时长。
  • 长快照、持续删除、突发写入与后台维护并存时的退化,以及恢复正常所需时间。

9.3 从症状追到资源,不直接调旋钮

下面是待验证的假设,不是看到症状就成立的根因
症状先看证据不要直接做
LSM 周期性写超时stall 日志、不可变表数、L0 数、待合并字节、CPU 和 I/O 饱和度把所有暂停阈值调大,掩盖持续超载
删除后磁盘不降快照或读者引用、旧版本、合并覆盖范围、可复用空间与文件大小的区别只凭 Delete 返回成功,就认为物理空间已回收
B+Tree 随机更新变慢目标页缓存命中率、物理读取、分裂率、日志同步与页争用只看读写比例就换引擎
范围查询很慢返回行数与实际扫描条目数、覆盖索引、回表量、重叠段数、删除密度把整个问题归为“树不适合扫描”

9.4 恢复测试与吞吐测试同样必要

在独立测试环境中,对写入、页分裂、flush、compaction 和文件发布过程注入故障。重启后验证:承诺持久化的写入仍在,原子批次没有部分生效,删除数据没有复活,范围扫描符合参考模型。测试设备 I/O 失败和空间耗尽时,确认引擎报错且不错误地返回成功。

只杀进程不足以测试掉电持久性,因为操作系统缓存仍然存在。系统崩溃、撕裂写和掉电场景需要相应的故障注入或隔离测试环境;不要在生产数据上做破坏性验证。

10 / Primary sources

来源与继续深挖

以下均为实现方文档或源码说明,核对日期为 2026 年 9 月 11 日。MySQL 采用 8.4 文档,PostgreSQL 采用 17 文档;RocksDB Wiki 持续更新,参数默认值与细节应再对照实际部署版本。文中的算式与数字示例为明确假设下的推导,没有跨引擎性能倍数的实测主张。

建议先读 3、4 建立模型,再读 6、10、12 深挖实现。 图示均为本文绘制,未复用来源中的图片。

  1. MySQL 8.4:Clustered and Secondary Indexes

    。聚簇索引、二级索引中的主键与回表路径。

  2. MySQL 8.4:The Physical Structure of an InnoDB Index

    。默认页大小、填充、顺序插入与合并阈值。

  3. PostgreSQL 17:B-Tree Implementation

    。推荐起点,页结构、递归分裂、MVCC 版本造成的索引维护与回收。

  4. RocksDB Overview 。推荐起点,MemTable、WAL、SST、快照、写入同步与缓存的整体关系。

  5. RocksDB BlockBasedTable Format

    。数据块、索引、过滤器和 footer 的职责。

  6. RocksDB Leveled Compaction

    。层内有序段、重叠范围合并与动态层容量。

  7. RocksDB Universal Compaction

    。Tiered 思路、读写与空间取舍,以及全量合并的临时空间。

  8. RocksDB Snapshot 。序列号可见性、快照生命周期与合并时的版本保留。

  9. RocksDB MANIFEST 。一致文件集合、Version Edit 与 CURRENT。

  10. PostgreSQL 17:nbtree README

    。深入阅读,B-link 并发、high key、兄弟指针与字节级分裂策略。

  11. RocksDB Bloom Filter 。误报率、过滤器内存预算与整 key / 前缀过滤的边界。

  12. LevelDB Implementation 。深入阅读,较小实现中的文件生命周期、合并范围和点删除标记回收条件;其中历史参数与硬件数字不作为本文性能依据。

  13. RocksDB Write Stalls 。待刷盘表、L0 和待合并字节触发的背压。

  14. PostgreSQL 17:Write-Ahead Logging

    。日志先于数据持久化、REDO 与分组同步。

  15. RocksDB Transactions。悲观 / 乐观事务、原子 WriteBatch 与读写冲突保护。

  16. PostgreSQL 17:full_page_writes

    。部分页写入的风险,以及完整页 WAL 记录的用途。