MySQL 的 REPEATABLE READ 到底能不能防幻读——从快照读、当前读到 Next-Key Lock

"可重复读防不了幻读" 这句话,几乎每个学过数据库的人都背过。但只要你在 InnoDB 上动手做过实验,就会发现一个尴尬的事实:教科书上的结论,在这里经常对不上号------明明是可重复读级别,别的事务插进来的新行,怎么就是看不见?可换个姿势查询,它又冒出来了。同一张表、同一个隔离级别,幻读有时候防得住,有时候防不住,这中间的分界线在哪里?

这篇文章就把这条线画出来。答案涉及 InnoDB 的两套读取机制和一种叫间隙锁的东西,把这三样看清,"防不防得住" 就不再是玄学。

1 先把名词对齐:三类读异常与四个隔离级别

谈幻读之前,得先保证我们说的是同一件事。SQL 标准定义了三类并发读取异常,按严重程度递进:

  • 脏读: 读到了别的事务尚未提交的数据,对方一回滚,你读到的就是从未存在过的值。
  • **不可重复读:**同一事务内两次读同一行,值不一样,因为中间被别的事务改掉并提交了。
  • **幻读:**同一事务内两次执行同一查询,结果集的行数变了------别的事务插入(或删除)了满足你查询条件的行。

后两者的区别要拎清:不可重复读针对 "某一行的内容变了",幻读针对 "符合条件的行多了或少了"。标准据此划出四个隔离级别,每一级依次多防一类异常:

隔离级别 脏读 不可重复读 幻读
读未提交 可能 可能 可能
读已提交 不可能 可能 可能
可重复读 不可能 不可能 可能
串行化 不可能 不可能 不可能

争议就落在第三行:标准说可重复读防不了幻读,需要升到串行化。而 InnoDB 的可重复读,实际表现远比这张表激进。要解释这个差异,得先回答一个更基本的问题:InnoDB 的一行数据,凭什么能被读出好几个版本?


2 快照读:MVCC 造出来的 "照骗"

InnoDB 的普通 SELECT(不带锁修饰的查询)走的是多版本并发控制,简称 MVCC。它的工作方式可以概括成一句话:修改不覆盖旧值,而是留下版本链条,读的人各取所需。

具体来说,每行数据都隐藏着两个版本号:创建它的事务 ID 和删除它的事务 ID。更新一行时,InnoDB 不是原地改写,而是把旧版本挪到 undo log 里,让新版本指回去,形成一条按事务 ID 排序的链。而每个事务在读取时,手里都攥着一份 "视图"(ReadView),本质是一张名单:开始时哪些事务还活着、下一个将分配的事务 ID 是多少。拿着这份名单去逐个版本检查,就能判断哪个版本对自己可见。

关键在于视图的创建时机,这是隔离级别的分水岭:

  • **读已提交:**每条查询语句执行前,都重新生成一份视图。别的事务刚提交的修改,下一条语句立刻可见,于是 "同一事务内两次读结果不同" 的不可重复读就来了。
  • **可重复读:**事务里的第一条查询执行时生成视图,之后整个事务都用这一份。事务开始之后别人提交的一切修改,对它统统不可见。

注意第二点的覆盖范围:别人提交的插入,同样不可见。 行的多版本机制不只对更新生效------新插入的行,其创建事务 ID 晚于我的视图,直接判定为不可见。所以对普通 SELECT 而言,可重复读级别下别说不可重复读,幻读也一并被挡在视图之外。两次执行同一查询,结果集纹丝不动。

到这里,似乎问题已经有了答案:防得住。但先别急,这个结论只对 "读" 成立。如果事务里混进了写操作,另一套机制就要登场了。


3 当前读:写操作不能活在旧版本里

想一个场景:事务 A 要给所有余额低于 100 元的账户扣 10 元手续费。它先 SELECT 出来 5 个账户------但这是快照读,读到的是开事务那一刻的世界。如果此刻别的事务又插入了一个余额 50 元的新账户并提交,A 按快照里的 5 个账户扣完款,第 6 个就漏了。

对查询来说,读旧一点无妨;对写入来说,基于过期数据做决策是要出事故的。所以 InnoDB 规定:写操作必须读最新版本。 以下语句都会绕过 MVCC 视图,直接读取记录的当前值:

  • UPDATE、DELETE;
  • SELECT ... FOR UPDATE(加排他锁的锁定读);
  • SELECT ... LOCK IN SHARE MODE(8.0 版本写作 FOR SHARE,加共享锁的锁定读)。

