运维和研发联查线上问题的时候,我最怕听到的一句话是"这个SQL我本地跑没问题"。本地之所以没问题,多半不是因为SQL本身写得好,而是因为没有第二个事务在同一秒里跟你抢数据。MySQL 并发控制要解决的就是这种"抢",而围绕它的三个核心异常读——脏读、不可重复读、幻读——恰恰是我们在生产上最容易看到、又最容易只背概念不上手排查的三个词。我把这三个现象当作并发问题第一张定位图,建议每个做后端和数据库的人先把它们钉进脑子里,再去看 MVCC 和间隙锁。
1. 先把三个"不对劲"的现象钉死:脏读、不可重复读、幻读
1.1 脏读:拿到了一份还没签字的合同
脏读是指一个事务读到了另一个事务尚未提交的数据。你把未提交的数据比作一份还没有签字的合同就行——对方当事人还在犹豫要不要改金额,你已经拿着这份草稿去安排下一步工作了,等对方真正签字时发现金额完全不是他最开始草稿里的那个数,你前面所有安排全部作废。
我们线上一个运营后台就踩过这个坑。当时统计"今日成交订单"的报表 SQL 直接 join 了订单主表,订单表里一个订单从用户下单、支付成功、商家发货、用户确认,中间会有好几个事务先后修改状态。报表查询的任务和订单事务恰好落在同一时刻,就可能把一条"支付中"的订单当成"已成交"算进去。更要命的是,如果那个订单最后支付失败回滚了,报表里那条已经看到的"成交金额"就成了彻底不存在的东西。
根因上说,脏读之所以会发生,是因为读取方没有"版本隔离能力"。InnoDB 的普通 SELECT 走的是多版本读,它必须能识别哪个版本已经提交、哪个版本还在事务里。读已提交级别往上都不允许读到未提交版本,所以脏读在 MySQL 默认隔离级别下其实很难出现。但要注意,如果你把隔离级别改成 READ UNCOMMITTED,或者某些中间件/连接池把会话级别调乱了,脏读就会回来找你。
1.2 不可重复读:同一份报告,两次读数对不上
不可重复读,字面意思很准确:同一个事务内,你执行了两次一模一样的 SELECT,但读到的数据不一样。注意区别,这次别人提交的不是"草稿"而是"正式签字",问题是你在同一个事务里两次看到的正式结果不同。
我处理过一个库存统计的案例。事务里第一步先SELECT SUM(stock) FROM warehouse WHERE area_id = 10,紧接着做一系列业务计算,最后又执行了一次同样的 SUM。另一个并发事务在中间把一个仓库的库存从 100 改成了 80,并且提交了。如果隔离级别是读已提交,第二次 SUM 会读到 80,前后差 20,整个统计逻辑就得重算。
不可重复读的麻烦在于它不是一个错误值问题,而是一个一致性问题。事务本身应该像一张"某个时间点的快照",结果你在这个事务里看到的时间点前后不一致,业务代码就很难判断到底以哪一次为准。
1.3 幻读:数人数的人最怕人数会变
幻读比不可重复读更隐蔽。不可重复读针对的是"已有行的值变了",幻读针对的是"整批结果集里多出了原本不存在的行"。
最典型的场景是事务内两次SELECT COUNT(*)。第一次查出符合条件的记录有 100 条,第二次查出了 102 条,多出来的 2 条是另一个事务在这期间插入并提交的新记录。分页查询、报表统计、对账任务都是幻读的重灾区,因为它们的核心逻辑就是按行数和结果集做处理。
有人会问:"多出来两条已提交的数据,不是也挺正常的吗?"不,在可重复读隔离级别下,事务内部应当保持一个稳定视角。如果第一次查询基于某种条件把数据一批批拉出来处理,第二批还没处理完,前面已经处理过的数据里突然又混进来新成员,整个批处理任务的去重、断点、分批逻辑全都会被打乱。
1.4 三个现象的本质差别
把三个现象放在一起看更清楚:
| 异常读 | 问题对象 | 对方事务做了什么 | 主要防线 |
|---|---|---|---|
| 脏读 | 未提交的新数据 | 写了但还没提交 | MVCC 版本可见性判断 |
| 不可重复读 | 已有行的值 | 对已有行 UPDATE / DELETE 后提交 | Read View 固定快照或行锁 |
| 幻读 | 结果集中的新行 | INSERT 新记录后提交 | 间隙锁 / Next-Key Lock |
一句话小结:脏读是别人没签字的文件你拿去用了;不可重复读是同一页文件你看两次,发现字被改了;幻读是你看第二次时整份文件后面还多出了两页。MVCC 管的是前两者和快照读下的幻读,间隙锁管的是当前读下真正会把新行塞进结果集的幻读。
2. 隔离级别与 InnoDB 的"加档",RC 和 RR 真正差别在哪
2.1 SQL 标准四级隔离,只是最低及格线
SQL 标准定义了四个隔离级别:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。标准里对每个级别"可能发生哪些异常"有一张对照表,几乎所有学习数据库的人第一课都会看到:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能 |
| 可串行化 | 不会 | 不会 | 不会 |
但我要提醒一点:标准只是最低及格线,尤其对 MySQL 的 REPEATABLE READ,绝不能按表格里"幻读可能"去理解。InnoDB 的实际实现把这条路加厚了。
2.2 RR 模式下 InnoDB 为什么值得信任
InnoDB 的默认隔离级别就是 REPEATABLE READ,也正是因为默认,很多团队会忽略它到底做了什么。它做的事可以拆成两层:
- 对普通 SELECT(快照读),靠 MVCC 里的 Read View 固定一份一致性快照,整个事务内看到的都是事务开始时的数据视图,因此不可重复读和常规幻读都被挡在门外。
- 对 UPDATE、DELETE、SELECT ... FOR UPDATE(当前读),靠行锁配合间隙锁把扫描范围真正锁住,新记录想插入这个范围会被阻塞,因此当前读下的幻读也被堵住了。
换句话说,InnoDB 的 RR 实际效果比 ANSI 标准里的 RR 更强,甚至在某些层面接近 SERIALIZABLE。这也是为什么 MySQL 官方文档里明确说,在默认 RR 下它使用 Next-Key Lock 阻止了幻读。
2.3 当前读与快照读决定隔离效果
理解隔离级别的钥匙,是分清"快照读"和"当前读"。
普通SELECT是快照读,也叫一致性非锁定读。它不加锁,靠 MVCC 读某个历史版本。读已提交级别下,每条 SELECT 语句都会重新生成一次 Read View,所以同一事务内两次普通 SELECT 可能读到前后不同的提交结果;可重复读级别下,只有第一条普通 SELECT 会生成 Read View,后续都沿用同一个,因此事务内看到的数据始终如一。
UPDATE、DELETE、INSERT和SELECT ... FOR UPDATE是当前读。它不读历史版本,永远读最新已经提交的数据,并且会对涉及记录加锁。当前读无法像快照读那样靠"看旧版本"绕过冲突,所以才必须引入间隙锁。很多人在面试时把 MVCC 说成"MySQL 解决一切并发的工具",这是不对的——MVCC 解决的是快照读的可见性,当前读下的并发控制还得靠锁。
3. MVCC 的真身:隐藏列、undo 版本链和 Read View
3.1 每行数据自带的三个隐藏字段
MVCC 这个名字听起来很高端,但它落地到 InnoDB 其实非常具体:每一行聚簇索引记录上都有几个用户一般看不见的系统字段,MVCC 的所有逻辑都建立在它们之上。
| 隐藏字段 | 作用 |
|---|---|
| DB_TRX_ID | 最近一次修改该行记录的事务 ID |
| DB_ROLL_PTR | 回滚指针,指向该记录上一个版本在 undo log 中的位置 |
| DB_ROW_ID | 当表没有主键时,InnoDB 用它生成聚簇索引的隐藏自增 ID |
你可以在 MySQL 8.0 里给 InnoDB 表建一个无主键的小表试一下,会发现它最后会生成一个名为GEN_CLUST_INDEX的隐藏聚簇索引,底层实际上就是用 DB_ROW_ID 来组织记录。主键和隐藏字段之间不是竞争关系,而是聚簇索引的两种来源。
3.2 UPDATE 不会直接扔掉旧数据的证据链
很多人以为执行 UPDATE 就是把磁盘上那行数据原地改掉,旧值直接覆盖。InnoDB 不是这么干的,它做的是:
- 先把旧版本完整写到 undo log;
- 再在数据页上生成新版本,修改 DB_TRX_ID 为当前事务 ID;
- 新版本的 DB_ROLL_PTR 指向上一个版本在 undo log 中的位置。
所以同一个主键下的多版本记录,会通过 DB_ROLL_PTR 串成一条"版本链",链头是最新版本,越往链尾走历史越久。事务回滚时,顺着版本链把旧值找回来就能恢复;多版本读时,也是在这条链上挑一个对当前事务"可见"的版本返回。
这也顺带解释了为什么长事务会拖垮性能:事务不结束,历史版本就不能被 purge 清理,版本链越拉越长,每次读判断要走的链就越长,undo log 占用也越多。
3.3 Read View 的裁决规则和一条可见性判断流程
Read View 是 MVCC 里做可见性判断的"裁判"。它生成时会记下四样东西:
- creator_trx_id:生成该 Read View 的事务 ID;
- m_ids:生成瞬间还在活跃、未提交的所有事务 ID 列表;
- min_trx_id:m_ids 里的最小事务 ID;
- max_trx_id:生成 Read View 时系统尚未分配过的下一个事务 ID。
当快照读扫描某一行时,InnoDB 会取出该行当前的 DB_TRX_ID,按下面这套规则做对号入座:
| 当前记录的事务 ID | 可见性结论 |
|---|---|
| 小于 min_trx_id | 可见,事务已提交且早于视图生成时间 |
| 等于 creator_trx_id | 可见,是自己这个事务修改的数据 |
| 在 m_ids 列表中 | 不可见,对方还没提交 |
| 不在 m_ids 列表中且小于 max_trx_id | 可见,对方已提交 |
| 大于等于 max_trx_id | 不可见,是在视图生成之后才开启的事务 |
举个例子。假设 Read View 生成时,m_ids = [100, 101],min_trx_id = 100,max_trx_id = 102,creator_trx_id = 99。扫描到一行记录时发现 DB_TRX_ID 是 100,说明这条记录最近一次修改来自事务 100,而 100 还在活跃未提交列表里,所以当前版本不可见。于是 InnoDB 沿着 DB_ROLL_PTR 找到上一个版本,假设上个版本的 DB_TRX_ID 是 98,98 小于 100,说明已经提交且早于当前 Read View,那就直接返回这个旧版本。整套动作看起来像"读取历史快照",实际上就是一次可见性判断加版本链回溯。
3.4 RC 与 RR 的 Read View 差异,是理解隔离级别的钥匙
RC 和 RR 在 MVCC 上的差别,本质就是 Read View 的生成时机不同。
- 在 RC 下,每一条普通 SELECT 语句都会生成一个新的 Read View,因此每条 SQL 都能看到"此刻之前所有已提交的数据",这也解释了为什么 RC 下同一事务内两次普通 SELECT 会读出不同结果。
- 在 RR 下,Read View 只在事务第一条普通 SELECT 时生成一次,后续所有普通 SELECT 全部复用这个视图,事务的生命周期内看到的数据集合始终一致。
所以如果业务里存在一个比较长的事务,中间穿插多次普通 SELECT,但又要求这些 SELECT 看到同一份数据快照,RR 天然满足需求。而 RC 虽然锁开销更小,却要求业务代码自己容忍"事务内两次读不一致"。
4. 间隙锁:为什么解决幻读必须锁"空"
4.1 MVCC 堵得住快照读,堵不住当前读
讲完 MVCC,必须要讲清楚边界。普通 SELECT 走 MVCC,可以通过固定 Read View 让幻读无感;但当前读解决不了。比如事务 A 执行UPDATE t SET balance = balance - 100 WHERE user_id = 888,它必须基于最新已提交数据做扣减,如果另一个事务刚好插入了一条 user_id = 888 的新记录,A 如果不把这个位置锁住,扫描范围就会多出一个"之前不存在但符合条件"的行,之后的统计、总分页、对账全部错乱。
为什么行锁拦不住这种新插入?因为行锁锁的是已存在的记录,新插入的行会产生一条新的索引记录,原来的记录锁根本触碰不到它。要防止幻读,光锁住"已有行"不够,必须把"还没有行但将来可能插入行"的空隙也锁起来。这就是间隙锁存在的根本理由。
4.2 从记录锁到 Next-Key Lock 再到插入意向锁
InnoDB 在索引上主要提供了三种不同粒度的锁:
- 记录锁:只锁索引记录本身,其他事务不能修改或删除这条记录。
- 间隙锁:锁两个索引记录之间的区间,区间里现在没有数据,但禁止其他事务往这个区间 INSERT 新记录。
- Next-Key Lock:记录锁和间隙锁的组合,锁住记录本身加它前面那段间隙,区间是左开右闭。
还有一把插入意向锁需要提一下。插入操作真正开始前,InnoDB 会先在目标间隙上申请插入意向锁,它是一种示意"我准备往这个间隙插数据"的锁。多个插入意向锁之间可以共存,所以多个事务可以同时准备往不同间隙插入;但插入意向锁和已有的间隙锁/Next-Key Lock 是冲突的,一旦间隙被别的当前读锁住,插入就只能等待。
| 锁类型 | 锁对象 | 主要冲突对象 |
|---|---|---|
| 记录锁 | 具体索引记录 | 其他记录锁 |
| 间隙锁 | 索引记录之间的空档 | 插入意向锁 |
| Next-Key Lock | 索引记录 + 前面间隙 | 插入意向锁 |
4.3 唯一索引上的锁降级,以及最容易误解的场景
间隙锁有个非常容易误解的细节:唯一索引的等值查询,如果命中了记录,并不会再加间隙锁,只对命中的那条记录加记录锁。这是因为唯一索引天然保证了不可能再有第二条相同值的记录插入,不需要靠锁空来防幻读。
但如果唯一索引等值查询没有命中记录,情况就反过来,InnoDB 会在目标位置的前后间隙上加锁。这个"加了却锁空了"的行为很多人不适应,但逻辑上完全合理:既然没有命中已有记录,就必须防止其他事务在这个空位上插入一条满足查询条件的记录,否则当前读下一次可能就会多出一条。
普通索引则完全不同。普通索引不是唯一的,即使当前已经命中一条 age=30 的记录,其他事务未来也完全可能再插入一条 age=30 的记录,所以普通索引的等值查询命中时,依然要加 Next-Key Lock,把记录两侧间隙一起锁住。
4.4 间隙锁的代价:并发下降与死锁变多
间隙锁解决了幻读,但不是没有成本。
第一,它把"空闲区间"也变成互斥资源。本来两个事务往同一 SQL 扫描范围内的不同空档插入记录是可以并行的,一旦间隙被锁,后续插入就只能排队,写并发明显下降。对秒杀、积分流水这类高写入场景,代价尤其大。
第二,间隙锁非常容易引起死锁。常见模式是:事务 A 先锁住一个间隙,事务 B 锁住另一个相邻间隙,然后 A 向 B 的间隙插入被阻塞,B 向 A 的间隙插入也被阻塞,数据库检测到死锁后只能回滚其中一个事务。相比普通行锁死锁,这种"互相等空位"的死锁更难从业务代码直觉上发现。
所以很多线上高并发团队会选择把隔离级别降到读已提交。RC 下 InnoDB 关闭了常规的间隙锁,只在外键约束检查和唯一性检查等特殊场景短暂使用间隙锁,写并发会宽松很多,代价是业务端必须接受可能的幻读,或者在应用层用唯一约束、分布式锁等方式兜底。
5. 从一条 UPDATE 出发,拆一遍 InnoDB 的真实加锁流程
5.1 主键等值 UPDATE 的加锁路径
我们用一个具体的表拆解加锁过程。表结构如下:
CREATE TABLE t ( id INT PRIMARY KEY, age INT NOT NULL, name VARCHAR(20), KEY idx_age (age) ); INSERT INTO t VALUES (10, 20, 'a'), (20, 30, 'b'), (30, 40, 'c');假设事务执行:
BEGIN; UPDATE t SET name = 'a1' WHERE id = 20;这条更新的路径很直接:主键索引 id=20 这一行加 X 记录锁。因为主键是唯一的,等值查询又命中了记录,不需要加间隙锁,其他事务不能修改或删除 id=20,也不能再插入相同主键的记录。如果被更新的列里碰巧包含普通索引列 age,InnoDB 还需要同步修改 idx_age 上对应的索引项,此时会对辅助索引上的该索引项加锁,这和主键上的记录锁是两把锁,不能混为一谈。
5.2 普通索引等值 UPDATE 为什么锁得更多
再看这个:
BEGIN; UPDATE t SET name = 'x' WHERE age = 30;由于 WHERE 条件走的是普通索引 idx_age,处理过程明显更谨慎:
- 在 idx_age 上找到 age=30 对应的索引项,对其加 Next-Key Lock,也就是锁住 age=20 与 age=30 之间的间隙、age=30 这条记录本身、以及 age=30 与 age=40 之间的间隙;
- 通过辅助索引回表找到主键索引 id=20,对 id=20 加记录锁;
- 由于辅助索引不唯一,即使扫描只碰到一条 age=30,也必须防止其他事务再插入一条 age=30 或其他恰好落在该区间的数据。
很多老开发在这里犯的错是:以为命中一行就只锁一行。实际执行时 EXPLAIN 能看到用了 idx_age 作为扫描索引,但锁的范围已经覆盖了辅助索引相邻的间隙。这也是为什么普通索引等值更新会比主键等值更新更容易阻塞并发插入。
5.3 范围 UPDATE 形成的间隙锁区间
如果 WHERE 条件改成范围:
BEGIN; UPDATE t SET name = 'x' WHERE age >= 25;InnoDB 会从 idx_age 上第一个满足 age >= 25 的索引项开始,沿着索引一直向尾部扫描。扫描过程中经过的每一个索引项都加 Next-Key Lock,每两个索引项之间的间隙加间隙锁,最后通常会把索引末尾的 supremum 伪记录也锁上,以免有记录插入到区间末尾之外的空档。范围条件越大,锁住的区间越大,其他事务可操作的空白区域就越小。
真实生产里,把这种范围条件的索引列写错,导致本可以走小范围索引的 SQL 走到全表或者宽范围扫描,一瞬间就能把整张表的写操作全部堵住。锁范围控制是数据库优化里比索引选择更容易被忽略的第二课。
5.4 锁等待与死锁的排查方法
当出现锁等待或者死锁时,第一动作不是直接杀进程,而是看证据。老牌命令是:
SHOW ENGINE INNODB STATUS\G;输出里重点看LATEST DETECTED DEADLOCK段落,里面会列出两个事务各自持有哪些锁、正在等待哪把锁、最终回滚了谁,还会附上触发死锁的两条 SQL。这个日志基本能回答 80% 的排查问题。
MySQL 5.7 和 8.0 还可以用系统表精确观察当前锁状态:
SELECT * FROM information_schema.innodb_trx\G; SELECT * FROM information_schema.innodb_lock_waits\G;MySQL 8.0 提供了更细粒度的 performance_schema 视图:
SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;实际排查时我的顺序永远是:先看死锁日志确认是哪两条语句互等,再用 data_locks 确认各自锁的类型是 RECORD/GAP/NEXT-KEY,最后回到 SQL 的 WHERE 条件和索引设计上看锁范围是否合理。死锁日志只会告诉你"谁跟谁撞上了",不会告诉你"为什么撞",后面这一步必须自己做。
6. 工程选择:隔离级别、binlog 与长事务的现实权衡
6.1 线上到底用 RR 还是 RC
这是一个没有标准答案但很有工程规律的问题。
先说一个背景:MySQL 之所以默认 RR,和早期主从复制使用基于语句的 binlog 格式有关。在 STATEMENT 格式下,如果主库用 RC 级别,同一个 SQL 在主库和从库的并发环境下可能因为锁和快照差异产生不同数据。用 RR 加行锁加间隙锁,能让语句级复制有更一致的加锁行为。现在 ROW 格式已经普及,从库直接记录数据行变更,不再那么依赖隔离级别给语句打底,所以很多团队开始放心切到 RC。
我的实践建议分几种场景:
- 财务、结算、对账:这类业务要求事务内多次读到的结果完全一致,直接用默认 RR 最稳,不要为了"性能好一点"牺牲一致性。
- 高并发交易流水:写入和更新非常密集,可以评估切成 RC,间隙锁取消后写并发明显提升,但要接受幻读,并由应用层通过唯一约束、状态机、分布式锁等手段兜住。
- binlog 格式:线上至少MySQL 5.7 以上都建议用
binlog_format=ROW,这比纠结隔离级别更重要。
# my.cnf 示例 transaction_isolation = READ-COMMITTED binlog_format = ROW innodb_lock_wait_timeout = 5innodb_lock_wait_timeout我习惯从默认的 50 秒降到 3 到 5 秒。锁等待时间长对用户来说就是卡死,与其让一堆事务排队占用连接,不如早点超时报错,让监控和告警介入。
6.2 长事务是如何拖垮 undo 与锁的
隔离级别选完之后,真正决定并发体质的往往是事务长度。
在 RR 模式下,事务第一条普通 SELECT 会生成 Read View 并一直保持。只要事务不提交,这个 Read View 就会一直占用,undo log 中该视图需要的历史版本就不能被 purge 清理。事务跑得越久,版本链越长,后续所有读和写都要背上更重的历史包袱。RC 模式虽然每条 SELECT 都新建视图,但如果一条 SELECT 或一个事务长时间不结束,同样会阻塞 undo 的清理。
代码层面经常出问题的地方是:把远程接口调用、消息推送、文件导出这些耗时操作写进了数据库事务里。事务开着,连接占着,锁也占着,外部服务慢一拍,整个数据库的活跃事务数就开始上涨。控制事务时间,本质是在控制版本链长度和锁持有时间。
6.3 我平时最容易踩的三个并发坑
第一个是索引条件设计不当导致间隙锁范围失控。明明一个小等值更新,因为 SQL 里写了范围条件,或者索引选择器选到了一个错误索引,导致锁区间从理想的几行扩大到几十万行,核心表写入直接被堵死。解决思路是每次 UPDATE/DELETE 都看 EXPLAIN,确认走的是选择性最好的索引,条件尽量设计成等值或极窄范围。
第二个是"先 SELECT 再 UPDATE"的组合操作在 RR 下踩坑。SELECT 读的是快照,UPDATE 是当前读,两者看到的数据可能不是同一个版本。如果业务逻辑依赖"先查到余额再扣减",一定要用SELECT ... FOR UPDATE或者把扣减直接写成原子更新,否则并发下扣错余额几乎必然发生。
第三个是死锁后没有留下现场就直接重启应用。死锁日志记录在被 kill 的事务信息里,是无价之宝。遇到死锁先SHOW ENGINE INNODB STATUS,把日志留存,再调整 SQL 和索引。只重启不分析,下一次死锁只会换个姿势再来。
我在实际生产里见过太多因为并发控制没理清导致的线上事故,也渐渐发现 MVCC、间隙锁这些东西并不只是面试题。它们关系到你写出的每一条 UPDATE 会锁住多少行、每个事务能维持多久的一致性快照、每次死锁背后到底是谁在等谁的下一步操作。把这些原理吃透,再看SHOW ENGINE INNODB STATUS里那些锁信息时,你会觉得它们一条条都说得非常清楚。