335 lines
13 KiB
Markdown
335 lines
13 KiB
Markdown
---
|
||
tags: [MySQL, 一致性读, 当前读, snapshot read, current read]
|
||
create time: 2026-05-16 00:00
|
||
---
|
||
|
||
# 一致性读 vs 当前读
|
||
|
||
## 概述
|
||
|
||
> [!QUESTION] 思考题
|
||
> 事务 A 在执行 `SELECT * FROM orders WHERE id = 1`,同时事务 B 正在 `UPDATE orders SET amount = 100 WHERE id = 1` 并尚未 COMMIT——此时 A 应该读到旧值还是新值?
|
||
|
||
InnoDB 为此提供了两种读模式:**一致性读(Consistent Nonlocking Read)**和**当前读(Current Read)**。它们的核心区别在于是否使用 MVCC 快照、是否获取锁。这个区别直接影响你对并发行为的预期,也是理解 InnoDB 事务隔离的基石。
|
||
|
||
## 两种读的对比
|
||
|
||
| 特性 | 一致性读(快照读) | 当前读 |
|
||
|------|-------------------|--------|
|
||
| **语句** | `SELECT`(不加锁) | `SELECT ... FOR UPDATE/SHARE MODE` |
|
||
| | | `UPDATE` |
|
||
| | | `DELETE` |
|
||
| | | `INSERT` |
|
||
| **使用 MVCC?** | ✅ 是,读历史版本 | ❌ 否,读最新已提交数据 |
|
||
| **加锁?** | ❌ 不加锁 | ✅ 加 X 锁或 S 锁 |
|
||
| **看到的是?** | Read View 时刻的数据 | 实际的、最新的行记录 |
|
||
| **隔离级别依赖?** | ✅ 是(RR vs RC 行为不同)| ❌ 否(总是读最新)|
|
||
|
||
> [!TIP] 核心直觉
|
||
> - **一致性读** = 读「快照」→ 不阻塞别人,别人也阻塞不了你 → 适合报表、数据分析
|
||
> - **当前读** = 读「真实」→ 拿到最新一行并锁住 → 适合余额扣减、库存扣减等写前校验场景
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant T1 as Transaction A
|
||
participant DB as InnoDB
|
||
participant T2 as Transaction B
|
||
|
||
T1->>DB: BEGIN;
|
||
T2->>DB: BEGIN;
|
||
|
||
T1->>DB: SELECT * FROM t WHERE id=1;
|
||
Note over T1,DB: 一致性读 → 创建 ReadView,<br/>读到 mvcc_version_A
|
||
|
||
T2->>DB: UPDATE t SET col="v2" WHERE id=1;
|
||
T2->>DB: COMMIT;
|
||
|
||
T1->>DB: SELECT * FROM t WHERE id=1;
|
||
Note over T1,DB: 一致性读 → 复用同一 ReadView,<br/>仍读到 mvcc_version_A ✅
|
||
|
||
T1->>DB: SELECT * FROM t WHERE id=1 FOR UPDATE;
|
||
Note over T1,DB: 当前读 → 绕过 ReadView,<br/>读到 v2 + 获取 X 锁
|
||
```
|
||
|
||
## 底层原理:Undo Log + Version Chain
|
||
|
||
一致性读之所以能读到「历史版本」,核心依赖 InnoDB 的 **undo log** 和行记录中的 **version chain**。
|
||
|
||
### Row Record 的结构(MVCC 视角)
|
||
|
||
每一行记录除了业务数据外,还隐藏了两个关键字段:
|
||
|
||
| 隐藏字段 | 说明 |
|
||
|----------|------|
|
||
| `DB_TRX_ID` | 最后修改这行记录的事务 ID(6 字节) |
|
||
| `DB_ROLL_PTR` | 回滚指针,指向 undo log 中这条记录的旧版本(20 字节) |
|
||
|
||
### Undo Log 链(Version Chain)的形成
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["新写入的行"] --> B["旧版本 (undo_log_1)"]
|
||
B --> C["更早版本 (undo_log_2)"]
|
||
C --> D["再早版本 (undo_log_3)"]
|
||
|
||
style A fill:#00D866,color:#fff
|
||
style B fill:#FF9F43,color:#000
|
||
style C fill:#FF9F43,color:#000
|
||
style D fill:#FF9F43,color:#000
|
||
```
|
||
|
||
每次 UPDATE / DELETE 操作时,InnoDB 不会直接覆盖原行,而是:
|
||
|
||
1. **将旧行数据写入 undo log**(形成版本链上一个节点)
|
||
2. **在新行上记录前一个版本的回滚指针** (`DB_ROLL_PTR`)
|
||
3. 更新 `DB_TRX_ID` 为当前事务 ID
|
||
|
||
> [!QUESTION] 为什么是链表而不是数组?
|
||
> 因为版本数量在写入时无法预知。链表允许 O(1) 追加新版本,且通过 `DB_ROLL_PTR` 可以高效遍历——只沿着真正需要回溯的路径走。
|
||
|
||
### Read View 如何从版本链中找到可见版本
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["SELECT 执行"] --> B["构建 ReadView<br/>m_ids, min_trx_id, max_trx_id"]
|
||
B --> C{"遍历 version chain"}
|
||
C --> D["检查当前版本的 DB_TRX_ID"]
|
||
|
||
D --> E{"trx_id 在 ReadView 中可见?"}
|
||
|
||
E -->|是| G["✅ 返回此版本"]
|
||
E -->|否| F["沿 DB_ROLL_PTR 到上一版本"]
|
||
F --> C
|
||
|
||
F --> H{"是否已无更多版本?"}
|
||
H -->|是| I["返回空 — 无可视数据"]
|
||
|
||
style B fill:#00B6BC,color:#fff
|
||
style G fill:#00D866,color:#fff
|
||
style I fill:#FF6B6B,color:#fff
|
||
```
|
||
|
||
> [!NOTE] 可见性判断规则
|
||
> - 若 `trx_id == ReadView.m_ids` 中的某个值 → **当前事务自己改的,可见**
|
||
> - 若 `trx_id < min_trx_id` → **提交于快照之前的,可见**
|
||
> - 若 `trx_id >= max_trx_id` → **提交于快照之后的,不可见**
|
||
> - 若在 `[min_trx_id, max_trx_id)` 之间且在 `m_ids` 列表中 → **可见(该事务在 ReadView 创建时未提交)**
|
||
> - 若在 `[min_trx_id, max_trx_id)` 之间但**不在** `m_ids` 列表中 → **不可见(该事务已提交)**
|
||
|
||
### 一致性读的完整流程
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["普通 SELECT"] --> B{"目标行是否有锁?"}
|
||
|
||
B -->|无锁| C["直接读取当前行"]
|
||
|
||
B -->|有锁| D["跳过当前行"]
|
||
D --> E{"DB_ROLL_PTR 是否存在?"}
|
||
|
||
E -->|否| F["无可视版本, 返回空"]
|
||
E -->|是| G["跟随指针读取 undo log 旧版本"]
|
||
G --> H{"旧版本 trx_id 可见?"}
|
||
|
||
H -->|是| I["✅ 返回可见版本"]
|
||
H -->|否| J{"还有更早版本?"}
|
||
|
||
J -->|是| G
|
||
J -->|否| F
|
||
|
||
C --> K["返回结果"]
|
||
I --> K
|
||
F --> K
|
||
|
||
style B fill:#FF9F43,color:#000
|
||
style I fill:#00D866,color:#fff
|
||
style F fill:#FF6B6B,color:#fff
|
||
```
|
||
|
||
> [!TIP] 一致性读的性能优势
|
||
> 由于不需要加锁、不需要等待锁释放,一致性读在大量只读场景下几乎不产生并发开销。这就是为什么报表查询、数据导出都应该用普通 `SELECT`——既不影响业务写入,也避免了自己被阻塞。
|
||
|
||
## 深入理解一致性读
|
||
|
||
> [!NOTE] 为什么叫「快照读」?
|
||
> 因为它读的不是磁盘上的最新数据,而是某个时间点的数据「快照」。在 RR 模式下,这个快照在事务第一次 SELECT 时就冻结了;在 RC 模式下,每次 SELECT 都刷新快照。
|
||
|
||
## 深入理解当前读
|
||
|
||
```sql
|
||
-- 所有当前读语句
|
||
SELECT ... LOCK IN SHARE MODE; -- 加 S 锁(共享读锁)
|
||
SELECT ... FOR UPDATE; -- 加 X 锁(排他写锁)
|
||
UPDATE ... -- 隐式加 X 锁
|
||
DELETE ... -- 隐式加 X 锁
|
||
INSERT ... -- 隐式加 X 锁(对被插入的行)
|
||
```
|
||
|
||
### FOR UPDATE 的实际行为
|
||
|
||
```sql
|
||
-- 会话 A
|
||
BEGIN;
|
||
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
|
||
-- 加 X 锁到 user_id=100 对应的所有索引记录
|
||
-- 包括聚簇索引记录和所有二级索引记录
|
||
|
||
-- 会话 B(尝试读取)
|
||
BEGIN;
|
||
SELECT * FROM orders WHERE user_id = 100;
|
||
-- ✅ 能读到!(一致性读,不走当前读路径)
|
||
-- 读到的是 FOR UPDATE 之前的版本
|
||
|
||
-- 会话 B(尝试修改)
|
||
UPDATE orders SET amount = 50 WHERE user_id = 100;
|
||
-- ⏳ 阻塞!需要 X 锁,被会话 A 持有
|
||
-- 直到 A COMMIT 或 ROLLBACK
|
||
```
|
||
|
||
### FOR SHARE MODE 的行为
|
||
|
||
```sql
|
||
-- 会话 A
|
||
BEGIN;
|
||
SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
|
||
-- 加 S 锁(其他事务也可以加 S 锁,但都不能加 X 锁)
|
||
|
||
-- 会话 B
|
||
BEGIN;
|
||
SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
|
||
-- ✅ 可以同时获得 S 锁(共享)
|
||
|
||
UPDATE orders SET amount = 50 WHERE user_id = 100;
|
||
-- ⏳ 阻塞!需要 X 锁,但有 S 锁在
|
||
```
|
||
|
||
## 二级索引的回表效应
|
||
|
||
当前读在二级索引上有一个特殊行为:不仅锁二级索引记录,还要**回表锁住聚簇索引记录**。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant T as 事务
|
||
participant SI as 二级索引 idx_user_id
|
||
participant CI as 聚簇索引 PK
|
||
|
||
T->>SI: UPDATE orders SET amount=? WHERE user_id=100
|
||
SI->>SI: 找到所有 user_id=100 的记录,<br/>得到 pk=[1,5,8]
|
||
|
||
loop 遍历每个 pk
|
||
SI->>CI: 回表加 X 锁
|
||
CI-->>SI: 已锁
|
||
end
|
||
|
||
SI-->>T: UPDATE 完成
|
||
```
|
||
|
||
> [!TIP] 这意味着什么?
|
||
> 用二级索引做 UPDATE/DELETE/FOR UPDATE 时,锁的范围比看起来更大——它覆盖了二级索引记录 + 回表后的聚簇索引记录。如果二级索引区分度不高(比如 gender 列),会导致大量行被锁住。
|
||
|
||
## 实战决策树
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Q["你需要读数据"] --> Decision{"要读最新提交的数据吗?"}
|
||
|
||
Decision -->|不需要 / 读旧数据也可接受| Snapshot["用普通 SELECT<br/>一致性读 ✅"]
|
||
Decision -->|必须读最新数据| CurrentRead["用当前读"]
|
||
|
||
CurrentRead --> Purpose{"接下来要修改这些数据吗?"}
|
||
Purpose -->|是| FU["SELECT ... FOR UPDATE<br/>拿 X 锁"]
|
||
Purpose -->|否,只是防别人改| FS["SELECT ... LOCK IN SHARE MODE<br/>拿 S 锁"]
|
||
|
||
style Snapshot fill:#00D866,color:#fff
|
||
style FU fill:#00B6BC,color:#fff
|
||
style FS fill:#FF9F43,color:#000
|
||
style Decision fill:#FF9F43,color:#000
|
||
```
|
||
|
||
## 实战场景与避坑指南
|
||
|
||
### ✅ 典型场景:扣减余额(防超卖)
|
||
|
||
```sql
|
||
-- 正确的做法:当前读 + FOR UPDATE
|
||
BEGIN;
|
||
SELECT balance FROM user_wallet WHERE user_id = ? FOR UPDATE;
|
||
-- ⏱️ 读到的是最新余额,同时锁住行
|
||
|
||
UPDATE user_wallet SET balance = balance - 50 WHERE user_id = ?;
|
||
COMMIT;
|
||
```
|
||
|
||
> [!WARNING] 为什么不能用普通 SELECT?
|
||
> 如果用了 `SELECT balance FROM user_wallet WHERE user_id = ?`(一致性读),两个并发事务可能都读到旧的 balance=200,然后各自减 50 → 最后余额变成 150 而不是 100。**数据丢失了**。这就是为什么写前校验必须用当前读。
|
||
|
||
### ✅ 典型场景:报表查询
|
||
|
||
```sql
|
||
-- 正确的做法:纯 SELECT,不加锁
|
||
SELECT product_name, SUM(quantity)
|
||
FROM orders
|
||
WHERE created_at >= '2026-01-01'
|
||
GROUP BY product_name;
|
||
```
|
||
|
||
这种场景不需要读「此刻的最新数据」,只要保证自身的事务内一致性即可。使用普通 SELECT 不会影响其他事务的写入性能。
|
||
|
||
### 🚫 常见误区:误以为 `FOR UPDATE` 会阻塞别人的 `SELECT`
|
||
|
||
很多开发者以为加了 `FOR UPDATE`,其他人就查不了这行了——**这是错误的**。
|
||
|
||
| 会话 A | 会话 B | 结果 |
|
||
|--------|--------|------|
|
||
| `SELECT ... FOR UPDATE` | `SELECT ...` | ✅ B 能读到(快照读)|
|
||
| `SELECT ... FOR UPDATE` | `SELECT ... FOR UPDATE` | ⏳ B 被阻塞 |
|
||
| `SELECT ... FOR UPDATE` | `UPDATE ...` | ⏳ B 被阻塞 |
|
||
| `SELECT ... FOR UPDATE` | `DELETE ...` | ⏳ B 被阻塞 |
|
||
|
||
**结论**:`FOR UPDATE` 只影响其他事务的**写操作**和**当前读**,不影响普通的 `SELECT`。
|
||
|
||
### 🚫 常见误区:忽略二级索引的回表锁
|
||
|
||
```sql
|
||
-- 假设 orders 表上有二级索引 idx_user_id(user_id)
|
||
BEGIN;
|
||
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
|
||
```
|
||
|
||
这里不仅锁定了 `idx_user_id = 100` 的所有索引记录,还通过回表对每个对应的**聚簇索引主键行**加了 X 锁。
|
||
|
||
> [!QUESTION] 如果 user_id 上有 100 万条记录,这个 FOR UPDATE 会怎样?
|
||
> 会锁住 100 万个聚簇索引记录!在 RR 模式下还会加间隙锁,导致整个索引范围都被锁定。此时任何其他基于 `user_id` 或主键的 INSERT / UPDATE 都会被阻塞。**这就是为什么大范围的 FOR UPDATE 是线上事故的高发原因。**
|
||
|
||
### 🚫 陷阱:Undo Log 无限膨胀
|
||
|
||
每次 UPDATE 操作都会将旧版本写入 undo log。如果一个热点行被高频更新,version chain 会越来越长:
|
||
|
||
```
|
||
新值 ← v_n ← v_{n-1} ← ... ← v_2 ← v_1 ← 初始值
|
||
```
|
||
|
||
当一致性读需要遍历这条长链时,性能会逐渐下降。在极端情况下可能导致:
|
||
- **查询变慢**:遍历越长的 version chain 消耗越多 CPU
|
||
- **undo log 文件膨胀**:占用大量磁盘空间
|
||
|
||
**建议**:避免对同一行做高频的小幅度更新(例如计数器),考虑改为追加写入历史表。
|
||
|
||
## RC vs RR 下的差异总结
|
||
|
||
| 场景 | RR 下的行为 | RC 下的行为 |
|
||
|------|-----------|-----------|
|
||
| `SELECT`(普通) | 第一次 SELECT 创建 ReadView,之后复用 | 每次 SELECT 创建新 ReadView |
|
||
| `SELECT ... FOR UPDATE` | 读最新 + 锁记录 | 读最新 + 锁记录(与 RR 相同)|
|
||
| `UPDATE WHERE` 条件列 | 加 Next-Key Lock(含间隙)| 只加 Record Lock(不含间隙)|
|
||
| 同一个事务内多次读 | 始终看到一致快照 | 每次可能看到新提交的数据 |
|
||
|
||
> [!QUESTION] 什么时候 RC 和 RR 的行为会「打架」?
|
||
> 当你在同一个连接中混合使用了 RC 和 RR(通过 `SET SESSION` 切换隔离级别)。例如你的框架默认用 RR,但某个关键接口临时切了 RC。这时同一个事务内的 SELECT 行为不一致——前半段用旧 ReadView,后半段用新 ReadView。建议在事务开始时明确设置隔离级别,并在事务结束后恢复。
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/MySQL/25-MVCC 原理]] — ReadView 的构造和版本链查找
|
||
- [[hhs/MySQL/26-锁机制总览]] — 当前读涉及的 S 锁和 X 锁
|
||
- [[hhs/GORM/08-事务管理]] — GORM 中的 FirstForUpdate / SetLock 用法
|