专业书籍精读 · Database Internals · 第 6 章
Database Internals · Ch 6 · Alex Petrov · 2019
前几章把数据库那本「目录」建好了:一层层的路标,几步就翻到你要的那一页。这一章问的是另一件事——这套办法是四十年前为机械硬盘、单核机器想出来的,今天还合身吗?
你大概以为,数据库的贵在于「找」。其实贵在「改」。你改一条八十个字的记录,硬盘上至少要重写一整页(几千到上万个字);而且为了别人不会读到一张改到一半的纸,这页上还得挂个「施工中」的牌子——谁路过谁等。找一条记录只要翻三四页,改一条记录却要重抄一页、还要让路人排队。
一,改一个字,重写一整页。页是硬盘的最小单位,改一个字节和改一整页花的力气差不多。
二,「施工中」的牌子挂在越上面越堵。目录树的最顶上那一页人人都要路过,它一挂牌,整台机器的几十个核就一起排队。
三,原地涂改本身就危险。正把一页覆盖到一半断电,这页就成了半旧半新的乱码——上一章那本流水账,一多半就是为了给这种「涂改」擦屁股。
一、誊清而不涂改。要改哪一页,不动原稿,抄一张新的、在新纸上改;从封面到那一页的一路路标也跟着重抄,最后把目录第一行指向新版本——换指向是一瞬间的事,要么旧要么新,没有中间态。正在读旧版本的人完全不受影响,读者永远不必排队。
二、先贴便签,攒够了再誊。不必每改一次就重抄整页,先在页边贴一串便签写「这行改成了什么」,读的人把原页和便签合起来看;便签攒多了再一次性重抄成干净的一页。
三、分层归档。新改动先进最上面那本薄册子,薄册子满了就整本并进下面更厚的一本,一层层往下。每本册子做好就不再改,写下去全是一口气顺着写——固态盘最喜欢这样,最怕东一处西一处地零敲碎打。
四、大家只认门牌号,不认地址。所有指路的地方都写门牌号,另有一本「门牌号 → 现在的地址」的登记簿。要改某一页,就写一张「改动条」贴在原页上,再把登记簿那一行改指向它——改一行登记簿是不可分割的瞬间动作,谁都不用挂牌、不用排队。
这四招各治一个病:誊清治「怕断电、读者排队」,便签治「改一个字重写一页」,分层归档治「固态盘怕零散写」,门牌号治「排队」。代价也都长得像:新版本写出来了,旧版本得有人打扫——省下的写,往往变成多占的空间和一堆后台整理的活。
经典的目录树贵在改,不在找。这一章的四个变体全在拆同一个假设——非得在原地涂改不可吗?不涂改,改成誊清一份、贴便签攒着、分层归并、或者只追加一张改动条再换个门牌指向;换来的是不怕断电、读者不排队、对固态盘友好,付出的是空间和一份没完没了的打扫工作。
想进到具体机制、记号和示意图? → 切到精读版
这一章不是介绍四个新数据结构,而是把「B-Tree = 按页组织 + 原地修改 + 加闩保护」这个捆绑拆开,逐项替换掉。核心命题只有一句反直觉的话:B-Tree 的成本重心早就从「查一次要跑几趟盘」挪到了「改一次要写多少字节、要让多少线程等」——所以本章所有变体,几乎没有一个是为了让查找更快,它们都是为了让写更少、等更短、对闪存更友好。而它们共同的第一步动作惊人地一致:不再原地修改。
4–16 KB;B-Tree 的一个节点通常就是一页。8 KB 页,就是 80 倍。本章位于 Part I「存储引擎」的第 6 章。Ch2–Ch4 建起了教科书式的 B-Tree(结构、页格式、分裂与合并的工程细节),Ch5 用页缓存 + 预写日志 + 并发控制让它扛住崩溃与并发。这一章是同一条线上的变奏:既然经典 B-Tree 的痛点都源自「原地修改」,那把这个前提换掉会长出什么样子。它同时是通向下一章的桥——Ch7 的 LSM-Tree 就是把「不原地改、攒批顺序写」推到极致;而 Bw-Tree 用到的兄弟指针来自 Ch5 提过的 Blink-tree,MVCC 快照的思路则和 Ch5 的并发控制一脉相承。
经典 B-Tree 在「找」这件事上几乎无可挑剔:10 亿条记录、扇出几百的树高也就 4 层,一次点查最多几趟 I/O。问题全在「改」的那一侧,可以摊成三笔账。
第一笔,写放大。改一条 100 字节的记录,磁盘上最少重写一个 8–16 KB 的页——页级放大就有 80–160 倍。这还只是开头:为防止「页写到一半断电」留下半新半旧的撕裂页(torn page),引擎还得交税——MySQL InnoDB 的双写缓冲(doublewrite buffer)把同一页写两遍,PostgreSQL 的 full_page_writes 要求每次检查点后第一次改某页时把整页塞进 WAL。再往下,固态盘按擦除块(常见几 MB)管理,零散随机写诱发内部搬迁,又叠一层。「原地更新」不是免费的默认选项,它是一串税单的源头。
第二笔,并发。Ch5 的闩蟹行已经把根节点持闩时间压得很短,但压不掉一个事实:每次查找和修改都必须从根开始,而根只有一个。单机 64–128 核的今天,瓶颈不再是磁盘寻道,而是热点页上的缓存行来回弹跳——一个核动了根上的闩,其他核缓存里的副本就作废。越是内存驻留、越是多核,加闩本身的开销占比越高。
第三笔,碎片与空间。节点通常只装到七成满,分裂后一度只有五成;反复增删之后,页内空洞与页间顺序错乱一起累积,顺序扫描从「顺着读」退化成「到处跳」。
不解决会怎样?也没怎样——几十年主流数据库都这么活着。但三种场景里这三笔账会变成致命伤:写密集负载(吃掉带宽与 SSD 寿命)、多核内存数据库(加闩开销压过一切)、要求崩溃后零恢复时间的嵌入式场景。本章的变体分别冲着它们去。
思路一句话:任何页都不再被覆盖。要修改某个叶子,就复制一份、改在副本上;副本换了地址,父节点里那个指针就得跟着改——于是父节点也得复制一份……一路复制到根(这叫路径拷贝)。最后把「树根在哪」这一个指针原子地切到新根上。切换之前,整棵新树对外不存在;切换完成的一瞬间,它整个生效。没有中间态,也就不需要日志来擦屁股。
它顺手买一送三。其一,崩溃安全免费:根指针要么旧要么新,磁盘上永远是一棵自洽的树,不需要 redo / undo,重启即可用。其二,天然 MVCC:读事务开始时抓住当时的根,之后写者怎么折腾都碰不到它,读路径上一把闩都不用加。其三,读可以直接走内存映射:LMDB 把整个数据库 mmap 进地址空间,取数据直接从映射内存返回,不 malloc 不 memcpy。
代价在三处,都不轻。其一,单次写放大更大:树高 4 的树改一条记录要新写 4 页,100 字节付出 32–64 KB。其二,写者只能有一个:路径拷贝 + 换根天然串行,好处是写者永不死锁,坏处是写吞吐锁死在单线程。其三,旧页回收:换下来的页要进空闲页表等复用,而只要还有一个老读事务钉着旧根,那批页就一页也不能复用——一个跑了半小时的只读事务就能让文件肉眼可见地膨胀,和 PostgreSQL 里长事务拖住 vacuum 是同一个病。
写时复制解决了原子性和读并发,却没解决写放大——改一个字节仍要重写整页。惰性(lazy)路线换了个角度:既然重写整页贵,就别每次都重写;把更新先攒在内存里,攒够了再算出一个新页。
关键是承认一件事:页在内存里的形态,没必要等于它在磁盘上的形态。磁盘形态要紧凑、能顺序扫描,内存形态则可以用指针、跳表这类对 CPU 友好的结构。WiredTiger(MongoDB 的默认存储引擎)就是典型:磁盘上的页镜像读进内存后保持只读,新的插入与更新挂进这一页附带的更新链,读取时把「基础镜像 + 更新链」合起来看;等到这页被驱逐或做检查点时,才执行一次归并(reconciliation),把两边算成一个干净的新页写出去。
算一笔账就明白它值在哪:如果一页在两次检查点之间被改了 50 次,经典做法是把这页刷 50 次(或至少多次),惰性做法只在归并时写一次——写放大按更新链长度摊薄。代价是读路径变重(要合并两处数据)、内存里驻着尚未落盘的更新(崩溃时靠预写日志补回)。同一路线上还有一支惰性自适应树(LA-Tree):把缓冲区挂在子树上而不是单页上,缓冲满了就把更新级联下推到下一层的缓冲区,一路推到叶子——已经很接近下一章 LSM 的味道了。
前两招都还在「一棵树」的框架里。FD-Tree 换了形状,动机非常具体:闪存上随机写远比顺序写贵(不能就地改写,必须整块擦除后重写,零散小写会诱发内部搬迁)。既然如此,那就把所有写变成顺序写。
结构分两部分:顶上是一棵很小的 B-Tree(head tree),装得下最新的少量数据;下面是若干层不可变的有序段,容量按固定倍数逐层放大。新数据先进头部小树,满了就与下一层归并成一个新的有序段——归并是从头到尾顺着写的,正合闪存胃口。写路径上再没有一次随机写。
但分层带来一个新麻烦:查一个键要逐层去找,每层一次二分查找就是一次随机读,层数一多读就塌了。分数级联是这里的点睛之笔:在上层的段里预先埋下指向下层对应位置的指针(书里叫 fence,栅栏)。于是查完第 1 层,你手里已经攥着「第 2 层该从哪个位置看起」,不必重新二分。原论文给出的结论是:更新只花 O(logB n) 次顺序 I/O,查找花 O(logB n) 次随机 I/O(B 是闪存页大小)——白话说就是查找的成本和普通 B-Tree 同量级,写入的成本却降到了写优化结构的水平。
Bw-Tree(Microsoft Research,2013)冲的是第二笔账——并发。它的目标极端:整棵树一把闩都不加,全靠 CAS 推进。三个零件缺一不可。
零件一:增量记录。要改一页,不动这页,写一条只说「这次改了什么」的小记录,让它指向原页——形成一条增量链。读的时候从链头往下走,把沿途的改动叠加到基础页上。写的成本从「重写一页」降成「追加一条几十字节的记录」。
零件二:映射表。这是全章最值得学的一招。树里所有指针存的都不是物理地址,而是逻辑页号;另有一张映射表把页号翻译成当前物理地址。于是「用新版本替换某一页」就退化成在映射表里改一个槽位——一条 CAS 指令,原子完成。父节点里的指针一个字节都不用动,这正是无闩得以成立的关键:修改一页从来不需要同时锁住它的父亲。CAS 失败也不要紧,说明别人抢先了,重读、重试即可。
零件三:合并与回收。链越长读越慢(原论文的实测曲线里,吞吐随链变长明显下降),所以链长超过阈值就合并(consolidation):把基础页和整条链算成一个新页,再 CAS 进映射表。换下来的旧页与旧增量不能立刻释放——可能还有线程正在读,而无闩结构里没人能告诉你「读完了」。解法是基于纪元的回收(epoch-based reclamation):待释放对象挂进当前纪元,等进入过该纪元的线程都离开了再统一释放。
分裂怎么办?这是最难的部分:分裂要同时改自己和父亲两个节点,而 CAS 一次只能原子地改一处。做法是拆成两个各自原子的步骤:第一步在被分裂节点上装一条「分裂增量」,附带指向新兄弟的侧向指针(Blink-tree 的思路)——此时结构已可用,父节点虽不知情,走到这里的线程顺着侧向指针也能找到目标;第二步再往父节点装一条「索引项增量」。撞见半完成分裂的线程不干等,而是顺手把第二步替它做完。
最后一支路线来自算法领域,思想漂亮但工程上少见。经典 B-Tree 要调一个参数:节点多大才配得上页大小。可存储层级不止一层——L1 / L2 / L3 缓存、内存、SSD,每层的「块」都不一样,一个节点大小不可能同时讨好所有层。缓存无关 B-Tree 用递归的 van Emde Boas 布局把树摊平:反复把树从中间高度切开,上半棵与每个下半棵各自连续存放。于是无论上层用多大的块来读,读到的那段里都恰好含一条连续的树路径,不知道块大小也能拿到接近最优的 I/O。代价是更新时维护这个布局很贵——它是一种启发,而非生产选项。
表 1 · 四条变体路线:各治什么病,代价是什么
| 写时复制 | 惰性 / 缓冲 | FD-Tree | Bw-Tree | |
|---|---|---|---|---|
| 主治 | 崩溃安全 + 读并发 | 写放大 | 闪存上的随机写 | 多核上的加闩开销 |
| 关键动作 | 路径拷贝 + 原子换根 | 更新先入内存链,驱逐时归并 | 分层不可变有序段 + 栅栏指针 | 增量链 + 映射表 CAS |
| 白赚了什么 | 不需要 WAL 与恢复流程;读者天然拿到快照 | n 次改动摊成 1 次页重写 | 写路径全部是顺序写 | 线程永不阻塞,被挂起也不拖累别人 |
| 主要代价 | 单次写放大 ≈ 树高;只能一个写者;长读事务卡住页回收 | 读要合并两份数据;未落盘更新需日志兜底 | 读放大(逐层查)+ 归并的写放大 | 实现复杂;指针追逐伤 CPU 缓存;需 epoch 回收 |
| 真实系统 | LMDB(OpenLDAP) | WiredTiger(MongoDB)、LA-Tree | 论文实现,思想入 LSM 一脉 | SQL Server 内存优化表的非聚集索引 |
表 2 · 原地更新 vs 不原地更新:一次决定引出的一串连锁反应
| 原地更新(经典 B-Tree) | 不原地更新(本章各变体) | |
|---|---|---|
| 崩溃后怎么办 | 页可能被写坏,必须靠 WAL 重放 / 回滚,还得防撕裂页(双写、整页写 WAL) | 旧版本原封不动,切换是原子的,最轻的实现连恢复流程都不需要 |
| 读者与写者 | 读者要加闩,写者改页时读者得等 | 旧版本一直可读,天然快照 / MVCC |
| 写的形状 | 随机写,位置由树结构决定 | 偏向顺序写(归并、追加),对闪存友好 |
| 空间 | 紧凑;碎片来自页内空位 | 要为旧版本留位置,必须有回收机制 |
| 谁来打扫 | 没什么可扫,但恢复代码复杂 | 空闲页表 / 合并 / epoch 回收 / 归并——省下的写变成后台的活 |
落到选型上的判据:
这一章的价值不在于让你去实现一棵 Bw-Tree,而在于一张可迁移的权衡地图:不可变 + 间接层 + 缓冲 + 延迟回收。ZFS、Btrfs 的写时复制与 LMDB 同源;容器镜像的分层、Git 的对象存储也是「不改旧的、只加新的、最后换一个引用」。凡是把「原地修改」换成「新写一份 + 原子换指向」的系统,都会同时收获快照能力与崩溃安全,也同时欠下一笔垃圾回收的债。面试里「LMDB 为什么不需要 WAL」「无闩就一定更快吗」「为什么写优化存储都长得像分层归并」,考的都是这一章。
WT_INSERT 结构插入行存叶子页。叶子页上已有的条目通过 WT_UPDATE 结构更新、修改或删除。随着新的更新产生,这些结构被串成一条更新链」;而脏页被驱逐时「要经过归并(reconciliation)」。WiredTiger 官方架构文档 · arch-btree ↗2700 万次 get/set,读写比约 7.5:1)上,Bw-tree 吞吐 10.4 M ops/s,BerkeleyDB 为 555 K ops/s,提速 18.7 倍;写更密集的去重负载上是 8.6 倍,合成负载 5.8 倍。对照无闩跳表也快约 3.7 倍(3.83 对 1.02 M ops/s)。J. Levandoski, D. Lomet, S. Sengupta《The Bw-Tree》, ICDE 2013 ↗1.1–2.5 倍,但结论毫不含糊——「尽管有我们的改进,Bw-Tree 的表现仍不及其他使用锁的并发数据结构」。这是对「无闩必然更快」最有力的一记反驳。Z. Wang, A. Pavlo et al.《Building a Bw-Tree Takes More Than Just Buzz Words》, SIGMOD 2018 ↗O(logB n) 次顺序 I/O、查找花 O(logB n) 次随机 I/O」,效果是「查找性能与标准 B+-tree 相仿,更新性能与写优化的 B+-tree 变体相仿」——正是本章想要的那种「两头都不吃亏」。Y. Li, B. He, R. J. Yang, Q. Luo, K. Yi《Tree Indexing on Solid State Drives》, VLDB 2010 ↗① 经典 B-Tree 的成本重心在「改」不在「找」:改 100 字节要重写一个 8–16 KB 页,还要加闩挡住并发。本章所有变体都冲着这两笔账去。
② 三笔账:写放大(页级 80–160 倍,再叠双写 / 整页写 WAL / SSD 擦除块)、加闩开销(多核下根节点的缓存行争用)、碎片与空间。
③ 写时复制:路径拷贝 + 原子换根。白赚崩溃安全(可以完全不要 WAL)与快照隔离,代价是单次写放大 ≈ 树高、只能有一个写者、旧页要等长读事务放手。代表:LMDB。
④ 惰性 / 缓冲:承认「内存形态 ≠ 磁盘形态」,更新先挂内存链,驱逐或检查点时归并成一个新页,把 n 次改动摊成 1 次页重写。代表:WiredTiger。
⑤ FD-Tree:头部小 B-Tree + 逐层放大的不可变有序段,写路径全是顺序写,再用栅栏指针(分数级联)抵消多层查找的读放大。它是通往下一章 LSM 的桥。
⑥ Bw-Tree:增量链(写变便宜)+ 映射表(换一页 = 一次 CAS,父节点不用动)+ 合并与 epoch 回收;分裂拆成两个原子步骤,用 Blink-tree 式侧向指针撑住中间态,别的线程撞见了顺手帮忙做完。
⑦ 缓存无关 B-Tree:用 van Emde Boas 布局把「页多大」这个参数从设计里删掉。思想漂亮,维护昂贵,实践罕见。
⑧ 贯穿全章的主线:不可变 + 间接层 + 缓冲 + 延迟回收——省下的写会变成多占的空间和后台的活,总量不会凭空消失。
⑨ 最该记住的反直觉结论:无闩不等于更快。厂商给出 18.7 倍的正面数字(内存内、对比 BerkeleyDB),独立复现却发现它仍不及使用锁的现代并发结构。看结论要看谁做的实验、在什么负载上、和什么比。