MySQL并发安全_锁与MVCC机制

从计算机科学的视角来看,数据库管理系统本质上是一个维护 ACID 特性的状态机。在单线程模型下,状态转换是确定的。然而,一旦引入并发,系统便陷入了混乱。

InnoDB 存储引擎的并发模块主要目的就是 在并发吞吐量与数据一致性之间寻找平衡。这是一个零和博弈——隔离性越高,并发度就越低

并发带来的问题

当多个事务同时操作同一份数据时,如果没有合适的隔离机制,可能会出现以下三种一致性问题:

  • 脏读:一个事务 A 读取到了另一个事务 B 尚未提交 的数据。如果这个事务 B 最终回滚了,那么事务 A 基于读取到的数据的操作就会导致错误的结果。
  • 不可重复读:同一个事务内,多次读取同一个数据,得到的结果却不一致(可能是其它事务修改了数据)。
  • 幻读(Phantom Read):同一个事务内,多次执行同一个查询条件的 SQL,结果集的行数不一致(可能是其它事务插入/删除了数据)。

为了解决这些问题,SQL 标准定义了四种隔离级别(RU, RC, RR, Serializable)。

这四种事务隔离级别,级别越高,数据一致性越好,但并发性能越低。

  1. READ UNCOMMITTED(读未提交):允许读取其他事务未提交的数据。极少使用,仅适用于对数据一致性要求极低、追求极致并发性能的场景(如 日志统计)。脏读、不可重复读、幻读都可能发生。
  2. READ COMMITTED(读已提交):只能读取其他事务已提交的数据,无法读取未提交的数据。大多数应用的常用级别(Oracle、SQL Server 默认隔离级别),平衡了一致性和性能。避免了脏读。但仍可能发生不可重复读和幻读。
  3. REPEATABLE READ(可重复读)MySQL 默认隔离级别。同一个事务内多次读取同一数据,结果始终一致(即使其他事务修改并提交了该数据)。避免了脏读、不可重复读。理论上仍可能出现幻读,但 MySQL 的 InnoDB 存储引擎通过 间隙锁(Next-Key Lock) 解决了幻读问题,所以 在 InnoDB 中该级别无幻读
  4. SERIALIZABLE(串行化):所有事务串行执行。避免了所有并发问题(脏读、不可重复读、幻读)。并发性能极低(相当于单线程),容易出现事务超时、死锁。极少使用,仅适用于对数据一致性要求极高、并发量极低的场景(如 金融核心交易)。

标准只是规范InnoDB 是如何落地的

核心依赖两大基石:MVCC(多版本并发控制)锁机制

简单来说:MVCC 解决了“读-写”冲突(无锁并发),而锁解决了“写-写”冲突。

MVCC

在传统锁定协议中,读操作会阻塞写操作。MVCC 引入了“快照读”的概念,通过保留数据的历史版本,实现 读写不阻塞

Undo Log 版本链

InnoDB 表中的每一行记录,除了用户数据外,还包含三个隐藏列:

  • DB_TRX_ID:最后一次修改该行的 事务 ID
  • DB_ROLL_PTR:回滚指针,指向 Undo Log 中该行的 上一个版本
  • DB_ROW_ID:隐藏主键(若无显式主键时生成)。

每当数据被修改,旧版本会被“搬”到 Undo Log 中。DB_ROLL_PTR 将这些历史版本串联成一个单向链表。

Undo Log 会无限膨胀吗?

不会。InnoDB 有后台 Purge 线程。当系统中最老的事务都不再需要某个版本(即没有 Read View 引用它)时,Purge 线程会物理删除这些废弃的 Undo Log,以此回收空间。

Read View(一致性视图)

Read View 是事务进行快照读时生成的“可见性判据”。它记录了 生成时刻 系统中所有 活跃(未提交) 的事务 ID。

核心字段:

  • m_ids:活跃事务 ID 列表。
  • min_trx_idm_ids 中的最小值(低水位)。
  • max_trx_id:系统当前最大事务 ID + 1(高水位)。
  • creator_trx_id:当前事务自己的 ID。

