上一篇讲清了行「怎么存、怎么靠索引找到」;一旦多个事务同时扣预算、改配置、扫流水,问题就变成:谁能看见谁的修改、冲突时谁该等谁。本篇专攻 InnoDB 的并发模型——隔离级别、MVCC、行锁与间隙锁——这是广告计费与预算账本里最容易踩坑、也最值钱的一块。
本文是 MySQL 内核系列的第 2 篇(事务隔离、MVCC 与锁)。 全系列 5 篇:
- MySQL 内核(开篇)· InnoDB 存储结构与 B+ 树索引
- MySQL 内核 · 事务隔离、MVCC 与锁(本篇)
- MySQL 内核 · redo log、undo log 与 binlog
- MySQL 内核 · 执行计划与慢查询优化
- MySQL 内核 · 主从复制与高可用
一句话定位:普通 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读最新已提交版本并加锁,不走「快照逍遥」。 - 记录锁 / 间隙锁 / 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→ 可见(创建视图前已提交); - 落在 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 >= ?。
undo 不是免费的:版本链过长时,每次读都可能追很多次 ROLL_PTR。长事务、大查询、SELECT … 扫全表不提交,会拖住 purge,表现为历史链表长度上涨、磁盘涨、CPU 花在遍历旧版本上。广告侧「开一个报表连接挂着不关」足以拖垮热库。
4. 锁:记录、间隙与 Next-Key
4.1 锁加在索引上
InnoDB 行锁本质是锁索引记录(或间隙)。没有合适索引时,可能退化成锁更多记录甚至很粗的扫描锁,并发立刻变差——所以锁与索引设计是一件事。
4.2 三种锁
- Record Lock:锁住某条索引记录;
- Gap Lock:锁住两条记录之间的空隙,禁止插入;
- Next-Key Lock:Record + 其前面的 Gap,RR 下默认常见形态。
RR 防幻读靠 gap;RC 几乎不加间隙锁,插入更流畅,但幻读要业务自己防。
4.3 幻读例子(直觉)
事务 A 在 RR 下:
SELECT * FROM bill WHERE campaign_id = 1001 FOR UPDATE;
-- 稍后再次同等条件查询,不希望多出「新插入」的行
若只锁已有记录、不锁间隙,事务 B 可插入另一条 campaign_id=1001,A 再读就像见鬼——幻读。next-key / gap 挡住的就是这类插入。
4.4 死锁从哪来
典型配方:
- 两个事务以不同顺序锁多行(先锁 campaign 再锁 budget vs 反过来);
- RR 下额外 gap 让「以为没冲突的插入/更新」也互相卡住;
- 长事务持锁,放大碰撞窗口。
InnoDB 会检测死锁并回滚代价较小的一方——业务必须重试且保持幂等。
加锁范围还和索引是否命中强相关:
-- 有 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 ……
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 = 当前读 + 加锁;
- 临界区只留「读改写必须原子的那一下」,旁路校验放事务外。
SELECT … LOCK IN SHARE MODE(8.0 起亦作 FOR SHARE)加共享锁,适合「读稳定但不改」且不能接受快照稍旧的场景;预算扣减仍应倾向排他当前读或原子 UPDATE。
6. 插入意图锁与监控
Gap 上还有 Insert Intention Lock:多个事务往同一间隙插入不同位置时,本可并行,但与已有 gap/next-key 冲突时会等待。表现就是「插入一条补偿流水莫名卡住」。排查:
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」导致偶现幻读或死锁难复现。
参考
- MySQL 官方文档. Transaction Isolation Levels
- MySQL 官方文档. InnoDB Multi-Versioning
- MySQL 官方文档. InnoDB Locking:record / gap / next-key。
- 官方博客与源码笔记中关于 ReadView 的可见性算法讨论(以你使用的大版本为准核对细节)。
- 《高性能 MySQL》:隔离级别与锁的工程实践章节。