Skip to content

死锁产生原因 ​

死锁(Deadlock)不是数据库的随机 bug,而是并发事务互相持有对方需要的锁、形成环形等待的必然结果。PostgreSQL 会自动检测并中止其中一个事务,所以死锁本身不会让数据库「卡死」,但它会打断业务、把错误抛给应用。

真正麻烦的是:死锁往往只在生产环境的高并发时刻出现,本地测试怎么试都复现不了。要根治它,必须先弄清楚它到底从哪来。本页把 PostgreSQL 中七类最常见的死锁成因逐一讲透。

前置知识

阅读本节前,建议先了解:行级锁(FOR UPDATE / FOR SHARE)、页级锁与死锁、锁冲突矩阵

基础概念 ​

什么是死锁 ​

死锁指的是两个或多个事务各自持有对方正在等待的锁,谁也无法继续。它和普通「锁等待」有本质区别:

对比项锁等待(Lock Wait)死锁(Deadlock)
成因单方向的依赖:A 等 B环形的依赖:A 等 B,B 等 A
能否自行解除能(持锁者提交/回滚后自动继续)不能,必须外部干预
PostgreSQL 的处置一直等,直到超时或拿到锁检测到环后自动中止一个事务
典型错误码55P03(超时)/ 无40P01(deadlock_detected)
日志特征still waiting for ...deadlock detected + DETAIL 等待环
text
普通锁等待(可自行解除)
  事务 A ──持有──► 行 1
  事务 B ──等待──► 行 1        A 提交 → B 立刻继续 ✅

死锁(无法自行解除)
  事务 A ──持有──► 行 1 ──等待──► 行 2 ◄──持有── 事务 B
                                                        │
                                          B ──等待──► 行 1
  A 等 B、B 等 A → 环形 → PostgreSQL 必须杀掉一个 ⛔

死锁的四个必要条件 ​

死锁的产生在操作系统理论上需要同时满足四个条件(Coffman 条件)。PostgreSQL 的锁机制天然满足前三个,所以只要再凑出「循环等待」,死锁就必然发生:

条件含义PostgreSQL 中的体现能否消除
互斥(Mutual Exclusion)资源同一时刻只能被一个持有者占用行级 ExclusiveLock、表级 AccessExclusiveLock 都是排他的不能(锁的本质要求)
持有并等待(Hold and Wait)已持有资源,同时还在申请新资源事务持有行 1 的锁,继续 UPDATE 行 2可以(一次性申请)
不可抢占(No Preemption)已分配的锁不能被强制抢走锁只能由持有者提交/回滚后释放不能(只能靠检测后中止事务)
循环等待(Circular Wait)存在等待的环A 等 B、B 等 A可以(统一加锁顺序)

记忆口诀

前三个条件是锁机制自带属性,改不掉;唯一能被应用彻底消除的是「循环等待」。这就是为什么几乎所有死锁解决方案最后都落到一句话:让所有事务以相同的顺序加锁。

死锁的典型表现 ​

死锁发生后,应用会收到一个 40P01 错误,同时服务端日志里会留下完整的等待环。这个错误不是连接断开,事务被中止但会话仍然存活,所以应用可以(也应该)自动重试。

sql
-- 应用侧捕获的核心特征
-- SQLSTATE  40P01
-- MESSAGE   deadlock detected
-- DETAIL    Process 1234 waits for ShareLock on transaction 5678; blocked by process 8765. ...
text
ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 678; blocked by process 12346.
Process 12346 waits for ShareLock on transaction 679; blocked by process 12345.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,3) in relation "accounts"

死锁、锁等待与活锁的区别 ​

三者经常被混为一谈,但处置方式完全不同:

text
① 锁等待      A 等 B 释放锁          → 等待即可(或加 lock_timeout 兜底)
② 死锁        A 等 B,B 等 A(环)    → 数据库自动中止一个事务(40P01)
③ 活锁        A、B 反复失败反复重试    → 无环、无等待,但永远做不成事

活锁(Livelock) 是死锁的隐蔽变种:两个事务都不阻塞,但每次都因为冲突被中止、每次都重试、每次都再冲突。它往往出现在「重试没有退避、冲突窗口固定」的实现里,表现为 CPU 和日志量飙升、业务成功率却接近 0。它没有 40P01,所以更难发现。

