最近在线上发现了一个异常,代码已经有段时间了,不知道是哪位哥们写的,然后也不知道为啥他会做这个判断,大概的逻辑是这样的:

  1. records, err := queryDB(conds)
  2. ... ... // 一堆的业务逻辑
  3. rows, err := updateDB(conds)
  4. if len(rows) != len(records) {
  5. reportErrorMetrics()
  6. }

然后在日志中看到的情况是,records 的数量是 8 个,但是 update 的 rows 的数量却是 9 个,这有点费解,然后我就看了一下这个条件对应的所有记录,发现一个问题就是其中有一条记录的 created_time 和 updated_time 非常接近。

但是,我们的事务模式默认就是 RR 模式,不应该有这种情况才对。但是,当我认真地了解了一下 RR 之后,发现确实会有可能出现这里的情况,于是,在这篇文章我就总结了一下我了解到的一些关于 RR 的细节,还是老样子,我们使用的是 MySQL InnoDB,其他引擎还没有做深入地探究。


1. 手动实验:同一个事务里 SELECT 看到 8 行,UPDATE 匹配 9 行

为了确认这不是业务代码统计错了,我们可以用两个 MySQL 会话手动复现一下。

先假设 user 表里,id > 10 AND id < 20 这个范围当前有 8 条记录,并且这些记录的 status 都是 0。同时,id = 15 这个位置还没有记录。

先打开第一个会话,作为事务 A:

  1. -- Session A
  2. SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  3. BEGIN;
  4. SELECT COUNT(*) AS records
  5. FROM user
  6. WHERE id > 10 AND id < 20;

返回结果是:

  1. records
  2. -------
  3. 8

这一步很关键:这个普通 SELECT 是快照读,它会在事务 A 里创建 Read View。事务 A 后续的普通 SELECT 会继续复用这个 Read View。

接着打开第二个会话,作为事务 B,往这个范围里插入一条新记录并提交:

  1. -- Session B
  2. BEGIN;
  3. INSERT INTO user(id, status) VALUES (15, 0);
  4. COMMIT;

现在回到事务 A。你再执行一次普通 SELECT

  1. -- Session A
  2. SELECT COUNT(*) AS records
  3. FROM user
  4. WHERE id > 10 AND id < 20;

你仍然会看到:

  1. records
  2. -------
  3. 8

到这里,一切看起来都符合 REPEATABLE READ 的直觉:事务 A 的普通 SELECT 看不到事务 B 后插入并提交的新记录。

但是,如果事务 A 接下来执行同一个范围条件的 UPDATE

  1. -- Session A
  2. UPDATE user
  3. SET status = 1
  4. WHERE id > 10 AND id < 20;

MySQL 可能会返回类似这样的结果:

  1. Query OK, 9 rows affected
  2. Rows matched: 9 Changed: 9 Warnings: 0

这就是我们要复现的异常现场:

  1. 同一个事务 A 里:
  2. 前面的普通 SELECT 看到 8 行;
  3. 后面的 UPDATE 却匹配了 9 行。

关键点在这里:

  1. 普通 SELECT 是快照读,看的是 Read View 下可见的版本;
  2. UPDATE 是当前读,要读当前最新可更新的记录。

所以这个实验不是在说明“RR 失效了”,而是在说明同一个事务里混用了两种读语义:前面的普通 SELECT 按旧快照看世界,后面的 UPDATE 按当前可更新的数据执行。

理解这个异常,后面几个概念就有了落点:Read View、当前读、SELECT ... FOR UPDATE、gap lock、next-key lock,都是在解释同一件事:

  1. InnoDB 如何既提供一致性快照,又在需要修改数据时处理当前真实记录。

2. 先别急着怪 RR:这里其实混了两种读

看到实验结果时,第一反应很容易是:不是 REPEATABLE READ 吗,为什么同一个事务里还能一会儿 8 行、一会儿 9 行?

问题就在于,这里的两个动作不是同一种读。

