☰
MySQL并发控制:MVCC、隔离级别与间隙锁的协同机制
2026/10/1 3:54:48 网站建设 项目流程

写这篇东西的起因,是前不久帮一个朋友的电商项目排查线上问题。他们的订单表在高峰时段频繁出现“转账超时”和“库存对不上”的报错,DBA查了一圈锁等待,最后发现是几条insert语句被间隙锁卡住了。排查过程中我翻了不少文档,也和团队里几个刚接触MySQL的后端同学聊了聊,发现大家对于MVCC、隔离级别、间隙锁这些概念其实都比较模糊:知道名字,但不清楚它们之间到底怎么配合,更不知道线上出问题的时候该从哪里下手。

所以我想把这几年在MySQL并发控制这个方向上踩过的坑、看过的源码逻辑、实操过的排查手段整理成这篇内容。主要讲清楚四件事:MVCC多版本并发控制的底层机制、脏读/不可重复读/幻读这三种异常到底怎么发生、InnoDB的隔离级别如何借助MVCC和间隙锁来压制这些问题,以及线上遇到锁等待或死锁时该怎么定位和处理。无论你是刚接触事务的后端新人,还是正在准备面试的求职者,或者已经在维护线上库的工程师,这篇都值得花二十分钟看完。

1. 并发控制到底在解决什么问题

1.1 从一次真实的“并发事故”说起

先说我朋友那个项目的具体场景。订单表里有库存字段,下单逻辑大概是三步:查库存是否充足、扣减库存、生成订单。单看每一步都没问题,但一旦多个用户同时买同一件商品,麻烦就出来了。两个事务同时读到库存还剩1件,各自都认为可以下单,结果库存被扣成负数,订单却生成了两笔。

这种问题在并发编程里叫竞态条件,放在数据库里就是事务并发带来的数据不一致。数据库解决这个问题的手段,总结起来就两样:一个叫加锁,一个叫多版本。MySQL的InnoDB引擎其实两个都用,而且用得很巧妙——先用MVCC处理普通的读请求,再用锁应对写冲突,两者配合才撑起了事务的隔离性。

理解这一点特别重要。很多人把“并发控制”等同于“加锁”,觉得只要把读写都锁住就安全了。但那样做的代价是性能断崖式下跌,因为读写互相阻塞,并发量根本起不来。MVCC的思路则是:读不加锁,写不加锁,读写之间也不阻塞,只有写写之间才需要锁来仲裁。这个设计直接决定了MySQL在高并发下的吞吐能力。

1.2 隔离性与一致性的平衡艺术

事务的ACID四大特性里,和并发控制关系最紧密的是I(Isolation,隔离性)。隔离性描述的是:多个事务同时执行时,彼此之间应该隔离到什么程度。完全隔离当然最安全,但代价是只能串行执行,性能归零。完全不隔离性能最好,但数据会乱成一锅粥。

所以SQL标准定义了四个隔离级别,本质上是在数据一致性和并发性能之间做权衡。InnoDB默认用的是REPEATABLE READ(可重复读),这也是MySQL和Oracle、PostgreSQL一个很不一样的地方——Oracle默认是READ COMMITTED(读已提交),而MySQL在可重复读这个级别上,通过MVCC和间隙锁做到了比标准定义更强的隔离效果,基本消灭了幻读。

后面我会反复提到几个关键术语:脏读、不可重复读、幻读。先给个直觉化的理解:脏读是"读了别人没提交的数据",不可重复读是"同一行数据两次读不一样",幻读是"同一个范围两次查出记录数不一样"。它们是三种不同维度的问题,对应不同的隔离强度,千万别混为一谈。

2. MVCC多版本并发控制:MySQL的“时间机器”

2.1 undo log 版本链与事务ID

MVCC的全称是Multi-Version Concurrency Control,多版本并发控制。一句话解释:数据库里的每一行数据,在物理上可能同时存在多个历史版本,每个版本都和某个事务绑定。读数据的时候,根据当前事务的状态,选择一个合适的版本返回,这样读操作就不需要等待其他事务提交了。