sql
-- 活锁特征:短时间内在同一批行上反复出现序列化失败/死锁,却都没有成功
SELECT xact_start, query
FROM pg_stat_activity
WHERE query ILIKE '%UPDATE accounts%'
ORDER BY xact_start DESC
LIMIT 20;

语法与用法 ​

制造与观察死锁 ​

想理解成因,最快的方法就是手工制造一个。下面是最经典的两行反向更新:

会话 1:

sql
BEGIN;

-- 第一步:锁住行 1
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

会话 2(此时执行,不会被阻塞,因为它锁的是另一行):

sql
BEGIN;

-- 第一步:锁住行 2
UPDATE accounts SET balance = balance - 100 WHERE id = 2;

回到会话 1,尝试锁行 2 —— 开始等待:

sql
-- 第二步:等待会话 2 释放行 2
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
text
-- 会话 1 此刻的状态:wait_event_type = 'Lock',wait_event = 'transactionid'

回到会话 2,尝试锁行 1 —— 环闭合,死锁触发:

sql
-- 第二步:等待会话 1 释放行 1 → 形成环
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
text
ERROR:  deadlock detected
DETAIL:  Process 45281 waits for ShareLock on transaction 1243; blocked by process 45282.
Process 45282 waits for ShareLock on transaction 1244; blocked by process 45281.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,1) in relation "accounts"

死锁错误信息解读 ​

上面那段报错里的每个字段都有明确含义,读懂它就能直接定位代码:

字段含义排查价值
ERROR: deadlock detected检测器在等待图里找到了环确认是死锁而非超时
Process N waits for XLock on transaction T进程 N 在等事务 T 的行锁第一个进程是被牺牲的那个
blocked by process M挡在前面的是进程 M环上的下一个节点
HINT: See server log for query details完整 SQL 只写进日志需配合 log_lock_waits 或日志检索
CONTEXT: while updating tuple (0,3) in relation "accounts"冲突发生在哪张表的第几个元组直接指向冲突的行
SQLSTATE 40P01应用侧重试的判断依据应立即重试(需幂等)
sql
-- 把「哪个进程被牺牲」和「当前谁在等谁」一起查出来
SELECT pid,
       wait_event_type,
       wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 60)       AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
text
 pid  | wait_event_type |  wait_event   | blocked_by |              query
------+-----------------+---------------+------------+---------------------------------
45281 | Lock            | transactionid | {45282}    | UPDATE accounts SET balance = ...

详细说明 ​

场景一:不同顺序更新同一批行 ​

这是线上死锁的头号来源,占绝大多数生产事故。根源在于「同一组行、不同的访问顺序」:

sql
-- 转账 A → B:先减 A,再加 B
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 锁 2
COMMIT;
sql
-- 同时,转账 B → A:先减 B,再加 A —— 顺序正好相反!
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;  -- 锁 2
UPDATE accounts SET balance = balance + 100 WHERE id = 1;  -- 锁 1 → 环
COMMIT;
text
标签等价于:
  事务 A: 持有 [1] → 等待 [2]
  事务 B: 持有 [2] → 等待 [1]     ← 环

关键认识:这两条 SQL 单独看都是「正确的」,只有在并发时才出问题。这也解释了为什么本地单线程测试永远复现不了。

sql
-- 正确做法:让「一组行」的更新顺序与业务无关,只与主键顺序有关
BEGIN;
-- 无论转账方向如何,永远按 id 从小到大加锁
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

场景二:外键隐式加锁 ​

外键(Foreign Key)会自动加锁,这是最容易被忽略的成因。向子表插入一行时,PostgreSQL 必须确认父表的被引用行存在且未被删除,于是会在父表那一行上加 FOR KEY SHARE 行锁;反过来,UPDATE 父表(修改非键列)需要在同一行上加 FOR NO KEY UPDATE。这两种模式是冲突的(见锁冲突矩阵),于是「外键检查」与「更新父表」交错时就会闭环:

