数据库基础体系 · 第 16/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
MySQL 事务与锁:Read View、间隙锁、死锁和一致性读
事务和锁解决的是两个不同但相互关联的问题:
- 事务定义一组操作的提交、回滚和隔离边界。
- 锁限制并发事务对某些数据或索引范围的访问。
- MVCC通过保存旧版本,让一部分读操作不必等待写操作。
- Read View定义一次一致性读能够看到哪些事务版本。
- 一致性读通常读取快照,而不是读取当前最新版本。
- 当前读则读取最新版本,并可能加锁。
本文以 MySQL 8.4、InnoDB、单实例部署为主要边界。除非特别说明,示例使用默认的 REPEATABLE READ 隔离级别。
一、先建立几个边界:事务、存储引擎与读类型
1. 事务边界由 COMMIT、ROLLBACK 和自动提交决定
默认情况下,MySQL 通常开启自动提交:
SELECT @@autocommit;
当 autocommit = 1 时,一条独立的 INSERT、UPDATE、DELETE 或普通查询通常就是一个事务。语句成功结束后,事务立即提交;语句失败时,通常回滚该语句产生的影响。
显式事务则需要明确边界:
START TRANSACTION;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
UPDATE account
SET balance = balance + 100
WHERE id = 2;
COMMIT;
如果第二条语句失败,应用可以执行:
ROLLBACK;
事务边界很重要,因为锁通常至少持有到当前事务结束。若开启自动提交,单条语句加的锁可能在语句结束时就释放;若显式开启事务,锁通常会持续到 COMMIT 或 ROLLBACK。
2. 本文的锁语义主要属于 InnoDB
MySQL 是数据库服务器,具体的事务和锁语义还取决于存储引擎。本文讨论的记录锁、间隙锁、Next-Key Lock、MVCC 和 Read View 都是 InnoDB 机制。
可以确认表的存储引擎:
SHOW TABLE STATUS LIKE 'account';
或者:
SELECT TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'account';
如果表使用的不是 InnoDB,不能直接套用本文的锁行为。
二、InnoDB 的版本、聚簇索引与 Undo
理解 Read View 之前,需要先理解 InnoDB 如何保存一行数据的历史版本。
1. 聚簇索引记录包含事务信息
InnoDB 表通常以主键组织聚簇索引。聚簇索引记录内部包含与 MVCC 相关的隐藏信息,通常包括:
- 创建或最近修改该版本的事务标识;
- 指向 Undo 记录的指针。
当事务修改一行时,InnoDB 不一定直接丢弃旧值。旧值的一部分会通过 Undo 记录保存,从而形成版本链:
当前聚簇索引记录
|
v
Undo 旧版本 1
|
v
Undo 旧版本 2
假设一行 id = 1 的余额经历以下变化:
事务 10:balance = 100
事务 20:balance = 80
事务 30:balance = 50
当前记录可能是 50,Undo 链中保留 80 和 100。一个较早创建的 Read View 可能沿着版本链回退,找到它有权看到的 80 或 100。
旧版本不是永久保存的。事务提交后,如果已经没有任何活跃 Read View 需要它,后台 Purge 线程最终可以清理相关 Undo。长事务或长时间未释放的快照会阻碍清理,导致历史版本和 Undo 占用增加。
2. MVCC 不等于“所有读都不加锁”
InnoDB 的读操作至少要区分两类:
| 读类型 | 典型语句 | 主要行为 |
|---|---|---|
| 一致性读 | 普通 SELECT |
根据 Read View 读取可见版本,通常不加记录锁 |
| 当前读 | SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE |
读取当前版本,通常加锁或参与锁检查 |
例如:
SELECT balance
FROM account
WHERE id = 1;
这是普通一致性读。
SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;
这是当前读。它需要读取当前版本,并为后续修改锁定目标记录。
UPDATE 也不是“先用普通快照读出旧值,再盲目写入”。它需要对目标记录执行当前读和加锁检查,以避免覆盖已经发生的并发修改。
三、Read View:一致性读看到哪个版本
1. Read View 是什么
Read View 是 InnoDB 为一致性读建立的可见性快照。它不是一份完整的数据副本,而是一组用于判断事务版本是否可见的边界信息。
可以抽象为:
Read View = 创建者事务 + 创建时仍活跃的事务集合 + 事务 ID 边界
对于某一行的某个版本,InnoDB 会查看该版本对应的事务 ID,判断该版本在 Read View 创建时是否已经对当前读可见。
Read View 的核心直觉是:
读取某行时,不只看聚簇索引中的当前值,还要沿 Undo 版本链寻找“在这个快照中可见”的版本。
2. 可见性判断的形式化描述
设:
v:某个行版本的创建事务 ID;c:创建 Read View 的事务 ID;A:创建 Read View 时仍处于活跃状态的事务 ID 集合;U:创建 Read View 时尚未分配的事务 ID 下界;L:创建时事务 ID 的上界。
可以用如下规则描述一般可见性:
- 如果
v = c,通常认为该版本属于当前事务自己的修改,因此可见。 - 如果
v < L,说明该版本对应的事务在快照形成前已经完成,版本可见。 - 如果
v >= U,说明该事务在快照形成时尚未完成,版本不可见。 - 如果
L <= v < U,则检查v是否仍在活跃事务集合A中:- 在
A中:事务当时尚未提交,不可见; - 不在
A中:事务在快照形成前已经提交,可见。
- 在
不同文档和源码实现中对边界字段的命名可能容易混淆,但判断的核心是不变的:已提交事务的版本可见,快照形成时仍未提交的事务版本不可见,当前事务自己的修改可见。
如果当前记录版本不可见,InnoDB 沿 Undo 链回退,重复上述判断,直到找到可见版本或确认不存在可见版本。
3. Read View 在什么时候创建
这取决于隔离级别。
REPEATABLE READ
在 InnoDB 的默认隔离级别下,事务中的第一次一致性读通常创建 Read View,后续普通 SELECT 复用这个快照。
因此:
START TRANSACTION;
SELECT ...; -- 第一次一致性读,通常建立 Read View
SELECT ...; -- 继续使用该 Read View
这里的“事务开始时间”不能简单等同于“快照创建时间”。如果事务开始后先执行了 UPDATE,然后才执行第一次普通 SELECT,Read View 通常是在第一次一致性读时建立,而不是在 START TRANSACTION 时建立。
READ COMMITTED
在 READ COMMITTED 下,每次一致性读通常建立新的 Read View。因此,同一事务中的两次普通查询可能看到不同的已提交结果:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
SELECT balance FROM account WHERE id = 1;
如果两次查询之间其他事务提交了修改,第二次查询可能看到新值。
4. 完整算例:两个 Read View 的可见性
假设初始数据为:
balance = 100
事务 ID 和操作如下:
T10:已经提交,balance = 100
T20:执行 UPDATE,未提交,balance = 80
T30:执行 UPDATE,未提交,balance = 50
当前聚簇索引中的版本是:
trx_id = 30, balance = 50
|
v
trx_id = 20, balance = 80
|
v
trx_id = 10, balance = 100
此时事务 T40 创建 Read View,创建时活跃事务集合为:
A = {20, 30}
对当前版本逐个判断:
- 版本
50的事务 ID 是30; 30在活跃集合A中;- 因此
50对T40不可见; - 沿 Undo 链回退到
80; - 事务 ID
20也在A中; - 因此
80仍不可见; - 继续回退到
100; - 事务 ID
10在 Read View 创建前已提交,因此100可见。
所以 T40 的普通 SELECT 返回:
balance = 100
如果后来 T20、T30 都提交,T40 在 REPEATABLE READ 下再次执行普通 SELECT,仍可能返回 100,因为它复用了旧 Read View。
但如果 T40 执行:
SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;
这是当前读,不按旧 Read View 返回历史版本,而是等待必要的锁后读取最新版本。此时它可能读取到:
balance = 50
具体能否立即读取,取决于 T20、T30 是否已经提交以及锁是否释放。
四、一致性读与当前读的差异
1. 普通 SELECT 是一致性读
在没有锁定子句的情况下:
SELECT *
FROM orders
WHERE id = 100;
InnoDB 通常使用 Read View 读取一个一致版本。它可以在另一个事务尚未提交修改时,直接读取旧版本,而不是等待该事务提交。
这正是 MVCC 的作用:读和写在某些情况下可以并行。
2. SELECT ... FOR UPDATE 是当前读
START TRANSACTION;
SELECT *
FROM orders
WHERE id = 100
FOR UPDATE;
它的语义是:
- 查找满足条件的当前记录;
- 等待与其他事务冲突的锁;
- 锁定记录及必要的索引范围;
- 返回当前版本;
- 在事务结束前保留锁。
它适用于“读取后必须基于该值修改”的场景,例如扣减库存:
START TRANSACTION;
SELECT stock
FROM product
WHERE id = 10
FOR UPDATE;
UPDATE product
SET stock = stock - 1
WHERE id = 10
AND stock > 0;
COMMIT;
但这里仍然需要检查 UPDATE 影响行数。锁并不自动保证业务条件成立。
3. SELECT ... FOR SHARE 也是当前读
START TRANSACTION;
SELECT status
FROM orders
WHERE id = 100
FOR SHARE;
它读取当前版本,并持有共享锁。其他事务通常可以继续读取,但不能对同一记录执行冲突的排他修改。
MySQL 8.4 推荐使用 FOR SHARE;旧版本中常见的 LOCK IN SHARE MODE 是兼容写法。
4. 一个容易混淆的反例
在 REPEATABLE READ 下,下面两条语句可能返回不同结果:
SELECT status FROM orders WHERE id = 100;
SELECT status FROM orders WHERE id = 100 FOR UPDATE;
第一条使用旧快照,第二条执行当前读。因此,“同一个事务里所有 SELECT 都看到同一个值”是不正确的。
准确说法是:
同一事务中的普通一致性读通常复用同一个 Read View;当前读不受这个普通快照的读取规则约束。
五、InnoDB 的锁对象:记录、间隙与索引范围
InnoDB 的锁主要作用于索引,而不是抽象的“表中某一行”。即使 SQL 看起来是在操作一行,底层也通常是在访问某个索引记录和索引范围。
1. 记录锁 Record Lock
记录锁锁定某个索引记录。例如主键索引中:
10, 20, 30
对 id = 20 的精确访问,可能锁定索引记录 20。
需要注意,InnoDB 的记录锁是索引记录锁:
- 如果通过主键定位,通常锁聚簇索引记录;
- 如果通过二级索引定位并修改数据,通常还要访问并锁定对应的聚簇索引记录;
- 这就是索引访问路径与锁范围紧密相关的原因。
2. 间隙锁 Gap Lock
间隙锁锁定两个索引记录之间的范围,而不是锁定某一条已经存在的记录。
假设索引值为:
10, 20, 30
可以把索引间隙抽象为:
(-∞, 10)
(10, 20)
(20, 30)
(30, +∞)
锁住 (10, 20) 后,其他事务不能在这个范围内插入新的索引值,例如 15。
间隙锁的主要作用是防止幻读式插入:一个事务锁定了查询范围,另一个事务不能在该范围中插入符合条件的新索引记录。
间隙锁有一个容易被忽略的特征:
在 InnoDB 中,间隙锁之间通常可以共存;真正与插入发生直接冲突的是插入意向锁和相关的间隙锁。
因此,不能简单把间隙锁理解为“这个区间只能被一个事务占用”。
3. Next-Key Lock
Next-Key Lock 是:
记录锁 + 记录前面的间隙锁
如果索引记录为 20,对应的 Next-Key Lock 可以抽象为:
(10, 20]
它既阻止修改或删除索引记录 20,也阻止在 10 和 20 之间插入新记录。
在 REPEATABLE READ 下,InnoDB 的范围锁定通常使用 Next-Key Lock,以抑制范围内的并发插入。
4. 插入意向锁 Insert Intention Lock
事务准备插入某个索引位置时,会先获得插入意向锁。它表达的是:
我准备在这个间隙中的某个具体位置插入记录。
例如两个事务分别向同一间隙插入 12 和 15,它们之间不一定互相阻塞,因为最终插入位置不同。但如果该间隙已经被另一个事务的间隙锁或 Next-Key Lock 覆盖,插入就可能等待。
5. 意向锁 Intention Lock
意向锁是表级锁,用来表示事务准备在表中的某些记录上加共享锁或排他锁。
常见模式包括:
IS:意向共享锁;IX:意向排他锁。
它的作用不是替代行锁,而是让表级锁和行级锁之间可以快速判断兼容性。例如,一个事务准备对某行加排他锁时,先在表上持有 IX,这样其他事务申请冲突的表级锁时就能发现该表已有行级锁意图。
六、间隙锁的完整算例
先创建测试表:
CREATE TABLE inventory (
id BIGINT PRIMARY KEY,
sku VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
KEY idx_sku (sku)
) ENGINE = InnoDB;
INSERT INTO inventory (id, sku, quantity) VALUES
(1, 'A', 10),
(2, 'C', 20);
假设两个会话都设置为默认的 REPEATABLE READ。
会话 A:锁定二级索引范围
START TRANSACTION;
SELECT id, sku, quantity
FROM inventory
WHERE sku >= 'A' AND sku < 'D'
FOR UPDATE;
这条语句通过 idx_sku 查找范围。它可能锁定:
sku = 'A'对应的索引记录;sku = 'C'对应的索引记录;- 这些记录之间和范围边界相关的间隙;
- 对应的聚簇索引记录。
锁的精确表现取决于访问计划、索引结构和边界条件,但在 REPEATABLE READ 下,范围当前读通常会产生 Next-Key Lock 或相关间隙锁。
会话 B:插入范围内的新值
START TRANSACTION;
INSERT INTO inventory (id, sku, quantity)
VALUES (3, 'B', 5);
由于 'B' 位于 'A' 和 'C' 之间,插入可能等待会话 A 释放范围锁。
可以观察等待:
SELECT *
FROM performance_schema.data_lock_waits;
也可以查看当前锁:
SELECT *
FROM performance_schema.data_locks;
这些表的具体字段可能随版本和配置有所变化,应以当前实例中 DESCRIBE performance_schema.data_locks 的结果为准。
会话 A 执行:
COMMIT;
之后,会话 B 才能继续插入。
为什么普通快照读不一定阻塞插入
如果会话 A 执行的是:
START TRANSACTION;
SELECT id, sku, quantity
FROM inventory
WHERE sku >= 'A' AND sku < 'D';
这通常是一致性读。它可以通过 Read View 得到一个一致版本,并不以“锁住未来可能插入的范围”为目标,因此通常不会因为普通 SELECT 而阻止会话 B 插入。
这说明:
“防止幻读”不能简单理解为“所有普通 SELECT 都会加范围锁”。
InnoDB 的普通一致性读通过快照获得稳定视图;范围锁主要出现在当前读、写操作以及某些约束检查路径中。
七、隔离级别如何影响间隙锁和一致性读
1. REPEATABLE READ
InnoDB 默认使用 REPEATABLE READ。主要特征是:
- 普通一致性读在事务内通常复用同一个 Read View;
- 当前读使用最新版本;
- 范围当前读和写操作通常使用 Next-Key Lock,以阻止范围内插入;
- 普通快照读不必通过加锁来保持结果稳定。
2. READ COMMITTED
READ COMMITTED 的主要特征是:
- 每次一致性读通常建立新的 Read View;
- 同一事务中可以看到其他事务后来提交的数据;
- InnoDB 通常减少间隙锁的使用,记录锁仍然用于保护被访问或修改的记录;
- 间隙锁仍可能用于外键约束检查和重复键检查等必要路径。
因此,不能表述为“READ COMMITTED 完全没有间隙锁”。更准确的说法是:普通范围访问下,InnoDB 通常不使用 REPEATABLE READ 那样的间隙锁策略,但约束维护仍可能需要间隙相关锁。
可以对当前会话设置隔离级别:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
该设置影响后续事务,不应在已经依赖某个快照的事务中途随意切换并据此推断行为。
3. SERIALIZABLE
SERIALIZABLE 会进一步限制并发读写。对于 InnoDB,普通读通常会被转换为更强的锁定读行为,从而显著增加阻塞和死锁风险。
它不等价于“性能更好地解决所有并发问题”,而是用更强的隔离换取更少的并发。
4. 隔离级别不是跨服务器复制的一致性协议
事务隔离级别只描述一个 MySQL 实例内、同一存储引擎事务之间的可见性和冲突规则。
它不能自动保证:
- 读副本立即看到主库刚提交的数据;
- 复制延迟期间的读写一致;
- 主从切换后客户端一定读到最新提交;
- 多个 MySQL 实例之间形成分布式串行化事务。
这些问题属于 Binlog、GTID、复制确认、路由和故障切换边界,不能由 Read View 或行锁单独解决。
八、为什么会死锁:等待图与循环依赖
1. 死锁的形式化条件
设事务集合为:
T = {T1, T2, ..., Tn}
建立等待图:
Ti -> Tj
表示事务 Ti 正在等待事务 Tj 持有的锁。
如果等待图中存在环:
T1 -> T2 -> ... -> T1
则这些事务形成死锁。没有环时可能只是锁等待;有环时,任何事务都无法仅靠继续执行打破等待链。
2. 最小死锁示例
创建测试表:
CREATE TABLE wallet (
id BIGINT PRIMARY KEY,
balance DECIMAL(12, 2) NOT NULL
) ENGINE = InnoDB;
INSERT INTO wallet (id, balance) VALUES
(1, 100.00),
(2, 100.00);
两个会话都关闭自动提交。
会话 A
START TRANSACTION;
UPDATE wallet
SET balance = balance - 10
WHERE id = 1;
会话 A 持有 id = 1 的排他锁。
会话 B
START TRANSACTION;
UPDATE wallet
SET balance = balance - 20
WHERE id = 2;
会话 B 持有 id = 2 的排他锁。
会话 A 再执行
UPDATE wallet
SET balance = balance + 10
WHERE id = 2;
会话 A 等待会话 B 释放 id = 2。
会话 B 再执行
UPDATE wallet
SET balance = balance + 20
WHERE id = 1;
会话 B 等待会话 A 释放 id = 1。
等待关系为:
A 等待 B 持有的 id=2
B 等待 A 持有的 id=1
形成环,InnoDB 的死锁检测器可以识别该死锁,并选择一个事务作为牺牲者回滚。被回滚的事务通常收到类似错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
另一个事务可能继续执行。
3. 死锁回滚的边界
死锁和普通锁等待超时不是同一件事:
- 死锁:InnoDB 通常主动检测并回滚一个事务;
- 锁等待超时:等待超过
innodb_lock_wait_timeout后,通常回滚当前语句,而不是自动回滚整个事务。
因此,应用必须区分:
1213:死锁
1205:锁等待超时
发生死锁后,事务通常已经不可继续按原逻辑使用,应用应回滚事务,并重试完整事务,而不是只重试其中一条 SQL。
4. 为什么“加锁顺序一致”能降低死锁
将上面的两个事务都改为先锁定较小的主键:
START TRANSACTION;
UPDATE wallet
SET balance = balance - 10
WHERE id = 1;
UPDATE wallet
SET balance = balance + 10
WHERE id = 2;
COMMIT;
另一个转账事务也始终按 id = 1、id = 2 的顺序执行。这样:
- 第一个事务先获得
id = 1; - 第二个事务如果也需要
id = 1,就在这里等待; - 第一个事务继续获得
id = 2并提交; - 第二个事务随后继续;
- 不会形成双方各持有一部分资源的循环等待。
这不能证明系统绝不会死锁,因为还可能有其他索引、表、外键或业务资源参与,但统一访问顺序能显著减少一种最常见的死锁来源。
5. 死锁也可能由间隙锁造成
死锁不只发生在两个明确的主键记录之间。范围查询、二级索引、插入意向锁和唯一键检查都可能参与等待图。
例如:
- 事务 A 锁定二级索引范围;
- 事务 B 持有另一条记录并准备插入该范围;
- A 又等待 B 的聚簇索引记录;
- B 同时等待 A 的间隙锁释放。
因此,死锁诊断不能只看 SQL 的 WHERE id = ...,还要看实际访问的索引和锁模式。
九、锁等待与死锁的诊断方法
1. 查看当前事务
SELECT *
FROM information_schema.innodb_trx;
重点关注:
- 事务开始时间;
- 事务状态;
- 是否正在等待;
- 已执行时间;
- 事务线程信息;
- 锁等待相关字段。
长时间运行的事务可能导致:
- 锁长期不释放;
- Undo 无法及时清理;
- Read View 长期存在;
- 表空间和磁盘占用增长。
2. 查看锁和等待关系
在支持相应 Performance Schema 表的 MySQL 8.x 环境中:
SELECT *
FROM performance_schema.data_locks;
SELECT *
FROM performance_schema.data_lock_waits;
data_locks 用于查看锁对象和锁模式,data_lock_waits 用于关联请求锁的事务与阻塞事务。
排查时需要把这些信息与事务、线程、SQL 文本关联起来。不同版本的字段名称和可用字段应以本机表结构为准:
DESCRIBE performance_schema.data_locks;
DESCRIBE performance_schema.data_lock_waits;
3. 查看最近一次死锁报告
SHOW ENGINE INNODB STATUS\G
其中的 LATEST DETECTED DEADLOCK 部分通常包含:
- 参与死锁的事务;
- 每个事务已经持有的锁;
- 每个事务正在等待的锁;
- 被回滚的事务;
- 相关索引和记录信息。
诊断时不要只记录“哪个 SQL 报错”,还应记录完整死锁报告,因为同一条 SQL 可能通过不同执行计划锁定不同范围。
4. 先确认执行计划
EXPLAIN
SELECT id, sku
FROM inventory
WHERE sku >= 'A' AND sku < 'D'
FOR UPDATE;
锁范围与执行计划密切相关:
- 使用合适的索引,通常只扫描并锁定较窄范围;
- 没有合适索引时,可能扫描更多记录;
- 锁定读的范围扩大后,阻塞和死锁概率也会上升。
“我只更新了一行”并不必然代表只访问了一条索引记录。需要结合 EXPLAIN、索引定义和锁报告判断实际行为。
十、唯一索引、普通索引与锁范围的差异
假设表中有:
PRIMARY KEY (id)
KEY idx_sku (sku)
1. 唯一索引精确匹配通常不需要锁定后续间隙
对于完整唯一索引等值条件:
SELECT *
FROM inventory
WHERE id = 1
FOR UPDATE;
如果该记录存在,InnoDB 通常只需要锁定匹配的唯一索引记录,而不必像普通范围扫描一样锁定记录前后的整个间隙。
这是一个常见优化,也是生产中“精确唯一查找”和“范围查找”锁行为差异的重要原因。
但必须满足“完整唯一索引等值匹配”这一条件。以下情况不能机械套用该结论:
- 只使用联合唯一索引的部分列;
- 使用范围条件;
- 通过普通非唯一索引查找;
- 查询条件无法形成唯一定位;
- 外键和重复键检查路径。
2. 普通索引通常锁定索引范围和对应主键记录
对非唯一二级索引进行范围当前读:
SELECT *
FROM inventory
WHERE sku = 'A'
FOR UPDATE;
即便 sku = 'A' 看起来是等值条件,如果 sku 不是唯一索引,仍可能有多条匹配记录,InnoDB 需要扫描和锁定相关二级索引记录,并访问对应聚簇索引记录。
因此,索引设计不仅影响查询速度,也影响:
- 锁定的索引数量;
- 锁定的范围;
- 锁等待持续时间;
- 死锁发生概率。
十一、常见误解与反例
误解一:REPEATABLE READ 下所有查询都看事务开始时的数据
不准确。
更准确的描述是:
- 第一次普通一致性读通常在该时刻建立 Read View;
- 后续普通一致性读复用该快照;
- 事务自己的修改可见;
FOR UPDATE、FOR SHARE、UPDATE、DELETE是当前读;- 当前读不按普通一致性读的历史快照返回数据。
反例:
START TRANSACTION;
UPDATE account
SET balance = balance + 10
WHERE id = 1;
SELECT balance
FROM account
WHERE id = 1;
当前事务自己的更新通常对自己可见。不能因为“事务使用 RR”就认为它只能看到事务开始前的旧值。
误解二:普通 SELECT 会自动锁住读到的行
通常不成立:
SELECT *
FROM account
WHERE id = 1;
这是普通一致性读,通常不加阻止其他事务修改的记录锁。
如果业务需要“读到后不允许其他事务修改”,应明确使用:
SELECT *
FROM account
WHERE id = 1
FOR UPDATE;
或者根据业务需要使用 FOR SHARE。
误解三:有了 FOR UPDATE,后续逻辑就一定安全
FOR UPDATE 只锁定数据库访问范围,不会自动保护事务外的业务操作。
例如:
1. 事务 A SELECT ... FOR UPDATE
2. 事务 A COMMIT
3. 事务 A 调用外部支付服务
4. 事务 A 再更新数据库
提交后锁已经释放,外部服务调用期间其他事务可以修改数据。如果业务要求数据库状态与外部副作用严格原子化,单靠 InnoDB 行锁无法完成,通常需要重新设计事务边界、幂等机制或可靠消息流程。
误解四:锁等待超时会自动回滚整个事务
通常不应如此假设。
锁等待超时常见错误为:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
InnoDB 通常回滚产生超时的当前语句,但事务中此前成功执行的语句可能仍然有效,锁也可能继续持有。应用应根据事务状态明确执行 ROLLBACK,不要继续把该事务当作干净状态使用。
误解五:死锁说明数据库有 bug
死锁是并发事务之间形成循环等待的结果,很多情况下是合法并发行为。数据库检测到死锁后选择回滚一个事务,是为了打破循环。
应用层通常需要:
- 捕获死锁错误;
- 回滚当前事务;
- 等待一个有限、带抖动的时间;
- 重试完整事务;
- 限制最大重试次数并记录日志。
重试单条 SQL 而不重建事务,可能导致事务中的业务步骤不完整。
十二、事务重试必须重试“完整事务”
下面是伪代码对应的正确边界:
for attempt in 1..max_attempts:
begin transaction
try:
按固定顺序读取并锁定资源
执行全部业务更新
commit
return success
catch deadlock or retryable lock error:
rollback
sleep(backoff_with_jitter)
continue
catch other error:
rollback
return failure
重试必须满足两个前提:
- 事务内操作具有幂等性,或通过唯一业务号防止重复处理;
- 外部副作用不能在事务重试中被重复执行,或者外部调用本身也具备幂等语义。
例如扣款业务不能简单地在数据库回滚后无条件重新调用扣款接口。数据库事务重试和外部系统重试是两个不同的可靠性问题。
十三、事务长度、索引和锁范围的因果关系
可以把一次锁定操作抽象为:
SQL
-> 执行计划
-> 访问索引记录和范围
-> 申请记录锁/间隙锁/表级意向锁
-> 与已有锁比较兼容性
-> 成功执行或进入等待
因此,以下因素会直接影响并发:
1. 事务越长,锁持有时间越长
START TRANSACTION;
UPDATE ...;
-- 长时间计算、网络调用、等待用户输入
UPDATE ...;
COMMIT;
中间的非数据库操作不会让 InnoDB 自动释放锁。只要事务没有结束,锁仍可能阻塞其他事务。
2. 缺少索引会扩大访问范围
如果更新条件没有合适索引:
UPDATE orders
SET status = 'paid'
WHERE user_id = 100
AND status = 'pending';
InnoDB 可能扫描大量记录,实际加锁范围和锁持续时间都可能比预期大。合适的联合索引可以缩小扫描范围,但索引是否有效仍应通过 EXPLAIN 验证。
3. 不同事务使用不同索引路径,可能产生不同锁顺序
事务 A 通过 idx_user 找记录,事务 B 通过 idx_status 找记录,它们最终可能都要锁定二级索引和聚簇索引记录,但顺序不同,从而形成死锁。
所以“业务上更新的是同一批行”并不足以判断死锁风险,还需要观察:
- SQL 条件;
- 索引;
- 执行计划;
- 访问顺序;
- 外键和唯一键检查。
十四、Read View、锁与幻读之间的关系
“幻读”通常指同一事务对一个范围执行查询时,后一次查询出现了前一次查询没有的、符合条件的新记录。
InnoDB 有两种不同的处理路径:
1. 普通一致性读通过快照保持读视图稳定
在 REPEATABLE READ 下:
START TRANSACTION;
SELECT COUNT(*)
FROM orders
WHERE amount >= 100;
后续另一个事务插入并提交一条 amount >= 100 的记录时,当前事务再次执行同样的普通 SELECT,通常仍使用旧 Read View,因此不会看到这条新记录。
这是快照层面的稳定,不是因为第一个 SELECT 给未来插入加了范围锁。
2. 当前读通过范围锁阻止并发插入
如果执行:
START TRANSACTION;
SELECT *
FROM orders
WHERE amount >= 100
FOR UPDATE;
InnoDB 需要对当前范围加锁。其他事务在被锁定范围内插入符合条件的记录可能等待,直到锁释放。
因此:
- 一致性读主要依赖 MVCC 和 Read View;
- 当前读的范围稳定性主要依赖锁;
- 两者都可以避免某些表象上的幻读,但机制不同。
3. RR 并不等于完整串行化
即使普通范围读在快照上稳定,多个事务之间仍可能出现业务层面的写偏差。例如两个医生事务分别读取“至少一名医生值班”,都看到快照中有另一名医生,于是各自把自己标记为休假,最后可能无人值班。
这类问题不是简单的单行冲突,而是跨行约束。需要通过:
- 锁定代表性范围或汇总记录;
- 提高隔离级别;
- 使用显式约束或串行化设计;
来保护业务不变量。
十五、锁的生命周期和回滚路径
1. COMMIT
提交后:
- 当前事务的修改对后续可见;
- 通常释放该事务持有的锁;
- 其他等待事务可能继续执行;
- Undo 版本不会立刻全部删除,而是等待 Purge 判断是否仍被快照需要。
2. ROLLBACK
回滚后:
- 当前事务的数据修改撤销;
- 当前事务的锁释放;
- 等待事务可能继续执行。
如果事务因死锁被选为牺牲者,InnoDB 会回滚该事务。应用仍应正确清理连接上的事务状态。
3. 连接异常和会话终止
如果客户端连接断开,服务器通常会清理对应会话和事务,回滚未提交修改并释放锁。但不能把“依赖客户端断开来释放锁”当作正常控制流程;连接池、网络故障和异常清理会使问题更难诊断。
十六、与 Buffer Pool、Binlog 和复制的边界
1. Buffer Pool 不是锁,也不是 Read View
Buffer Pool 缓存 InnoDB 页,减少磁盘 I/O。它影响访问性能,但不决定某个事务版本是否可见。
- Read View 决定版本可见性;
- 锁决定并发冲突;
- Buffer Pool 决定数据页是否更可能从内存访问。
三者属于不同层次。
2. 事务提交与 Binlog 持久化是不同协调点
事务提交可能同时涉及:
- InnoDB redo;
- Binlog;
- 存储引擎提交;
- 复制发送和确认。
具体持久化和崩溃恢复行为受配置影响,例如 innodb_flush_log_at_trx_commit、sync_binlog 以及复制模式。不要把“当前事务已经释放行锁”直接等同为“所有副本都已经应用该事务”。
3. 副本上的一致性读有额外延迟边界
在主库提交后,副本可能尚未执行对应事务。客户端若随后切换到副本读取,看到旧数据可能是复制延迟,而不是 Read View 错误。
因此需要区分:
主库内部事务快照问题
vs.
主副本之间的复制可见性问题
GTID、半同步复制和故障切换策略可以改善部署级一致性,但不能改变单个 InnoDB 事务内部的锁和 Read View 规则。
十七、一个可复现实验:同时观察快照读、当前读和锁等待
准备数据:
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE = InnoDB;
INSERT INTO account (id, balance) VALUES (1, 100);
假设两个会话均为 REPEATABLE READ,且使用显式事务。
会话 A:未提交更新
START TRANSACTION;
UPDATE account
SET balance = 80
WHERE id = 1;
此时会话 A 持有 id = 1 的排他锁,但没有提交。
会话 B:普通一致性读
START TRANSACTION;
SELECT balance
FROM account
WHERE id = 1;
预期通常返回:
100
原因是:
- 会话 A 的
80属于未提交版本; - 会话 B 的 Read View 不认为该版本可见;
- 会话 B 沿 Undo 链读取已提交的
100; - 普通读不需要等待会话 A 提交。
会话 B:当前读
SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;
这次通常会等待会话 A,因为:
FOR UPDATE需要当前读;- 会话 A 持有该记录的排他锁;
- 会话 B 不能按照旧快照直接完成锁定读;
- 会话 A 执行
COMMIT或ROLLBACK后,会话 B 才能继续; - 如果 A 提交了更新,会话 B 通常读取到
80。
会话 A 提交
COMMIT;
该实验展示了最重要的区别:
普通 SELECT:可能读旧版本,不等待
SELECT ... FOR UPDATE:读当前版本,需要等待冲突锁
十八、生产排查时应如何从现象推导原因
遇到“数据库变慢”或“某条 SQL 卡住”时,可以按因果链排查:
第一步:确认是否真的在等待锁
查看当前活动事务和锁等待,而不是仅凭 SQL 执行时间猜测:
SELECT *
FROM information_schema.innodb_trx;
SELECT *
FROM performance_schema.data_lock_waits;
第二步:确认阻塞者是否是长事务
重点查看:
- 事务开始时间;
- 最后一次 SQL;
- 是否处于空闲但未提交状态;
- 是否在等待外部调用;
- 是否来自连接池连接泄漏。
第三步:确认锁住的是记录还是范围
结合:
SHOW ENGINE INNODB STATUS\G
以及:
SELECT *
FROM performance_schema.data_locks;
判断是否涉及:
- 主键记录;
- 二级索引记录;
- Gap;
- Next-Key;
- 插入意向锁;
- 外键或唯一键检查。
第四步:检查执行计划和索引
EXPLAIN SELECT ...;
SHOW CREATE TABLE your_table\G
如果实际扫描范围远大于业务预期,应先解决访问路径和事务边界,而不是盲目调大锁等待超时。
第五步:区分死锁与超时
- 死锁通常是确定性的循环依赖,需要分析锁顺序;
- 超时可能只是单向阻塞,也可能是长事务、慢 SQL 或异常连接造成;
- 调大
innodb_lock_wait_timeout只能延后报错,不能消除冲突。
结语
InnoDB 的事务并发机制可以沿着三条线理解:
数据版本链 + Read View
-> 决定一致性读能看到哪个版本
索引记录 + 间隙 + Next-Key Lock
-> 决定当前读和写会锁定什么范围
事务等待图
-> 决定锁冲突是否只是等待,还是形成死锁
普通一致性读主要依赖 MVCC,不必为了读取旧版本而等待写锁;FOR UPDATE、FOR SHARE 和 DML 属于当前读,需要面对当前锁状态。间隙锁保护的不是已经存在的某一行,而是索引范围中未来可能出现的记录。死锁则不是简单的“锁太多”,而是事务之间形成了循环等待。
真正准确地判断一次并发行为,需要同时看:
- 隔离级别;
- 是否为一致性读或当前读;
- Read View 的创建时机;
- Undo 版本链;
- 实际使用的索引;
- 记录锁和间隙锁范围;
- 事务提交边界;
- 是否存在复制或部署层的额外延迟。
只记住“RR 不可重复读”“FOR UPDATE 会加锁”这些结论还不够。只有把版本可见性、索引范围和事务等待关系连接起来,才能解释具体 SQL 为什么读到旧值、为什么插入被阻塞,以及为什么两个看似无关的更新最终会互相死锁。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MySQL InnoDB 存储与索引:页、聚簇索引、回表和 Buffer Pool
- 下一篇:MySQL 查询优化:EXPLAIN、统计信息、Join、排序和慢日志
- 延伸:MySQL 复制与高可用:Binlog、GTID、半同步、切换与一致性
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论