这个多版本结构是怎么来的?关键在于undo log。假设有一张用户表,id=1的name字段初始值是"张三"。事务A把它改成"李四",事务B再改成"王五"。每次修改,InnoDB并不会直接覆盖旧值,而是先把旧值写入undo log,然后在数据行上指向undo log里的对应版本。多次修改下来,这行数据就变成了一条版本链:当前值"王五" -> 历史值"李四" -> 原始值"张三"。

每个事务在开始时会拿到一个单调递增的事务ID,修改数据时,这行记录会记下"是我(事务ID=X)改的"。版本链上的每个版本也都带着自己的事务ID。所以当某个事务来读取这行数据时,它顺着版本链往回找,通过比对事务ID,就能判断哪个版本对自己是可见的。

注意:这里说的"版本链保存在undo log里"并不完全准确。准确说是undo log记录了反向操作(比如update语句会记录update前镜像),InnoDB通过它构造出历史版本。理解成版本链没有任何问题,面试和实战都够用了。

2.2 ReadView:如何判断哪个版本对当前事务可见

版本链有了,接下来核心问题变成:一个事务读取数据时,该怎么从版本链里挑出版本?答案靠ReadView(读视图),这是MVCC判断可见性的核心数据结构。

ReadView在事务执行快照读(普通SELECT)时生成,里面记了四个关键的东西:

组成要素含义
m_ids生成ReadView时,当前系统中所有活跃(未提交)读写事务的ID列表
min_trx_idm_ids中最小的那个事务ID
max_trx_id生成ReadView时,系统将要分配给下一个事务的ID
creator_trx_id生成这个ReadView的事务自己的ID

判断一条版本的可见性时,InnoDB按照下面这套规则来:

  • 如果版本的事务ID等于creator_trx_id,说明是自己改的,可见。
  • 如果版本的事务ID小于min_trx_id,说明这个事务早就提交了,可见。
  • 如果版本的事务ID大于等于max_trx_id,说明这个事务在ReadView生成时才刚启动,不可见。
  • 如果版本的事务ID在min_trx_id和max_trx_id之间,需要判断它是否在m_ids活跃列表里:不在说明已提交,可见;还在说明未提交,不可见。

如果当前版本不可见,就顺着版本链继续往前找,直到找到可见版本。这个过程就像翻历史档案:当前记录不给看,就翻上一版,翻到能给看的为止。

这里有个关键点,不同的隔离级别生成ReadView的时机不同。READ COMMITTED是每次执行SELECT都会生成一个新的ReadView,所以其他事务提交后,下次查询就能看到新数据,产生不可重复读。REPEATABLE READ只在事务第一次执行SELECT时生成ReadView,之后整个事务都沿用这一个视图,所以不管查多少次,看到的内容都一致,这就是可重复读的实现原理。

2.3 快照读与当前读:MVCC的两种读法

在InnoDB里,读取数据其实分两种:快照读(snapshot read)和当前读(current read)。

快照读就是普通的SELECT语句,不加任何锁。它读的是版本链上某个历史版本,不是最新数据,所以性能极高,不会被阻塞。MVCC主要服务于这类读取。

当前读则特殊一些,它读的一定是最新版本,而且会对读取的记录加锁。哪些操作是当前读?SELECT ... FOR UPDATE(加排他锁)、SELECT ... LOCK IN SHARE MODE(加共享锁)、UPDATE、DELETE、INSERT,本质上都是当前读。因为要修改数据,就必须基于最新值操作,否则会出现丢失更新的问题。

搞清楚这两种读法非常重要。很多人学MVCC时有个误区:以为可重复读级别的所有读都走MVCC。实际上MVCC只管快照读。一旦你执行UPDATE或者SELECT FOR UPDATE,立刻回到"当前读"模式,走的是最新数据和加锁逻辑。这也是后面讲间隙锁的基础——间隙锁锁定的就是当前读涉及的范围。

3. 三大读异常问题的前世今生

3.1 脏读:读到别人还没提交的数据