RC 级别 下每条查询语句执行前都会重新创建一个 Read View,确保读取到的是最新的已提交数据(语句级快照)。RR 级别 下仅在事务启动后的第一次查询时创建 Read View,整个事务期间复用,从而实现可重复读(事务级快照)。

可见性算法

当事务读取某行数据时,会拿该行当前的 DB_TRX_ID 与 Read View 进行比对:

  1. 落在低水位之前 (TRX_ID < min):说明修改该行的事务早已提交,可见
  2. 落在高水位之后 (TRX_ID >= max):说明是未来的事务修改的,不可见
  3. 落在水位之间
    • TRX_IDm_ids 列表中:说明事务还没提交,不可见(除非是自己改的)。
    • 若不在列表里:说明事务已提交,可见

如果当前版本不可见,就顺着 Undo Log 链表找上一个版本,直到找到可见版本为止。

一句话:只能看到比我老的事务提交的数据和我自己修改的数据。看不到比我新的事务。

锁机制

MVCC 搞定了读,但写入时必须加锁。InnoDB 的锁设计非常精妙。

锁的分类

MySQL 中的锁可以从 粒度、兼容性、使用方式、算法 等多个维度分类:

按锁的作用(读/写)和互斥规则分类:

  • 共享锁(S 锁):读锁,允许事务读取数据,多个事务可同时持有同一资源的 S 锁。S 锁与 X 锁互斥(读时不能写,写时不能读)
  • 排他锁(X 锁):写锁,允许事务修改/删除/插入数据。同一资源只能有一个 X 锁;X 锁与任何锁(S/X)都互斥

加锁方式

  • 隐式锁:InnoDB 自动加锁/释放,无需用户干预。 INSERT/UPDATE/DELETE 自动加 X 锁、普通 SELECT 不加锁。
  • 显式锁:用户通过 SQL 语句手动加锁,需手动解锁(或事务结束自动解锁)。 SELECT ... FOR UPDATELOCK TABLES

锁的状态

意向锁是 InnoDB 为了 快速判断表级锁和行级锁冲突 而设计的“表级标记锁”,用户无需手动操作,由数据库自动管理:

  • 意向共享锁(IS):事务准备给某行加 S 锁,先给表加 IS 锁
  • 意向排他锁(IX):事务准备给某行加 X 锁,先给表加 IX 锁

主要作用是 避免表锁和行锁的冲突检测耗时,例如事务 A 给某行加了行锁(隐含表的 IX 锁),事务 B 申请表的 S 锁时,只需检测表的 IX 锁,无需遍历所有行锁,直接判定冲突。

粒度

表锁:加锁时直接锁定整张表,无需遍历行,加锁/解锁速度快。InnoDB 中表锁仅在特殊场景触发,比如全表扫描无索引、ALTER TABLE 等 DDL 操作。

1
2
3
4
5
6
-- 手动加读表锁(其他事务可读,不可写)
LOCK TABLES t_user READ;
-- 手动加写表锁(其他事务不可读、不可写)
LOCK TABLES t_user WRITE;
-- 解锁
UNLOCK TABLES;

InnoDB 默认行锁,但以下场景会触发表锁,进而引发 DML 问题:

  • 无索引的 DML 操作,行锁退化为表锁:InnoDB 的行锁基于索引实现,若 DML 操作的 WHERE 条件无索引(或使用失效索引),InnoDB 会扫描全表并给所有行加行锁,等价于表锁。
  • DDL 操作触发表锁,阻塞所有 DML:InnoDB 执行 ALTER TABLE/DROP TABLE 等 DDL 操作时,会强制加表级排他锁,阻塞所有 DML 操作,且 DDL 操作通常耗时较长。MySQL 5.6+ 引入了 Online DDL 优化,部分 DDL 操作(如加列)不会完全阻塞 DML,但核心 DDL(如改主键、删列)仍会触发表锁。
  • 显式加表锁导致的 DML 阻塞:用户手动执行 LOCK TABLES 加表锁后,所有 DML 操作都会受表锁规则限制,行锁失效。

