Skip to content

死锁检测机制 ​

上一页讲了死锁为什么产生。这一页回答一个更底层的问题:PostgreSQL 是怎么发现死锁的?

答案不是「数据库一直盯着所有事务」——那样开销太大。真实机制是:一个进程在等锁等到 deadlock_timeout(默认 1 秒)之后,会自己醒过来,沿着「谁在等谁」的链条走一圈;如果绕回了自己,就说明形成了环,于是抛出一个 40P01 错误并中止其中一方。

本页把这条链路拆开:等待图(Wait-for Graph)怎么建、检测什么时候触发、牺牲者(Victim)怎么选、日志里能看到什么、以及如何用 pg_locks / pg_blocking_pids() 手工还原一个等待环。

前置知识

阅读本节前,建议先了解:死锁产生原因、锁等待与超时、行级锁(FOR UPDATE / FOR SHARE)

基础概念 ​

检测的核心思想:等待图与环 ​

PostgreSQL 把「谁在等谁」抽象成一张有向图,叫等待图(Wait-for Graph):

  • 节点:正在等待锁的事务(后端进程)。
  • 有向边:A → B 表示「A 正在等 B 释放锁」。

在这张图上,死锁等价于存在一个有向环。所以「死锁检测」这件事,本质上就是在等待图上做一次环检测——一个经典的图算法问题。

text
等待图(无环):正常等待          等待图(有环):死锁
  A ──► B ──► C                      A ──► B
                                      ▲     │
  等 C 提交,A、B 都会陆续拿到锁        └── C ◄┘
                                      A 等 B、B 等 C、C 等 A → 环

谁在检测:等待者自己 ​

一个常见的误解是「PostgreSQL 有一个后台进程专门扫死锁」。实际上没有这样一个常驻扫描进程。真实流程是:

text
1. 事务 T 请求一个锁,发现和别人冲突 → 进入等待队列,开始睡眠
2. 睡眠期间如果被唤醒(拿到锁),流程结束,不做任何检测
3. 如果一直睡到 deadlock_timeout(默认 1s)还没拿到锁
   → T 自己醒来,以「T」为起点,在等待图上做一次环检测
4. 若发现环 → 报告死锁(40P01)、中止某个事务
   若未发现环 → 继续睡,等下一次超时后再检查

为什么「等满 1 秒」才检测

检测本身要遍历锁表(LOCK 结构、PROCLOCK 结构),是有成本的。如果每次等锁都立刻检测,短锁争用(几毫秒就结束)会被大量无谓的检查淹没。1 秒是「绝大多数正常锁等待早已结束」的经验阈值,只对真正长时间的等待付出检测成本。

检测的触发时机与「假死锁」 ​

由于检测是「等满 deadlock_timeout 才做一次」,会出现两个重要推论:

  • 持续时间短于 deadlock_timeout 的锁冲突,永远不会被判定为死锁。即使它们是环形的,只要其中一方在 1 秒内提交或回滚,环就自动解开了,双方都正常继续。
  • 死锁报错不是「立刻」出现的,而是滞后最多 deadlock_timeout。所以 40P01 的响应时间通常是 1 秒左右。
text
时间轴
0ms      事务 A 与 B 形成环(互相等待)
   │
   ├── 双方都进入睡眠,开始计时
   │
100ms    若此时 A 因某种原因回滚 → 环解开,双方都不会报错
   │
1000ms   A 醒来,运行检测器 → 发现环 → 中止 B(或 A)
   │
1000ms+  应用收到 40P01

只有「重量级锁」参与检测 ​

死锁检测器只覆盖由锁管理器(Lock Manager)统一管理的重量级锁(Heavyweight Lock):表级锁、行级锁、事务 ID 锁、advisory 锁等——也就是能在 pg_locks 里看到的那些。

存储层的轻量级锁(LWLock)走的是完全独立的机制,不参与死锁检测。这也是排查时的一个分水岭:

锁类型是否参与死锁检测等待事件异常时的表现
表级锁 / 行级锁 / 事务锁是wait_event_type = 'Lock'报 40P01,自动解除
advisory 锁是wait_event_type = 'Lock'报 40P01,自动解除
LWLock否wait_event_type = 'LWLock'不报错,会一直等
Buffer Pin否wait_event_type = 'BufferPin'不报错,会一直等
sql
-- 一眼区分:是在等重量级锁,还是卡在存储层的轻量级锁上
SELECT pid,
       wait_event_type,
       wait_event,
       state,
       now() - query_start AS waited,
       left(query, 50)     AS query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