sql
-- 会话 1:插入子表 → 外键检查在 parent#1 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (1);
sql
-- 会话 2:插入子表 → 外键检查在 parent#2 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (2);
sql
-- 会话 1:更新 parent#2 → 需要 FOR NO KEY UPDATE,与会话 2 的 FOR KEY SHARE 冲突 → 等待
UPDATE parent SET note = 'x' WHERE id = 2;
sql
-- 会话 2:更新 parent#1 → 需要 FOR NO KEY UPDATE,与会话 1 的 FOR KEY SHARE 冲突 → 等待
-- 会话 1 等会话 2、会话 2 等会话 1 → 死锁
UPDATE parent SET note = 'y' WHERE id = 1;
text
ERROR:  deadlock detected
DETAIL:  Process 33120 waits for ShareLock on transaction 901; blocked by process 33121.
Process 33121 waits for ShareLock on transaction 902; blocked by process 33120.
CONTEXT:  while locking tuple (0,2) in relation "parent"

外键锁的死锁最隐蔽

关键在于外键检查的 FOR KEY SHARE 锁会一直持有到事务结束,而它与后续的父表更新(FOR NO KEY UPDATE)冲突。所以「同一个事务里既插子表、又更新多行父表」这种写法,只要加锁顺序不一致,就会形成环。

场景三:唯一索引冲突 ​

唯一索引(Unique Index)的并发插入也是死锁高发区。当两个事务插入相同的键值时,后者需要等待前者的事务结束来决定自己是否违反唯一约束:

sql
-- 会话 1
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com');   -- 拿到「猜测插入」令牌,未提交
sql
-- 会话 2:同一键值 → 必须等会话 1 的结果
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com');   -- 等待

单看这一对只会「等待」,不会死锁。真正的死锁需要两个键值的交叉插入:

sql
-- 会话 1:插入两个键值,顺序是 (A, B)
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com');   -- 拿到 A
INSERT INTO users (email) VALUES ('b@x.com');   -- 等 B(被会话 2 占)
sql
-- 会话 2:插入同样的两个键值,顺序是 (B, A) —— 交叉!
BEGIN;
INSERT INTO users (email) VALUES ('b@x.com');   -- 拿到 B
INSERT INTO users (email) VALUES ('a@x.com');   -- 等 A(被会话 1 占)
text
ERROR:  deadlock detected
DETAIL:  Process 51002 waits for ShareLock on transaction 1877; blocked by process 51003.
Process 51003 waits for ShareLock on transaction 1878; blocked by process 51002.
HINT:  See server log for query details.
sql
-- 规避:批量插入前先去重排序;或用 ON CONFLICT 让语义变成「幂等写入」
INSERT INTO users (email)
VALUES ('a@x.com'), ('b@x.com')
ON CONFLICT (email) DO NOTHING;

场景四:INSERT ... ON CONFLICT 的并发陷阱 ​

INSERT ... ON CONFLICT DO UPDATE(俗称 upsert)看起来是原子的,但在多行、多事务场景下依然会死锁。原因是 DO UPDATE 在发生冲突的行上加了排他锁,而多行插入的加锁顺序取决于输入顺序:

sql
-- 会话 1 按 (1, 2) 顺序 upsert;会话 2 按 (2, 1) 顺序 upsert 同样的行 → 行锁获取顺序交叉 → 死锁
BEGIN;
INSERT INTO counters (id, n) VALUES (1, 1), (2, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
text
ERROR:  deadlock detected
DETAIL:  Process 60110 waits for ShareLock on transaction 2044; blocked by process 60111.
Process 60111 waits for ShareLock on transaction 2045; blocked by process 60110.
CONTEXT:  while locking tuple (0,2) in relation "counters"
sql
-- 规避:让输入行按主键排序后再发送(应用侧或 SQL 侧都行)
INSERT INTO counters (id, n)
SELECT id, 1 FROM unnest(ARRAY[2, 1]) AS t(id)
ORDER BY id
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;

为什么 ORDER BY 能解决

ON CONFLICT DO UPDATE 处理每一行时都会去锁目标行。只要所有并发事务按同一个顺序去锁同一批行,循环等待就不可能形成——这正是「统一加锁顺序」原则在 upsert 上的应用。

场景五:DDL 与 DML 混合 ​

DDL(如 ALTER TABLE)会申请 ACCESS EXCLUSIVE 表锁,它与所有其他锁模式都冲突。当 DDL 与长事务 DML 交错,就很容易和显式锁一起构成环:

sql
-- 会话 1:先锁 t1 再锁 t2;会话 2:先锁 t2 再锁 t1 → 环
BEGIN;
LOCK TABLE t1 IN ACCESS EXCLUSIVE MODE;   -- 拿到 t1
-- ... 中间夹着业务逻辑
LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;   -- 等 t2 → 死锁
text
ERROR:  deadlock detected
DETAIL:  Process 71200 waits for AccessExclusiveLock on relation 16385 of database 16384; blocked by process 71201.
Process 71201 waits for AccessExclusiveLock on relation 16386 of database 16384; blocked by process 71200.
sql
-- 更隐蔽的现实版本:一个事务里既有 DML 又有 DDL
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;   -- 持有 orders 的 ROW EXCLUSIVE
ALTER TABLE order_items ADD COLUMN memo text;     -- 需要 order_items 的 ACCESS EXCLUSIVE
COMMIT;
sql
-- 排查:谁在等表锁、等的是哪张表
SELECT l.pid,
       l.locktype,
       l.mode,
       l.relation::regclass AS table_name,
       l.granted
FROM pg_locks l
WHERE l.locktype = 'relation'
ORDER BY l.granted, l.pid;

场景六:PL/pgSQL 函数与触发器 ​

触发器(Trigger)是死锁的放大器:它在一条语句内部隐式执行额外 SQL,把「加锁顺序」藏进了别人看不见的地方。

sql
-- 一个「审计」触发器:每次更新 orders 都写一条 audit
CREATE OR REPLACE FUNCTION trg_audit_orders()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO audit_log (tbl, row_id, at)
  VALUES ('orders', NEW.id, now());
  RETURN NEW;
END;
$$;

CREATE TRIGGER audit_orders
AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION trg_audit_orders();

现在两条 SQL 的真实加锁顺序已经和表面写法不一样了:

sql
-- 表面:先 orders 后 audit_log
-- 实际(行级):orders 行 → audit_log 行
UPDATE orders SET status = 'paid' WHERE id = 1;
sql
-- 另一个事务里有个函数,先写 audit_log 再更新 orders
CREATE OR REPLACE FUNCTION pay_and_log(p_id bigint)
RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO audit_log (tbl, row_id, at) VALUES ('orders', p_id, now());
  UPDATE orders SET status = 'paid' WHERE id = p_id;
END;
$$;
text
两条路径的加锁顺序相反:
  路径 1: orders → audit_log
  路径 2: audit_log → orders        ← 并发即死锁

ERROR:  deadlock detected
DETAIL:  Process 82001 waits for ShareLock on transaction 3120; blocked by process 82002.
Process 82002 waits for ShareLock on transaction 3121; blocked by process 82001.
CONTEXT:  SQL statement "INSERT INTO audit_log ..."
PL/pgSQL function trg_audit_orders() line 3 at SQL statement

排查函数/触发器死锁的关键

报错里的 CONTEXT 会明确列出函数名和行号(如上例的 trg_audit_orders() line 3)。看到这种 CONTEXT,就说明加锁顺序被触发器或函数改写了,必须把它内部执行的 SQL 也纳入「加锁顺序审查」。

场景七:批处理与多表更新顺序不一致 ​

批量任务(数据修复、对账、离线计算)通常一次处理多张表,且各任务的表访问顺序往往由业务的自然书写顺序决定,天然不一致:

sql
-- 批处理 A:先清明细,再改主表;批处理 B:先改主表,再清明细。
-- 并发执行 → order_items / orders 两条锁路径交叉 → 死锁
BEGIN;
DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE status = 'void');
UPDATE orders SET status = 'archived' WHERE status = 'void';
COMMIT;
sql
-- 规避:批处理统一「从父表到子表」或「按表名排序」的顺序,
-- 并且每批只处理有限行数,减小锁范围与持锁时间
SELECT id FROM orders WHERE status = 'void' ORDER BY id LIMIT 1000;

40P01 错误与日志解读 ​

死锁被检测到时,PostgreSQL 会在被牺牲的事务上抛出 40P01,并同时在服务端日志里记录完整信息。开启 log_lock_waits 后,日志还能给出「等待了多久、谁持有锁」:

ini
# postgresql.conf
log_lock_waits = on          # 等待超过 deadlock_timeout 就记日志
deadlock_timeout = 1s        # 默认 1 秒
log_min_messages = warning
text
# 服务端日志(等待超过 deadlock_timeout 时)
LOG:  process 45281 still waiting for ShareLock on transaction 1243 after 1000.221 ms
DETAIL:  Process holding the lock: 45282. Wait queue: 45281.
CONTEXT:  while updating tuple (0,1) in relation "accounts"
STATEMENT:  UPDATE accounts SET balance = balance + 100 WHERE id = 2;

