Skip to content
Charles Shao
Go back

MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁

–views

上一篇讲清了行「怎么存、怎么靠索引找到」;一旦多个事务同时扣预算、改配置、扫流水,问题就变成:谁能看见谁的修改、冲突时谁该等谁。本篇专攻 InnoDB 的并发模型,也就是隔离级别、MVCC、行锁与间隙锁这套机制——这是广告计费与预算账本里最容易踩坑、也最值钱的一块。

本文是 MySQL 内核 系列的第 2 篇(事务隔离、MVCC 与锁)。 全系列 5 篇:

  1. MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引
  2. MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁(本篇)
  3. MySQL 内核深挖 · redo、undo 与 binlog 机制
  4. MySQL 内核深挖 · EXPLAIN 计划与慢查询优化
  5. MySQL 内核深挖 · 主从复制链路与 HA 高可用

一句话定位:普通 SELECT 靠 MVCC(ReadView + undo 版本链)在不加锁的情况下读到「自己该看的快照」;写与当前读靠记录锁 / 间隙锁 / next-key 串行化冲突区间,从而在 RR 下近似消掉幻读。

TL;DR

Table of contents

Open Table of contents

1. 事务要保护什么

ACID 里和并发强相关的是 I(Isolation)。正因如此,在广告场景中我们往往面临三个典型诉求:扣预算时绝不能让两个请求同时读到「余额 100」然后各自扣减 80 导致超卖;报表事务沿叶子链表范围扫描一段流水时,绝不希望被半截提交的脏数据污染;对账按 campaign_id 范围锁住时,更不能容忍别人插进一条「幻行」让结果漂移。隔离级别,本质上就是在正确性与并发度之间做权衡。

2. 四种隔离级别与三种现象

四种隔离级别对脏读、不可重复读、幻读的挡住能力与主要机制对照。

InnoDB 默认 RR;工程上不少写多库会改 RC,用业务幂等补一致性。

级别核心行为(InnoDB)
READ UNCOMMITTED可读未提交;几乎不用
READ COMMITTED只读已提交;每次 SELECT 新快照
REPEATABLE READ事务内普通读重复读;next-key 防幻
SERIALIZABLE普通读也加共享锁,串行化程度最高

标准 SQL 里 RR 可不防幻读;InnoDB 的 RR + next-key 在实践中往往能挡住大部分幻读场景——面试和排障都要分清「标准」与「实现」。

一个最小对照(心智实验):

现象典型触发RR 默认大致表现
脏读读到别的事务未提交修改不会
不可重复读两次普通读间别人已提交更新普通读通常不变
幻读范围再次读多出新行当前读 + next-key 常能挡

需要强调的是,普通快照读与当前读的可见性规则截然不同。日常踩坑中,很多「我明明是 RR 为什么还会读到新值」的困惑,根源正是在多次查询之间穿插了更新或 FOR UPDATE 这类当前读,从而破坏了一致性视图的预期。

3. MVCC:版本链与 ReadView

3.1 行上的隐藏列

回忆上一篇提到的行结构,聚簇索引的行记录上默认带有几个隐藏列:DB_TRX_ID 记录最后修改该行的事务 ID;DB_ROLL_PTR 指向 undo 中的上一版本;(无主键时还有 DB_ROW_ID 作为隐藏主键)。当你执行一次 UPDATE 时,引擎并不会原地抹去旧值,而是去修改最新行镜像、写 undo 记下旧值,并将 ROLL_PTR 指向这个旧版,于是就在内存和磁盘间拉出了一条从新到旧的版本链。

3.2 ReadView 可见性

当事务发起一致性读时,内核会为之创建 ReadView(数据快照)。它的关键字段可以这样直觉理解:m_ids 记录了创建视图时仍活跃(未提交)的事务集合,而 min_trx_id / max_trx_id 则圈出了活跃事务的范围边界。

判断某版本是否可见的简化规则如下:如果创建该版本的 trx_id 小于 min_trx_id,说明它在创建视图前已提交,必定可见;如果 trx_id 落在 m_ids 里,说明修改它的事务还在跑,不可见;若 等于自己的 trx_id,说明是自己改的,当然可见;其余情况则严格按「是否在创建视图前已提交」来细判。一旦当前版本不可见,引擎就会顺着 ROLL_PTR 回表去找更老版本,直到遇见可见版本或链表触底为止。

