MySQL 事务与锁机制:MVCC、幻读与死锁深度解析

“我用的是 RR 隔离级别,为什么还是出现了幻读?””两个事务各更新一行,结果直接死锁了?”事务和锁是 MySQL 里最容易翻车、也最常被面试官追问的部分。本文从原理到实战一次讲透,建议收藏对照。

一、为什么需要事务

没有事务时,一组操作要么全部成功、要么全部失败就无法保证。典型场景:账户 A 给账户 B 转账 100 元,需要执行「A 扣 100」「B 加 100」两条 SQL。如果在两条语句之间数据库宕机,就会出现 A 的钱扣了、B 的钱没加的数据不一致

事务(Transaction)的作用,就是把多个操作打包成一个不可分割的整体,保证业务的正确性。

二、事务的四大特性:ACID

特性 含义 InnoDB 如何实现
Atomicity 原子性 事务内的操作要么全做、要么全不做 undo log(回滚日志)
Consistency 一致性 事务前后数据都满足业务约束 由 A、I、D 共同保证
Isolation 隔离性 并发事务之间互不影响 锁 + MVCC
Durability 持久性 提交后数据永久保存 redo log(重做日志)

2.1 原子性靠 undo log

每一条写操作(INSERT/UPDATE/DELETE)执行前,InnoDB 都会先把「反向操作」记录到 undo log。事务回滚时,按 undo log 反向执行即可恢复到事务前的状态。

1
2
3
-- 逻辑示意:更新前先把旧值记入 undo log
UPDATE account SET balance = balance - 100 WHERE id = 'A';
-- 若失败回滚,InnoDB 根据 undo log 把 A 的 balance 还原

2.2 持久性靠 redo log

数据先写内存(Buffer Pool),再异步刷盘,这之间存在崩溃风险。redo log 采用 WAL(Write-Ahead Logging) 机制:事务提交时,先把修改记录到 redo log(顺序写、速度快),即使还没刷到数据页,崩溃后也能根据 redo log 重放恢复。

一句话记忆:undo log 负责「回滚」,redo log 负责「重做」,二者共同撑起 ACID。

三、隔离级别与并发三兄弟

当多个事务并发执行,会出现三类经典问题:

问题 描述 例子
脏读 读到另一个事务未提交的数据 事务 A 改了值还没提交,事务 B 读到了,A 万一回滚,B 读到就是脏数据
不可重复读 同一事务内两次读同一行,结果不同(侧重修改) 事务 A 读余额为 100,事务 B 改成了 200 并提交,A 再读变成 200
幻读 同一事务内两次范围查询,行数不同(侧重增删) 事务 A SELECT WHERE age>20 得到 3 行,事务 B 插入 1 行并提交,A 再查得到 4 行

SQL 标准定义了四种隔离级别:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED(读未提交) ❌ 可能 ❌ 可能 ❌ 可能
READ COMMITTED(读已提交,RC) ✅ 解决 ❌ 可能 ❌ 可能
REPEATABLE READ(可重复读,RR) ✅ 解决 ✅ 解决 ⚠️ 基本解决(见第五节)
SERIALIZABLE(串行化) ✅ 解决 ✅ 解决 ✅ 解决

重点:MySQL InnoDB 默认的隔离级别是 REPEATABLE READ,并且通过 MVCC + 临键锁在「大部分场景」下已经能避免幻读,这是它和标准 SQL 定义不同的地方。

四、MVCC 多版本并发控制

MVCC(Multi-Version Concurrency Control)是 InnoDB 实现「读不加锁、读写不阻塞」的核心。它的本质是:为每行数据保存多个历史版本,读请求根据事务的「可见性规则」找到属于自己的那个版本

4.1 行记录的隐藏字段

InnoDB 每行数据除了你定义的列,还有几个隐藏字段:

字段 作用
DB_TRX_ID(事务 ID) 最近一次修改该行的事务 ID
DB_ROLL_PTR(回滚指针) 指向 undo log 中该行上一个版本的指针
DB_ROW_ID 隐式主键(无主键时由它充当)

4.2 undo log 版本链

每次更新一行,旧版本并不会立刻删除,而是被写入 undo log,并通过 DB_ROLL_PTR 串成一条版本链。链条头部是最新版本,尾部是最早版本。

1
2
3
4
5
6
7
8
9
10
最新版本 (trx_id=20)
│ roll_ptr

旧版本 (trx_id=15)
│ roll_ptr

更旧版本 (trx_id=10)
│ roll_ptr

NULL(初始插入版本)

4.3 ReadView:判断哪个版本「对我可见」

事务执行快照读时,InnoDB 会生成一个 ReadView,里面记录了:

  • m_ids:当前活跃(未提交)的事务 ID 列表
  • min_trx_idm_ids 中的最小值
  • max_trx_id:下一个将要分配的事务 ID
  • creator_trx_id:创建该 ReadView 的事务 ID

对版本链上的每一版,可见性判断规则:

1
2
3
4
5
6
7
8
版本的事务 ID = trx_id
1. trx_id == creator_trx_id → 可见(自己改的)
2. trx_id < min_trx_id → 可见(在 ReadView 创建前已提交)
3. trx_id >= max_trx_id → 不可见(ReadView 创建后才开启)
4. min_trx_id <= trx_id < max_trx_id:
├─ trx_id 在 m_ids 中 → 不可见(该事务还没提交)
└─ trx_id 不在 m_ids → 可见(已提交)
不可见就顺着 roll_ptr 找上一个版本,直到找到可见版本或链尾

4.4 RC 与 RR 的关键差异:ReadView 生成时机