脏读是最低级的并发问题,只会出现在**读未提交(READ UNCOMMITTED)**级别。它描述的场景是:事务A修改了一条数据但还没提交,事务B此时去读,读到了A修改后的"半成品"数据。然后A回滚了,B刚才读到的数据就成了凭空捏造的东西。

举一个具体的例子。账户余额表里有100元。事务A执行转账,把余额改成50元,还没提交。事务B执行查询,看到余额是50元,于是基于这个数据做报表。这时候事务A突然回滚,余额恢复成100元。B刚才基于50元做的一切操作全部错乱。

为什么说脏读是最低级的?因为它读到的数据是"可能不存在"的——对方回滚之后,这个值从未真正存在于数据库的历史中(提交后才算真正存在)。这个问题在MVCC机制下其实天然被解决:ReadView判断可见性时,未提交事务的版本对你一定是不可见的。所以即使你把隔离级别调到READ UNCOMMITTED,InnoDB底层仍然会走MVCC,并不会有真正意义上的脏读。当然,READ UNCOMMITTED在InnoDB里实际不会去生成ReadView那么严格的结构,读到的逻辑版本较随意,但实践上我们通常说InnoDB不会出现脏读,因为它用版本链天然避开了“用户未提交数据”的暴露。

3.2 不可重复读:同一行数据两次读结果不一样

不可重复读发生在READ COMMITTED级别。场景描述:事务A先查询了一次某行数据,事务B随后修改了这行数据并提交,事务A再次查询同一行,发现值和第一次不一样了。

假设商品价格是200元。事务A查询价格,得到200元。事务B把价格改成180元并提交。事务A再次查询,得到180元。对事务A来说,两次查询同一行,结果不同,这就是不可重复读。听起来好像不严重,但对账、报表类业务是致命的:一条统计SQL要扫很多行,执行过程中其他事务改了数据,统计结果就会对不上。

对应到MVCC机制就很清晰了:READ COMMITTED级别下,每次SELECT都会生成新的ReadView,事务B提交后它的版本变成可见的,事务A第二次查询自然读到了新值。REPEATABLE READ级别则因为复用第一次的ReadView,抓着一个版本不放,两次结果就一致了。

3.3 幻读:记录数突然多了或者少了

幻读比不可重复读更隐蔽,也更难理解。区别在于:不可重复读关注的是"同一行数据的值变了";幻读关注的是"记录集合的成员变了"。具体来说,事务A执行一个范围查询,第一次查出5条记录;事务B往这个范围里插入了一条满足条件的新记录并提交;事务A再次执行同样范围查询,查出6条记录。多出来的那条,就像幻觉一样,所以叫幻读。

举个例子:查询订单表里金额大于100元的订单,第一次查到10条。这时另一个事务插入了一笔金额200元的新订单并提交。再查一次,变成11条。订单总数变了,就是幻读。

在MVCC机制下,REPEATABLE READ的快照读其实不会出现幻读,因为ReadView复用了,新插入的记录对事务A不可见。但问题出在当前读上。如果事务A执行SELECT ... FOR UPDATE或者UPDATE,走的是当前读、读最新数据,新插入的记录会立刻暴露出来。更麻烦的是,如果事务A想把某条新插入的记录更新一下,或者插入一条会被自己范围查询包含的记录,就可能和事务B产生锁冲突或数据不一致。所以,仅仅靠MVCC,可重复读级别下幻读无法被彻底消灭,必须引入锁机制——这就是间隙锁登场的理由。

4. 隔离级别与MVCC、锁的配合逻辑

4.1 四种隔离级别对三类问题的压制关系

SQL标准定义了四个隔离级别,不同级别对三类读异常的容忍度不同。先看对照表:

隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED可能可能可能无MVCC严格视图,直接读最新版本(实际引擎层面不同)
READ COMMITTED不可能可能可能每次SELECT生成新ReadView
REPEATABLE READ不可能不可能可能(InnoDB中基本不可能)首次SELECT生成ReadView并复用 + 间隙锁
SERIALIZABLE不可能不可能不可能全部加锁,串行执行

