Files
cs-note/hhs/MySQL/06-事务与并发控制/28-一致性读与当前读.md
T
2026-05-24 11:42:38 +08:00

17 KiB
Raw Blame History

tags, create time
tags create time
MySQL
一致性读
当前读
snapshot read
current read
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] 核心直觉

  • 一致性读 = 读「快照」→ 不阻塞别人,别人也阻塞不了你 → 适合报表、数据分析
  • 当前读 = 读「真实」→ 拿到最新一行并锁住 → 适合余额扣减、库存扣减等写前校验场景
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)的形成

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

具体示例:一行数据经历三次 UPDATE

-- 初始: INSERT INTO product(id, name, price) VALUES(1, '键盘', 200);
-- 行记录: {id=1, name='键盘', price=200, DB_TRX_ID=50, DB_ROLL_PTR=NULL}

随后三个事务依次修改 price:

操作 聚簇索引中的行 undo log 新增节点
初始 INSERT {price=200, trx=50, roll_ptr=NULL} —
TXN 60: SET price=180 {price=180, trx=60, roll_ptr→undo_1} undo_1: {price=200, trx=50}
TXN 70: SET price=150 {price=150, trx=70, roll_ptr→undo_2} undo_2: {price=180, trx=60}
TXN 80: SET price=99 {price=99, trx=80, roll_ptr→undo_3} undo_3: {price=150, trx=70}

此时 version chain 为:

聚簇索引行(price=99) ──roll_ptr──→ undo_3(price=150) ──→ undo_2(price=180) ──→ undo_1(price=200)
   trx=80                          trx=70                trx=60                 trx=50

[!TIP] 类比理解 把 version chain 想象成「编辑历史」——每次修改都不会覆盖原文,而是把旧版本存进回收站(undo log),新版本放回桌面(聚簇索引)。一致性读就是根据你打开文档的时间点,去回收站翻找对应的版本。

[!QUESTION] 为什么是链表而不是数组? 因为版本数量在写入时无法预知。链表允许 O(1) 追加新版本,且通过 DB_ROLL_PTR 可以高效遍历——只沿着真正需要回溯的路径走。

Read View 如何从版本链中找到可见版本

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] 可见性判断规则(5 条,从上到下依次判断)

  1. trx_id == 当前事务 ID → ✅ 自己改的,可见
  2. trx_id < min_trx_id → ✅ 快照前已提交,可见
  3. trx_id >= max_trx_id → ❌ 快照后才启动,不可见
  4. trx_id ∈ [min_trx_id, max_trx_id) 且在 m_ids 中 → ❌ 快照时尚未提交,不可见
  5. trx_id ∈ [min_trx_id, max_trx_id) 且不在 m_ids 中 → ✅ 快照前已提交,可见

核心直觉:m_ids 就是「快照瞬间还在跑的事务名单」,名单上的事务其修改对当前快照不可见。

一致性读的完整流程

flowchart TD
    A["普通 SELECT"] --> B["读取当前行最新版本"]
    B --> C{"用 ReadView 判断<br/>DB_TRX_ID 是否可见?"}

    C -->|可见| D["✅ 返回此版本"]
    C -->|不可见| E{"DB_ROLL_PTR 是否存在?"}

    E -->|否| F["❌ 无可视版本, 返回空"]
    E -->|是| G["沿 DB_ROLL_PTR 读取 undo log 旧版本"]
    G --> H{"旧版本 trx_id 可见?"}

    H -->|可见| D
    H -->|不可见| I{"还有更早版本?"}

    I -->|是| G
    I -->|否| F

    style C fill:#FF9F43,color:#000
    style D fill:#00D866,color:#fff
    style F fill:#FF6B6B,color:#fff

[!NOTE] 与之前版本的区别 一致性读不关心行上有没有锁——它总是从最新版本开始,通过 ReadView 的可见性规则决定该版本是否对当前事务可见。不可见就沿 undo chain 往回找,直到找到第一个可见版本(或链尾)。

