"我用的是 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 反向执行即可恢复到事务前的状态。
-- 逻辑示意:更新前先把旧值记入 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 串成一条版本链。链条头部是最新版本,尾部是最早版本。
最新版本 (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_id:m_ids中的最小值max_trx_id:下一个将要分配的事务 IDcreator_trx_id:创建该 ReadView 的事务 ID
对版本链上的每一版,可见性判断规则:
版本的事务 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 UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT/UPDATE/DELETE,读的是最新已提交版本,并加锁。
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 下的「解决幻读」指的是当前读场景。如果你的事务全程只用普通 (快照读),确实不会幻读;但如果混合了 或 ,靠的正是临键锁在保护你。
六、InnoDB 锁体系总览
6.1 行锁、表锁与意向锁
| 锁 | 粒度 | 说明 |
|---|---|---|
| 行锁 | 行 | InnoDB 支持,MyISAM 不支持 |
| 表锁 | 表 | 整张表锁定 |
| 意向锁(IS/IX) | 表 | 意向标记,表级,用于快速判断能否加表锁,本身不阻塞行锁 |
意向锁是「占位符」:事务准备给某几行加行锁前,先给整张表加一个意向锁,这样别的事务想加表锁时不用逐行扫描,直接看意向锁即可。
6.2 锁的兼容矩阵
| 已持有 \ 请求 | IS | IX | S(表共享) | X(表排他) |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
6.3 行锁加在哪?索引是关键
-- 假设 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 死锁的四个必要条件
- 互斥:资源只能被一个事务占用
- 持有并等待:持有锁的同时还在等别的锁
- 不可剥夺:锁不能被强行抢占
- 循环等待:A 等 B 的锁、B 等 A 的锁,形成环
四个条件同时满足才会死锁,打破任意一个即可解除。
7.2 经典死锁案例
-- 事务 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 排查死锁
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 并发难题基本都能迎刃而解。