注意看表里REPEATABLE READ那一行,SQL标准承认它可能产生幻读,但InnoDB通过间隙锁把它强行压制住了,这也是MySQL的RR级别比标准定义更"强"的原因。所以在MySQL的默认隔离级别下,三类问题实际都不会发生。

面试中常问的问题:"MySQL默认隔离级别是什么?为什么不用READ COMMITTED?"答案的核心就是:MySQL选择RR,主要因为历史上主从复制在RR下基于binlog的记录更安全(statement格式下,RR能避免一些复制不一致),加上MySQL用间隙锁补齐了幻读漏洞,所以RR在一致性上比RC更稳。但代价是锁范围更大,并发度降低、死锁概率增加。这也是很多互联网公司会把隔离级别改成RC的原因——性能优先,幻读问题在业务层面用唯一约束等方式兜底。

4.2 InnoDB默认级别REPEATABLE READ到底强在哪

我们结合MVCC和锁,重新梳理一下RR级别下一条复合查询的完整流程:

事务A执行SELECT ... WHERE id > 10 FOR UPDATE,这个当前读操作会:先对满足条件的所有记录加记录锁(Record Lock),阻止其他事务修改或删除;同时对记录之间的间隙加间隙锁(Gap Lock),阻止其他事务在这个间隙插入新记录;联合起来就是临键锁(Next-Key Lock),把整个范围连数据带空隙都锁住。

此时事务B想往这个范围里插入一条id=15的记录,发现间隙被锁,只能等待A提交。A的事务全程看到的记录集合都不会变化,幻读被锁机制挡住了。快照读的幻读问题则靠复用ReadView解决,双管齐下,RR级别下三类读异常全部被压制。

4.3 实际业务怎么选隔离级别

我见过不少团队一上来就用默认RR,线上跑得也挺稳,但如果对并发度要求高,可以评估一下RC。两种选择各有适用场景:

  • 需要严格的一致性兜底、业务逻辑简单、难以在应用层处理并发冲突的场景,用RR+间隙锁等于让数据库帮你把边界守死。典型比如财务对账、资金交易。
  • 高并发写入、热点行更新频繁、死锁容忍度低的互联网业务,用RC减少锁竞争。RC下间隙锁不再生效,只有记录锁,死锁概率大幅下降。代价是可能遇到不可重复读,但很多业务(比如推荐流、内容列表)并不依赖同一事务内的多次一致性读取。

切换隔离级别也简单:执行SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;或者写在配置文件里transaction-isolation = READ-COMMITTED。注意一定要先评估业务里的长事务和多语句事务,RC下它们的行为会不一样。

5. 间隙锁的加锁机制与实操

5.1 记录锁、间隙锁、临键锁三兄弟

InnoDB的锁机制有几种形态,理解它们的区别是排查锁问题的基础:

记录锁(Record Lock):锁定索引上的一条具体记录。注意是锁索引记录,不是锁整行数据。InnoDB的二级索引和聚簇索引都会被锁覆盖。执行UPDATE users SET age=20 WHERE id=1,会在id=1这条聚簇索引记录上加X锁。

间隙锁(Gap Lock):锁定一个区间,但不包含区间里的记录本身。它锁的是"记录与记录之间的空隙",目的是防止其他事务向这个空隙插入新记录。间隙锁之间是兼容的(多个事务可以同时持有同一个间隙的间隙锁),它只和"往这个区间插入数据"的操作冲突。

临键锁(Next-Key Lock):记录锁和间隙锁的组合,左开右闭区间,既锁住记录又锁住前面的间隙。它是InnoDB默认的加锁单位。比如对索引值20加临键锁,实际锁定的是(10, 20]这个区间,既保护了记录20,也保护了10到20之间不能插入新值。

三个概念最直观的类比:记录锁是"锁住车位本身",间隙锁是"锁住两个车位之间的空位",临键锁是"车位带前面的空位一起锁"。这样你就能理解,为什么RR级别下间隙锁能挡插入——因为插入必然要落在某个空隙里,空隙被锁就进不来。

5.2 加锁规则详解:什么时候会锁住一个范围

