死锁检测机制
上一页讲了死锁为什么产生。这一页回答一个更底层的问题: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 释放锁」。
在这张图上,死锁等价于存在一个有向环。所以「死锁检测」这件事,本质上就是在等待图上做一次环检测——一个经典的图算法问题。
等待图(无环):正常等待 等待图(有环):死锁
A ──► B ──► C A ──► B
▲ │
等 C 提交,A、B 都会陆续拿到锁 └── C ◄┘
A 等 B、B 等 C、C 等 A → 环谁在检测:等待者自己
一个常见的误解是「PostgreSQL 有一个后台进程专门扫死锁」。实际上没有这样一个常驻扫描进程。真实流程是:
1. 事务 T 请求一个锁,发现和别人冲突 → 进入等待队列,开始睡眠
2. 睡眠期间如果被唤醒(拿到锁),流程结束,不做任何检测
3. 如果一直睡到 deadlock_timeout(默认 1s)还没拿到锁
→ T 自己醒来,以「T」为起点,在等待图上做一次环检测
4. 若发现环 → 报告死锁(40P01)、中止某个事务
若未发现环 → 继续睡,等下一次超时后再检查为什么「等满 1 秒」才检测
检测本身要遍历锁表(LOCK 结构、PROCLOCK 结构),是有成本的。如果每次等锁都立刻检测,短锁争用(几毫秒就结束)会被大量无谓的检查淹没。1 秒是「绝大多数正常锁等待早已结束」的经验阈值,只对真正长时间的等待付出检测成本。
检测的触发时机与「假死锁」
由于检测是「等满 deadlock_timeout 才做一次」,会出现两个重要推论:
- 持续时间短于
deadlock_timeout的锁冲突,永远不会被判定为死锁。即使它们是环形的,只要其中一方在 1 秒内提交或回滚,环就自动解开了,双方都正常继续。 - 死锁报错不是「立刻」出现的,而是滞后最多
deadlock_timeout。所以40P01的响应时间通常是 1 秒左右。
时间轴
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' | 不报错,会一直等 |
-- 一眼区分:是在等重量级锁,还是卡在存储层的轻量级锁上
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; 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 会:
- 向被中止的那个进程抛出 SQLSTATE
40P01(deadlock_detected),中止它当前的事务(不是断开会话)。 - 在服务端日志里写入完整的等待环细节(
DETAIL行)和触发位置的CONTEXT。
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。
检测机制与观察手段的对应关系
pg_stat_activity.wait_event_type = 'Lock' → 该进程正处于「等待」状态
pg_blocking_pids(pid) → 该进程等待的「阻塞者」列表(出边)
pg_locks.granted = false → 该进程有未授予的锁请求
pg_locks.granted = true → 该进程正在持有的锁(构成别人的入边)-- 一次性看到:谁在等、等哪类锁、被谁挡着
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; 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 的行代表「已持有」。
SELECT pid,
locktype,
mode,
granted,
relation::regclass AS table_name,
transactionid,
waitstart
FROM pg_locks
WHERE granted = false
ORDER BY waitstart; pid | locktype | mode | granted | table_name | transactionid | waitstart
------+---------------+-----------+---------+------------+---------------+----------------------------
45281 | transactionid | ShareLock | f | | 1243 | 2026-09-28 10:20:31.412+08-- 把「等待中的锁」和「持有中的锁」拼起来,人工还原环
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;打开日志,让环「留痕」
内存里的等待图是瞬时的,死锁一解除就什么都没了。要事后分析,必须靠日志:
# 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 查看/临时修改(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」,而不是某个物理锁对象。
-- 验证:行锁冲突时,等的是 transactionid,而不是 tuple
SELECT locktype, mode, granted, transactionid, tuple, virtualxid
FROM pg_locks
WHERE pid = pg_backend_pid(); locktype | mode | granted | transactionid | tuple | virtualxid
---------------+---------------+---------+---------------+-------+------------
relation | RowExclusiveLock | t | | |
virtualxid | ExclusiveLock | t | | | 6/142deadlock_timeout 与检测成本
deadlock_timeout 的取值直接决定「检测频率」与「检测成本」的平衡:
| 取值 | 效果 | 适用场景 |
|---|---|---|
| 极小(如 50ms) | 检测非常频繁,短等待也要跑一次图搜索 | 几乎从不推荐,浪费 CPU |
1s(默认) | 兼顾响应速度与开销 | 绝大多数系统 |
| 10s 以上 | 检测很省,但死锁要等很久才被解除 | 锁等待普遍很短、死锁极罕见的批处理系统 |
-- 查看当前值与层级来源,确认没被某层配置意外覆盖
SELECT name, setting, unit, pending_restart, source, sourcefile
FROM pg_settings
WHERE name = 'deadlock_timeout'; name | setting | unit | pending_restart | source | sourcefile
------------------+---------+------+-----------------+-----------+----------------------------
deadlock_timeout | 1 | s | f | file | /etc/postgresql/.../postgresql.conf把 deadlock_timeout 设成 0 会怎样
设为 0 意味着「一旦需要等待就立刻检测」。这会让每一次正常的锁等待都触发一次图搜索,在高并发短事务场景下带来可观的额外 CPU 开销,甚至因为检测本身要拿锁管理器的内部锁而加剧争用。这不是优化,是自伤。
检测算法:从自己出发找环
检测器的入口是一个「从当前等待者出发的深度优先搜索(DFS)」:
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 未发现环一次真实搜索过程(沿用「不同顺序更新」的例子)
起点 T = 45281
45281 在等 transactionid=1243 → 持有者是 45282
45282 在等 transactionid=1244 → 持有者是 45281
45281 == 起点 → 环闭合 ✅为什么只需要检测「自己所在的环」
等待图上如果存在环,一定包含正在执行检测的那个进程。因为其它事务若形成环,环上的某个进程在等满超时后也会自己发起检测。所以不需要做全图扫描——只搜自己的连通子图就够了,成本大幅下降。
牺牲者(Victim)是怎么选的
找到环之后,必须中止环上的某个事务才能解开。这个被中止的事务叫牺牲者(Victim)。选择原则可以概括为:
- 优先中止尽量少的事务:能杀一个解决,就绝不杀两个。
- 多个等价选择时,具体杀谁没有对应用可见的、可靠的约定。
所以正确的工程态度是:不要依赖「数据库会杀哪个」,而要让环上的每一个事务都可重试、都幂等。
-- 应用侧的正确应对:把 40P01 当作「可重试的瞬时错误」
-- 伪代码:
-- try:
-- run_transaction()
-- except DeadlockDetected (40P01):
-- backoff_and_retry() # 3~5 次上限 + 指数退避 + 抖动牺牲者不确定,但有一件事是确定的:
环上的事务必须都能「安全地重来一遍」
→ 写操作幂等(ON CONFLICT / 条件更新 / 唯一键约束)
→ 重试带退避,避免退化成活锁死锁报告里的 DETAIL 行怎么读
日志与报错里的 DETAIL 是环的完整打印,它是排查的黄金信息:
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 的加锁顺序
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 让这类等待也进日志,与死锁日志配合形成完整证据链:
# 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.# 推荐的基础日志配置:能定位死锁,又不至于刷爆磁盘
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 # 谨慎开启,量大时改用采样-- 事后统计:日志里出现过多少次死锁(配合外部日志检索工具)
-- 数据库内只能拿到累计计数:
SELECT datname, deadlocks
FROM pg_stat_database
WHERE datname = current_database();用 pg_locks 与 pg_blocking_pids 手工还原等待环
自动化工具不可用时,用两条查询就能手工把环画出来。思路是「以等待者为起点,一跳一跳往外走」:
-- 第 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; pid | next_hop | wait_event | waited
------+----------+---------------+------------
45281 | {45282} | transactionid | 00:00:01.6-- 第 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
); pid | next_hop | state | wait_event_type | query
------+----------+--------+-----------------+-------------------
45282 | {45281} | active | Lock | UPDATE accounts ...把两跳拼起来:
45281 → {45282} → {45281} 绕回起点 → 环成立 → 确认死锁监控:把死锁从「偶发投诉」变成「可观测指标」
检测机制本身不提供告警,需要自己把计数器接出来:
-- 累计死锁次数(单调递增,适合做增量告警)
SELECT datname,
deadlocks,
xact_commit,
xact_rollback,
stats_reset
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1'); datname | deadlocks | xact_commit | xact_rollback | stats_reset
---------+-----------+-------------+---------------+----------------------------
appdb | 17 | 2840193 | 12904 | 2026-09-01 00:00:00.000+08-- 当前是否还有等待超过 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 的分工
三者都会「让语句失败」,但触发条件完全不同,排查时必须分清:
谁在等 触发条件 错误码
deadlock_timeout ──► 等锁的进程 等待 > N 后去检测 仅在真有环时 40P01
lock_timeout ──► 等锁的进程 等锁总时长 > N 55P03
statement_timeout ──► 执行中的语句 语句总耗时 > N 57014-- 一个会话里三者可以同时存在,谁先满足谁生效
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 步:准备环境与两张表
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;CREATE TABLE
INSERT 0 2第 2 步:打开日志开关
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
SHOW log_lock_waits;ALTER SYSTEM
pg_reload_conf
----------------
t
(1 row)
log_lock_waits
----------------
on
(1 row)第 3 步:制造环,并观察「等待 1 秒」的现象
会话 1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有行 1会话 2:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- 持有行 2会话 1 申请行 2 —— 此时只是「等待」,不是死锁:
UPDATE accounts SET balance = balance + 100 WHERE id = 2;立刻(不到 1 秒内)查询状态,可以看到它在等:
SELECT pid, wait_event_type, wait_event, now() - query_start AS waited
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'; pid | wait_event_type | wait_event | waited
------+-----------------+---------------+------------
45281 | Lock | transactionid | 00:00:00.3此时服务端日志也会出现一条「长等待」记录(达到 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 —— 环闭合,检测器立即判定死锁:
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, wait_event_type, state
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'; pid | wait_event_type | state
-----+-----------------+-------
(0 rows)第 5 步:用视图还原刚才的环
把 pg_locks(未授予的请求)与 pg_blocking_pids()(阻塞关系)结合,人工画出环:
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; pid | waiting_for | blocked_by | locktype | transactionid | granted | query
------+---------------+------------+---------------+---------------+---------+----------------
45281 | transactionid | {45282} | transactionid | 1243 | f | UPDATE accounts ...-- 补充:看「持有者」侧的已授予锁,确认两端都拿到了对方的等待对象
SELECT pid, locktype, mode, granted, transactionid
FROM pg_locks
WHERE pid IN (45281, 45282)
ORDER BY pid, granted; pid | locktype | mode | granted | transactionid
------+---------------+---------------+---------+---------------
45281 | transactionid | ExclusiveLock | t | 1244
45282 | transactionid | ExclusiveLock | t | 1243解读:
45281 持有 1244(即行 2 的锁),却在等 1243
45282 持有 1243(即行 1 的锁),却在等 1244
→ 互相持有对方所需 → 正是环第 6 步:从累计指标确认死锁确实发生过
SELECT datname, deadlocks, xact_rollback
FROM pg_stat_database
WHERE datname = current_database(); datname | deadlocks | xact_rollback
---------+-----------+---------------
appdb | 18 | 12905对照第 1 步之前的取样(deadlocks = 17),
现在变成 18 → 本次死锁被记录了一次。
注意:这是「累计值」,做告警时要取两次采样的差值。第 7 步:验证「短于 deadlock_timeout 的环不会被判死锁」
把检测阈值调大,再看同一个环:只要其中一方及时提交,就不会报错。
ALTER SYSTEM SET deadlock_timeout = '5s';
SELECT pg_reload_conf();-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;-- 会话 2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;-- 会话 1:申请行 2 → 开始等待(但要等满 5s 才会去检测)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;-- 会话 2:在 5 秒内立刻提交 → 环自动解开,双方都成功,不会出现 40P01
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;COMMIT
-- 会话 1 随后顺利拿到锁:
UPDATE 1-- 恢复默认值,避免影响后续实验
ALTER SYSTEM RESET deadlock_timeout;
SELECT pg_reload_conf();注意事项
错误!以为有「后台进程专门扫描死锁」。 检测由等待中的进程自己在 deadlock_timeout 之后发起,没有常驻扫描任务。所以「等得越久才被检测到」是机制使然。
正确理解:
等待 < deadlock_timeout 就结束 → 永远不会被检测(也不需要)
等待 ≥ deadlock_timeout → 由该等待者自己发起一次环检测错误!把 deadlock_timeout 当成「死锁超时时间」来理解。 它不是「等 N 秒就放弃」,而是「等 N 秒后去检查一下有没有环」;没环就继续等。
-- 错误认知下的写法:指望靠它让语句失败
SET deadlock_timeout = '1s'; -- 并不会让等待 1s 的语句失败错误!依赖「数据库会杀掉另一个事务」来设计业务。 牺牲者是哪个事务没有可靠约定,环上每个事务都必须可重试且幂等。
-- 不推荐:假设「一定是我活下来」
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' 应转向存储层争用排查。
-- 区分对待:只有 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; wait_event_type | count
-----------------+-------
Lock | 1
LWLock | 2错误!生产环境不开 log_lock_waits。 死锁报错本身不含完整 SQL,只有 HINT: See server log for query details。不开日志,事后就只能靠猜。
-- 不推荐
log_lock_waits = off不推荐:把 deadlock_timeout 调到毫秒级来「更快发现死锁」。 会让正常短锁等待也频繁触发图搜索,得不偿失。
不推荐:看到 40P01 就无限重试而不修加锁顺序。 高冲突下会退化成活锁,日志和 CPU 都被打满,业务却依然失败。
最佳实践
- 先确认「是哪一类问题」再动手:
wait_event_type = 'Lock'+40P01才走死锁路径;LWLock走存储层路径。
-- 巡检第一步:当前所有等待,按类型归类
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(除非有明确理由)。
log_lock_waits = on
deadlock_timeout = 1s
log_line_prefix = '%m [%p] %q%u@%d app=%a '- 把
pg_stat_database.deadlocks做成增量告警,而不是只看绝对值。
-- 采集脚本示意:记录两次采样,只看 delta
SELECT datname, deadlocks
FROM pg_stat_database
WHERE datname = current_database();- 应用侧统一实现「40P01 / 40001 重试器」,并带指数退避与抖动。
推荐参数:
最大重试 3~5 次
退避 50ms → 100ms → 200ms
抖动:每次叠加 0~30ms 随机值(打散重试风暴)
重试前必须保证事务已 ROLLBACK- 用
pg_blocking_pids()作为「等待链」的唯一入口,比手工 JOINpg_locks更快定位。
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 的加锁顺序。
1. 应用报错中有 40P01 ?
2. log 中搜 'deadlock detected' 取 DETAIL 与 CONTEXT
3. 按 PID 找到对应连接的完整 STATEMENT
4. 对比两条业务路径的加锁顺序 → 统一之- 对涉及多行的写操作,强制统一顺序(按主键排序、按固定表序),这是从源头消灭环的唯一手段。
UPDATE accounts a
SET balance = a.balance + v.delta
FROM (VALUES (1, -100), (2, 100)) AS v(id, delta)
WHERE a.id = v.id;- 监控「长等待」而不只是「死锁」:死锁只是冰山一角,长期锁等待同样是事故前兆。
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;下一节
继续学习:避免死锁的最佳实践
参考链接
- PostgreSQL 官方文档:显式锁(Explicit Locking,Deadlocks 一节)
- PostgreSQL 官方文档:锁管理参数(deadlock_timeout / log_lock_waits)
- PostgreSQL 官方文档:pg_locks 视图
- PostgreSQL 官方文档:系统信息函数(pg_blocking_pids)
- PostgreSQL 官方文档:监控统计(pg_stat_database / pg_stat_activity)
- PostgreSQL 官方文档:错误代码(40P01 / 40001 / 55P03)
- PostgreSQL 官方文档:错误报告与日志(log_line_prefix)