ORDER BY waited DESC;
text
 pid  | wait_event_type |  wait_event   | state  |   waited   |       query
------+-----------------+---------------+--------+------------+-------------------
45281 | Lock            | transactionid | active | 00:00:01.2 | UPDATE accounts ...
45310 | LWLock          | buffer_content| active | 00:00:00.1 | UPDATE hot_row ...

检测结果:40P01 与日志 ​

一旦检测到环,PostgreSQL 会:

  1. 向被中止的那个进程抛出 SQLSTATE 40P01(deadlock_detected),中止它当前的事务(不是断开会话)。
  2. 在服务端日志里写入完整的等待环细节(DETAIL 行)和触发位置的 CONTEXT。
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"

语法与用法 ​

观察等待边(谁在等谁) ​

没有「死锁检测函数」可以直接调用,但可以把检测器看到的等待边查出来。核心是两个工具:pg_blocking_pids() 与 pg_locks.granted。

text
检测机制与观察手段的对应关系
  pg_stat_activity.wait_event_type = 'Lock'   →  该进程正处于「等待」状态
  pg_blocking_pids(pid)                       →  该进程等待的「阻塞者」列表(出边)
  pg_locks.granted = false                    →  该进程有未授予的锁请求
  pg_locks.granted = true                     →  该进程正在持有的锁(构成别人的入边)
sql
-- 一次性看到:谁在等、等哪类锁、被谁挡着
SELECT a.pid,
       a.wait_event_type,
       a.wait_event,
       pg_blocking_pids(a.pid) AS blocked_by,
       a.state,
       now() - a.query_start   AS waited,
       left(a.query, 50)       AS query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY waited DESC;
text
 pid  | wait_event_type |  wait_event   | blocked_by | state  |   waited   |       query
------+-----------------+---------------+------------+--------+------------+------------------
45281 | Lock            | transactionid | {45282}    | active | 00:00:01.5 | UPDATE accounts ...

从锁视图看未授予的请求 ​

pg_locks 里,每一行是一个锁请求;granted = false 的那一行代表「正在等」,granted = true 的行代表「已持有」。

sql
SELECT pid,
       locktype,
       mode,
       granted,
       relation::regclass AS table_name,
       transactionid,
       waitstart
FROM pg_locks
WHERE granted = false
ORDER BY waitstart;
text
 pid  |   locktype    |   mode    | granted | table_name | transactionid |         waitstart
------+---------------+-----------+---------+------------+---------------+----------------------------
45281 | transactionid | ShareLock | f       |            |          1243 | 2026-09-28 10:20:31.412+08
sql
-- 把「等待中的锁」和「持有中的锁」拼起来,人工还原环
SELECT l.pid,
       l.locktype,
       l.mode,
       l.granted,
       l.relation::regclass AS table_name,
       l.transactionid,
       a.wait_event_type,
       pg_blocking_pids(l.pid) AS blocked_by
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype IN ('transactionid', 'tuple', 'relation')
ORDER BY l.pid, l.granted;

打开日志,让环「留痕」 ​

内存里的等待图是瞬时的,死锁一解除就什么都没了。要事后分析,必须靠日志:

ini
# postgresql.conf
log_lock_waits = on       # 等待超过 deadlock_timeout 就记一条日志(默认 off)
deadlock_timeout = 1s     # 检测阈值,也是 log_lock_waits 的判定阈值
log_line_prefix = '%m [%p] %q%u@%d '   # 让日志带上时间、PID、用户、库名
sql
-- 用 SQL 查看/临时修改(deadlock_timeout 需要超级用户权限)
SHOW deadlock_timeout;
SHOW log_lock_waits;

-- log_lock_waits 可以按会话/角色设置
ALTER DATABASE appdb SET log_lock_waits = on;

详细说明 ​

等待图是怎么建起来的 ​

等待图的边来自两处信息,检测器把它们拼在一起:

  • 出边(我在等谁):来自锁请求。事务 T 请求的锁与某个已持有的锁冲突,于是 T 挂到该锁的等待队列(Wait Queue)上,「队列前面的持有者与等待者」就是 T 的出边候选。
  • 入边(谁在等我):把上面的关系反过来看即可——检测器并不需要单独维护入边表,它从「当前进程」出发顺着等待链递归即可。

关键在于:行级锁冲突在锁结构上表现为 transactionid 等待。因为 PostgreSQL 的行锁写在元组头的 xmax 里,加锁者需要等的是「那个持锁事务的 XID」,而不是某个物理锁对象。