事务 A 前面的 SELECT COUNT(*) 是普通 SELECT,在 InnoDB 里属于快照读。它关心的是:

  1. 按照我这个事务的快照,现在应该看到哪些版本?

后面的 UPDATE 不一样。它要真的去改数据,所以它属于当前读。它关心的是:

  1. 现在 B+Tree 上有哪些已经提交、可以被我更新的记录?

所以这次实验的关键,不是“RR 一定看到了幻读”,而是:

  1. 普通 SELECT 复用了旧快照;
  2. UPDATE 读取并修改当前可更新版本。

只要先把这两种读分开,后面的现象就不那么诡异了。


3. MVCC 到底解决了哪一半问题?

回到刚才的实验。Session B 插入 id = 15 并提交以后,Session A 又执行了一次普通 SELECT COUNT(*),结果仍然是 8。

这说明 MVCC 是生效的。

Session A 第一次普通 SELECT 时创建了一个 Read View。后面再做普通 SELECT,InnoDB 会继续拿这个 Read View 去判断版本是否可见。Session B 后插入的那条记录,对这个旧 Read View 来说不可见,所以普通查询仍然是 8 行。

从这个角度看,可以说:

  1. RR + MVCC
  2. 保证同一个事务里的普通快照读不会看到后续插入的新记录。

但 MVCC 只回答一个问题:

  1. 这个版本对我的快照是否可见?

它不回答另一个问题:

  1. 别人还能不能往这个范围里插入新记录?

更不保证:

  1. 后面的 UPDATE 只能更新前面 SELECT 看到的那 8 行。

所以更准确的说法是:MVCC 解决了快照读的一致性;如果是当前读,比如 UPDATEDELETESELECT ... FOR UPDATE,就还要看锁。


4. Read View 是什么时候创建的?

刚才实验里有个细节很关键:Session A 的 Read View 不是在 BEGIN 那一刻就自动创建的,而是在第一次普通快照读时创建的。

也就是这一步:

  1. -- Session A
  2. SELECT COUNT(*) AS records
  3. FROM user
  4. WHERE id > 10 AND id < 20;

这条普通 SELECT 执行以后,事务 A 才有了后续普通查询要复用的 Read View。

所以在 RR 下,通常可以这样理解:

  1. BEGIN 只是开启事务;
  2. 第一次快照读才创建 Read View
  3. 后续普通 SELECT 复用同一个 Read View

这也解释了一个容易踩的点。假设事务 A 只是 BEGIN,但还没有执行任何普通 SELECT

  1. -- Session A
  2. BEGIN;

这时事务 B 先提交了修改:

  1. -- Session B
  2. UPDATE user SET name = 'Alice' WHERE id = 1;
  3. COMMIT;

然后事务 A 第一次普通查询:

  1. -- Session A
  2. SELECT name FROM user WHERE id = 1;

事务 A 是能看到 Alice 的,因为它的 Read View 是在这条 SELECT 时才创建的。

但如果事务 A 先查过一次:

  1. -- Session A
  2. BEGIN;
  3. SELECT name FROM user WHERE id = 1;
  4. -- 这里创建 Read View,看到 Tom

然后事务 B 再提交 Alice

  1. -- Session B
  2. UPDATE user SET name = 'Alice' WHERE id = 1;
  3. COMMIT;

事务 A 再普通查询时,仍然会沿用旧 Read View:

  1. -- Session A
  2. SELECT name FROM user WHERE id = 1;
  3. -- 仍然看到 Tom

有一个例外:

  1. START TRANSACTION WITH CONSISTENT SNAPSHOT;

这个语句会在事务开始时就创建一致性快照,相当于提前把 Read View 固定下来。


5. 如果一开始就用 SELECT … FOR UPDATE 呢?

刚才的实验里,Session A 一开始只是普通 SELECT。它没有锁住范围,所以 Session B 可以插入 id = 15 并提交。

那如果业务代码不是普通查询,而是一开始就用:

  1. SELECT *
  2. FROM user
  3. WHERE id > 10 AND id < 20
  4. FOR UPDATE;

