📌 PDF :大白话说Java面试题 --- 03-Mysql篇
第15题:MySQL 的事务中,幻读是怎么解决的
📚 回答:
- 核心考点 :
大厂面试要求深入理解MVCC与Next-Key Lock的区别 、快照读 vs 当前读的不同处理方式 、Next-Key Lock的加锁范围 ,以及RR级别下幻读是否100%解决。面试官常追问:"RR级别下能完全避免幻读吗?"、"Serializable隔离级别怎么解决幻读?"
1. 幻读的定义
幻读是指:在同一事务中,相同的查询条件 在不同时间点执行,返回的结果集行数不一致。
典型场景:
sql
-- 事务 A
SELECT * FROM users WHERE age = 20; -- 返回 10 条
-- 事务 B 插入了一条 age=20 的新记录
INSERT INTO users (age) VALUES (20);
-- 事务 A 再次查询
SELECT * FROM users WHERE age = 20; -- 返回 11 条(幻读)
幻读 vs 不可重复读:
| 问题 | 定义 | 示例 |
|---|---|---|
| 不可重复读 | 同一行数据,两次读取的内容不一致 | 第一次读到name='张三',第二次变成name='李四' |
| 幻读 | 两次查询返回的行数不一致 | 第一次10行,第二次11行(新增) |
注意 :MySQL的InnoDB在RR级别下,MVCC已经解决了快照读 的幻读,但当前读仍需Next-Key Lock解决。
2. 解决方案概览
| 读类型 | 解决方案 | 原理 |
|---|---|---|
| 快照读(SELECT) | MVCC | 读取Undo日志中的历史版本,不受新插入数据影响 |
| 当前读(SELECT FOR UPDATE/UPDATE/DELETE) | Next-Key Lock | 锁定记录+间隙,防止其他事务插入 |
为什么需要两种方案?
- MVCC:无锁并发,性能好,但只能用于快照读
- Next-Key Lock:阻塞其他事务,保证当前读数据一致性,有锁代价
3. MVCC如何解决快照读的幻读
3.1 MVCC核心组件
| 组件 | 作用 | 存储位置 |
|---|---|---|
| Undo Log版本链 | 记录每次修改的历史版本,通过roll_pointer串联 |
Undo表空间 |
| ReadView | 判断事务可见性的快照 | 事务执行快照读时生成 |
| trx_id | 最近修改该行的事务ID | 每行记录头信息 |
| roll_pointer | 指向上一个版本的指针 | 每行记录头信息 |
3.2 ReadView判断规则
cpp
// ReadView核心字段
m_ids // 当前未提交的事务ID列表(活跃事务)
min_trx_id // m_ids中的最小值
max_trx_id // 系统预分配的下一个事务ID(即当前最大事务ID+1)
creator_trx_id // 创建该ReadView的事务ID
// 可见性判断规则(伪代码)
bool isVisible(trx_id) {
if (trx_id == creator_trx_id) return true; // 当前事务修改
if (trx_id < min_trx_id) return true; // 已提交
if (trx_id >= max_trx_id) return false; // 未来事务
if (trx_id is not in m_ids) return true; // 已提交
return false; // 未提交
}
3.3 MVCC解决快照读幻读示例
sql
-- 事务 A(开启时间早)
START TRANSACTION;
SELECT * FROM users WHERE age = 20; -- 生成ReadView_A,返回10行
-- 事务 B(在事务A之后,提交前)
START TRANSACTION;
INSERT INTO users (age) VALUES (20);
COMMIT;
-- 事务 A 再次快照读
SELECT * FROM users WHERE age = 20; -- 仍用ReadView_A,新插入的行不可见,仍返回10行
关键:事务A的第二次快照读仍使用第一次生成的ReadView,因此看不到事务B插入的新行。
4. Next-Key Lock解决当前读的幻读
4.1 什么是Next-Key Lock
Next-Key Lock = Record Lock(记录锁) + Gap Lock(间隙锁)
| 锁类型 | 锁范围 | 作用 |
|---|---|---|
| Record Lock | 锁定具体索引记录 | 防止其他事务修改/删除该记录 |
| Gap Lock | 锁定索引记录之间的间隙 | 防止其他事务在该间隙插入新记录 |
| Next-Key Lock | Record Lock + Gap Lock | 防止幻读(新记录插入) |
4.2 Gap Lock的锁定范围
sql
-- 假设users表有id索引:1, 3, 5, 7, 9
-- 查询条件:WHERE id = 5 且使用范围锁定
Next-Key Lock锁定范围:(3, 5] 和 (5, 7]
-- 即锁定了3-7之间的所有间隙
间隙锁定规则:
- 锁定的间隙是开区间(不包含边界值,除非是Record Lock)
- 间隙锁之间不互斥(多个事务可以锁同一个间隙)
- 间隙锁只阻止插入 ,不阻止读
4.3 Next-Key Lock解决当前读幻读示例
sql
-- 事务 A(当前读)
START TRANSACTION;
SELECT * FROM users WHERE age = 20 FOR UPDATE;
-- 获取 Next-Key Lock,锁定 age=20 的记录及对应间隙
-- 事务 B(试图插入)
START TRANSACTION;
INSERT INTO users (age) VALUES (20);
-- 阻塞,因为事务 A 锁定了该间隙
-- 事务 A 再次查询
SELECT * FROM users WHERE age = 20 FOR UPDATE;
-- 返回行数不变,无幻读
5. Next-Key Lock在不同查询条件下的加锁范围
| 查询条件 | 锁类型 | 加锁范围 | 示例(索引值:1,3,5,7,9) |
|---|---|---|---|
| 唯一索引等值查询(命中) | Record Lock | 仅锁定该记录 | WHERE id=5 → 锁5 |
| 唯一索引等值查询(未命中) | Gap Lock | 锁定命中间隙 | WHERE id=6 → 锁(5,7) |
| 普通索引等值查询(命中) | Next-Key Lock | 锁记录+前间隙 | WHERE age=5 → 锁(3,5]、(5,7) |
| 普通索引等值查询(未命中) | Gap Lock | 锁定命中间隙 | WHERE age=6 → 锁(5,7) |
| 范围查询 | Next-Key Lock | 锁范围内的所有间隙+记录 | WHERE id > 5 → 锁(5,+∞) |
关键规则(InnoDB加锁原则):
- 唯一索引等值命中 → Record Lock(退化为行锁)
- 唯一索引等值未命中 → Gap Lock(锁定间隙)
- 普通索引 → Next-Key Lock(无法退化,因为可能有重复值)
- 范围查询 → Next-Key Lock,锁到范围结束
- 无索引条件 → 全表锁(所有间隙+记录),高并发风险
示例分析:
sql
-- 表结构
CREATE TABLE t (id INT PRIMARY KEY, age INT, INDEX idx_age(age));
-- 已有数据:age: 1, 3, 5, 7, 9
-- 查询1:唯一索引命中
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 锁:id=5的Record Lock(不锁间隙)
-- 查询2:普通索引命中
SELECT * FROM t WHERE age = 5 FOR UPDATE;
-- 锁:Next-Key Lock覆盖(3,5]和(5,7)
-- 不能插入age=4,5,6
-- 但可以插入age=2,8
-- 查询3:范围查询
SELECT * FROM t WHERE age > 5 FOR UPDATE;
-- 锁:所有age>5的Next-Key Lock,直到正无穷
-- 不能插入age=6,7,8,9,10...
6. RR级别下幻读能100%解决吗?
结论 :InnoDB RR级别能100%防止幻读(官方定义)。
官方保障:
- 快照读:MVCC保证同一事务中的SELECT返回一致快照
- 当前读:Next-Key Lock阻止其他事务插入/删除符合条件的记录
边界情况(面试陷阱):
sql
-- 事务 A
SELECT * FROM users WHERE age = 20; -- 快照读,返回10行
-- 事务 B 插入一条age=20并提交
-- 事务 A 执行当前读
SELECT * FROM users WHERE age = 20 FOR UPDATE; -- 返回11行(幻读?)
结论 :这不是幻读,因为事务A通过不同读类型 (快照读→当前读)获取了不同的数据视图。幻读的定义要求相同的查询类型。如果事务A第一次就用当前读,第二次也用当前读,不会有幻读。
7. 不同隔离级别下幻读的处理
| 隔离级别 | 是否有幻读 | 解决方案 | 备注 |
|---|---|---|---|
| READ UNCOMMITTED | ✅ 有 | 无 | 脏读+幻读 |
| READ COMMITTED | ✅ 有 | 无 | 有不可重复读+幻读 |
| REPEATABLE READ | ❌ 无 | MVCC(快照读)+ Next-Key Lock(当前读) | MySQL默认级别 |
| SERIALIZABLE | ❌ 无 | 所有SELECT隐式加锁(LOCK IN SHARE MODE) |
性能极差,实际不用 |
RC vs RR区别:
- RC级别没有Gap Lock,所以当前读无法防止幻读
- RC级别快照读每次都生成新ReadView,所以快照读也无法防止幻读
8. 实战案例分析
案例1:秒杀系统防止超卖
sql
-- 商品表
CREATE TABLE product (
id INT PRIMARY KEY,
stock INT,
INDEX idx_stock(stock)
);
-- 减库存(避免幻读:防止减到负数)
START TRANSACTION;
-- 当前读锁定stock范围
SELECT stock FROM product WHERE stock >= 1 FOR UPDATE;
-- 检查后执行减库存
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
案例2:订单号生成防止重复
sql
-- 使用间隙锁防止幻读
START TRANSACTION;
-- 锁定order_no在(100, 200]范围的间隙
SELECT * FROM orders WHERE order_no BETWEEN 100 AND 200 FOR UPDATE;
-- 在此范围内生成新订单号,不会被其他事务插入重复
INSERT INTO orders (order_no) VALUES (150);
COMMIT;
9. 总结对比表
| 对比维度 | MVCC | Next-Key Lock |
|---|---|---|
| 解决的问题 | 快照读的幻读+不可重复读 | 当前读的幻读 |
| 核心机制 | Undo版本链 + ReadView | Record Lock + Gap Lock |
| 是否加锁 | 无锁(非阻塞) | 有锁(阻塞其他事务) |
| 隔离级别依赖 | RR级别及以上 | RR级别及以上 |
| 性能开销 | 低 | 中(间隙锁可能扩大锁范围) |
| 适用场景 | 普通查询 | SELECT FOR UPDATE、UPDATE、DELETE |
💡 面试官想要的满分总结:
"MySQL通过两种机制解决幻读,取决于读类型:
快照读(普通SELECT) :通过MVCC解决。事务第一次快照读生成ReadView,后续快照读复用该ReadView,通过Undo版本链判断可见性,新提交事务插入的数据不可见,保证了无幻读。
当前读(SELECT FOR UPDATE/UPDATE/DELETE) :通过Next-Key Lock解决。Next-Key Lock = Record Lock + Gap Lock,不仅锁定现有记录,还锁定记录之间的间隙,阻止其他事务在间隙中插入新记录,从而防止幻读。
关键区别:
MVCC是乐观锁方案:无锁并发,性能好
Next-Key Lock是悲观锁方案:加锁阻塞,保证当前读数据一致
注意点:RR级别下,MVCC和Next-Key Lock共同保证100%无幻读
唯一索引等值查询时,Next-Key Lock退化为Record Lock
无索引时,Next-Key Lock会锁全表(所有间隙+记录)
一句话:快照读的幻读靠MVCC,当前读的幻读靠Next-Key Lock(Record Lock+Gap Lock),两者在RR级别下联手彻底消灭幻读。"
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