前言
MySQL 事务面试里有一道"送命题":
可重复读(Repeatable Read,RR)能防住幻读吗?
不少人脱口而出:不能,只有串行化(Serializable)才能防幻读。
这个答案在 SQL 标准里没错,但在 MySQL InnoDB 里却是错的。InnoDB 的 RR 通过 MVCC + 间隙锁(Gap Lock) 的组合,确实把幻读防住了。
但很多人听完后反而更迷糊:
- MVCC 到底怎么"看到"旧版本的? —— 不是简单一句"快照"就解释得清的
- Read View 里那四个字段到底在比较什么? ——
m_ids、min_trx_id总是搞混 - 间隙锁锁的"间隙"长什么样? —— 不是锁行,那是锁空气吗?
- 为什么普通
SELECT不怕幻读,而UPDATE还要靠间隙锁?
这篇文章会用生活化的类比 + 具体事务时间线 + 流程图,把 MVCC 与间隙锁彻底讲透。
一、📋 事务隔离级别与并发问题
1.1 四种并发问题
先统一语言。多个事务同时操作同一份数据时,可能产生四种异常现象:
| 问题 | 通俗解释 | 发生的场景 |
|---|---|---|
| 脏读 | 读到了别人还没提交的数据 | 事务 A 修改了某行,事务 B 立刻读到;随后 A 回滚,B 读到的就是"假数据" |
| 不可重复读 | 同一事务内,两次读同一行,结果不一样 | 事务 A 第一次读到余额 1000;事务 B 修改并提交后,A 再次读到 800 |
| 幻读 | 同一事务内,两次范围查询,结果行数不一样 | 事务 A 先查 age > 18 有 3 条;事务 B 插入一条 age = 20,A 再查有 4 条 |
| 丢失更新 | 两个事务同时改同一行,后提交的覆盖了先提交的 | A、B 同时读取库存 10,都扣 1,结果库存变成 9 而不是 8 |
1.2 SQL 标准隔离级别
SQL 标准定义了四种隔离级别,对上面的问题有不同的防御能力:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现核心 |
|---|---|---|---|---|
| 读未提交(RU) | ❌ 可能 | ❌ 可能 | ❌ 可能 | 几乎不加控制 |
| 读已提交(RC) | ✅ 避免 | ❌ 可能 | ❌ 可能 | MVCC,每条 SELECT 生成新 Read View |
| 可重复读(RR) | ✅ 避免 | ✅ 避免 | ❌ 标准允许 | MVCC,事务级 Read View |
| 串行化(Serializable) | ✅ 避免 | ✅ 避免 | ✅ 避免 | 严格加锁串行 |
SQL 标准认为 RR 下幻读仍可能发生,因为标准只规定了"同一行"的可见性,没规定"新插入行"。
1.3 MySQL InnoDB 的 RR 不一样
InnoDB 在 RR 下做了额外优化:
- 普通 SELECT:走 MVCC 快照读,靠 Read View 保证看不到别的事务新插入的数据。
SELECT ... FOR UPDATE/UPDATE/DELETE:走当前读,靠 Next-Key Lock(临键锁) 锁住范围,阻止别的事务插入。
所以 MySQL InnoDB 的 RR 是能防幻读的。后面我们会分两条线讲清楚:MVCC 负责"快照读防幻读",间隙锁负责"当前读防幻读"。
二、💻 MVCC 原理:快照里的世界
2.1 为什么要有 MVCC?
想象数据库是一间教室,里面有一排座位(数据行)。很多老师(事务)同时进来改座位牌。
如果没有 MVCC,任何老师改座位牌的时候,其他老师都不能看——必须等改完。这样读写互相阻塞,并发很低。
MVCC 的做法是:每次修改座位牌之前,先拍一张旧照片存起来。其他老师想看的时候,可以去看照片,不用等正在修改的老师。只有真正要修改的老师才需要抢"写锁"。
这就是 MVCC 的核心思想:写操作不阻塞读操作,读操作不阻塞写操作。
2.2 每行数据背后的三个隐藏字段
InnoDB 在每行数据后面偷偷加了三个隐藏字段:
┌─────────────┬─────────────┬─────────────┬─────────────────┐
│ DB_TRX_ID │ DB_ROLL_PTR │ DB_ROW_ID │ 用户数据列 │
├─────────────┼─────────────┼─────────────┼─────────────────┤
│ 6 字节 │ 7 字节 │ 6 字节 │ name, age... │
│ 最后修改它 │ 回滚指针 │ 隐藏主键 │ │
│ 的事务 ID │ 指向旧版本 │ 没有主键时 │ │
│ │ │ InnoDB 自动生成 │ │
└─────────────┴─────────────┴─────────────┴─────────────────┘
- DB_TRX_ID:最后一次修改这行数据的事务 ID。
- DB_ROLL_PTR:指向 undo log 中的上一个版本。
- DB_ROW_ID:如果表没有显式主键,InnoDB 用它作为隐藏主键。
用户平时看不到这三个字段,但 MVCC 的每一次判断都依赖它们。
2.3 undo log 版本链
每次修改一行数据时,InnoDB 不会直接覆盖旧数据,而是:
- 把旧数据复制到 undo log。
- 在数据页上写入新版本。
- 新版本的
DB_ROLL_PTR指向 undo log 里的旧版本。
这样多次修改就形成了版本链:
版本链就像一条"时光隧道",顺着
DB_ROLL_PTR一直往前找,能找到这行数据的所有历史版本。
2.4 Read View:事务的"快照"
当一个事务执行第一次普通 SELECT 时,InnoDB 会给它生成一个 Read View(一致性读视图) 。
你可以把它理解为事务拍照时记录下的"现场信息":
四个字段的含义:
| 字段 | 含义 | 作用 |
|---|---|---|
m_ids | 生成 Read View 时,所有还没提交的事务 ID 列表 | 判断某版本是不是"未提交" |
min_trx_id | 活跃事务中最小的 ID | 比它小的版本一定已提交 |
max_trx_id | 下一个待分配的事务 ID | 比它大的版本是"未来事务"生成的 |
creator_trx_id | 创建 Read View 的事务自己的 ID | 自己修改的版本对自己可见 |
2.5 可见性判断规则
事务读取某一行时,拿到这行的 DB_TRX_ID(记为 trx_id),然后按下面规则判断:
1. trx_id == creator_trx_id
→ 可见(这行是我自己改的)
2. trx_id < min_trx_id
→ 可见(修改它的事务在我拍照前已经提交)
3. trx_id >= max_trx_id
→ 不可见(修改它的事务在我拍照之后才出现)
4. min_trx_id <= trx_id < max_trx_id
→ 看 trx_id 是否在 m_ids 中
a. 在 m_ids 中 → 不可见(事务还没提交)
b. 不在 m_ids 中 → 可见(事务已提交)
如果当前版本不可见,就顺着 DB_ROLL_PTR 找上一个版本,再用同样规则判断,直到找到可见版本。
2.6 RC vs RR:Read View 生成时机的差异
这是面试最爱考的区分点:
| 隔离级别 | Read View 生成时机 | 效果 |
|---|---|---|
| 读已提交(RC) | 每条 SELECT 都生成新的 Read View | 能看到其他事务最新提交的数据 |
| 可重复读(RR) | 事务第一次 SELECT 时生成,整个事务复用 | 始终看到事务开始时的快照 |
我们用具体的时间线来看区别。假设表里有这样一行数据:
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50)
) ENGINE=InnoDB;
INSERT INTO user VALUES (1, 'Alice');
-- 假设插入事务 ID = 100
场景 A:读已提交(RC)
时间 事务 A (trx_id=200) 事务 B (trx_id=201)
────────────────────────────────────────────────────────────
T1 BEGIN BEGIN
T2 SELECT name FROM user WHERE id=1
→ 生成 Read View A
→ m_ids=[200,201], min=200, max=202
→ 当前行 trx_id=100 < 200 → 可见
→ 结果:Alice
T3 UPDATE user SET name='Bob' WHERE id=1
T4 SELECT name FROM user WHERE id=1
→ RC 重新生成 Read View
→ m_ids=[200,201], min=200, max=202
→ 当前行 trx_id=201 在 m_ids 中 → 不可见
→ 沿版本链找到 trx_id=100 的旧版本 → 可见
→ 结果:Alice(B 还没提交)
T5 COMMIT
T6 SELECT name FROM user WHERE id=1
→ RC 再次生成 Read View
→ m_ids=[200], min=200, max=202
→ 当前行 trx_id=201 不在 m_ids 中 → 可见
→ 结果:Bob(B 已提交)
在 RC 下,事务 A 第三次读到了 Bob,这就是"不可重复读"。
场景 B:可重复读(RR)
时间 事务 A (trx_id=200) 事务 B (trx_id=201)
────────────────────────────────────────────────────────────
T1 BEGIN BEGIN
T2 SELECT name FROM user WHERE id=1
→ 生成 Read View A,整个事务复用
→ m_ids=[200,201], min=200, max=202
→ 结果:Alice
T3 UPDATE user SET name='Bob' WHERE id=1
T4 SELECT name FROM user WHERE id=1
→ 复用 Read View A
→ m_ids=[200,201], trx_id=201 在 m_ids 中 → 不可见
→ 找到旧版本 trx_id=100 → 可见
→ 结果:Alice
T5 COMMIT
T6 SELECT name FROM user WHERE id=1
→ 仍然复用 Read View A
→ m_ids=[200,201], trx_id=201 仍在 m_ids 中 → 不可见
→ 结果:Alice
在 RR 下,事务 A 三次都读到 Alice,实现了可重复读。即使事务 B 提交了,Read View 里仍然认为 B 是"活跃事务",因为 Read View 是在 T2 生成的快照。
三、🔍 快照读 vs 当前读
MVCC 解决的是"快照读",但数据库里还有一种"当前读"。
| 类型 | SQL 例子 | 读取方式 | 是否加锁 | 能否防幻读 |
|---|---|---|---|---|
| 快照读 | 普通 SELECT | 读 MVCC 快照 | 不加锁 | RR 下靠 Read View 防幻读 |
| 当前读 | SELECT ... FOR UPDATE / UPDATE / DELETE | 读最新已提交数据 | 加锁 | RR 下靠间隙锁防幻读 |
-- ✅ 快照读:不加锁,看 Read View 里的版本
SELECT * FROM user WHERE age > 18;
-- ⚠️ 当前读:要加锁,读最新数据
SELECT * FROM user WHERE age > 18 FOR UPDATE;
UPDATE user SET status = 1 WHERE age > 18;
DELETE FROM user WHERE age > 18;
为什么 UPDATE 也要算当前读?
因为 UPDATE 必须基于最新数据做修改。如果它去读快照,就可能基于一个旧版本修改,导致"丢失更新"。所以 UPDATE 会先做一次当前读,找到最新行并加 X 锁,再修改。
四、🔒 间隙锁与临键锁
4.1 什么是间隙锁?
间隙锁(Gap Lock)锁的不是某一行,而是两行之间的空隙。
假设表 user 的 id 主键已经有这些数据:
SELECT id FROM user ORDER BY id;
-- 结果:1, 5, 10, 15, 20
那么这些记录把索引轴分成了若干间隙:
(-∞, 1), (1, 5), (5, 10), (10, 15), (15, 20), (20, +∞)
如果事务 A 执行:
BEGIN;
SELECT * FROM user WHERE id > 10 FOR UPDATE;
InnoDB 会锁住 (10,15)、(15,20)、(20,+∞) 这些间隙,以及 id=15 和 id=20 这两行本身。
此时事务 B 想插入:
INSERT INTO user VALUES (12, ...);
id=12 落在 (10,15) 间隙内,会被间隙锁挡住,进入阻塞状态。
间隙锁与间隙锁之间不冲突。两个事务可以同时持有同一个间隙的间隙锁,但它们都会阻止第三个事务在这个间隙里插入。
4.2 临键锁(Next-Key Lock)
InnoDB 在 RR 下的默认锁不是单纯的间隙锁,而是 Next-Key Lock = 行锁 + 间隙锁。
它的区间是左开右闭:(前一条记录, 当前记录]。
还是上面的数据 1, 5, 10, 15, 20:
SELECT * FROM user WHERE id = 10 FOR UPDATE;
会生成 Next-Key Lock,锁住 (5, 10],即:
- 间隙锁:
(5, 10),阻止插入 id 在 5 到 10 之间的数据。 - 记录锁:
id = 10这一行本身。
4.3 临键锁的退化规则
InnoDB 会根据查询条件"智能"退化锁,减少锁范围:
| 查询条件 | 退化结果 | 原因 |
|---|---|---|
| 唯一索引等值查询,且命中记录 | 记录锁(Record Lock) | 只有一行,不需要防插入 |
| 唯一索引等值查询,未命中记录 | 间隙锁(Gap Lock) | 没有行可锁,只锁间隙 |
| 非唯一索引等值查询 | Next-Key Lock + 可能的间隙锁 | 可能有多个行满足条件 |
| 范围查询 | Next-Key Lock | 必须锁住整个范围防幻读 |
-- 唯一索引等值命中 → 退化为记录锁
SELECT * FROM user WHERE id = 10 FOR UPDATE;
-- 锁住:id=10 这一行
-- 唯一索引等值未命中 → 退化为间隙锁
SELECT * FROM user WHERE id = 7 FOR UPDATE;
-- 锁住:间隙 (5,10)
-- 范围查询 → Next-Key Lock
SELECT * FROM user WHERE id > 5 AND id < 15 FOR UPDATE;
-- 锁住:(5,10] + (10,15)
4.4 插入意向锁(Insert Intention Lock)
插入一行之前,事务会先申请一个插入意向锁。它表示"我想在这个间隙里插入数据"。
插入意向锁的特点是:
- 多个事务向同一个间隙的不同位置插入时,互相不冲突。
- 但如果这个间隙已经被间隙锁锁住,插入意向锁就会等待。
-- 表 user 的 id 已有 1, 5, 10
-- 事务 A 和 B 同时向 (5,10) 间隙插入不同值
-- 事务 A: INSERT INTO user VALUES (6, ...)
-- 事务 B: INSERT INTO user VALUES (8, ...)
-- 两者不冲突,都能成功
-- 但如果事务 C 先执行:
SELECT * FROM user WHERE id > 5 AND id < 10 FOR UPDATE;
-- 事务 A 和 B 的插入都会被阻塞
五、🎯 MVCC + 间隙锁如何防止幻读
现在回到核心问题:InnoDB 的 RR 怎么防幻读?
答案是两条防线:
5.1 快照读防幻读
普通 SELECT 走 MVCC 快照读。事务第一次 SELECT 时生成的 Read View 会固定下来,后续别的事务插入的新行,其 trx_id >= max_trx_id,对当前事务不可见。
所以即使别的事务插入了一行满足条件的数据,当前事务再次 SELECT 也看不到,因为它读的是旧快照。
-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18; -- 第一次读,生成 Read View
-- 结果:3 条
-- 事务 B 插入一条 age=20 并提交
INSERT INTO user VALUES (100, 'New', 20);
COMMIT;
-- 事务 A 再次查询
SELECT * FROM user WHERE age > 18;
-- 结果:仍然是 3 条(RR 快照读)
5.2 当前读防幻读
SELECT ... FOR UPDATE / UPDATE / DELETE 需要读最新数据,不能只看快照。这时就靠 Next-Key Lock 把范围锁住,阻止其他事务插入。
-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18 FOR UPDATE;
-- 对 age > 18 的范围加 Next-Key Lock
-- 事务 B 想插入 age=20
INSERT INTO user VALUES (101, 'New2', 20);
-- ❌ 阻塞,等待事务 A 释放锁
-- 事务 A 再次查询
SELECT * FROM user WHERE age > 18 FOR UPDATE;
-- 结果仍然是 3 条,幻读被防住
5.3 为什么有人说 RR 还是会有"幻读"?
有一种特殊情况容易让人误解:
-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 18; -- 快照读,3 条
-- 事务 B 插入 age=20 并提交
INSERT INTO user VALUES (102, 'New3', 20);
COMMIT;
-- 事务 A 执行 UPDATE
UPDATE user SET name = 'X' WHERE age > 18;
-- 当前读!会读到事务 B 插入的那行并加锁修改
-- 事务 A 再次快照读
SELECT * FROM user WHERE age > 18;
-- 结果变成了 4 条!
这看起来像是"幻读",但本质原因是:第一次是快照读,UPDATE 切换成了当前读。UPDATE 读到了事务 B 插入的那行,并对其加锁修改。修改后,这行数据的 trx_id 变成了事务 A 自己的 creator_trx_id,于是事务 A 再次快照读时就能看到它。
这不是 RR 没防住幻读,而是在事务内部先用快照读、再用当前读,导致自己把别的事务插入的数据拉进了自己的可见范围。如果业务需要严格保证范围查询结果不变,应该全程使用
SELECT ... FOR UPDATE,或者避免在事务中混合使用两种读。
六、⚠️ 常见误区与死锁
6.1 误区一:间隙锁只在 RR 下生效
✅ 正确。RC 隔离级别下,InnoDB 只加记录锁,不加间隙锁。所以 RC 下当前读是可能幻读的。
-- 设置 RC
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM user WHERE id > 10 FOR UPDATE;
-- 只锁 id=15, 20 两行,不锁间隙
-- 其他事务可以插入 id=12
INSERT INTO user VALUES (12, ...);
-- ✅ 成功
6.2 误区二:间隙锁之间会冲突
❌ 错误。间隙锁与间隙锁之间是兼容的。
-- 事务 A
SELECT * FROM user WHERE id = 7 FOR UPDATE;
-- 加间隙锁 (5,10)
-- 事务 B
SELECT * FROM user WHERE id = 8 FOR UPDATE;
-- 也加间隙锁 (5,10)
-- ✅ 不会阻塞
6.3 间隙锁导致的死锁
虽然间隙锁之间不冲突,但结合插入意向锁可能产生死锁:
-- 表 user 的 id: 1, 5
-- 事务 A
BEGIN;
SELECT * FROM user WHERE id = 3 FOR UPDATE;
-- 加间隙锁 (1,5)
-- 事务 B
BEGIN;
SELECT * FROM user WHERE id = 4 FOR UPDATE;
-- 也加间隙锁 (1,5)
-- 事务 A 插入
INSERT INTO user VALUES (2, ...);
-- 等待插入意向锁(与 B 的间隙锁冲突)
-- 事务 B 插入
INSERT INTO user VALUES (3, ...);
-- 等待插入意向锁(与 A 的间隙锁冲突)
-- 💥 死锁!InnoDB 会回滚其中一个事务
七、📖 实战建议
7.1 选择合适的隔离级别
| 业务场景 | 推荐隔离级别 | 理由 |
|---|---|---|
| 只读报表 / 对账 | RR | MVCC 快照读不加锁,长时间查询不阻塞写 |
| 高并发写入 | RC | 减少间隙锁,降低死锁概率 |
| 强一致性要求 | Serializable | 最安全,但性能最差 |
| 默认业务 | RR(MySQL 默认) | 兼顾一致性与并发 |
7.2 避免长事务
长事务会长时间持有 Read View 和锁,导致 undo log 无法及时清理,版本链越来越长,影响性能。
-- ❌ 错误:事务里调用外部 HTTP 接口
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
-- 调用支付回调 HTTP 接口(可能耗时几秒)
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
-- ✅ 正确:事务最小化
-- 外部调用放在事务外
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
7.3 确保查询走索引
没有索引时,UPDATE / DELETE 会锁全表或全表扫描,锁范围剧增。
-- ❌ 错误:没有索引,可能锁大量行
UPDATE user SET status = 1 WHERE age = 20;
-- 如果 age 没有索引,会全表扫描
-- ✅ 正确:给 age 加索引
CREATE INDEX idx_age ON user(age);
UPDATE user SET status = 1 WHERE age = 20;
7.4 按固定顺序加锁
多行更新时,按相同顺序加锁可以避免交叉死锁。
-- ❌ 错误:不同事务加锁顺序不一致
-- 事务 A:先锁 id=1,再锁 id=2
-- 事务 B:先锁 id=2,再锁 id=1
-- ✅ 正确:统一按 id 从小到大
BEGIN;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
SELECT * FROM user WHERE id = 2 FOR UPDATE;
COMMIT;
八、📝 总结
一句话总结:
MySQL InnoDB 的可重复读(RR)确实防住了幻读:普通
SELECT靠 MVCC 的 Read View 读固定快照;SELECT ... FOR UPDATE/UPDATE/DELETE等当前读靠 Next-Key Lock(行锁 + 间隙锁)阻止其他事务插入。MVCC 通过隐藏字段、undo log 版本链和 Read View 可见性规则实现;间隙锁锁住索引记录之间的空隙,专门防止"插入新行"导致的幻读。
速记核心:
| 概念 | 一句话 |
|---|---|
| MVCC | 多版本并发控制,读写互不阻塞 |
| 隐藏字段 | DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID |
| Read View | 事务快照,记录活跃事务 ID |
| RC | 每条 SELECT 新 Read View |
| RR | 首次 SELECT 生成,事务复用 |
| 快照读 | 普通 SELECT,不加锁 |
| 当前读 | FOR UPDATE / UPDATE / DELETE,加锁 |
| 间隙锁 | 锁空隙,防插入 |
| 临键锁 | 行锁 + 间隙锁,RR 默认 |
| 幻读 | RR 下被 MVCC + 间隙锁防住 |
九、高频面试题
Q1: MySQL 可重复读能防幻读吗?
能。MySQL InnoDB 的 RR 通过两条路径防幻读:普通 SELECT 走 MVCC 快照读,Read View 固定,看不到别的事务新插入的行;FOR UPDATE / UPDATE / DELETE 走当前读,靠 Next-Key Lock 锁住范围阻止插入。
Q2: MVCC 是怎么实现的?
每行数据有三个隐藏字段:DB_TRX_ID(最后修改事务)、DB_ROLL_PTR(回滚指针)、DB_ROW_ID(隐藏主键)。修改时旧版本写入 undo log 形成版本链。事务第一次 SELECT 生成 Read View,包含 m_ids、min_trx_id、max_trx_id、creator_trx_id,根据可见性规则从最新版本沿版本链找到可见版本。
Q3: Read View 的可见性规则是什么?
对于自己修改的版本可见;trx_id < min_trx_id 的版本可见(已提交);trx_id >= max_trx_id 的版本不可见(未来事务);min_trx_id <= trx_id < max_trx_id 时,若 trx_id 在 m_ids 中则不可见(未提交),不在则可见(已提交)。
Q4: RC 和 RR 的 Read View 有什么区别?
RC 下每条 SELECT 都会生成新的 Read View,所以能看到其他事务最新提交的数据;RR 下只有事务第一次 SELECT 生成 Read View,整个事务复用,因此实现可重复读。
Q5: 间隙锁锁的是什么?
间隙锁锁住的是索引记录之间的空隙,而不是记录本身。它的作用是阻止其他事务在空隙中插入新行,从而防止幻读。
Q6: 快照读和当前读有什么区别?
快照读是普通 SELECT,读 MVCC 快照,不加锁;当前读是 SELECT ... FOR UPDATE、UPDATE、DELETE,读最新已提交数据并加锁。
Q7: 临键锁和间隙锁的关系是什么?
临键锁(Next-Key Lock)= 间隙锁(Gap Lock)+ 记录锁(Record Lock)。它是 InnoDB 在 RR 下的默认锁,区间是左开右闭 (前一条记录, 当前记录]。
十、面试速记 10 条
| # | 要点 |
|---|---|
| 1 | MySQL RR 防住了幻读(MVCC + 间隙锁) |
| 2 | MVCC = 隐藏字段 + undo log + Read View |
| 3 | RC 每条 SELECT 生成 Read View |
| 4 | RR 首次 SELECT 生成 Read View,事务复用 |
| 5 | 快照读靠 Read View,当前读靠锁 |
| 6 | 间隙锁锁间隙,防插入 |
| 7 | 临键锁 = 行锁 + 间隙锁 |
| 8 | FOR UPDATE 是当前读 |
| 9 | MySQL RR ≠ SQL 标准 RR(多了间隙锁) |
| 10 | 混合快照读和当前读可能出现"自我修改可见" |