# 真正判定为死锁时
ERROR:  deadlock detected
DETAIL:  Process 45281 waits for ShareLock on transaction 1243; blocked by process 45282.
Process 45282 waits for ShareLock on transaction 1244; blocked by process 45281.
HINT:  See server log for query details.
sql
-- 应用侧:区分「可重试」和「不可重试」的错误
-- 40P01 deadlock_detected      → 立即重试(需幂等)
-- 40001 serialization_failure  → 立即重试(需幂等)
-- 55P03 lock_not_available     → 退避后重试
-- 57014 query_canceled         → 先查语句本身,不要盲目重试

死锁与活锁、锁等待的区别(排查决策树) ​

把三类问题放进一张决策树,排查时就不会跑偏:

text
语句挂住不动
├─ pg_stat_activity.wait_event_type = 'Lock' ?
│   ├─ 是 → 有等待。等谁?pg_blocking_pids(pid)
│   │        ├─ 阻塞者是 idle in transaction → 长事务问题(见「锁等待与超时」)
│   │        └─ 阻塞者是 active → 可能正在形成死锁,观察是否出现 40P01
│   └─ 否 → 看 wait_event_type:
│            LWLock / BufferPin → 存储层争用(见「页级锁与死锁」)
│            IO / IPC          → 与锁无关
└─ 报错 40P01 → 已确认死锁 → 看 DETAIL 还原环 → 统一加锁顺序
sql
-- 决策树第一层的落地查询
SELECT pid,
       state,
       wait_event_type,
       wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       now() - xact_start    AS xact_age,
       left(query, 50)       AS query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
ORDER BY xact_start;

实战示例 ​

下面从零复现三种最典型的死锁,并观察它们的报错与日志。

第 1 步:建表与初始数据 ​

sql
DROP TABLE IF EXISTS child;
DROP TABLE IF EXISTS parent;
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS counters;

CREATE TABLE accounts (
  id      int PRIMARY KEY,
  owner   text NOT NULL,
  balance numeric(12,2) NOT NULL DEFAULT 0
);

INSERT INTO accounts (id, owner, balance) VALUES
  (1, 'alice', 1000),
  (2, 'bob',   1000);

CREATE TABLE parent (
  id   int PRIMARY KEY,
  note text
);

CREATE TABLE child (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  parent_id int NOT NULL REFERENCES parent (id)
);

INSERT INTO parent (id, note) VALUES (1, 'p1'), (2, 'p2');

CREATE TABLE counters (
  id int PRIMARY KEY,
  n  bigint NOT NULL DEFAULT 0
);
text
CREATE TABLE
INSERT 0 2
CREATE TABLE
CREATE TABLE
INSERT 0 2
CREATE TABLE

第 2 步:制造「不同顺序更新」的死锁 ​

会话 1:

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

会话 2:

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;

会话 1 继续(进入等待):

sql
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

会话 2 继续(触发死锁):

sql
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
text
ERROR:  deadlock detected
DETAIL:  Process 45281 waits for ShareLock on transaction 1243; blocked by process 45282.
Process 45282 waits for ShareLock on transaction 1244; blocked by process 45281.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,1) in relation "accounts"

此时其中一个会话的事务被中止,另一个立刻成功。用下面的查询确认「环已经解开」:

sql
SELECT pid, state, wait_event_type, left(query, 50) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
text
 pid | state | wait_event_type | query
-----+-------+-----------------+-------
(0 rows)

第 3 步:观察等待中的锁与等待链 ​

在「会话 1 已经等待、会话 2 尚未触发死锁」的那个瞬间,可以从两个视图看到完整的等待信息:

sql
-- 谁在等、等的那一刻锁是什么类型
SELECT a.pid,
       a.wait_event_type,
       a.wait_event,
       l.locktype,
       l.mode,
       l.granted
FROM pg_stat_activity a
LEFT JOIN pg_locks l ON l.pid = a.pid
WHERE a.wait_event_type = 'Lock';
text
 pid  | wait_event_type |  wait_event   |   locktype    |   mode    | granted
