数据库基础体系 · 第 16/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MySQL 事务与锁:Read View、间隙锁、死锁和一致性读

事务和锁解决的是两个不同但相互关联的问题:

  • 事务定义一组操作的提交、回滚和隔离边界。
  • 限制并发事务对某些数据或索引范围的访问。
  • MVCC通过保存旧版本,让一部分读操作不必等待写操作。
  • Read View定义一次一致性读能够看到哪些事务版本。
  • 一致性读通常读取快照,而不是读取当前最新版本。
  • 当前读则读取最新版本,并可能加锁。

本文以 MySQL 8.4、InnoDB、单实例部署为主要边界。除非特别说明,示例使用默认的 REPEATABLE READ 隔离级别。


一、先建立几个边界:事务、存储引擎与读类型

1. 事务边界由 COMMITROLLBACK 和自动提交决定

默认情况下,MySQL 通常开启自动提交:

SELECT @@autocommit;

autocommit = 1 时,一条独立的 INSERTUPDATEDELETE 或普通查询通常就是一个事务。语句成功结束后,事务立即提交;语句失败时,通常回滚该语句产生的影响。

显式事务则需要明确边界:

START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

UPDATE account
SET balance = balance + 100
WHERE id = 2;

COMMIT;

如果第二条语句失败,应用可以执行:

ROLLBACK;

事务边界很重要,因为锁通常至少持有到当前事务结束。若开启自动提交,单条语句加的锁可能在语句结束时就释放;若显式开启事务,锁通常会持续到 COMMITROLLBACK

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 链中保留 80100。一个较早创建的 Read View 可能沿着版本链回退,找到它有权看到的 80100

旧版本不是永久保存的。事务提交后,如果已经没有任何活跃 Read View 需要它,后台 Purge 线程最终可以清理相关 Undo。长事务或长时间未释放的快照会阻碍清理,导致历史版本和 Undo 占用增加。

2. MVCC 不等于“所有读都不加锁”

InnoDB 的读操作至少要区分两类:

读类型 典型语句 主要行为
一致性读 普通 SELECT 根据 Read View 读取可见版本,通常不加记录锁
当前读 SELECT ... FOR UPDATESELECT ... FOR SHAREUPDATEDELETE 读取当前版本,通常加锁或参与锁检查

例如:

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 的上界。

可以用如下规则描述一般可见性:

  1. 如果 v = c,通常认为该版本属于当前事务自己的修改,因此可见。
  2. 如果 v < L,说明该版本对应的事务在快照形成前已经完成,版本可见。
  3. 如果 v >= U,说明该事务在快照形成时尚未完成,版本不可见。
  4. 如果 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}

对当前版本逐个判断:

  1. 版本 50 的事务 ID 是 30
  2. 30 在活跃集合 A 中;
  3. 因此 50T40 不可见;
  4. 沿 Undo 链回退到 80
  5. 事务 ID 20 也在 A 中;
  6. 因此 80 仍不可见;
  7. 继续回退到 100
  8. 事务 ID 10 在 Read View 创建前已提交,因此 100 可见。

所以 T40 的普通 SELECT 返回:

balance = 100

如果后来 T20T30 都提交,T40REPEATABLE READ 下再次执行普通 SELECT,仍可能返回 100,因为它复用了旧 Read View。

但如果 T40 执行:

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

这是当前读,不按旧 Read View 返回历史版本,而是等待必要的锁后读取最新版本。此时它可能读取到:

balance = 50

具体能否立即读取,取决于 T20T30 是否已经提交以及锁是否释放。


四、一致性读与当前读的差异

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;

它的语义是:

  1. 查找满足条件的当前记录;
  2. 等待与其他事务冲突的锁;
  3. 锁定记录及必要的索引范围;
  4. 返回当前版本;
  5. 在事务结束前保留锁。

它适用于“读取后必须基于该值修改”的场景,例如扣减库存:

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,也阻止在 1020 之间插入新记录。

REPEATABLE READ 下,InnoDB 的范围锁定通常使用 Next-Key Lock,以抑制范围内的并发插入。

4. 插入意向锁 Insert Intention Lock

事务准备插入某个索引位置时,会先获得插入意向锁。它表达的是:

我准备在这个间隙中的某个具体位置插入记录。

例如两个事务分别向同一间隙插入 1215,它们之间不一定互相阻塞,因为最终插入位置不同。但如果该间隙已经被另一个事务的间隙锁或 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 = 1id = 2 的顺序执行。这样:

  1. 第一个事务先获得 id = 1
  2. 第二个事务如果也需要 id = 1,就在这里等待;
  3. 第一个事务继续获得 id = 2 并提交;
  4. 第二个事务随后继续;
  5. 不会形成双方各持有一部分资源的循环等待。

这不能证明系统绝不会死锁,因为还可能有其他索引、表、外键或业务资源参与,但统一访问顺序能显著减少一种最常见的死锁来源。

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 UPDATEFOR SHAREUPDATEDELETE 是当前读;
  • 当前读不按普通一致性读的历史快照返回数据。

反例:

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

死锁是并发事务之间形成循环等待的结果,很多情况下是合法并发行为。数据库检测到死锁后选择回滚一个事务,是为了打破循环。

应用层通常需要:

  1. 捕获死锁错误;
  2. 回滚当前事务;
  3. 等待一个有限、带抖动的时间;
  4. 重试完整事务;
  5. 限制最大重试次数并记录日志。

重试单条 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_commitsync_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

原因是:

  1. 会话 A 的 80 属于未提交版本;
  2. 会话 B 的 Read View 不认为该版本可见;
  3. 会话 B 沿 Undo 链读取已提交的 100
  4. 普通读不需要等待会话 A 提交。

会话 B:当前读

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

这次通常会等待会话 A,因为:

  1. FOR UPDATE 需要当前读;
  2. 会话 A 持有该记录的排他锁;
  3. 会话 B 不能按照旧快照直接完成锁定读;
  4. 会话 A 执行 COMMITROLLBACK 后,会话 B 才能继续;
  5. 如果 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 UPDATEFOR SHARE 和 DML 属于当前读,需要面对当前锁状态。间隙锁保护的不是已经存在的某一行,而是索引范围中未来可能出现的记录。死锁则不是简单的“锁太多”,而是事务之间形成了循环等待。

真正准确地判断一次并发行为,需要同时看:

  • 隔离级别;
  • 是否为一致性读或当前读;
  • Read View 的创建时机;
  • Undo 版本链;
  • 实际使用的索引;
  • 记录锁和间隙锁范围;
  • 事务提交边界;
  • 是否存在复制或部署层的额外延迟。

只记住“RR 不可重复读”“FOR UPDATE 会加锁”这些结论还不够。只有把版本可见性、索引范围和事务等待关系连接起来,才能解释具体 SQL 为什么读到旧值、为什么插入被阻塞,以及为什么两个看似无关的更新最终会互相死锁。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。