专业书籍精读 · Database Internals · 第 4 章
Database Internals · Ch 4 · Alex Petrov · 2019
前两章你已经知道:数据库用一棵「目录树」找数据,树的每个节点就是硬盘上的一页纸。可那都是静态的样子。这一章问的是动态的问题:这本册子每天被塞进几百万条新记录、删掉几百万条旧记录,它是怎么一边被翻、一边自己长胖变瘦,还始终不乱的?
把数据库想成一排文件柜的抽屉,每个抽屉按字母顺序放卡片,抽屉外贴着「A–F」「G–M」这样的标签。
教科书讲到这里就结束了:抽屉满了就分成两个,再去总目录上加一行新标签。听起来两句话的事。可真管这排柜子的人知道,麻烦全在这两句话之外。
四件事,教科书一件都没讲。
第一,分完家得通知上级。可你手上只有一个抽屉,怎么知道它的标签写在总目录的第几行?给每张卡片背面写上「我属于第几个抽屉」倒是可以,但抽屉一分家,里面成百上千张卡片全得重写一遍背面。
第二,分家太频繁。抽屉一满就劈成两半,两个新抽屉各只装一半——于是柜子永远只用了一半的容量,你却要多买一倍的柜子。
第三,号码牌总是递增时,对半分是纯浪费。新卡片的编号一天比一天大,永远只往最后一个抽屉里塞。这时候把满抽屉对半劈开,前半个抽屉这辈子都不会再有人往里放东西了——它就那么半空着,直到永远。
第四,删了的地方收不回来。抽走一张卡片只是留下一道缝;抽屉不会自动变小,柜子也不会自动变少。
这解释了两件你多半亲眼见过的事。用自增编号当主键,比用随机乱码当主键快得多、也省得多——顺序插入让每个抽屉都塞到快满,随机插入把新卡片撒得到处都是,处处引发分家。以及,往一张空表里灌一亿行数据,先灌数据、最后再建索引,比边灌边建快得多——前者是「一屉屉码满」,后者是「一张张插」。
诚实的代价:把抽屉塞得越满,将来在中间插一张卡片就越容易立刻引发分家——所以正经数据库都会故意留一成空位,宁可多占一点柜子。
教科书说「抽屉满了就分家」,那是全章最容易的一句。真正的功夫在别处:用面包屑找到上级、先向邻居借地方少分家、顺序号别对半切、能整柜码满就别一张张插。
想看面包屑栈怎么处理并发、右倾分裂省下多少空间、PostgreSQL 与 InnoDB 的真实默认值? → 切到精读版
你以为这一章讲的是「分裂与合并算法」,其实那两行伪代码是全章最容易的部分。真正的工程量全在分裂之外:怎么找到父节点(答案是面包屑,不是父指针)、怎么少分裂(先向兄弟借)、顺序键怎么别浪费掉一半空间(右倾分裂)、删完的洞怎么收回来(vacuum)。这一章是把第 2 章的树形状、第 3 章的页字节,缝成一个能在生产里连跑十年而不崩不胖的实现——它是「教科书 B-Tree」和「PostgreSQL / InnoDB 里那棵 B-Tree」之间的全部差距。
本章是《Database Internals》Part I「存储引擎」的第 4 章,也是 Part I 里工程味最重的一章。上承第 2、3 章——第 2 章论证了 B-Tree 为什么是磁盘上的默认答案(高扇出把树压矮),第 3 章给出了一页的字节布局(槽式页、cell、空闲清单);下启第 5 章「事务处理与恢复」,本章里每一次分裂、合并、页内压缩,到了第 5 章都得配上 WAL 才敢在崩溃面前声称正确。对应现实:PostgreSQL 的 nbtree 模块、InnoDB 的聚簇索引、SQLite 的 btree.c——本章讲的每个机制,在这三份代码里都找得到对应的实现和它们各自的取舍。
把教科书那版 B-Tree 直接搬进生产,你会连撞四堵墙。
第一堵:分裂要往上传播,而节点不知道自己的父亲是谁。叶子满了拆成两半,父节点必须多收一个分隔键;父可能也满,继续往上,最坏一路顶到根——根一分裂,树就长高一层。可页里存的只有键和子指针,没有指回父亲的路。天真的补救是存一个父指针,代价却立刻现形:节点一分裂,它名下所有子节点的父指针都要改一遍——扇出 500 的节点分裂就是 500 次额外页更新,还全得写进 WAL。
第二堵:对半分裂让你白买一半的硬件。满了就 50/50 劈开,两个新页各半满,随机插入下稳态填充率长年在 50%–70% 徘徊。给个量级:1 亿行的订单表,主键索引每条 16 字节、8 KiB 页扇出约 500,填满约 2 GB,稳态半满就是 4 GB。多出的 2 GB 不只占硬盘,它等量地挤占页缓存——本来全放得进内存的索引现在放不下,命中率一掉,延迟就抬头。
第三堵:顺序键把这笔浪费变成纯亏。主键是自增 ID 或时间戳时,每一条插入都砸在最右边那片叶子上。此时对半分裂尤其蠢:劈出来的左半页再也不会有任何新键落进去,永久半空。填充率被死死钉在 50%——而它本可以接近 100%。
第四堵:删除和更新留下的洞不会自己消失。删一条只是标记 + 挂空闲清单(第 3 章);变长字段被 UPDATE 改长了原地放不下,就得挪走、原位留洞。洞攒够了,一页有效载荷可能只剩三成,却照样占着 8 KiB 磁盘、8 KiB 缓存,每次读还是整页搬。
表 1 · 教科书讲的 B-Tree vs 生产实现必须补的东西
| 问题 | 教科书的说法 | 真实实现要补什么 |
|---|---|---|
| 找到父节点 | 「把分隔键插入父节点」——默认父节点已知 | 面包屑栈:下行时压栈,需要时弹栈回溯 |
| 节点满了 | 立刻对半分裂 | 先向兄弟借地方拖延分裂;顺序键改右倾分裂 |
| 节点太空 | 与兄弟合并 | 合并代价高——PostgreSQL 干脆只回收全空的页 |
| 一次建整棵树 | —(没讲) | 批量加载:预排序后自底向上建,零分裂 |
| 并发 | —(没讲) | 兄弟指针 + 高键(Blink-tree),让读不必上锁 |
B-Tree 节点的页头除了第 3 章讲过的那些(魔数、cell 数、空闲空间边界),还得记这一页在树里的身份:叶子还是内部节点。这决定了 cell 按哪种格式解读——叶子里是「键 + 值」,内部节点里是「分隔键 + 子页 ID」。
然后是一个容易被跳过、却逼出两种流派的细节:内部节点有 N 个分隔键,却有 N+1 个子指针。把「键 + 指针」打包成 cell,最后那个指针就配不到键——它管的是「比所有分隔键都大」的那一片。两条出路:
nbtree 直接实现了它。兄弟指针(sibling links)是页头里的另一件事:同层相邻页互指。好处很实在——范围扫描(WHERE id BETWEEN 1000 AND 9000)沿叶子链横扫即可,不必每读完一页就爬回父节点。代价同样实在:每次分裂、合并、页删除都要额外维护左右邻居,而这几个页在并发下属于不同的 latch,顺序稍有不慎就是死锁。
找到一页之后还要在页里定位。第 3 章说过,槽式页里 cell 物理上乱放,但槽目录(偏移量数组)按键有序——所以二分是在这个小而连续的偏移量数组上做的:取中间的槽,顺偏移量跳过去读键,比大小,缩小区间。一页 8 KiB 装 500 个键,log₂500 ≈ 9 步到底,全程只碰这一页,不产生任何额外 I/O。
一个小设计值得点名:没找到时不能只返回「没找到」。插入要知道插在哪儿,范围扫描要知道从哪儿开始扫。所以实现普遍让二分返回插入点,并用负数编码(如 -(插入点) - 1)把两种结果塞进一个返回值:正数是命中下标,负数一变换就是插入位置。同一次二分,三种调用者都用得上。
这是全章第一个真正的工程决断。叶子分裂后父节点要收下新的分隔键;父可能也满,继续往上;一路顶到根,根分裂,树长高一层——这是 B-Tree 唯一会长高的时刻,所以它天然保持完美平衡。问题只有一个:怎么找到父节点?
直觉方案是给每个节点存父指针,Petrov 直接否掉了它:写放大——分裂时被搬进新节点那一半的子节点父指针全部作废,扇出 500 的节点分裂就是数百次额外页更新 + 数百条 WAL 记录;并发不一致——父指针散落在几百个页里,没法和分裂本身放进同一个原子操作。
真实答案是面包屑(breadcrumbs):查找本来就是从根往下走的,那就在下行时把「经过哪一页、走的哪个槽」一路压进栈;需要往上传播时,弹栈就是父节点。妙处在于这份信息本来就白拿——你为了找到叶子,无论如何都得走这一趟。
还有一个必须处理的细节:面包屑可能过期。并发下你走到叶子的这段时间里,栈里记的父节点自己可能已经分裂,你的分隔键不再归它管。这时高键与兄弟指针第二次救场:把栈里的页读回来,发现键超过了它的高键,就沿兄弟指针右移一步再试——不必重新从根走一遍,也不必全程握着锁。
第二个工程决断针对填充率。既然分裂又贵(写三个页 + WAL)又浪费空间(两个半满页),那就能不分就不分:节点满了先看左右兄弟,哪边有空位就把边界上的几个键挪过去,顺手更新父节点里那个分隔键——这叫再平衡(rebalancing)。
这就是 B*-tree 的思路:只有自己和兄弟都满了,才把 2 个节点拆成 3 个。算笔账:普通 B-Tree 分裂后每个节点保证至少半满,最低填充率 50%;2 拆 3 让每个节点至少装到 2/3,最低填充率提到约 66%。同样的数据,索引体积小掉四分之一,页缓存里也就多装四分之一。
代价一点不含糊:一次操作要同时改两三个页(自己、兄弟、父),并发下就是同时握两三个 latch——加锁顺序必须全局一致,否则死锁;写放大也从「改一页」变成「改三页」。这是一笔拿写路径复杂度和延迟尖刺换空间与缓存命中率的交易,不是所有引擎都愿意做。反向的合并同理:听着漂亮,但同样要动多个页,还容易和分裂来回拉锯(刚合完又满了、又分开)——这正是 PostgreSQL 干脆放弃合并半空页的原因。
这一节最贴近日常。你的主键多半是自增 ID、时间戳或雪花 ID——它们都单调递增,于是每一条插入都砸在最右边那片叶子上。对这种负载,通用逻辑处处吃亏,值得单开快车道。
快车道一:右倾分裂。最右叶子满了别劈中间,把绝大部分键留在原页,只把最后一小撮挪进新页。理由很硬:左半页此后不可能再收到任何键(新键只会更大),对半劈就是把一半空间永久锁死。InnoDB 的实际填充率印证了这笔账——顺序插入与随机插入的差距近乎一倍,官方数字见下方实证框。
快车道二:缓存最右叶子。既然插入总落在同一页,就别每次从根走一趟。PostgreSQL 的 nbtree 缓存了上次插入命中的叶子,若它仍是最右叶、还有空位、且新键比页里首键大,就直接插进去,这条路径叫 fastpath。它自带一个刹车:所有写线程都去抢同一页的排他锁,反而会争用——所以拿不到锁就退回常规路径。
快车道三:批量加载。数据本来就有序时(导入、重建索引、LSM 的合并输出),逐条插入是纯浪费。正确做法是自底向上建树:把有序数据一页页码满写成叶子层,再取每片叶子的首键组成上一层,逐层往上直到只剩一个根。全程零分裂零查找,页可填到接近 100%,且是纯顺序写盘。MySQL 把它叫 sorted index build,创建或重建索引时默认走这条路。这也解释了那条经典建议:往空表灌数据,先灌数据、最后建索引。
压缩能省磁盘、省 I/O,代价是每次读都要解压。Petrov 把选择压平成一个问题:粒度多大?
还有一条不改布局的省法:去重。PostgreSQL 13 起的 B-Tree deduplication 就是典型——一个键值重复很多次时(比如给低基数的 status 列建索引),把它们合并成一条「键 + 一串行指针」的 posting list,键只存一次,既省体积又顺带推迟叶子页分裂。
最后是维护。两个来源持续制造死空间:删除(标记 + 挂空闲清单,第 3 章)与更新(变长值改长了原地放不下,只能挪走、原位留洞)。结果是页里有效载荷越来越低,页数却一个不少——磁盘、页缓存、每次整页读,成本一分不减。对策分两层:页内把存活 cell 重新紧密码到一起(代价是整页重写),页间把完全空掉的页摘下来放进全局空闲页清单供分裂复用——注意后者通常不缩小文件,只是让空间可复用。
这里有一个必须知道的现实差异。PostgreSQL 只回收「完全变空」的页——只剩几个键的页继续占着,于是「一个区间里绝大多数键最终被删掉」这种模式会长期浪费空间。InnoDB 走另一条路:填充率跌破 MERGE_THRESHOLD 就跟邻页合并,代价是两页都在阈值附近时「合了又分」地抖动。同一个问题,两种权衡,两家都在官方文档里诚实写下了自己的代价(原文见下方实证框)。
表 2 · 怎么找到父节点
| 父指针 | 面包屑栈(主流) | |
|---|---|---|
| 怎么做 | 每个节点存一个指向父亲的页 ID | 下行查找时把「页 + 槽号」压栈,需要时弹栈 |
| 额外成本 | 节点分裂时,被搬走的几百个子节点父指针全要改,还要各写一条 WAL | 零额外成本——查找本来就要走这一趟 |
| 并发 | 父指针散在数百页里,没法和分裂放进一个原子操作 | 栈可能过期,但靠高键 + 兄弟指针右移一步即可修正 |
| 适合 | 只读或极少写的树 | 几乎所有生产 B-Tree |
表 3 · 节点满了怎么办:四种分裂策略
| 策略 | 做法 | 填充率 | 代价 | 什么时候用 |
|---|---|---|---|---|
| 对半分裂 | 满了就 50/50 劈开 | 随机负载稳态 50–70% | 顺序键下左半页永久半空 | 默认兜底;键分布确实随机时够用 |
| 先向兄弟借 (B*-tree) | 自己满了先匀给邻居;两边都满才 2 拆 3 | 最低从 50% 提到 ≈ 66% | 一次动 2–3 个页,多握 latch,写放大与死锁风险都上升 | 空间 / 缓存吃紧,且愿意为此付写路径复杂度 |
| 右倾分裂 | 只把最后一小撮键挪进新页 | InnoDB 顺序插入 ≈ 15/16 (94%) | 判错负载(其实是随机键)就白留了空位 | 自增 ID / 时间戳 / 雪花 ID——绝大多数 OLTP 主键 |
| 批量加载 | 输入预排序,自底向上一次建成 | 可到接近 100%(留 fillfactor) | 要求输入已排序;建的过程中不能并发写 | 初次导入、REINDEX、LSM 合并输出 |
表 4 · 留白与回收:各家的真实默认值
| PostgreSQL(nbtree) | MySQL / InnoDB | |
|---|---|---|
| 建索引时留白 | fillfactor 默认 90(叶子填到 90%,右端扩展时同样按此填) | innodb_fill_factor,排序建索引时生效;聚簇索引插入默认预留 1/16 |
| 顺序插入的填充率 | 靠 fastpath 缓存最右叶,避免每次从根走 | 官方数字:≈ 15/16 满;随机插入则 1/2 – 15/16 |
| 页太空怎么办 | 只回收完全变空的页;只剩几个键的页继续占着 | 低于 MERGE_THRESHOLD(默认 50)尝试与邻页合并 |
| 这么选的代价 | 「区间里绝大多数键被删」的模式会严重浪费空间,需要 REINDEX | 官方警告:两页都接近 50% 时合了又分,抖动伤性能 |
| 省空间的额外招 | 去重(PG 13+,默认开):重复键合成 posting list,同时推迟分裂 | 页级压缩(ROW_FORMAT=COMPRESSED 等) |
选主键时说得出理由。「UUID v4 当主键为什么慢」不是玄学:随机键让插入均匀砸向每一片叶子,每片都得读进页缓存、改脏、写回,分裂遍地开花;换成时间有序的 ID(自增、UUID v7、雪花 ID),插入集中在最右一页,走右倾分裂与 fastpath——索引体积近乎减半,页缓存里放得下的索引近乎翻倍。这条结论有官方文档背书,不是传闻。
导数据时选得对路。往空表灌一亿行:边灌边维护 5 个索引,等于一亿次「走根到叶 + 可能分裂 + 写 WAL」;先灌数据、最后 CREATE INDEX,走的是排序 + 自底向上批量建树,纯顺序 I/O、零分裂、页填到接近满。
排障时找得到方向。「表只有 3 GB,索引却 12 GB,VACUUM 之后一点没小」——多半就是第七节那件事,要真收回来得 REINDEX。反过来,InnoDB 上写延迟周期性抖动,就该想到 MERGE_THRESHOLD 附近的合并—分裂拉锯。
nbtree README 直接写明:本实现基于 Lehman & Yao 的高并发 B-Tree 算法,并为反向扫描额外加了「左兄弟」链接;对单调递增的键,它缓存上次插入命中的最右叶子走 fastpath,但因为实测发现这会加剧最右页排他锁的争用,所以只条件性地尝试取锁,拿不到就退回常规路径。PostgreSQL 源码 src/backend/access/nbtree/README ↗fillfactor 默认 90——静态表适合 100,频繁更新的表该调低以减少分裂。PostgreSQL 文档「CREATE INDEX」↗ PG 13 起还默认开启 B-Tree 去重:重复键合并成「键 + 有序 TID 数组」的 posting list,文档称其显著减小索引体积,且因为是懒执行的,还能阻止或推迟叶子页分裂。PostgreSQL 文档「B-Tree Implementation · Deduplication」↗MERGE_THRESHOLD 默认 50(即此前硬编码的值,可设 1–50),删除或缩短行导致页填充率跌破它就尝试与邻页合并;文档并直言「若两页都接近 50% 满,合并后很快又会分裂;这种合并—分裂若频繁发生,会对性能产生不利影响」。MySQL 文档「Configuring the Merge Threshold for Index Pages」↗innodb_fill_factor 预留增长空间(文档明确称该值是「提示而非硬性上限」)。MySQL 文档「Sorted Index Builds」↗50%–70%,意味着你为索引买的硬盘和内存有三分之一到一半在装空气。本章大半篇幅(再平衡、右倾分裂、批量加载、去重)都在跟这个数字较劲。REINDEX / VACUUM FULL,代价是重建。① 一句话:教科书的「满了就分裂」是全章最容易的部分,真正的工程量在分裂之外——怎么找父节点、怎么少分裂、顺序键怎么不浪费、删完怎么收回来。
② N 个分隔键配 N+1 个子指针,落单那个要么进页头(最右指针,SQLite),要么靠高键配平(PostgreSQL)。而高键 + 兄弟指针 = Lehman & Yao 的 Blink-tree:读者靠「键超过了高键」识破并发分裂、右移一步继续,几乎不用加读锁;兄弟链还让范围扫描不必爬回父节点,代价是每次分裂 / 合并都要顺手维护邻居。
③ 页内二分是在有序的偏移量数组上做的(8 KiB 页约 500 键,log₂500 ≈ 9 步),且必须返回插入点而非只报「没找到」。
④ 面包屑栈取代父指针:下行时顺手记下路径,传播分裂时弹栈即得。父指针出局,是因为节点一分裂就要改数百个子节点的父指针,且无法与分裂原子化。
⑤ 先向兄弟借地方(B*-tree)把最低填充率从 50% 提到约 2/3,代价是一次动 2–3 个页、多握 latch。反向的合并同理,所以 PostgreSQL 干脆放弃合并半空页。
⑥ 顺序键值得单开快车道:右倾分裂(InnoDB 顺序插入页约 15/16 满,随机插入只 1/2–15/16)+ 缓存最右叶(PostgreSQL fastpath,且会因锁争用主动退回常规路径)。
⑦ 批量加载:输入已排序时自底向上建树,零分裂零查找、纯顺序写、页填到接近满——这就是「先灌数据、最后建索引」快得多的原因(MySQL 的 sorted index build)。
⑧ 压缩只有粒度一个问题:整文件压了页就不可寻址,按 cell 压不出东西,按页是主流折中;另有一条不改布局的省法是 PG 13+ 的去重。而维护是长期账:删除与变长更新持续制造死空间,靠页内压缩 + 页级回收兜底,但通常不缩小文件——PostgreSQL 只回收全空页(需 REINDEX 才真收),InnoDB 用 MERGE_THRESHOLD 合并(代价是合了又分的抖动)。