上一篇讲清了行「怎么存、怎么靠索引找到」;一旦多个事务同时扣预算、改配置、扫流水,问题就变成:谁能看见谁的修改、冲突时谁该等谁。本篇专攻 InnoDB 的并发模型,也就是隔离级别、MVCC、行锁与间隙锁这套机制——这是广告计费与预算账本里最容易踩坑、也最值钱的一块。
本文是 MySQL 内核 系列的第 2 篇(事务隔离、MVCC 与锁)。 全系列 5 篇:
- MySQL 内核深挖 · InnoDB 存储结构与 B+ 树索引
- MySQL 内核深挖 · 事务隔离级别、MVCC 与行锁(本篇)
- MySQL 内核深挖 · redo、undo 与 binlog 机制
- MySQL 内核深挖 · EXPLAIN 计划与慢查询优化
- MySQL 内核深挖 · 主从复制链路与 HA 高可用
一句话定位:普通 SELECT 靠 MVCC(ReadView + undo 版本链)在不加锁的情况下读到「自己该看的快照」;写与当前读靠记录锁 / 间隙锁 / next-key 串行化冲突区间,从而在 RR 下近似消掉幻读。
TL;DR
- 四种隔离级别:RU / RC / RR / Serializable;InnoDB 默认 RR,且用 next-key 大幅缓解幻读。
- 脏读 / 不可重复读 / 幻读:分别对应「读未提交」「同一查询结果变了」「范围多出新行」。
- MVCC:更新不原地抹掉旧值,而是写 undo、用
DB_TRX_ID+DB_ROLL_PTR串版本链;读用 ReadView 判断哪个版本可见。 - RR vs RC:RR 事务内首次普通读生成 ReadView 并复用;RC 每次普通读新建 ReadView,所以 RC 允许「读到别人已提交的新值」。
- 当前读:
SELECT … FOR UPDATE/UPDATE/DELETE读最新已提交版本并加锁,不走 MVCC 快照路径。 - 记录锁 / 间隙锁 / next-key:锁行、锁行间空隙、两者组合;RR 下范围与等值当前读常带 gap,用于防幻读,也是死锁与插入阻塞的常见来源。
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 回表去找更老版本,直到遇见可见版本或链表触底为止。
普通 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 Lock:单纯锁住某条确定的索引记录;
- Gap Lock:锁住两条索引记录之间的空隙,严禁其他事务在此插入;
- Next-Key Lock:Record Lock 与其前面的 Gap Lock 的组合,这也是 RR 隔离级别下为了防幻读最常见的默认形态。
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 机制。
参考
- MySQL 官方文档. Transaction Isolation Levels
- MySQL 官方文档. InnoDB Multi-Versioning
- MySQL 官方文档. InnoDB Locking:record / gap / next-key。
- 官方博客与源码笔记中关于 ReadView 的可见性算法讨论(以你使用的大版本为准核对细节)。
- 《高性能 MySQL》:隔离级别与锁的工程实践章节。