接下来是重头戏:什么SQL会在什么索引条件下加什么锁。这是排查问题最需要掌握的部分。

第一条规则:等值查询命中的唯一索引。比如SELECT * FROM users WHERE id = 5 FOR UPDATE,id是主键,直接命中唯一记录,只在id=5上加记录锁,不需要间隙锁。因为唯一索引的值不存在"间隙插入导致幻读"的可能——插入一个id=5的记录会被唯一约束挡住。

第二条规则:等值查询未命中任何记录。此时MySQL会在查找路径上的间隙加间隙锁。例如id最大是10,执行SELECT * FROM users WHERE id = 100 FOR UPDATE,锁定的是(10, +∞)这个间隙。另一个事务想插入id=50的记录,会被阻塞。这个场景特别容易踩坑:你以为只是查一下不存在的记录,其实锁住了一大片插入空间。

第三条规则:范围查询。SELECT * FROM users WHERE id > 10 FOR UPDATE,会对10之后的记录加记录锁,同时对10之后的每个间隙加间隙锁。范围越大,锁的范围越大,阻塞的插入越多。

第四条规则:二级索引。如果WHERE条件走的是非唯一索引,即使等值查询命中了记录,也需要在二级索引记录及其相邻间隙加锁,同时回表在聚簇索引上加对应的记录锁。二级索引的间隙锁情况更复杂,因为多个二级索引记录可能指向同一行,幻读问题更难控制,所以锁的范围往往会扩大。

这里有一个高频面试题:"间隙锁在RC级别下存在吗?"答案是:不存在。RC级别为了提升并发度,只使用记录锁。所以RC下幻读没有被锁机制压制,可能出现类似"同一范围记录数两次不一致"的问题。这也是RC和RR在锁维度上最大的区别。

5.3 最容易踩间隙锁的坑:insert与死锁

实践中间隙锁导致的问题,大多出现在insert场景。拿最经典的"先查后插"逻辑举例:

-- 事务A SELECT * FROM users WHERE phone = '13800138000' FOR UPDATE; -- 没查到记录,但锁定了phone索引上的某个间隙 -- 事务B INSERT INTO users (id, phone) VALUES (10086, '13800138000'); -- 被A的间隙锁阻塞,等待

事务A明明没有查到任何记录,但它锁住了不存在的phone值附近的间隙,导致事务B插入相同phone值时被阻塞,直到A提交或回滚。如果A和B彼此持有对方需要的间隙锁,就可能死锁。

死锁的典型两例:

  • 两个事务都先执行范围查询,获得间隙锁,然后各自尝试插入对方间隙范围内的数据,互相等待。
  • 多个事务同时对相同记录做先查后改,锁顺序不一致,形成循环等待。

排查死锁,第一件事是执行SHOW ENGINE INNODB STATUS\G,看LATEST DETECTED DEADLOCK部分,它会明确显示两个事务各自的SQL和持锁等待锁的生命线。第二件事是开启innodb_print_all_deadlocks = ON,把死锁信息打到错误日志里,方便事后复盘。

6. 常见问题与排查技巧实录

6.1 从锁等待到死锁:一次线上问题的完整复盘

今年上半年我接手过一个仓储系统的问题。现象是:早上八点半开始,大批库存更新接口报错,错误码是Lock wait timeout exceeded; try restarting transaction。查了监控,数据库的锁等待队列一度涨到几十个。

当时我做的第一件事是看information_schema里的锁相关表,用了一条SQL:

SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM performance_schema.data_lock_waits w INNER JOIN information_schema.innodb_trx r ON w.requesting_engine_transaction_id = r.trx_id INNER JOIN information_schema.innodb_trx b ON w.blocking_engine_transaction_id = b.trx_id;

跑完发现阻塞源头是一个凌晨跑的数据清洗任务,它开启了一个长事务,对库存表做了全表范围更新,持有了大量间隙锁,导致早上入库的insert全部排队。解决了这个长事务,问题立刻缓解。这个案例说明:排查锁等待,第一步永远是找出"谁在阻塞、谁在等待",而不是盲目重启服务。

