每日高级 Java 面试题 · Day 02

本文深入剖析InnoDB在RR隔离级别下幻读的成因与机制,重点阐述MVCC(ReadView+undo版本链)实现快照读、next-key lock保障当前读防幻影、以及唯一漏掉的'快照读→当前读→快照读'幻读场景。同时揭示长事务对undo purge、history list及快照读性能的工程影响,并关联RC/RR与binlog格式的复制一致性要求。

日期:2026-10-19 模块:MySQL 调优 · InnoDB 事务引擎(MVCC / 锁)
适用岗位:高级 Java 工程师(5 年+)
源码基线:MySQL 8.0 分支(storage/innobase),涉及 8.4 重构处单独标注


今日面试题

【题目】
InnoDB 在 RR(可重复读) 隔离级别下,到底能不能解决幻读?

请从三条线完整讲清楚:

  1. 1.

    ReadView 可见性算法 与 undo 版本链是如何配合实现"快照读"的;

  2. 2.

    next-key lock 是如何在"当前读"下阻止幻影插入的;

  3. 3.

    在什么场景下,RR 依然会出现幻读——为什么这是 MVCC 机制本身的边界,而不是 Bug。

追问(面试官必问):

  1. 1.

    RR 的 ReadView 是在 BEGIN 那一刻创建的吗?如果不是,是什么时候?

  2. 2.

    m_up_limit_id 和 m_low_limit_id 哪个是大值?为什么 InnoDB 的命名是"反"的?

  3. 3.

    唯一索引 WHERE id = 5 命中与不命中,加锁方式有什么本质区别?为什么?

  4. 4.

    一个跑了 10 分钟的长事务,除了"锁住行"之外还会造成什么后果?如何观测?

  5. 5.

    为什么互联网公司普遍把隔离级别降到 RC + binlog_format=ROW,这跟 statement 格式的 binlog 有什么关系?


考察点

  • •

    是否真正理解 "快照读"与"当前读"是两套完全独立的并发控制机制——这是本题的分水岭,80% 的候选人会混着答

  • •

    能否准确复述 ReadView 的四个字段与可见性判定流程(低水位 / 高水位 / 活跃列表 / creator),并且知道边界值取 < 还是 <=

  • •

    是否读过 row0vers.cc / lock0lock.cc,知道版本链回溯是惰性遍历而不是"把整条链拉出来"

  • •

    对 next-key lock 的退化规则(唯一等值命中退化为 record lock、向右遍历不满足条件退化为 gap lock)是否清楚

  • •

    能否区分 三种幻读场景,并指出 InnoDB RR 唯一漏掉的是"快照读→当前读→快照读"

  • •

    是否具备工程视角:长事务 → undo 无法 purge → history list length 膨胀 → 快照读变慢的因果链,以及 RC / RR 与复制格式的耦合


参考答案

一、核心结论

  1. 1.

    InnoDB 的 RR 是通过"两条腿"防幻读的:快照读靠 MVCC(ReadView + undo 版本链,不加任何锁),当前读靠 next-key lock(记录锁 + 间隙锁)。两者各管一半,互不覆盖。

  2. 2.

    RR 仍然会幻读,且只有一个场景:事务先做快照读(建立 ReadView)→ 别的事务插入并提交 → 本事务执行 UPDATE(当前读,能看到新行并把它改掉,trx_id 变成自己)→ 再快照读时,这行因为 trx_id == creator_trx_id 而变得"可见",幻影现身。根因是 MVCC 只约束"读",不约束"写"。

  3. 3.

    RC 与 RR 在源码层面几乎是同一份代码,唯一实质差异是 ReadView 的生命周期:RC 每条语句新建一个(语句级快照),RR 在事务内第一次快照读时建一个并一直复用(事务级快照)。所谓"隔离级别"在这里就是一个 ReadView 复用策略。


二、深入原理

2.1 前置:聚簇索引的三个隐藏列

InnoDB 的每一行(聚簇索引叶子节点)都带三个用户不可见的系统列:

隐藏列

长度

作用

DB_TRX_ID

6 字节

最后一次修改(INSERT/UPDATE)本行的事务 ID,即"这个版本是谁写的"

DB_ROLL_PTR

7 字节

回滚指针,指向 undo log 中的上一个版本,构成版本链

DB_ROW_ID

6 字节

仅当表没有主键、也没有唯一非空索引时生成,作为聚簇索引键

注意:DELETE 不真正删行,只是把记录的 delete_flag 置位(info bits),真正的物理删除由 purge 线程在确认没有 ReadView 需要该版本后完成。

行格式对比(面试可加分):DYNAMIC(8.0 默认)与 COMPACT 都有这三个列;REDUNDANT 是更老的格式。行头里 info bits 的 DELETE_FLAG 就是"逻辑删除"位。

2.2 undo 版本链:一条单向链表

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