情况就变了。

SELECT ... FOR UPDATE 是加锁读,也是当前读。它不是为了“看旧快照里有什么”,而是为了:

  1. 读取当前最新可修改的数据,并把接下来可能要修改的记录锁住。

如果查询条件能走合适的索引,在 RR 下,InnoDB 通常会对扫描到的范围加锁。这样 Session B 再往这个范围里插入 id = 15,就可能被阻塞,直到 Session A 提交或回滚。

这也是 SELECT ... FOR UPDATE 的典型用途。比如库存扣减:

  1. BEGIN;
  2. SELECT stock
  3. FROM product
  4. WHERE id = 1
  5. FOR UPDATE;
  6. UPDATE product
  7. SET stock = stock - 1
  8. WHERE id = 1;
  9. COMMIT;

它表达的是:

  1. 我要先读这条记录,而且接下来大概率要改它。
  2. 在我提交或回滚之前,别的事务别抢着改这条记录。

所以可以把普通 SELECTSELECT ... FOR UPDATE 放在一起对比:

类型 普通 SELECT SELECT ... FOR UPDATE
读法 快照读 当前读
目标 看快照里可见的版本 找当前最新可锁定版本
是否加锁 不加锁 加排他锁
RR 下是否复用旧 Read View
适合场景 展示、统计、普通查询 先查后改、避免并发改同一批数据

6. Read View 下的版本,和最新可锁定版本,不是一回事

为了让这个差别更直观,可以先看一条单行记录。

初始数据是:

  1. id = 1, name = 'Tom'

Session A 先做普通查询:

  1. -- Session A
  2. BEGIN;
  3. SELECT name FROM user WHERE id = 1;
  4. -- 看到 Tom,并创建 Read View

Session B 修改并提交:

  1. -- Session B
  2. UPDATE user SET name = 'Alice' WHERE id = 1;
  3. COMMIT;

Session A 再做普通查询:

  1. -- Session A
  2. SELECT name FROM user WHERE id = 1;
  3. -- 仍然看到 Tom

这时 Tom 就是 Read View 下可见的历史版本。

但如果 Session A 接下来执行:

  1. -- Session A
  2. SELECT name FROM user WHERE id = 1 FOR UPDATE;

它不能去锁 undo log 里的旧版本 Tom。旧版本只是用来构造快照结果的,不是当前 B+Tree 上那条真实可锁定记录。

所以当前读要找的是最新可锁定版本。Session B 已经提交以后,Session A 这里就可能读到并锁住 Alice

把这个逻辑放回第 1 节的范围实验里,就是:

  1. 普通 SELECT 看到的是 Read View 下的 8 行;
  2. UPDATE 要处理的是当前范围里最新可更新的 9 行。

7. 回到实验:第 9 行到底从哪来?

现在再看第 1 节那个实验,答案就比较清楚了。

第 9 行就是 Session B 插入并提交的那条 id = 15 记录。它是在 Session A 的 Read View 创建之后插入的,所以 Session A 的普通 SELECT COUNT(*) 看不到它。

但是 UPDATE 是当前读。它执行范围条件时,看的不是旧 Read View 里那 8 行集合,而是当前已经提交、并且可以被更新的记录。

所以实验里才会出现:

  1. 普通 SELECT8
  2. UPDATERows matched: 9

如果第 9 行原本 status 已经是目标值,MySQL 还可能显示:

  1. Rows matched: 9
  2. Changed: 8

这里要顺手区分一下:

  1. Rows matched 表示 WHERE 条件匹配了多少行;
  2. Changed 表示真正发生内容变化的有多少行。

还有一个细节:Session A 执行完这个 UPDATE 后,如果再执行普通 SELECT

  1. SELECT *
  2. FROM user
  3. WHERE id > 10 AND id < 20;

它可能也能看到 9 行。原因是那条本来对旧 Read View 不可见的新记录,已经被 Session A 自己更新过了,而事务总是能看到自己的修改。

