This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/28-一致性读与当前读.md
T
2026-05-17 00:06:11 +08:00

335 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 用法