聚簇索引叶子记录(当前版本)
┌──────────┬──────────┬──────────┬─────────┐
│  id=1    │  c='B'   │ TRX_ID=300│ ROLL_PTR│──┐
└──────────┴──────────┴──────────┴─────────┘  │
                                              ▼
                              undo log(update undo)
                        ┌──────────────────────────┐
                        │ TRX_ID=200   c='A'       │──┐
                        └──────────────────────────┘  │
                                                      ▼
                                             ┌──────────────────┐
                                             │ TRX_ID=100  c='0'│  ← 插入版本(insert undo)
                                             └──────────────────┘

两个关键点,很多候选人答不出来:

  • •

    insert undo 与 update undo 生命周期不同。insert undo 只服务于回滚,事务提交后即可丢弃;update undo 还要服务于 MVCC,必须等没有 ReadView 再需要它才能被 purge。这是"长事务导致 undo 膨胀"的根源。

  • •

    版本链是惰性遍历的。SELECT 不会把链全部展开,而是从当前版本开始,逐个回退,遇到第一个可见版本就立刻返回。所以热点行的长版本链会显著拖慢快照读(每次回退都要读 undo page,可能触发磁盘 IO)。

2.3 ReadView:四个字段 + 一次区间判定

ReadView 就是"快照"在代码里的实体,只有四个有效字段:

字段

含义

边界语义

m_creator_trx_id

创建该视图的事务 ID

自己改的,永远可见

m_up_limit_id

低水位 = 创建瞬间活跃事务中的最小 ID

trx_id < m_up_limit_id → 一定可见

m_low_limit_id

高水位 = 创建瞬间系统下一个待分配的 ID(max+1)

trx_id >= m_low_limit_id → 一定不可见

m_ids

创建瞬间所有活跃(未提交)读写事务 ID 列表,升序

落在中间区间时,二分查找:在列表里 → 不可见;不在 → 可见

m_low_limit_no

给 purge 用的下界(非可见性判定)

小于它的 undo 可回收

命名坑(高频追问):m_up_limit_id 是低水位,m_low_limit_id 是高水位——名字和大小关系是反的。记住 Doxygen 注释原话即可:m_low_limit_id 是 "high water mark",m_up_limit_id 是 "low water mark"。面试时主动指出这个坑,等于告诉面试官"我读过源码"。

判定流程图(trx_id 为待判定的行版本):

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

                    ┌──────────────────────────────┐
   row.trx_id ─────▶│ trx_id == creator_trx_id ?   │──是──▶ 可见(自己改的)
                    └──────────────┬───────────────┘
                                   │否
                    ┌──────────────▼───────────────┐
                    │ trx_id < m_up_limit_id ?     │──是──▶ 可见(视图创建前已提交)
                    └──────────────┬───────────────┘
                                   │否
                    ┌──────────────▼───────────────┐
                    │ trx_id >= m_low_limit_id ?   │──是──▶ 不可见(视图创建后才分配)
                    └──────────────┬───────────────┘
                                   │否(落在中间灰色区间)
                    ┌──────────────▼───────────────┐
                    │ binary_search(m_ids, trx_id) │
                    └──────┬───────────────┬───────┘
                       找到 │               │ 没找到
                          ▼               ▼
                    不可见(当时还活着)  可见(当时已提交但 ID 较大)

为什么"落在中间又不在 m_ids 里"就可见? 因为 m_ids 只记录当时仍活跃的事务。一个 ID 小于高水位、却没进 m_ids,说明它在 ReadView 创建之前就已经提交了——只是它的 ID 比当时最老的活跃事务大,所以没被低水位一刀切掉。

2.4 RC 与 RR:差异只有 ReadView 的生命周期

维度

READ COMMITTED

REPEATABLE READ

ReadView 创建时机

每条 SELECT 语句开始时

事务内第一次快照读时

ReadView 销毁

语句结束即关闭

事务提交/回滚时关闭

同一事务两次查询

可能看到不同结果(不可重复读)

结果固定(可重复读)

加锁读(FOR UPDATE)

无间隙锁,仅记录锁

next-key lock(含间隙锁)

semi-consistent read

生效(UPDATE 时对不匹配行提前放锁)

不生效

典型用途

互联网 OLTP 默认

金融/账务、需要语句级可重复

几个必须纠正的常见错误认知:

  • •

    ❌ "RR 在 BEGIN 时就建 ReadView" —— 错。是第一次快照读时。所以 BEGIN 之后空等 10 秒再查,看到的是 10 秒后的数据。

  • •

    ✅ 例外:START TRANSACTION WITH CONSISTENT SNAPSHOT 会在 BEGIN 时立即建立 ReadView。

  • •

    ❌ "RR 靠锁实现可重复读" —— 错。RR 的可重复读完全由 MVCC 提供,普通 SELECT 一行锁都不加。

  • •

    ⚠️ 只读事务可能根本不分配 trx_id(trx->id == 0,m_creator_trx_id 也就是 0)。所以别用 trx_id 大小去推断事务先后顺序。

