← 中文资源

简体中文版本

统一行锁顺序以消除死锁

让所有写入方按同一确定性顺序加锁,配合 lock_timeout,并用并发测试证明 40P01 不再出现。

阅读英文原文

概述

PostgreSQL 检测到等待环时会中止其中一个事务。经典形态是两个事务以相反顺序锁定 A、B 两行,单独看都没问题,组合起来就形成环。重试能缓解症状,却掩盖了设计缺陷并浪费计算:只要时序再次吻合,死锁就会重演。

步骤

  1. 用两个会话复现问题:一个锁 id=1,另一个锁 id=2,然后互相请求对方的行,其中一个会报 ERROR: deadlock detected(40P01)。
  2. 选定所有写入方都能从行本身计算出的顺序,通常按主键升序,逐条加锁:
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]);
}
  1. 在事务内设置 SET LOCAL lock_timeout = '2s',把无限等待变成有界失败,交由请求层重试,并把 55P03 映射为可重试响应。
  2. 多表操作也要固定表顺序,并把排序逻辑放进共享的数据访问层,而不是散落在各个调用点。

验证

用 40 个双向并发转账跑测试:40P01 计数必须为 0,所有转账提交成功,账户余额总和保持不变。再故意把其中一处加锁顺序改回相反方向,确认测试能捕获回归。另需观察锁等待指标,若持续上升说明事务持有锁的时间过长。

注意事项

  • 只有所有写入方都遵守约定才有效,批处理、导入脚本、管理后台必须复用同一套排序逻辑。
  • 提前加锁会增加竞争,事务应尽量短,绝不能在持锁期间做网络调用或等待用户输入。
  • 外键插入会对父行加 KEY SHARE 锁,隐式锁同样可能参与死锁,需要纳入排序或缩小事务范围。
  • 该方案无法避免并发 DDL 或索引页分裂引起的死锁。
  • 单条 SELECT ... ORDER BY ... FOR UPDATE 并不保证物理加锁顺序,逐条加锁更可靠。