ARTICLE DETAIL

资讯详情

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

MySQL锁机制与事务实战:从原理到死锁排查与优化

MySQL锁机制与事务实战:从原理到死锁排查与优化 1. 为什么一聊MySQL性能绕不开锁和事务做后端这几年遇到最多的线上事故其实不是代码崩了而是数据库先扛不住了。尤其是当你负责的系统从单机小流量慢慢涨到千万级、亿级数据量MySQL的锁和事务就成了你迟早要正面硬刚的东西。MySQL锁机制负责保证并发下的数据一致性事务则决定了一组操作是“都成功”还是“都失败”两者配合不好轻则接口超时重则整库锁死、订单对不上账。这篇文章不打算给你堆概念而是从原理讲到实战结合我实际排查过的锁等待和死锁案例把MySQL锁机制与事务这条线彻底捋清楚。适合谁来读如果你是刚接触数据库的后端开发、运维或者已经在写业务但被“锁等待超时”“Deadlock found”折磨过这篇文章能帮你建立一套完整的排查思路。我会先讲事务和锁到底在解决什么问题再拆InnoDB的行锁、间隙锁、临键锁是怎么配合的然后是MVCC和隔离级别的实现细节最后是几个真实案例的定位方法和优化手段。看完不敢说你能解决所有问题但至少再遇到锁相关的报错你知道该往哪个方向查而不是瞎重启。1.1 事务到底解决了什么问题ACID拆开看先说事务。很多新人背事务的ACID四个特性背得滚瓜烂熟原子性、一致性、隔离性、持久性。但真到用的时候“我到底该不该开事务”这个问题很多人其实没想明白。我给你翻译成人话原子性你转账时扣款和加款必须同时成功或同时失败不能只扣不加。一致性事务执行前后数据库的约束、账目总数不能被破坏钱不会凭空多出来。隔离性两个事务同时改同一行数据最终结果不能互相干扰出脏数据。持久性事务提交了数据就不能丢哪怕下一秒机器断电。这四条里面原子性、一致性、持久性相对好理解真正的难点在隔离性——因为它需要通过锁和MVCC来实现而这恰恰是面试和实战里最容易翻车的地方。我早年接手过一个订单系统当时为了“性能”把一批更新操作全部放在没有事务的普通SQL里执行结果某次库存扣减成功了但订单创建失败用户付款成功却看到“下单失败”。老板半夜打电话问为什么对不上账那一刻我才意识到事务不是锦上添花而是数据正确性的底线。1.2 没有锁的话并发事务会乱成什么样假设没有锁两个事务A和B同时把同一行库存从100改成90A读到的可能是B改到一半的中间值或者A的修改直接被B覆盖。这就是经典的丢失更新、脏读、不可重复读问题。具体来说脏读事务A读到了事务B未提交的修改。比如B把库存改成50但还没提交A读到了50随后B回滚库存实际还是100A拿着50去做了业务判断那就错了。不可重复读事务A两次读同一行中间事务B提交了修改A第二次读到的值和第一次不一样。幻读事务A按条件查询一批数据事务B插入了一条新记录并提交A再次查询时多了一行“幻影”数据。锁机制就是用来压制这些问题的。但锁也不是越严越好——你把整张表锁住并发全串行化了性能会暴跌到不可接受。所以MySQL才设计了多层次的锁机制让开发者根据业务场景在“安全”和“性能”之间做权衡。2. 锁机制底层拆解全局锁、表锁、行锁到底锁的是什么MySQL里的锁从粒度上分大体是三层全局锁、表级锁、行级锁。很多人只听说过行锁但实际线上遇到“锁表”问题往往是表级锁或者元数据锁引发的。我按层次逐个拆。2.1 全局锁与表锁什么时候该用、什么时候千万别碰全局锁是MySQL最狠的一招用FLUSH TABLES WITH READ LOCK简称FTWRL可以把整个实例变成只读。所有DML写操作都会阻塞所有表都只能查不能改。这招在早期做全库逻辑备份的时候经常用因为它能保证备份期间的数据一致性。但我要提醒一句现在你基本不该在业务高峰期用全局锁。我见过一个团队为了导数据直接在生产库上执行FTWRL结果整个核心链路写操作全部卡住接口超时率飙到100%最后只能紧急kill备份进程才恢复。整个过程不超过三分钟但损失已经造成了。表锁分为两种一种是显式的LOCK TABLES另一种是MySQL自动加的元数据锁MDL锁。显式表锁在业务代码里基本没人用了因为InnoDB有更细粒度的行锁没必要用粗粒度的表锁去牺牲并发。但MDL锁值得注意——当你执行ALTER TABLE修改表结构的时候MySQL会自动申请表的MDL写锁这个期间所有读写都会被阻塞。大表DDL导致的长时间锁等待是很多线上事故的“隐形杀手”。2.2 行锁、间隙锁与临键锁InnoDB的行锁不是你以为的“锁一行”InnoDB的行锁才是重头戏。很多人都知道InnoDB支持行级锁但实际操作中经常发现“我只改了一行为什么整个范围的记录都被锁了”这是因为InnoDB的行锁并不只是在索引记录上加锁而是分三种记录锁Record Lock锁住索引记录本身。间隙锁Gap Lock锁住索引记录之间的“间隙”防止其他事务在这个间隙里插入新记录主要解决幻读问题。临键锁Next-Key Lock记录锁和间隙锁的结合锁住“记录记录前面的间隙”。这里有个关键认知InnoDB的行锁是建立在索引上的。如果你的SQL没有走索引那InnoDB只能退而求其次对全表所有记录加上锁——表面上你只更新了一行实际上把整张表的行锁都占了。这就是“SQL没有命中索引导致大量锁等待”的根因。我建议你记住这张对照表锁类型锁的粒度主要作用触发场景风险点记录锁单行索引记录阻止其他事务修改同一行等值查询且主键/唯一索引命中低正常并发场景间隙锁索引记录之间的间隙阻止其他事务在间隙内插入RR隔离级别下的范围查询容易扩大锁范围临键锁记录前置间隙解决幻读RR隔离级别下的等值/范围查询锁范围比预期大意向锁表级标记协调表锁与行锁DML操作自动加几乎无感知理解临键锁是理解InnoDB锁机制的关键。举个例子表里有id为1、5、10三行数据你执行SELECT * FROM t WHERE id 3 FOR UPDATE在可重复读隔离级别下InnoDB不仅会锁住id5和id10这两行还会锁住5到10之间、10到正无穷之间的间隙。这意味着其他事务想插入id7的数据会被阻塞。从业务角度这保证了“你查询到的范围不会在事务期间多出数据”但从并发性能角度你的锁范围可能已经超出了你的直觉。2.3 意向锁表锁和行锁之间怎么共存的意向锁这个概念很多人不理解但它在MySQL内部非常重要。想象一下事务A给某一行加了行锁事务B想给整张表加表锁。如果没有机制告诉B“这表里已经有人锁了行”B就得遍历整张表的行锁来判断效率极低。意向锁就是用来解决这个问题的事务A在加行锁之前会先给表加一个“意向锁”作为标记表示“我准备在这张表的某些行上加锁”。这个意向锁有两种意向共享锁IS锁表示事务准备加共享行锁。意向排他锁IX锁表示事务准备加排他行锁。表锁和意向锁的兼容关系是这样的意向锁之间互相兼容IS和IX都可以同时存在但表级共享锁S锁和IX锁不兼容表级排他锁X锁和任何锁都不兼容。这个机制其实就是一个“先声明、后操作”的协议让你在加表级锁之前就能快速判断是否与现有行锁冲突避免全表扫描判断。我最初学这块的时候也觉得“意向锁跟我没关系”直到有一次排查锁等待发现SHOW ENGINE INNODB STATUS里的锁信息里全是IS和IX标记才意识到读懂这些信息是定位问题的基本功。所以别跳过去后面实战部分你还会见到它们。3. 事务隔离级别与MVCC同一个SELECT结果为什么不一样锁是“悲观”地阻止并发冲突MySQL还有一个“乐观”的思路MVCC多版本并发控制。简单说MVCC通过保存数据的多个历史版本让读操作不用加锁也能看到一致的数据快照。这是InnoDB高性能的核心秘密。3.1 四种隔离级别分别能防住什么先看隔离级别的定义。SQL标准定义了四种隔离级别InnoDB默认是REPEATABLE READ可重复读RR隔离级别脏读不可重复读幻读并发度READ UNCOMMITTED可能可能可能最高READ COMMITTED避免可能可能高REPEATABLE READ避免避免可能InnoDB可避免中SERIALIZABLE避免避免避免最低这里有个很多人误解的点标准SQL里说RR隔离级别下幻读是可能发生的但InnoDB通过临键锁和MVCC在RR级别下就把幻读解决了。这也是面试高频问题“InnoDB为什么在RR级别下就能避免幻读”的答案。READ UNCOMMITTED基本没人用因为它允许读到别人未提交的数据没法保证任何一致性。READ COMMITTED是很多其他数据库比如Oracle的默认级别它比RR的并发度稍高因为它不保留间隙锁只在读取时每次生成新的快照。但代价是同一个事务内两次查询结果可能不一致。SERIALIZABLE是最极端的所有读都是当前读全部加锁并发性能惨不忍睹一般只用于对一致性要求极高且并发极低的场景。3.2 MVCC快照读的实现undo log和read viewMVCC的底层是三个关键组件隐藏列、undo log、read view。InnoDB的每行记录都有两个隐藏列DB_TRX_ID最近一次修改这行的事务ID和DB_ROLL_PTR回滚指针指向undo log中该行的旧版本。当你更新一行数据时InnoDB不会直接覆盖旧数据而是把旧版本写入undo log新版本保留在那里回滚指针指向旧版本。这样一条记录就形成了一个版本链。当执行普通的SELECT快照读时InnoDB会根据当前事务的read view来决定这条记录对当前事务是否可见。read view的核心信息包括创建这个read view时活跃的事务ID列表。列表中的最小事务ID。已创建的最大事务ID。判断规则很简单如果记录的DB_TRX_ID小于最小活跃ID说明这条记录在read view创建时已经提交可见如果DB_TRX_ID大于等于最大ID说明这条记录在read view创建之后才修改不可见如果在两者之间则要看它是否在活跃列表中——在列表中就是未提交不可见。不可见时沿版本链往上找直到找到可见版本。用生活类比解释MVCC就像图书馆里的“借阅历史”。你借书的时候给你一张“现场快照”read view记录了当时谁在借书活跃事务。之后别人还书、借书提交事务都不会影响你手里这本“快照”的页码。你每次翻开来读看到的都是快照时刻的内容。3.3 当前读与快照读很多人栽在这里MVCC解决的是快照读普通SELECT但SELECT ... FOR UPDATE、UPDATE、DELETE这些操作属于“当前读”它们不看历史版本只读当前最新版本并且会加锁。这就是为什么有些人在事务里先查一次数据再更新结果发现数据变了——因为查询走的是快照读更新走的是当前读两者看到的“数据版本”可能不一致。举个例子。事务A开启后先SELECT查了一下库存是100。这时事务B把库存改成了80并提交。A再次SELECT因为是快照读看到的还是100。但A如果执行UPDATE操作的是当前版本80更新结果会基于80计算。如果你在代码里先读后算再写就可能出现“用100做基准实际覆盖了80”的并发问题。这个坑我在写库存扣减逻辑时真实踩过。当时代码逻辑是“先查库存判断是否充足再扣减”在并发下出现了超卖。后来改成UPDATE语句里直接写条件SET stock stock - 1 WHERE stock 0利用当前读加锁保证原子性才解决。说到底MVCC把读和写分成了两套机制你脑子里必须时刻清楚我在做快照读还是当前读。4. 实战避坑锁等待、死锁的定位与处理理论知识再多不如真实排查一次。这一节我分享几个我处理过的真实场景并给出可以直接用的SQL和思路。4.1 锁等待超时怎么查线上最典型的报错是Lock wait timeout exceeded; try restarting transaction。这个报错的意思是当前事务等待获取锁的时间超过了innodb_lock_wait_timeout默认50秒事务被回滚。接到这种报错第一步不是看业务代码而是先查当前有哪些锁在等待。用下面这条SQL在MySQL 5.7及以上的information_schema里查SELECT r.trx_id AS waiting_trx_id, r.trx_state AS waiting_trx_state, r.trx_started AS waiting_started, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_state AS blocking_trx_state, b.trx_query AS blocking_query, b.trx_mysql_thread_id AS blocking_thread_id FROM information_schema.innodb_lock_waits AS w JOIN information_schema.innodb_trx AS r ON w.requesting_trx_id r.trx_id JOIN information_schema.innodb_trx AS b ON w.blocking_trx_id b.trx_id ORDER BY r.trx_started;这个查询能直接告诉你哪个事务在等待哪个事务堵住了它。拿到阻塞事务的线程ID后可以进一步用SHOW ENGINE INNODB STATUS看锁详情或者用performance_schema.data_lock_waits8.0版本查看更细粒度的锁等待信息。我的排查习惯是先确认阻塞方是谁再判断这个阻塞事务能不能尽快结束。如果阻塞事务是一个挂了很久的“僵尸事务”——比如代码里开了事务但一直没提交最直接的办法是找到它的线程ID然后KILL掉。但注意KILL之前一定要确认这个事务没有在执行关键业务否则会造成数据不一致。4.2 死锁日志怎么读死锁Deadlock和锁等待超时是不同的概念。锁等待是“我等你的锁你等我的锁但你迟早会释放”死锁是“我等你的锁你也在等我的锁谁也等不到只能靠数据库的检测机制强制回滚其中一个”。死锁的日志藏在SHOW ENGINE INNODB STATUS里。执行这条命令后在输出中找到LATEST DETECTED DEADLOCK段你会看到类似下面这样*** (1) TRANSACTION: TRANSACTION 12345, ACTIVE 3 sec MySQL thread id 6789, OS thread handle 123456 update t_order set status 2 where order_id 100 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 1 page no 3 n bits 80 index PRIMARY *** (2) TRANSACTION: TRANSACTION 67890, ACTIVE 2 sec update t_order set status 2 where order_id 101 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 1 page no 3 n bits 80 index PRIMARY *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 1 page no 3 n bits 80 index PRIMARY读死锁日志的关键是看两个事务各自“持有”什么锁、“等待”什么锁。死锁的本质是锁的获取顺序不一致。比如事务A先锁订单100再锁订单101事务B先锁订单101再锁订单100两个事务交错等待就形成环。我们的死锁处理策略是三层第一死锁发生后MySQL会自动回滚代价较小的事务业务代码里要做好“捕获死锁异常并重试”的逻辑第二从设计上规避死锁——所有涉及多行更新的场景按固定顺序加锁比如按主键ID从小到大排序后逐条更新第三尽量缩短事务时间减少锁持有时间降低碰撞概率。4.3 三个典型案例与修复方案案例一更新未命中索引导致全表行锁。背景是库存表更新时SQL的WHERE条件用了非索引字段product_name导致InnoDB只能对所有记录加锁。表现是任何一个UPDATE都互相阻塞并发一上来就大量锁等待。修复方案很简单给product_name加上索引或者把更新条件改成走主键id。这个案例给我最大的教训是写完SQL先看执行计划EXPLAIN里type是ALL说明全表扫描就要警惕锁范围扩大。案例二大事务长时间持有锁。背景是批量导入任务里一次事务处理了5万条数据每一条都执行一次UPDATE整个事务运行了十几分钟。期间任何对这个表的写操作全部排队。修复方案把大事务拆成小事务每500条提交一次同时限制批处理时间。这个方案牺牲了一点“要么全部成功要么全部失败”的原子性但在多数非核心批处理场景代价是可接受的。案例三RR隔离级别下间隙锁导致的插入阻塞。背景是两个事务都在插入同一张表且插入的id范围有交集。事务A先插入id5还在事务中事务B插入id6时被间隙锁阻塞导致B等满50秒报超时。修复方案如果业务对幻读不敏感可以把隔离级别调整为READ COMMITTED减少间隙锁或者重新设计主键生成策略避免插入范围冲突。现象可能原因快速定位命令修复方向Lock wait timeout exceeded其他事务持有锁过久information_schema.innodb_lock_waits找到阻塞事务并处理优化事务时长Deadlock found多事务加锁顺序不一致SHOW ENGINE INNODB STATUS统一加锁顺序缩短事务加入重试逻辑大量锁等待且SQL无索引命中WHERE条件无索引EXPLAIN查看执行计划补充索引调整SQLALTER TABLE长时间卡住大表DDL的MDL锁等待SHOW PROCESSLIST使用在线DDL工具或低峰期执行5. 高并发场景下的事务与锁优化实践掌握了原理和排查手段最后要聊的是怎么在写业务代码时从源头上减少锁问题。这一节讲几个我实践中总结出来的原则。5.1 事务瘦身与锁粒度控制事务的黄金法则是事务要尽可能短锁的范围要尽可能小。很多人写代码习惯把业务逻辑全部塞进事务里包括调用外部接口、发送MQ消息、甚至等待用户输入这些都是锁坏掉的常见原因。一个合理的修改是在事务外先完成所有前置校验和准备操作事务内只执行真正需要原子性的数据变更提交后再发消息、记录日志。比如下单的逻辑商品校验和价格计算放在事务外事务内只做库存扣减和订单表插入。我见过一个极端案例一个事务里调用了第三方支付接口网络超时导致事务挂起30秒这30秒内库存行一直锁着后面所有下单用户全部超时。把外部调用挪出事务后问题立刻消失。另外锁粒度控制也很关键。如果业务逻辑只关心某一行就不要用SELECT ... FOR UPDATE锁整个范围。用FOR UPDATE时要注意它锁的是“索引范围”不是“查询结果的数量”。你WHERE id IN (1,2,3)只会锁3行但如果WHERE create_time 2024-01-01命中了大量记录且没有合适索引锁的范围就完全失控了。5.2 索引与锁的关系索引选错锁全部遭殃前面已经反复提到索引和锁的关系这里再强调一次。InnoDB的锁单位是索引记录索引选得好不好直接决定锁粒度的粗细。三条核心原则第一更新和删除语句的WHERE条件必须走唯一索引或主键这样锁的才是确定的行如果走二级索引且扫描范围大InnoDB会先锁二级索引记录再回表锁主键记录锁的数量翻倍。第二联合索引的设计要考虑最左前缀避免范围扫描扩展到不必要的数据。第三能用等值查询就别用范围查询等值查询最多加间隙锁范围查询会带上临键锁锁的范围大得多。我以前优化过一条慢SQLUPDATE t_order SET status 3 WHERE user_id 123 AND create_time 2024-06-01。索引是单独的user_id索引和单独的create_time索引优化器最后选择了全表扫描。加了(user_id, create_time)联合索引后不仅查询变快了锁的范围也从全表缩小到精确的行整体并发性能显著提升。这就是索引和锁联动优化的实际收益。5.3 分布式事务与本地锁的边界思考热词里反复出现“分布式事务”“订单与库存分布式事务”这里多说两句。很多人以为分布式事务能解决所有一致性问题但实际上分布式事务比如TCC、Saga的本质是“用业务逻辑补偿换取最终一致性”它并不能替代本地数据库事务和锁的约束。在分布式事务场景下每个参与方订单服务、库存服务各自维护独立的数据库本地事务本地事务的锁机制依然在起作用。你在设计时要注意两点一是分布式事务中的每一步本地事务尽量短避免“整个分布式事务运行期间一直持有本地锁”的设计二是配置好超时和重试机制本地锁等待超时后要能触发分布式事务的补偿流程而不是无限挂起。我遇到过一种糟糕的设计一个分布式事务里订单服务先锁定用户的金额再远程调用库存服务扣库存如果库存服务响应慢用户金额行就被一直锁着其他业务全部卡住。正确的做法是让分布式事务的每个分支都尽量自治尽快提交本地事务用消息队列或状态机去推进后续流程。另外我建议所有做高并发业务的团队把innodb_lock_wait_timeout从默认的50秒调小一些比如10秒。这样锁等待不会变成“无声的长阻塞”而是快速失败、快速重试反而更接近“快速失败、快速反馈”的高并发设计原则。当然这个值不能太小太小会导致短时锁竞争就直接报错需要根据业务接口的RT做调优。最后再分享一个小技巧线上排查锁问题不要一上来就KILL进程或者改隔离级别先花五分钟看SHOW ENGINE INNODB STATUS和information_schema.innodb_trx把事务链路理清楚。多数锁问题其实不是MySQL配置不对而是业务代码里的事务范围太大、SQL没走索引、加锁顺序不一致这些“人祸”。把这些源头管住锁和事务就不会在深夜把你从被窝里叫起来。
返回列表