2.5 快照读 vs 当前读:两套机制的分界线

快照读(Consistent Read)

当前读(Locking Read)

语句

普通 SELECT

SELECT ... FOR UPDATE / FOR SHARE、UPDATE、DELETE、INSERT

读什么

ReadView 可见的历史版本

最新已提交版本(读之前先加锁)

加锁

不加锁

next-key lock / record lock / gap lock

是否受 ReadView 约束

是

否

防幻读手段

MVCC

next-key lock

这张表就是本题的题眼。 大量"RR 能防幻读"的说法之所以在实战中翻车,就是因为忽略了 UPDATE 属于当前读,它不受 ReadView 约束。

2.6 next-key lock:当前读下的防幻影机制

定义:next-key lock = record lock + gap lock,锁住的是前开后闭区间 (prev_record, current_record]。

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

索引记录:      -∞      10        20        30       +∞
间隙:        ( -∞,10) (10,20)  (20,30)  (30,+∞ )
next-key:             (10,20]  (20,30]  (30,+∞ ]

给 id=20 加 next-key lock,锁的是 (10, 20]——既锁住 20 这行,也锁住 10 和 20 之间的空隙。别的会话想往这个空隙插 15,会被"插入意向锁"卡住,这就是防幻影的核心。

五条加锁规则(面试能背下来直接加分,源自何登发《MySQL 加锁处理分析》):

  1. 1.

    加锁的基本单位是 next-key lock,扫描到哪个索引记录就锁哪个区间。

  2. 2.

    只有被扫描到的对象才会加锁(所以走不走索引,决定了锁的范围——没索引就是全表所有间隙,等价于锁表)。

  3. 3.

    唯一索引等值查询且记录存在 → next-key lock 退化为 record lock。

  4. 4.

    唯一索引等值查询且记录不存在 → next-key lock 退化为 gap lock。

  5. 5.

    唯一索引范围查询,会一直扫描到第一个不满足条件的值为止(那个值的间隙也会被锁)。

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- 表结构:CREATE TABLE t (id INT PRIMARY KEY, k INT, c INT, KEY idx_k(k));

-- ① 唯一索引等值命中 → 仅 record lock,锁 id=5 这一行,不锁间隙 → 可以插 6
SELECT * FROM t WHERE id = 5 FOR UPDATE;

-- ② 唯一索引等值未命中 → 退化为 gap lock,锁 (5,10) 这个间隙 → 插 6 会被阻塞
SELECT * FROM t WHERE id = 7 FOR UPDATE;

-- ③ 普通索引等值 → next-key lock 锁 idx_k + 回表时给聚簇索引记录加 record lock
SELECT * FROM t WHERE k = 5 FOR UPDATE;

-- ④ 无索引列 → 全表扫描,所有间隙 + 所有记录都加锁(≈ 锁表),必须避免
SELECT * FROM t WHERE c = 5 FOR UPDATE;

间隙锁的三个反直觉特性(高频追问):

  • •

    间隙锁之间不冲突。两个事务可以同时持有同一个间隙的 gap lock,因为它们的目的都只是"阻止插入",彼此没有数据竞争。

  • •

    插入意向锁(Insert Intention Lock)才会和 gap lock 冲突。INSERT 前先在目标间隙上加插入意向锁,它与任何 gap lock 互斥——这是幻影真正被挡住的地方。

  • •

    同一间隙上的多个插入意向锁彼此兼容(不同位置的插入不冲突),所以并发插入不会互相阻塞。

2.7 幻读的三种场景推演(本答案最值钱的部分)

场景 A:快照读 vs 快照读 —— 完全免疫

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- T1                                  -- T2
BEGIN;
SELECT * FROM t WHERE id BETWEEN 1 AND 10;   -- 0 行,建 ReadView
                                       BEGIN;
                                       INSERT INTO t VALUES (5, 5, 0);
                                       COMMIT;
SELECT * FROM t WHERE id BETWEEN 1 AND 10;   -- 仍然 0 行 ✅

T2 的 trx_id >= m_low_limit_id(在 ReadView 之后才分配)→ 不可见。MVCC 挡住了。

场景 B:当前读 vs 当前读 —— next-key lock 挡住

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- T1                                        -- T2
BEGIN;
SELECT * FROM t WHERE id BETWEEN 1 AND 10 FOR UPDATE;  -- 锁住 (-∞,10]
                                             BEGIN;
                                             INSERT INTO t VALUES (5,5,0);  -- 阻塞 ⏳
                                             -- 等 T1 提交后才继续

