ARTICLE DETAIL

资讯详情

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

数据库死锁问题分析:从原理到实战排查指南

数据库死锁问题分析:从原理到实战排查指南 大概两个月前的一个周五下午我正在食堂排队打饭手机上的告警群突然开始连环响。运维同事发的截图里全是同一个关键词数据库死锁。后台接口大面积报错用户投诉也跟着来了。第一反应不是慌而是先看应用日志里的错误码清一色的Error 1213: Deadlock found when trying to get lock; try restarting transaction。那段时间我对死锁的理解仅限于面试题里的“四个必要条件”真到线上炸锅才意识到这个问题的麻烦程度远超想象。这篇文章想把“数据库死锁问题分析”这件事讲透它到底是怎么发生的、用什么命令能快速定位、有哪些排查工具和日志解读方法、以及我在实战里踩过的坑和总结出的处理套路。如果你是后端开发、DBA或者正在准备面试这篇文章应该能让你少走不少弯路。1. 一次线上死锁事故现象、误判与初步排查先还原一下当时的事故现场。我们有个账户余额服务线上的交易接口会更新用户余额同时每天晚上有一个批量对账任务在跑历史数据修正。某个时间点开始应用日志里冒出大量1213错误集中在同一批用户上持续了大概几分钟。看起来像是“数据库卡了”但查了CPU、内存、磁盘IO一切正常连接数也没有打满慢SQL日志里只有几条平时就存在的语句。这时候才意识到很多人对数据库问题的第一反应是“性能问题”但其实锁相关问题完全是另一个分支。死锁和普通的锁等待在外观上很像本质却完全不同。对比项数据库死锁普通锁等待本质多个事务互相持有对方需要的锁形成循环等待谁也前进不了一个事务在等另一个事务释放锁形成排队是否必然超时InnoDB会主动检测并回滚其中一个事务立刻报错1213需要等innodb_lock_wait_timeout超时报错1205数据库状态数据库本身健康CPU和内存都正常通常伴随高并发或长事务需要看具体场景对业务影响偶发、随机、可重试持续、排队、容易拖垮整体吞吐当时我们的第一轮排查走的就是“性能问题”的老路看监控、看慢查询、看连接数绕了一大圈。最后是执行了SHOW ENGINE INNODB STATUS\G在输出的末尾找到了LATEST DETECTED DEADLOCK段落才确认这是死锁而且不是偶发的一次性问题是两条特定SQL在并发场景下必现的冲突。这个排查顺序的问题在于死锁日志只在最近一次死锁发生后才会保留如果我先重启了数据库或者连接池被频繁重建第一手证据可能就没了。所以现在我对团队的要求是遇到数据库报错第一步先区分错误码。看到1213什么参数都先别调立刻抓死锁信息。这不光是排查效率问题更是复盘的第一步。2. 死锁的形成机制四个必要条件与InnoDB的自动检测要说清楚死锁绕不开那四个经典条件互斥、持有并等待、不可剥夺、循环等待。很多文章讲得比较抽象我习惯用“十字路口赌气”来类比。四个方向的车同时开到路口都想直行穿过对方车道这就是互斥路口的空间同一时刻只能被一辆车占据每辆车都占着当前车道还在等前面的车让路这是持有并等待没有哪辆车会被拖走这是不可剥夺四辆车互相堵住谁都没法动这就是循环等待。数据库里的行锁、间隙锁、表锁和路上的车道是同一个道理。InnoDB处理死锁的方式很直接。它维护了一张等待图每个事务是一个节点事务等待某个锁的关系是一条有向边。每次一个事务尝试加锁的时候InnoDB都会检查这张图里有没有环一旦发现环就立刻选择一个“代价最小”的事务进行回滚释放它持有的锁让其他事务继续跑。这个代价通常按照事务修改的行数、undo日志量、执行时间等维度估算。回滚之后客户端会收到1213错误应用层只要重试就能再次尝试获取锁而不是永远卡死。这里有个被很多人忽略的点事务隔离级别会影响死锁的场景。MySQL默认的REPEATABLE READ下间隙锁gap lock是开启的。两个事务插入数据到相同间隙、互相等待对方持有的间隙锁这种死锁在READ COMMITTED下根本不会发生。所以有些团队把隔离级别从RR改成RC之后死锁数量肉眼可见地下降这不是玄学是间隙锁被禁用的直接结果。当然RC也有它的代价比如不可重复读问题具体怎么取舍要结合业务不能为了降低死锁就盲目改隔离级别。死锁和普通锁等待还有一个容易混淆的地方。普通锁等待是“排队”比如一个长事务一直不提交后面的更新全部阻塞等到innodb_lock_wait_timeout超时才报错。死锁则是“互相等着对方放行”数据库会主动介入。一个是超时兜底一个是主动检测处理路径完全不同。这也是为什么排查时先看错误码、再看死锁日志顺序不能反。3. 定位数据库死锁的实操工具与日志解读定位死锁最核心的工具就是SHOW ENGINE INNODB STATUS。这条命令的输出很长别被吓到只需要关注LATEST DETECTED DEADLOCK这一段。以下是我当年截到的核心片段结构经过脱敏整理LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 912410, ACTIVE 4 sec starting index read MySQL thread id 6, OS thread handle 12345 UPDATE t_user_balance SET balance balance - 100 WHERE user_id 10086 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 31 page no 12 n bits 176 index PRIMARY *** (2) TRANSACTION: TRANSACTION 912411, ACTIVE 3 sec starting index read MySQL thread id 7, OS thread handle 12346 UPDATE t_user_balance SET balance balance 100 WHERE user_id 10086 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 31 page no 12 n bits 176 index PRIMARY *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 31 page no 3 n bits 176 index PRIMARY解读这段日志的关键有三点第一每个事务的WAITING FOR和HOLDS THE LOCK列出来的是锁的space id、page no、n bits、index通过这些可以判断锁在哪个索引、哪条记录上第二两条SQL如果互相 “WAITING FOR” 对方持有的记录就构成了循环等待第三日志里还会标明事务里正在执行的SQL语句这条信息对还原业务场景极其有用。MySQL 8.0之后锁信息被搬进了performance_schema可以查data_locks和data_lock_waits这两张表观察当前正在发生的锁等待关系比翻死锁日志更加实时和灵活SELECT lw.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx, lw.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx, l.ENGINE_LOCK_ID, l.OBJECT_NAME, l.INDEX_NAME, l.LOCK_TYPE, l.LOCK_MODE FROM performance_schema.data_lock_waits lw JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID lw.REQUESTING_ENGINE_LOCK_ID ORDER BY waiting_trx;对于老版本的MySQL可以用information_schema.innodb_trx查看当前事务配合SHOW PROCESSLIST判断谁在阻塞谁。这套组合拳基本覆盖了MySQL环境下的死锁定位需求。其他数据库的思路也类似。PostgreSQL没有像InnoDB那样“预置好的死锁状态输出”但可以查pg_locks视图配合pg_stat_activity找到阻塞链路的源头它的死锁错误码是40P01应用层只要捕获这个码做重试即可。SQLite的情况又不一样它的锁粒度是整个数据库的写锁同一时刻只允许一个写者多线程并发写的时候更多见的是database is locked严格来说不是死锁而是写锁竞争需要在打开数据库时设置busy_timeout来缓解。国产数据库比如达梦、人大金仓多数兼容Oracle或MySQL的锁模型官方工具里通常都有对应的锁等待查看器思路一样先看谁持有、谁在等再串联成完整链路。还有一个很容易踩的坑数据库死锁和线程死锁是两码事但线上表现可能很像。有一次排查半天发现1213只是表象真正的问题是应用服务里的连接池被长事务全部占满新请求拿不到连接然后业务线程互相等待超时形成了应用层的线程死锁。这时候数据库里反而没什么异常。所以排查时一定要分清数据库日志里有没有死锁连接池状态是不是正常应用线程栈是不是有新线程不断卡在获取连接上三件事要同时看不能只盯着一边。4. 一个隐藏很深的死锁案例索引失效与加锁顺序失控背景是一个用户余额更新系统线上交易接口会执行类似这样的语句UPDATE t_user_balance SET balance balance - 100 WHERE user_id 10086;另一个每日对账任务会按照用户编号扫描一批用户逐个更新余额-- 简化后的批量任务逻辑 UPDATE t_user_balance SET balance balance 5 WHERE user_id IN (...);两个业务平时各跑各的相安无事。但某天开始每天下午批量任务运行期间线上接口就陆陆续续报1213。奇数的是单看这两条SQL都属于“按用户维度的行级更新”理论上一次只锁一行怎么会死锁排查链路是这样走的。第一步抓死锁日志。日志里两个事务确实都在操作t_user_balance而且等待的方向看起来毫无规律一会儿A等B一会儿B等A。第二步看执行计划。问题立刻浮出水面t_user_balance.user_id字段在表里是VARCHAR(32)但批量任务代码里传入的是数字类型。MySQL在比较时发生了隐式类型转换优化器直接放弃了user_id字段上的索引选择全表扫描。这一步就是要命的地方。线上接口按user_id走索引一次锁一行批量任务因为索引失效实际是全表扫描按照主键顺序从头到尾扫每一行扫到了就加锁。两条SQL的加锁顺序一个按哈希、一个按主键物理顺序完全是两个节奏碰上同一批用户时就会形成“我拿到了你的下一行你拿到了我的下一行”的交叉等待死锁就在这种看似随机的场景下出现了。这个案例的通用性是超乎想象的。死锁的根本原因是“加锁顺序不稳定”而让加锁顺序不稳定的因素远不止隐式转换还包括表上压根没建合适的索引、字段字符集或排序规则不一致导致索引无法使用、LIKE %xxx这种前置通配符导致索引失效、连接条件的前缀长度不一致等。任何一个因素都可能导致优化器所选执行计划里的扫描路径发生变化加锁顺序也随之变得不可预测。所以后来我在代码评审里立了一个规矩所有UPDATE和DELETE语句必须自带EXPLAIN并且type列不允许出现ALL全表扫描。一张表数据量小还好一旦数据量大全表扫描的加锁范围就是灾难。这条规矩看着简单实际上能挡住一多半隐藏的死锁问题。5. 从源头降低死锁概率应用侧设计策略排查归排查最终要落到怎么少发生。我的经验是应用侧的设计占七成数据库参数占三成。这里分享几个我认为最有效的策略。第一固定加锁顺序。如果多个事务要更新多张表或者多行数据尽量让所有事务都按照相同的顺序去访问资源。比如要更新用户A和用户B的余额所有业务都先按ID小的更新、再按ID大的更新交叉等待的路径就被切断了。这个思路不仅在数据库层适用在分布式锁、缓存更新上同样有效。但要注意这只是降低概率不能根除因为执行计划不是完全由SQL写法的顺序决定的索引选择变化照样会打乱顺序。第二缩小事务边界。这是性价比最高的一招。很多死锁来自事务里塞了太多无关操作比如在事务里调外部接口、循环查两百次数据、等用户输入。持锁时间越长与其他事务发生交叉的概率就越高。理想的事务里只放必要的写入操作其他读取和计算全部移到事务外。第三用重试让死锁变成可恢复的偶发事件。数据库已经用1213告诉我们“请重试”应用层就应该照做。常见的做法是捕获死锁错误码做有限次数的随机退避重试// 伪代码示例重点是捕获错误码并重试 for (int retry 0; retry 3; retry) { try { executeUpdate(sql); break; } catch (DeadlockException e) { if (retry 2) { throw e; } Thread.sleep(50 new Random().nextInt(100)); } }随机退避比固定等待好因为所有请求都按相同间隔重试的话很容易在下一秒又撞在一起。第四控制影响行数。加锁数量约等于实际影响的行数。UPDATE ... WHERE status 0这种写法看起来很合理但如果status这个字段没有索引或者列表里绝大多数行都满足条件一次更新就是几万行和任何一条并发写入触发死锁的概率都会成倍上升。让影响行数缩到个位数死锁概率自然就下来了。第五能用乐观锁就别用悲观锁。很多业务场景根本不需要SELECT ... FOR UPDATE这种悲观锁用版本号做乐观锁就足够。让冲突在提交阶段暴露出来而不是在持锁阶段互相等待这能在架构上规避掉一大类死锁。死锁高发场景推荐手段多行按不同顺序更新固定资源访问顺序事务里做耗时的外部操作拉出事务只留必要写操作一次更新几万行优化SQL走索引、限流分批并发抢同一资源乐观锁版本号实现偶发死锁造成业务失败捕获错误码随机退避重试6. 数据库层参数、连接池与监控告警应用侧策略之外数据库本身的参数也很重要。这里有几个参数值得重点理解而不是盲目调因为调错方向比死锁本身危害更大。innodb_lock_wait_timeout默认50秒是“普通锁等待”的超时上限不是死锁检测时间。死锁检测由innodb_deadlock_detect控制默认开启。有个场景需要注意如果业务是高并发、短事务、点查并发每次加锁本身就很快死锁检测机制反而会成为性能瓶颈。这种情况下可以考虑关闭innodb_deadlock_detect让锁等待靠innodb_lock_wait_timeout兜底也就是把1213变成1205。但这是极端优化手段一般团队不建议碰因为关掉之后所有“冲突”都只能通过超时暴露停顿时间会变得很长。连接池参数和死锁之间也有隐性的联动。最典型的情况是高并发时应用侧拿不到连接报Connection is not available表面看着像数据库故障实际上事务并没有释放连接。如果应用代码里有未提交的长事务占着连接和锁连接池很快被打满后续请求全部阻塞在获取连接上。这时候数据库里的SHOW PROCESSLIST能看到一堆Sleep状态的连接。监控数据库层的活跃事务数量和平均事务时长比监控连接池使用率更能提前发现这类风险。监控告警方面MySQL在information_schema.innodb_metrics中提供了死锁相关的计数器可以定期采样SELECT name, count FROM information_schema.innodb_metrics WHERE name lock_deadlocks;更好的做法是所有读写数据库的服务在日志里把1213MySQL、40P01PostgreSQL这类错误码单独统计并且设一个告警阈值。死锁数量突然从零变成一小时几次绝对值得立刻拉群处理而不是等用户投诉。7. 最后分享几条我处理死锁的经验写到最后把这几年的体会整理成几条硬经验不一定放之四海皆准但每次处理死锁问题我都会先过一遍第一先看错误码再看死锁日志最后才动参数。很多人上来就调innodb_lock_wait_timeout方向基本是错的。1213要重试1205要查长事务这两个的处理逻辑完全不同。第二SQL的WHERE条件里字段类型和传入类型必须一致。隐式转换不仅让索引失效更会让加锁范围不可控这条我至少被坑过三次。第三能解释清楚“我这把锁加了哪些行”的SQL才是合格的线上SQL。我会经常问团队“这条UPDATE影响多少行” 回答不上来的先去跑EXPLAIN。这个习惯比任何工具都管用。第四死锁日志是金子别丢。我建议公司统一把SHOW ENGINE INNODB STATUS的定期采集成一个定时任务哪怕只是保存最近几次的输出。等到出问题时才发现没有现场日志排查难度会翻倍。第五别把一个可重试的1213上升成DBA和架构师之间的互殴。死锁在多数场景下不是“数据库坏了”而是业务并发模型的正常产物。与其追求彻底消灭不如设计好重试和监控让它在可控范围内发生。这一点心态摆正了很多问题都不再是问题。
返回列表