只要你用开源关系型数据库写过业务,就一定绕不开这三样东西——undo log、redo log、bin log。很多人能把“事务四大特性”“ACID”背得滚瓜烂熟,但真被问到“一条 UPDATE 执行过程中这三本日志各自记了什么、为什么提交的时候非要搞一个二阶段提交”,就很容易卡壳。这很正常,因为日志是数据库最底层的那套机制,平时你写 CRUD 根本看不见它,可它恰恰决定了三件要命的事:事务能不能回滚、断电后数据丢不丢、主从之间对不对得上账。
这篇文章我把这三本日志里里外外拆开讲一遍,再带你完整走一次事务提交的全程,重点讲清楚二阶段提交为什么存在、崩溃恢复时是怎么靠日志状态来“断案”的。适合对数据库原理有兴趣的开发者、刚入手 DBA 方向的朋友,以及那些已经被主从数据不一致、误删数据恢复这类问题折磨过的人。看完以后你再看“双 1 配置”“组提交”“崩溃恢复”这些词,就不会再觉得它们是玄学了。
1. 三大日志的分工:先把各自的一亩三分地划清楚
1.1 undo log:藏在身后的旧账本,一切回滚的底气
undo log 属于数据库引擎层。它在事务执行期间,把每次修改前的旧值记录下来。比如你把某行数据的状态从“待支付”改成了“已支付”,真正动手改数据之前,引擎会先往 undo log 里存一条“这行原来的值是待支付”。如果事务在中途出错,要执行回滚,就把这些旧值重新写回去。
这个过程你可以理解成:你给墙刷漆之前,先拍一张旧墙面的照片。刷坏了想恢复原样,照着照片重新刷一遍就行。undo log 就是那张“照片”。
但 undo 的用途不止回滚。你可能用过可重复读隔离级别下的多版本并发控制,它是靠什么来构造老版本的?靠的就是 undo log。事务开启并创建 read view 之后,如果某个数据行被别的事务改了,当前事务要去读旧版本时,引擎会顺着当前版本的 undo 指针,在历史上找到符合可见性规则的版本。这个版本数据不是存在数据页里的,而是通过 undo 逐条还原出来的。
undo log 本身也分两类:insert undo 和 update undo。插入操作产生的 undo 相对简单,因为插入的行只有自己能看到,回滚时直接删除即可,这类 undo 在事务提交后就可以直接清理。而更新和删除产生的 undo 要复杂一些,因为可能还有别的事务在读取旧版本,提交后也不能马上清掉,要等所有可能的读取者都结束后,由后台的 purge 线程逐步清理。这也是为什么有时候一张表频繁更新,undo 文件会偏大,甚至出现版本堆积的后果。
1.2 redo log:断电之后那根救命稻草
redo log 同样属于引擎层,但它和 undo log 完全是两个方向。undo 记的是“旧值”,redo 记的是“新值”;undo 用来回滚,redo 用来重做。redo log 记录的是物理层面的修改痕迹:哪个表空间、哪个数据页、在页面哪个偏移量处、写入了什么内容。说白了,它不是记你执行了什么 SQL,而是记录这次修改在磁盘页面上到底动了哪些地方。
为什么需要它?这就得从数据库的缓存机制说起。每次写操作不能真的直接去改磁盘文件,性能会差到没法用。引擎的做法是先改内存里的缓冲池数据页,然后把这个页标记成脏页,等到合适的时机再统一刷到磁盘。这就带来一个风险:如果事务提交了、内存里的数据还没来得及刷盘,这时候机器突然断电,内存内容全没了,这一笔已提交的数据就丢了。
为了解决这个问题,业界普遍使用 WAL 机制,也就是先写日志,再写数据。事务提交时,引擎先把这条修改对应的 redo log 刷到磁盘上,这个消息一落盘,事务就算稳了。哪怕数据页还没刷盘,未来某天系统崩溃重启,引擎也会扫描 redo log,把那些已提交但尚未落盘的修改重新执行一遍,从而保证了持久性。
redo log 还有一个特点:它是固定大小、循环利用的。你可以把它想象成环形剧场里的黑板,旧内容被新内容不断覆盖。引擎内部通过 checkpoint 机制标记哪些脏页已经刷盘,刷盘之后对应的 redo 就不再有价值,可以被后续日志覆盖。如果 redo 写满了而脏页迟迟没刷下去,系统就会强制触发刷盘动作。
1.3 bin log:站在服务层的全程录像
bin log 跟前面两个完全不在一个层级。undo 和 redo 是存储引擎自己维护的,bin log 则是数据库实例的服务层产生的,和用哪个存储引擎没有直接关系。它记录的内容是“事务执行后产生的逻辑结果”,可以是执行过的 SQL 语句,也可以是每一行数据的具体变更,看你的记录格式配置。
bin log 的用途非常明确,平时你遇到的基于日志的主从复制,从库就是用主库传来的 bin log 来重放数据变更。做基于时间点、基于位置的数据恢复,也是拿全量备份加上后续 bin log 一起回放。此外,如果你想审计某段时间内哪些表被改过,同样可以从 bin log 里翻出证据。
它的生命周期和 redo 完全不同。bin log 是按文件滚动归档的,一个文件写满了就换下一个,你可以根据保留策略留下很多历史上的 bin log,而不是循环覆盖。也正因为如此,bin log 文件积累多了之后,它本身也占磁盘,日常运维里清理过期 bin log 是一个标准动作。
三种日志放一起对比,职责差异就很明显了:
| 对比项 | undo log | redo log | bin log |
|---|---|---|---|
| 所在层级 | 引擎层 | 引擎层 | 服务层 |
| 记录内容 | 修改前的旧值 | 页面物理修改 | 逻辑变更结果 |
| 主要用途 | 回滚、多版本控制 | 崩溃恢复、持久性保证 | 主从复制、恢复、审计 |
| 生命周期 | 事务结束后可逐步清理 | 循环覆盖、受 checkpoint 控制 | 文件滚动归档、长期保留 |
| 记录格式 | 内部格式 | 内部物理格式 | statement / row / mixed |
2. 为什么要二阶段提交:两本日志对不上账的后果
2.1 一次写操作在三本日志里的完整顺序
事务执行过程中,正常流程是这样的:先为事务生成 undo log,记录旧值;然后在内存页里应用修改,并生成对应的 redo log;此时数据页已经变了,但 redo 和 bin log 都还没有落到最终状态。等到事务提交的那一刻,才是真正较劲的时候。
提交动作大体分成三步:首先把该事务的 redo log 刷入磁盘,并标记为 prepare 状态;然后写入 bin log 并落盘;最后把 redo log 中的事务标记为 commit。这里最关键的一点在于 redo 的 prepare 状态出现在 bin log 写入之前,而 redo 的 commit 状态出现在 bin log 写入之后。正是这一步,构成了所谓的二阶段提交。
你可能要问:为什么提交一个事务要搞得这么麻烦,直接把 redo 刷盘、打个 commit 完事不就行了吗?问题在于光引擎自己点头还不够,bin log 也必须跟着点头。两个日志分布在不同的模块,它们各自记录各自的,不可能天然保持一致。任何一个先写入、任何一个后写入,崩溃恢复时都可能出现“引擎说提交了,bin log 却没有”或者“bin log 有了,引擎却没提交”的尴尬局面。
2.2 假设没有二阶段提交,会发生什么
为了理解二阶段提交存在的意义,不妨看看如果不用它会出现哪些问题。
先设想一种方案:先写 redo 并提交,再写 bin log。主库上引擎已经把事务提交了,数据也稳了,但这时候机器崩了,bin log 还没写出去。等主库恢复,数据没有丢,可 bin log 里压根没有这个事务的记录。后面的从库拿不到这条变更,数据就和主库不一致。更麻烦的是,如果你以后需要拿 bin log 做恢复,这条事务也丢了,主库和从库之间的账永远对不上。
再设想另一种方案:先写 bin log,再提交 redo。如果 bin log 已经落盘、事务却被回滚了,那么从库会把这条变更重放出来,可主库自己反而没有这条数据。这就更离谱了,业务上从库多出了一部分数据,主从不一致问题的方向反了过来。
所以你会发现,单靠调整先后顺序解决不了问题。必须要有一个机制,让 redo 和 bin log 要么一起生效,要么一起不生效,不能出现中间状态。二阶段提交正是专门为这个原子性问题设计的。
2.3 先 prepare 后 commit:一场两阶段的“投票”
二阶段提交的思路不复杂,就是给事务提交人为地划出两个阶段。
第一阶段是准备阶段。引擎把 redo log 刷到磁盘,并标记为该事务处于 prepare 状态,同时把事务的 XID 写进日志。这个状态含义是:引擎已经准备好提交,事务的修改已经持久化到了 redo 里,只等最后一声令下。
第二阶段是提交阶段。服务层把该事务的 bin log 写入并落盘,然后让引擎把 redo 里的这个事务标记成 commit。从这一刻起,事务才算真正提交完成,客户端才会收到成功的响应。
注意一个细节:commit 标记本身并不需要立刻刷盘。它只是修改内存中 redo 日志块的状态,真正落盘可以顺延到后续刷盘时机。这是因为引擎判断一个事务是否有效,依据的是 prepare 阶段就已经持久化的 redo 内容,commit 标记只是用来标记“这个事务已经最终定案”。
3. 二阶段提交的完整执行链路与崩溃恢复断案逻辑
3.1 正常提交的分步拆解
把二阶段提交放到实际操作里去,一条事务从执行到提交,大致会经历下面这些步骤。
当你发起一条 UPDATE 语句时,事务已经隐式或显式地开启。引擎先写 undo 日志,记录修改前的旧值,接着在缓冲池中更新对应数据页,页面变为脏页,同时生成 redo 日志记录这次页面修改。这个阶段里,redo 可能还在内存的日志缓冲区里,并没有立刻落盘。
提交时刻到来,第一阶段启动。引擎把事务产生的 redo 日志从日志缓冲区写入系统表空间下的 redo 日志文件,并调用系统调用完成落盘,使它在断电后依然存活。此时日志中的事务标记为 prepare,同时写入事务的 XID。
紧接着第二阶段开始。服务层把事务的 bin log 写入 bin log 文件。同样地,bin log 也不是直接落盘的,事务产生的日志事件先进 bin log 缓冲区,提交时统一写入文件并执行落盘。这一步走完,bin log 里已经完整记录了这个事务。
最后一步,引擎把 redo 日志里对应的这个事务标记为 commit。这个动作完成后,事务才算真正结束。整个提交过程中,undo、redo、bin log 三本日志的状态才算达成一致。
这里有一个工程上的优化很重要,叫组提交。如果每一个事务提交都要刷盘一次,高并发下的 fsync 次数会非常恐怖,性能直线下降。组提交的思路是:把一批同时到达提交阶段的事务攒在一起,用一次刷盘操作同时落盘多个事务的 redo 和 bin log。这样既能保证持久性,又能把磁盘写入次数降下来。很多线上数据库把组提交调好后,即使开着最安全的双 1 配置,性能也可以接受。
3.2 崩溃之后,谁说了算
二阶段提交的真正价值体现在崩溃恢复时。数据库启动时,引擎会扫描 redo log,找出所有没有完成提交动作的事务。这里只会有两种情况:一种是在 prepare 状态下就没有后续了,另一种是已经打上了 commit 标记。
先看已经标记 commit 的事务,这个没有争议,直接判定为已提交,恢复时重放它的修改。真正需要断案的是那些停在 prepare 状态的事务。它们到底是提交了还是没提交?只看 redo 自己无法给出答案,因为二阶段提交里,prepare 状态只是准备信号,最终是否提交取决于 bin log 有没有写入完成。
这时候恢复流程会拿出事务写在 redo 里的 XID,去 bin log 里查找这个事务是否存在。如果 bin log 里能找到这个 XID,而且对应的事务事件是完整的,那说明第二阶段已经走过,只是 commit 标记还没刷盘而已。此时系统会补齐 commit 标记,事务判定为已提交。反之,如果 bin log 里根本没有这个事务,或者事务事件不完整,比如只写了一半机器就崩了,那么这个事务判定为未提交,执行回滚。
你可能会好奇,为什么偏偏拿 bin log 当判断依据?因为 bin log 是数据同步的源头,主从复制、外部数据同步都靠它。只要 bin log 里出现了某个事务,那么从库、下游系统都有可能已经持有这份数据。主库恢复时如果不承认这个事务,就会造成主库和从库之间的数据分歧。反过来,bin log 没有完整写出的事务,从库不会看到,主库也就不该保留。所以用 bin log 是否完整作为最终提交的判据,能保证所有数据管道各方看得到同一份“最终决议”。
3.3 手工模拟崩溃点,你会看到什么
为了把恢复逻辑讲透,我模拟一个事务提交过程中的三个崩溃瞬间。假设事务 T 已经执行完 UPDATE,正在走提交流程。
| 崩溃时刻 | redo 状态 | bin log 状态 | 恢复动作 | 最终结果 |
|---|---|---|---|---|
| 时刻 A:prepare 刷盘后、写 binlog 前 | T 标记为 prepare,含 XID | 没有 T 的完整事务 | 找到 prepare 事务,binlog 无对应 XID,回滚 | 事务未提交,数据回滚 |
| 时刻 B:binlog 写入完成落盘后、commit 标记前 | T 标记为 prepare,含 XID | T 的完整事务已存在,含 XID | 找到 prepare 事务,binlog 有完整 XID,补齐 commit | 事务已提交,数据保留 |
| 时刻 C:redo commit 标记已写入 | T 标记为 commit | T 已存在 | 扫描到 commit 标记,无需额外判断 | 事务已提交,数据保留 |
这张表基本就是崩溃恢复逻辑的全部。你不需要背更多的规则,三行就够。真正要留意的,是 bin log 的“完整”这两个字到底怎么判断。一个事务在 bin log 里以 BEGIN 事件开头,以 XID 事件或 COMMIT 事件结尾。如果文件里只出现了 BEGIN、中间的一堆变更事件,但没有结尾标记,就说明写一半崩了,要按未提交处理。
4. 常见问题与实战排查技巧:把三本日志变成排查利器
4.1 “双 1”配置真的任何时候都必须吗
常在数据库文档里看到两个参数:引擎层的 innodb_flush_log_at_trx_commit 和服务层的 sync_binlog。两个都设为 1 就是所谓的“双 1”,这是数据安全级别最高的配置,但很多人不清楚它们分别控制什么,以及调低之后到底会有什么后果。
innodb_flush_log_at_trx_commit 设置为 1 时,每个事务提交都会把 redo log 刷盘,保证事务不会因数据库进程崩溃或操作系统崩溃而丢失。设为 0 时,redo 只停留在内存日志缓冲区,由后台线程定频刷盘,性能最好,但数据库进程崩溃就会丢失最近的一批已提交事务。设为 2 时,提交时把 redo 写入操作系统的 page cache,但不等它落盘。这种配置下,数据库进程崩溃不会丢数据,因为数据还在操作系统缓存里,只有操作系统或整机断电才会丢。
sync_binlog 同理。设为 1 时,每个事务提交都刷盘 bin log;设为 0 或大于 1 的值时,bin log 落盘频率降低,性能更好,但如果系统崩溃,bin log 文件可能缺失一部分事务。同样,bin log 一旦缺失,那部分数据无法从 bin log 回放,主从节点之间也会出现分歧。
实际选型时我的建议是:交易、支付、订单这类核心链路,老老实实保持双 1,别动。日志类的、允许丢失几十秒数据的业务,可以尝试组合调整,但一定要把风险写在纸上,让业务方明确知道什么场景下会丢数。下面这张表是不同组合下的风险表现:
| redo 参数 | bin log 参数 | 数据库进程崩溃 | 操作系统崩溃 | 主从一致性 |
|---|---|---|---|---|
| 1 | 1 | 不丢已提交事务 | 不丢已提交事务 | 无不一致窗口 |
| 2 | 1 | 不丢已提交事务 | 可能丢最近未落盘事务 | 可能不一致 |
| 1 | 0 | 不丢已提交事务 | bin log 缺失部分事务 | 可能不一致 |
| 2 | 0 | 不丢已提交事务 | 两边都可能丢失 | 明显不一致窗口 |
需要特别注意:bin log 参数为 0 时,数据库进程崩溃本身也可能触发 bin log 文件的不完整。因为部分 bin log 还停留在缓存中没落盘,进程重启后这些内容就没了。不要以为 bin log 参数为 0 只会影响系统崩溃场景。
4.2 如何判断一个 bin log 文件是不是完整的
排查主从问题或做恢复时,最常做的一件事就是检查某个 bin log 文件是否完整。方法不难,核心是看事务边界。
bin log 文件开头有一个固定的文件头标记。内部每个事务以 BEGIN 事件开始,一个事务包含的各类变更事件之后,以 XID_EVENT 或 QUERY_EVENT 的 COMMIT 形式收尾。没有结尾的事件就是未完事务。正常关闭或轮转生成的 bin log 末尾还会有一个 ROTATE_EVENT,标识下一个 bin log 文件信息。
实操时可以先用 mysqlbinlog 把文件解析出来,重点看文件尾部的耗时和 XID。如果文件尾部是一个被截断的事务事件,大概率是异常断电导致的半截文件。这种文件拿去回放,要么会报错,要么会在回放时出现主键冲突、数据对不上的情况。
如果发现 bin log 不完整,又确实需要恢复数据,原则是优先用完整的全量备份回到某个可靠时间点,再应用不完整文件之前最后一个完整事务位置之后的 bin log。缺失的事务只能人工根据业务情况补齐,没有太好的自动化办法。
4.3 自己动手模拟一次“半提交”状态
理解二阶段提交最好的方式,不是看一百篇文章,而是自己造一次崩溃现场。
最直接的做法是在一个事务里故意加一个耗时的循环,比如通过存储过程或应用代码在事务中 sleep,然后在另一个线程里执行 SHOW ENGINE INNODB STATUS,观察事务的状态。也可以直接查看 performance_schema 里的事务表和日志相关表,确认 prepare 状态存在。但要看到崩溃恢复的完整效果,还是要维持事务提交与 kill 进程的时机竞争。
我试过的一种可复现做法是:开一个大事务,修改几万行数据,同时在应用层设置一个断点卡住提交前的位置。在断点处强行 kill 掉数据库进程,模拟在 redo 已刷盘、bin log 未写全的窗口崩溃。重启后去看库里的数据量,你会发现事务被回滚了。再把断点移到提交代码之后、应用收到响应之前的位置,kill 进程,重启后事务是提交状态。这两种结果的差别,就是 prepare 和 commit 的差别,也是二阶段提交里 bin log 是否完整写入的差别。
做这类实验时注意不要在真实生产库上搞,建议起一个干净的本地实例,配好双 1,关闭自动提交,用测试表操作。反复几次之后你对崩溃恢复的几个状态会有特别直观的感受。
4.4 误删数据后,利用 bin log 做时间点恢复的实操流程
最后一个场景也是 DBA 工作中最常遇到的:手滑删了一批数据,或者 UPDATE 忘加 WHERE 条件。这时候不用重装实例,只要你之前开了 bin log,并且 bin log 文件还在,就有机会救回来。
整体思路是先找到误操作发生前的 bin log 位置,再把后续的 bin log 回放到一个临时实例,导出需要的数据,最后导入原库。步骤大致如下:
- 先确认当前实例的 bin log 开启状态和文件列表。在命令行执行 SHOW BINARY LOGS,找出误操作时间段对应的 bin log 文件。
- 用 mysqlbinlog 解析误操作前后两个文件,定位到那条要命的 SQL。row 格式下会看到明确的 DELETE 事件或 UPDATE 事件,记录它的 position。如果不知道具体时间,就用 --start-datetime 和 --stop-datetime 配合,逐步缩小范围。
- 准备一台干净的临时实例,把全量备份先恢复到误操作之前的时间点,然后应用 bin log 直到误操作位置的前一个字节。注意 stop-position 一定不要包含那条错误 SQL。
- 在临时实例里导出你需要的数据。如果是 DELETE 误删,就把这段 bin log 用反操作方式处理;如果是 UPDATE 误改,从临时实例导出正确行的值,再导回原库。
- 确认无误后,把数据还原回生产环境。回写之前再次确认影响行数、业务影响面,尽量选业务低峰期操作。
这个流程里最容易踩的坑有三个。第一个是恢复了临时实例,但忘记把临时实例的 super_read_only 打开,导致后续 bin log 回放顺序受影响。第二个是 mysqlbinlog 导出时没有加 --base64-output=decode-rows,row 格式下看到的全是编码内容,没法直接分析。第三个是回放时没有从全量备份的 bin log 位置接着放,而是从头放了一遍,导致主键冲突。这三个坑我都踩过,写出来给你避雷。
4.5 几个值得长期关注的监控点
日志机制讲到最后,给几个日常运维能用上的观察指标。redo log 的写入是否频繁落盘,可以通过 SHOW ENGINE INNODB STATUS 看到 log 相关信息和操作系统刷盘统计。重点关注日志写等待时间,如果 redo 写入经常处于等待状态,优先检查磁盘性能,尤其是磁盘是不是机械盘、是否有其他任务抢占 IO。
bin log 这边要关注文件增长速度和磁盘剩余空间,以及每个 bin log 文件的写入是否正常轮转。主从同步出现延迟,除了网络因素,大概率是 bin log 较大加上从库回放能力不足。可以看从库的复制线程状态,确认 IO 线程和 SQL 线程之间是否有积压。
undo log 则需要看信息里面的 history list length,这个值代表未清理的历史版本链表长度。如果这个值持续增大,说明存在长事务,或者有非常老的读事务一直不结束,导致 undo 无法清理。它大涨通常意味着你的某条业务 SQL 跑太久了,或者是连接池里的连接长时间不提交、不关闭事务。
最后说点实在的
我在多次处理数据库故障之后,最大的体会是:很多看似玄学的问题,追到根上其实都是日志问题。主从延迟卡死,去翻 bin log 的写入和回放;实例崩溃恢复后发现数据不对,回去看 redo 和 bin log 的 XID 是否对得上;长事务拖垮整个实例,查 undo 的历史版本堆积。把这些日志机制吃透了,你再去看那些部署参数、高可用方案、备份恢复策略,会发现每个设计都有它对应的底层逻辑。
如果只记一句话,那就是:redo 保证你提交的数据不丢,bin log 保证你能把数据同步出去且能恢复回来,undo 保证你随时有反悔的余地。而二阶段提交,就是让这三件事在同一个事务上同时成立的那道锁。