id=5 落在 (-∞, 10] 这个 next-key lock 里,插入意向锁必须等。锁挡住了。

场景 C:快照读 → 当前读 → 快照读 —— RR 也会幻读 ❌

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- T1                                          -- T2
BEGIN;
SELECT * FROM t WHERE id BETWEEN 1 AND 10;     -- 0 行,建 ReadView(此时没有 id=5)
                                               BEGIN;
                                               INSERT INTO t VALUES (5,5,0);
                                               COMMIT;
UPDATE t SET c = c + 1 WHERE id BETWEEN 1 AND 10;
-- ↑ 当前读!不受 ReadView 约束,能看到 id=5 这行,把它 c 改成 1,
--   并且把这行的 DB_TRX_ID 改写成了 T1 自己的 trx_id  →  Affected rows: 1

SELECT * FROM t WHERE id BETWEEN 1 AND 10;
-- ↑ 快照读,但 id=5 的 trx_id == m_creator_trx_id  →  可见!
--   结果:凭空多出一行 c=1 的记录  ❌ 幻读

这就是 InnoDB RR 唯一漏掉的幻读场景,本质原因是:

MVCC 保证了"读到的历史是一致的",但 UPDATE 是当前读——它按最新数据加锁并修改。一旦本事务修改了 ReadView 之外的行,该行的 DB_TRX_ID 就变成了 creator_trx_id,此后任何快照读都会判定它"对自己可见"。读的一致性被自己的写破坏了。

规避方式(工程上真正有用的三条):

  1. 1.

    业务层加锁串行化:SELECT ... FOR UPDATE 先锁住范围再操作(把场景 C 变成场景 B)。

  2. 2.

    乐观锁 / 版本号:UPDATE ... WHERE version = ?,用影响行数判断是否被并发修改。

  3. 3.

    别在同一事务里混用快照读和当前读做"先查后改",尤其是"查列表 → 批量更新"这种模式。

2.8 一个容易漏掉的细节:二级索引上的 MVCC

快照读走二级索引时,叶子节点只有 (索引列, 主键),没有 DB_TRX_ID。InnoDB 的处理方式是:

  • •

    二级索引页头有一个 PAGE_MAX_TRX_ID,表示"修改过本页的最大事务 ID"。

  • •

    如果 PAGE_MAX_TRX_ID < m_up_limit_id,说明整页都是老数据,所有记录对本 ReadView 都可见,可以直接用(覆盖索引场景下免回表)。

  • •

    否则必须回表到聚簇索引,用真实的 DB_TRX_ID 做可见性判定;如果聚簇索引的当前版本不可见,还要沿 undo 链回溯。

这解释了一个经典现象:同样的 SQL,加不加覆盖索引,快照读的性能可能差一个数量级——覆盖索引省掉的不只是回表 IO,还有"逐行可见性判定 + 版本链回溯"。

2.9 工程后果:长事务到底可怕在哪

后果

机制

undo 无法 purge

purge 线程只能清理"所有 ReadView 都不再需要"的 undo(由最老 ReadView 的 m_low_limit_no 决定)。长事务把水位钉死,undo 表空间持续膨胀

版本链变长

后续所有快照读要沿更长的链回溯,热点行读取变慢,甚至触发 undo page 的物理读

history list length 飙升

SHOW ENGINE INNODB STATUS 的 History list length 持续增长,是长事务最直接的信号

MDL 阻塞 DDL

事务持有的元数据锁会让 ALTER TABLE 排队,进而把后续所有 SQL 全部堵死(经典事故)

主从延迟

大事务在 binlog 里是一个巨大 event,从库回放期间无法并行

锁持有时间长

锁等待超时(innodb_lock_wait_timeout 默认 50s)、死锁概率上升

观测手段(面试时说出具体命令,可信度立刻不同):

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- 1. 当前所有事务(按开始时间排序,看谁跑得久)
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs,
       trx_rows_locked, trx_rows_modified, trx_isolation_level, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 2. 当前持有的锁与等待关系(8.0 用 performance_schema,替代已废弃的 innodb_locks)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- 3. undo 堆积与死锁
SHOW ENGINE INNODB STATUS\G
--   看 TRANSACTIONS 段的 "History list length" 与 "LATEST DETECTED DEADLOCK"

三、关键源码分析

以下基于 MySQL 8.0 分支 storage/innobase。为聚焦主线,省略了 error handling、mtr、comp(行格式)等分支,保留判断逻辑原貌。

3.1 ReadView 的结构定义(include/read0types.h)

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

class ReadView {
  // ...
 private:
  // 高水位(high water mark):trx_id >= 它的事务,其修改对本视图一律不可见。
  // 取值 = 创建视图瞬间 trx_sys 的"下一个待分配 trx_id",即当前 max_trx_id。
  // 也就是说:这个 ID 的事务在创建时刻一定还没发生。
  trx_id_t m_low_limit_id;

