最近在线上发现了一个异常,代码已经有段时间了,不知道是哪位哥们写的,然后也不知道为啥他会做这个判断,大概的逻辑是这样的:
records, err := queryDB(conds)... ... // 一堆的业务逻辑rows, err := updateDB(conds)if len(rows) != len(records) {reportErrorMetrics()}
然后在日志中看到的情况是,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:
-- Session ASET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN;SELECT COUNT(*) AS recordsFROM userWHERE id > 10 AND id < 20;
返回结果是:
records-------8
这一步很关键:这个普通 SELECT 是快照读,它会在事务 A 里创建 Read View。事务 A 后续的普通 SELECT 会继续复用这个 Read View。
接着打开第二个会话,作为事务 B,往这个范围里插入一条新记录并提交:
-- Session BBEGIN;INSERT INTO user(id, status) VALUES (15, 0);COMMIT;
现在回到事务 A。你再执行一次普通 SELECT:
-- Session ASELECT COUNT(*) AS recordsFROM userWHERE id > 10 AND id < 20;
你仍然会看到:
records-------8
到这里,一切看起来都符合 REPEATABLE READ 的直觉:事务 A 的普通 SELECT 看不到事务 B 后插入并提交的新记录。
但是,如果事务 A 接下来执行同一个范围条件的 UPDATE:
-- Session AUPDATE userSET status = 1WHERE id > 10 AND id < 20;
MySQL 可能会返回类似这样的结果:
Query OK, 9 rows affectedRows matched: 9 Changed: 9 Warnings: 0
这就是我们要复现的异常现场:
同一个事务 A 里:前面的普通 SELECT 看到 8 行;后面的 UPDATE 却匹配了 9 行。
关键点在这里:
普通 SELECT 是快照读,看的是 Read View 下可见的版本;UPDATE 是当前读,要读当前最新可更新的记录。
所以这个实验不是在说明“RR 失效了”,而是在说明同一个事务里混用了两种读语义:前面的普通 SELECT 按旧快照看世界,后面的 UPDATE 按当前可更新的数据执行。
理解这个异常,后面几个概念就有了落点:Read View、当前读、SELECT ... FOR UPDATE、gap lock、next-key lock,都是在解释同一件事:
InnoDB 如何既提供一致性快照,又在需要修改数据时处理当前真实记录。
2. 先别急着怪 RR:这里其实混了两种读
看到实验结果时,第一反应很容易是:不是 REPEATABLE READ 吗,为什么同一个事务里还能一会儿 8 行、一会儿 9 行?
问题就在于,这里的两个动作不是同一种读。
事务 A 前面的 SELECT COUNT(*) 是普通 SELECT,在 InnoDB 里属于快照读。它关心的是:
按照我这个事务的快照,现在应该看到哪些版本?
后面的 UPDATE 不一样。它要真的去改数据,所以它属于当前读。它关心的是:
现在 B+Tree 上有哪些已经提交、可以被我更新的记录?
所以这次实验的关键,不是“RR 一定看到了幻读”,而是:
普通 SELECT 复用了旧快照;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 行。
从这个角度看,可以说:
RR + MVCC保证同一个事务里的普通快照读不会看到后续插入的新记录。
但 MVCC 只回答一个问题:
这个版本对我的快照是否可见?
它不回答另一个问题:
别人还能不能往这个范围里插入新记录?
更不保证:
后面的 UPDATE 只能更新前面 SELECT 看到的那 8 行。
所以更准确的说法是:MVCC 解决了快照读的一致性;如果是当前读,比如 UPDATE、DELETE、SELECT ... FOR UPDATE,就还要看锁。
4. Read View 是什么时候创建的?
刚才实验里有个细节很关键:Session A 的 Read View 不是在 BEGIN 那一刻就自动创建的,而是在第一次普通快照读时创建的。
也就是这一步:
-- Session ASELECT COUNT(*) AS recordsFROM userWHERE id > 10 AND id < 20;
这条普通 SELECT 执行以后,事务 A 才有了后续普通查询要复用的 Read View。
所以在 RR 下,通常可以这样理解:
BEGIN 只是开启事务;第一次快照读才创建 Read View;后续普通 SELECT 复用同一个 Read View。
这也解释了一个容易踩的点。假设事务 A 只是 BEGIN,但还没有执行任何普通 SELECT:
-- Session ABEGIN;
这时事务 B 先提交了修改:
-- Session BUPDATE user SET name = 'Alice' WHERE id = 1;COMMIT;
然后事务 A 第一次普通查询:
-- Session ASELECT name FROM user WHERE id = 1;
事务 A 是能看到 Alice 的,因为它的 Read View 是在这条 SELECT 时才创建的。
但如果事务 A 先查过一次:
-- Session ABEGIN;SELECT name FROM user WHERE id = 1;-- 这里创建 Read View,看到 Tom
然后事务 B 再提交 Alice:
-- Session BUPDATE user SET name = 'Alice' WHERE id = 1;COMMIT;
事务 A 再普通查询时,仍然会沿用旧 Read View:
-- Session ASELECT name FROM user WHERE id = 1;-- 仍然看到 Tom
有一个例外:
START TRANSACTION WITH CONSISTENT SNAPSHOT;
这个语句会在事务开始时就创建一致性快照,相当于提前把 Read View 固定下来。
5. 如果一开始就用 SELECT … FOR UPDATE 呢?
刚才的实验里,Session A 一开始只是普通 SELECT。它没有锁住范围,所以 Session B 可以插入 id = 15 并提交。
那如果业务代码不是普通查询,而是一开始就用:
SELECT *FROM userWHERE id > 10 AND id < 20FOR UPDATE;
情况就变了。
SELECT ... FOR UPDATE 是加锁读,也是当前读。它不是为了“看旧快照里有什么”,而是为了:
读取当前最新可修改的数据,并把接下来可能要修改的记录锁住。
如果查询条件能走合适的索引,在 RR 下,InnoDB 通常会对扫描到的范围加锁。这样 Session B 再往这个范围里插入 id = 15,就可能被阻塞,直到 Session A 提交或回滚。
这也是 SELECT ... FOR UPDATE 的典型用途。比如库存扣减:
BEGIN;SELECT stockFROM productWHERE id = 1FOR UPDATE;UPDATE productSET stock = stock - 1WHERE id = 1;COMMIT;
它表达的是:
我要先读这条记录,而且接下来大概率要改它。在我提交或回滚之前,别的事务别抢着改这条记录。
所以可以把普通 SELECT 和 SELECT ... FOR UPDATE 放在一起对比:
| 类型 | 普通 SELECT |
SELECT ... FOR UPDATE |
|---|---|---|
| 读法 | 快照读 | 当前读 |
| 目标 | 看快照里可见的版本 | 找当前最新可锁定版本 |
| 是否加锁 | 不加锁 | 加排他锁 |
| RR 下是否复用旧 Read View | 是 | 否 |
| 适合场景 | 展示、统计、普通查询 | 先查后改、避免并发改同一批数据 |
6. Read View 下的版本,和最新可锁定版本,不是一回事
为了让这个差别更直观,可以先看一条单行记录。
初始数据是:
id = 1, name = 'Tom'
Session A 先做普通查询:
-- Session ABEGIN;SELECT name FROM user WHERE id = 1;-- 看到 Tom,并创建 Read View
Session B 修改并提交:
-- Session BUPDATE user SET name = 'Alice' WHERE id = 1;COMMIT;
Session A 再做普通查询:
-- Session ASELECT name FROM user WHERE id = 1;-- 仍然看到 Tom
这时 Tom 就是 Read View 下可见的历史版本。
但如果 Session A 接下来执行:
-- Session ASELECT name FROM user WHERE id = 1 FOR UPDATE;
它不能去锁 undo log 里的旧版本 Tom。旧版本只是用来构造快照结果的,不是当前 B+Tree 上那条真实可锁定记录。
所以当前读要找的是最新可锁定版本。Session B 已经提交以后,Session A 这里就可能读到并锁住 Alice。
把这个逻辑放回第 1 节的范围实验里,就是:
普通 SELECT 看到的是 Read View 下的 8 行;UPDATE 要处理的是当前范围里最新可更新的 9 行。
7. 回到实验:第 9 行到底从哪来?
现在再看第 1 节那个实验,答案就比较清楚了。
第 9 行就是 Session B 插入并提交的那条 id = 15 记录。它是在 Session A 的 Read View 创建之后插入的,所以 Session A 的普通 SELECT COUNT(*) 看不到它。
但是 UPDATE 是当前读。它执行范围条件时,看的不是旧 Read View 里那 8 行集合,而是当前已经提交、并且可以被更新的记录。
所以实验里才会出现:
普通 SELECT:8 行UPDATE:Rows matched: 9
如果第 9 行原本 status 已经是目标值,MySQL 还可能显示:
Rows matched: 9Changed: 8
这里要顺手区分一下:
Rows matched 表示 WHERE 条件匹配了多少行;Changed 表示真正发生内容变化的有多少行。
还有一个细节:Session A 执行完这个 UPDATE 后,如果再执行普通 SELECT:
SELECT *FROM userWHERE id > 10 AND id < 20;
它可能也能看到 9 行。原因是那条本来对旧 Read View 不可见的新记录,已经被 Session A 自己更新过了,而事务总是能看到自己的修改。
所以,这个线上异常的核心不是“RR 不可重复读了”,而是:
同一个事务里,普通 SELECT 和 UPDATE 的读语义不同。
8. 如果 UPDATE 正在执行,别人还能插进来吗?
前面的实验里,顺序是这样的:
Session A 先普通 SELECT,创建 Read View;Session B 插入并提交;Session A 后执行 UPDATE。
所以 Session B 的新记录已经提交在前,Session A 的 UPDATE 后执行时就能匹配到它。
如果顺序反过来,就不是同一个结果了。
比如 Session A 已经开始执行范围 UPDATE:
-- Session ABEGIN;UPDATE userSET status = 1WHERE id > 10 AND id < 20;
如果 id 上有合适索引,InnoDB 在 RR 下通常会对这个范围加 next-key lock / gap lock。此时 Session B 再插入:
-- Session BINSERT 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 的语义组合 | 锁住一条记录,以及它前面的间隙 |
假设索引里已有这些值:
10, 20, 30
那么中间的间隙就是:
(-∞, 10)(10, 20)(20, 30)(30, +∞)
如果某个事务做当前读:
SELECT *FROM userWHERE id > 10 AND id < 20FOR UPDATE;
即使 (10, 20) 之间暂时没有记录,InnoDB 也需要阻止别的事务插入:
INSERT INTO user(id) VALUES (15);
否则后续再做当前读,范围里就会突然多一行。这就是当前读语义下要处理的“幻影行”。
next-key lock 可以理解成:
(previous index value, current index value]
比如对 20 加 next-key lock,语义上就是锁住:
(10, 20]
拆开看就是:
gap lock: (10, 20)record lock: 20
它既能防止别人改 20,也能防止别人插入 15。
10. next-key lock 是一个真实的独立锁对象吗?
这里还有个容易误解的点:我们平时说 next-key lock,好像它是一个独立的锁类型。
从理解行为来说,这么说没问题:
next-key lock ≈ record lock + gap lock
但如果看 InnoDB 内部实现,它不一定真的创建一个叫“next-key lock”的独立对象。更准确地说,InnoDB 会在索引记录锁上用不同模式或标记表达这些语义:
只锁记录只锁 gap锁记录 + gap,也就是 next-key lock 的效果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
共享锁和排他锁是最基础的锁模式。
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
这类语句会尝试加共享锁,也就是 S Lock。
SELECT * FROM user WHERE id = 1 FOR UPDATE;
这类语句会尝试加排他锁,也就是 X Lock。
放回前面的实验里,范围 UPDATE 最终要修改记录,所以它需要的是排他语义。它不会只满足于旧 Read View 里的版本,而是要找到当前能被修改的索引记录,然后给这些记录加锁。
Intention Lock
意向锁是 InnoDB 自动加的表级协调锁。它不是直接锁某一行,而是在表这一层先挂一个标记:
我这个事务准备在这张表里的某些行上加 S/X 锁。
常见有两种:
IS:Intention Shared LockIX:Intention Exclusive Lock
比如这条语句:
SELECT *FROM userWHERE id = 1LOCK IN SHARE MODE;
大致可以理解成:
表上加 IS;id = 1 的索引记录上加 S 锁。
而这条语句:
SELECT *FROM userWHERE id = 1FOR UPDATE;
或者:
UPDATE userSET name = 'Alice'WHERE id = 1;
大致可以理解成:
表上加 IX;id = 1 的索引记录上加 X 锁。
为什么需要这个“表上的标记”?
假设没有意向锁,另一个事务想锁整张表:
LOCK TABLES user WRITE;
MySQL/InnoDB 就需要检查 user 表里是不是有任何一行已经被别的事务锁住。表很大时,这个检查会很贵。
有了意向锁以后,判断就轻很多:
如果表上已经有 IX,说明表里某些行正在或即将被加排他锁;这时别人想加整表写锁,就不能直接成功。
意向锁最容易误解的地方是:多个事务持有 IX 并不冲突。
比如两个事务分别更新不同的行:
-- Session AUPDATE user SET name = 'Alice' WHERE id = 1;-- Session BUPDATE user SET name = 'Bob' WHERE id = 2;
它们都可以在 user 表上持有 IX。真正是否冲突,要看后面具体的行锁是不是落在同一条索引记录或同一个范围上。
可以粗略看一下兼容关系:
| 当前已有 / 新申请 | IS | IX | S 表锁 | X 表锁 |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 冲突 |
| IX | 兼容 | 兼容 | 冲突 | 冲突 |
| S 表锁 | 兼容 | 冲突 | 兼容 | 冲突 |
| X 表锁 | 冲突 | 冲突 | 冲突 | 冲突 |
所以意向锁的重点不是“锁住整张表不让别人访问”,而是:
用一个表级标记,帮助表锁和行锁快速判断能不能共存。
Insert Intention Lock
插入意向锁和刚才的 gap 有关。
假设索引里有:
10, 20
两个事务分别想插入:
1516
它们都在 (10, 20) 这个 gap 里,但插入位置不同,通常不需要互相完全阻塞。
但如果另一个事务已经用当前读锁住了这个范围:
SELECT *FROM userWHERE id > 10 AND id < 20FOR UPDATE;
那么插入 15 或 16 的事务就可能被阻塞。
所以 insert intention lock 不是在说“我要锁整张表”,而是在说:
我想往某个 gap 里的某个位置插入一条记录。
它和 gap lock 的关系,正好解释了为什么有时候两个插入可以并发,有时候一遇到范围当前读就会卡住。
Auto-Inc Lock
Auto-Inc Lock 和自增主键有关:
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 | 是 | SELECT、UPDATE、ALTER 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 层。它保护的不是某一行数据,而是表结构元数据,比如字段、索引、表是否存在。
它的目的很直接:
当有人正在读写一张表时,不允许别人同时把这张表结构改掉。
比如 Session A 开了一个事务,并查询了 user:
-- Session ABEGIN;SELECT *FROM userWHERE id = 1;
这时 user 表上会有共享型 MDL。只要 Session A 的事务还没结束,Session B 想改表结构就要等:
-- Session BALTER TABLE user ADD COLUMN nickname VARCHAR(50);
更容易让人困惑的是 Session C:
-- Session CSELECT *FROM userWHERE id = 2;
它明明只是普通查询,也可能被卡住。原因是 Session B 的 ALTER TABLE 已经在等待排他型 MDL,后续新的查询可能排在它后面,于是形成这样的队列:
Session A 持有共享 MDL,还没提交;Session B 等待排他 MDL,准备 ALTER;Session C 想申请共享 MDL,但排在 B 后面,也被堵住。
这就是线上经常看到的现象:一个长事务没提交,后面一个 DDL 卡住,再后面大量普通查询也跟着堆积。
可以粗略记成:
| 语句 | 常见 MDL 语义 |
|---|---|
SELECT / INSERT / UPDATE / DELETE |
共享型 MDL |
ALTER TABLE / DROP TABLE / TRUNCATE TABLE / RENAME TABLE |
排他型 MDL |
在显式事务里,DML 相关的 MDL 通常会持有到事务结束,也就是 COMMIT 或 ROLLBACK。所以长事务不仅会影响行锁,也可能影响 DDL。
显式表锁和引擎表锁
显式表锁就是你直接告诉 MySQL 我要锁表:
LOCK TABLES user WRITE;
这种锁现在普通业务代码里相对少见,更多出现在维护脚本、导入导出、老系统兼容逻辑里。
还有一种情况是存储引擎本身主要使用表级锁。比如 MyISAM 的写操作通常是表级锁,而不是像 InnoDB 那样优先走行锁。
这也是为什么讨论 MySQL 锁时必须先限定存储引擎。本文一直讨论的是 InnoDB;如果换成 MyISAM,很多结论就不一样了。
全局读锁和命名锁
FLUSH TABLES WITH READ LOCK 是全局读锁:
FLUSH TABLES WITH READ LOCK;
它不是某张表的普通表锁,而是 Server 层的全局锁,常见于备份场景。
GET_LOCK() 则是用户级命名锁:
SELECT GET_LOCK('job:daily-report', 10);
它也不是 InnoDB 行锁,而是 MySQL Server 提供的一种命名互斥能力。
没走索引的 UPDATE 是不是表锁?
这里还有一个很常见的误区:InnoDB 里条件没走索引的 UPDATE,是不是会退化成表级锁?
更准确地说,通常不是。
比如:
UPDATE userSET status = 1WHERE email = '[email protected]';
如果 email 没有索引,InnoDB 可能需要扫描很多记录,并对扫描过程中涉及的记录加锁。这个现象看起来很像“整张表都被锁住了”,但实现上它通常仍然是大量索引记录锁或范围锁,而不是简单地加一个表级 X 锁。
所以以后再说“MySQL 锁表了”,最好先拆成几个问题:
这是 Server 层的 MDL,还是 InnoDB 层的锁?是显式 LOCK TABLES,还是 InnoDB 自动加的意向锁?是表级锁,还是大量行锁看起来像锁表?是普通快照读的问题,还是当前读加锁的问题?
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_NONE、LOCK_S 或 LOCK_X |
| InnoDB handler 锁入口 | storage/innobase/handler/ha_innodb.cc:15636、storage/innobase/handler/ha_innodb.cc:16514 |
external_lock() 和 store_lock() 连接 Server 层表锁语义与 InnoDB |
| InnoDB 基础锁模式 | storage/innobase/include/lock0types.h:46 |
LOCK_IS、LOCK_IX、LOCK_S、LOCK_X、LOCK_AUTO_INC |
| InnoDB 锁对象表示 | storage/innobase/include/lock0priv.h:136 |
type_mode 把锁类型、锁模式、gap/record 标记 OR 在一起 |
| 表锁和记录锁类型位 | storage/innobase/include/lock0lock.h:949 |
LOCK_TABLE 和 LOCK_REC |
| next-key / gap / record-only | storage/innobase/include/lock0lock.h:962 |
LOCK_ORDINARY 是 next-key 语义,LOCK_GAP 和 LOCK_REC_NOT_GAP 分别表达 gap 与 record-only |
| 记录锁实际加锁 | storage/innobase/lock/lock0lock.cc:6292、storage/innobase/lock/lock0lock.cc:6371 |
二级索引和聚簇索引分别检查并加锁 |
| 行扫描选择 gap/next-key | storage/innobase/row/row0sel.cc:1232 |
sel_set_rec_lock() 接收 LOCK_ORDINARY、LOCK_GAP、LOCK_REC_NOT_GAP |
| InnoDB 意向锁 | storage/innobase/row/row0sel.cc:5123、storage/innobase/lock/lock0lock.cc:3996 |
加锁读先对表加 LOCK_IS 或 LOCK_IX |
| Insert intention lock | storage/innobase/include/lock0lock.h:980、storage/innobase/lock/lock0lock.cc:5975 |
插入等待 gap 时使用 LOCK_INSERT_INTENTION |
| AUTO-INC 锁 | storage/innobase/row/row0mysql.cc:1237、storage/innobase/handler/ha_innodb.cc:7388 |
自增计数器的表级互斥锁 |
| Server 层表锁 | sql/lock.cc:315、mysys/thr_lock.c:976 |
mysql_lock_tables() 最终调用 thr_multi_lock() |
显式 LOCK TABLES |
sql/sql_parse.cc:2269、sql/sql_base.cc:6741 |
解析后进入 lock_tables() |
| MyISAM 表锁 | storage/myisam/ha_myisam.cc:1956、storage/myisam/mi_locking.c:34 |
MyISAM 的表锁最终走 mi_lock_database() |
| MDL 元数据锁 | sql/mdl.h:159、sql/mdl.cc:3562、sql/sql_base.cc:2790 |
MDL 类型定义、获取锁、打开表时获取 MDL |
FLUSH TABLES WITH READ LOCK |
sql/lock.cc:1060、sql/lock.cc:1126、sql/lock.cc:1210 |
全局读锁用 MDL 实现,并分两步阻塞更新和 COMMIT |
GET_LOCK() 用户级命名锁 |
sql/item_func.cc:5303、sql/item_func.cc:5510 |
User_level_lock 和 Item_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 下,它们不是一回事:
普通 SELECT:快照读,复用 Read View;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看起来可能像锁表,但通常更准确地说是扫描并锁住了大量记录。