
运维和研发联查线上问题的时候我最怕听到的一句话是这个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 View3.1 每行数据自带的三个隐藏字段MVCC 这个名字听起来很高端但它落地到 InnoDB 其实非常具体每一行聚簇索引记录上都有几个用户一般看不见的系统字段MVCC 的所有逻辑都建立在它们之上。隐藏字段作用DB_TRX_ID最近一次修改该行记录的事务 IDDB_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 的事务 IDm_ids生成瞬间还在活跃、未提交的所有事务 ID 列表min_trx_idm_ids 里的最小事务 IDmax_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 100max_trx_id 102creator_trx_id 99。扫描到一行记录时发现 DB_TRX_ID 是 100说明这条记录最近一次修改来自事务 100而 100 还在活跃未提交列表里所以当前版本不可见。于是 InnoDB 沿着 DB_ROLL_PTR 找到上一个版本假设上个版本的 DB_TRX_ID 是 9898 小于 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 会在目标位置的前后间隙上加锁。这个加了却锁空了的行为很多人不适应但逻辑上完全合理既然没有命中已有记录就必须防止其他事务在这个空位上插入一条满足查询条件的记录否则当前读下一次可能就会多出一条。普通索引则完全不同。普通索引不是唯一的即使当前已经命中一条 age30 的记录其他事务未来也完全可能再插入一条 age30 的记录所以普通索引的等值查询命中时依然要加 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;这条更新的路径很直接主键索引 id20 这一行加 X 记录锁。因为主键是唯一的等值查询又命中了记录不需要加间隙锁其他事务不能修改或删除 id20也不能再插入相同主键的记录。如果被更新的列里碰巧包含普通索引列 ageInnoDB 还需要同步修改 idx_age 上对应的索引项此时会对辅助索引上的该索引项加锁这和主键上的记录锁是两把锁不能混为一谈。5.2 普通索引等值 UPDATE 为什么锁得更多再看这个BEGIN; UPDATE t SET name x WHERE age 30;由于 WHERE 条件走的是普通索引 idx_age处理过程明显更谨慎在 idx_age 上找到 age30 对应的索引项对其加 Next-Key Lock也就是锁住 age20 与 age30 之间的间隙、age30 这条记录本身、以及 age30 与 age40 之间的间隙通过辅助索引回表找到主键索引 id20对 id20 加记录锁由于辅助索引不唯一即使扫描只碰到一条 age30也必须防止其他事务再插入一条 age30 或其他恰好落在该区间的数据。很多老开发在这里犯的错是以为命中一行就只锁一行。实际执行时 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_formatROW这比纠结隔离级别更重要。# 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里那些锁信息时你会觉得它们一条条都说得非常清楚。