  // 低水位(low water mark):trx_id < 它的事务,其修改对本视图一律可见。
  // 取值 = 创建瞬间活跃事务列表中的最小值;列表为空则等于 m_low_limit_id。
  // 注意命名反直觉:名字带 low,实际是"下界";m_low_limit_id 才是高水位。
  trx_id_t m_up_limit_id;

  // 创建该视图的事务自己的 ID;自己的修改永远可见(这是 resolve 场景 C 的根因)。
  // 8.0 中只读事务可能尚未分配 trx_id,此时为 0。
  trx_id_t m_creator_trx_id;

  // 创建瞬间所有"活跃读写事务"的 ID 集合,按升序排列(ids_t 保证 front() 是最小值)。
  // 只读事务不进这个列表 —— 所以"不在列表里"才能等价于"已提交"。
  ids_t m_ids;

  // 与可见性无关,专供 purge 线程使用:比它更老的 undo 日志可以安全回收。
  // 全局最老 ReadView 的这个值,就是 undo 回收的水位线。
  trx_id_t m_low_limit_no;

  // 注:ids_t 是类似 std::vector 的自定义有序容器,保证 front() 是最小值,
  //     并暴露 data() 返回连续内存首地址,便于 std::binary_search。
};
3.2 可见性判定(ReadView::changes_visible,read0types.h)

这是整道题的核心函数,每一行都要能讲:

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

bool changes_visible(trx_id_t id, const table_name_t &name) const {
  ut_ad(id > 0);   // 断言:待判定的行版本 trx_id 必须有效(只读事务的 0 不会到这里)

  // ① 快速路径 1:低水位以下 —— 视图创建前就已提交的事务,直接可见。
  //    这是绝大多数行的命中路径,开销仅一次整数比较。
  // ② 快速路径 2:trx_id 等于自己 —— 自己改的当然看得见。
  //    ★ 场景 C 的幻读就是从这里"漏"进来的:UPDATE 把新行的 trx_id 改成了自己。
  if (id < m_up_limit_id || id == m_creator_trx_id) {
    return true;
  }

  // 防御性检查:trx_id 超出合理范围说明数据页损坏
  check_trx_id_sanity(id, name);

  // ③ 高水位及以上 —— 视图创建之后才分配的事务,一定不可见。
  //    场景 A 里 T2 的插入就是走这条路被判不可见的。
  if (id >= m_low_limit_id) {
    return false;
  } else if (m_ids.empty()) {
    // ④ 创建视图时系统没有任何活跃事务 → 中间区间全部已提交,可见。
    return true;
  }

  // ⑤ 灰色区间 [m_up_limit_id, m_low_limit_id):必须查活跃列表。
  //    用二分查找(O(log n))而不是哈希:m_ids 本身是有序数组,
  //    n 通常是几十到几百,二分比哈希表更 cache-friendly 且省内存。
  const ids_t::value_type *p = m_ids.data();
  return (!std::binary_search(p, p + m_ids.size(), id));
  //      ↑ 在列表里 → 说明创建视图时它还没提交 → 不可见(返回 false)
  //        不在列表里 → 它在创建前已提交、只是 ID 较大没被低水位覆盖 → 可见
}

版本演进:MySQL 8.4 起 ReadView 被抽象为 Read_view_interface,签名简化为 changes_visible(trx_id_t id),table_name_t 参数被移走(check_trx_id_sanity 内部处理)。判断逻辑完全没变。能说出这个细节,说明不是背八股。

3.3 ReadView 的创建时机(trx0trx.cc / read0read.cc)

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// 简化主干:不同隔离级别下 ReadView 的获取策略
void trx_assign_read_view(trx_t *trx) {
  switch (trx->isolation_level) {
    case TRX_ISO_READ_UNCOMMITTED:
      // RU:压根不建视图,所有行都读最新版本(脏读就是这么来的)
      break;

    case TRX_ISO_READ_COMMITTED:
      // RC:每条语句开始都重新开一个视图 → 语句级快照
      //     (语句结束时会 close,所以同一事务的两条 SELECT 看到不同结果)
      MVCC::view_open(trx->read_view);
      break;

    default:  // RR / SERIALIZABLE
      // RR:只有第一次快照读才创建;之后 read_view != nullptr,直接复用
      //     ★ 所以 BEGIN 之后不查、等 10 秒再查,看到的是 10 秒后的数据
      if (trx->read_view == nullptr) {
        trx->read_view = MVCC::view_open(mysql_trx->read_view);
      }
      break;
  }
}

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// MVCC::view_open 内部(read0read.cc):真正填字段的地方,调用时持有 trx_sys->mutex
void ReadView::prepare(trx_id_t id) {
  m_creator_trx_id = id;                              // 自己
  m_low_limit_no   = trx_get_serialisation_min_trx_no();
  m_low_limit_id   = trx_sys_get_next_trx_id_or_no(); // 高水位 = 下一个待分配的 ID
  // 若此时没有活跃读写事务,则列表为空,低水位 = 高水位(中间区间不存在)
  if (!trx_sys->rw_trx_ids.empty()) {
    copy_trx_ids(trx_sys->rw_trx_ids);                // 拷贝活跃列表(升序)
  } else {
    m_ids.clear();
  }
  m_up_limit_id = m_ids.empty() ? m_low_limit_id : m_ids.front();  // 低水位 = 最小值
}