undo 版本链从新到旧串联,ReadView 用 m_ids 与 trx 边界决定当前读事务看到哪一版 budget。

普通 SELECT 走 MVCC:不加锁,靠历史版本「穿越」;代价是 undo 与 purge 压力。

3.3 RR 与 RC 的差别(落地)

这套机制直接造就了隔离级别在落地时的差异。RR 在事务中第一次发起普通 SELECT 时生成 ReadView,并在后续查询中复用,因此同一事务的重复读表现极其稳定。对比前一种方案,RC 则是每个普通 SELECT 都会重新生成 ReadView,这就导致它能读到其他事务已提交的新值,即在语义上允许「不可重复读」。

回到刚才扣预算的场景,当前读(如 FOR UPDATE、更新或删除操作)根本不走快照逻辑,它们一律去抓取最新已提交版本并加排他锁。如果你在预算扣减这种「先读后改」的链路里只依赖普通 SELECT,并发下极易踩坑——两个事务拿同一份快照去计算,写回时就会引发数据覆盖,所以必须强制走当前读,或者采用原子化 UPDATE … WHERE balance >= ? 的姿势。

但这套「无锁化」的 MVCC 并不是免费的。当更新过于频繁导致版本链拉得很长时,每次读取都不得不在内存页和 undo 空间里反复追溯 ROLL_PTR。如果有长事务、大查询或者 SELECT … 扫全表迟迟不提交,就会死死拖住 purge 线程,导致 undo 历史记录无法被回收。在线上计费库的可观测指标中,你会看到历史链表长度飙升、磁盘容量告警,甚至 CPU 全耗在遍历旧版本上——广告侧「开一个报表连接挂着不释放」这种失误,足以直接拖垮热库。

4. 锁:记录、间隙与 Next-Key

4.1 锁加在索引上

明确一个心智:InnoDB 的行锁本质上是锁住了索引记录(或索引之间的间隙)。如果没有命中合适的索引,行锁就可能退化成锁住更多无关记录,甚至变成粗粒度的全表扫描锁,导致整个表的并发吞吐瞬间暴跌。这就意味着,在 InnoDB 里,锁设计与索引设计永远是同一件事。

4.2 三种锁

索引线上的记录与间隙:Record / Gap / Next-Key 分别锁住什么。

RR 防幻读靠 gap;RC 几乎不加间隙锁,插入更流畅,但幻读要业务自己防。

4.3 幻读例子(直觉)

假设事务 A 在 RR 下执行当前读:

SELECT * FROM bill WHERE campaign_id = 1001 FOR UPDATE;
-- 稍后再次同等条件查询,不希望多出「新插入」的行

如果引擎只锁住已有的 campaign_id=1001 记录而不锁间隙,事务 B 此时完全可以插入另一条同样为 1001 的新记录。当 A 再次查询时,就会凭空多出一行,这就是标准的幻读。正是 Next-Key Lock 填补了记录周边的 gap,才成功在物理层面挡住了这类并发插入。

4.4 死锁从哪来

死锁的最典型配方往往是:两个事务以不同顺序竞争同一资源(比如先锁 campaign 再锁 budget,另一个却反过来)。更要命的是,RR 下额外引入的间隙锁会让那些「你以为没有交集」的插入或更新操作,意外地互相阻塞在锁等待上。一旦叠加长事务长时间持锁,碰撞的窗口期就会被剧烈放大。此时 InnoDB 虽然能侦测到死锁并回滚代价较小的事务,但业务层必须做好重试机制并且打平幂等逻辑。

此外,加锁范围与索引是否命中强相关,用两个 UPDATE 试刀就能看出端倪:

-- 有 UNIQUE(campaign_id):命中唯一索引,倾向于精准锁单行
UPDATE budget SET balance = balance - 1 WHERE campaign_id = 1001 AND balance >= 1;