sql
-- 验证:行锁冲突时,等的是 transactionid,而不是 tuple
SELECT locktype, mode, granted, transactionid, tuple, virtualxid
FROM pg_locks
WHERE pid = pg_backend_pid();
text
   locktype    |     mode      | granted | transactionid | tuple | virtualxid
---------------+---------------+---------+---------------+-------+------------
 relation      | RowExclusiveLock | t    |               |       |
 virtualxid    | ExclusiveLock | t       |               |       | 6/142

deadlock_timeout 与检测成本 ​

deadlock_timeout 的取值直接决定「检测频率」与「检测成本」的平衡:

取值效果适用场景
极小(如 50ms)检测非常频繁,短等待也要跑一次图搜索几乎从不推荐,浪费 CPU
1s(默认)兼顾响应速度与开销绝大多数系统
10s 以上检测很省,但死锁要等很久才被解除锁等待普遍很短、死锁极罕见的批处理系统
sql
-- 查看当前值与层级来源,确认没被某层配置意外覆盖
SELECT name, setting, unit, pending_restart, source, sourcefile
FROM pg_settings
WHERE name = 'deadlock_timeout';
text
      name        | setting | unit | pending_restart |  source   |         sourcefile
------------------+---------+------+-----------------+-----------+----------------------------
 deadlock_timeout | 1       | s    | f               | file      | /etc/postgresql/.../postgresql.conf

把 deadlock_timeout 设成 0 会怎样

设为 0 意味着「一旦需要等待就立刻检测」。这会让每一次正常的锁等待都触发一次图搜索,在高并发短事务场景下带来可观的额外 CPU 开销,甚至因为检测本身要拿锁管理器的内部锁而加剧争用。这不是优化,是自伤。

检测算法:从自己出发找环 ​

检测器的入口是一个「从当前等待者出发的深度优先搜索(DFS)」:

text
DeadLockCheck(T)
  for each 锁请求 R that T is waiting for:
      for each 持有者/队列前方进程 P of R:
          if P == T:            # 绕回了起点
              return 发现环 ✅
          if P 已被访问过:        # 说明 P 在另一条已知链上,避免重复
              continue
          mark P
          if DeadLockCheck(P) 发现环:
              return 发现环 ✅
  return 未发现环
text
一次真实搜索过程(沿用「不同顺序更新」的例子)
  起点 T = 45281
  45281 在等 transactionid=1243        → 持有者是 45282
      45282 在等 transactionid=1244    → 持有者是 45281
          45281 == 起点 → 环闭合 ✅

为什么只需要检测「自己所在的环」

等待图上如果存在环,一定包含正在执行检测的那个进程。因为其它事务若形成环,环上的某个进程在等满超时后也会自己发起检测。所以不需要做全图扫描——只搜自己的连通子图就够了,成本大幅下降。

牺牲者(Victim)是怎么选的 ​

找到环之后,必须中止环上的某个事务才能解开。这个被中止的事务叫牺牲者(Victim)。选择原则可以概括为:

  • 优先中止尽量少的事务:能杀一个解决,就绝不杀两个。
  • 多个等价选择时,具体杀谁没有对应用可见的、可靠的约定。

所以正确的工程态度是:不要依赖「数据库会杀哪个」,而要让环上的每一个事务都可重试、都幂等。

sql
-- 应用侧的正确应对:把 40P01 当作「可重试的瞬时错误」
-- 伪代码:
--   try:
--       run_transaction()
--   except DeadlockDetected (40P01):
--       backoff_and_retry()      # 3~5 次上限 + 指数退避 + 抖动
text
牺牲者不确定,但有一件事是确定的:
  环上的事务必须都能「安全地重来一遍」
  → 写操作幂等(ON CONFLICT / 条件更新 / 唯一键约束)
  → 重试带退避,避免退化成活锁

死锁报告里的 DETAIL 行怎么读 ​

日志与报错里的 DETAIL 是环的完整打印,它是排查的黄金信息:

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"
片段含义得到的结论
Process 45281 waits for ShareLock第一条通常是被牺牲者找出应用里处理该 PID 的连接
on transaction 1243它在等事务 1243 的行锁冲突发生在行级,不是表级
blocked by process 45282挡它的是 45282环上的下一个节点
第二行 blocked by process 45281又绕回来了环闭合,确认是死锁
CONTEXT: while updating tuple (0,1)冲突在 accounts 表的第 0 页第 1 个元组直接定位到具体行
HINT: See server log for query details报错本身不含 SQL必须去日志里找完整语句
sql
-- 日志里拿到 SQL 后,回到数据库确认这两条 SQL 的加锁顺序
SELECT pid, usename, application_name, xact_start, left(query, 80) AS query
FROM pg_stat_activity
WHERE pid IN (45281, 45282);

