一文讲透 MVCC:普通 SELECT 何时不加锁?(RC/RR 实战篇)
- 一、结论先行
- [二、MVCC 是怎么做到"不加锁也能读"的?](#二、MVCC 是怎么做到“不加锁也能读”的?)
- 三、什么时候真的"不加锁"?(对照表)
-
- [✅ 不加锁的情况(99% 的查询)](#✅ 不加锁的情况(99% 的查询))
- [❌ 一定会加锁的情况(当前读)](#❌ 一定会加锁的情况(当前读))
- [四、最关键的概念:快照读 vs 当前读](#四、最关键的概念:快照读 vs 当前读)
-
- [1️⃣ 快照读(Snapshot Read)](#1️⃣ 快照读(Snapshot Read))
- [2️⃣ 当前读(Current Read)](#2️⃣ 当前读(Current Read))
- 五、用一个时间线彻底看懂
-
- 初始数据
- [事务 A(先开始)](#事务 A(先开始))
- [事务 B(后开始)](#事务 B(后开始))
- [事务 A 再查一次](#事务 A 再查一次)
-
- [在 RR 下:](#在 RR 下:)
- [在 RC 下:](#在 RC 下:)
- 六、那"什么时候会偷偷加锁"?(非常容易踩坑)
-
-
- [⚠️ 场景 1:`SELECT ... FOR UPDATE`](#⚠️ 场景 1:
SELECT ... FOR UPDATE) - [⚠️ 场景 2:唯一索引 vs 非唯一索引](#⚠️ 场景 2:唯一索引 vs 非唯一索引)
- [⚠️ 场景 3:SERIALIZABLE 隔离级别](#⚠️ 场景 3:SERIALIZABLE 隔离级别)
- [⚠️ 场景 1:`SELECT ... FOR UPDATE`](#⚠️ 场景 1:
-
- [七、意向锁 & MVCC 的关系](#七、意向锁 & MVCC 的关系)
- 八、总结
一、结论先行
InnoDB 在"普通 SELECT + RC / RR 隔离级别"下,使用 MVCC 读历史版本,不加任何行锁,也不加意向锁。
也就是说:
sql
SELECT * FROM table WHERE ...
在绝大多数情况下:
❌ 不加行锁
❌ 不加意向锁
❌ 不阻塞写
❌ 不被写阻塞
二、MVCC 是怎么做到"不加锁也能读"的?
3个核心组件
1️⃣ Undo Log :保存行的历史版本
2️⃣ Read View :当前事务"能看到哪些事务"
3️⃣ 隐藏字段:
trx_id(最后一次修改该行的事务)roll_pointer(指向 undo log)
👉 普通 SELECT:
- 直接走 undo log
- 构造一个 一致性视图
- 完全不碰锁
三、什么时候真的"不加锁"?(对照表)
✅ 不加锁的情况(99% 的查询)
| SQL | 是否加锁 | 原因 |
|---|---|---|
SELECT ... |
❌ | MVCC 一致性读 |
SELECT ... WHERE ... |
❌ | 读历史版本 |
SELECT COUNT(*) |
❌ | 读快照 |
SELECT ... LIMIT |
❌ | 快照读 |
SELECT ... JOIN ... |
❌ | 快照读 |
📌 前提条件:
- 隔离级别 = RC / RR
- 不是
FOR UPDATE / LOCK IN SHARE MODE
❌ 一定会加锁的情况(当前读)
| SQL | 加什么锁 |
|---|---|
SELECT ... FOR UPDATE |
行 X + 表 IX |
SELECT ... LOCK IN SHARE MODE |
行 S + 表 IS |
UPDATE ... |
行 X + 表 IX |
DELETE ... |
行 X + 表 IX |
INSERT ... |
行 X + 表 IX |
👉 只要是"当前读" = 一定加锁
四、最关键的概念:快照读 vs 当前读
1️⃣ 快照读(Snapshot Read)
sql
SELECT * FROM user WHERE id = 1;
- 读的是 历史版本
- 不关心最新数据
- 不加锁
- 不阻塞任何人
✅ 默认 SELECT 都是 快照读
2️⃣ 当前读(Current Read)
sql
SELECT * FROM user WHERE id = 1 FOR UPDATE;
- 必须读 最新版本
- 必须保证别人不能改
- 所以 一定加锁
五、用一个时间线彻底看懂
初始数据
text
id=1, balance=100
事务 A(先开始)
sql
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
- 读到:100
- ❌ 不加锁
事务 B(后开始)
sql
START TRANSACTION;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
- 改成 200
- 加 X 锁 → 提交释放
事务 A 再查一次
sql
SELECT balance FROM account WHERE id = 1;
在 RR 下:
- 仍然读到:100
- 因为 Read View 不变
- ❌ 不加锁
在 RC 下:
- 读到:200
- 每次 SELECT 生成新 Read View
- ❌ 不加锁
📌 关键点:
不管 RC 还是 RR,普通 SELECT 都不加锁
六、那"什么时候会偷偷加锁"?(非常容易踩坑)
⚠️ 场景 1:SELECT ... FOR UPDATE
sql
SELECT * FROM user WHERE age > 20 FOR UPDATE;
-
加:
- 行 X 锁
- Next-Key Lock(行 + 间隙)
-
表上加 IX
👉 范围查询 = 锁一大片
⚠️ 场景 2:唯一索引 vs 非唯一索引
sql
SELECT * FROM user WHERE email='a@b.com' FOR UPDATE;
| 索引情况 | 锁 |
|---|---|
| 唯一索引 | 行锁 |
| 非唯一索引 | Next-Key Lock |
⚠️ 场景 3:SERIALIZABLE 隔离级别
sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM user WHERE id = 1;
😱 即使是普通 SELECT:
- 也会加 S 锁
- 等价于
LOCK IN SHARE MODE
📌 这是唯一一个"普通 SELECT 也加锁"的情况
七、意向锁 & MVCC 的关系
快照读(普通 SELECT)
↓
不加行锁
↓
也就不需要意向锁
当前读(FOR UPDATE / UPDATE)
↓
加行锁
↓
自动加 IS / IX
八、总结
"普通查快照,不锁;
改数据、要最新,必锁;
FOR UPDATE / SERIALIZABLE,锁必到。"