内存结构的黑科技:位图与页级锁

如果要锁住全表 100 万行数据,内存会爆吗?不会

InnoDB 采用 页级锁管理 + 位图 (Bitmap) 的稀疏结构:

  • 锁结构体 (lock_rec_t) 是挂在 Page (16KB) 上的,不是挂在 Row 上的。
  • 结构体内部用一个 Bitmap 标记该页中哪几行被锁了。

结论:无论你锁住一行还是锁住该页的一千行,内存开销是恒定的。这是“空间换时间”的经典反例——通过数据结构优化实现了双赢。

结论:无论你锁住一行还是锁住该页的一千行,内存开销是恒定的。这是工程设计中“空间换时间”的经典反例——这里通过数据结构的优化实现了空间和时间的双赢。

行锁InnoDB 通过索引实现行锁无索引时会退化为表锁),仅锁定操作的行。

1
2
3
4
5
6
-- 手动加共享锁(读锁)
SELECT * FROM t_user WHERE id=1 LOCK IN SHARE MODE;
-- 手动加排他锁(写锁)
SELECT * FROM t_user WHERE id=1 FOR UPDATE;
-- DML 操作(INSERT/UPDATE/DELETE)会自动加排他锁,提交/回滚后释放
UPDATE t_user SET name='李四' WHERE id=1;

间隙锁:属于 InnoDB 特有的锁,锁定“索引记录之间的间隙”,不锁定具体行。仅在 RR 及以上隔离级别生效。目的是防止其他事务在间隙中插入数据(防止幻读)。

Next-Key Lock:临键锁,RR 级别下 InnoDB 的默认加锁单位。执行 UPDATEDELETESELECT ... FOR UPDATE 时默认触发。组合了“行锁 + 间隙锁”。锁定范围为:(上一个索引值, 当前索引值]。例如:ID 为 10、20 的行,Next-Key Lock 会锁定 (10,20](包含 20 行 + 10~20 间隙)。是 InnoDB 解决幻读的核心机制。

Next-Key Lock 并不是一种全新的锁,而是行锁与间隙锁的组合。

不同隔离级别的实现

MySQL 中,SELECT,也就是读操作,其实分为两种:

普通的 SELECT——一致性读,又称为快照读。就是使用快照信息显示基于某个时间点的查询结果,而不考虑与此同时运行的其他事务所执行的更改。在数据库的 RC 这种隔离级别中,还支持 “半一致读” ,一条 update 语句,如果 where 条件匹配到的记录已经加锁,那么 InnoDB 会返回记录最近提交的版本,由 MySQL 上层判断此是否需要真的加锁。

INSERTUPDATEDELETE,涉及数据修改时——当前读,必须读取最新的、已提交的数据。

读未提交完全不做隔离控制,读操作直接读取数据的“最新版本”,写操作仅加基础行锁(不影响读)。

读已提交:通过 MVCC 实现 “快照读”,避免脏读;但同一事务内多次读会读取不同的已提交版本,导致不可重复读。

  1. 读操作(快照读):每次执行读 SQL 时,生成一个 当前时间点的快照(基于事务 ID),仅读取该快照之前已提交的事务修改的数据版本;
  2. 读操作(当前读):对于 select ... for update/select ... lock in share mode 这类“当前读”(需要加读锁的读),会加 共享行锁(S 锁),但锁仅在当前语句执行完释放(而非事务结束)。
  3. 写操作:对修改/删除的行加排他行锁(X 锁),锁在事务提交/回滚时释放;写操作会阻塞其他写操作,但不阻塞快照读。

该级别下,MVCC 的“快照”是 语句级 的(每次读语句生成新快照),而非事务级;

仅解决脏读,仍存在不可重复读(因为快照随语句刷新)和幻读(无间隙锁,允许插入新数据)。