所以,这个线上异常的核心不是“RR 不可重复读了”,而是:

  1. 同一个事务里,普通 SELECT UPDATE 的读语义不同。

8. 如果 UPDATE 正在执行,别人还能插进来吗?

前面的实验里,顺序是这样的:

  1. Session A 先普通 SELECT,创建 Read View
  2. Session B 插入并提交;
  3. Session A 后执行 UPDATE

所以 Session B 的新记录已经提交在前,Session A 的 UPDATE 后执行时就能匹配到它。

如果顺序反过来,就不是同一个结果了。

比如 Session A 已经开始执行范围 UPDATE

  1. -- Session A
  2. BEGIN;
  3. UPDATE user
  4. SET status = 1
  5. WHERE id > 10 AND id < 20;

如果 id 上有合适索引,InnoDB 在 RR 下通常会对这个范围加 next-key lock / gap lock。此时 Session B 再插入:

  1. -- Session B
  2. INSERT INTO user(id, status) VALUES (15, 0);

Session B 往往会被阻塞,直到 Session A 提交或回滚。

所以排查这种问题时,时间线很重要:

时间线 结果
B 先插入并提交,A 后执行范围 UPDATE A 可能更新到 B 新插入的记录
A 的范围 UPDATE 已经开始并锁住范围,B 再插入 B 通常会被阻塞
B 插入但未提交,A 执行 UPDATE A 通常等待;B 提交后可能被更新,B 回滚则不存在

这也是为什么不能只说“RR 会不会有幻读”。要看你说的是普通快照读,还是会加锁的当前读。


9. gap lock、record lock、next-key lock 是怎么接上这个实验的?

现在问题又往前走了一步:如果当前读需要防止别人往范围里插入新记录,InnoDB 靠什么做到?

先看三个概念:

锁什么 作用
record lock 已存在的索引记录 防止别人修改或删除这条记录
gap lock 两条索引记录之间的间隙 防止别人往这个间隙插入新记录
next-key lock record lock + gap lock 的语义组合 锁住一条记录,以及它前面的间隙

假设索引里已有这些值:

  1. 10, 20, 30

那么中间的间隙就是:

  1. (-∞, 10)
  2. (10, 20)
  3. (20, 30)
  4. (30, +∞)

如果某个事务做当前读:

  1. SELECT *
  2. FROM user
  3. WHERE id > 10 AND id < 20
  4. FOR UPDATE;

即使 (10, 20) 之间暂时没有记录,InnoDB 也需要阻止别的事务插入:

  1. INSERT INTO user(id) VALUES (15);

否则后续再做当前读,范围里就会突然多一行。这就是当前读语义下要处理的“幻影行”。

next-key lock 可以理解成:

  1. (previous index value, current index value]

比如对 20 加 next-key lock,语义上就是锁住:

  1. (10, 20]

拆开看就是:

  1. gap lock: (10, 20)
  2. record lock: 20

它既能防止别人改 20,也能防止别人插入 15


10. next-key lock 是一个真实的独立锁对象吗?

这里还有个容易误解的点:我们平时说 next-key lock,好像它是一个独立的锁类型。

从理解行为来说,这么说没问题:

  1. next-key lock record lock + gap lock

但如果看 InnoDB 内部实现,它不一定真的创建一个叫“next-key lock”的独立对象。更准确地说,InnoDB 会在索引记录锁上用不同模式或标记表达这些语义:

  1. 只锁记录
  2. 只锁 gap
  3. 锁记录 + gap,也就是 next-key lock 的效果
  4. insert intention lock

所以这件事可以分三层看:

层次 说法
概念层 可以说有 next-key lock 这个概念
行为层 它确实能表现出“记录 + 间隙”的锁效果
实现层 不一定是一个独立物理锁对象,而是索引记录锁及其 gap 语义

对我们排查线上问题来说,最重要的是行为层:如果当前读锁住了范围,其他事务往这个范围插入新记录就可能被挡住。


