专业书籍精读 · Database Internals · 第 5 章
Database Internals · Ch 5 · Alex Petrov · 2019
你在手机上点「转账」,屏幕转了半秒,弹出「成功」。就在这半秒里,机房停电了。
前四章讲的是数据库怎么把数据摆得整齐、找得飞快。这一章讲另一件事:改到一半断电了怎么办;两个人同时改同一条记录怎么办。没有这一章,前面那棵漂亮的目录树只是个「单人使用、且永不断电」才成立的玩具。
你大概以为:硬盘上那些数据文件是数据库的「正本」,旁边那个日志文件是备份。其实反过来。日志才是正本、才是真相;数据文件只是日志的一份「誊清稿」——丢了照着日志重誊一遍就是。数据库的可靠性,整个建立在这层颠倒的关系上。
一,改一件事要动好几个地方。转账要一边减、一边加,这两下必须要么都算数、要么都不算数——只做一半,钱就凭空消失了。
二,改动发生在内存里,而内存断电就没。数据库不可能每改一个字就跑一趟硬盘,它把常用的那几页搬到内存「桌面」上改、攒一批再写回去。中间一停电,桌面上没写回去的全丢。
三,往硬盘上东一处西一处地写,慢得离谱。你改的记录散落在第 3 页、第 700 页、第 9 万页;硬盘最怕来回翻找,最喜欢「一直往后写」。
先记流水账,再动正本。要改什么,先在一本只往后添、从不回头改的流水账上写一行「我要把第 700 页那个数从 100 改成 80」;这一行落盘了,才允许去动正本。流水账一路往后写,正合硬盘胃口。于是提交一笔转账,你只需等这一行账落盘——正本什么时候誊清,慢慢来。
崩溃之后,先重演,再撤销。最反直觉的一步:断电重启,数据库不是先清理烂摊子,而是拿起流水账把整段历史原样重演一遍——连那些明知最后要撤销的动作也照做。等世界还原到断电前一刻,它才回头把那些「话说了一半、没来得及说『我做完了』」的事务倒着撤掉。先铺平地板再挪家具,比一边铺一边挪简单得多。
两种完全不同的「锁」。一种管人:不让你看到别人做了一半的事,跟着整笔事务从头持到尾。另一种管手:内存里那页纸正被改写,别的线程这会儿读到的会是半成品——它只护住那几微秒,改完立刻放。中文都叫锁,其实是两码事,混淆它们是新手最常摔的跟头。
有了这几招,数据库才敢说那句「成功」。它的意思不是「你的钱已誊到正本上」,而是「这件事已写进流水账、流水账已真的落盘;剩下的交给我,断电也丢不了」。代价也藏在这句话里:只要链条上有一环(硬盘、固件、文件系统)谎称「写好了」,上面所有精巧设计一起落空。
数据库的真相不在数据文件里,在那本只往后添的流水账里:改任何东西之前先记账,提交只等这一行账落盘;断电重启后,先照账把历史整个重演一遍,再把没说完的话撤掉。至于并发,记住有两种锁——一种护事务之间的规矩,一种只护内存里那页纸的几微秒。
想进到具体机制、记号和示意图? → 切到精读版
这一章把存储引擎从「一个会存会找的数据结构」升级成一个真正的数据库:ACID 不是某个模块提供的,而是页缓存、预写日志、并发控制三件套合力挤出来的结果。而它们共同的底牌只有一条——改数据之前先把要做的事写进日志。由此得出全章最该记住的一句反直觉判断:磁盘上的数据文件不是数据库的正本,日志才是;数据文件只是日志的一份可以丢弃、可以重算的物化结果。
8 KB,InnoDB 默认 16 KB)。fsync:命令操作系统把字节真正推到持久介质并等它确认——唯一能说「丢不了」的动作,也是整条链上最慢的一步。本章是 Part I「存储引擎」的第 5 章,也是这一部分的收口之作。前四章——引论与概览、B-Tree 基础、文件格式、实现 B-Tree——描述的都是一个静态、单线程、永不断电的世界里数据该怎么摆。这一章把那棵树扔进真实世界:会有几百个并发连接同时读写它,随时可能断电。往后,Ch6(B-Tree 变体)里的写时复制(copy-on-write)就是「换一种方式实现原子性」的分支,Ch7(LSM-Tree)则把本章的「日志」直接抬成主存储结构;而 Part II 的 Ch13 分布式事务,本地那一半正是这里讲的东西。
把前四章的成果摆到一起,会看见一个尴尬的缺口:一次逻辑上「原子」的改动,落到存储引擎里往往要动好几个页。往 B-Tree 插一条记录可能触发叶子分裂、父节点加指针、甚至根节点上移——三四页各自的修改,中间任何一刻断电,磁盘上留下的就是一棵结构损坏的树:指针指向一个还没写完的页,整个索引作废。
而这些页都不是直接改在磁盘上的,是改在内存的页缓存里——也就是说,任何时刻磁盘上的数据文件都处在「过期且不自洽」的中间状态。再叠上几百个事务同时读写,问题就拧成三股:内存与磁盘之间的搬运怎么既快又可控(页缓存与驱逐、检查点)、断电后怎么从一堆半成品里还原出自洽的数据库(日志与恢复)、并发事务之间怎么不互相看到半成品(并发控制)。不解决它们,数据库能给的最强承诺只有「祝你好运」。
面试里问「ACID 怎么实现的」,标准答案不是一句话,而是一张分工表。一个事务从进入引擎到提交,依次经过四个组件:事务管理器协调整笔事务、决定提交还是中止;锁管理器守住被访问的逻辑对象,别的事务想碰就排队;页缓存把页从磁盘调进内存,所有修改都发生在这里;日志管理器把「我准备怎么改」先写成日志。
操作系统本来就有文件缓存,数据库为什么还要自己做一层?因为它需要三件操作系统给不了的东西:知道哪些页正被人用(不能踢)、哪些页被改过(踢之前必须先落盘)、以及决定谁先落盘的权力。这三件都直接关系正确性,交不出去。
关键动作有四个。钉住(pinning):页正被使用时加引用计数,不为零就不许驱逐——B-Tree 往下走时,路径上的页都是钉住的。标脏:内存里改过就打标记。驱逐(eviction):缓存满了挑一个没被钉住的页踢掉;若它是脏的得先写回,而写回之前还必须确认覆盖它的日志已落盘。检查点:与其等缓存满了被动零星刷盘,不如定期主动成批刷,顺便在日志里打个标记,把恢复起点往前推。
驱逐策略比课本上的 LRU 复杂得多,原因很实际:朴素 LRU 会被一次全表扫描彻底冲垮。一条 SELECT * FROM 大表 顺序读过几百万页、每页只用一次,却把攒了几小时的热点页全挤出去——扫描结束,命中率归零,接下来几分钟所有查询都在打磁盘。所以 MySQL InnoDB 把 LRU 链表切成「新 / 旧」两段:新读进来的页先插在中点(旧段头部)而不是最前面,再次被访问才升级进新段——一次性扫描的页只在旧段里打个转就被淘汰,碰不到热点。旧段默认占 3/8(innodb_old_blocks_pct 默认 37)。
WAL 的规则只有一句话,却撑起整章:任何对数据页的修改,其日志记录必须先于该数据页落盘。反过来读更有味道——只要日志在,数据页何时落盘就完全自由了,因为随时能照日志重算。数据文件由此降格成「日志的一份物化快照」。
这笔买卖为什么划算,要给量级。一笔事务改了 5 条散落在不同页上的记录:老实刷盘是 5 次随机写;走 WAL 则是把 5 条日志拼进一段连续字节,一次顺序写 + 一次 fsync。机械盘上随机写要付寻道时间(约 5–10 毫秒),顺序写几乎不用;NVMe 上寻道虽已消失,一次 fsync 的固有开销(0.1–1 毫秒量级)仍远大于追加几条记录进日志缓冲。差距还会被组提交(group commit)放大:并发到达的几十笔事务共享同一次 fsync——每秒 5 万笔小事务的负载下,fsync 次数能从 5 万降到几千。
日志里记什么?三种口味。物理日志记「第 700 页第 32 字节从 0x0A 变成 0x14」——重放精确,但一次 B-Tree 分裂会产生大量字节级记录。逻辑日志记「往表 t 插入 (k=42, v=…)」——紧凑,但重放它要求数据库已处在语义完整的状态。ARIES 取折中的物理逻辑日志(physiological logging):页与页之间是逻辑的、页内部是物理的——记「在第 700 页里插入这条记录」,页内怎么挪字节由页自己负责。
再有两条策略选择,构成全章最经典的四象限。偷(steal):允许把尚未提交的事务弄脏的页写回磁盘吗?强制(force):事务提交时必须把它改过的页全写回磁盘吗?
表 1 · steal / force 四象限:为什么工业界几乎一致选了「偷 + 不强制」
| force:提交时强制刷所有脏页 | no-force:提交时不刷脏页 | |
|---|---|---|
| no-steal 未提交的脏页不许写回 | 既不用 redo 也不用 undo,最简单;但提交慢(随机写)+ 缓存被未提交事务占死 | 不用 undo,但要 redo;仍然受「大事务撑爆缓存」之苦 |
| steal 未提交的脏页可以写回 | 需要 undo,不需要 redo;提交依然慢 | 需要 redo + undo,实现最复杂——但提交只等一次顺序写、缓存可自由周转。ARIES / InnoDB / PostgreSQL 走的都是这条 |
读法:左上角最好写,右下角最好用。把复杂度从运行时挪进恢复代码里,是这一章最核心的一次交易——运行时每天跑几十亿次,恢复代码一年跑不了几次。
ARIES(Mohan 等人 1992 年在 IBM 提出,DB2 的恢复算法)是「偷 + 不强制」这条路上的标准答案。它的恢复分三个阶段,顺序是分析 → 重做 → 撤销。
① 分析:从上一个检查点向前扫日志,重建两张表——脏页表(崩溃时哪些页是脏的、每页最早被哪条日志弄脏)与活跃事务表(崩溃时还没提交的事务,即「输家」)。这一步只是把崩溃瞬间的现场重新画出来。
② 重做:从脏页表里最早那个日志位置开始向前重放。最反直觉的一点在这——ARIES 管它叫重演历史(repeating history):所有事务的所有改动都重做一遍,包括那些明知属于「输家」、马上就要被撤销的。为什么不聪明点跳过?因为跳过意味着重做逻辑得判断「哪些该做」,而这个判断本身依赖一个尚未还原的状态。先无脑还原到崩溃前一刻、再统一撤销,简单得多也更不易错。避免无谓重放靠的不是聪明,是个笨办法:页头里存着它最后被哪条日志改过(pageLSN),日志编号不比它大就说明这次改动已在盘上,跳过。
③ 撤销:对输家事务沿日志倒着走,逐条抵消。第二个精巧设计在这——补偿日志记录(CLR):每撤销一步,就再写一条日志记下「我撤销了什么、下一步该撤销哪条」。于是即便数据库在恢复途中又崩一次,重启后照着 CLR 就知道撤销进行到哪了,不会重复撤销。恢复流程因此是幂等的——这点在实践中至关重要,因为「恢复时又崩」恰恰是最常见的连环故障(引发第一次崩溃的坏内存条还在机器里呢)。
并发控制要挡住一组异常:脏读(读到别人没提交的数据)、不可重复读(同一事务里两次读同一行结果不同)、幻读(两次范围查询看到的行数不同)。理论上的黄金标准是可串行化(serializability)——并发执行的结果等价于把这些事务按某个顺序一个个跑完。隔离级别,本质就是用放宽哪几种异常,换多少并发度。
实现路线有三条。悲观(锁):先申请锁再动手,冲突就等,靠两阶段锁(先只加、后只放)保证可串行化。乐观(OCC):先干,提交前再检查这期间有没有人跟我冲突,有就整笔重来。多版本(MVCC):写不覆盖旧值而是产生新版本,读按自己的时间戳取「当时该看到的那个版本」——读永远不阻塞写、写也不阻塞读,这正是它在 OLTP 场景压倒性流行的原因(PostgreSQL、InnoDB、Oracle 都是 MVCC)。
然后是本章最该单独拎出来的一对概念,也是数据库面试最高频的辨析题——锁(lock)与闩(latch)。中文都译成「锁」,可它们连守护对象都不是一回事。
表 2 · 锁 vs 闩:中文同名,实则两个世界
| 锁 lock | 闩 latch | |
|---|---|---|
| 守护什么 | 逻辑对象:一行、一段键区间、一张表 | 物理结构:内存里那一页、那个链表节点 |
| 持有多久 | 整个事务,可达秒级甚至更久 | 一次结构操作,纳秒到微秒级 |
| 为谁存在 | 隔离性——别让事务看到半成品 | 正确性——别让线程读到改到一半的内存 |
| 谁管理 | 锁管理器,有等待队列、可查询、可显式加 | 就是操作系统的互斥量 / 读写锁,无中央登记 |
| 死锁怎么办 | 检测:维护「谁在等谁」的图,发现环就选一个牺牲者回滚 | 不检测——靠约定固定的加闩顺序从设计上避免 |
| 崩溃后 | 需要恢复(锁表要在恢复期间重建) | 随内存一起消失,无需恢复 |
闩最漂亮的用法是 B-Tree 上的闩蟹行(latch crabbing)。往下查找时,每层都持闩不放,根节点就成了全库瓶颈;一层不持,又可能读到一个正在分裂的中间节点。蟹行的规则是像螃蟹倒手一样,先抓住孩子,确认孩子「安全」后再松开父亲——「安全」指这次写不会让该节点分裂或合并(有足够空位),那它的祖先就绝不会被波及,可以放心松手。全程手里最多攥两层。
顺带一提,Blink-tree 更进一步:给每个节点加一个指向右兄弟的指针和一个「最高键」。这样即使你读到的是一个刚分裂完、还没来得及更新父指针的节点,也能顺着右指针自己找到该去的地方——用一点结构冗余换掉了一部分加闩需求。
表 3 · 三条并发控制路线:什么场景选什么
| 悲观 · 两阶段锁 | 多版本 MVCC | 乐观 OCC | |
|---|---|---|---|
| 基本做法 | 动手前先申请锁,冲突就等 | 写产生新版本,读按时间戳取旧版本 | 先干,提交前验证有无冲突 |
| 读写互相阻塞 | 会——读写互斥,长事务拖垮并发 | 不会——读不阻塞写、写不阻塞读 | 不会(但可能白干一场) |
| 主要代价 | 锁开销 + 死锁检测;吞吐随冲突率快速下降 | 旧版本要占空间、要清理(PostgreSQL 的 vacuum、InnoDB 的 undo 段膨胀) | 冲突率一高就雪崩式重试,白白烧 CPU |
| 适合 | 冲突密集、事务短 | 读多写少的通用 OLTP(绝大多数业务) | 冲突稀少、事务短小 |
| 真实系统 | 早期 DB2、SQL Server 默认(悲观锁 + 意向锁) | PostgreSQL、MySQL InnoDB、Oracle | 内存数据库、部分分布式引擎的写路径 |
表 4 · 页缓存驱逐策略:为什么没人用课本上的 LRU
| 策略 | 怎么工作 | 代价 / 失效场景 |
|---|---|---|
| 朴素 LRU | 踢掉最久没被访问的页 | 一次全表扫描就能清空整个缓冲池;且维护链表要加锁,本身成为热点 |
| CLOCK | 页排成环,用一个引用位近似 LRU,指针转一圈踢掉引用位为 0 的 | 精度不如 LRU,但几乎无锁——用精度换并发度 |
| LFU / 按频率 | 踢掉访问次数最少的 | 对「曾经很热、现在没人用」的页反应迟钝,需要老化机制 |
| LRU-K / 2Q / 中点插入 | 看第 K 次访问的时间,或先放「旧段」、二次访问才升级 | 参数要调;实现复杂。InnoDB 走的是中点插入,旧段占 3/8 |
还有一组不进表、但选型时必须掂量的权衡:
checkpoint_timeout 默认 5 分钟,正是在「日常开销」和「最坏恢复时间」之间取的一个保守中值。fsync,提交延迟高但真丢不了;放宽成「每秒刷一次」(如 InnoDB 的 innodb_flush_log_at_trx_commit=2),吞吐能提升数倍,代价是崩溃可能丢掉最后一秒的已提交事务——这是拿持久性 D 直接换性能,只有在业务明确能接受时才可以按。这一章是所有单机数据库的公共地基,抽象几乎没有过时:PostgreSQL、InnoDB、SQLite、SQL Server 的恢复路径都是 ARIES 的变体或简化;就连 RocksDB 这类 LSM 引擎,内存表前面也挡着一个功能完全相同的 WAL。往上一层,它还是分布式事务的本地一半——两阶段提交里「参与者承诺我一定能提交」,兑现手段正是本地日志已经落盘。面试里「WAL 为什么快」「redo 和 undo 各解决什么」「lock 和 latch 有什么区别」都是常规题,分水岭就在能不能说清本章这几条因果。
3/8(innodb_old_blocks_pct 默认 37),就为了让全表扫描挤不掉热点页。反面印证课本 LRU 在真实负载下不够用。MySQL 8.4 参考手册 · Buffer Pool ↗fsync 描述成一道 I/O 屏障,并列出头号损坏原因之一:文件系统或硬件在 fsync 上撒谎——它一谎报「写好了」,上层所有日志设计一起失效。SQLite 官方文档 · How To Corrupt An SQLite Database File ↗fsync 可能返回成功,而那页其实已经丢了——这个假设在代码里躺了近 20 年,修复方式是fsync 一失败就直接 PANIC(PostgreSQL 12 引入并回补到 9.4 起各版本)。本章唯一的反面实证:日志算法再严密,也挡不住底层接口语义不成立。PostgreSQL Wiki · Fsync Errors ↗fsync 返回成功就万事大吉。fsyncgate 证明这条假设能在最主流的操作系统上悄悄失守 20 年。真正的持久性要求硬件、固件、文件系统、数据库四层都不撒谎。① ACID 不是某个模块给的,是页缓存 + 预写日志 + 并发控制三件套合力挤出来的;共同底牌是「先记日志,再改数据」。
② 最该记住的反直觉判断:日志才是数据库的正本,数据文件只是它一份可丢弃、可重算的物化结果。
③ 页缓存自己管,为的是掌握三件事:谁在被用(钉住)、谁被改过(脏页)、谁先落盘。朴素 LRU 会被一次全表扫描冲垮,所以工业实现都用 CLOCK / 中点插入这类变体。
④ 日志先行规则:覆盖某脏页的日志必须先于该页落盘。提交的代价因此收缩成一次顺序写 + 一次 fsync,还能靠组提交摊薄。
⑤ 四象限选型:工业界一致选偷 + 不强制(steal / no-force)——运行时最快,代价是恢复代码里 redo、undo 都得有。把复杂度从每天几十亿次的运行路径,挪进一年跑不了几次的恢复路径。
⑥ ARIES 三阶段:分析 → 重做 → 撤销。重做要「重演历史」,连注定回滚的也照做;撤销要写补偿记录(CLR),好让恢复本身再崩一次也不出错。
⑦ 并发控制三条路:悲观锁(冲突密集)、MVCC(读多写少的通用选择,代价是旧版本要清理)、乐观 OCC(冲突稀少,否则重试雪崩)。
⑧ 锁 ≠ 闩:锁守逻辑对象、持续整个事务、有死锁检测;闩守内存里的物理结构、只持续微秒、靠固定加闩顺序避免死锁。B-Tree 用闩蟹行——先抓孩子、确认安全再松父亲。
⑨ 三组要自己拿主意的权衡:检查点频率(日常开销 ↔ 恢复时间)、日志刷盘严格度(延迟 ↔ 会不会丢已提交事务)、隔离级别(并发度 ↔ 应用得自己防哪些异常)。而地基比想象中薄——fsync 一撒谎,上面全盘落空。