注意 prepare 全程持有全局 trx_sys->mutex。高并发下频繁创建 ReadView(即 RC 下的每条语句)本身就是一次全局锁竞争——这是 RC 在高并发下并非"零成本"的一个细节。

3.4 快照读的版本回溯(row0vers.cc / lock0lock.cc)

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// 步骤 1:先看当前版本是否可见(O(1),绝大多数情况到此为止)
bool lock_clust_rec_cons_read_sees(const rec_t *rec, dict_index_t *index,
                                   const ulint *offsets, const ReadView *view) {
  // 从行里取出隐藏列 DB_TRX_ID
  trx_id_t trx_id = row_get_rec_trx_id(rec, index, offsets);
  // 直接丢给 ReadView 判定 —— 就是 3.2 那个函数
  return view->changes_visible(trx_id, index->table->name);
}

// 步骤 2:不可见则沿 undo 链回溯,直到找到第一个可见版本
// (row_vers_build_for_consistent_read 的主干,已简化)
bool row_vers_build_for_consistent_read(const rec_t *rec, mtr_t *mtr,
                                        dict_index_t *index, ulint **offsets,
                                        ReadView *view, mem_heap_t **heap,
                                        rec_t **old_vers /* out */) {
  const rec_t *version = rec;        // 从当前(最新)版本出发

  for (;;) {
    // 若当前版本带 DELETE_FLAG,说明这行在某个版本被删了。
    // 需要额外判断"删除它的事务对本视图是否可见",否则会漏掉
    // "删了又插"这类场景(二级索引上尤其容易出错,见 row_vers_impl_x_locked)。
    if (rec_get_deleted_flag(version, comp)) {
      // ... 判断删除者可见性,不可见则继续回退
    }

    // 取 ROLL_PTR 指向的 undo 记录,重建出上一个版本
    // ★ 这一步会真的去读 undo 表空间(可能触发磁盘 IO),长版本链的代价就在这里
    if (trx_undo_prev_version_build(version, mtr, index, offsets, *heap, &prev_version) != DB_SUCCESS) {
      break;   // undo 已被 purge 或损坏 → 无更老版本
    }

    version = prev_version;
    *offsets = rec_get_offsets(version, index, *offsets, ULINT_UNDEFINED, heap);

    // 命中可见版本就返回,不会把整条链走完(惰性遍历)
    if (view->changes_visible(row_get_rec_trx_id(version, index, *offsets),
                              index->table->name)) {
      *old_vers = const_cast<rec_t *>(version);
      return true;
    }
  }
  return false;
}

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// 步骤 3:二级索引的"整页可见"优化(lock0lock.cc)
bool lock_sec_rec_cons_read_sees(const rec_t *rec, const dict_index_t *index,
                                 const ReadView *view) {
  // 二级索引行里没有 DB_TRX_ID,只能拿页头的 PAGE_MAX_TRX_ID(改过本页的最大事务号)
  trx_id_t max_trx_id = page_get_max_trx_id(page_align(rec));
  // sees() 是比 changes_visible() 更"宽松"的判定:
  // 只要"改过本页的最大事务"都不在活跃列表里,就认为整页对本视图可见。
  // 返回 true → 可直接用二级索引记录(覆盖索引场景免回表);
  // 返回 false → 必须回表到聚簇索引,用真实 DB_TRX_ID 逐行判定。
  return view->sees(max_trx_id);
}
3.5 加锁路径:next-key lock 是怎么加上的(lock0lock.cc)

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// 加锁模式位(type_mode 的高位)—— 面试常问的三个常量
//   LOCK_ORDINARY    = next-key lock(记录锁 + 前面的间隙)  ← RR 的默认形态
//   LOCK_GAP         = 纯间隙锁(只锁记录之前的空隙)
//   LOCK_REC_NOT_GAP = 纯记录锁(不锁间隙)  ← 唯一索引等值命中时退化成的形态
//   LOCK_INSERT_INTENTION = 插入意向锁(INSERT 时加在目标间隙上)