log_lock_waits:把「等待」也留下来 ​

死锁是少数派。线上更常见的是长等待:语句没死锁,但被别人卡了几十秒。log_lock_waits 让这类等待也进日志,与死锁日志配合形成完整证据链:

text
# log_lock_waits = on 时的两类日志

# ① 长等待(超过 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.
ini
# 推荐的基础日志配置:能定位死锁,又不至于刷爆磁盘
log_lock_waits = on
deadlock_timeout = 1s
log_min_messages = warning
log_line_prefix = '%m [%p] %q%u@%d app=%a '
log_min_duration_statement = 0                 # 谨慎开启,量大时改用采样
sql
-- 事后统计:日志里出现过多少次死锁(配合外部日志检索工具)
-- 数据库内只能拿到累计计数:
SELECT datname, deadlocks
FROM pg_stat_database
WHERE datname = current_database();

用 pg_locks 与 pg_blocking_pids 手工还原等待环 ​

自动化工具不可用时,用两条查询就能手工把环画出来。思路是「以等待者为起点,一跳一跳往外走」:

sql
-- 第 1 跳:找出所有等待者,以及直接阻塞它们的进程
SELECT pid,
       pg_blocking_pids(pid) AS next_hop,
       wait_event,
       now() - query_start AS waited
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
ORDER BY waited DESC;
text
 pid  | next_hop |  wait_event   |   waited
------+----------+---------------+------------
45281 | {45282}  | transactionid | 00:00:01.6
sql
-- 第 2 跳:看这些「阻塞者」自己是不是也在等别人 → 若绕回来即为环
SELECT pid,
       pg_blocking_pids(pid) AS next_hop,
       state,
       wait_event_type,
       left(query, 50) AS query
FROM pg_stat_activity
WHERE pid = ANY (
  SELECT unnest(pg_blocking_pids(pid))
  FROM pg_stat_activity
  WHERE cardinality(pg_blocking_pids(pid)) > 0
);
text
 pid  | next_hop | state  | wait_event_type |       query
------+----------+--------+-----------------+-------------------
45282 | {45281}  | active | Lock            | UPDATE accounts ...
text
把两跳拼起来:
  45281 → {45282} → {45281}     绕回起点 → 环成立 → 确认死锁

监控:把死锁从「偶发投诉」变成「可观测指标」 ​

检测机制本身不提供告警,需要自己把计数器接出来:

sql
-- 累计死锁次数(单调递增,适合做增量告警)
SELECT datname,
       deadlocks,
       xact_commit,
       xact_rollback,
       stats_reset
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1');
text
 datname | deadlocks | xact_commit | xact_rollback |         stats_reset
---------+-----------+-------------+---------------+----------------------------
 appdb   |        17 |     2840193 |         12904 | 2026-09-01 00:00:00.000+08
sql
-- 当前是否还有等待超过 1 秒的锁(实时告警用)
SELECT pid,
       wait_event_type,
       wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       now() - query_start   AS waited
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
  AND now() - query_start > interval '1 second'
ORDER BY waited DESC;

检测与 lock_timeout、statement_timeout 的分工 ​

三者都会「让语句失败」,但触发条件完全不同,排查时必须分清:

text
                        谁在等        触发条件              错误码
deadlock_timeout  ──►   等锁的进程    等待 > N 后去检测      仅在真有环时 40P01
lock_timeout      ──►   等锁的进程    等锁总时长 > N         55P03
statement_timeout ──►   执行中的语句  语句总耗时 > N         57014
sql
-- 一个会话里三者可以同时存在,谁先满足谁生效
BEGIN;
SET LOCAL lock_timeout = '3s';        -- 等锁 3s 就放弃
SET LOCAL statement_timeout = '10s';  -- 语句总共 10s 就放弃
SET LOCAL deadlock_timeout = '1s';    -- 等满 1s 去查一次死锁
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
COMMIT;

40P01 与 55P03 的处置方式不同

40P01 说明确实是死锁,必须从「加锁顺序」上根治,重试只是兜底;55P03 只是超时,多数情况下退避重试即可。看到 55P03 就以为「有死锁」,会白白把排查方向带偏。