------+-----------------+---------------+---------------+-----------+---------
45281 | Lock            | transactionid | transactionid | ShareLock | f
sql
-- 等待链:pid → 阻塞它的进程
SELECT pid,
       pg_blocking_pids(pid) AS blocking_pids,
       left(query, 50)       AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
text
 pid  | blocking_pids |              query
------+---------------+---------------------------------
45281 | {45282}       | UPDATE accounts SET balance = ...

第 4 步:解读服务端日志 ​

死锁的完整 SQL 只在日志里。开启 log_lock_waits 后,日志会先给出「等待事件」,再给出「死锁判定」:

ini
log_lock_waits = on
text
LOG:  process 45281 still waiting for ShareLock on transaction 1243 after 1000.221 ms
DETAIL:  Process holding the lock: 45282. Wait queue: 45281.
CONTEXT:  while updating tuple (0,1) in relation "accounts"
STATEMENT:  UPDATE accounts SET balance = balance + 100 WHERE id = 2;

ERROR:  deadlock detected
DETAIL:  Process 45281 waits for ShareLock on transaction 1243; blocked by process 45282.
Process 45282 waits for ShareLock on transaction 1244; blocked by process 45281.
sql
-- 累计的死锁次数:用于监控与告警(是累计值,不是瞬时值)
SELECT datname, deadlocks, xact_commit, xact_rollback
FROM pg_stat_database
WHERE datname = current_database();
text
 datname | deadlocks | xact_commit | xact_rollback
---------+-----------+-------------+---------------
 appdb   |         3 |       91204 |           561

第 5 步:制造「外键隐式锁」的死锁 ​

这是最难靠肉眼发现的场景。会话 1:

sql
-- 插入子表 → 外键检查在 parent#1 上加 FOR KEY SHARE,持有到事务结束
BEGIN;
INSERT INTO child (parent_id) VALUES (1);

会话 2(同时执行):

sql
-- 插入子表 → 外键检查在 parent#2 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (2);

会话 1 更新 parent#2 → 与 FOR KEY SHARE 冲突,进入等待:

sql
UPDATE parent SET note = 'locked' WHERE id = 2;

会话 2 更新 parent#1 → 环闭合,死锁:

sql
UPDATE parent SET note = 'locked' WHERE id = 1;
text
ERROR:  deadlock detected
DETAIL:  Process 33120 waits for ShareLock on transaction 901; blocked by process 33121.
Process 33121 waits for ShareLock on transaction 902; blocked by process 33120.
CONTEXT:  while locking tuple (0,2) in relation "parent"
sql
-- 规避:把「更新父表」与「插入子表」按固定顺序拆开;
-- 或者先更新完父表、再统一插子表(所有事务保持同一顺序即可)

第 6 步:制造「唯一索引 / upsert 交叉」的死锁 ​

