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

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 | -- 逻辑示意:更新前先把旧值记入 undo log |
2.2 持久性靠 redo log
数据先写内存(Buffer Pool),再异步刷盘,这之间存在崩溃风险。redo log 采用 WAL(Write-Ahead Logging) 机制:事务提交时,先把修改记录到 redo log(顺序写、速度快),即使还没刷到数据页,崩溃后也能根据 redo log 重放恢复。
三、隔离级别与并发三兄弟
当多个事务并发执行,会出现三类经典问题:
| 问题 | 描述 | 例子 |
|---|---|---|
| 脏读 | 读到另一个事务未提交的数据 | 事务 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 | 最新版本 (trx_id=20) |
4.3 ReadView:判断哪个版本「对我可见」
事务执行快照读时,InnoDB 会生成一个 ReadView,里面记录了:
m_ids:当前活跃(未提交)的事务 ID 列表min_trx_id:m_ids中的最小值max_trx_id:下一个将要分配的事务 IDcreator_trx_id:创建该 ReadView 的事务 ID
对版本链上的每一版,可见性判断规则:
1 | 版本的事务 ID = trx_id |
4.4 RC 与 RR 的关键差异:ReadView 生成时机
| 隔离级别 | ReadView 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每次 SELECT 都生成新 ReadView | 能看到别的事务最新已提交的数据 → 不可重复读 |
| REPEATABLE READ | 第一次 SELECT 时生成,之后整个事务复用 | 始终看到同一份快照 → 可重复读 |
五、幻读:快照读与当前读
5.1 快照读 vs 当前读
- 快照读(Snapshot Read):普通
SELECT,基于 MVCC 读历史版本,不加锁。 - 当前读(Current Read):
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT/UPDATE/DELETE,读的是最新已提交版本,并加锁。
1 | SELECT * FROM user WHERE age > 20; -- 快照读,无锁 |
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/INSERT 或 FOR UPDATE,靠的正是临键锁在保护你。
六、InnoDB 锁体系总览
6.1 行锁、表锁与意向锁
| 锁 | 粒度 | 说明 |
|---|---|---|
| 行锁 | 行 | InnoDB 支持,MyISAM 不支持 |
| 表锁 | 表 | 整张表锁定 |
| 意向锁(IS/IX) | 表 | 意向标记,表级,用于快速判断能否加表锁,本身不阻塞行锁 |
意向锁是「占位符」:事务准备给某几行加行锁前,先给整张表加一个意向锁,这样别的事务想加表锁时不用逐行扫描,直接看意向锁即可。
6.2 锁的兼容矩阵
| 已持有 \ 请求 | IS | IX | S(表共享) | X(表排他) |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
6.3 行锁加在哪?索引是关键
1 | -- 假设 id 是主键,name 有普通索引,age 无索引 |
七、死锁:成因、案例与排查
7.1 死锁的四个必要条件
- 互斥:资源只能被一个事务占用
- 持有并等待:持有锁的同时还在等别的锁
- 不可剥夺:锁不能被强行抢占
- 循环等待:A 等 B 的锁、B 等 A 的锁,形成环
四个条件同时满足才会死锁,打破任意一个即可解除。
7.2 经典死锁案例
1 | -- 事务 A |
事务 A 持有 A 等 B,事务 B 持有 B 等 A → 循环等待 → 死锁。InnoDB 默认会检测死锁,并回滚其中一个代价较小的事务,另一个得以继续执行(报错:Deadlock found when trying to get lock)。
7.3 排查死锁
1 | SHOW ENGINE INNODB STATUS; -- 查看最近一次死锁的详细信息(LATEST DETECTED DEADLOCK 段) |
7.4 避免死锁的实战建议
- 统一加锁顺序:所有事务都按相同顺序(如先 A 后 B)访问资源,避免交叉等待。
- 缩短事务:事务越长,持有锁越久,冲突概率越高,尽量把大事务拆小。
- 降低锁粒度:用
SELECT ... FOR UPDATE时范围尽量小,避免无索引导致表锁。 - 合理重试:捕获死锁异常后,业务层做有限次数的重试(被回滚的那个事务)。
八、生产避坑总结
| 场景 | 风险 | 正确做法 |
|---|---|---|
| 大事务 | 长时间持锁、主从延迟 | 拆分事务、批量提交 |
| 无索引更新 | 行锁退化为表锁 | 确保 WHERE 走索引 |
| 混用快照读/当前读 | 误以为不会幻读 | 明确自己需要的是哪类读 |
| 并发更新同一行 | 锁等待、死锁 | 统一顺序、控制并发 |
| RC 下统计 | 前后不一致 | 需要可重复读时用 RR |