实战示例 ​

下面完整复现一次死锁,并从报错、日志、视图三个角度把它「看穿」。

第 1 步:准备环境与两张表 ​

sql
CREATE TABLE IF NOT EXISTS 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)
ON CONFLICT (id) DO NOTHING;
text
CREATE TABLE
INSERT 0 2

第 2 步:打开日志开关 ​

sql
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
SHOW log_lock_waits;
text
ALTER SYSTEM
 pg_reload_conf
----------------
 t
(1 row)

 log_lock_waits
----------------
 on
(1 row)

第 3 步:制造环,并观察「等待 1 秒」的现象 ​

会话 1:

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

会话 2:

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

会话 1 申请行 2 —— 此时只是「等待」,不是死锁:

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

立刻(不到 1 秒内)查询状态,可以看到它在等:

sql
SELECT pid, wait_event_type, wait_event, now() - query_start AS waited
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
text
 pid  | wait_event_type |  wait_event   |   waited
------+-----------------+---------------+------------
45281 | Lock            | transactionid | 00:00:00.3
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;

第 4 步:闭合环,捕获 40P01 ​

会话 2 申请行 1 —— 环闭合,检测器立即判定死锁:

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, wait_event_type, state
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
text
 pid | wait_event_type | state
-----+-----------------+-------
(0 rows)

第 5 步:用视图还原刚才的环 ​

把 pg_locks(未授予的请求)与 pg_blocking_pids()(阻塞关系)结合,人工画出环:

sql
SELECT a.pid,
       a.wait_event                                                 AS waiting_for,
       pg_blocking_pids(a.pid)                                      AS blocked_by,
       l.locktype,
       l.transactionid,
       l.granted,
       left(a.query, 45)                                            AS query
FROM pg_stat_activity a
LEFT JOIN pg_locks l ON l.pid = a.pid AND l.granted = false
WHERE a.wait_event_type = 'Lock' OR cardinality(pg_blocking_pids(a.pid)) > 0;
text
 pid  |  waiting_for  | blocked_by |   locktype    | transactionid | granted |     query
------+---------------+------------+---------------+---------------+---------+----------------
45281 | transactionid | {45282}    | transactionid |          1243 | f       | UPDATE accounts ...
sql
-- 补充:看「持有者」侧的已授予锁,确认两端都拿到了对方的等待对象
SELECT pid, locktype, mode, granted, transactionid
FROM pg_locks
WHERE pid IN (45281, 45282)
ORDER BY pid, granted;
text
 pid  |   locktype    |     mode      | granted | transactionid
------+---------------+---------------+---------+---------------
45281 | transactionid | ExclusiveLock | t       |          1244
45282 | transactionid | ExclusiveLock | t       |          1243
text
解读:
  45281 持有 1244(即行 2 的锁),却在等 1243
  45282 持有 1243(即行 1 的锁),却在等 1244
  → 互相持有对方所需 → 正是环

第 6 步:从累计指标确认死锁确实发生过 ​

sql
SELECT datname, deadlocks, xact_rollback
FROM pg_stat_database
WHERE datname = current_database();
text
 datname | deadlocks | xact_rollback
---------+-----------+---------------
 appdb   |        18 |         12905
text
对照第 1 步之前的取样(deadlocks = 17),
现在变成 18 → 本次死锁被记录了一次。
注意:这是「累计值」,做告警时要取两次采样的差值。

第 7 步:验证「短于 deadlock_timeout 的环不会被判死锁」 ​

把检测阈值调大,再看同一个环:只要其中一方及时提交,就不会报错。

sql
ALTER SYSTEM SET deadlock_timeout = '5s';
SELECT pg_reload_conf();
sql
-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
sql
-- 会话 2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
sql
-- 会话 1:申请行 2 → 开始等待(但要等满 5s 才会去检测)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
sql
-- 会话 2:在 5 秒内立刻提交 → 环自动解开,双方都成功,不会出现 40P01
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
text
COMMIT

-- 会话 1 随后顺利拿到锁:
UPDATE 1
sql
-- 恢复默认值,避免影响后续实验
ALTER SYSTEM RESET deadlock_timeout;
SELECT pg_reload_conf();

注意事项 ​

错误!以为有「后台进程专门扫描死锁」。 检测由等待中的进程自己在 deadlock_timeout 之后发起,没有常驻扫描任务。所以「等得越久才被检测到」是机制使然。