11. InnoDB 里除了这些范围锁,还有哪些常见锁?

顺着这个问题继续看,InnoDB 的锁其实不只 record lock 和 gap lock。只是这次线上异常刚好和范围更新、当前读关系更大,所以我们先遇到了它们。

常见锁可以先放一张表:

层级 用途
S Lock / X Lock 行或记录锁模式 共享锁、排他锁
Intention Lock 表级 表示事务接下来要在表里的某些行上加 S/X 锁
Insert Intention Lock gap 上的特殊锁 表示事务想往某个 gap 插入记录
Auto-Inc Lock 表级 保护自增 ID 分配
Next-Key Lock 概念语义 record + gap,防止范围当前读里的幻影行

S Lock / X Lock

共享锁和排他锁是最基础的锁模式。

  1. SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;

这类语句会尝试加共享锁,也就是 S Lock

  1. SELECT * FROM user WHERE id = 1 FOR UPDATE;

这类语句会尝试加排他锁,也就是 X Lock

放回前面的实验里,范围 UPDATE 最终要修改记录,所以它需要的是排他语义。它不会只满足于旧 Read View 里的版本,而是要找到当前能被修改的索引记录,然后给这些记录加锁。

Intention Lock

意向锁是 InnoDB 自动加的表级协调锁。它不是直接锁某一行,而是在表这一层先挂一个标记:

  1. 我这个事务准备在这张表里的某些行上加 S/X 锁。

常见有两种:

  1. ISIntention Shared Lock
  2. IXIntention Exclusive Lock

比如这条语句:

  1. SELECT *
  2. FROM user
  3. WHERE id = 1
  4. LOCK IN SHARE MODE;

大致可以理解成:

  1. 表上加 IS
  2. id = 1 的索引记录上加 S 锁。

而这条语句:

  1. SELECT *
  2. FROM user
  3. WHERE id = 1
  4. FOR UPDATE;

或者:

  1. UPDATE user
  2. SET name = 'Alice'
  3. WHERE id = 1;

大致可以理解成:

  1. 表上加 IX
  2. id = 1 的索引记录上加 X 锁。

为什么需要这个“表上的标记”?

假设没有意向锁,另一个事务想锁整张表:

  1. LOCK TABLES user WRITE;

MySQL/InnoDB 就需要检查 user 表里是不是有任何一行已经被别的事务锁住。表很大时,这个检查会很贵。

有了意向锁以后,判断就轻很多:

  1. 如果表上已经有 IX
  2. 说明表里某些行正在或即将被加排他锁;
  3. 这时别人想加整表写锁,就不能直接成功。

意向锁最容易误解的地方是:多个事务持有 IX 并不冲突。

比如两个事务分别更新不同的行:

  1. -- Session A
  2. UPDATE user SET name = 'Alice' WHERE id = 1;
  3. -- Session B
  4. UPDATE user SET name = 'Bob' WHERE id = 2;

它们都可以在 user 表上持有 IX。真正是否冲突,要看后面具体的行锁是不是落在同一条索引记录或同一个范围上。

可以粗略看一下兼容关系:

当前已有 / 新申请 IS IX S 表锁 X 表锁
IS 兼容 兼容 兼容 冲突
IX 兼容 兼容 冲突 冲突
S 表锁 兼容 冲突 兼容 冲突
X 表锁 冲突 冲突 冲突 冲突

所以意向锁的重点不是“锁住整张表不让别人访问”,而是:

  1. 用一个表级标记,帮助表锁和行锁快速判断能不能共存。

Insert Intention Lock

插入意向锁和刚才的 gap 有关。

假设索引里有:

  1. 10, 20

两个事务分别想插入:

  1. 15
  2. 16

它们都在 (10, 20) 这个 gap 里,但插入位置不同,通常不需要互相完全阻塞。

但如果另一个事务已经用当前读锁住了这个范围:

  1. SELECT *
  2. FROM user
  3. WHERE id > 10 AND id < 20
  4. FOR UPDATE;

那么插入 1516 的事务就可能被阻塞。

