MySQL / InnoDB 存储引擎系列 · 第四篇

为什么强行杀掉数据库进程,已提交的数据也不会丢?

上一篇讲到:脏页不会立刻刷盘,后台慢慢刷就行,前提是"只要日志写盘了,崩溃后能靠日志把它重放回来"。这一篇把这句话坐实——不是讲道理,是真的把本机运行的 MySQL 进程 kill -9,看已提交和未提交的数据分别会怎样。

这就是 redo log:先写日志、后写数据(Write-Ahead Logging)。下面所有现象都是本机 MySQL 9.7.1 真实崩溃、真实重启、真实查出来的结果。

kill -9
两次真实杀死 mysqld 进程做的实验
已提交 → 在
进程被杀后重启,已 COMMIT 的行原样还在
未提交 → 消失
同样被杀,未 COMMIT 的行重启后不见了

Write-Ahead Logging:先写日志,后写数据

核心原则一句话:任何修改要生效,必须先把"打算怎么改"写进日志(并且日志已经落盘),然后才谈得上"这个修改是安全的"——至于对应的数据页什么时候真正刷盘,上一篇已经说过,不着急。

LSN

日志序列号

单调递增的计数器,标记日志流写到哪了。每个页也记录着"最后一条应用到它身上的日志是第几号"——这是判断"这个页新不新"的依据。

mtr

mini-transaction

InnoDB 内部最小的原子日志单元——不是 SQL 那个"事务",而是"对页做的一小组修改,要么全记下来,要么都不记"。源码文件名直译就是"迷你事务缓冲区"。

崩溃恢复

先重放,再回滚

重启时分两步:先把日志里已经落盘的记录全部重放一遍(不管是谁的事务),再把那些"重放出来但没有提交记录"的事务,依据 undo log 撤销掉。

storage/innobase/trx/trx0roll.cc · mysql-server @ mysql-9.7.1, L708 —— 这就是下面实验里"未提交的行消失了"背后真正执行的函数 /** Rollback or clean up any incomplete transactions which were encountered in crash recovery. If the transaction already was committed, then we clean up a possible insert undo log. If the transaction was not yet committed, then we roll it back. */ void trx_rollback_or_clean_recovered(bool all)

真实实验:两次 kill -9

不是模拟——直接找到本机 mysqld 的真实进程号,发 SIGKILL,进程立刻消失,没有任何优雅关闭的机会。

实验 A:提交之后立刻杀

真实实测 $ mysql -uroot redodemo -e "START TRANSACTION; INSERT INTO ledger VALUES (1,'committed_before_crash'); COMMIT;" # 命令成功返回之后…… $ PID=$(cat /opt/homebrew/var/mysql/*.pid); kill -9 $PID 进程立即消失,没有任何关闭日志 $ mysql.server start 2026-08-27T14:06:47 [System] Starting XA crash recovery... 2026-08-27T14:06:47 [System] XA crash recovery finished. $ mysql -uroot redodemo -e "SELECT * FROM ledger;" id marker 1 committed_before_crash ← 还在

实验 B:提交前杀(事务开着一直没 COMMIT)

真实实测 $ mysql -uroot redodemo <<'EOF' & # 后台开一个连接,故意不提交 START TRANSACTION; INSERT INTO ledger VALUES (2,'UNCOMMITTED_should_vanish'); SELECT SLEEP(20); EOF # 这个连接还在 SLEEP,事务还开着…… $ PID=$(cat /opt/homebrew/var/mysql/*.pid); kill -9 $PID 进程立即消失 background: ERROR 2013 (HY000): Lost connection to MySQL server during query $ mysql.server start && mysql -uroot redodemo -e "SELECT * FROM ledger;" id marker 1 committed_before_crash (id=2 那一行,没有了)
这不是巧合,也不是"运气好没丢数据"——这是 WAL 设计出来就该有的行为:已经提交(并且已经 fsync 落盘)的东西,进程再怎么被强杀都丢不了;没提交的东西,哪怕已经改了内存里的页,重启后也会被系统主动撤销,不会留下一半的痕迹。

时间线复盘

