日期:2026-10-19 模块:MySQL 调优 · InnoDB 事务引擎(MVCC / 锁)
适用岗位:高级 Java 工程师(5 年+)
源码基线:MySQL 8.0 分支(storage/innobase),涉及 8.4 重构处单独标注
今日面试题
【题目】
InnoDB 在 RR(可重复读) 隔离级别下,到底能不能解决幻读?
请从三条线完整讲清楚:
-
1.
ReadView 可见性算法 与 undo 版本链是如何配合实现"快照读"的;
-
2.
next-key lock 是如何在"当前读"下阻止幻影插入的;
-
3.
在什么场景下,RR 依然会出现幻读——为什么这是 MVCC 机制本身的边界,而不是 Bug。
追问(面试官必问):
-
1.
RR 的 ReadView 是在
BEGIN那一刻创建的吗?如果不是,是什么时候? -
2.
m_up_limit_id和m_low_limit_id哪个是大值?为什么 InnoDB 的命名是"反"的? -
3.
唯一索引
WHERE id = 5命中与不命中,加锁方式有什么本质区别?为什么? -
4.
一个跑了 10 分钟的长事务,除了"锁住行"之外还会造成什么后果?如何观测?
-
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.
InnoDB 的 RR 是通过"两条腿"防幻读的:快照读靠 MVCC(ReadView + undo 版本链,不加任何锁),当前读靠 next-key lock(记录锁 + 间隙锁)。两者各管一半,互不覆盖。
-
2.
RR 仍然会幻读,且只有一个场景:事务先做快照读(建立 ReadView)→ 别的事务插入并提交 → 本事务执行
UPDATE(当前读,能看到新行并把它改掉,trx_id变成自己)→ 再快照读时,这行因为trx_id == creator_trx_id而变得"可见",幻影现身。根因是 MVCC 只约束"读",不约束"写"。 -
3.
RC 与 RR 在源码层面几乎是同一份代码,唯一实质差异是 ReadView 的生命周期:RC 每条语句新建一个(语句级快照),RR 在事务内第一次快照读时建一个并一直复用(事务级快照)。所谓"隔离级别"在这里就是一个 ReadView 复用策略。
二、深入原理
2.1 前置:聚簇索引的三个隐藏列
InnoDB 的每一行(聚簇索引叶子节点)都带三个用户不可见的系统列:
|
隐藏列 |
长度 |
作用 |
|---|---|---|
|
|
6 字节 |
最后一次修改(INSERT/UPDATE)本行的事务 ID,即"这个版本是谁写的" |
|
|
7 字节 |
回滚指针,指向 undo log 中的上一个版本,构成版本链 |
|
|
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 就是"快照"在代码里的实体,只有四个有效字段:
|
字段 |
含义 |
边界语义 |
|---|---|---|
|
|
创建该视图的事务 ID |
自己改的,永远可见 |
|
|
低水位 = 创建瞬间活跃事务中的最小 ID |
|
|
|
高水位 = 创建瞬间系统下一个待分配的 ID(max+1) |
|
|
|
创建瞬间所有活跃(未提交)读写事务 ID 列表,升序 |
落在中间区间时,二分查找:在列表里 → 不可见;不在 → 可见 |
|
|
给 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 销毁 |
语句结束即关闭 |
事务提交/回滚时关闭 |
|
同一事务两次查询 |
可能看到不同结果(不可重复读) |
结果固定(可重复读) |
|
加锁读( |
无间隙锁,仅记录锁 |
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) | |
|---|---|---|
|
语句 |
普通 |
|
|
读什么 |
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.
加锁的基本单位是 next-key lock,扫描到哪个索引记录就锁哪个区间。
-
2.
只有被扫描到的对象才会加锁(所以走不走索引,决定了锁的范围——没索引就是全表所有间隙,等价于锁表)。
-
3.
唯一索引等值查询且记录存在 → next-key lock 退化为 record lock。
-
4.
唯一索引等值查询且记录不存在 → next-key lock 退化为 gap lock。
-
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.
业务层加锁串行化:
SELECT ... FOR UPDATE先锁住范围再操作(把场景 C 变成场景 B)。 -
2.
乐观锁 / 版本号:
UPDATE ... WHERE version = ?,用影响行数判断是否被并发修改。 -
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 的 |
|
版本链变长 |
后续所有快照读要沿更长的链回溯,热点行读取变慢,甚至触发 undo page 的物理读 |
|
history list length 飙升 |
|
|
MDL 阻塞 DDL |
事务持有的元数据锁会让 |
|
主从延迟 |
大事务在 binlog 里是一个巨大 event,从库回放期间无法并行 |
|
锁持有时间长 |
锁等待超时( |
观测手段(面试时说出具体命令,可信度立刻不同):
--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.
SELECT ... FOR UPDATE在唯一索引等值未命中时,为什么只加 gap lock 而不加 record lock?(记录根本不存在,没有行可锁;只能锁住"它本该在的那个空隙") -
2.
RC 下为什么几乎没有间隙锁? 因为 RC 允许幻读,不需要防插入;同时
innodb_locks_unsafe_for_binlog在 8.0 已被移除,其功能由 RC 承担——RC 还会走 semi-consistent read:UPDATE遇到已锁定的不匹配行时,先读最新提交版本判断是否需要真的加锁,不需要就提前放锁,显著降低锁冲突。 -
3.
为什么
binlog_format=STATEMENT时必须用 RR? statement 格式记录的是 SQL 原文,从库重放时若允许幻读,UPDATE ... WHERE range在主从两侧影响的行数可能不同 → 主从不一致。next-key lock 保证"主库看到的行集合"是可串行化的,从库重放才能一致。改成 ROW 格式后记录的是行变更镜像,不再依赖这个约束,所以可以安全降到 RC。 -
4.
START TRANSACTION WITH CONSISTENT SNAPSHOT与普通BEGIN有什么区别? 前者在开启事务的同时立即建立 ReadView;后者延迟到第一次快照读。做逻辑备份(mysqldump--single-transaction)必须用前者。 -
5.
为什么
m_ids用有序数组 + 二分,而不是HashSet? 活跃事务数量通常很小(几十),二分在连续内存上 cache 友好、无哈希开销、无扩容;且需要front()快速取最小值做低水位。 -
6.
SERIALIZABLE是怎么实现的? 它把普通SELECT隐式转换成SELECT ... FOR SHARE(加 next-key lock 的共享锁),即"全部走当前读"。代价是并发度骤降,几乎不用。 -
7.
乐观锁 /
version字段能完全替代 RR 吗? 不能。乐观锁解决的是"写写冲突",幻读是"读范围与并发插入"的问题,需要范围锁或串行化语义。 -
8.
MySQL 8.0 的
SKIP LOCKED/NOWAIT用在哪? 典型的"任务队列"场景:SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1让多个 worker 并发取任务而不互相阻塞——这是用 InnoDB 做轻量队列的标准写法。
明日预告:Day 03 计划覆盖「Spring 循环依赖与三级缓存」——重点讲
DefaultSingletonBeanRegistry的三级缓存为什么必须是三级、AOP 代理与循环依赖的冲突、以及@Async/构造器注入破坏循环依赖的原理。
本文深入剖析InnoDB在RR隔离级别下幻读的成因与机制,重点阐述MVCC(ReadView+undo版本链)实现快照读、next-key lock保障当前读防幻影、以及唯一漏掉的'快照读→当前读→快照读'幻读场景。同时揭示长事务对undo purge、history list及快照读性能的工程影响,并关联RC/RR与binlog格式的复制一致性要求。
1883

被折叠的 条评论
为什么被折叠?