所以 insert intention lock 不是在说“我要锁整张表”,而是在说:

  1. 我想往某个 gap 里的某个位置插入一条记录。

它和 gap lock 的关系,正好解释了为什么有时候两个插入可以并发,有时候一遇到范围当前读就会卡住。

Auto-Inc Lock

Auto-Inc Lock 和自增主键有关:

  1. INSERT INTO orders(user_id) VALUES (1);

如果表里有 AUTO_INCREMENT 字段,InnoDB 需要安全地分配自增值。不同 MySQL 配置和语句类型下,自增锁的粒度会不同,但目标都是保证自增值分配正确。

这一类锁也属于表级协调能力。它和前面讨论的 record lock、gap lock 不是同一个维度:record lock / gap lock 关心的是索引记录和间隙,Auto-Inc Lock 关心的是这张表的自增值分配。


12. 表级锁、MDL 和 Server 层锁要分开看

排查锁问题时,还有一个坑:不是所有 MySQL 里的锁都是 InnoDB 行锁,也不是所有“看起来锁了整张表”的情况都是真的表级锁。

先把常见场景放在一起:

场景 所属层 是否表级 典型语句
MDL,Metadata Lock MySQL Server SELECTUPDATEALTER TABLE
显式表锁 MySQL Server / 引擎 LOCK TABLES user WRITE
存储引擎只支持表锁 存储引擎 MyISAM 表上的写操作
InnoDB 意向锁 InnoDB 是,但只是协调标记 行锁前自动加 IS / IX
InnoDB Auto-Inc Lock InnoDB AUTO_INCREMENT 的插入
全局读锁 MySQL Server 全局级 FLUSH TABLES WITH READ LOCK
命名锁 MySQL Server 用户级 GET_LOCK()

MDL:最常见的“表被卡住”来源

MDL 是 Metadata Lock,元数据锁,属于 MySQL Server 层。它保护的不是某一行数据,而是表结构元数据,比如字段、索引、表是否存在。

它的目的很直接:

  1. 当有人正在读写一张表时,不允许别人同时把这张表结构改掉。

比如 Session A 开了一个事务,并查询了 user

  1. -- Session A
  2. BEGIN;
  3. SELECT *
  4. FROM user
  5. WHERE id = 1;

这时 user 表上会有共享型 MDL。只要 Session A 的事务还没结束,Session B 想改表结构就要等:

  1. -- Session B
  2. ALTER TABLE user ADD COLUMN nickname VARCHAR(50);

更容易让人困惑的是 Session C:

  1. -- Session C
  2. SELECT *
  3. FROM user
  4. WHERE id = 2;

它明明只是普通查询,也可能被卡住。原因是 Session B 的 ALTER TABLE 已经在等待排他型 MDL,后续新的查询可能排在它后面,于是形成这样的队列:

  1. Session A 持有共享 MDL,还没提交;
  2. Session B 等待排他 MDL,准备 ALTER
  3. Session C 想申请共享 MDL,但排在 B 后面,也被堵住。

这就是线上经常看到的现象:一个长事务没提交,后面一个 DDL 卡住,再后面大量普通查询也跟着堆积。

可以粗略记成:

语句 常见 MDL 语义
SELECT / INSERT / UPDATE / DELETE 共享型 MDL
ALTER TABLE / DROP TABLE / TRUNCATE TABLE / RENAME TABLE 排他型 MDL

在显式事务里,DML 相关的 MDL 通常会持有到事务结束,也就是 COMMITROLLBACK。所以长事务不仅会影响行锁,也可能影响 DDL。

显式表锁和引擎表锁

显式表锁就是你直接告诉 MySQL 我要锁表:

  1. LOCK TABLES user WRITE;

这种锁现在普通业务代码里相对少见,更多出现在维护脚本、导入导出、老系统兼容逻辑里。

还有一种情况是存储引擎本身主要使用表级锁。比如 MyISAM 的写操作通常是表级锁,而不是像 InnoDB 那样优先走行锁。

