概述
PostgreSQL 检测到等待环时会中止其中一个事务。经典形态是两个事务以相反顺序锁定 A、B 两行,单独看都没问题,组合起来就形成环。重试能缓解症状,却掩盖了设计缺陷并浪费计算:只要时序再次吻合,死锁就会重演。
步骤
- 用两个会话复现问题:一个锁 id=1,另一个锁 id=2,然后互相请求对方的行,其中一个会报
ERROR: deadlock detected(40P01)。 - 选定所有写入方都能从行本身计算出的顺序,通常按主键升序,逐条加锁:
const ids = [fromId, toId].sort((a, b) => a - b);
for (const id of ids) {
await client.query('SELECT id FROM accounts WHERE id = $1 FOR UPDATE', [id]);
}
- 在事务内设置
SET LOCAL lock_timeout = '2s',把无限等待变成有界失败,交由请求层重试,并把 55P03 映射为可重试响应。 - 多表操作也要固定表顺序,并把排序逻辑放进共享的数据访问层,而不是散落在各个调用点。
验证
用 40 个双向并发转账跑测试:40P01 计数必须为 0,所有转账提交成功,账户余额总和保持不变。再故意把其中一处加锁顺序改回相反方向,确认测试能捕获回归。另需观察锁等待指标,若持续上升说明事务持有锁的时间过长。
注意事项
- 只有所有写入方都遵守约定才有效,批处理、导入脚本、管理后台必须复用同一套排序逻辑。
- 提前加锁会增加竞争,事务应尽量短,绝不能在持锁期间做网络调用或等待用户输入。
- 外键插入会对父行加
KEY SHARE锁,隐式锁同样可能参与死锁,需要纳入排序或缩小事务范围。 - 该方案无法避免并发 DDL 或索引页分裂引起的死锁。
- 单条
SELECT ... ORDER BY ... FOR UPDATE并不保证物理加锁顺序,逐条加锁更可靠。