深色模式
02 · 事务与 ACID
事务这块最容易背成四个字母。真正的问题是:数据库凭什么敢说"这条数据一定不会丢"?
一句话本质
事务把"多条操作"打包成"一个不可分割的整体":要么全做,要么全不做,且做完就一定能查到。
生活类比:银行转账
A 给 B 转 100 元 = 两个动作:
① A 的余额 -100
② B 的余额 +100
如果 ① 做完系统崩了:
没有事务 > A 少了 100,B 没收到,钱凭空消失
有事务 > ① 被撤销(undo),或者 ② 被补做(redo)核心机制
一、ACID 到底谁负责
| 字母 | 含义 | 靠什么实现 |
|---|---|---|
| A 原子性 | 全做或全不做 | undo log(记录旧值,回滚时反向操作) |
| C 一致性 | 数据满足约束(余额不能为负、外键有效) | 不是数据库单独实现的,是 A+I+D + 应用逻辑共同保证的结果 |
| I 隔离性 | 并发事务互不干扰 | 锁 + MVCC(见 03、04 篇) |
| D 持久性 | 提交后即使断电也不丢 | redo log(WAL,先写日志再写数据) |
最重要的认知:C 是目标,A、I、D 是手段。
面试问"ACID 怎么实现",答"A 靠 undo、D 靠 redo、I 靠锁和 MVCC、C 是前三者加上业务约束共同保证的结果"——这个层次就对了。如果答"C 靠……",说明没理解。
二、undo log:怎么撤销
执行 UPDATE user SET balance = 900 WHERE id = 1; (原值 1000)
undo log 记录:
"id=1 的 balance 原来是 1000"
回滚时:UPDATE user SET balance = 1000 WHERE id = 1undo log 还兼职做 MVCC:它保存了旧版本,所以其他事务能读到"修改前的值"。这就是 03 篇版本链的来源。
undo log 是逻辑日志:记录"做了什么操作",回滚时执行相反操作。不是物理的"页变成了什么样"。
三、redo log:怎么保证不丢
核心问题:修改数据要先改 Buffer Pool(内存),再刷到磁盘。但如果每次提交都刷盘,太慢(一次随机 IO 10ms)。
WAL(Write-Ahead Logging)的做法:
① 修改 Buffer Pool(内存里的页)
② 把"改了什么"追加到 redo log(顺序写,很快)
③ redo log 刷盘(fsync)> 此时就可以回复客户端"提交成功"
④ 数据页在后台慢慢刷盘(异步,不阻塞)
如果断电:
Buffer Pool 里的修改丢了,但 redo log 已经落盘
重启时重放 redo log,把丢失的修改补回来为什么快:顺序写 vs 随机写。
- 直接改数据页:要找到那一页在磁盘的位置,随机写(10ms 级)
- 写 redo log:追加到文件末尾,顺序写(0.1ms 级)
关键参数 innodb_flush_log_at_trx_commit:
| 值 | 行为 | 丢失风险 |
|---|---|---|
| 1(默认) | 每次提交都 fsync | 不丢(最安全) |
| 0 | 每秒刷一次 | 丢 1 秒 |
| 2 | 写到 OS 缓存,每秒刷 | 丢 1 秒(OS 崩)/ 不丢(只是 MySQL 崩) |
四、binlog:Server 层的日志
| redo log | binlog | |
|---|---|---|
| 层级 | InnoDB 引擎层 | Server 层 |
| 内容 | 物理日志("某页某偏移改成了什么") | 逻辑日志("执行了哪条 SQL") |
| 写入 | 循环写(固定大小,覆盖旧内容) | 追加写(不覆盖) |
| 用途 | 崩溃恢复 | 主从复制、数据恢复 |
为什么两个都要:
- redo log 是 InnoDB 自己的,循环写,只能用于崩溃恢复。
- binlog 是 Server 层的,所有引擎共用,追加写,能用于主从同步和按时间点恢复。
五、两阶段提交(这是最常考的一致性细节)
问题:一次提交要写两个日志(redo 和 binlog),怎么保证两个都成功?
如果先写 redo 再写 binlog,写完 redo 后崩了:
> redo 说"已提交",binlog 没有
> 主库有这条数据,从库(靠 binlog 同步)没有 > 主从不一致
如果先写 binlog 再写 redo,写完 binlog 后崩了:
> binlog 有,主库没有 > 从库多了一条数据 > 不一致解法:两阶段提交
① 写 redo log,标记为 prepare 状态
② 写 binlog
③ 写 redo log,标记为 commit 状态
崩溃恢复时的判断:
- redo 是 prepare,binlog 完整 > 提交(步骤 ③ 没做完,补做)
- redo 是 prepare,binlog 不完整 > 回滚关键:用 binlog 是否完整来决定 redo 该提交还是回滚。这样两个日志要么都生效,要么都不生效。
六、事务的隔离性与异常(为 03 篇铺垫)
| 异常 | 现象 |
|---|---|
| 脏读 | 读到了别的事务未提交的数据(对方可能回滚) |
| 不可重复读 | 同一事务内,两次读同一行,结果不同(别人改了并提交) |
| 幻读 | 同一事务内,两次范围查询,行数不同(别人插入并提交) |
不可重复读 vs 幻读的区别:
- 不可重复读:同一行的值变了(UPDATE)
- 幻读:行数变了(INSERT/DELETE)
四个隔离级别(详见 03 篇):
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | ✅ 可能 | ✅ | ✅ |
| 读已提交 | ❌ | ✅ | ✅ |
| 可重复读(MySQL 默认) | ❌ | ❌ | ⚠️ 理论上可能,InnoDB 用间隙锁基本解决 |
| 串行化 | ❌ | ❌ | ❌ |
为什么这么设计
反事实 1:如果没有 undo log
事务执行到一半失败,无法回滚。数据处于"一半改了、一半没改"的中间状态,业务逻辑完全错乱。
而且没有 undo log 就没有 MVCC——因为 MVCC 要靠 undo log 里的旧版本。
反事实 2:如果没有 redo log(每次直接刷数据页)
每次提交都要随机写磁盘。一个事务改 10 行、分布在 5 个页上 > 5 次随机 IO = 50ms。TPS 只有 20。
有了 redo log(顺序写):一次追加 ≈ 0.1ms。TPS 能到几千。
redo log 的本质是"把随机写变成顺序写"——这是数据库性能的核心机密。
反事实 3:如果没有两阶段提交
主从数据不一致。而且这种不一致是静默的——不会报错,只在某天发现"从库少了 3 条数据",且无法追溯是哪三条。
面试怎么问
Q:ACID 分别怎么实现?
A > undo log(记录旧值,反向操作回滚) D > redo log(WAL,先写日志再刷数据页) I > 锁 + MVCC C > 不是单独实现的,是 A/I/D 加上业务约束共同保证的结果 这个" C 不是数据库实现的"是加分点。
Q:redo log 和 binlog 的区别?
层级(引擎层 vs Server 层)、内容(物理 vs 逻辑)、写入方式(循环写 vs 追加写)、用途(崩溃恢复 vs 主从复制)。
Q:为什么需要两阶段提交?
因为要写两个日志,必须保证原子性。用"redo prepare > binlog > redo commit",崩溃时根据 binlog 是否完整决定提交还是回滚。
Q:WAL 是什么?为什么快?
Write-Ahead Logging:先写日志再写数据页。快的原因是顺序写替代随机写——日志追加到文件末尾,而数据页要定位到磁盘随机位置。
追问链:
事务怎么保证原子性?
> undo log 记了什么?(旧值)
> 那持久性呢?(redo log)
> redo log 为什么比直接改数据页快?(顺序写 vs 随机写)
> 那 binlog 呢?(Server 层,用于主从)
> 两个日志怎么保证一致?(两阶段提交)
> 崩溃时怎么判断?(redo 是 prepare 且 binlog 完整 > 提交)和其他知识的连接
- < 来自 IO 模型与零拷贝:等设备的时候,CPU 在干什么:fsync、页缓存、顺序写 vs 随机写,是 WAL 性能的来源
- < 来自 调度与并发同步:CPU 分给谁,共享数据谁先动:事务的隔离性靠锁,锁理论同源
- > 影响 隔离级别与 MVCC:undo log 是 MVCC 版本链的载体
- > 影响 锁与死锁:隔离级别的实现依赖锁
- > 影响 SQL 执行与优化:长事务会拖住 undo log 的清理(purge),导致版本链过长
- > 影响 分布式:两阶段提交(2PC)是分布式事务的基础,也是它的性能瓶颈
自测 3 问
Q1:为什么说"一致性(C)不是数据库实现的"?
点开看答案
因为一致性是业务层面的目标,不是数据库能单独保证的。
数据库能保证的:
- 原子性(要么全做要么全不做)
- 隔离性(并发不互相干扰)
- 持久性(提交了就不丢)
但"数据符合业务规则"这件事,数据库管不了。比如:
- "账户余额不能为负"——数据库不知道这个规则,除非你写
CHECK约束或触发器。 - "A 转账给 B,总额不变"——这需要应用层保证两个 UPDATE 在同一个事务里。
准确说法:A、I、D 是数据库提供的手段,C 是这些手段加上业务约束共同达成的结果。
面试这样答,比背"一致性由原子性隔离性持久性共同保证"更清楚。
Q2:redo log 循环写,写满了怎么办?
点开看答案
redo log 是固定大小的环形文件(比如 4 个 1GB 的文件),有一个"写指针"和一个"检查点指针"。
- 写指针往前走,记录新的日志。
- 检查点指针表示"这个位置之前的日志对应的数据页,已经刷到磁盘了,可以覆盖"。
写满时:写指针追上检查点指针 > 必须停下来,强制把 Buffer Pool 里的脏页刷盘,推进检查点,腾出空间。
这就是为什么"redo log 太小会导致性能抖动"——频繁触发强制刷盘,把异步刷盘变成了同步等待。
工程建议:redo log 不要设太小,让检查点推进有足够缓冲。
Q3:既然有 redo log,为什么还需要 binlog?
点开看答案
因为它们服务于不同的目的,不能互相替代:
| redo log | binlog | |
|---|---|---|
| 谁需要 | 只有 InnoDB 自己 | 整个 MySQL Server(包括主从、备份工具) |
| 能干什么 | 崩溃恢复(重放未刷盘的修改) | 主从复制(从库重放主库的 binlog)、按时间点恢复 |
| 写入方式 | 循环写,会被覆盖 | 追加写,永久保留(直到手动清理) |
| 内容 | 物理(页级别的修改) | 逻辑(SQL 语句或行变更) |
关键:redo log 会被覆盖,所以不能用来做主从同步(从库落后太多就丢了)。binlog 追加写且可长期保留,才能支撑"从库延迟几小时再同步"。
另外,redo log 是 InnoDB 特有的,如果换成 MyISAM 引擎就没有了——但 binlog 在 Server 层,所有引擎都有。
类比失效处 / 易错点
- "转账"类比会让人以为事务只用于多表操作。单表单行也能用事务,只要需要原子性。
- undo log 不只是"回滚用"。它还承担 MVCC 的"旧版本"职责,所以长事务会导致 undo log 无法清理、版本链过长、磁盘暴涨。
- redo log 不是"备份"。它是"还没刷到数据页的修改记录",一旦对应数据页刷盘,那部分 redo 就没用了(可以被覆盖)。
- 两阶段提交(数据库内部)≠ 分布式事务的 2PC。虽然思想相似,但一个是"两个日志",一个是"多个节点"。面试时要说清是哪个。
- 隔离级别越高越好是错的。串行化性能极差,实际业务几乎不用;MySQL 默认的可重复读已经能满足绝大多数场景。
✅ 验收清单
- 能说出 ACID 四个字母各由什么机制实现,并解释"为什么 C 不是数据库实现的"
- 能说清 undo log 的两个作用(回滚 + MVCC 旧版本)
- 能用"顺序写 vs 随机写"解释 WAL 为什么快
- 能对比 redo log 和 binlog 的四个区别(层级、内容、写入、用途)
- 能讲清两阶段提交的流程,以及崩溃时怎么判断该提交还是回滚
- 能区分脏读、不可重复读、幻读,并说出各自的成因(UPDATE vs INSERT)
- 能说出 redo log 写满时会发生什么
- 合上书,用自己的话讲 3 分钟,中途不卡壳