12-MySQL事务MVCC与锁

O泡李华 6

事务之间怎样相处?MVCC、锁等待与死锁恢复

同一个事务里读取可能看到旧值,更新却作用于新值;没有查到记录,也可能阻止其他会话插入。理解这些现象,需要把版本读取与锁定访问分开,不能只背可重复读的定义。

具体 SQL 观察沿用原有 MySQL 8.0.29 示例。不同实验先执行自己的数据准备,执行计划和锁行为不被推广成所有版本或负载的保证。

1. 历史版本怎样决定读取结果

可见性、快照读与当前读建立第一条主线。

MVCC 让读取能够选择历史版本

MVCC 是多版本并发控制。InnoDB 记录携带与事务、回滚信息有关的内部字段;读取时,可以结合 undo 信息追溯合适的历史版本,而不是总把最新内容直接交给所有事务。

但“有历史版本”还不够,需要一套可见性规则决定本次读取应该看到哪个版本。Read View 用来描述一致性读取时的可见性边界。

MVCC 主要解释一致性非锁定读的版本选择,不能解释成“数据库不再需要锁”。写入和锁定读取仍然会使用锁,也可能等待或发生死锁。InnoDB 多版本机制

快照读与当前读有什么区别?

在本文使用的 READ COMMITTED、REPEATABLE READ 场景中,普通 SELECT 通常执行一致性非锁定读取,也常被称为快照读。它依据可见性规则读取版本,不会因为另一事务改了数据就一律等待它结束。

SELECT ... FOR UPDATE 是锁定读取,通常归入“当前读”的讨论。它读取适合锁定操作的当前记录状态并加锁;遇到其他事务未完成的冲突写入时,可能等待。UPDATE、DELETE 也不能简单按普通 SELECT 的历史快照执行。InnoDB 锁定读取

“当前读就是读最新值”容易被误解。它不是允许读取别人的任意未提交值,而是需要结合锁与提交状态协调。本文在另一事务已经提交后观察当前读,避免把锁等待与版本变化混在一起。

Read View 如何判断版本可见?

可以用下面的简化规则理解它,不需要一开始就记住源码字段名:

  1. 当前事务自己的修改通常对自己可见。
  2. 版本的事务 id 如果早于视图建立时最小的活跃事务边界,可以作为较早已完成的版本考虑。
  3. 如果版本事务 id 已经达到或超过视图建立时“下一事务 id”的边界,它属于视图之后的事务,对该视图不可见。
  4. 落在中间区间的版本,需要判断其事务是否在视图建立时仍然活跃;活跃事务的版本不可见,否则可以可见。

不可见时,读取沿历史版本查找。这里讨论的是选择版本的思路;真实实现还要处理事务 id 分配、只读事务和记录状态等细节,不能把“事务开始时间”直接等同于固定事务 id。

尤其要分清“现在已经提交”与“这个视图建立时是否可见”。某个事务在视图建立时仍活跃,即使后来提交,旧视图也不会因此自动更新。

RR 与 RC 的主要差别是读取视图的复用

隔离级别 本文普通一致性查询的行为
READ COMMITTED,简称 RC 每次一致性读取使用新的快照,后续语句可以看到其开始前的新提交
REPEATABLE READ,简称 RR 同一事务的普通一致性读取通常复用首次这类读取建立的快照

因此,RR 下执行 START TRANSACTION,通常并不立即建立普通查询要使用的快照。首次一致性读取发生之前,别的事务提交的变化,可能出现在第一次查询中。

RR 下可以通过 START TRANSACTION WITH CONSISTENT SNAPSHOT 显式建立一致性快照。这个修饰的效果与隔离级别有关,不能直接照搬为所有级别的同一行为。一致性非锁定读

2. 两个独立会话观察隔离行为

保留准备步骤和各会话顺序,避免把顺序执行当成并发。

准备两个真正独立的会话

打开两个 MySQL 客户端连接,分别称为会话 A、会话 B。不能在同一个客户端标签里来回执行并把它当成两个事务。

先在任一连接中初始化新的实验库:

