数据库死锁几乎是每个后端工程师都会遇到的线上噩梦:两个事务互相等待对方持有的锁,谁也无法推进,最终其中一个被数据库强制回滚。本文从一段可复现的代码出发,带你完整走通「复现 → 定位 → 根治」全流程,把抽象的锁竞争变成可操作的排障手册。
一、死锁到底是什么
死锁并不是数据库”坏了”,而是并发事务在争夺资源时陷入的一种僵局。学术界把死锁的必要条件总结为四条:互斥、持有并等待、不可剥夺、循环等待。四条同时成立,死锁才会发生。对数据库而言,最常见的就是”循环等待”——事务 A 拿着行 1 的锁去等行 2,事务 B 拿着行 2 的锁去等行 1,彼此都不放手。理解这四条,才能有的放矢地去打破它。
二、一个最经典的死锁案例
假设有一张账户表 accounts,两个并发请求分别要把钱从 A 转给 B、从 B 转给 A。如果两个事务的更新顺序相反,就极易撞车。下面这段 SQL 在很多人的生产环境里真实上演过:
-- 事务 T1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 事务 T2(与 T1 并发执行)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
当事务 T1 先锁住 id=1、事务 T2 先锁住 id=2,接着各自去更新对方已锁住的行时,就形成了循环等待。InnoDB 的死锁检测线程会在短时间内发现这个环,并强制回滚其中代价较小的事务,抛出 1213 错误(Deadlock found when trying to get lock)。被回滚的事务需要由应用层重试,否则这笔转账就”消失”了。
三、用 Docker 在本地稳定复现
要在本地亲手制造一次死锁,最干净的办法是用 Docker 起一个 MySQL,再用两个连接交错执行。这样你既能观察现象,也能在不影响生产的前提下练习定位。
# docker-compose.yml 起一个 MySQL 8
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpw
MYSQL_DATABASE: demo
ports: ['3306:3306']
# 用两个连接交错执行,制造死锁
import pymysql, threading
def transfer(conn, a, b):
cur = conn.cursor()
cur.execute('UPDATE accounts SET balance=balance-100 WHERE id=%s', (a,))
cur.execute('UPDATE accounts SET balance=balance+100 WHERE id=%s', (b,))
conn.commit()
c1 = pymysql.connect(host='127.0.0.1', user='root', password='rootpw', db='demo')
c2 = pymysql.connect(host='127.0.0.1', user='root', password='rootpw', db='demo')
t1 = threading.Thread(target=transfer, args=(c1, 1, 2))
t2 = threading.Thread(target=transfer, args=(c2, 2, 1))
t1.start(); t2.start(); t1.join(); t2.join()
运行后几乎必然触发 1213,这正是我们想要的”可控故障”。如果你还没有本地 MySQL 环境,可以参考 《Docker Compose 一键编排:MySQL + Redis + 后端服务》 快速拉起一套。
四、线上如何快速定位死锁
生产环境不能靠”复现”,要靠日志和状态视图。MySQL 有两个关键抓手:
-- 开启全量死锁日志(需 SUPER 权限,重启失效)
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 查看最近一次死锁详情
SHOW ENGINE INNODB STATUS;
-- 查看当前锁等待超时(默认 50 秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SHOW ENGINE INNODB STATUS 输出里的 LATEST DETECTED DEADLOCK 段落会完整记录两个事务各自持有和等待的锁、执行的 SQL,以及最终被回滚的一方。默认它只保留最后一次死锁,开启 innodb_print_all_deadlocks 后每一次死锁都会写进错误日志,方便事后复盘。
| 字段 | 含义 | 排障关注点 |
|---|---|---|
| WAITING FOR THIS LOCK | 事务在等哪把锁 | 看等待的是记录锁还是间隙锁 |
| HOLDS THE LOCK | 事务已持有哪把锁 | 谁占了资源、持锁多久 |
| WE ROLL BACK | 被回滚的一方 | 它的 SQL 就是”牺牲者” |
五、死锁根因的四种形态
并不是所有死锁都来自”两行互锁”。实际排障中,我把它归为四类:
| 形态 | 触发场景 | 典型 SQL | 破解法 |
|---|---|---|---|
| 行锁交叉 | 两事务反向更新同行 | UPDATE … WHERE id=1/2 | 统一加锁顺序 |
| 间隙锁 | RR 下范围更新再插入 | UPDATE … WHERE id>10 | 降 RC 或缩小范围 |
| 外键级联 | 更新父表触发子表锁 | UPDATE parent | 统一父子顺序 |
| MDL 锁 | DDL 与 DML 并发 | ALTER TABLE | 低峰执行 DDL |
其中间隙锁(Gap Lock)最容易被忽视:在可重复读隔离级别下,一个范围更新会把”不存在的间隙”也锁住,两个事务各自锁住不同间隙又互相插入,就会死锁。外键级联和元数据锁(MDL)则常出现在”业务代码 + 在线表结构变更”同时进行的窗口期。
六、根治死锁的六条军规
死锁无法 100% 杜绝,但可以把它降到几乎为零。我总结了六条可直接落地的军规:
- 统一加锁顺序:所有事务都按同一主键顺序加锁,循环等待从根本上被打破。
# 关键:按主键升序加锁,杜绝循环等待
def lock_in_order(conn, ids):
for _id in sorted(ids):
conn.execute('SELECT * FROM accounts WHERE id=%s FOR UPDATE', (_id,))
- 缩短事务:事务越短,持锁时间越短,撞车概率指数级下降。能拆的批量操作就拆。
- 避免长事务:长事务不仅持锁久,还会拖慢 purge、膨胀 undo 日志。
- 建好索引:没有命中索引的更新会退化成表锁或扩大间隙锁范围(延伸阅读:《MySQL 索引底层原理:B+树、覆盖索引与最左前缀》)。
- 超时与重试:把
innodb_lock_wait_timeout调小,配合应用层捕获 1213 后幂等重试。 - 谨慎降隔离级别:把 RR 降到 RC 可消除大部分间隙锁死锁,但要评估幻读风险。
七、MySQL 与 PostgreSQL 的死锁差异
换数据库,死锁的”脾气”也不同:
| 维度 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 死锁检测 | 内置等待图检测 | 默认也检测 |
| 错误码 | 1213 | 40P01 |
| 定位手段 | SHOW ENGINE INNODB STATUS | pg_locks + log_lock_waits |
| 常见诱因 | 间隙锁 / 外键级联 | 行锁 / 咨询锁 |
PostgreSQL 没有间隙锁概念,但行级 FOR UPDATE 与咨询锁(advisory lock)搭配不当时同样会死锁,可通过 log_lock_waits=on 与查询 pg_locks 定位(延伸:《PostgreSQL 慢查询优化:执行计划解读与索引调优实战》、《PostgreSQL vs SQL Server:语法差异与迁移避坑指南》、《PostgreSQL 流复制实战:用 Patroni 实现高可用主从集群》)。
八、监控与兜底设计
再好的预防也挡不住偶发。真正稳健的系统会在应用层兜底:捕获死锁异常后,用幂等键做有限次重试。
import functools, time, pymysql
DEADLOCK_CODES = (1213, '40P01')
def retry_on_deadlock(tries=3, delay=0.2):
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **k):
for i in range(tries):
try:
return fn(*a, **k)
except pymysql.MySQLError as e:
if e.args[0] in DEADLOCK_CODES and i < tries - 1:
time.sleep(delay * (i + 1))
continue
raise
return wrapper
return deco
关键是重试必须幂等——用唯一业务单号保证"转两次账不会扣两次钱"。把重试封装成装饰器,业务代码就能无感知地获得死锁免疫力。
九、线上排查清单
- 拉取
LATEST DETECTED DEADLOCK,确认双方 SQL 与锁类型。 - 确认是否缺少索引导致锁范围扩大。
- 检查两个事务的加锁顺序是否一致。
- 评估事务是否过长、是否可拆分。
- 临时开启全量死锁日志,观察是否高频发生。
- 应用层补齐 1213 / 40P01 捕获与幂等重试。
结语
死锁不可怕,可怕的是"看不见"。把复现、日志、军规、兜底四件套配齐,它就会从"线上事故"变成"可观测的小插曲"。如果你想把缓存层的并发也一并理顺,可以继续读 《Redis 缓存设计:穿透、击穿、雪崩与最佳实践》、《Redis 读写分离实战:主从架构与高可用扩展》 与 《PostgreSQL 分区表实战:性能优化与自动维护》。