[!TIP] 一致性读的性能优势 由于不需要加锁、不需要等待锁释放,一致性读在大量只读场景下几乎不产生并发开销。这就是为什么报表查询、数据导出都应该用普通 SELECT——既不影响业务写入,也避免了自己被阻塞。

深入理解一致性读

[!NOTE] 为什么叫「快照读」? 因为它读的不是磁盘上的最新数据,而是某个时间点的数据「快照」。在 RR 模式下,这个快照在事务第一次 SELECT 时就冻结了;在 RC 模式下,每次 SELECT 都刷新快照。

端到端示例:跟着 ReadView 走一遍

[!QUESTION] 先想想:如果三个事务交替修改同一行,第四个事务来做一致性读,它到底看到谁的版本?

假设有一行 account(id=1, balance=1000),当前 max_trx_id = 104:

时间 事务 操作 状态
T1 TXN 101 UPDATE balance=900 已提交 ✅
T2 TXN 102 UPDATE balance=800 未提交 🔒
T3 TXN 103 UPDATE balance=700 已提交 ✅
T4 TXN 104(我们) BEGIN; SELECT balance 开始一致性读

此时 InnoDB 构建 ReadView:

m_ids       = [102]        ← 快照时仍在运行的事务(只有 TXN 102 未提交)
min_trx_id  = 102
max_trx_id  = 105          ← 下一个待分配的事务 ID

行的 version chain(从新到旧):

balance=700 (trx=103) ← balance=800 (trx=102) ← balance=900 (trx=101) ← balance=1000 (trx=旧)

一致性读沿 version chain 逐版本判断:

  1. trx=103:在 [102,105) 内且不在 m_ids 中 → 已提交 → ✅ 可见
  2. 直接返回 balance=700,不再往后找

[!TIP] 关键洞察 虽然最新版本是 balance=700(trx=103),但如果你把 TXN 103 提交撤销、让 trx=102 先提交,那我们就会跳过 trx=102 的版本(它在 m_ids 中不可见),最终看到 trx=101 的 balance=900。可见性判断完全取决于「谁在快照瞬间已经提交」。

RR vs RC 的快照时机差异

sequenceDiagram
    participant T as TXN A (RR 模式)
    participant T2 as TXN B
    participant DB as InnoDB

    Note over T: BEGIN
    T->>DB: SELECT balance FROM account WHERE id=1
    Note over T,DB: 快照创建 (ReadView #1)

    T2->>DB: UPDATE balance=500 WHERE id=1
    T2->>DB: COMMIT

    T->>DB: SELECT balance FROM account WHERE id=1
    Note over T,DB: 复用 ReadView #1 → 看不到 500

    T->>DB: SELECT balance FROM account WHERE id=1
    Note over T,DB: 仍然复用 ReadView #1 → 还是看不到 500
    Note over T: 整个事务期间看到的值始终一致

如果 TXN A 用的是 RC 模式,每次 SELECT 都会创建新的 ReadView,第二次 SELECT 就能看到 balance=500。

深入理解当前读

-- 所有当前读语句
SELECT ... LOCK IN SHARE MODE;   -- 加 S 锁(共享读锁)
SELECT ... FOR UPDATE;            -- 加 X 锁(排他写锁)
UPDATE ...                         -- 隐式加 X 锁
DELETE ...                         -- 隐式加 X 锁
INSERT ...                         -- 隐式加 X 锁(对被插入的行)

FOR UPDATE 的实际行为

-- 会话 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 的行为

-- 会话 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 锁在

二级索引的回表效应

当前读在二级索引上有一个特殊行为:不仅锁二级索引记录,还要回表锁住聚簇索引记录。

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 列),会导致大量行被锁住。

实战决策树

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

实战场景与避坑指南

✅ 典型场景:扣减余额(防超卖)

-- 正确的做法:当前读 + 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。数据丢失了。这就是为什么写前校验必须用当前读。

✅ 典型场景:报表查询

-- 正确的做法:纯 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。

🚫 常见误区:忽略二级索引的回表锁

-- 假设 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。建议在事务开始时明确设置隔离级别,并在事务结束后恢复。

关联笔记