text
正确理解:
  等待 < deadlock_timeout 就结束 → 永远不会被检测(也不需要)
  等待 ≥ deadlock_timeout       → 由该等待者自己发起一次环检测

错误!把 deadlock_timeout 当成「死锁超时时间」来理解。 它不是「等 N 秒就放弃」,而是「等 N 秒后去检查一下有没有环」;没环就继续等。

sql
-- 错误认知下的写法:指望靠它让语句失败
SET deadlock_timeout = '1s';   -- 并不会让等待 1s 的语句失败

错误!依赖「数据库会杀掉另一个事务」来设计业务。 牺牲者是哪个事务没有可靠约定,环上每个事务都必须可重试且幂等。

sql
-- 不推荐:假设「一定是我活下来」
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;   -- 可能被 40P01 中止的就是这个事务

错误!把 LWLock 等待当成死锁来排查。 轻量级锁不参与死锁检测,不会产生 40P01;看到 wait_event_type = 'LWLock' 应转向存储层争用排查。

sql
-- 区分对待:只有 wait_event_type = 'Lock' 才属于死锁检测的范畴
SELECT wait_event_type, count(*)
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
GROUP BY wait_event_type;
text
 wait_event_type | count
-----------------+-------
 Lock            |     1
 LWLock          |     2

错误!生产环境不开 log_lock_waits。 死锁报错本身不含完整 SQL,只有 HINT: See server log for query details。不开日志,事后就只能靠猜。

ini
-- 不推荐
log_lock_waits = off

不推荐:把 deadlock_timeout 调到毫秒级来「更快发现死锁」。 会让正常短锁等待也频繁触发图搜索,得不偿失。

不推荐:看到 40P01 就无限重试而不修加锁顺序。 高冲突下会退化成活锁,日志和 CPU 都被打满,业务却依然失败。

最佳实践 ​

  • 先确认「是哪一类问题」再动手:wait_event_type = 'Lock' + 40P01 才走死锁路径;LWLock 走存储层路径。
sql
-- 巡检第一步:当前所有等待,按类型归类
SELECT wait_event_type,
       wait_event,
       count(*)                    AS sessions,
       max(now() - query_start)    AS longest_wait
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
GROUP BY wait_event_type, wait_event
ORDER BY sessions DESC;
  • 生产环境开启 log_lock_waits,并把 deadlock_timeout 保持在默认 1s(除非有明确理由)。
ini
log_lock_waits = on
deadlock_timeout = 1s
log_line_prefix = '%m [%p] %q%u@%d app=%a '
  • 把 pg_stat_database.deadlocks 做成增量告警,而不是只看绝对值。
sql
-- 采集脚本示意:记录两次采样,只看 delta
SELECT datname, deadlocks
FROM pg_stat_database
WHERE datname = current_database();
  • 应用侧统一实现「40P01 / 40001 重试器」,并带指数退避与抖动。
text
推荐参数:
  最大重试 3~5 次
  退避 50ms → 100ms → 200ms
  抖动:每次叠加 0~30ms 随机值(打散重试风暴)
  重试前必须保证事务已 ROLLBACK
  • 用 pg_blocking_pids() 作为「等待链」的唯一入口,比手工 JOIN pg_locks 更快定位。
sql
SELECT pid,
       pg_blocking_pids(pid)  AS blocked_by,
       now() - query_start    AS waited,
       left(query, 60)        AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
ORDER BY waited DESC;
  • 排查死锁时固定四步走:看报错确认 40P01 → 读 DETAIL 还原环 → 去日志取完整 SQL → 审查这两条 SQL 的加锁顺序。
text
1. 应用报错中有 40P01 ?
2. log 中搜 'deadlock detected' 取 DETAIL 与 CONTEXT
3. 按 PID 找到对应连接的完整 STATEMENT
4. 对比两条业务路径的加锁顺序 → 统一之
  • 对涉及多行的写操作,强制统一顺序(按主键排序、按固定表序),这是从源头消灭环的唯一手段。
sql
UPDATE accounts a
SET balance = a.balance + v.delta
FROM (VALUES (1, -100), (2, 100)) AS v(id, delta)
WHERE a.id = v.id;
  • 监控「长等待」而不只是「死锁」:死锁只是冰山一角,长期锁等待同样是事故前兆。
sql
SELECT pid, wait_event, now() - query_start AS waited, left(query, 60) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
  AND now() - query_start > interval '5 seconds'
ORDER BY waited DESC;

下一节

继续学习:避免死锁的最佳实践

参考链接 ​