这套读取方式叫当前读。它带来一个连锁问题:既然读的是最新数据,就要防止 "我读完、我算完、我写回" 的窗口期内,有别人往我的操作范围里塞进新行。否则就回到上一段的扣费事故------这正是幻读的本义,只不过发生在写入路径上。

怎么防?加锁。但普通的行锁锁的是 "已存在的行",挡不住 "还没插入的行"。InnoDB 的解法是把行之间的 "缝隙" 也锁上,这就是间隙锁。


4 Next-Key Lock:把索引区间整个锁死

4.1 加锁的基本单位

InnoDB 在可重复读级别下的加锁单位叫 Next-Key Lock,它是两样东西的和:

  • **记录锁(Record Lock):**锁住索引上的一条记录;
  • **间隙锁(Gap Lock):**锁住两条索引记录之间的开区间,阻止任何落在这个区间内的插入。

举例说明。表里主键 ID 目前有 1 和 5 两个值,那么索引上存在一个记录 1、一个记录 5,以及它们之间的间隙 (1, 5)。如果一条查询的加锁范围落在这个区域,Next-Key Lock 会同时锁住记录 5 和间隙 (1, 5)------别的会话想插入 ID 为 2、3、4 的行,都得排队等锁。

锁的范围由查询条件在索引上的定位决定,规则大致是:等值命中唯一索引时退化为纯记录锁;等值未命中时锁住目标所在的间隙;范围查询则锁住扫描覆盖的所有区间。间隙锁还有个反直觉的特性:两个间隙锁之间不互斥------两个事务可以同时持有同一个间隙的锁,它的敌人只有插入操作。

4.2 动手验证

口说无凭,开两个会话实测。先准备数据:

sql 复制代码
CREATE TABLE account (
    id   INT PRIMARY KEY,
    name VARCHAR(20),
    balance INT
) ENGINE=InnoDB;

INSERT INTO account VALUES (1, 'alice', 500), (5, 'bob', 80), (10, 'carol', 300);

会话 A 开启事务,锁定所有余额低于 100 的行:

sql 复制代码
-- 会话 A
BEGIN;
SELECT * FROM account WHERE balance < 100 FOR UPDATE;

balance 上没有索引,这条查询会走全表扫描,把整张表的记录和所有间隙都纳入锁定范围。此时到会话 B 里插入任何新行:

sql 复制代码
-- 会话 B
BEGIN;
INSERT INTO account VALUES (7, 'dave', 90);
-- 阻塞,直到会话 A 提交或超时

会话 B 卡住了。间隙不释放,新行就进不来,幻读对锁定读无路可走。会话 A 提交后,B 的插入才能继续。这就是 InnoDB 在可重复读级别下 "超额完成" 标准的那部分:不仅靠 MVCC 让快照读看不见幻影,还靠 Next-Key Lock 让幻影根本无法在锁定的范围内诞生。

那 "可重复读防不了幻读" 的说法是错的吗?也不是------它只是不完整。有一个场景,幻读确实会露头。


5 防不住的那个角落

5.1 复现幻读

把上面的实验稍作修改:不锁范围,只对已存在的某一行做锁定读。

sql 复制代码
-- 会话 A
BEGIN;
SELECT * FROM account WHERE id = 5 FOR UPDATE;
-- 等值命中唯一索引,只加记录锁,间隙 (1, 5) 和 (5, 10) 未被锁定
sql 复制代码
-- 会话 B
BEGIN;
INSERT INTO account VALUES (3, 'erin', 60);
-- 成功,不阻塞
COMMIT;

此时会话 A 再执行普通 SELECT,由于视图创建于插入之前,看到的仍是三行------MVCC 把幻影挡在门外。真正的转折发生在下一步,A 去 UPDATE 那条它从没 "见过" 的记录:

sql 复制代码
-- 会话 A,继续上面的事务
SELECT COUNT(*) FROM account;   -- 3 行,快照里没有 erin
UPDATE account SET balance = 100 WHERE id = 3;  -- Rows matched: 1
SELECT COUNT(*) FROM account;   -- 4 行,erin 出现了

UPDATE 是当前读,它 "看见" 了 id 为 3 的行并把它改掉;这一改,行的创建版本就变成了本事务自己的 ID,于是随后的快照读也能看到它了。幻读就这样在一个事务内部诞生:前一次查询 3 行,后一次查询 4 行,同一事务,同一条件。

5.2 该怎么理解

这个反例的成因可以拆成两半:一是锁定读只锁了单条记录,没有为范围查询预判加间隙锁,给了别的事务插入的机会;二是快照读与当前读在同一事务里并存,UPDATE 这类写操作会把本不在视野内的行强行拉进来。