可重复读:整个事务内所有读操作共用一个 事务启动时的快照,确保多次读结果一致;结合间隙锁解决幻读,是 InnoDB 最核心的隔离级别。

  1. 读操作(快照读):事务启动时生成一个 全局快照(基于事务启动时的最大已提交事务 ID),整个事务内所有读操作都使用这个快照,不再刷新;
  2. 读操作(当前读):对于 select ... for update 等当前读,InnoDB 会加 Next-Key Lock(行锁 + 间隙锁)
  3. 写操作:对修改/删除的行加排他行锁(X 锁),并结合间隙锁锁定周边间隙;锁仅在事务提交/回滚时释放,确保写操作的互斥性。

MVCC 的“快照”是 事务级 的(整个事务共用一个快照),而非语句级;

通过 Next-Key Lock 解决了幻读(这是 MySQL 对标准 SQL 隔离级别的优化,标准中 REPEATABLE READ 仍有幻读);

写操作会检测“幻读风险”,如果其他事务在间隙插入了数据,当前事务的写操作会失败并回滚,确保一致性。

串行化:强制所有事务 串行执行,完全禁止并发操作,通过加表级锁/严格的行锁+间隙锁实现,彻底解决所有并发问题。

即使是普通的 select(快照读),也会被强制转为 当前读,并对读取的行/间隙加 S 锁;S 锁会持续到事务结束,导致其他事务的写操作必须等待当前事务完成。对修改/删除/插入的行加排他行锁(X 锁),并对所有相关间隙加间隙锁;排他锁会阻塞所有其他事务的读/写操作,直到当前事务提交/回滚。

所有事务按顺序执行,同一时间只有一个事务能操作目标表/行;如果检测到事务间的冲突(如事务 A 持有 S 锁,事务 B 申请 X 锁),则后申请的事务会等待,直到前一个事务结束;若等待超时,会触发死锁或超时错误。

锁的退化与扩展

假设表结构:id (主键), age (普通索引)。

现有 id 数据:10, 20, 30。

场景 1:唯一索引等值查询

规则:Next-Key Lock 退化为 Record Lock

  • SQLSELECT * FROM t WHERE id = 20 FOR UPDATE;
  • 加锁行为:仅锁定 id = 20 这一行记录。
  • 优化原理:由于主键/唯一索引具有 唯一性约束,数据库可以确定该位置不会被插入重复数据(即不会发生幻读)。因此,锁机制自动优化,取消间隙锁,仅保留行锁。

场景 2:非唯一索引等值查询

规则:Next-Key Lock 扩展(向右遍历加 Gap Lock)

  • SQLSELECT * FROM t WHERE age = 20 FOR UPDATE;
  • 加锁行为
    1. 命中记录:对 age = 20 加 Next-Key Lock,锁定区间 (10, 20]
    2. 向右扩展:由于索引不唯一,InnoDB 需要防止在 20 后面插入新的 20。因此继续向右遍历,直到找到第一个 不等于 20 的索引值(即 30)。
    3. 封锁间隙:对 (20, 30) 加 Gap Lock(注意是开区间,不锁 30)。
  • 最终锁范围:索引区间 (10, 30) + 记录 age = 20
  • 结论:普通索引等值查询时,锁范围 = 前间隙 + 记录本身 + 后间隙

场景 3:唯一索引范围查询

规则:不退化,严格遵循 Next-Key Lock

  • SQLSELECT * FROM t WHERE id > 20 FOR UPDATE;
  • 加锁行为
    1. 锁定区间 (20, 30]
    2. 锁定区间 (30, +∞)
  • 原理:范围查询需要保证在该范围内不出现新的“幻影”记录,因此即使是唯一索引,也必须加间隙锁,严格遵循 Next-Key Lock 规则。

场景 4:查询不存在的记录

规则:Next-Key Lock 退化为 Gap Lock

  • SQLSELECT * FROM t WHERE id = 15 FOR UPDATE;
  • 加锁行为
    1. 定位:系统查找 id = 15 失败,定位到其右侧第一个存在的记录 id = 20
    2. 初始锁理论上 应在 (10, 20] 加 Next-Key Lock。(这里的“理论上”指的是在 RR 级别下默认按照 Next-Key Lock 方式进行加锁)
    3. 退化:因为查询值 15 不存在(不等于 20),Next-Key Lock 退化为 Gap Lock。
  • 最终锁范围:锁定区间 (10, 20)
  • 效果:锁住了 1020 之间的缝隙,防止其他事务插入 15,但不影响 id = 20 这行记录本身的读写。