事务 A INSERT id=1 → 页在内存里被改了(这就是脏页,上一篇的话题)
事务 A COMMIT → innodb_flush_log_at_trx_commit=1(默认值),这一步会同步等日志 fsync 完成才返回
事务 B INSERT id=2 → 页在内存里也被改了,日志也生成了,但事务还没走到 COMMIT
💥 kill -9:内存里的一切(缓冲池、未提交状态)瞬间消失;唯独已经落盘的日志文件还在
重启·阶段1 重放(redo)磁盘上日志里记录的每一次页修改——不分是谁的事务,id=1 和 id=2 都会先被"重放"回来
重启·阶段2 扫描重放出来的事务:A 有提交记录,放行;B 没有提交记录,用 undo log 撤销——id=2 被拿掉

关键细节:id=2 的修改确实被重放过一次,不是"日志里压根没有它所以没重放"。真正让它消失的,是重放之后那道单独的"清理未提交事务"关卡。这也是为什么崩溃恢复要分两个阶段,而不是一步到位——重放阶段只管"页对不对得上日志",完全不关心事务提交与否;提交与否是第二阶段才处理的问题。

演示:重放,再回滚

用上面真实实验的结构做了一个可验证的最小模型(两个事务、两条日志记录),重放规则和真实源码里的两阶段完全一致。

崩溃恢复:先 redo 重放,再撤销未提交事务

日志(磁盘上,LSN 顺序)

页 / 数据状态

innodb_flush_log_at_trx_commit:durability 是可以调的

上面的实验用的是默认值 1——每次 COMMIT 都同步等 fsync 完成。这不是唯一选项。

COMMIT 时做什么能扛住什么
1(默认,本文实验用的值)写日志 + 立即 fsync,等落盘才返回mysqld 崩溃、操作系统崩溃、断电——一个都不丢
2写日志到操作系统缓存,不等 fsync;由后台每秒左右 fsync 一次mysqld 崩溃没事(数据在 OS 缓存里);但操作系统崩溃或断电,最近这一小段可能丢
0连写到操作系统缓存都不是每次 COMMIT 就做,后台每秒左右统一处理mysqld 崩溃就可能丢最近一小段——durability 最弱,换来最少的日志相关开销

换句话说,=1 买的是"进程崩、系统崩、断电,三种情况都不丢"的保证,代价是每次提交都要等一次磁盘 fsync;调到 02 是在拿"最坏情况下丢一小段最近的提交"去换更高的吞吐——这是一个诚实的权衡,不是免费的性能提升。本文的两次真实实验都是在默认值 1 下做的,这也是为什么"已提交必然不丢"这句话在这里成立。

真实机器上的 LSN

本机空闲时刻,三个 LSN 位点是相等的——因为没有新的修改在发生。

SELECT * FROM performance_schema.global_status WHERE VARIABLE_NAME LIKE 'Innodb_redo_log%'; Innodb_redo_log_checkpoint_lsn 571476695 Innodb_redo_log_current_lsn 571476695 Innodb_redo_log_flushed_to_disk_lsn 571476695

三者的关系永远是 checkpoint_lsn ≤ flushed_to_disk_lsn ≤ current_lsn:current_lsn 是日志流内存里写到哪了;flushed_to_disk_lsn 是真正落盘到哪了——这条线以下的修改,进程崩了也丢不了,就是本文实验验证的那条线;checkpoint_lsn 更保守,它要求对应的数据页本身也已经刷盘(上一篇的话题),只有这条线以下的日志空间才能被安全地循环复用。三条线不重合的时候,差的就是"日志已经安全但页还没刷"和"日志还没来得及落盘"这两段窗口。

参考与说明

  • "两次 kill -9"实验是本文的核心证据,不是复述文档——两次都在本机真实的 MySQL 9.7.1 进程上做的,终端输出、错误日志时间戳、重启后的查询结果均为真实记录,未做任何删改(只是截取了相关行)。
  • 源码引用(trx0roll.cctrx_rollback_or_clean_recovered)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致。
  • 演示动画里"后台日志写入线程会独立于 COMMIT 主动 flush"这一细节是真实存在的机制(否则"回滚未提交事务"这个恢复阶段就没有存在的必要),但具体触发时机(缓冲区写满、周期性 flush 等)属于简化叙述,没有在源码层面逐条核对触发条件。
  • 没有涉及:undo log 的物理结构、binlog 与 redo log 的两阶段提交(XA,本文错误日志片段里出现的"XA crash recovery"字样正是这个机制的一部分)——前者是系列第五篇的内容。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电