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 log、undo log 与 binlog
  4. MySQL 内核 · 执行计划与慢查询优化
  5. MySQL 内核 · 主从复制与高可用

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

TL;DR

Table of contents

Open Table of contents

1. 事务要保护什么

ACID 里和并发强相关的是 I(Isolation)。广告场景三个典型诉求:

隔离级别就是在正确性并发度之间拨档。

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 行上的隐藏列

聚簇索引行大体带有:

一次 UPDATE:改最新行镜像、写 undo 记旧值、ROLL_PTR 指向旧版——于是形成从新到旧的版本链

3.2 ReadView 可见性

事务做一致性读时创建 ReadView,关键字段直觉理解:

判断某版本是否可见(简化):

不可见就沿 ROLL_PTR 找更老版本,直到看见或没有。

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

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

3.3 RR 与 RC 的差别(落地)

当前读FOR UPDATE、更新删除)一律找最新已提交版本并加锁,与隔离级别下的快照读规则不同——预算扣减那种「先读后改」若只用普通 SELECT,会在并发下翻车,必须当前读或原子 UPDATE … WHERE balance >= ?

undo 不是免费的:版本链过长时,每次读都可能追很多次 ROLL_PTR。长事务、大查询、SELECT … 扫全表不提交,会拖住 purge,表现为历史链表长度上涨、磁盘涨、CPU 花在遍历旧版本上。广告侧「开一个报表连接挂着不关」足以拖垮热库。

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

4.1 锁加在索引上

InnoDB 行锁本质是锁索引记录(或间隙)。没有合适索引时,可能退化成锁更多记录甚至很粗的扫描锁,并发立刻变差——所以锁与索引设计是一件事

4.2 三种锁

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

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 死锁从哪来

典型配方:

  1. 两个事务以不同顺序锁多行(先锁 campaign 再锁 budget vs 反过来);
  2. RR 下额外 gap 让「以为没冲突的插入/更新」也互相卡住;
  3. 长事务持锁,放大碰撞窗口。

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 … 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」导致偶现幻读或死锁难复现。

参考


views
Share this post on:

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