这也是为什么讨论 MySQL 锁时必须先限定存储引擎。本文一直讨论的是 InnoDB;如果换成 MyISAM,很多结论就不一样了。

全局读锁和命名锁

FLUSH TABLES WITH READ LOCK 是全局读锁:

  1. FLUSH TABLES WITH READ LOCK;

它不是某张表的普通表锁,而是 Server 层的全局锁,常见于备份场景。

GET_LOCK() 则是用户级命名锁:

  1. SELECT GET_LOCK('job:daily-report', 10);

它也不是 InnoDB 行锁,而是 MySQL Server 提供的一种命名互斥能力。

没走索引的 UPDATE 是不是表锁?

这里还有一个很常见的误区:InnoDB 里条件没走索引的 UPDATE,是不是会退化成表级锁?

更准确地说,通常不是。

比如:

  1. UPDATE user
  2. SET status = 1
  3. WHERE email = '[email protected]';

如果 email 没有索引,InnoDB 可能需要扫描很多记录,并对扫描过程中涉及的记录加锁。这个现象看起来很像“整张表都被锁住了”,但实现上它通常仍然是大量索引记录锁或范围锁,而不是简单地加一个表级 X 锁。

所以以后再说“MySQL 锁表了”,最好先拆成几个问题:

  1. 这是 Server 层的 MDL,还是 InnoDB 层的锁?
  2. 是显式 LOCK TABLES,还是 InnoDB 自动加的意向锁?
  3. 是表级锁,还是大量行锁看起来像锁表?
  4. 是普通快照读的问题,还是当前读加锁的问题?

13. MySQL 5.7.44 源码位置对照表

下面这张表可以作为阅读 MySQL 5.7.44 源码时的路线图。路径均相对 MySQL 源码根目录。

文章里的概念 源码位置 看什么
ReadView 类 storage/innobase/include/read0types.h:48 5.7 中 Read View 的核心类
版本可见性判断 storage/innobase/include/read0types.h:169 ReadView::changes_visible() 如何判断事务版本是否可见
ReadView 创建准备 storage/innobase/read/read0read.cc:453 ReadView::prepare() 设置上下界和活跃事务列表
MVCC 打开 ReadView storage/innobase/read/read0read.cc:554 MVCC::view_open() 分配或复用 ReadView
RR 第一次快照读创建 ReadView storage/innobase/trx/trx0trx.cc:2271 trx_assign_read_view() 注释说明第一次 consistent read 时创建
普通 SELECT 的一致性读 storage/innobase/row/row0sel.cc:5113 select_lock_type == LOCK_NONE 时走快照读并分配 ReadView
SELECT ... FOR UPDATE 解析 sql/sql_yacc.yy:9239 FOR UPDATE 被解析成 TL_WRITE
加锁读下发到表 sql/sql_parse.cc:6238 set_lock_for_tables() 设置表的 lock type 和 MDL 类型
InnoDB 当前读锁类型 storage/innobase/include/row0mysql.h:799 select_lock_type 表示 LOCK_NONELOCK_SLOCK_X
InnoDB handler 锁入口 storage/innobase/handler/ha_innodb.cc:15636storage/innobase/handler/ha_innodb.cc:16514 external_lock()store_lock() 连接 Server 层表锁语义与 InnoDB
InnoDB 基础锁模式 storage/innobase/include/lock0types.h:46 LOCK_ISLOCK_IXLOCK_SLOCK_XLOCK_AUTO_INC
InnoDB 锁对象表示 storage/innobase/include/lock0priv.h:136 type_mode 把锁类型、锁模式、gap/record 标记 OR 在一起
表锁和记录锁类型位 storage/innobase/include/lock0lock.h:949 LOCK_TABLELOCK_REC
next-key / gap / record-only storage/innobase/include/lock0lock.h:962 LOCK_ORDINARY 是 next-key 语义,LOCK_GAPLOCK_REC_NOT_GAP 分别表达 gap 与 record-only
记录锁实际加锁 storage/innobase/lock/lock0lock.cc:6292storage/innobase/lock/lock0lock.cc:6371 二级索引和聚簇索引分别检查并加锁
行扫描选择 gap/next-key storage/innobase/row/row0sel.cc:1232 sel_set_rec_lock() 接收 LOCK_ORDINARYLOCK_GAPLOCK_REC_NOT_GAP
InnoDB 意向锁 storage/innobase/row/row0sel.cc:5123storage/innobase/lock/lock0lock.cc:3996 加锁读先对表加 LOCK_ISLOCK_IX
Insert intention lock storage/innobase/include/lock0lock.h:980storage/innobase/lock/lock0lock.cc:5975 插入等待 gap 时使用 LOCK_INSERT_INTENTION
AUTO-INC 锁 storage/innobase/row/row0mysql.cc:1237storage/innobase/handler/ha_innodb.cc:7388 自增计数器的表级互斥锁
Server 层表锁 sql/lock.cc:315mysys/thr_lock.c:976 mysql_lock_tables() 最终调用 thr_multi_lock()
显式 LOCK TABLES sql/sql_parse.cc:2269sql/sql_base.cc:6741 解析后进入 lock_tables()
MyISAM 表锁 storage/myisam/ha_myisam.cc:1956storage/myisam/mi_locking.c:34 MyISAM 的表锁最终走 mi_lock_database()
MDL 元数据锁 sql/mdl.h:159sql/mdl.cc:3562sql/sql_base.cc:2790 MDL 类型定义、获取锁、打开表时获取 MDL
FLUSH TABLES WITH READ LOCK sql/lock.cc:1060sql/lock.cc:1126sql/lock.cc:1210 全局读锁用 MDL 实现,并分两步阻塞更新和 COMMIT
GET_LOCK() 用户级命名锁 sql/item_func.cc:5303sql/item_func.cc:5510 User_level_lockItem_func_get_lock::val_int()