所以对开头那个问题,完整的回答是:InnoDB 的可重复读对快照读完全免疫幻读,对锁定读通过 Next-Key Lock 阻止幻读发生,但两者混用时存在边界情况,幻读仍可能出现。 说它 "防得住" 和 "防不住" 的人,通常分别站在两套机制的位置上,谁也没说全。

实践中如果业务要求严格排除这类边界,路子有两条:锁定读时用足够的条件覆盖目标范围(让间隙锁替你把关),或者干脆升到串行化级别。


6 为什么各家数据库在这件事上长得不一样

把视野放大一点,会发现幻读可能是各家数据库行为分歧最大的一档异常。MySQL 靠间隙锁硬扛,PostgreSQL 同样叫 "可重复读" 却是另一套内核,Oracle 干脆默认运行在读已提交。这不是谁做错了,根源在于 SQL 标准的出身。

SQL 标准起草于上世纪九十年代,参照的是基于锁的数据库实现。在纯锁模型里,防不可重复读要锁行,防幻读就得锁范围,级别之间的差异直接对应锁的粒度------那四级表格就是照着这个模型画的。可后来的主流实现纷纷转向 MVCC:读操作不加锁,靠版本来隔离,写操作再加行锁。锁与版本混血之后,"某一级别防什么" 就不再是标准那几行字能覆盖的了,各家都在按自己的理解补齐语义:

  • PostgreSQL的可重复读实际是快照隔离,读旧版本、写有冲突检测,防幻读的路径和 InnoDB 的 MVCC 部分类似,但它的锁定读没有间隙锁机制,写冲突靠序列化失败报错兜底;
  • Oracle长期默认读已提交,可重复读(它叫串行化)用快照实现,遇到写冲突直接让事务重试;
  • SQL Server早期在可重复读下用范围锁,行为最接近教科书;2005 年之后引入版本化选项,同一级别又能以另一副面孔运行。

标准只规定了结果------不能出现哪些现象,没有规定手段。MVCC 让 "读" 的隔离变得廉价,锁让 "写" 的隔离变得可靠,各家在这两者之间做不同的配比,于是同一名字下的实际行为天差地别。这也提醒我们一个更实在的结论:谈隔离级别,必须落到具体数据库的具体版本上才有意义。 背标准表格是入门,读实现差异才是进阶。


7 结语

回到最初的问题:可重复读到底能不能防幻读?现在可以给出有分寸的回答------在 InnoDB 里,纯读事务防得住,靠 MVCC 的视图;带锁定读的事务大部分防得住,靠 Next-Key Lock 把索引区间锁成禁区;快照读与当前读混用时存在漏网之鱼,本文第 5 节的场景就是那个口子。

比答案本身更值得留下的是这个认识:InnoDB 防幻读不是靠某一个机制,而是 MVCC 与间隙锁两条防线各守一段。理解了分工,也就理解了那些看似矛盾的现象为什么同时成立。顺带一提,间隙锁不是免费的午餐------它把并发插入挡在门外,高并发写入场景下锁等待和死锁的概率都会上升,这也正是不少大厂把线上库改回读已提交的缘由之一。隔离级别的选择,从来都是在隔离强度与并发吞吐之间找平衡,没有标准答案,只有适合与否。

相关推荐
千桐科技1 小时前
qKnow智能体构建平台专业版 v3.1.5 发布:扩容工作流编排调用能力,上线Text2SQL插件,拓宽数据类智能应用构建边界
服务器·数据库·oracle·qknow
宇灬宇1 小时前
oracle直方图测试
数据库·oracle
Henry-SAP1 小时前
SAP PP中的核心业务流程——离散制造、重复制造、流程制造三大模式解析
前端·数据库·人工智能·制造
Pniubi1 小时前
mysql 一张表可以有多少列
数据库·mysql
阡陌数智2 小时前
Extended Thinking 深度拆解:Claude Opus 5.5 推理范式、Token 计费陷阱与 Agent 工程落地
数据库
Dragon~Snow2 小时前
Centos Stream10 安装 MySQL-9.7.2-linux-glibc2.28-x86_64.tar
linux·mysql·centos
skywalk81632 小时前
不用 SSH、不装数据库:我用 1 个 Python 脚本 + Git,把 15 个 AI Agent 串成了一条流水线
数据库·人工智能·python·ssh·集群
Decay__3 小时前
19_MySQL 告警与自动化运维 —— 告警分级收敛 / Webhook / 定时巡检 / 脚本库
运维·数据库·sql·mysql·架构