-- 无合适索引:沿叶子链表范围扫描大量记录并锁得更凶,甚至引发大面积阻塞
UPDATE budget SET balance = balance - 1 WHERE external_code = 'cmp_xxx';

所以遇到「锁竞争扛不住」的报警时,第一反应往往是先看执行计划修索引,这比盲目去降级隔离级别要有效得多。

5. 快照读 vs 当前读(写预算时怎么写)

在实战中,广告预算的扣减千万不要写成这种脆弱的形态:

BEGIN;
SELECT balance FROM budget WHERE campaign_id=? FOR UPDATE; -- 还行,至少当前读
-- 中间算一堆,甚至打 RPC …… 这会将 p99 延迟拉满,并长时间霸占锁
UPDATE budget SET balance = balance - ? WHERE campaign_id=?;
COMMIT;

更干净、并发度更高的形态应该是:

UPDATE budget
SET balance = balance - ?
WHERE campaign_id = ? AND balance >= ?;
-- 通过检查 ROW_COUNT() 确认是否扣减成功;必要时再写一条幂等流水

核心原则在于:普通 SELECT 走 MVCC 快照,绝不阻塞别人的写;而 FOR UPDATE / UPDATE / DELETE 是当前读并伴随加锁。因此,临界区里只该留下「读改写必须原子的那一下」,把旁路校验、RPC 调用通通放在事务之外。

至于 SELECT … LOCK IN SHARE MODE(8.0 起亦作 FOR SHARE),它主要用于给记录加上共享锁,适合那种「需要读到稳定数据但又不修改」,并且业务逻辑不能接受快照哪怕一点点旧的场景;但对于预算扣减,仍应坚定选用排他当前读或单条原子 UPDATE。

6. 插入意图锁与监控

在 Gap 之上,其实还有一种专为提升并发设计的 Insert Intention Lock(插入意图锁)。当多个事务往同一个间隙内的不同位置插入数据时,它们本可以安全并行,但如果这个间隙恰好被其他事务的 Gap Lock 或 Next-Key Lock 保护着,插入操作就会立刻触发停写,进入等待队列。线上的典型表现就是「一条普通补偿流水莫名其妙地阻塞在锁等待里」。排查这类问题,不妨多用下面这些命令:

SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- 或旧版本 INFORMATION_SCHEMA.INNODB_LOCKS / INNODB_LOCK_WAITS
SHOW ENGINE INNODB STATUS\G  -- 看 LATEST DETECTED DEADLOCK

把「究竟是谁持有什么锁、又在等什么锁」彻底看清,比盲目改参数降隔离级别要靠谱、安全得多。

7. 选型:RR 还是 RC?

场景建议
配置库读多写少、要稳定快照默认 RR 通常合适
计费/预算写冲突极多可考虑 RC,少 gap,补幂等与约束
强范围一致、可接受阻塞RR + 谨慎当前读

无论哪档,记住三板斧:扣钱用单条原子更新或清晰的当前读;事务尽量短;加锁顺序全局打平。

需要强调的是,把 transaction_isolation 改成 RC 属于实例或会话级别的变更。如果你的应用使用了连接池,务必确保会话级别的设置全局统一,否则很容易出现「一半连接是 RR、一半是 RC」的诡异状态,导致线上偶现的幻读或死锁根本无法复现。

排障口诀:长事务不提交拖死 purge,历史版本上涨磁盘拉响报警;RR 乱加间隙锁,并发插入易触发互斥等待;遇到死锁别慌张,锁顺序打平再重试;监控常看 data_locks,看清阻塞谁在等谁。

这也解释了为什么上一篇的 undo 版本链,正是这里 purge 卡住的根因。下一篇我们会拆开 redo log、undo log 与 binlog 三条日志线,看一次 UPDATE 在内存页、版本链和 binlog 事件里到底怎么流转并最终落盘,以及上一篇提到的 undo 版本链在这里如何被彻底回收——欢迎继续阅读 MySQL 内核深挖 · redo、undo 与 binlog 机制。

参考


–views
Share this post on:

Previous Post
MySQL 内核深挖 · redo、undo 与 binlog 机制
Next Post
特征工程总览:算法是大厨,但菜的上限由「备料」决定