死锁产生原因
死锁(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 等待环 |
普通锁等待(可自行解除)
事务 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 错误,同时服务端日志里会留下完整的等待环。这个错误不是连接断开,事务被中止但会话仍然存活,所以应用可以(也应该)自动重试。
-- 应用侧捕获的核心特征
-- SQLSTATE 40P01
-- MESSAGE deadlock detected
-- DETAIL Process 1234 waits for ShareLock on transaction 5678; blocked by process 8765. ...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"死锁、锁等待与活锁的区别
三者经常被混为一谈,但处置方式完全不同:
① 锁等待 A 等 B 释放锁 → 等待即可(或加 lock_timeout 兜底)
② 死锁 A 等 B,B 等 A(环) → 数据库自动中止一个事务(40P01)
③ 活锁 A、B 反复失败反复重试 → 无环、无等待,但永远做不成事活锁(Livelock) 是死锁的隐蔽变种:两个事务都不阻塞,但每次都因为冲突被中止、每次都重试、每次都再冲突。它往往出现在「重试没有退避、冲突窗口固定」的实现里,表现为 CPU 和日志量飙升、业务成功率却接近 0。它没有 40P01,所以更难发现。
-- 活锁特征:短时间内在同一批行上反复出现序列化失败/死锁,却都没有成功
SELECT xact_start, query
FROM pg_stat_activity
WHERE query ILIKE '%UPDATE accounts%'
ORDER BY xact_start DESC
LIMIT 20;语法与用法
制造与观察死锁
想理解成因,最快的方法就是手工制造一个。下面是最经典的两行反向更新:
会话 1:
BEGIN;
-- 第一步:锁住行 1
UPDATE accounts SET balance = balance - 100 WHERE id = 1;会话 2(此时执行,不会被阻塞,因为它锁的是另一行):
BEGIN;
-- 第一步:锁住行 2
UPDATE accounts SET balance = balance - 100 WHERE id = 2;回到会话 1,尝试锁行 2 —— 开始等待:
-- 第二步:等待会话 2 释放行 2
UPDATE accounts SET balance = balance + 100 WHERE id = 2;-- 会话 1 此刻的状态:wait_event_type = 'Lock',wait_event = 'transactionid'回到会话 2,尝试锁行 1 —— 环闭合,死锁触发:
-- 第二步:等待会话 1 释放行 1 → 形成环
UPDATE accounts SET balance = balance + 100 WHERE id = 1;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 | 应用侧重试的判断依据 | 应立即重试(需幂等) |
-- 把「哪个进程被牺牲」和「当前谁在等谁」一起查出来
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'; pid | wait_event_type | wait_event | blocked_by | query
------+-----------------+---------------+------------+---------------------------------
45281 | Lock | transactionid | {45282} | UPDATE accounts SET balance = ...详细说明
场景一:不同顺序更新同一批行
这是线上死锁的头号来源,占绝大多数生产事故。根源在于「同一组行、不同的访问顺序」:
-- 转账 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;-- 同时,转账 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;标签等价于:
事务 A: 持有 [1] → 等待 [2]
事务 B: 持有 [2] → 等待 [1] ← 环关键认识:这两条 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。这两种模式是冲突的(见锁冲突矩阵),于是「外键检查」与「更新父表」交错时就会闭环:
-- 会话 1:插入子表 → 外键检查在 parent#1 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (1);-- 会话 2:插入子表 → 外键检查在 parent#2 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (2);-- 会话 1:更新 parent#2 → 需要 FOR NO KEY UPDATE,与会话 2 的 FOR KEY SHARE 冲突 → 等待
UPDATE parent SET note = 'x' WHERE id = 2;-- 会话 2:更新 parent#1 → 需要 FOR NO KEY UPDATE,与会话 1 的 FOR KEY SHARE 冲突 → 等待
-- 会话 1 等会话 2、会话 2 等会话 1 → 死锁
UPDATE parent SET note = 'y' WHERE id = 1;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)的并发插入也是死锁高发区。当两个事务插入相同的键值时,后者需要等待前者的事务结束来决定自己是否违反唯一约束:
-- 会话 1
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com'); -- 拿到「猜测插入」令牌,未提交-- 会话 2:同一键值 → 必须等会话 1 的结果
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com'); -- 等待单看这一对只会「等待」,不会死锁。真正的死锁需要两个键值的交叉插入:
-- 会话 1:插入两个键值,顺序是 (A, B)
BEGIN;
INSERT INTO users (email) VALUES ('a@x.com'); -- 拿到 A
INSERT INTO users (email) VALUES ('b@x.com'); -- 等 B(被会话 2 占)-- 会话 2:插入同样的两个键值,顺序是 (B, A) —— 交叉!
BEGIN;
INSERT INTO users (email) VALUES ('b@x.com'); -- 拿到 B
INSERT INTO users (email) VALUES ('a@x.com'); -- 等 A(被会话 1 占)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.-- 规避:批量插入前先去重排序;或用 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 在发生冲突的行上加了排他锁,而多行插入的加锁顺序取决于输入顺序:
-- 会话 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;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 侧都行)
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 交错,就很容易和显式锁一起构成环:
-- 会话 1:先锁 t1 再锁 t2;会话 2:先锁 t2 再锁 t1 → 环
BEGIN;
LOCK TABLE t1 IN ACCESS EXCLUSIVE MODE; -- 拿到 t1
-- ... 中间夹着业务逻辑
LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE; -- 等 t2 → 死锁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.-- 更隐蔽的现实版本:一个事务里既有 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;-- 排查:谁在等表锁、等的是哪张表
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,把「加锁顺序」藏进了别人看不见的地方。
-- 一个「审计」触发器:每次更新 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 的真实加锁顺序已经和表面写法不一样了:
-- 表面:先 orders 后 audit_log
-- 实际(行级):orders 行 → audit_log 行
UPDATE orders SET status = 'paid' WHERE id = 1;-- 另一个事务里有个函数,先写 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;
$$;两条路径的加锁顺序相反:
路径 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 也纳入「加锁顺序审查」。
场景七:批处理与多表更新顺序不一致
批量任务(数据修复、对账、离线计算)通常一次处理多张表,且各任务的表访问顺序往往由业务的自然书写顺序决定,天然不一致:
-- 批处理 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;-- 规避:批处理统一「从父表到子表」或「按表名排序」的顺序,
-- 并且每批只处理有限行数,减小锁范围与持锁时间
SELECT id FROM orders WHERE status = 'void' ORDER BY id LIMIT 1000;40P01 错误与日志解读
死锁被检测到时,PostgreSQL 会在被牺牲的事务上抛出 40P01,并同时在服务端日志里记录完整信息。开启 log_lock_waits 后,日志还能给出「等待了多久、谁持有锁」:
# postgresql.conf
log_lock_waits = on # 等待超过 deadlock_timeout 就记日志
deadlock_timeout = 1s # 默认 1 秒
log_min_messages = warning# 服务端日志(等待超过 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.-- 应用侧:区分「可重试」和「不可重试」的错误
-- 40P01 deadlock_detected → 立即重试(需幂等)
-- 40001 serialization_failure → 立即重试(需幂等)
-- 55P03 lock_not_available → 退避后重试
-- 57014 query_canceled → 先查语句本身,不要盲目重试死锁与活锁、锁等待的区别(排查决策树)
把三类问题放进一张决策树,排查时就不会跑偏:
语句挂住不动
├─ 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 还原环 → 统一加锁顺序-- 决策树第一层的落地查询
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 步:建表与初始数据
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
);CREATE TABLE
INSERT 0 2
CREATE TABLE
CREATE TABLE
INSERT 0 2
CREATE TABLE第 2 步:制造「不同顺序更新」的死锁
会话 1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;会话 2:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;会话 1 继续(进入等待):
UPDATE accounts SET balance = balance + 100 WHERE id = 2;会话 2 继续(触发死锁):
UPDATE accounts SET balance = balance + 100 WHERE id = 1;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"此时其中一个会话的事务被中止,另一个立刻成功。用下面的查询确认「环已经解开」:
SELECT pid, state, wait_event_type, left(query, 50) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'; pid | state | wait_event_type | query
-----+-------+-----------------+-------
(0 rows)第 3 步:观察等待中的锁与等待链
在「会话 1 已经等待、会话 2 尚未触发死锁」的那个瞬间,可以从两个视图看到完整的等待信息:
-- 谁在等、等的那一刻锁是什么类型
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'; pid | wait_event_type | wait_event | locktype | mode | granted
------+-----------------+---------------+---------------+-----------+---------
45281 | Lock | transactionid | transactionid | ShareLock | f-- 等待链: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; pid | blocking_pids | query
------+---------------+---------------------------------
45281 | {45282} | UPDATE accounts SET balance = ...第 4 步:解读服务端日志
死锁的完整 SQL 只在日志里。开启 log_lock_waits 后,日志会先给出「等待事件」,再给出「死锁判定」:
log_lock_waits = onLOG: 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.-- 累计的死锁次数:用于监控与告警(是累计值,不是瞬时值)
SELECT datname, deadlocks, xact_commit, xact_rollback
FROM pg_stat_database
WHERE datname = current_database(); datname | deadlocks | xact_commit | xact_rollback
---------+-----------+-------------+---------------
appdb | 3 | 91204 | 561第 5 步:制造「外键隐式锁」的死锁
这是最难靠肉眼发现的场景。会话 1:
-- 插入子表 → 外键检查在 parent#1 上加 FOR KEY SHARE,持有到事务结束
BEGIN;
INSERT INTO child (parent_id) VALUES (1);会话 2(同时执行):
-- 插入子表 → 外键检查在 parent#2 上加 FOR KEY SHARE
BEGIN;
INSERT INTO child (parent_id) VALUES (2);会话 1 更新 parent#2 → 与 FOR KEY SHARE 冲突,进入等待:
UPDATE parent SET note = 'locked' WHERE id = 2;会话 2 更新 parent#1 → 环闭合,死锁:
UPDATE parent SET note = 'locked' WHERE id = 1;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"-- 规避:把「更新父表」与「插入子表」按固定顺序拆开;
-- 或者先更新完父表、再统一插子表(所有事务保持同一顺序即可)第 6 步:制造「唯一索引 / upsert 交叉」的死锁
CREATE TABLE counters (
id int PRIMARY KEY,
n bigint NOT NULL DEFAULT 0
);-- 会话 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;-- 会话 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;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"-- 修正:让输入顺序确定(按主键排序),死锁立即消失
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;INSERT 0 2注意事项
错误!以为「死锁是数据库的 bug,升级版本就能解决」。 死锁是应用加锁顺序不一致的必然结果,任何数据库都躲不掉。PostgreSQL 的职责只是检测并中止,而不是防止——防止是应用的责任。
死锁是「应用设计问题」在数据库层的投影:
加锁顺序不一致(代码问题)
↓ 并发时
循环等待(必然结果)
↓ 数据库检测
40P01 + 中止一个事务(数据库兜底)错误!用「按主键排序」以外的顺序去更新一组行。 只要顺序与业务参数相关,就一定会出现两条反向路径:
-- 错误!顺序由参数决定,反向调用即死锁
UPDATE accounts SET balance = balance - 100 WHERE id = :from_id;
UPDATE accounts SET balance = balance + 100 WHERE id = :to_id;-- 正确!顺序由数据本身决定,与调用方向无关
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。
-- 不推荐:在同一个事务里「先更新父表,再插入引用它的子表」
BEGIN;
UPDATE parent SET note = 'x' WHERE id = 1;
INSERT INTO child (parent_id) VALUES (1);
COMMIT;错误!把 deadlock_timeout 设得很大来「掩盖」死锁。 它只是「等待多久之后开始检查死锁」的阈值,调大只会延迟报错,不会消除死锁;而且等待期间锁一直被占着,阻塞面更大。
# 不推荐:把检测拖到 60 秒,业务已经被拖垮了
deadlock_timeout = 60s错误!把 ON CONFLICT DO UPDATE 当成「绝对原子无锁」的写入。 它在冲突行上仍要加排他锁,多行多事务交叉时依旧死锁。
不推荐:应用侧捕获 40P01 后不做任何退避就立即重试。 在高冲突场景下这会退化为活锁。
不推荐:while(true) { try { doWork(); break; } catch (Deadlock) { /* 立即重试 */ } }
推荐: 重试次数上限 + 指数退避 + 随机抖动(jitter)最佳实践
- 统一一切加锁顺序:多行更新按主键排序;多表更新按固定表序(如按表名字典序)或「父表先于子表」。
-- 多行更新永远按 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、不要等待人工输入、不要做重计算。
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 100; -- 只做必要的写
COMMIT; -- 立刻提交- 一次性锁定所需资源,把「持有并等待」变成「一次性申请」,从根上破坏第二个必要条件。
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提前显式固定父表行。
BEGIN;
-- 先显式锁父表相关行,顺序与其它事务保持一致
SELECT id FROM parent WHERE id = 1 FOR KEY SHARE;
UPDATE parent SET note = 'x' WHERE id = 1;
COMMIT;- 批量 upsert / 多值插入前排序,让所有并发事务按同一顺序锁同一批行。
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当作「正常可恢复错误」。
重试策略建议:
最大重试次数:3~5 次
退避:50ms → 100ms → 200ms(指数)
抖动:每次叠加 0~30ms 随机值,避免「重试风暴」同步化- 开启
log_lock_waits并把pg_stat_database.deadlocks纳入监控,让死锁从「偶发投诉」变成「可观测指标」。
SELECT name, setting, source
FROM pg_settings
WHERE name IN ('log_lock_waits', 'deadlock_timeout');- 审查触发器与函数内部的 SQL:它们的加锁顺序对调用者不可见,是死锁最隐蔽的来源。
参考链接
- PostgreSQL 官方文档:显式锁(Explicit Locking,含 Deadlocks 一节)
- PostgreSQL 官方文档:锁管理参数(deadlock_timeout / log_lock_waits)
- PostgreSQL 官方文档:pg_locks 视图
- PostgreSQL 官方文档:SELECT(The Locking Clause,FOR KEY SHARE)
- PostgreSQL 官方文档:LIGHTWEIGHT LOCKS / 系统信息函数(pg_blocking_pids)
- PostgreSQL 官方文档:监控统计(pg_stat_database.deadlocks)
- PostgreSQL 官方文档:错误代码(40P01 / 40001 / 55P03)
下一节
继续学习:死锁检测机制