CREATE DATABASE blog_mvcc_lab;
USE blog_mvcc_lab;
CREATE TABLE account (
 id INT PRIMARY KEY,
 balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES(1,100);

同名库已存在时,应选择新库名,不要混用已有业务数据。然后在 A、B 两个连接中分别执行:

USE blog_mvcc_lab;
SET autocommit=1;
SELECT CONNECTION_ID();

两个 CONNECTION_ID 应不同。下面按先后顺序操作,B 在明确写 START TRANSACTION 之前都使用自动提交,因此它的 UPDATE 执行完成就提交。

实验一:BEGIN 后的第一次读取看到什么?

第一步,会话 A 开启 RR 事务,暂时不查询表:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

第二步,会话 B 修改并自动提交:

UPDATE account SET balance=110 WHERE id=1;

第三步,会话 A 首次普通查询:

SELECT balance FROM account WHERE id=1;

结果为 110。这说明不能把 RR 的普通读取快照无条件说成“BEGIN 时的全部数据”。

第四步,会话 B 再次修改并自动提交:

UPDATE account SET balance=120 WHERE id=1;

第五步,会话 A 连续执行:

SELECT balance FROM account WHERE id=1;
SELECT balance FROM account WHERE id=1 FOR UPDATE;
SELECT balance FROM account WHERE id=1;
UPDATE account SET balance=balance+1 WHERE id=1;
SELECT balance FROM account WHERE id=1;
COMMIT;

对应结果如下:

操作 结果 原因
普通 SELECT 110 复用已建立的一致性读取快照
SELECT FOR UPDATE 120 锁定读取看到 B 已提交的新版本
再次普通 SELECT 110 锁定读取没有把普通读取快照刷新成最新视图
UPDATE balance=balance+1 更新 1 行 基于可更新的当前记录执行加法
更新后的普通 SELECT 121 当前事务能够看到自己的修改

这里尤其容易回答错:UPDATE 并没有拿首次普通 SELECT 的 110 加 1 得到 111,而是在当前记录 120 的基础上得到 121。

如果应用把旧查询结果 110 存进变量,随后执行 UPDATE ... SET balance=111,则是另一条业务逻辑,可能覆盖其他修改。不要把数据库的原子增量更新与应用侧“先读再覆盖”混为一谈。

实验二:RC 的下一次查询看到新提交

前一实验结束后,余额为 121。先在会话 A 执行:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id=1;

第一次查询结果为 121。再在会话 B 执行:

UPDATE account SET balance=130 WHERE id=1;

然后回到会话 A:

SELECT balance FROM account WHERE id=1;
COMMIT;

第二次结果为 130。两次一致性读取使用新的快照,可以读到语句之间的新提交。这种不可重复读是该隔离级别允许的行为,不是 MyBatis 映射错误。

做数据库机制实验时,直接使用两个客户端连接更容易控制变量。如果改为应用代码,还要留意 ORM 或 MyBatis 会话缓存是否让第二次调用根本没有执行 SQL。

实验三:显式快照与未提交版本

先在会话 A 执行:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT;

在会话 B 修改并自动提交:

UPDATE account SET balance=140 WHERE id=1;

回到会话 A:

SELECT balance FROM account WHERE id=1;
COMMIT;

结果为 130,因为本次 RR 的一致性快照已显式建立。这与实验一“先 BEGIN,再由 B 提交,再首次普通查询”的结果不同。

接着,在会话 B 开启事务并写入但不提交:

START TRANSACTION;
UPDATE account SET balance=999 WHERE id=1;

会话 A 开启新的 RR 事务并普通查询:

START TRANSACTION;
SELECT balance FROM account WHERE id=1;
COMMIT;

结果为 140,没有读到未提交的 999。最后会话 B 回滚:

ROLLBACK;

如果把 A 的读取改成 FOR UPDATE,行为就涉及 B 的未提交写锁,可能等待;不能再套用这个普通快照读的立即返回结果。

为什么“可重复读”不是所有语句都看到相同内容?

RR 的普通一致性读取、锁定读取和本事务写入具有不同语义。实验一已经展示,同一事务中普通 SELECT 读到 110,锁定读取读到 120,自己更新后读到 121。

本事务自己的修改可以与历史快照里的其他记录同时出现在结果中。因此,不能把每一次查询结果都理解成“数据库在某个真实时刻的完整照片”。

同样,“RR 完全没有幻读”需要说明对象。复用快照可以让重复普通查询避开其他事务的新插入;锁定范围读取则涉及索引、间隙锁和 next-key 锁。混合读取与写入时更不能直接套用一个结论。InnoDB 隔离级别

对于“至少保留一名值班人员”等跨记录约束,两个事务分别读取条件并修改不同记录,也不一定仅靠 RR 就保证业务约束。需要选择合适的约束、锁定方案或更强的事务策略。

长事务为什么影响历史版本回收?

历史版本要能支持仍然存在的一致性读取。长期持有快照的事务,可能延缓相关 undo 历史的清理,增加资源压力。

排查这类问题时,应结合活动事务、事务持续时间与实际 undo 清理状态,不是简单看到长事务就断言数据库一定内存泄漏。应用中也应避免让用户交互、长时间外部请求或大批量处理无必要地包在一个事务里。

本文验证了版本读取结果,没有采集 undo 内部结构或进行长期资源压力测试,因此不提供虚构的空间增长数据。

3. 锁不仅作用于返回的记录

记录、间隙与访问路径解释插入为什么等待。

InnoDB 锁住的对象是什么?

类型 主要保护对象 需要分清的边界
记录锁 索引记录 常说的行锁,实际与访问的索引记录有关
间隙锁 索引记录之间的区间 主要阻止符合区间的插入,不是锁住一条已经存在的行
next-key 锁 索引记录与其前面的间隙 常用于 RR 下的范围锁定
意向锁 表级的加锁意向 IX 不意味着整张表的所有记录都被排他锁住

S 锁通常表示共享,X 锁通常表示排他。记录锁上的 S 与 X 有冲突规则;纯间隙锁的目的和兼容关系又不同,不能只看到两个锁都标有 X 就套用记录锁结论。InnoDB 锁说明

锁范围与实际访问路径有关。二级索引访问还可能涉及相应主键记录;没有合适索引时,扫描和锁定的范围可能比业务想象的大。不能仅凭 WHERE 返回一条记录,就断言只锁一条索引记录。

准备实验

在新的实验库中执行:

CREATE DATABASE blog_lock_lab;
USE blog_lock_lab;
CREATE TABLE stock (
 id INT PRIMARY KEY,
 qty INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO stock VALUES(10,10),(20,10),(30,10);

打开两个独立客户端连接 A、B,各执行:

USE blog_lock_lab;
SET autocommit=1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION innodb_lock_wait_timeout=2;

2 秒等待上限只为了让实验及时返回。每组实验结束都显式提交或回滚,避免前一组的锁影响后一组。已有同名库时改用新的实验库名。

唯一键等值查询:存在的记录与附近插入

会话 A 执行并保持事务:

START TRANSACTION;
SELECT * FROM stock WHERE id=20 FOR UPDATE;

这里使用完整唯一主键条件,并且 id=20 存在。会话 B 执行:

INSERT INTO stock VALUES(25,10);
DELETE FROM stock WHERE id=25;
START TRANSACTION;
UPDATE stock SET qty=qty-1 WHERE id=20;

插入 25 成功,随后清理这条实验记录;更新 20 则等待,最终返回 1205:Lock wait timeout exceeded。这说明本例的现存唯一键等值锁定,没有把附近插入也全部挡住。

超时后在 B 执行,再在 A 执行:

ROLLBACK;

不要把这个结论推广到唯一键查找不存在的记录、非完整联合唯一键条件或范围查询。它们可能有不同的间隙锁行为。

RR 下空范围也能阻止插入

会话 A 开启新的事务:

START TRANSACTION;
SELECT * FROM stock WHERE id>10 AND id<20 FOR UPDATE;

结果为空,因为当前只有 10、20、30。会话 B 执行:

START TRANSACTION;
INSERT INTO stock VALUES(15,10);

插入 15 等待,最终返回 1205。锁定读取虽然没有返回记录,仍然保护了相关索引间隙。

在 B 回滚失败事务,然后执行:

ROLLBACK;
UPDATE stock SET qty=qty+1 WHERE id=30;

更新 30 成功。本例间隙保护并不是“整张表都不能写”。最后在 A 执行 ROLLBACK。

如果有第三个观察连接与相应权限,可以在 A 持锁时查看:

SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA='blog_lock_lab';

本次观察到 TABLE 的 IX,以及 PRIMARY 上 X,GAP、LOCK_DATA 为 20 的记录。后者描述 20 前面的相关间隙,并不意味着业务正在排他修改 id=20 的值。具体显示会随查询形态、数据和版本变化。data_locks 文档

RC 下同一范围的区别

在 A 中设置 RC,并执行相同的空范围锁定:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM stock WHERE id>10 AND id<20 FOR UPDATE;

会话 B 执行:

INSERT INTO stock VALUES(15,10);

这次插入成功。A 随后 ROLLBACK;B 再执行 DELETE FROM stock WHERE id=15 清理记录。

RC 通常减少搜索和索引扫描中的间隙锁使用,但外键检查、重复键检查等仍可能使用相关锁。不能把 RC 概括成“没有任何间隙锁”,也不能仅为了消除等待就忽略业务依赖的隔离语义。

4. 死锁检测以后业务怎样恢复

两条记录就能构成循环等待,恢复必须回到事务语义。

两条记录也能组成死锁

先按下面顺序执行;本组不依赖上一组余额,只需要 id=10、30 存在。

会话 A:

START TRANSACTION;
UPDATE stock SET qty=qty-1 WHERE id=10;

会话 B:

START TRANSACTION;
UPDATE stock SET qty=qty-1 WHERE id=30;

然后 A 执行,这条语句暂时等待,不要取消:

UPDATE stock SET qty=qty-1 WHERE id=30;

趁 A 等待,在 B 执行:

UPDATE stock SET qty=qty-1 WHERE id=10;

等待关系形成闭环:A 持有 10 等待 30,B 持有 30 等待 10。开启死锁检测时,InnoDB 选择一个事务回滚,让另一个继续。本次实验 B 返回 1213:Deadlock found,A 的等待更新完成;受害者不是固定保证。

实验结束后,两个会话都执行 ROLLBACK,避免把这组演示修改保留。观察死锁信息:

SHOW ENGINE INNODB STATUS;

本次输出包含 LATEST DETECTED DEADLOCK,可以检查双方语句、持有锁与等待锁。它是最近一次死锁信息,高并发环境中可能很快被另一事件替换。死锁文档

1205 与 1213 怎么处理?

死锁检测后的 1213 会让受害事务回滚。锁等待超时 1205 在默认相关配置下通常只回滚当前语句,不能当成“整个事务一定已经消失”。应用应明确回滚,避免继续提交之前的部分修改。

重试应从完整业务事务边界重新开始,并设置次数、退避和错误范围。事务中已经发出的外部通知不会被数据库回滚,重试前还要处理幂等与外部副作用。

减少死锁可以从固定记录访问顺序、缩短事务、减少不必要扫描和检查索引开始。例如多个库存项始终按主键排序更新,比各请求按随机顺序加锁更容易避免闭环。它能降低风险,不是对所有死锁的绝对消除。

5. 设计判断与适用边界

数据库隔离是读写规则,不是取消业务竞争。先读余额再在应用层判断,可能仍需要条件更新、锁定读取或版本控制。事务保持短小,访问多对象时尽量统一顺序,并在失败后重新检查业务前提。

锁等待超时和死锁是不同结果。重试应在明确回滚或新事务边界上进行,限制次数并保证副作用安全。把远程请求放进长事务,会延长历史版本与锁的保留,可能让吞吐和恢复同时恶化。

6. 问答

问:RR 的 Read View 一定在 BEGIN 时建立吗?

普通事务通常在首次一致性读取时建立。RR 下显式 WITH CONSISTENT SNAPSHOT 可以改变建立时机。

问:快照读与当前读分别是什么?

本文 RC、RR 下普通 SELECT 依据可见性规则读取历史版本;FOR UPDATE 属于锁定读取,涉及当前记录状态和锁。不能把它理解成读取其他事务的任意未提交内容。

问:查不到记录,就没有锁吗?

不一定。RR 下的范围锁定可以保护间隙,本文空范围挡住了插入 15。

问:行锁一定只锁 WHERE 返回的行吗?

不能这样保证。实际锁与索引访问、扫描范围、隔离级别有关,需要结合执行计划和锁信息分析。

参考资料