7AA7 博客

数据库事务日志:WAL、redo 与 undo 的作用机制

Cache 孙 UID:133
2026-10-18 15:48
55 0 2

把某一行从 1 改成 2,如果直接改磁盘上的数据页,这是一次随机写。数据库每秒要改成千上万行,数据页在磁盘上的位置又是分散的,全靠随机写会立刻碰到 IOPS 上限。日志的作用就是把这个随机写换成顺序写。

为什么需要 WAL

顺序写和随机写不在同一个量级:机械盘随机写的 IOPS 通常在几百量级,顺序写吞吐可达上百 MB 每秒;SSD 上差距缩小但仍存在,随机写还带来更多写放大。具体数字取决于硬件与阵列配置,量级关系是稳定的。

WAL(Write-Ahead Logging)的规则只有一句:修改数据页之前,必须先保证描述这个修改的日志已落盘。它带来两个结果:

  • 持久性有保障了。数据页没来得及写也不要紧,只要日志在,就能重新做出来。
  • 性能也变好了。事务提交只要求日志落盘,不要求脏页落盘,多次随机写被推迟,可以合并、可以在后台慢慢做。

顺序写还带来并发上的好处:多个事务同时提交时,日志追加到同一文件尾部,是一次 IO 而不是多次。

redo 与 undo 的分工

一个事务产生的日志里同时包含两种记录,它们解决的问题不同。

redo undo
记录内容 物理日志:某个页面的某个偏移改成了什么 逻辑日志:如何撤销这次修改
保证的特性 持久性(已提交的不丢) 原子性(未提交的能回滚)
重放方向 向前,把修改再做一遍 向后,把修改撤销
额外用途 崩溃恢复 MVCC 的一致性读

redo 是物理的,它不关心这行数据的业务含义,只记录”页面 X 偏移 Y 处的值从 A 变成 B”。这让重放变得简单且幂等——配合 LSN 判断,已做过的修改可以直接跳过。

undo 是逻辑的,记录撤销动作:插入对应删除,把值从 1 改成 2 对应改回 1。它除回滚外还承担 MVCC 的另一半职责——当事务要读”它开始时的版本”而最新版本被未提交事务改过,就顺着 undo 链往回找。

有一处容易忽略:undo 本身的写入也受 redo 保护。undo 也写在页面里,对它的修改同样要记 redo,否则崩溃后 undo 自己先坏了。

写日志与刷脏页的解耦:checkpoint

数据页被修改后先在缓冲池里成为脏页,不必立刻写回磁盘。这就是日志与数据页的解耦:事务提交时只要求日志落盘,脏页什么时候写回是另一个决定。

但如果永远不刷脏页,两件事会失控:日志会无限增长,恢复时要从头重放。checkpoint 就是为了解决这两点:在某个时刻把一批脏页刷回磁盘,并记录一个位置,表示”这个位置之前的日志所对应的修改都已经落在数据页上了”。

checkpoint 之后,它之前的日志在崩溃恢复时就不需要重放了,日志空间也可以开始复用。

刷新方式有两种形态,取舍明确:

  • sharp checkpoint:一次刷完所有脏页,日志可立刻截断,恢复时间最短,但刷的瞬间产生 IO 尖峰,表现为周期性卡顿。
  • fuzzy checkpoint:分批刷,记录渐进的 checkpoint LSN,IO 平滑,代价是恢复起点更靠后、恢复时间更长。

实际多用后者,并用水位控制触发时机:脏页比例或日志使用量达到阈值就启动一轮。日志文件的总大小是关键配置——配太小会频繁触发 checkpoint,把 IO 压力传导到前台;配太大则占用磁盘且恢复时间变长。

LSN:把”做到哪了”变成一个数字

LSN(Log Sequence Number)是单调递增的日志序号,通常是日志文件的字节偏移。它出现在三个地方,构成了恢复机制的基础:

  • 每条日志记录有自己的 LSN。
  • 每个数据页的头部记录最后一次修改它的 LSN(page LSN)。
  • checkpoint 记录它自己的 LSN,表示恢复起点。