// 核心冲突判定:是否要等(省略了表锁/ supremum 等分支)
static bool lock_rec_has_to_wait(const trx_t *trx, ulint type_mode,
                                 const lock_t *lock2, const RecLock *rec_lock) {
  // ① 间隙锁之间不冲突:双方都是 gap(且都不带插入意向)→ 直接放行
  //    这就是"两个事务能同时持有同一间隙的 gap lock"的源码出处
  if ((type_mode & LOCK_GAP) && lock_rec_get_gap(lock2)) {
    return false;
  }

  // ② 我加 gap lock,对方是任何记录锁 → 也不冲突(gap 只是"禁止插入")
  //    只有 INSERT 才会被 gap 挡住
  if ((type_mode & LOCK_GAP) && !(type_mode & LOCK_INSERT_INTENTION)) {
    return false;
  }

  // ③ 我要插入(插入意向锁),对方持有 gap lock → 必须等 ★ 幻影在这里被挡住
  if ((type_mode & LOCK_INSERT_INTENTION) && lock_rec_get_gap(lock2)) {
    return true;
  }

  // ④ 其余情况走标准锁相容矩阵(S/X 互斥、IX 与 X 互斥 ...)
  return !lock_mode_compatible(lock_mode, lock_rec_get_mode(lock2));
}

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

// 加锁入口:一条 SQL 扫到一条索引记录,就调一次这里
dberr_t lock_rec_lock(bool impl, ulint mode, const buf_block_t *block,
                      ulint heap_no, dict_index_t *index, trx_t *trx,
                      ulint type_mode) {
  // type_mode 里已经由上层拼好了 LOCK_ORDINARY / LOCK_GAP / LOCK_REC_NOT_GAP
  // 具体拼哪种,取决于:是否唯一索引、是否等值、是否命中、是否范围扫描
  if (lock_rec_other_has_conflicting(mode | type_mode, block, heap_no, trx, rec_lock)) {
    // 有冲突 → 进等待队列(或直接返回 DB_LOCK_WAIT,由上层决定是否休眠)
    return DB_LOCK_WAIT;
  }
  // 无冲突 → 直接挂到该记录的锁哈希表上(lock_sys->rec_hash)
  lock_rec_add_to_queue(type_mode, block, heap_no, index, trx, true);
  return DB_SUCCESS;
}

一条 SQL 可能加很多把锁:UPDATE t SET c=1 WHERE k=5(k 有普通索引)会先锁 idx_k 上的 next-key lock,回表后再给聚簇索引记录加 record lock。所以 trx_rows_locked 常常远大于 trx_rows_modified。


四、实战:三分钟复现与排查

复现 RR 幻读(可直接在测试库跑):

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

CREATE TABLE t (id INT PRIMARY KEY, k INT, c INT, KEY idx_k(k)) ENGINE=InnoDB;
INSERT INTO t VALUES (1,1,0),(10,10,0);
SET SESSION transaction_isolation = 'REPEATABLE-READ';

-- 会话 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 1 AND 10;      -- 2 行,建立 ReadView

-- 会话 B
BEGIN; INSERT INTO t VALUES (5,5,0); COMMIT;

-- 会话 A(关键:这条 UPDATE 是当前读)
UPDATE t SET c = c + 1 WHERE id BETWEEN 1 AND 10;
-- Query OK, 3 rows affected   ← 明明"看不见"的 id=5 被改到了!

SELECT * FROM t WHERE id BETWEEN 1 AND 10;      -- 3 行 ❌ 幻读
COMMIT;

排查长事务的固定动作:

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

-- 找出超过 60 秒的事务
SELECT trx_id, trx_mysql_thread_id, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

-- 看 undo 堆积(History list length 持续增长 = 有老视图钉住了 purge)
SHOW ENGINE INNODB STATUS\G

-- 看某个连接持有哪些锁
SELECT * FROM performance_schema.data_locks WHERE THREAD_ID = ?;

治理手段:innodb_lock_wait_timeout 调小(如 5s)让问题快速暴露;max_execution_time 兜底慢查询;应用层事务边界收窄(RPC 调用绝对不能放在事务里);批处理分片提交。


五、一图总结