6.2 常用SQL助你洞察并发健康度

这里整理几条我平时排查并发问题最常用的SQL,建议大家收藏备用。

查看当前所有正在执行的事务:

SELECT * FROM information_schema.innodb_trx\G

重点看这几列:trx_started(事务开始时间),trx_rows_locked(锁定行数),trx_rows_modified(修改行数),trx_state(RUNNING还是LOCK WAIT)。trx_started距离现在越久的事务,越是潜在的长事务。

查看当前所有锁和等待关系:

SELECT * FROM performance_schema.data_lock_waits\G SELECT * FROM performance_schema.data_locks\G

这两张表能告诉你:哪条记录被哪个事务锁着、等待方是谁。配合innodb_trx的线程ID,就能拼接出完整的"谁等谁"链条。

查看InnoDB运行状态里最近一次死锁详情:

SHOW ENGINE INNODB STATUS\G

只看LATEST DETECTED DEADLOCK段落,里面有加锁和回滚的事务详情。

实践建议:把这些查询封装成一个"排障脚本",出问题先跑一遍,绝大多数锁问题能在五分钟内定位到根因。

6.3 从实战中总结的几条关键建议

最后分享几条我自己在多次踩坑后沉淀下来的经验,每条背后都有真实的线上教训。

第一,一切并发问题先从业务SQL开始查,不要一上来就调数据库参数。很多所谓"并发性能差",根源是某条SQL没走索引,导致锁从几行扩大到几万行。先看执行计划,确认是否走了合适的索引。一个走不上索引的UPDATE,在RR级别下可能锁住全表间隙,谁都别想插入。

第二,事务一定要短。长事务是并发问题之源:它持有的锁时间越长,阻塞越多,生成的undo log也越多,还可能导致版本链过长影响查询性能。建议代码层面明确事务边界,不要在事务里做RPC调用、外部接口请求等耗时操作。

第三,时刻记得"先查后插"这个模式有间隙锁风险。如果业务要求并发下不能插入重复记录,优先靠唯一索引兜底而非先SELECT再INSERT。唯一索引冲突会直接报错,数据库处理起来更高效,不会产生长时间的间隙锁等待。

第四,监控锁等待和死锁指标。重点关注lock_wait_timeout的值(默认50秒),如果业务对响应时间敏感,建议调低到5秒左右,让等待的事务快速失败,由应用层重试,而不是傻傻卡住用户请求。这个参数可以动态修改,但要确保应用层有配套的重试逻辑。

第五,RC级别并不总是更优。如果你依赖RR的快照一致性做报表或对账,贸然降级会导致同一事务内多次读结果不一致。线上切换前,一定要对业务里所有跨语句读逻辑做梳理。

7. 个人实操体会与补充

我在实际排查中最大的感受是:MVCC和锁这两套机制,单看任何一个都不难,但它们咬合在一起后,行为会变得很复杂。尤其是间隙锁这种"看不见摸不着"的锁,你写代码时根本感觉不到它,只有线上出问题时才意识到它锁住的范围有多大。所以我的习惯是:每个涉及范围查询或先查后插的SQL上线前,都手动模拟一下两个并发事务的执行路径,看它们各自的加锁范围和相互等待关系。这个习惯帮我避开了好几次潜在的线上事故。

最后再说一个小技巧,如果你用的是MySQL 8.0,可以在测试环境开一条SQL跟踪来观察加锁过程。MySQL 8.0提供了performance_schema.data_locks和data_lock_waits,比老版本的information_schema更精细。调试时用两个终端分别开事务执行同样的SQL,再实时查这两张表,就能直观看到锁是怎么一步步被加上去的。理解了加锁的粒度,你对于"为什么这里会卡住"的判断就会准确很多。

并发控制的门道说深很深,涉及锁结构、日志、事务调度;说浅也浅,核心就是一句话:读走多版本避开写,写靠锁来仲裁冲突,间隙锁补齐幻读的漏洞。把这条主线理解到位,再配合几次实盘排查,很多东西自然就通了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询