ARTICLE DETAIL

资讯详情

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

MySQL事务与锁机制:高并发下数据一致性的核心实践

MySQL事务与锁机制:高并发下数据一致性的核心实践 前几天线上有个订单服务在处理并发库存扣减时库里出现了负数库存。我翻了两小时日志最后定位到的问题并不在业务逻辑而是事务没包住边界、锁没有按预期兜底。说真的只要你的系统要同时处理多人下单、多人抢购、多节点并发写数据MySQL事务与锁就是没法绕开的两块基石。无论你是在写存储过程、设计表结构还是排查线上死锁理解“事务到底隔离了什么”、“锁到底锁住了谁”都比背一百条优化口诀管用。这篇文章不是从概念到概念的抄书我会直接按照我梳理后的思路来写先搞清楚高并发下事务与锁分别解决什么问题再拆MySQL的隔离级别和MVCC然后深入InnoDB的行锁、间隙锁和死锁场景最后结合Spring事务失效、扣库存方案、分布式锁这些真实能踩到的坑给你一套可复用的实操经验。适合正在做后端开发、数据库设计或者准备技术面试的读者。看完之后你至少能回答“RR隔离级别下到底有没有幻读”、“行锁为什么有时候会退化成表锁”、“死锁怎么定位”这几类高频问题。1. 高并发数据系统里事务与锁到底在扛什么1.1 一个卖超的库存告诉你事务要解决什么问题先看一个非常经典的超卖场景。假设商品库存表里有一条记录UPDATE stock SET count count - 1 WHERE sku_id 100;如果两个用户同时发起下单在没有事务和锁保护的情况下两个会话都可能先读到同样的库存快照比如count5然后各自减一最后两个会话都写入count4。实际情况是两人都下单成功库存却只扣了两次中的一次。如果继续并发下去库存就可能变成负数账目全乱。这个例子想说明的是高并发业务里“多线程操作同一份数据”天然是错乱的根源。事务主要干的一件事就是把一组读写操作打包成一个原子单位要么全部成功提交要么全部回滚不会出现“A语句成功、B语句失败”的中间状态。这就是事务的原子性。我习惯用记账本类比来跟新人解释你和同事共用一本账本一个人记支出一个人记收入。如果两次记账之间另一个人又改了余额你们互相盖掉了对方的记录月底怎么都对不上。事务就是给“读余额、改余额、写回余额”这套动作设定一个不可分割的边界让别人不能钻到中间来操作。除了原子性事务还有一致性、隔离性、持久性合起来就是ACID。一致性是业务层面的比如“库存不能为负”隔离性是并发层面的决定一个事务能看到别人多少未提交的数据持久性是故障恢复层面的提交了的事务即使数据库崩溃也不能丢。高并发系统之所以难写是因为这四个属性需要在性能与可靠之间反复权衡而实现权衡的核心工具就是锁和日志。1.2 锁是用来“排队”的悲观锁与乐观锁的基本思路事务解决“一组操作要不要成立”锁则解决“多个操作同时碰同一份数据时怎么排队”。其实从使用者的视角看锁就两大类思路。悲观锁的思路是我先锁住这一行或者这一张表别人不允许同时修改操作完再释放。数据库里最常见的实现就是SELECT * FROM stock WHERE sku_id 100 FOR UPDATE;执行这条语句后事务A对sku_id100那行加了排他锁事务B再想更新这一行时只能排队等着。这种方式在写冲突激烈的场景里非常稳能严格串行化对同一数据的修改不会出现互相覆盖。缺点也明显锁带来的等待时间变长极端情况下容易退化成排队系统。乐观锁的思路则相反我不加锁但我相信冲突概率很低。更新前先读取一个版本号或者旧值更新时在SQL条件里带上这个版本号如果版本号变了就说明数据已经被别人改过了本次更新失败重试即可。典型写法UPDATE stock SET count count - 1, version version 1 WHERE sku_id 100 AND version 1;影响行数为0就说明这个版本已经不存在需要重新查询再试一次。乐观锁的特点是不阻塞读读多写少的场景性能特别好写冲突多的时候则会出现大量无效更新和重试反而拖垮数据库。所以从应用层面看锁不光是数据库内部机制也是你在代码里做并发控制的一种设计选择。后面我会专门展开行锁、间隙锁、死锁因为数据库底层的锁策略才是真正决定性能的变量。2. MySQL事务机制拆解隔离级别、MVCC与日志2.1 四种隔离级别与三种并发读异常MySQL的SQL标准里定义了四种隔离级别它们本质上是在“我看得见别人的哪些未提交数据”这个问题上做不同程度让步。读未提交Read Uncommitted一个事务能读到另一个事务还未提交的数据会导致脏读。比如订单金额还没最终确认对端已经读到临时数字去计算分账数据一旦回滚就全错了。这在实际系统里基本不采用。读已提交Read Committed只能读到别人已经提交的数据解决了脏读但仍存在不可重复读。同一个事务里两次执行相同的SELECT可能因为别人的提交得到不同结果。可重复读Repeatable ReadMySQL InnoDB的默认隔离级别。保证一个事务里多次读取同一份数据结果一致。它由MVCC快照读机制实现并配合间隙锁解决大部分幻读问题。串行化Serializable所有读写都强制加锁一个一个事务排队执行并发能力最低但一致性最强。这里要特别说明的是三种并发读异常脏读、不可重复读、幻读。不可重复读指同一行的内容变了比如订单状态从“待支付”变成了“已支付”幻读指同一个查询条件下记录的行数变了比如第一次查没有库存记录第二次查突然插入了一行新的记录。关键在于即使一个事务始终使用同一条SQL另一个事务的插入操作会让返回结果集变多这就是幻读。MySQL在可重复读级别下其实没有完全消除幻读只是通过MVCC让快照读看不到新插入的记录。如果你改为使用SELECT ... FOR UPDATE这种当前读它就会走最新数据还是可能读到别的事务刚刚提交的新行。为了阻止别人在区间里插入新行InnoDB引入了间隙锁我在下一章会详细拆。2.2 MVCC快照读与undo log版本链InnoDB的多版本并发控制是理解事务隔离的关键。它不会为每个SELECT都加锁而是采用版本链的方式给每条记录保留多个历史版本读到哪个版本由事务启动时生成的Read View决定。每条数据行除了业务字段还会携带事务ID和回滚指针。每次更新操作不会直接覆盖旧值而是先把旧值写进undo log形成一条版本链。当快照读发生时InnoDB会基于当前活跃事务列表构造Read View里面包含一组“正在执行且未提交”的事务ID。如果记录版本的事务ID比Read View里最早活跃的事务ID还小说明这个版本已经提交可以读取如果事务ID落在活跃事务列表里说明这个修改还没提交要沿着版本链往回找到上一个可用版本读取。举个例子事务A把库存从5改成4但还没提交。事务B此时执行普通SELECT正常情况下看到的是5而不是4因为B的Read View里把A标记为未提交B会沿版本链找到修改前的版本5。这就是MVCC实现“读已提交/可重复读”的底层原理。另外一个一定要分清的概念是快照读和当前读。普通SELECT不带FOR UPDATE、LOCK IN SHARE MODE就属于快照读UPDATE、DELETE、INSERT这种写操作以及带FOR UPDATE的查询都属于当前读一定要读最新版本并加锁。高并发项目里最容易出的问题就是“我以为SELECT看到的和更新时用的数据一样”其实一个走快照、一个走当前版本中间被别的事务改掉了都不知道。2.3 redo log、undo log与崩溃后的数据可靠性事务承诺了持久性这个承诺靠的不是直接写磁盘数据页而是WALWrite-Ahead Logging机制。简单说事务提交时InnoDB先把这次修改记录到redo log再把数据页的变更落盘。即使数据库突然崩溃只要redo log还在重启后就能按日志重放把数据恢复到崩溃前最后一个已提交状态。我需要特别提醒一点redo log是InnoDB层的物理日志记录的是“哪个数据页的哪个位置改成了什么值”。而binlog是MySQL Server层的逻辑日志记录的是SQL语句或者行变更事件用于主从复制、数据恢复和时间点回放。两套日志都要写两套日志还必须保持一致所以InnoDB在提交事务时采用两阶段提交先写redo log并标记为prepare再写binlog最后把redo log标记为commit。如果这一步处理不好就会发生“主库有数据、从库没同步”或者相反的情况。在实际运维里我见过不少人只关注了业务SQL忽略了日志机制带来的性能影响。大事务意味着redo log、binlog、undo log同时膨胀。特别是undo log并不只是给事务回滚用的它同时服务于MVCC快照读。如果有一个休眠的长事务一直不提交它的Read View一直不释放旧版本的undo数据就不能清理最终会看到undo表空间疯狂增长磁盘占用飙升。这个点我在后面讲大事务问题时还会再提。3. InnoDB锁机制的完整拼图从表锁到间隙锁3.1 全局锁、表锁、行锁各自的使用边界MySQL的锁按粒度大致可以分成全局锁、表级锁和行级锁。全局锁最常见的就是FLUSH TABLES WITH READ LOCK它会停止所有写操作让整个数据库处于只读状态主要用于全库备份场景。因为会影响线上业务一般只在低峰期使用或者干脆用Percona的物理备份工具配合GTID实现不停机备份。表级锁又包括多种。显式的LOCK TABLES t WRITE会锁住整张表MDL锁元数据锁则是MySQL内部保护表结构的锁比如一个ALTER TABLE在跑另一个事务想查询这张表就可能被MDL锁阻塞。InnoDB里还有一种意向锁它是一种“表级别标记”事务打算在一行上加共享锁或排他锁前先给表打上意向锁标记方便后续表锁判断冲突不用逐行检查。行级锁才是InnoDB真正体现高性能的武器。InnoDB的行锁是建立在索引上的如果你更新的条件没有命中使用索引MySQL会扫描全表锁住的就不是一行而是一大片记录等同把整张表锁住。这也是“行锁变成表锁”最常见的来源。所以优化器没走对索引你的锁粒度就会失控必须通过执行计划检查是否用上了合适的索引。3.2 行锁、间隙锁与临键锁的加锁场景InnoDB的行锁可以细分成三类。Record Lock记录锁锁住的是索引记录本身。比如WHERE id 10且id是唯一索引则只锁这行。Gap Lock间隙锁锁住的是两条记录之间的区间目标是防止其他事务在这个区间插入新记录。间隙锁的典型场景就是可重复读隔离级别下防止幻读当一个事务用条件范围去查询并加锁时即使条件范围内没有这条记录它也会把这条记录前后的区间锁住别人要想往这个区间插入数据就会阻塞形成“间隙锁堵插入”。Next-Key Lock临键锁是记录锁和间隙锁的组合锁的范围是一个左开右闭的区间。举个例子表里id只有1、5、9三条记录你执行SELECT * FROM t WHERE id BETWEEN 1 AND 5 FOR UPDATE;最终被锁的范围可能包括(1,5]这个区间以及5之后的间隙具体边界还要依赖索引数和查询条件。这是因为MySQL想防止你在这个查询范围内读到一个幻影新行。这个机制很反直觉的一点是普通索引上的等值查询也可能加间隙锁。比如SELECT * FROM t WHERE name order_100 FOR UPDATE;如果name列有普通索引但表里没有找到完全匹配的记录它会把该记录可能存在的间隙锁住避免其他事务先插入一条相同的name记录。很多面试题爱问“没有命中索引就不会用到行锁直接退化成表锁”这句话的准确理解应该是没有索引时需要全表扫描去定位目标行扫描过程中访问到的所有记录都会加锁结果范围就跟全表锁一样了。判断标准永远是先看执行计划里有没有可用的索引。3.3 死锁的成因、排查与规避死锁是锁等待的一种极端结果事务A持有了资源1的锁等待资源2事务B持有了资源2的锁等待资源1。两个事务互相等待谁都不会让最后只能靠MySQL的死锁检测机制让其中一方回滚。我实际线上遇到过一个典型场景两个事务都在操作订单表和库存表但操作顺序相反。事务A先更新订单表再更新库存表事务B先更新库存表再更新订单表。两者并发跑到一半互相占住了对方下一次要更新的表于是立刻死锁。排查方法很简单SHOW ENGINE INNODB STATUS;该命令的LATEST DETECTED DEADLOCK部分会明确显示两个事务各自执行的SQL、持有的锁、等待的锁。我每次处理死锁都是先靠它定位SQL再逐个看业务代码的加锁顺序。规避死锁最有效的办法不是等数据库回滚而是把所有涉及多张表的更新操作都按照统一顺序执行。比如规定“先更新订单再更库存”所有代码里都遵守死锁概率会断崖式下降。另外事务要尽量短锁只持有到提交不在事务里做RPC调用和耗时的外部请求。还有一点尽量用相同索引条件去更新行避免扫描范围过大导致锁范围不可控。4. 业务落地中的事务与锁实操记录4.1 Spring事务失效背后的几个真实原因代码层面最隐蔽、最容易出问题的就是Spring的声明式事务。很多人以为只要加了Transactional事务就会生效但实际踩坑后才发现有几种情况事务根本不开启。第一是自调用问题。同一个类里一个方法调用另一个带Transactional的方法事务默认是失效的因为Spring事务基于AOP代理内部this.method()调用并没有经过代理对象。解决办法是拆到另一个Bean里或者自己注入代理对象再调。第二是异常被吞掉。事务方法里自己catch了异常Spring事务管理器感知不到异常发生自然就不会回滚。我建议只在确实需要吞异常时catch并且把异常标记记录下来或者直接抛出RuntimeException配合rollbackFor指定回滚条件。默认情况下Spring只对RuntimeException回滚受检异常不会触发回滚这也是个高频失忆点。第三是方法修饰符限制。Transactional应用在非public方法上时默认不生效。这不是语法报错却会让代码静默进入无事务状态特别难察觉。写的时候养成习惯事务方法一定是public并且由外部Bean调用入口进入。第四是事务传播行为理解不透。REQUIRES_NEW会挂起当前事务再开一个新事务如果方法A有事务又去调用开启了REQUIRES_NEW的方法BB提交慢的话A持有的锁并不会释放。这会导致锁持有时间变长进而引发链条式的锁等待。4.2 高并发扣库存与事务锁的几种正确解法“扣库存”几乎是高并发系统的标准考题。初级做法是先SELECT库存判断够不够再UPDATE扣减。这种方式在不加锁的情况下绝对会超卖因为它根本就是两步操作中间天然存在时间窗口。稍微好一点的方案是加悲观锁BEGIN; SELECT count FROM stock WHERE sku_id 100 FOR UPDATE; UPDATE stock SET count count - 1 WHERE sku_id 100; COMMIT;这个方案可行但它把“查询判断”和“扣减”放进了同一个事务并且对一行长时间加锁。单机并发不高时问题不大一旦热点商品被抢购这行记录就成了绝对的串行瓶颈数据库请求堆积锁等待时间指数上升。更推荐用的是条件更新直接一条原子SQLUPDATE stock SET count count - 1 WHERE sku_id 100 AND count 0;如果影响行数为1说明扣减成功如果为0说明库存已经不足业务层回滚或提示失败。这种方式不再需要显式事务包住“查询更新”没有中间快照的时间窗口也不用加锁等待。这里注意即使是在事务里执行你也只需要确保这条UPDATE本身原子不要让会话连着做“读判断”再“更新”。如果并发量已经到了单条热点记录完全扛不住的程度就要考虑把扣减动作前移到Redis里。我在实际项目里使用过的方法是先用Redis NX命令做预扣减扣减成功后异步通知订单服务写MySQL后续再通过每日对账任务保证Redis和MySQL最终一致。比如LOCAL result redis.call(HINCRBY, stock:100, count, -1)或者使用DECR类命令。但要注意Redis方案并不是一劳永逸它引入了缓存、对账、补偿和一致性维护的复杂度。业务规模没到那个量级我不建议你过早上这个方案。先用好MySQL的条件更新和合适的索引大部分场景其实已经够了。4.3 分布式事务与分布式锁的简单取舍单体库里事务和锁都好控制分库分表之后一个事务跨到多个数据库节点普通事务就失效了。这时候要么引入分布式事务方案要么改变数据模型减少跨库事务。我把自己在实际项目里的选择策略分享给你。分布式事务常见有XA两阶段提交、TCC补偿和基于消息的最终一致性。XA由数据库或事务管理器统一协调强一致但锁持有时间会明显变长在高并发场景吞吐容易下滑TCC需要业务自己实现Try、Confirm、Cancel三套逻辑比较复杂适合对最终一致性要求较高而且能接受复杂度代价的核心链路而本地消息表或者MQ事务消息走最终一致性适合订单创建、积分发放这类可以异步补偿的场景也是我平时用得最多的一种。分布式锁在高并发场景也很常用但我不建议一开始就自己用Redis的SETNX加过期时间撸一个锁。原因是这里面坑太多锁过期了但业务还没执行完另一个节点抢到锁导致同一业务并发执行删除锁时判断不严可能误删别人的锁Redis主从切换时锁记录丢失直接双写失控。真要自己实现分布式锁至少要解决原子加锁、唯一标识、续期机制三个问题目前比较成熟的方案是基于Redisson它会自动为锁做看门狗续期基本规避了“锁过期导致并发”的经典风险。说到底分布式锁和分布式事务都是“最后手段”。能通过数据分片或者本地事务解决的就不要引入额外组件引入之前先确定这个跨库操作是否真的必须保持强一致很多时候把要求放宽成最终一致性系统复杂度能降低一半。5. 线上问题排查实录与高频面试考点5.1 锁等待超时怎么定位三张系统表的用法线上出现Lock wait timeout exceeded错误时不要慌张先查清楚谁在持锁、谁在等待。MySQL提供了三张核心表。SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits; SHOW ENGINE INNODB STATUS;innodb_trx可以看到当前正在执行的事务包括事务启动时间、状态、等待锁还是持锁中、最后执行的SQL。innodb_lock_waits直接把正在阻塞的事务关联起来能看到等待的事务ID、阻塞它的事务ID以及对应的锁对象。SHOW ENGINE INNODB STATUS则提供最近一次死锁的详细加锁信息。定位到阻塞事务后如果确认它是僵尸事务比如代码里打开了事务但忘了提交可以直接KILL 事务ID;这个动作的代价是让该事务回滚释放所有锁。线上操作前我会再确认一遍避免误杀正在进行的核心业务。5.2 常见事务与锁的踩坑清单我根据多年的排障经验把最容易反复踩的问题整理成了下面这张表典型问题根因排查与整改建议行锁升级成表锁UPDATE/UPDATE条件未走索引用EXPLAIN看执行计划补索引锁等待超时频繁事务范围过大持锁时间过长拆分事务避免在事务内调用远程接口订单表读取慢且大量阻塞长事务持有MDL锁或行锁不提交查innodb_trxkill僵尸事务可重复读下出现幻读快照读看不到新行但当前读能看到理解快照读与当前读差异必要时用串行化死锁高发多个事务加锁顺序不一致统一全局加锁顺序缩短事务时间undo表空间膨胀长事务长期未提交旧版本无法清理及时提交事务优化慢查询事务回滚没生效Spring异常被catch或非RuntimeException正确设置rollbackFor异常上抛或交给代理处理5.3 面试时怎么答好MySQL锁原理与隔离级别高频面试题几乎都集中在这几个方向我把我的回答思路给你作为参考。如果被问到“MySQL为什么默认用可重复读”我会从主从复制背景切入早期版本binlog基于SQL语句复制如果在读已提交隔离级别下主库事务交替执行产生的binlog顺序到从库重放时可能得到不一致的数据可重复读加间隙锁能相对更可靠地避免这类问题。这个回答比死记硬背“因为官方默认”高一个层次。“MVCC和间隙锁如何配合”是另一个高频考点我的回答结构是三步MVCC通过undo版本链和Read View实现快照读隔离解决普通SELECT的不可重复读间隙锁配合当前读在写操作时锁住区间防止插入幻影行两者结合才是可重复读隔离级别的完整拼图。“乐观锁和悲观锁的实现原理和适用场景”就更好答了。乐观锁用版本号/时间戳配合条件更新读多写少的场景适用悲观锁用SELECT ... FOR UPDATE依赖行锁写冲突激烈的场景适用。答这种题时如果能顺手指出“高并发扣库存推荐原子条件更新而不是显式加锁”面试官会很认可你踩过实坑。坦白说事务与锁学到后面其实就是一个权衡问题。我在实际系统里见过太多“为了技术而技术”的设计数据量刚上百万就急着上分布式事务结果把链路拖得又慢又难排查。我个人一贯的做法是先把事务边界画清楚让每个操作尽可能只碰一张表的一行让锁只在真正需要保护的临界区出现再谈优化和扩容。你对数据访问路径设计得越合理数据库的锁竞争就越小这不是任何参数能替代的。最后分享一个小技巧每次上线前我会在压力测试环境里开启SHOW ENGINE INNODB STATUS定期采集专门去看锁等待和死锁日志。这个习惯帮我提前发现了不少触发性极低的问题很多坑等它们发生在线上时已经不只是技术问题了。如果你能把事务隔离级别、MVCC、行锁间隙锁这条链路想通再回头处理线上高并发故障思路会清楚很多。
返回列表