--javascripttypescriptshellbashsqljsonhtmlcssccppjavarubypythongorustmarkdown

                        一条 SQL 进来
                              │
              ┌───────────────┴────────────────┐
              │                                │
        【快照读】                         【当前读】
   普通 SELECT                    FOR UPDATE / UPDATE / DELETE
              │                                │
              ▼                                ▼
   ┌──────────────────────┐        ┌──────────────────────────┐
   │  取 ReadView         │        │  先加锁,再读最新版本     │
   │  RR: 首次快照读建一次 │        │  RR: next-key lock       │
   │  RC: 每条语句建一次   │        │  RC: 仅 record lock      │
   └──────────┬───────────┘        └────────────┬─────────────┘
              │                                 │
              ▼                                 ▼
   ┌──────────────────────┐        ┌──────────────────────────┐
   │ changes_visible()    │        │ 唯一等值命中 → record     │
   │  ├ 自己改的   → 可见  │        │ 唯一等值未中  → gap      │
   │  ├ < 低水位   → 可见  │        │ 普通索引     → next-key  │
   │  ├ >= 高水位  → 不可见│        │ 无索引       → 全表间隙   │
   │  └ 灰区→二分查 m_ids  │        └────────────┬─────────────┘
   └──────────┬───────────┘                     │
              │ 不可见                          ▼
              ▼                    挡住 INSERT(插入意向锁 vs gap)
   ┌──────────────────────┐                     │
   │ 沿 undo 链回溯        │                     │
   │ 直到找到可见版本      │                     │
   │ (找不到 → 行不存在) │                     │
   └──────────┬───────────┘                     │
              │                                 │
              └────────────┬────────────────────┘
                           ▼
              防幻读:A 场景 ✅  B 场景 ✅
                       C 场景 ❌(先快照读→当前读→再快照读)

六、面试话术模板(60 秒版)

"InnoDB 的 RR 防幻读是两套机制分工的结果:快照读走 MVCC,靠 ReadView 的四个字段——低水位、高水位、活跃列表、creator——判断行版本的 DB_TRX_ID 是否可见,不可见就沿 undo 链回溯,全程不加锁;当前读走 next-key lock,锁住 (prev, cur] 这个前开后闭区间,别的会话插入时撞上插入意向锁和 gap lock 的冲突就会阻塞。

但 RR 不是完全免疫幻读。有一个场景会漏:事务先快照读建了 ReadView,别的事务插入并提交,然后本事务执行 UPDATE——UPDATE 是当前读,不受 ReadView 约束,它能看到并修改那行新数据,同时把这行的 DB_TRX_ID 改成了自己。等下一次快照读时,changes_visible 第一行 id == m_creator_trx_id 直接返回 true,幻影就出现了。根因是 MVCC 只约束读、不约束写。

所以我在业务上会避免'先查列表再批量更新'这种混用模式,要么用 FOR UPDATE 先把范围锁住,要么用乐观锁版本号;同时把事务边界压到最短,因为长事务会钉住 purge 水位,导致 undo 膨胀、版本链变长、快照读越来越慢。"


七、延伸追问预演(自测)

  1. 1.

    SELECT ... FOR UPDATE 在唯一索引等值未命中时,为什么只加 gap lock 而不加 record lock?(记录根本不存在,没有行可锁;只能锁住"它本该在的那个空隙")

  2. 2.

    RC 下为什么几乎没有间隙锁? 因为 RC 允许幻读,不需要防插入;同时 innodb_locks_unsafe_for_binlog 在 8.0 已被移除,其功能由 RC 承担——RC 还会走 semi-consistent read:UPDATE 遇到已锁定的不匹配行时,先读最新提交版本判断是否需要真的加锁,不需要就提前放锁,显著降低锁冲突。

  3. 3.

    为什么 binlog_format=STATEMENT 时必须用 RR? statement 格式记录的是 SQL 原文,从库重放时若允许幻读,UPDATE ... WHERE range 在主从两侧影响的行数可能不同 → 主从不一致。next-key lock 保证"主库看到的行集合"是可串行化的,从库重放才能一致。改成 ROW 格式后记录的是行变更镜像,不再依赖这个约束,所以可以安全降到 RC。

  4. 4.

    START TRANSACTION WITH CONSISTENT SNAPSHOT 与普通 BEGIN 有什么区别? 前者在开启事务的同时立即建立 ReadView;后者延迟到第一次快照读。做逻辑备份(mysqldump --single-transaction)必须用前者。

  5. 5.

    为什么 m_ids 用有序数组 + 二分,而不是 HashSet? 活跃事务数量通常很小(几十),二分在连续内存上 cache 友好、无哈希开销、无扩容;且需要 front() 快速取最小值做低水位。

  6. 6.

    SERIALIZABLE 是怎么实现的? 它把普通 SELECT 隐式转换成 SELECT ... FOR SHARE(加 next-key lock 的共享锁),即"全部走当前读"。代价是并发度骤降,几乎不用。

  7. 7.

    乐观锁 / version 字段能完全替代 RR 吗? 不能。乐观锁解决的是"写写冲突",幻读是"读范围与并发插入"的问题,需要范围锁或串行化语义。

  8. 8.

    MySQL 8.0 的 SKIP LOCKED / NOWAIT 用在哪? 典型的"任务队列"场景:SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 让多个 worker 并发取任务而不互相阻塞——这是用 InnoDB 做轻量队列的标准写法。


明日预告:Day 03 计划覆盖「Spring 循环依赖与三级缓存」——重点讲 DefaultSingletonBeanRegistry 的三级缓存为什么必须是三级、AOP 代理与循环依赖的冲突、以及 @Async/构造器注入破坏循环依赖的原理。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值