死锁问题

Gap Lock 到底挡住了什么?它挡住的是 插入意向锁。插入意向锁是一种特殊的 间隙锁,由 INSERT 操作在真正插入行之前设置。它表示“我想在这个间隙插入数据”。如果不存其他事务的 Gap Lock,插入意向锁直接成功;如果存在 Gap Lock,插入意向锁被阻塞,进入等待状态。

Gap Lock 之间是不冲突的!。

事务 A 执行 SELECT ... WHERE id = 15 FOR UPDATE(不存在的行)。A 获得 (10, 20) 的 Gap Lock。

事务 B 执行 SELECT ... WHERE id = 16 FOR UPDATE(不存在的行)。B 也获得 (10, 20) 的 Gap Lock。注意:这里不会阻塞!因为 Gap Lock 只防插入,不防其他 Gap Lock。

事务 A 尝试执行 INSERT INTO t VALUES(15)。A 试图加“插入意向锁”,被 B 的 Gap Lock 阻塞。

事务 B 尝试执行 INSERT INTO t VALUES(16)。B 试图加“插入意向锁”,被 A 的 Gap Lock 阻塞。

结果:死锁。

Server 层的元数据锁(MDL)。是导致生产环境“全站卡死”的头号元凶。

机制

  • 执行 DML(增删改查)时,自动申请 MDL 读锁。
  • 执行 DDL(ALTER TABLE 等)时,自动申请 MDL 写锁。
  • MDL 读写互斥

灾难级阻塞链

  1. Session A:开启事务,执行 SELECT(长查询)。持有 MDL 读锁
  2. Session B:执行 ALTER TABLE 加字段。需要 MDL 写锁
    • 状态:被 A 阻塞,进入 Waiting for table metadata lock 队列。
    • 关键点:写锁的优先级极高,它会阻塞后续所有申请读锁的请求。
  3. Session C, D, E…:执行简单的 SELECT
    • 状态全部被 B 阻塞

后果:Session A 的一个慢查询,诱发了 Session B 的 DDL 阻塞,进而导致 Session C/D/E(原本毫秒级的查询)全部堆积。连接池(Connection Pool)在几秒内被打爆,业务全线崩溃。

MySQL 自身无法从逻辑上“根除”这种死锁,因为这是为了保证数据一致性(防止幻读)所必须付出的代价。

当死锁发生时,MySQL 采取的是 “事后补救”(检测并回滚)的策略;而作为开发者,我们需要做的是 “事前预防”(通过最佳实践规避)。

MySQL InnoDB 引擎并不能预测你会写出导致死锁的代码,它只能在死锁发生后进行干预。

InnoDB 默认开启 死锁检测(参数 innodb_deadlock_detect = on)。

  • 机制:InnoDB 会在内存中维护一张“锁等待图”(Wait-for Graph)。这是一个有向图,节点是事务,边表示等待关系。
  • 检测:每当一个事务请求锁被阻塞时,系统会快速检查这张图是否存在 环路(Cycle)。如果存在环(A 等 B,B 等 A),判定为死锁。
  • 处理:一旦检测到死锁,InnoDB 会选择一个 “牺牲者” (Victim)
  • 选择标准:通常选择持有锁最少、修改行数最少、回滚成本最低的那个事务。
  • 动作:强制回滚该事务,释放它持有的所有锁。
  • 结果:另一个事务获得锁,继续执行。

如果关闭了死锁检测(极高并发下为了减少 CPU 检测开销),MySQL 依靠超时来解决。参数是 innodb_lock_wait_timeout(默认 50 秒)。如果一个事务等待锁超过 50 秒,直接报错退出。这是一种保底手段。

互联网大厂最常用的方案是将隔离级别降级为 RC。