隔离级别 ReadView 生成时机 效果
READ COMMITTED 每次 SELECT 都生成新 ReadView 能看到别的事务最新已提交的数据 → 不可重复读
REPEATABLE READ 第一次 SELECT 时生成,之后整个事务复用 始终看到同一份快照 → 可重复读

这就是为什么 RR 能实现「可重复读」:整个事务只认第一次读时生成的快照,别人的提交对它「不可见」。

五、幻读:快照读与当前读

5.1 快照读 vs 当前读

  • 快照读(Snapshot Read):普通 SELECT,基于 MVCC 读历史版本,不加锁。
  • 当前读(Current Read)SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEINSERT/UPDATE/DELETE,读的是最新已提交版本,并加锁。
1
2
SELECT * FROM user WHERE age > 20;          -- 快照读,无锁
SELECT * FROM user WHERE age > 20 FOR UPDATE; -- 当前读,加临键锁

5.2 间隙锁与临键锁如何解决幻读

光靠 MVCC 的快照读只能解决「自己事务内看到的内容稳定」,但无法阻止别的事务插入新行。InnoDB 在 RR 下通过来堵住这个口子:

锁类型 锁定范围 说明
记录锁 Record Lock 锁住某一行 最基础的行锁
间隙锁 Gap Lock 锁住两条记录之间的「空隙」 防止在空隙中插入新记录
临键锁 Next-Key Lock 记录锁 + 间隙锁(左开右闭) RR 下的默认行锁算法

例如 SELECT * FROM user WHERE age > 20 FOR UPDATE,InnoDB 会对 (20, +∞) 这个区间加临键锁,禁止其他事务往这段区间插入新行,从而杜绝幻读。

注意:RR 下的「解决幻读」指的是当前读场景。如果你的事务全程只用普通 SELECT(快照读),确实不会幻读;但如果混合了 UPDATE/INSERTFOR UPDATE,靠的正是临键锁在保护你。

六、InnoDB 锁体系总览

6.1 行锁、表锁与意向锁

粒度 说明
行锁 InnoDB 支持,MyISAM 不支持
表锁 整张表锁定
意向锁(IS/IX) 意向标记,表级,用于快速判断能否加表锁,本身不阻塞行锁

意向锁是「占位符」:事务准备给某几行加行锁前,先给整张表加一个意向锁,这样别的事务想加表锁时不用逐行扫描,直接看意向锁即可。

6.2 锁的兼容矩阵

已持有 \ 请求 IS IX S(表共享) X(表排他)
IS
IX
S
X

6.3 行锁加在哪?索引是关键

1
2
3
4
-- 假设 id 是主键,name 有普通索引,age 无索引
UPDATE user SET age = 30 WHERE id = 1; -- 只锁 id=1 这一行 ✅
UPDATE user SET age = 30 WHERE name = 'Tom'; -- 锁 name='Tom' 的行(走索引)✅
UPDATE user SET age = 30 WHERE age = 30; -- 全表扫描!退化为表锁 ❌ 危险

生产铁律:UPDATE/DELETE 的 WHERE 条件必须走索引,否则行锁升级为表锁,直接拖垮整张表并发。这与「索引失效」一文中的结论一脉相承。

七、死锁:成因、案例与排查

7.1 死锁的四个必要条件

  1. 互斥:资源只能被一个事务占用
  2. 持有并等待:持有锁的同时还在等别的锁
  3. 不可剥夺:锁不能被强行抢占
  4. 循环等待:A 等 B 的锁、B 等 A 的锁,形成环

四个条件同时满足才会死锁,打破任意一个即可解除。

7.2 经典死锁案例

1
2
3
4
5
6
7
8
9
-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A'; -- 持有 A 的锁
UPDATE account SET balance = balance + 100 WHERE id = 'B'; -- 等待 B 的锁

-- 事务 B(几乎同时)
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 'B'; -- 持有 B 的锁
UPDATE account SET balance = balance + 50 WHERE id = 'A'; -- 等待 A 的锁

事务 A 持有 A 等 B,事务 B 持有 B 等 A → 循环等待 → 死锁。InnoDB 默认会检测死锁,并回滚其中一个代价较小的事务,另一个得以继续执行(报错:Deadlock found when trying to get lock)。

7.3 排查死锁

1
2
3
4
SHOW ENGINE INNODB STATUS;   -- 查看最近一次死锁的详细信息(LATEST DETECTED DEADLOCK 段)
SHOW PROCESSLIST; -- 查看当前所有连接和锁等待
-- 开启死锁日志(my.cnf)
-- innodb_print_all_deadlocks = 1

7.4 避免死锁的实战建议

  • 统一加锁顺序:所有事务都按相同顺序(如先 A 后 B)访问资源,避免交叉等待。
  • 缩短事务:事务越长,持有锁越久,冲突概率越高,尽量把大事务拆小。
  • 降低锁粒度:用 SELECT ... FOR UPDATE 时范围尽量小,避免无索引导致表锁。
  • 合理重试:捕获死锁异常后,业务层做有限次数的重试(被回滚的那个事务)。

八、生产避坑总结

场景 风险 正确做法
大事务 长时间持锁、主从延迟 拆分事务、批量提交
无索引更新 行锁退化为表锁 确保 WHERE 走索引
混用快照读/当前读 误以为不会幻读 明确自己需要的是哪类读
并发更新同一行 锁等待、死锁 统一顺序、控制并发
RC 下统计 前后不一致 需要可重复读时用 RR

总结:事务靠 ACID 立身,隔离性由「MVCC + 锁」双管齐下;快照读靠 ReadView 看历史、当前读靠临键锁防插入;行锁的命门在索引;死锁靠「统一顺序 + 短事务」化解。理解这套逻辑,MySQL 并发难题基本都能迎刃而解。