ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL并发控制:MVCC、隔离级别与间隙锁的协同机制

MySQL并发控制:MVCC、隔离级别与间隙锁的协同机制 写这篇东西的起因是前不久帮一个朋友的电商项目排查线上问题。他们的订单表在高峰时段频繁出现“转账超时”和“库存对不上”的报错DBA查了一圈锁等待最后发现是几条insert语句被间隙锁卡住了。排查过程中我翻了不少文档也和团队里几个刚接触MySQL的后端同学聊了聊发现大家对于MVCC、隔离级别、间隙锁这些概念其实都比较模糊知道名字但不清楚它们之间到底怎么配合更不知道线上出问题的时候该从哪里下手。所以我想把这几年在MySQL并发控制这个方向上踩过的坑、看过的源码逻辑、实操过的排查手段整理成这篇内容。主要讲清楚四件事MVCC多版本并发控制的底层机制、脏读/不可重复读/幻读这三种异常到底怎么发生、InnoDB的隔离级别如何借助MVCC和间隙锁来压制这些问题以及线上遇到锁等待或死锁时该怎么定位和处理。无论你是刚接触事务的后端新人还是正在准备面试的求职者或者已经在维护线上库的工程师这篇都值得花二十分钟看完。1. 并发控制到底在解决什么问题1.1 从一次真实的“并发事故”说起先说我朋友那个项目的具体场景。订单表里有库存字段下单逻辑大概是三步查库存是否充足、扣减库存、生成订单。单看每一步都没问题但一旦多个用户同时买同一件商品麻烦就出来了。两个事务同时读到库存还剩1件各自都认为可以下单结果库存被扣成负数订单却生成了两笔。这种问题在并发编程里叫竞态条件放在数据库里就是事务并发带来的数据不一致。数据库解决这个问题的手段总结起来就两样一个叫加锁一个叫多版本。MySQL的InnoDB引擎其实两个都用而且用得很巧妙——先用MVCC处理普通的读请求再用锁应对写冲突两者配合才撑起了事务的隔离性。理解这一点特别重要。很多人把“并发控制”等同于“加锁”觉得只要把读写都锁住就安全了。但那样做的代价是性能断崖式下跌因为读写互相阻塞并发量根本起不来。MVCC的思路则是读不加锁写不加锁读写之间也不阻塞只有写写之间才需要锁来仲裁。这个设计直接决定了MySQL在高并发下的吞吐能力。1.2 隔离性与一致性的平衡艺术事务的ACID四大特性里和并发控制关系最紧密的是IIsolation隔离性。隔离性描述的是多个事务同时执行时彼此之间应该隔离到什么程度。完全隔离当然最安全但代价是只能串行执行性能归零。完全不隔离性能最好但数据会乱成一锅粥。所以SQL标准定义了四个隔离级别本质上是在数据一致性和并发性能之间做权衡。InnoDB默认用的是REPEATABLE READ可重复读这也是MySQL和Oracle、PostgreSQL一个很不一样的地方——Oracle默认是READ COMMITTED读已提交而MySQL在可重复读这个级别上通过MVCC和间隙锁做到了比标准定义更强的隔离效果基本消灭了幻读。后面我会反复提到几个关键术语脏读、不可重复读、幻读。先给个直觉化的理解脏读是读了别人没提交的数据不可重复读是同一行数据两次读不一样幻读是同一个范围两次查出记录数不一样。它们是三种不同维度的问题对应不同的隔离强度千万别混为一谈。2. MVCC多版本并发控制MySQL的“时间机器”2.1 undo log 版本链与事务IDMVCC的全称是Multi-Version Concurrency Control多版本并发控制。一句话解释数据库里的每一行数据在物理上可能同时存在多个历史版本每个版本都和某个事务绑定。读数据的时候根据当前事务的状态选择一个合适的版本返回这样读操作就不需要等待其他事务提交了。这个多版本结构是怎么来的关键在于undo log。假设有一张用户表id1的name字段初始值是张三。事务A把它改成李四事务B再改成王五。每次修改InnoDB并不会直接覆盖旧值而是先把旧值写入undo log然后在数据行上指向undo log里的对应版本。多次修改下来这行数据就变成了一条版本链当前值王五 - 历史值李四 - 原始值张三。每个事务在开始时会拿到一个单调递增的事务ID修改数据时这行记录会记下是我事务IDX改的。版本链上的每个版本也都带着自己的事务ID。所以当某个事务来读取这行数据时它顺着版本链往回找通过比对事务ID就能判断哪个版本对自己是可见的。注意这里说的版本链保存在undo log里并不完全准确。准确说是undo log记录了反向操作比如update语句会记录update前镜像InnoDB通过它构造出历史版本。理解成版本链没有任何问题面试和实战都够用了。2.2 ReadView如何判断哪个版本对当前事务可见版本链有了接下来核心问题变成一个事务读取数据时该怎么从版本链里挑出版本答案靠ReadView读视图这是MVCC判断可见性的核心数据结构。ReadView在事务执行快照读普通SELECT时生成里面记了四个关键的东西组成要素含义m_ids生成ReadView时当前系统中所有活跃未提交读写事务的ID列表min_trx_idm_ids中最小的那个事务IDmax_trx_id生成ReadView时系统将要分配给下一个事务的IDcreator_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 UNCOMMITTEDInnoDB底层仍然会走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生成新ReadViewREPEATABLE 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想往这个范围里插入一条id15的记录发现间隙被锁只能等待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 age20 WHERE id1会在id1这条聚簇索引记录上加X锁。间隙锁Gap Lock锁定一个区间但不包含区间里的记录本身。它锁的是记录与记录之间的空隙目的是防止其他事务向这个空隙插入新记录。间隙锁之间是兼容的多个事务可以同时持有同一个间隙的间隙锁它只和往这个区间插入数据的操作冲突。临键锁Next-Key Lock记录锁和间隙锁的组合左开右闭区间既锁住记录又锁住前面的间隙。它是InnoDB默认的加锁单位。比如对索引值20加临键锁实际锁定的是(10, 20]这个区间既保护了记录20也保护了10到20之间不能插入新值。三个概念最直观的类比记录锁是锁住车位本身间隙锁是锁住两个车位之间的空位临键锁是车位带前面的空位一起锁。这样你就能理解为什么RR级别下间隙锁能挡插入——因为插入必然要落在某个空隙里空隙被锁就进不来。5.2 加锁规则详解什么时候会锁住一个范围接下来是重头戏什么SQL会在什么索引条件下加什么锁。这是排查问题最需要掌握的部分。第一条规则等值查询命中的唯一索引。比如SELECT * FROM users WHERE id 5 FOR UPDATEid是主键直接命中唯一记录只在id5上加记录锁不需要间隙锁。因为唯一索引的值不存在间隙插入导致幻读的可能——插入一个id5的记录会被唯一约束挡住。第二条规则等值查询未命中任何记录。此时MySQL会在查找路径上的间隙加间隙锁。例如id最大是10执行SELECT * FROM users WHERE id 100 FOR UPDATE锁定的是(10, ∞)这个间隙。另一个事务想插入id50的记录会被阻塞。这个场景特别容易踩坑你以为只是查一下不存在的记录其实锁住了一大片插入空间。第三条规则范围查询。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里的锁相关表用了一条SQLSELECT 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_stateRUNNING还是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再实时查这两张表就能直观看到锁是怎么一步步被加上去的。理解了加锁的粒度你对于为什么这里会卡住的判断就会准确很多。并发控制的门道说深很深涉及锁结构、日志、事务调度说浅也浅核心就是一句话读走多版本避开写写靠锁来仲裁冲突间隙锁补齐幻读的漏洞。把这条主线理解到位再配合几次实盘排查很多东西自然就通了。
返回列表