ARTICLE DETAIL

资讯详情

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

MySQL锁机制全解析:从表锁行锁到死锁排查实战

MySQL锁机制全解析:从表锁行锁到死锁排查实战 大半夜被线上告警叫醒数据库死锁日志刷了一屏Deadlock found when trying to get lock; try restarting transaction反复出现。这种场面干过后端的人应该都不陌生。锁机制是MySQL里最容易被误解、也最影响系统稳定性的部分——读锁、写锁、表锁、行锁、悲观锁、乐观锁、间隙锁光名字就一堆实际用起来更是处处有坑。这篇文章我会把这七种锁逐个拆开讲清楚不绕理论直接说它们在什么场景下起作用、为什么这样设计、用的时候会踩什么坑。文章适合正在做业务开发的工程师、准备数据库面试的同学以及那些被死锁和锁等待折磨过的DBA或后端负责人。1. 表锁与行锁粒度的差异决定了并发天花板1.1 表锁的真实成本和适用场景表锁顾名思义就是把整张表锁住。MySQL早期的主力存储引擎MyISAM只有表锁这也是为什么MyISAM在写入频繁的场景下表现很差——它对并发写入的支持约等于零。表锁的兼容性关系很直白锁类型读锁共享写锁排他读锁兼容不兼容写锁不兼容不兼容也就是说读读不互斥读写、写写都会互斥。在MyISAM时代所有写操作必须串行执行任何一个UPDATE或INSERT都会阻塞其他所有读写操作并发一上来性能就崩了。但表锁并不是一无是处。它的优点是开销小、加锁快而且不会产生死锁——因为整张表就一个锁不存在多个锁资源互相等待的问题。对于单纯读多写极少、或者需要全表批量处理的场景表锁反而更高效。我见过一些报表系统每天定时全量更新某张汇总表这种批量更新如果走行锁要维护成千上万个锁对象开销极大不如直接LOCK TABLE排他锁一把梭。1.2 InnoDB行锁的真正含义锁的是索引InnoDB的行锁才是现代高并发应用的基础。但这里有一个绝大多数人都会忽略的关键点InnoDB的行锁是加在索引记录上的不是加在数据行上的。如果一条SQL没有走索引InnoDB只能扫描主键聚簇索引的所有记录给扫描过程中碰到的每一条记录都加上锁。实际操作中我见过一个非常典型的线上事故UPDATE user SET age 18 WHERE name 张三;假设name字段上没有索引这条SQL执行时会全表扫描聚簇索引对主键索引中的每一行都加锁。虽然最后只更新了一条记录但加锁的范围是全部记录。此时其他事务想更新任意一行数据都得等这个事务提交。表面上是行锁实际效果等同于表锁。所以判断一个UPDATE是否真正行锁不要看它更新了多少行要看它的WHERE条件有没有走索引。1.3 锁粒度选择的实践建议我个人的实践原则是业务系统核心的读写并发请求一律用InnoDB的行锁保证并发度对于批处理任务、数据归档、表结构调整这种独占型操作显式使用表锁或直接LOCK TABLES避免行锁数量过多消耗内存永远不要用无索引字段作为UPDATE/DELETE的过滤条件这不止是性能问题更是锁粒度的灾难。2. 读锁与写锁共享与排他背后的并发妥协2.1 S锁与X锁的语义读锁和写锁不是两种具体技术而是锁的两种基本性质。InnoDB里的行锁分两类共享锁Shared LockS锁事务读一行数据时加S锁多个事务可以同时持有同一行数据的S锁。排他锁Exclusive LockX锁事务写一行数据时加X锁X锁与其他任何锁都不兼容。这里有个容易搞混的点普通SELECT语句在InnoDB下是不加锁的。它走的是MVCC多版本并发控制机制读取的是某个快照版本不需要加锁。这个机制让我经常跟团队里的小朋友解释不是所有读操作都会触发锁机制只有显式加锁的读FOR UPDATE、FOR SHARE和写操作INSERT/UPDATE/DELETE才会真正动用锁。2.2 意向锁表锁与行锁之间的桥梁讲读锁和写锁必须提意向锁不然你查SHOW ENGINE INNODB STATUS看到TABLE LOCK会一头雾水。意向锁是表级锁但它的作用是为了配合行锁。一个事务要给某行加X锁必须先给所在的表加意向排他锁IX要给某行加S锁必须先给表加意向共享锁IS。意向锁之间是兼容的它们只阻塞表级的S锁和X锁请求。举个例子事务A对users表id1这行加了X锁事务B想对整个users表加表级X锁。如果没有意向锁MySQL必须遍历users表的所有行确认没有任何行被加锁才能授予表锁。有意向锁之后直接检查表上的IX锁就能快速判断有行锁存在直接阻塞。这就是意向锁存在的意义——用极小的开销维护了表锁和行锁之间的兼容性判断。2.3 手动加锁的正确姿势实际业务中我们经常需要手动加锁两种方式要区分清楚-- 加S锁读锁 SELECT * FROM account WHERE id 1 FOR SHARE; -- MySQL 8.0之前的老写法 SELECT * FROM account WHERE id 1 LOCK IN SHARE MODE; -- 加X锁写锁 SELECT * FROM account WHERE id 1 FOR UPDATE;FOR SHARE锁定的是共享读锁其他事务仍然可以加S锁读取同一行但不能加X锁修改FOR UPDATE锁定的是排他写锁其他事务的读写都会被阻塞。用FOR UPDATE时有一个天坑必须放在事务里并且要让事务尽快提交锁才会释放。我见过有人直接在事务里查出来、做了大量业务计算甚至远程调用整个链路拖了几秒甚至几十秒再提交把并发请求全堵在一个锁上。这种问题排查起来特别迷惑因为SQL本身没问题问题出在锁的持有时间被严重拉长了。3. 悲观锁与乐观锁从SQL到业务的两种并发策略3.1 悲观锁的典型实现和适用边界悲观锁的核心思想是我先拿到锁再操作数据别人在我操作期间别想碰这条数据。在MySQL里悲观锁的落地方式就是前面提到的SELECT ... FOR UPDATE。什么时候该用悲观锁冲突概率高、重试代价大的场景。比如库存扣减假设库存只有10件却有100个并发请求来抢。如果用乐观锁大部分请求会更新失败需要重试反而放大压力。这时候用悲观锁让请求排队反而是稳定的方案。悲观锁的代码套路是# 伪代码示意 def deduct_stock(product_id, quantity): with db.transaction(): # 对目标行加X锁 row db.query_one( SELECT stock FROM product WHERE id %s FOR UPDATE, product_id ) if row.stock quantity: raise InsufficientStock() db.execute( UPDATE product SET stock stock - %s WHERE id %s, quantity, product_id )注意FOR UPDATE必须配合事务使用否则锁会在语句执行完立即释放悲观锁就形同虚设。另外FOR UPDATE同样遵循走索引才能锁行的规则如果WHERE条件没走索引那就是全表加锁并发直接归零。3.2 乐观锁的版本号设计与CAS陷阱乐观锁不锁数据库行而是靠版本号或时间戳在更新时做校验。它的核心是一个带条件的UPDATEUPDATE product SET stock stock - 1, version version 1 WHERE id 1 AND version 5;执行后如果影响行数为0说明版本号不匹配数据在这期间被其他事务改过需要重新查询、重新计算、再重试。乐观锁本质上是CAS思想在数据库层的实现。CAS有个臭名昭著的ABA问题——A改成B、B又改成A值看起来没变但过程变了。MySQL乐观锁通过版本号每次递增能有效规避ABA问题因为version一旦从1变成2再变回3不会回到旧值。乐观锁的取舍很清晰维度悲观锁乐观锁数据库资源占用高锁等待阻塞低不加锁冲突处理方式排队等待失败重试适用场景写冲突高写冲突低性能瓶颈锁竞争重试风暴一个典型的反面案例某系统在用户签到接口上用了乐观锁正常情况下没问题但运营搞活动时大量用户同一秒签到同一行配置数据重试请求把数据库打爆了。所以在用什么锁之前先评估冲突概率冲突概率低用乐观锁高用悲观锁不存在银弹。3.3 从扣库存场景看两种策略的取舍扣库存是这两种策略最经典的战场。我之前负责过一个秒杀项目最初用的是乐观锁UPDATE goods SET stock stock - 1 WHERE id 123 AND stock 0;这个写法实际上把版本号换成了库存量条件用stock 0作为校验条件。并发量在每秒几百时效果很好但到了秒杀瞬间每秒几万的量级大量请求的UPDATE执行成功但影响行数为0客户端不断重试数据库CPU直接冲高。后来切换到悲观锁方案请求变成串行执行虽然吞吐量数字下降了但每个请求的成功率变得可预测系统反而稳定。这个经验告诉我吞吐量和稳定性之间要做权衡不要只看压测数字。4. 间隙锁与临键锁防幻读的正面战场4.1 幻读为什么行锁治不了先明确幻读的定义在同一个事务里执行两次范围查询第二次查询多出了第一次没有的行。为什么行锁防不了幻读因为行锁只能锁住已经存在的行却管不住其他事务往这个范围内插入新行。事务A查id 10的所有行事务B插入一条id 100的记录并提交事务A再查一次发现多了一条——这就是幻读。InnoDB在可重复读隔离级别下通过MVCC解决了普通SELECT快照读的幻读问题但对于FOR UPDATE这类当前读必须靠间隙锁和临键锁来兜底。4.2 间隙锁和临键锁的加锁区间间隙锁Gap Lock锁定的是一个开区间范围比如两个索引值(5, 10)之间的空隙它不允许其他事务在这个空隙里插入任何数据。间隙锁之间是互相兼容的——多个事务可以同时持有同一个间隙的间隙锁这是它和行锁非常不一样的地方。但间隙锁与插入意向锁冲突所以能阻止新记录插入。临键锁Next-Key Lock是记录锁和间隙锁的合体锁定的范围是左开右闭区间。假设某索引的值有1、5、10那么临键锁覆盖的区间是(-∞, 1] (1, 5] (5, 10] (10, ∞)每个区间既包含边界值本身记录锁也包含边界之前的空隙间隙锁。这个设计有一个容易被忽略的副作用即使是完全等值的唯一索引查询如果记录不存在也可能产生间隙锁。比如执行SELECT * FROM t WHERE id 100 FOR UPDATE;如果id100不存在事务会获取(上一个值, 100]区间到(100, 下一个值]区间之间的间隙锁其他事务就无法在附近插入数据了。这意味着一个看似无伤大雅的SELECT可能阻塞整个区间的写入。4.3 间隙锁引发的死锁黑天鹅间隙锁是死锁的重灾区。最典型的案例是假设表里有id为1和5的两行记录两个事务同时执行-- 事务A BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 4 FOR UPDATE; -- 事务B BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 4 FOR UPDATE;两个事务都成功获得了间隙锁(1, 5)——因为间隙锁之间兼容互不阻塞。然后-- 事务A插入id3 INSERT INTO t (id) VALUES (3); -- 此时A需要插入意向锁但B持有间隙锁(1,5)A被阻塞 -- 事务B插入id3 INSERT INTO t (id) VALUES (3); -- 此时B需要插入意向锁但A也持有间隙锁(1,5)B被阻塞死锁形成MySQL检测到后会选择回滚其中一个事务。这个案列告诉我们事务里加了范围锁之后再做插入操作一定要想到间隙锁互相兼容这个特性。很多死锁不是并发量高触发的而是两个事务恰好在一个空隙上默契地互相等待。另外注意一个隔离级别的差异性间隙锁只在REPEATABLE READ隔离级别下生效。如果你的应用不需要严格的RR语义可以降到READ COMMITTED这样InnoDB只加记录锁不加间隙锁死锁概率会显著下降。很多互联网公司生产环境都用RC就是为了在并发和一致性之间找平衡。5. 死锁排查实录从日志到根因的完整链路5.1 死锁日志怎么看先上最常用的三板斧命令-- 查看InnoDB引擎状态重点看LATEST DETECTED DEADLOCK段 SHOW ENGINE INNODB STATUS\G -- 查看当前正在运行的事务 SELECT * FROM information_schema.innodb_trx\G -- MySQL 8.0查看当前锁信息 SELECT * FROM performance_schema.data_locks\G -- MySQL 5.7及之前版本 SELECT * FROM information_schema.innodb_locks\GSHOW ENGINE INNODB STATUS输出的死锁日志信息量非常大核心要关注这几点TRANSACTION编号和状态判断哪个事务被回滚WAITING FOR THIS LOCK TO BE GRANTED这是事务正在等待的锁HOLD OF THE LOCK或者LOCK HELD这是事务已经持有的锁最终MySQL的裁决回滚代价较小的事务。曾经遇到一个真实案例两个事务做转账-- 事务A UPDATE account SET balance balance - 100 WHERE user_id 1; UPDATE account SET balance balance 100 WHERE user_id 2; -- 事务B UPDATE account SET balance balance - 100 WHERE user_id 2; UPDATE account SET balance balance 100 WHERE user_id 1;两个事务同时执行A锁住了user_id1的行B锁住了user_id2的行然后A想锁user_id2、B想锁user_id1死锁立即形成。这个案例的根因是加锁顺序不一致。5.2 通过系统表定位问题事务死锁日志是事后分析出了死锁MySQL已经帮你回滚了。但更多时候我们面对的是锁等待不是死锁——一个事务迟迟不提交其他事务全部卡死数据库线程池被耗尽。遇到锁等待第一件事查information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_running_seconds, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;重点关注trx_running_seconds很大的事务它们大概率就是元凶。拿到trx_mysql_thread_id后可以通过performance_schema.data_locks查这个事务持有和等待的锁明细SELECT ENGINE_TRANSACTION_ID as trx_id, OBJECT_NAME as table_name, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks WHERE ENGINE_TRANSACTION_ID 某事务ID;LOCK_DATA这个字段会直接告诉你锁在哪一行索引记录上定位问题的效率非常高。5.3 根治死锁的几条实际经验排查完一个又一个死锁之后我总结出几条在业务里真正管用的经验第一统一加锁顺序。多个事务访问多个资源时约定一个固定的顺序比如按主键排序从根源上消除循环等待。转账场景就按user_id大小排序后再执行UPDATE。第二缩短事务时间。锁的持有时间决定了阻塞范围事务里的远程调用、外部接口、复杂计算全部移到事务外面。我曾见过一个事务里调用短信接口超时3秒期间持有100行数据的写锁整个系统的写请求都跟着遭殃。第三确保更新条件走索引。这条怎么强调都不为过——不只是性能问题还关系到行锁是否退化成表锁。第四设置合理的锁等待超时。innodb_lock_wait_timeout默认是50秒对OLTP系统来说太长了。我一般建议设置为3到5秒宁可让请求快速失败也不要无限阻塞拖垮整个实例。第五业务层必须做好重试。死锁无法100%避免MySQL的死锁检测器会回滚其中一个事务但应用层不捕获异常直接报错的话用户体验就是操作失败。正确的做法是捕获死锁错误码MySQL是1213做有限次数的重试。我这几年处理过好几次严重的锁问题最大的体会是锁机制不是背熟了八种锁的定义就能玩转的真正的难点在于搞清楚一个SQL执行时到底锁了哪些范围、锁了多久、和谁冲突。每次写完SQL先EXPLAIN看有没有走索引再想一想这个SQL在RR隔离级别下会不会锁住额外的区间最后把事务精简到最小。养成这个习惯之后线上锁故障至少能少一半。后面有时间我再写一篇MVCC和锁是怎么配合保证隔离级别的那部分同样有很多反直觉的设计。
返回列表