Gap Lock 和 Next-Key Lock 主要是为了解决 RR 级别下的幻读问题。RC 级别下,InnoDB 只剩行锁,不再加间隙锁。上述的 Gap Lock 死锁场景直接消失。

但代价是 幻读可能发生(但在绝大多数互联网业务中是可接受的)。而且 必须配合 Binlog 格式——也就是必须将 binlog_format 设置为 ROW 模式。因为在 RC 级别下,Statement 格式的 Binlog 会导致主从数据不一致。

其实 互联网公司选择使用 RC 的主要目的是提升并发。如上所说,RC 在加锁的过程中,是不需要添加 Gap Lock 和 Next-Key Lock 的,只对要修改的记录添加行级锁就行了。 这就使得并发度要比 RR 高很多。另外,因为 RC 还支持 “半一致读”,可以大大的减少了更新语句时行锁的冲突;对于不满足更新条件的记录,可以提前释放锁,提升并发度

如果必须使用 RR 隔离级别,请确保 FOR UPDATE 操作都能命中 唯一索引 且是 等值查询

不要使用“先检查是否存在,不存在则插入”的逻辑(即 Check-Then-Act)。

错误的写法(易死锁):

1
2
3
4
-- 事务 A 和 B 都跑这段代码
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 判断结果为空
INSERT INTO t VALUES (10); -- 死锁爆发点

推荐的写法(利用唯一约束):直接利用数据库的唯一索引约束来保证原子性。

1
2
3
4
-- 直接尝试插入
INSERT INTO t (id, name) VALUES (10, 'abc');
-- 捕获异常
-- 如果报错 Duplicate Key,说明已存在,则改为 UPDATE 或忽略

或者使用 INSERT ... ON DUPLICATE KEY UPDATE

1
2
INSERT INTO t (id, name) VALUES (10, 'abc') 
ON DUPLICATE KEY UPDATE name = 'abc';

注意ON DUPLICATE KEY UPDATE 在高并发下依然可能产生死锁(虽然概率低很多),因为它在检测到冲突时也会加锁,但比显式的 FOR UPDATE 更安全。

应当尽量避免范围更新,减少 UPDATE ... WHERE id > 10 这种范围操作。范围更新会锁定大量间隙,极易误伤其他正常的插入操作。

既然 RC 这么好,为什么 MySQL 官方几十年不改默认值?主要是因为 Replication(主从复制)的历史遗留问题

在 MySQL 5.0 以前,Binlog 只有一种格式:STATEMENTROW 格式 在 MySQL 5.1 才引入。

RC + Statement 格式 = 主从数据不一致

假设有表 t (id, age),数据为 (10, 10), (20, 20)。

场景

  • Session A (RC): DELETE FROM t WHERE age > 10; (执行中,未提交)。在 RC 下,没有 Gap Lock,它只锁住了 id = 20 这一行,没有锁住 (10, 20) 之间的间隙
  • Session B (RC): INSERT INTO t VALUES (15, 15); (立即执行成功,因为没有 Gap Lock 挡路)
  • Session B: COMMIT;
  • Session A: COMMIT;

结果分析

  • 主库(Master)
    1. A 先删除了 id = 20。
    2. B 插入了 id = 15。
    3. 结果:主库里有 id = 10, id = 15。
  • Binlog 记录(Statement 格式):Binlog 是按事务 提交顺序 记录的。B 先提交,A 后提交。
  • 从库(Slave)回放
    • 先插入 id = 15。
    • 执行删除 age > 10。因为 id = 15 满足条件,所以 id = 15 也被删了!
    • 结果:从库里只有 id = 10。

主从数据不一致了

虽然现在大家普遍使用 binlog_format = ROW(这解决了 RC 下的不一致问题),但 MySQL 为了保证对旧版本、旧配置(默认 Statement)的 最大兼容性,不敢贸然修改默认隔离级别。


MySQL并发安全_锁与MVCC机制
https://gavinmo1.github.io/2026/03/06/MySQL并发安全_锁与MVCC机制/
作者
Gavin
发布于
2026年3月6日
许可协议