数据库死锁频发,线上该怎么快速定位和解决?
介绍线上数据库死锁的快速定位与处理流程:先区分死锁和锁等待超时,再通过错误码、事务视图及死锁日志定位具体SQL与加锁顺序,按先止血再治本处理,避免重启碰运气。
学完这篇,你能在死锁报警后 10 分钟内定位到具体 SQL 和加锁顺序,并用「先止血、再治本」的顺序把问题压下去,而不是重启数据库碰运气。
第一步:先分清是死锁还是锁等待超时
这一步要确认故障性质,因为两者的处理方向完全不同:死锁是数据库主动回滚一方事务,锁等待超时是干等不到锁。
先看应用侧报错码。MySQL 报 Error 1213: Deadlock found when trying to get lock; try restarting transaction,就是真死锁;报 Error 1205: Lock wait timeout exceeded,是等待超时(默认 innodb_lock_wait_timeout = 50 秒)。PostgreSQL 报 ERROR: deadlock detected(SQLSTATE 40P01)是死锁,55P03 是 lock_not_available。
再看数据库当前有没有卡住的事务。MySQL 执行:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;
PostgreSQL 执行:
SELECT pid, state, wait_event_type, wait_event, now() - query_start AS dur, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY dur DESC;
wait_event_type = 'Lock' 的那几行就是卡住的源头。
注意:死锁被 InnoDB 自动检测后只会回滚代价小的事务,业务上表现为「偶发失败」,很多人误以为是网络抖动,其实是真死锁。
第二步:把死锁现场捞出来
这一步要拿到「谁持有锁、谁在等锁、最后执行了什么 SQL」的完整现场。
MySQL 直接看最近一次死锁:
SHOW ENGINE INNODB STATUS\G
输出里 LATEST DETECTED DEADLOCK 段落就是现场,重点看三块:TRANSACTION 1 和 TRANSACTION 2 各自 HOLDS THE LOCK(S) 和 WAITING FOR THIS LOCK TO BE GRANTED,以及下面 WE ROLL BACK TRANSACTION 说明回滚了谁。
但 SHOW ENGINE INNODB STATUS 只保留最近一次,想留存全部必须打开开关:
SET GLOBAL innodb_print_all_deadlocks = ON;
MySQL 8.0 支持动态开启,同时建议在 my.cnf 的 [mysqld] 段写死 innodb_print_all_deadlocks = ON,否则重启后失效。
PostgreSQL 则在 postgresql.conf 里设置:
log_lock_waits = on
deadlock_timeout = 1s
log_min_messages = warning
重启或 SELECT pg_reload_conf(); 生效,之后死锁会写进数据库日志。想看实时锁等待,用:
SELECT * FROM pg_locks WHERE NOT granted;
注意:
performance_schema.data_locks和data_lock_waits(MySQL 8.0)能看当前锁,但死锁瞬间的现场还是要靠日志,别指望事后再查。
第三步:从日志反推加锁顺序
这一步要把日志翻译成代码里的事务顺序,找到「两个事务以相反顺序拿同一批行」的根因。
典型日志长这样:事务 A 持有 PRIMARY 上 id=10 的锁,等待 id=20;事务 B 持有 id=20,等待 id=10。翻译成人话就是两段代码分别先更新 10 再更新 20、先更新 20 再更新 10。回到代码里搜这两条 SQL,找出批量更新、批量插入、循环里 update 的位置。
常见根因有四类:
- 批量更新不排序,
UPDATE ... WHERE id IN (...)底层按索引顺序,但两条业务线传入集合不同仍可能交叉;显式ORDER BY id更稳。 - 唯一索引冲突:
INSERT ... ON DUPLICATE KEY UPDATE或先SELECT再INSERT,并发下变成互相等对方插入意向锁。 - 范围查询加间隙锁:MySQL 在
REPEATABLE READ下,WHERE age > 20这类非唯一索引范围条件会加 next-key lock,锁住间隙,业务上毫无交集的两条 SQL 也会撞上。 - 事务里夹了 RPC 或 HTTP 调用,事务被拉长到几百毫秒,锁窗口被放大几十倍。
注意:如果日志显示等待的是
lock_mode X locks gap before rec,说明是间隙锁;把该事务隔离级别降到READ COMMITTED能消掉大部分间隙锁,但前提是binlog_format = ROW,改之前先确认。
第四步:应急止血,先把线上拉回来
这一步目标是不背锅地恢复服务,动作要快且可回滚。
先处理长事务。MySQL 查 information_schema.innodb_trx 找到 trx_started 超过 5 秒的 trx_mysql_thread_id,执行 KILL <thread_id>;。PostgreSQL 用 SELECT pg_terminate_backend(pid);。
再临时缓解。如果是间隙锁引发的,把相关会话或全局隔离级别降到 RC:
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
如果上游流量突增导致冲突概率升高,在网关层对该接口限流,同时把 innodb_lock_wait_timeout 从 50 调到 10 秒左右,让失败快速暴露而不是堆积连接。
应用侧必须补上重试:捕获 1213/1205(PG 捕获 40P01),做 3 次指数退避重试(100ms、300ms、900ms),死锁本身是「可重试错误」,重试一次大概率就过了。
注意:
KILL大事务会触发回滚,一个跑了 10 分钟的 delete 回滚可能比执行还久,操作前先看trx_rows_modified,数值巨大就选低峰期再动。
第五步:长期治理,别让同样的事故来第二次
这一步是把临时手段固化成规则,让死锁从「事故」降级为「日志里的一行」。
具体做四件事:
- 统一加锁顺序。所有涉及多行写入的业务,按主键升序排序后再批量提交,同一条链路上锁顺序必须一致。
- 缩短事务。把事务内的事务外操作全部挪出去,事务里只留 SQL;单事务执行时间控制在 50ms 以内作为评审红线。
- 用覆盖索引。能走唯一索引等值查询的绝不走范围查询,减少 next-key lock 覆盖面积。
- 上监控。对
1213错误码做分钟级计数告警,阈值设为每分钟 1 次;同时把死锁日志采集到日志平台,按 SQL 指纹聚合,一周看一次 TOP5。
注意:不要用「加大重试次数」掩盖根因,重试只能消化偶发冲突,同一 SQL 指纹每天出现几十次,说明加锁顺序本身有问题,必须改代码。
小结
- 先看错误码分性质:1213 / 40P01 是死锁,1205 / 55P03 是等待超时,处理路径不同。
- 现场靠日志:MySQL 打开
innodb_print_all_deadlocks,PostgreSQL 打开log_lock_waits,别指望事后从内存里捞。 - 读日志只抓三件事:谁持有、谁等待、最后执行的 SQL,然后回代码找加锁顺序冲突。
- 止血三招:KILL 长事务、必要时降隔离级别、应用层加指数退避重试。
- 治本靠规范:统一加锁顺序、缩短事务、减少范围查询、对 1213 做告警聚合。
转载请注明出处,版权归原作者所有。