MySQL 如何实现 ACID
通俗版,适合快速理解底层机制
什么是 ACID
| 特性 | 含义 | 一句话 |
|---|---|---|
| Atomicity(原子性) | 事务要么全做,要么全不做 | "转账扣钱和加钱必须一起成功" |
| Consistency(一致性) | 事务前后数据满足所有约束 | "余额不能变负数" |
| Isolation(隔离性) | 并发事务互不干扰 | "你转账时别人看不到中间状态" |
| Durability(持久性) | 提交后数据不丢 | "断电重启钱还在" |
逐项实现机制(InnoDB 引擎)
1. 原子性 --- Undo Log
核心思路:做之前先记"怎么撤销"。
bash
事务开始
→ INSERT 一行 → undo log 记录 "DELETE 这行"
→ UPDATE 余额 1000→800 → undo log 记录 "改回 1000"
事务失败/回滚
→ 按 undo log 逆序执行撤销操作
- Undo log 存在独立的回滚段(rollback segment)
- 事务提交后 undo log 不会立即删除(MVCC 还要用)
2. 持久性 --- Redo Log(WAL)
核心思路:先写日志,再写数据页。日志落盘 = 数据安全。
perl
写入流程:
修改内存中的数据页(Buffer Pool)
→ 同时写 redo log 到磁盘(顺序写,很快)
→ 返回"提交成功"
→ 后台异步把脏页刷到数据文件(随机写,慢)
崩溃恢复:
启动时扫描 redo log
→ 已提交但未刷盘的事务 → 重放(redo)
→ 未提交的事务 → 用 undo log 回滚
关键配置 :innodb_flush_log_at_trx_commit
=1(默认):每次提交都 fsync,最安全=2:写 OS 缓存,OS 崩才丢=0:每秒刷,最多丢 1 秒
3. 隔离性 --- MVCC + 锁
MVCC(多版本并发控制):读不加锁,靠版本链实现。
bash
每行数据隐藏两列:
- trx_id:最后修改该行的事务 ID
- roll_pointer:指向 undo log 中的旧版本
读取时:
根据事务启动时的"快照"(Read View)判断:
这行的 trx_id 在我启动前已提交?→ 可见
没提交?→ 沿 roll_pointer 找更早的版本
锁机制(写-写冲突时):
- 行锁(Record Lock):锁住具体行
- 间隙锁(Gap Lock):锁住索引间隙,防幻读
- 临键锁(Next-Key Lock)= 行锁 + 间隙锁
四种隔离级别:
| 级别 | 脏读 | 不可重复读 | 幻读 | MySQL 默认 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✓ | ✓ | ✓ | |
| READ COMMITTED | ✗ | ✓ | ✓ | |
| REPEATABLE READ | ✗ | ✗ | ✗* | ✓ |
| SERIALIZABLE | ✗ | ✗ | ✗ |
*InnoDB 在 RR 级别通过 Next-Key Lock 基本解决幻读(快照读靠 MVCC,当前读靠间隙锁)
4. 一致性 --- 以上三者 + 约束
一致性不是单独一个机制,而是结果:
markdown
一致性 = 原子性(不做一半)
+ 持久性(做完不丢)
+ 隔离性(并发不乱)
+ 数据库约束(NOT NULL / UNIQUE / FK / CHECK)
+ 应用层逻辑正确
5. Binlog 与两阶段提交(2PC)
为什么需要两阶段提交?
MySQL 有两份日志记录数据变更:
| 日志 | 层级 | 类型 | 用途 |
|---|---|---|---|
| redo log | InnoDB 引擎层 | 物理日志(页号+偏移+数据) | 崩溃恢复 |
| binlog | Server 层 | 逻辑日志(SQL/行变更) | 主从复制、备份恢复 |
问题:如果两份日志写入不一致,会出现主从数据不一致。
ini
假设不用 2PC,先写 redo 再写 binlog:
场景:UPDATE balance SET amount=800 WHERE id=1 (原值 1000)
① redo log 写入(amount=800) ✓
② ------断电------
③ binlog 未写入 ✗
结果:
主库恢复后 amount=800(redo 重放)
从库 amount=1000(binlog 没收到这条)
→ 主从不一致!
两阶段提交流程
perl
┌─────────────────────────────────────────────────────┐
│ 事务提交流程 │
├─────────────────────────────────────────────────────┤
│ │
│ Phase 1: PREPARE │
│ ┌──────────────────────────────────┐ │
│ │ redo log 写入磁盘,标记 prepare │ │
│ │ (此时 redo log 状态 = prepare) │ │
│ └──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Phase 2: COMMIT │
│ ┌──────────────────────────────────┐ │
│ │ ① binlog 写入磁盘(fsync) │ │
│ │ ② redo log 标记 commit │ │
│ └──────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
关键点:binlog 写入成功 = 事务提交的判定标准。
6. 断电崩溃恢复 --- MySQL 如何保障 ACID
恢复流程总览
perl
MySQL 启动
→ 扫描 redo log 中所有 prepare 状态的事务
→ 逐个检查对应 binlog 是否完整写入
→ 决定提交还是回滚
五种断电时刻的处理
perl
时间线:
─────①────②────③────④────⑤────→
│ │ │ │ │
│ │ │ │ └─ commit 标记后(已完成,无影响)
│ │ │ └─ binlog fsync 后,redo 标记 commit 前
│ │ └─ binlog 写入中(不完整)
│ └─ redo prepare 后,binlog 写入前
└─ redo 写入前(事务执行中)
| 断电时刻 | redo log 状态 | binlog 状态 | 恢复动作 | 数据结果 |
|---|---|---|---|---|
| ① 执行中 | 无/不完整 | 无 | 什么都不做 | 事务未发生 |
| ② prepare 后 | prepare | 无 | 回滚(undo log) | 事务撤销 |
| ③ binlog 写入中 | prepare | 不完整 | 回滚(undo log) | 事务撤销 |
| ④ binlog 已 fsync | prepare | 完整 | 提交(标记 commit) | 事务生效 |
| ⑤ 全部完成 | commit | 完整 | 无需处理 | 事务生效 |
判定逻辑伪代码
sql
for each redo_log_entry where status == PREPARE:
xid = redo_log_entry.transaction_id
if binlog contains complete entry for xid:
# binlog 完整 → 事务已对外可见(从库可能已复制)
→ COMMIT(重放 redo,标记 commit)
else:
# binlog 不完整或不存在 → 事务未对外承诺
→ ROLLBACK(用 undo log 撤销)
为什么 binlog 完整就必须提交?
因为 binlog 一旦 fsync 到磁盘:
→ 从库可能已经拉取并执行了这条变更
→ 如果主库回滚,主从就不一致了
→ 所以:binlog 落盘 = 不可反悔的承诺
各组件在崩溃恢复中的角色
arduino
┌────────────┬────────────────────────────────┐
│ 组件 │ 崩溃恢复中的作用 │
├────────────┼────────────────────────────────┤
│ Redo Log │ 重放已提交事务的数据页修改 │
│ Undo Log │ 回滚未提交事务的修改 │
│ Binlog │ 作为"事务是否已提交"的仲裁者 │
│ Checkpoint │ 标记已刷盘位置,缩小扫描范围 │
│ Doublewrite│ 防止部分页写入(torn page) │
└────────────┴────────────────────────────────┘
Doublewrite Buffer(补充:防半页写入)
arduino
问题:InnoDB 页 16KB,OS 一次写 4KB,写到一半断电 → 页损坏
解决:
刷脏页前 → 先把页写到 doublewrite buffer(顺序写,连续空间)
doublewrite 落盘 → 再写到真正的数据文件位置
崩溃时:
数据文件页损坏 → 从 doublewrite buffer 恢复完整页
然后再用 redo log 重放
一图总结
scss
┌─────────────────────────────────────┐
│ 事务执行 │
└──────────────┬──────────────────────┘
│
┌──────────────┼──────────────────────┐
│ │ │
Undo Log Redo Log (WAL) MVCC + 锁
(原子性) (持久性) (隔离性)
│ │ │
└──────────────┼──────────────────────┘
│
两阶段提交 (2PC)
┌────────────┼────────────┐
│ │
Redo prepare Binlog fsync
│ │
└────────────┬────────────┘
│
Redo commit
│
┌─────────┴─────────┐
│ 崩溃恢复决策 │
│ binlog 完整→提交 │
│ binlog 缺失→回滚 │
└───────────────────┘
│
一致性(结果)
常见面试追问
| 问题 | 答案要点 |
|---|---|
| redo log 和 binlog 区别? | redo 是引擎层物理日志(页+偏移),binlog 是 Server 层逻辑日志(SQL/行变更) |
| 两阶段提交是什么? | prepare(redo) → write(binlog) → commit(redo),保证两份日志一致 |
| MVCC 能完全替代锁吗? | 不能,写-写冲突必须加锁;SELECT ... FOR UPDATE 也走锁 |
| undo log 什么时候清理? | purge 线程判断没有 Read View 引用旧版本时清理 |
生成时间:2026-08-07