死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。PostgreSQL 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。

坏掉的样子

即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。

数据库检测到死锁时返回 40P01。应用不能依赖“后开始者必输”或“工作量少的一方会被中止”之类规则。锁等待超时、语句超时也可能先于死锁检测触发,返回另一种错误。因此必须查看实际错误码和服务器诊断,再判断发生了什么。没有看到死锁码,并不等于没有形成过循环;重试了一次,也不代表原来的取锁顺序已经修好。

快速验证

准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。甲创建两行数据,并显示死锁检测等待参数:

CREATE SCHEMA deadlock_demo;
CREATE TABLE deadlock_demo.items (id integer PRIMARY KEY,n integer);
INSERT INTO deadlock_demo.items VALUES (1,0),(2,0);
SHOW deadlock_timeout;

本次环境显示一秒。下面给语句十秒预算,让检测有时间发生,并确保没有更短的锁超时抢先干预。如果你的检测间隔更长,应相应调整实验预算。甲记录自己要增加的十,并先更新第一行,暂不提交:

\set delta 10
SET statement_timeout='10s';
BEGIN;
UPDATE deadlock_demo.items SET n=n+:delta WHERE id=1;

乙记录自己要增加的一百,先更新第二行,也保持事务打开:

\set delta 100
SET statement_timeout='10s';
BEGIN;
UPDATE deadlock_demo.items SET n=n+:delta WHERE id=2;

甲继续更新第二行,此时会等待乙的锁。提交这个块后,应立即切换到乙终端,不要等甲这条更新结束:

\set ON_ERROR_STOP off
UPDATE deadlock_demo.items SET n=n+:delta WHERE id=2;
\echo second_result :SQLSTATE
\set ON_ERROR_STOP on

甲等待期间,乙申请更新第一行:

\set ON_ERROR_STOP off
UPDATE deadlock_demo.items SET n=n+:delta WHERE id=1;
\echo second_result :SQLSTATE
\set ON_ERROR_STOP on

现在循环等待已经形成。一个终端输出死锁错误,另一个最终输出成功状态,甲或乙都可能成为失败者。演示中暂时允许预期错误继续显示,只是为了看清两种结果,不会决定数据库选谁中止。先在收到 40P01 的失败终端执行回滚,结束已经失败的事务:

ROLLBACK;

再在得到成功状态的终端提交完整操作:

COMMIT;

之后任选一个终端读取两行:

SELECT id,n FROM deadlock_demo.items ORDER BY id;

如果甲获胜,两行都是十;如果乙获胜,两行都是一百。不应出现一行十、一行一百的混合结果。失败者第一条已经成功执行的更新,也随着事务中止被撤销;不能把“第一条语句曾返回成功”记成业务操作已经提交了一半。这里的结果验证的是完整事务的原子性,而不只是两条连接最终都恢复响应。

现在回到失败者原来的终端,重试整个操作。该终端保存的增量变量仍然代表它原本要做的工作。先按编号顺序锁定两行,再执行更新并提交:

BEGIN;
SELECT id FROM deadlock_demo.items ORDER BY id FOR UPDATE;
UPDATE deadlock_demo.items SET n=n+:delta;
COMMIT;

锁定查询依次返回一和二。此时胜者已经提交,所以重试能够完成。最后任选终端核对两笔增量恰好各保留一次,并清理实验:

SELECT id,n FROM deadlock_demo.items ORDER BY id;
DO $$ BEGIN
  IF (SELECT array_agg(n ORDER BY id) FROM deadlock_demo.items) IS DISTINCT FROM ARRAY[110,110] THEN
    RAISE EXCEPTION 'deadlock recovery lost or duplicated work';
  END IF;
END $$;
DROP SCHEMA deadlock_demo CASCADE;

两行都必须是一百一十。断言不仅检查有没有错误,还能发现少做一次或重复一次的恢复问题。整个实验应保留原来的两个会话;如果临时开第三个终端执行重试,它没有失败者保留的客户端变量,也就不知道该恢复十还是一百。恢复对象由失败的完整操作决定,不能随意把成功者也重放一遍。

沿完整路径避免重复发生

当所有参与者都遵守同样的顺序,且行标识保持稳定时,固定顺序可以消除这里展示的双行循环。真实系统需要把这套顺序扩展到相关的其他资源,并有意识地选择所需锁模式。触发器和约束检查可能隐式访问另一张表;如果只审查表面上的两条更新,局部有序仍然无法排除其他路径引入的循环。

缩短事务、避免持锁等待用户或远程服务,可以减少占用窗口。收到服务器明确的死锁拒绝后,应回滚,再连同读取和业务判断一起重试。高竞争时要有次数、时间预算与随机退避,预算用完则上报最终错误。频繁死锁仍然需要检查访问顺序和负载分布,因为检测和重试都会消耗时间,有限重试更不承诺一定成功。这个实验展示了可核对的恢复结果,不是对任意工作负载都免于死锁的保证。

来源