应用报出 deadlock detected 时,PostgreSQL已经发现两个或更多事务形成循环等待,并主动中止其中一个事务,让其他事务继续。数据库没有彻底卡死,但被中止的业务操作会失败;应用若不正确回滚或重试,用户就可能看到500错误、订单状态不一致或任务重复执行。
死锁现场消失得很快。被选中的事务一旦回滚,等待环就被打破,此时再查 pg_locks 往往看不到原来的完整关系。因此第一优先级不是随手终止会话,而是保存服务端日志中的Process、DETAIL、HINT、CONTEXT和STATEMENT,并还原两个事务取得锁的先后顺序。

先确认错误码与发生时间
PostgreSQL死锁对应SQLSTATE 40P01。应用日志应记录错误码、请求ID、事务边界和精确时间,不要只保留一句通用数据库异常。
sudo journalctl -u postgresql --since '30 minutes ago' --no-pager
sudo grep -n -A12 -B3 'deadlock detected' /var/log/postgresql/postgresql-*.log
托管数据库需要从控制台日志或日志导出服务查找同一时间段。统一应用与数据库时区,确认日志是否使用UTC。只有时间对齐,才能把数据库后端PID与应用请求、定时任务或批处理对应起来。
lock timeout、statement timeout 和死锁不是同一错误。前两者是等待或语句执行超过配置阈值;死锁是依赖关系形成闭环。处理方法有交集,但根因判断不能混在一起。
读懂服务端死锁详情
典型日志会列出若干Process:进程A等待某个事务锁,却被进程B持有;进程B又等待A持有的锁。随后日志给出涉及的语句或上下文。把每个PID、事务ID、锁类型和SQL抄到同一条时间线上。
有些语句看起来只更新一行,触发器、外键检查、级联更新和函数内部SQL却可能再访问其他表。日志中的CONTEXT能揭示PL/pgSQL函数或触发器调用,不应只分析应用最外层SQL。
数据库通常只中止其中一个事务,不能依赖哪一方会被选为牺牲者。任何可能参与死锁的业务事务,都应能安全处理40P01并完整回滚。
用活动视图查看仍在等待的会话
死锁已经解除时看不到原环路,但频繁发生前往往先有普通锁等待。可以查询当前活动和阻塞者:
SELECT pid, usename, application_name, state,
now() - xact_start AS xact_age,
wait_event_type, wait_event,
pg_blocking_pids(pid) AS blockers,
left(query, 300) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
pg_blocking_pids 比自己拼接所有锁模式更适合快速找直接阻塞者。需要查看锁对象时,再联查 pg_locks:
SELECT l.pid, l.locktype, l.mode, l.granted,
l.relation::regclass AS relation,
a.state, a.wait_event_type, a.wait_event,
left(a.query, 200) AS query
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
ORDER BY l.granted, l.pid;
视图是当前快照,不能替代死锁日志。频繁高频扫描 pg_locks 也有成本,生产环境应限制采样频率和返回列。
还原事务边界与加锁顺序
死锁根因通常不在单条SQL,而在多条SQL组成的事务。需要知道事务从哪里BEGIN、按什么顺序更新哪些对象、何时COMMIT,以及中间是否等待外部接口、消息队列或用户输入。
两个事务都修改账户A和账户B,但一个先锁A再锁B,另一个先锁B再锁A,就可能形成循环。最有效的预防是让所有代码路径以一致顺序取得多个对象的锁,例如总是按主键升序处理。
批量UPDATE的行访问顺序不应仅凭SQL文本猜测。执行计划、索引选择和并发变化可能影响实际访问路径。需要严格顺序时,可先在事务内明确选择目标并按稳定键排序加锁,再执行修改,同时通过并发测试验证。
检查外键、触发器与索引
插入或更新外键列时,PostgreSQL需要检查被引用行;删除父表行也会与子表操作产生锁交互。不同业务模块以相反顺序修改父子表,是隐蔽死锁的常见来源。
SELECT tgname, pg_get_triggerdef(oid)
FROM pg_trigger
WHERE tgrelid = 'public.target_table'::regclass
AND NOT tgisinternal;
还要确认外键列是否有适当索引。缺少索引不必然制造死锁,但会放大扫描范围、延长事务和锁持有时间,提高并发冲突概率。使用 EXPLAIN (ANALYZE, BUFFERS) 前要评估语句是否会真正执行写操作,生产库不要随意对修改语句加ANALYZE。
触发器中访问多张表时,应把它纳入全系统统一锁顺序。只修改应用层的一条SQL,可能无法消除隐藏的反向顺序。
缩短事务持锁时间
事务开启后调用第三方接口、等待人工输入、处理大文件或休眠,会让锁被长时间持有。查询长期事务:
SELECT pid, usename, application_name, state,
now() - xact_start AS xact_age,
now() - query_start AS query_age,
left(query, 300) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
idle in transaction 尤其值得关注,说明客户端事务未结束却没有执行SQL。应检查连接池自动提交、异常分支和网络断开后的回滚处理。可以设置合理的事务空闲超时作为保护,但超时不能替代正确的事务设计。
大批量任务可分成可恢复的小批次,缩短单次持锁时间;批次太小又会增加提交开销。应根据行数、日志量、复制延迟和业务幂等性压测决定。
设计安全的40P01重试
官方建议在无法完全避免死锁时,对被中止事务进行重试。重试必须从整个事务开头开始,因为当前事务已经进入失败状态,不能只重新执行最后一条SQL。
应用应只捕获明确的可重试错误码,回滚连接后采用有限次数、指数退避并加入随机抖动。无限立即重试会让同一批请求再次同时抢锁,形成重试风暴。
支付、扣库存、发送消息等操作还要具备幂等键。数据库事务回滚不代表事务外的HTTP调用或消息发送自动撤销。先划清副作用边界,再决定哪些步骤能够安全重放。
谨慎调整deadlock_timeout与日志
deadlock_timeout 是等待锁多久后开始执行死锁检测,不是事务最长运行时间。检测有成本,值越低越快报告真实死锁,也会进行更多检查。官方默认通常为1秒,繁忙系统不应为了让报错更快而随意降到极小。
SHOW deadlock_timeout;
SHOW log_lock_waits;
SHOW log_min_error_statement;
临时开启 log_lock_waits 能帮助发现超过deadlock_timeout的普通锁等待,但日志量可能明显增加,应限定观察窗口并确认磁盘容量。修改参数前核对版本、作用域与是否需要reload。
需要稳定复现两条事务路径时,可以在 萤光云 建立隔离数据库,或在按小时计费的 LightNode 上运行脱敏并发脚本。不要复制生产数据、连接串或真实客户标识。
紧急处理阻塞会话
业务正在大面积阻塞时,可先确认阻塞者身份、事务年龄和影响,再决定取消语句或终止会话:
SELECT pg_cancel_backend(PID);
SELECT pg_terminate_backend(PID);
取消只尝试终止当前查询,终止会话会回滚整个事务并断开连接。二者都有业务影响,不能仅凭PID较老就执行。先通知应用负责人,确认该事务能否回滚、连接池是否会立即重连,以及是否存在重要维护操作。
PostgreSQL会自行打破真正的死锁,因此手工终止更多会话通常是为缓解伴随的大面积锁等待,而不是修复已经被检测到的那一次死锁。
修复后怎样验收
用并发测试覆盖原来的两条或多条事务路径,确认无论请求先后顺序如何,都按统一顺序取得锁。应用收到模拟40P01后,应完整回滚、有限重试,并保证最终业务结果只有一次。
继续观察数据库日志、死锁计数、锁等待时间和接口错误率,至少覆盖一次真实高峰。部署前后要保持日志时间和应用版本可关联。
合格的修复不是把deadlock detected隐藏掉,而是能明确画出原来的等待环,统一加锁顺序,并证明重试不会制造重复副作用。
常见问题
PostgreSQL会自动处理死锁吗?
会检测并中止其中一个事务来打破循环,但该事务的业务操作会失败,应用仍需回滚并按安全策略处理。
看到死锁需要立即执行pg_terminate_backend吗?
通常不需要。真正的死锁已由数据库选择事务中止。终止会话只应用于仍在造成严重阻塞且业务允许回滚的场景。
把deadlock_timeout调低能消除死锁吗?
不能。它只影响等待多久后进行检测,不改变事务的加锁顺序。根治仍依赖统一顺序、缩短事务和正确重试。


