事务、MVCC 与三大日志
一条 UPDATE 语句,如何保证"要么全成功、要么全回滚"?并发事务之间为什么不会互相踩?
答案藏在三层结构里:事务隔离级别(语义层)→ MVCC 多版本控制(实现层)→ Redo/Undo/Binlog(持久层)。
一、事务与隔离级别
1.1 ACID
| 特性 | 含义 | 依赖 |
|---|---|---|
| 原子性 (A) | 事务不可分割,失败全部回滚 | undo log |
| 一致性 © | 执行前后数据合法 | 由 A/I/D 共同保证 |
| 隔离性 (I) | 并发事务互不干扰 | MVCC + 锁 |
| 持久性 (D) | 提交后永久持久化 | redo log |
1.2 三大读问题
| 异常 | 定义 | 直观表现 |
|---|---|---|
| 脏读 | 读到其他事务未提交的数据 | 数据可能回滚,读到"废数据" |
| 不可重复读 | 同一事务内两次读同一条记录,值变化 | 其他事务修改并提交了该行 |
| 幻读 | 同一事务内两次查询,结果集行数变化 | 其他事务插入或删除了新行 |
1.3 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | MySQL 支持 |
|---|---|---|---|---|
| RU 读未提交 | 可能 | 可能 | 可能 | ✓ |
| RC 读已提交 | 解决 | 可能 | 可能 | ✓(互联网生产常选) |
| RR 可重复读 | 解决 | 解决 | 快照读无 / 当前读 Next-Key Lock 防 | ✓默认 |
| Serializable 串行化 | 解决 | 解决 | 解决 | ✓(性能极差) |
关键认知:MySQL 的 RR 在「快照读」下靠 MVCC 冻结快照消除幻读;在「当前读」下靠 Next-Key Lock 防幻读。锁机制见《03_锁机制与死锁防御》。
二、MVCC 多版本并发控制
MVCC 的核心思想:写数据时不覆盖旧数据(写入 undo log),读数据时按规则选择可见的旧版本。读写操作不同版本,互不干扰。
2.1 三大底层组件
组件一:隐藏字段(每行聚簇索引都有)
| 隐藏字段 | 大小 | 含义 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后一次修改该行的事务 ID |
DB_ROLL_PTR | 7 字节 | 指向 undo log 中上一个版本的指针 |
DB_ROW_ID | 6 字节 | 无主键时 InnoDB 自动生成聚簇索引 |
组件二:undo log 版本链 —— 数据的"时光机"
每次 UPDATE 前,先将旧值写入 undo log,新行的roll_pointer指向旧版本,形成从新到旧的单向链表。
B+Tree 物理行(只存最新版本) ┌───────────────────────────────────────┐ │ id=1, status=99, trx_id=10, │ │ roll_pointer ─────────────────────────┼───┐ └───────────────────────────────────────┘ │ ▼ undo log 版本1 ┌──────────────────────┐ │ status=0, trx_id=5, │ │ roll_pointer=NULL │ └──────────────────────┘存储澄清:B+树叶子节点只存当前最新版本,历史版本链在 undo log 中。MySQL 8.0 默认存储在
datadir下的undo_001、undo_002(独立.ibu文件)。
组件三:ReadView —— 判断"谁可见"的规则快照
ReadView 是纯内存结构,事务提交即销毁。包含 4 个字段:
| 字段 | 含义 | 边界 |
|---|---|---|
m_ids | 生成时活跃(未提交)事务 ID 列表 | 有序数组 |
min_trx_id | m_ids 中最小的事务 ID | 左边界 |
max_trx_id | 下一个即将分配的事务 ID | 右边界 |
creator_trx_id | 创建该 ReadView 的事务自身 ID | 当前事务 |
ReadView 是全局的,不是行级的:从内存全局事务管理器复制整个实例的活跃事务列表。好处是跨表 JOIN 逻辑一致;代价是一个古老长查询会"挟持"所有表的历史版本(见保护伞效应)。
2.2 可见性判定算法(5 条规则,严格优先级)
对某版本行的trx_id = T:
① T == creator_trx_id ? → 可见 ✓(自己能看自己的修改) ② T < min_trx_id ? → 可见 ✓(生成前已提交) ③ T >= max_trx_id ? → 不可见 ✗(生成后才开启的新事务) ④ min_trx_id <= T < max_trx_id ? T 在 m_ids 列表中 ? → 不可见 ✗(生成时仍活跃,未提交) T 不在 m_ids 列表 ? → 可见 ✓(生成时已提交)ASCII 数轴图解:
事务 ID 数轴 ──────────────────────────────────────────────────────► 0 ────── min_trx_id ─────── 活跃事务区间 ─────── max_trx_id ───► ∞ │ │ ↑ m_ids=[7,9,10] ↑ │ │ │ │ (trx_id 7,9,10 活跃) │ 可见区 │ 活跃区(查m_ids) │ 不可见区 (已提交) │ T=7 在m_ids→不可见 ✗ │ T=11,12→不可见 ✗ T=1,2,5 │ T=8 不在m_ids→可见 ✓ │ (未来事务) → 可见 ✓ │ T=9 在m_ids→不可见 ✗ │ │ T=10在m_ids→不可见 ✗ │边界纠误:
T == min_trx_id不可见(它在活跃列表里);T == max_trx_id不可见(它是未来事务)。
2.3 ReadView 生成时机:RC vs RR 的本质区别
| 隔离级别 | ReadView 生成时机 | 效果 |
|---|---|---|
| RC | 每次 SELECT都生成新 ReadView | 每次都能看到最新已提交 → 不可重复读 |
| RR | 事务内首次 SELECT生成,后续复用 | ReadView 冻结 → 可重复读 |
超高频考点:RR 下 ReadView 生成于第一条 SELECT,不是 BEGIN。
强制 BEGIN 时生成:START TRANSACTION WITH CONSISTENT SNAPSHOT;
RC 时间轴图解(两次查询 → 两个 ReadView):
事务A (trx_id=7, RC) 事务B (trx_id=10) │ SELECT #1 │ │ → 生成 ReadView #1 │ │ m_ids=[8,10,12] (B 在活跃列表) │ │ → B+Tree trx_id=10 在 m_ids → 不可见 → 回溯旧版本 0 │ │ UPDATE status=99 / COMMIT │ │ (trx_id=10 从全局活跃列表移除) │ SELECT #2 │ │ → 生成 ReadView #2 ★ RC 每次都新 │ │ m_ids=[8,12] (B 已提交,不在列表) │ │ → B+Tree trx_id=10 → 8≤10<13,查 m_ids → 不在 → 可见 ✓ │ → 返回 status=99 └─ COMMIT ✅ 两次查询结果不同:0 → 99(不可重复读)RR 时间轴图解(两次查询 → 同一个 ReadView):
事务A (trx_id=7, RR) 事务B (trx_id=10) │ SELECT #1 │ │ → 生成 ReadView #1 ★ RR 只生成一次 │ │ m_ids=[8,10,12] (B 在活跃列表) │ │ → B+Tree trx_id=10 在 m_ids → 不可见 → 回溯旧版本 0 │ │ UPDATE status=99 / COMMIT │ │ (trx_id=10 从全局移除) │ SELECT #2 │ │ → 复用 ReadView #1 ★ 冻结不变! │ │ m_ids=[8,10,12] (B 仍在列表里) │ │ → B+Tree trx_id=10 → 在 m_ids → 不可见 ✗ │ → 回溯旧版本 0 → 可见 ✓ └─ COMMIT ✅ 两次查询结果相同:0 → 0(可重复读 / 快照冻结)关键:即使 B 真实已 COMMIT,ReadView #1 生成时把 B 记在了 m_ids 里,所以对 A 来说 B 的修改永远不可见。
2.4 快照读 vs 当前读
| 读模式 | 触发 SQL | 加锁 | 数据来源 | 隔离级别影响 |
|---|---|---|---|---|
| 快照读 | 普通SELECT | ❌ 不加锁 | undo log 版本链可见版本 | RC/RR 体现为可见性差异 |
| 当前读 | SELECT FOR UPDATE/UPDATE/DELETE | ✅ 加锁 | B+Tree 物理最新行 | 总读最新;隔离级别影响加锁范围 |
关键结论:UPDATE/DELETE内部先做当前读读到最新值,不管你显式SELECT查的是多少。
账户余额初始 1000,事务A 快照读两次,事务B 中间扣 100: 事务A(快照读) 事务B(当前写) ├─ SELECT balance → 1000 │ ├─ UPDATE SET balance=900 / COMMIT ├─ SELECT balance → 1000(RR) 或 900(RC) │ └─ UPDATE SET balance = balance - 50 → 内部当前读读到最新 900,减成 850 → 不会基于之前的快照读计算 ★ UPDATE/DELETE 内部的读取是当前读,不管你显式查的是多少三、三大日志:Redo / Undo / Binlog
3.1 总览对比
| 日志 | 所属层 | 记录内容 | 性质 | 核心作用 | 写入时机 |
|---|---|---|---|---|---|
| Redo Log | 引擎层 | 物理页修改(页号、偏移量、内容) | 物理操作 | 崩溃恢复(持久性 D) | 事务执行过程中(Prepare + Commit) |
| Undo Log | 引擎层 | 逻辑逆操作(id=1, money=90→100) | 逻辑操作 | 事务回滚(原子性 A)+ MVCC 快照读 | 执行 DML 时实时写入 |
| Binlog | Server 层 | SQL / 行变更 | 逻辑操作 | 主从复制 + 时间点恢复 | 事务提交时(Redo Prepare 之后,Commit 之前) |
核心哲学:日志记录"操作(Events)“,数据文件记录"状态(State)”。只要有一串完整的操作日志,就可以推导出任意时刻的状态。
3.2 WAL 机制:Redo Log 为什么快
- 刷数据页(脏页):随机 I/O(磁盘寻道慢,毫秒级)
- 写 Redo Log:顺序 I/O(追加写,微秒级)
WAL(Write-Ahead Logging):先写日志(顺序快),再异步刷脏页(随机慢)。宕机后通过 Redo Log 重放,恢复已提交但未刷盘的数据。
3.3 一条 UPDATE 的完整日志时序
UPDATEuserSETmoney=90WHEREid=1;| 步骤 | 时机 | 动作 | 落盘 |
|---|---|---|---|
| ① | 执行瞬间 | 写 Undo Log(旧值 money=100) | 实时 |
| ② | 执行瞬间 | 加 X 行锁 + 间隙锁 | 内存锁 |
| ③ | 执行瞬间 | 修改 Buffer Pool(money=90),标记脏页 | 仅内存 |
| ④ | 执行瞬间 | 写 Redo Log(Prepare 状态) | 实时 |
| ⑤ | COMMIT时 | 写 Binlog | 实时 |
| ⑥ | Binlog 写完后 | 写 Redo Log(Commit 状态) | 实时 |
| ⑦ | 提交完成后 | 释放 X 锁 | — |
| ⑧ | 后台异步 | 脏页刷回磁盘.ibd | 随机 I/O,慢慢刷 |
Undo Log 写入时机纠误:Undo Log 是执行 DML 的瞬间就写入的,不是提交时才写。原因:① 其他事务的 MVCC 快照读立刻需要读到旧值;② 用户随时可能 ROLLBACK,必须有 Undo 才能撤销。
四、内部 XA 两阶段提交
4.1 为什么需要
Redo Log(引擎层)和 Binlog(Server 层)是两个独立日志。如果只写其一就宕机:
- 只写 Redo,未写 Binlog→ 主库恢复有新数据,从库同步缺失 →主从不一致
- 只写 Binlog,未写 Redo→ 从库重放了数据,主库回滚了 →主从不一致
4.2 两阶段流程
| 阶段 | 操作 | 状态 |
|---|---|---|
| Prepare | InnoDB 写 Redo Log,标记为Prepare | Redo Prepare |
| Commit | ① Server 写 Binlog → ② InnoDB 将 Redo 改为Commit | Redo Commit |
4.3 崩溃恢复规则
| 宕机时 Redo 状态 | Binlog 是否存在 | 恢复动作 |
|---|---|---|
| Prepare | 不存在 | 回滚(Binlog 没写,提交未完成) |
| Prepare | 存在且完整 | 提交(Binlog 写了,Server 认可) |
| Commit | 任意 | 提交(事务已确认) |
核心规则:以Binlog 写成功作为事务最终提交的"决定性标志"。
五、分布式事务
5.1 为什么单机事务不够
单体事务只在一个数据库连接内生效。涉及多个 MySQL 实例(分库分表)或跨 Redis / MQ,需要分布式事务协调者(TC)介入。
5.2 外部 XA(强一致性,性能差)
XASTART'xid1';-- 代替 BEGINUPDATEuserSETmoney=money-10WHEREid=1;UPDATEuserSETmoney=money+10WHEREid=2;XAEND'xid1';XAPREPARE'xid1';-- 第一阶段:所有参与者 Prepare,锁不释放XACOMMIT'xid1';-- 第二阶段:全局提交(锁释放)
XA PREPARE后行锁一直持有,直到全局XA COMMIT才释放。强一致但性能极差,互联网大厂基本不用。
5.3 Seata AT 模式(最终一致性,高性能)
核心思想:一阶段直接提交本地事务,释放锁;二阶段如果失败,用undo_log表反向补偿。
| 维度 | 单体 InnoDB Undo Log | Seata AT 的undo_log表 |
|---|---|---|
| 存储位置 | InnoDB 系统 Undo 表空间(二进制) | 业务数据库中的普通表 |
| 记录内容 | 物理/逻辑逆操作 | SQL 前后镜像(before/after) |
| 作用范围 | 单机本地回滚 + MVCC | 全局分布式回滚 |
| 生命周期 | Purge 线程自动清理 | TC 在全局提交后删除 |
| 谁生成 | InnoDB 引擎自动 | Seata 框架自动(业务无感) |
Seata AT 一阶段(业务代码只需@GlobalTransactional):
-- 业务代码:@GlobalTransactionalUPDATEuserSETmoney=money-10WHEREid=1;-- 框架自动执行(同一本地事务中):-- 1. 查询 Before Image(修改前数据)-- 2. 执行业务 UPDATE-- 3. 查询 After Image(修改后数据)-- 4. INSERT INTO undo_log (xid, branch_id, before_image, after_image) VALUES (...)-- 5. COMMIT(释放本地锁)★ 与外部 XA 的核心差异二阶段:
- 全局成功 → 框架异步删除
undo_log记录 - 全局失败 → 框架读取
before_image,生成反向 SQL 恢复数据
单体日志是分布式事务的"物理保险柜":Seata 的
undo_log写入磁盘时,必须依赖 Redo Log 保证不丢失、Binlog 保证同步到从库。如果 Redo Log 不起作用,Seata 刚写完undo_log就宕机,重启后记录丢失 → 无法恢复。
5.4 分布式事务角色
| 角色 | 全称 | 职责 |
|---|---|---|
| TC | Transaction Coordinator | 全局大管家,独立部署 |
| TM | Transaction Manager | 开启/提交/回滚全局事务 |
| RM | Resource Manager | 执行并汇报本地事务状态 |
核心速查表
RC vs RR 对比
| 维度 | RC | RR(MySQL 默认) |
|---|---|---|
| 解决的读问题 | 脏读 | 脏读 + 不可重复读 |
| ReadView 生成 | 每次 SELECT | 首次 SELECT |
| 并发修改后可见性 | 立即可见 | 不可见(回溯旧版本) |
| 典型现象 | 0 → 99(不可重复读) | 0 → 0(快照冻结) |
| 锁机制 | 行锁,几乎不加间隙锁 | Next-Key Lock(见锁专题) |
| 死锁概率 | 较低 | 较高(GAP 锁范围大) |
| binlog 要求 | 必须 ROW 格式 | STATEMENT/ROW/MIXED 都支持 |
| Spring 常量 | READ_COMMITTED | REPEATABLE_READ |
三大日志一句话
| 日志 | 一句话 |
|---|---|
| Undo | “后悔药”——回滚和 MVCC 读旧版本都靠它 |
| Redo | “保险单”——宕机后重放已提交但未刷盘的数据 |
| Binlog | “广播稿”——主从复制和时点恢复 |
可见性判定口诀
自己可见 → 已提交的老事务可见 → 未来事务不可见 → 活跃列表里查旧账。