sql
CREATE TABLE counters (
  id int PRIMARY KEY,
  n  bigint NOT NULL DEFAULT 0
);
sql
-- 会话 1:按 (1, 2) 顺序 upsert
BEGIN;
INSERT INTO counters (id, n) VALUES (1, 1), (2, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
sql
-- 会话 2:按 (2, 1) 顺序 upsert 同样的行 → 交叉加锁 → 死锁
BEGIN;
INSERT INTO counters (id, n) VALUES (2, 1), (1, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
text
ERROR:  deadlock detected
DETAIL:  Process 60110 waits for ShareLock on transaction 2044; blocked by process 60111.
Process 60111 waits for ShareLock on transaction 2045; blocked by process 60110.
CONTEXT:  while locking tuple (0,2) in relation "counters"
sql
-- 修正:让输入顺序确定(按主键排序),死锁立即消失
BEGIN;
INSERT INTO counters (id, n)
SELECT id, 1 FROM unnest(ARRAY[2, 1]) AS t(id)
ORDER BY id
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
COMMIT;
text
INSERT 0 2

注意事项 ​

错误!以为「死锁是数据库的 bug,升级版本就能解决」。 死锁是应用加锁顺序不一致的必然结果,任何数据库都躲不掉。PostgreSQL 的职责只是检测并中止,而不是防止——防止是应用的责任。

text
死锁是「应用设计问题」在数据库层的投影:
  加锁顺序不一致(代码问题)
        ↓ 并发时
  循环等待(必然结果)
        ↓ 数据库检测
  40P01 + 中止一个事务(数据库兜底)

错误!用「按主键排序」以外的顺序去更新一组行。 只要顺序与业务参数相关,就一定会出现两条反向路径:

sql
-- 错误!顺序由参数决定,反向调用即死锁
UPDATE accounts SET balance = balance - 100 WHERE id = :from_id;
UPDATE accounts SET balance = balance + 100 WHERE id = :to_id;
sql
-- 正确!顺序由数据本身决定,与调用方向无关
UPDATE accounts SET balance = balance -
  CASE WHEN id = :from_id THEN 100 ELSE 0 END
              + CASE WHEN id = :to_id   THEN 100 ELSE 0 END
WHERE id IN (:from_id, :to_id);

错误!以为外键不会加锁。 外键在父表被引用行上加 FOR KEY SHARE,且同样进等待队列、同样遵循 FIFO。

sql
-- 不推荐:在同一个事务里「先更新父表,再插入引用它的子表」
BEGIN;
UPDATE parent SET note = 'x' WHERE id = 1;
INSERT INTO child (parent_id) VALUES (1);
COMMIT;

错误!把 deadlock_timeout 设得很大来「掩盖」死锁。 它只是「等待多久之后开始检查死锁」的阈值,调大只会延迟报错,不会消除死锁;而且等待期间锁一直被占着,阻塞面更大。

ini
# 不推荐:把检测拖到 60 秒,业务已经被拖垮了
deadlock_timeout = 60s

错误!把 ON CONFLICT DO UPDATE 当成「绝对原子无锁」的写入。 它在冲突行上仍要加排他锁,多行多事务交叉时依旧死锁。

不推荐:应用侧捕获 40P01 后不做任何退避就立即重试。 在高冲突场景下这会退化为活锁。

text
不推荐:while(true) { try { doWork(); break; } catch (Deadlock) { /* 立即重试 */ } }
推荐:  重试次数上限 + 指数退避 + 随机抖动(jitter)

最佳实践 ​

  • 统一一切加锁顺序:多行更新按主键排序;多表更新按固定表序(如按表名字典序)或「父表先于子表」。
sql
-- 多行更新永远按 id 升序
UPDATE accounts a
SET balance = a.balance + v.delta
FROM (VALUES (1, 100), (2, -100)) AS v(id, delta)
WHERE a.id = v.id
  AND a.id = ANY (ARRAY[1, 2]);
  • 缩短事务、缩短持锁时间:不要在事务里调用外部 HTTP、不要等待人工输入、不要做重计算。
sql
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 100;  -- 只做必要的写
COMMIT;                                            -- 立刻提交
  • 一次性锁定所需资源,把「持有并等待」变成「一次性申请」,从根上破坏第二个必要条件。
sql
BEGIN;
-- 一次加锁,顺序固定,避免边做边等
LOCK TABLE orders, order_items IN ROW EXCLUSIVE MODE;
UPDATE orders SET status = 'paid' WHERE id = 100;
DELETE FROM order_items WHERE order_id = 100;
COMMIT;
  • 外键场景固定顺序:先插子表、后更新父表;或用 SELECT ... FOR KEY SHARE 提前显式固定父表行。
sql
BEGIN;
-- 先显式锁父表相关行,顺序与其它事务保持一致
SELECT id FROM parent WHERE id = 1 FOR KEY SHARE;
UPDATE parent SET note = 'x' WHERE id = 1;
COMMIT;
  • 批量 upsert / 多值插入前排序,让所有并发事务按同一顺序锁同一批行。
sql
INSERT INTO counters (id, n)
SELECT id, 1
FROM unnest(ARRAY[3, 1, 2]) AS t(id)
ORDER BY id
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
  • 让写操作幂等,并实现带退避的重试,把 40P01、40001 当作「正常可恢复错误」。
text
重试策略建议:
  最大重试次数:3~5 次
  退避:50ms → 100ms → 200ms(指数)
  抖动:每次叠加 0~30ms 随机值,避免「重试风暴」同步化
  • 开启 log_lock_waits 并把 pg_stat_database.deadlocks 纳入监控,让死锁从「偶发投诉」变成「可观测指标」。
sql
SELECT name, setting, source
FROM pg_settings
WHERE name IN ('log_lock_waits', 'deadlock_timeout');
  • 审查触发器与函数内部的 SQL:它们的加锁顺序对调用者不可见,是死锁最隐蔽的来源。

参考链接 ​

下一节

继续学习:死锁检测机制