从源码命名上也能看到前面讨论过的那个细节:next-key lock 更像一个行为语义。5.7 里真正落到锁对象上的是 LOCK_REC 加不同的精确模式,LOCK_ORDINARY 表达普通 next-key,LOCK_GAP 表达只锁间隙,LOCK_REC_NOT_GAP 表达只锁记录。


总结

这次线上异常最容易误判的地方是:我们下意识把前面的普通 SELECT 和后面的 UPDATE 当成了同一种读。

但在 InnoDB 的 RR 下,它们不是一回事:

  1. 普通 SELECT:快照读,复用 Read View
  2. UPDATE / DELETE / SELECT ... FOR UPDATE:当前读,处理当前最新可操作记录。

所以在第 1 节的实验里,Session A 前面普通 SELECT 看到 8 行,并不意味着后面的范围 UPDATE 也只能更新这 8 行。Session B 已经提交的第 9 行,虽然对旧 Read View 不可见,但对后面的当前读来说是可以被匹配和更新的。

在这篇文章中,你可以不看完整的内容,但是这几个重点我推荐记一下:

  • 先判断语句是快照读还是当前读;
  • RR 下 Read View 默认在第一次快照读时创建,不是在 BEGIN 时创建;
  • 普通 SELECT 看的是 Read View 下可见的历史版本;
  • UPDATE / DELETE / SELECT ... FOR UPDATE 看的是当前最新可操作版本;
  • 如果想阻止别人往范围里插入,需要当前读配合范围锁;
  • gap lock 锁间隙,record lock 锁已有索引记录;
  • next-key lock 是“记录 + 前方间隙”的锁语义,不一定是独立物理锁对象;
  • InnoDB 的意向锁是表级协调标记,用来协调表锁和行锁,不是直接锁住某一行;
  • MDL 属于 MySQL Server 层,长事务可能通过 MDL 把 DDL 甚至后续查询一起拖住;
  • 没走索引的 InnoDB UPDATE 看起来可能像锁表,但通常更准确地说是扫描并锁住了大量记录。