死锁:成因、检测与消除
死锁的成因只有一个:两个事务以相反的顺序获取同一组锁。它和并发量无关——两个并发就能死锁,一万个并发只要顺序一致就永远不会。
最简单的死锁
Section titled “最简单的死锁”A: 1 → 2,B: 2 → 1。顺序相反,就成环了。
Postgres 怎么检测
Section titled “Postgres 怎么检测”Postgres 不主动预防死锁,它是事后检测的:
SELECT name, setting, unit, short_desc
FROM pg_settings WHERE name IN ('deadlock_timeout', 'log_lock_waits');流程是这样的:
- 一个事务开始等锁时,先老老实实等
deadlock_timeout(默认 1 秒); - 超时后才启动死锁检测——构建「谁在等谁」的等待图,查找环;
- 找到环就选一个「牺牲者」(通常是检测发起方),报
deadlock detected并回滚它; - 没找到环就继续等。
你没意识到自己在加锁的地方
Section titled “你没意识到自己在加锁的地方”上面那个例子太明显了,现实中的死锁往往来自看不见的锁。
1. 批量操作的行处理顺序
Section titled “1. 批量操作的行处理顺序”解法:给批量操作强制排序。
UPDATE items SET n = n + 1WHERE id IN ( SELECT id FROM items WHERE id = ANY($1) ORDER BY id FOR UPDATE);先用一个带 ORDER BY ... FOR UPDATE 的子查询按固定顺序把锁拿全,再做更新。
2. 外键检查
Section titled “2. 外键检查”3. 唯一约束与 upsert
Section titled “3. 唯一约束与 upsert”两个事务同时插入相同的唯一键时,后来者必须等前者提交或回滚才知道自己该报冲突还是该成功。多个这样的等待交叉起来同样能成环——INSERT ... ON CONFLICT 也不能免疫,它内部一样要等。
消除死锁的四条策略
Section titled “消除死锁的四条策略”1. 统一加锁顺序。 这是唯一的根治手段,其余都是缓解。凡是要锁多个对象的操作,一律按同一个全序(通常是主键升序)获取。
2. 缩短事务。 持锁时间越短,两个事务的锁窗口重叠概率越低。特别是不要在事务里做网络调用——一次 HTTP 请求就能把持锁时间从毫秒拉到秒。
3. 一次性拿全锁。 在事务开头用 SELECT ... FOR UPDATE 把需要的行按固定顺序全部锁好,而不是边算边锁。
4. 应用层重试。 死锁无法完全消除,把 40P01(deadlock_detected)当作可重试错误:
for attempt in range(3): try: do_transaction() break except DeadlockDetected: sleep(random.uniform(0, 0.1 * 2 ** attempt)) # 指数退避 + 随机抖动抖动很重要——两个事务同时被唤醒同时重试,很可能立刻再次死锁。
死锁不限于同一张表,也不限于行锁。下面这个组合在发版时特别常见:
迁移脚本的纪律:低峰期执行、设 lock_timeout、一个事务只改一张表。
先自己回答,再点开对照。
死锁的根本成因是什么?它和并发量有关系吗?
成因只有一个:两个事务以相反的顺序获取同一组锁,形成环形等待。
和并发量无关。 两个并发就足以死锁;一万个并发,只要所有代码路径的加锁顺序一致,就永远不会死锁。并发量影响的只是「两条顺序相反的路径撞在一起」的概率,不影响环是否存在。
常见错误:线上死锁报警一多就去降并发——缩小连接池、加限流、把 worker 数从 16 调到 4。报警确实会变少,因为撞车概率降下来了,但环的结构一个字都没改,流量一涨立刻复发,而且此时吞吐已经被自己砍掉了。该做的是去读日志的 DETAIL 段,它明确写出了哪两个进程、在等哪两个事务、各自执行的是哪条语句——顺着这条线索找那一对顺序相反的代码路径。
Postgres 为什么要等 deadlock_timeout 才开始检测?调小它有什么坏处?该开的是哪个参数?
因为死锁检测要遍历全局等待图找环,代价不小;而绝大多数锁等待在 1 秒内就自然结束了,为它们跑一次检测纯属浪费。所以流程是:先老老实实等 deadlock_timeout(默认 1 秒),超时了才启动检测;找到环就选一个牺牲者回滚,没找到就继续等。
调小它的坏处:高并发下 CPU 会花在反复的图遍历上,而这些遍历绝大部分一无所获。
该打开的是 log_lock_waits。
常见错误:把 deadlock_timeout 调到 100ms 以「更快发现死锁」。此时环已经形成了,早发现 900 毫秒救不了任何事务——牺牲者照样要回滚重试。而代价是每一次普通锁等待都要触发一次全局等待图遍历。更反直觉的是:log_lock_waits 复用 deadlock_timeout 作为记录阈值,调小它会让日志被大量正常的短等待淹掉,反而看不见那些真正值得关注的、等了很久但没有形成死锁的情况。
两条不带显式事务嵌套的 UPDATE ... WHERE id IN (...) 怎么会死锁?怎么修?
因为一条 UPDATE 是逐行加锁的,而行的处理顺序由执行计划决定。两条语句碰巧以相反顺序处理同一组行,就成环了——没有显式事务嵌套,也没有手写 FOR UPDATE,照样死锁。
修法是给批量操作强制排序:
UPDATE items SET n = n + 1WHERE id IN ( SELECT id FROM items WHERE id = ANY($1) ORDER BY id FOR UPDATE);先用带 ORDER BY ... FOR UPDATE 的子查询按固定顺序把锁全部拿到,再做更新。
常见错误:以为在应用层把 IN 列表排好序就安全了。IN 列表的书写顺序不决定行的处理顺序——那由执行计划决定,走索引扫描、顺序扫描、位图扫描各不相同,同一条 SQL 在统计信息变化后还会换计划。今天不死锁不代表明天不死锁。要让顺序确定,只能写成显式的 ORDER BY ... FOR UPDATE 子查询。
外键会在什么时候引入你看不见的锁?
插入或更新子表、需要校验外键引用时。一条 INSERT INTO orders (user_id) VALUES (1) 会隐式对 users 表中 id = 1 那一行加 FOR KEY SHARE 锁——保证插入期间那个父行不被删掉、键不被改。
FOR KEY SHARE 之间互不冲突,所以通常无感。但只要同时有人 UPDATE users 改了键列(需要 FOR UPDATE),冲突就出现了,再叠加一次锁升级就能成环。
常见错误:排查死锁时只盯日志里那两条语句直接操作的表。上面那个例子里,应用代码从头到尾没有一个字提到 users,锁却实实在在加在了 users 上。分析加锁顺序时必须把外键牵连到的父表一起算进去——它们是同一个全序里的对象,只是没写在 SQL 里。
(回到上面「你没意识到自己在加锁的地方」一节)
为什么死锁重试必须带随机抖动?
因为死锁的两个事务是同时被唤醒的(一个被回滚、一个继续执行完毕)。如果重试延迟是固定的,它们会保持同样的相位差再撞一次,撞出一串重复死锁,直到重试次数耗尽。
抖动的作用是打散相位,让其中一个先跑完把锁全部拿到、提交,另一个再进场。
sleep(random.uniform(0, 0.1 * 2 ** attempt)) # 指数退避 + 随机抖动常见错误:加了退避但用固定值——sleep(0.1)、sleep(0.2)、sleep(0.4)。指数退避看起来很专业,但两个事务执行的是同一段代码、走的是同一张退避表,每一轮的等待时长完全一样,相位差原封不动地保留下来。退避解决的是「重试太密」,抖动解决的是「重试太齐」——两者缺一不可。