重放一条日志时先比较它的 LSN 与页面的 page LSN:若 page LSN 已大于等于它,说明修改已在页面里(脏页可能在崩溃前就刷盘了),跳过;否则应用。这个比较让 redo 重放天然幂等,恢复可以放心地”从起点一路重放到尾”。

事务提交时还会有一个提交 LSN,它决定了事务在恢复时应该被视为已提交还是未提交。

崩溃恢复的两个阶段

重启时数据库处于不确定状态:有些已提交事务的修改可能还没刷盘,有些未提交事务的修改可能已经刷盘。恢复的任务是消除这两种不一致。

第一阶段:分析。 从 checkpoint 记录的位置开始向后扫描日志,重建崩溃时刻的状态——哪些事务处于活跃状态(还没提交),哪些页面是脏的。这个阶段不修改数据,只恢复元数据。

第二阶段:重做。 从 checkpoint LSN 开始,把所有日志记录重放一遍,包括那些属于未提交事务的记录。这一步把数据库还原到崩溃发生的那一刻:已提交的修改补齐,未提交的修改也照样写上。

第三阶段:回滚。 对分析阶段找出的活跃事务,沿着它们的 undo 链把修改撤销掉。

第二阶段要重做未提交事务常被误解。原因很直接:未提交事务的修改可能已有一部分刷进数据页,而 undo 回滚要在一致的页面状态上进行,不先把崩溃时刻的状态完整还原,回滚就会作用在错误的基线上。所以顺序固定——先 redo 到崩溃点,再 undo 掉没提交的部分。

binlog 是另一种日志

把 redo 和 binlog 混为一谈是常见的混淆,它们是不同层次、不同用途的东西。

redo log binlog
所属层次 存储引擎层 数据库服务层
日志形态 物理日志(页面级) 逻辑日志(语句或行事件)
写入方式 循环写,空间可复用 追加写,按策略轮转或清理
主要用途 崩溃恢复 主从复制、时间点恢复

因为处于两层,一份修改要写进两个日志,就必须解决”两者不一致”的问题。做法是两阶段提交:redo 先写 prepare,再写 binlog,然后 redo 写 commit。恢复时拿 redo 里 prepare 状态的事务去 binlog 中查,找到就提交,找不到就回滚。

这解释了一个常见现象:关闭 binlog 时提交快一些,因为少了一次落盘和一次协调,代价是没有复制和时间点恢复能力。

刷盘策略:持久性与吞吐的取舍

日志写在用户态缓冲区,再进操作系统页缓存,最后才到磁盘。事务提交时到底要刷到哪一层,是一个可配置的取舍,常见的选项大致是:

  • 每次提交都 fsync。持久性最强,代价是每个事务一次同步 IO,吞吐受限于单次 fsync 的延迟。
  • 每秒刷一次。吞吐明显更好,代价是主机崩溃(不是进程崩溃)时可能丢失最后一秒内已提交的事务。
  • 交给操作系统自行决定。最快,也最不可控。

要注意”进程崩溃”和”主机掉电”的区别:日志写进操作系统页缓存但没 fsync,进程崩溃时数据还在,主机掉电就没了。评估持久性需求必须区分这两种故障——多数业务能接受进程级保护,金融类场景通常要求掉电也不丢。

组提交(group commit)是缓解 fsync 代价的主要手段:并发提交的事务不必各自 fsync,而是排成一批由一次 fsync 刷下去。并发较高时对吞吐的改善相当明显;并发很低时每个事务仍要等到下一次刷盘,延迟反而可能变差。

引入 binlog 的两阶段提交之后,组提交要跨两层协调——redo 的 commit 与 binlog 的写入安排在同一批次里,实现比单层复杂,原理一致:把多次同步 IO 合并成一次。

刷盘策略最终是业务问题而非技术问题:能接受丢多少、能接受多慢,取决于数据重要性与恢复手段。把这一点写成明确预期,比调参数名更重要。

2.3k浏览量
55赞
0评论
0参与用户
8 分钟阅读时间

评论(0)

还没有评论,来抢沙发。