避免死锁的最佳实践
前两页讲了死锁怎么产生、怎么被检测。但工程上最有价值的永远是第三件事:怎么让它别发生。
死锁无法被 100% 消灭——只要并发存在、锁存在,理论上的环就有可能出现。但绝大多数生产死锁都是由少数几个可修正的编码习惯造成的。本页把这些习惯整理成一套可落地的工程规范:统一加锁顺序、缩短事务、一次性加锁、正确使用超时、幂等与重试、以及上线前的审查清单。
基础概念
死锁防御的四个层次
不要指望单一手段解决问题。有效的防御是分层的:从「让它别发生」到「发生了也能自愈」,每一层都有明确的职责。
第 4 层 可观测 log_lock_waits + deadlocks 指标 + 告警 ← 发现
第 3 层 可恢复 幂等 + 带退避的重试(40P01 / 40001) ← 兜底
第 2 层 可预期 统一加锁顺序、一次性加锁、固定表序 ← 预防(核心)
第 1 层 少持锁 短事务、少语句、不在事务里做慢操作 ← 基础第 1、2 层是根治手段,能把死锁概率压到接近于零;第 3、4 层是安全网,负责处理漏网之鱼。只做第 3 层(疯狂重试)而不做第 2 层,就是典型的「治标不治本」,还会退化成活锁。
三句话原则
如果只能记住三件事,记这三句:
① 顺序一致:所有事务对同一组资源,按同一个顺序加锁。
② 事务要短:持锁时间越短,环形成的窗口越小。
③ 一次锁完:需要多个资源时,一次性申请,不要边做边等。为什么「统一顺序」是唯一真正的根治手段
回到 Coffman 条件:互斥、持有并等待、不可抢占都是锁机制的固有属性,改不掉。唯一能由应用消除的是「循环等待」。
而消除循环等待的方法只有一条:让等待关系永远是单向的。如果所有事务都按 1 → 2 → 3 的顺序加锁,那么「等 1 的人」不可能同时被「持有 3 的人」等待——因为持有 3 的人一定是按 1 → 2 → 3 走到 3 的,他不会回头等 1。图的边上就不可能闭环。
| 手段 | 作用于哪个必要条件 | 效果 |
|---|---|---|
| 统一加锁顺序 | 循环等待 | 根治,可完全消除该类死锁 |
一次性加锁(LOCK TABLE a, b) | 持有并等待 | 根治,避免边做边等 |
| 缩短事务 | —— | 大幅降低概率,缩小时间窗口 |
| 幂等 + 重试 | —— | 兜底,不能预防 |
| 超时参数 | —— | 兜底,把「永久卡死」变成「可控失败」 |
语法与用法
统一顺序:一次 UPDATE 搞定一组行
最有效的写法是把「多行、两次 UPDATE」压成「一组行、一次 UPDATE」,让顺序由数据决定而不是由代码路径决定。
-- 危险!顺序由参数决定,反向调用即死锁
UPDATE accounts SET balance = balance - 100 WHERE id = :from_id;
UPDATE accounts SET balance = balance + 100 WHERE id = :to_id;-- 正确!一条语句更新两行,顺序由执行计划决定,与调用方向无关
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (:from_id, -100), (:to_id, 100)) AS v(id, delta)
WHERE a.id = v.id
AND a.id = ANY (ARRAY[:from_id, :to_id]);UPDATE 2
-- 无论 :from_id 与 :to_id 谁大谁小,加锁顺序都由「扫描 id 的顺序」决定,
-- 因此不存在「反向路径」,环无法形成。一次性加锁:LOCK TABLE 固定表序
当一次操作必然涉及多张表时,先用一条 LOCK TABLE 把顺序固定下来。
BEGIN;
-- 表按字典序(或任意约定顺序)排列,所有事务都必须用这同一个顺序
LOCK TABLE accounts, audit_log, transfers IN ROW EXCLUSIVE MODE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 100);
INSERT INTO audit_log (tbl, row_id) VALUES ('accounts', 1);
COMMIT;缩短事务:SET LOCAL 控制持锁时长
BEGIN;
-- 只做必要的写操作
UPDATE orders SET status = 'paid' WHERE id = 100;
-- 若必须做多次操作,尽量一次批处理,而不是逐条往返
UPDATE order_items SET shipped = true WHERE order_id = 100;
COMMIT; -- 立刻提交,绝不在这里调用外部服务-- 兜底:即使代码写错了,也有超时保护
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '10s';
SET LOCAL idle_in_transaction_session_timeout = '30s';
UPDATE orders SET status = 'paid' WHERE id = 100;
COMMIT;详细说明
策略一:统一多行更新的加锁顺序
这是最高频的死锁来源,也是收益最大的修正点。判断标准很简单:如果一组行会被并发事务以不同顺序访问,就必须统一顺序。
-- 反例:转账,两条 UPDATE 的顺序取决于业务方向
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = :from;
UPDATE accounts SET balance = balance + 100 WHERE id = :to;
COMMIT;-- 正解 A:一条语句(推荐,顺序由执行计划统一)
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (:from, -100), (:to, 100)) AS v(id, delta)
WHERE a.id = v.id;-- 正解 B:应用层先排序,再按序执行(保留两条语句的写法)
-- 伪代码:
-- ids = sort([:from, :to]) # 永远按 id 升序
-- for id in ids:
-- UPDATE accounts SET balance = balance + delta[id] WHERE id = id
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(:from, :to);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(:from, :to);
COMMIT;-- 批量场景:永远按主键升序处理,每批限制行数
UPDATE accounts a
SET balance = a.balance + v.delta
FROM (VALUES (1, 10), (2, 20), (3, 30)) AS v(id, delta)
WHERE a.id = v.id;策略二:固定多表的访问顺序
跨表操作时,「先 A 后 B」和「先 B 后 A」两条路径一定会在并发时闭环。把表序书面化,并写进团队规范。
-- 正解:统一「主表 → 明细表 → 日志表」,所有代码都遵守
BEGIN;
UPDATE orders SET status = 'archived' WHERE id = :oid;
DELETE FROM order_items WHERE order_id = :oid;
INSERT INTO audit_log (tbl, row_id) VALUES ('orders', :oid);
COMMIT;-- 更稳的做法:开事务就先按固定顺序把表锁好
BEGIN;
LOCK TABLE orders, order_items, audit_log IN ROW EXCLUSIVE MODE;
-- 之后的语句顺序已不再重要
UPDATE orders SET status = 'archived' WHERE id = :oid;
DELETE FROM order_items WHERE order_id = :oid;
INSERT INTO audit_log (tbl, row_id) VALUES ('orders', :oid);
COMMIT;表序约定示例(团队规范的一部分):
业务表 → 明细表 → 关联表 → 日志/审计表
同层级内按表名字典序
DDL 语句与 DML 语句分开提交,不要混在一个事务里策略三:外键场景的固定顺序
外键检查会在父表被引用行上加 FOR KEY SHARE 行锁,并一直持有到事务结束;而 UPDATE 父表需要在同一行上加 FOR NO KEY UPDATE——两者冲突。因此「一个事务里既插子表、又更新多行父表」时,若顺序与其它事务不一致,就会闭环。
-- 危险!两个事务都「先插某行子表,再更新另一行父表」,且父表行号交叉
BEGIN;
INSERT INTO child (parent_id) VALUES (1); -- parent#1 上 FOR KEY SHARE
UPDATE parent SET note = 'x' WHERE id = 2; -- parent#2 上 FOR NO KEY UPDATE
COMMIT;
-- 另一事务写成 (parent_id=2 然后更新 id=1) → 加锁顺序交叉 → 死锁-- 正解:统一「先更新全部父表行,再插子表」,所有事务保持同一顺序
BEGIN;
UPDATE parent SET note = 'x' WHERE id = 2; -- 先做父表更新
INSERT INTO child (parent_id) VALUES (1); -- 后做子表插入
COMMIT;-- 正解二:用 FOR KEY SHARE 显式把父表行的加锁提到事务最前面,顺序与其它事务一致
BEGIN;
SELECT id FROM parent WHERE id IN (1, 2) ORDER BY id FOR KEY SHARE; -- 一次性、按序声明
INSERT INTO child (parent_id) VALUES (1);
UPDATE parent SET note = 'x' WHERE id = 2;
COMMIT;-- 排查辅助:确认外键真的会加锁(在 child 插入未提交时,观察 parent 上的锁)
SELECT l.pid, l.locktype, l.mode, l.relation::regclass AS table_name, l.granted
FROM pg_locks l
WHERE l.relation = 'parent'::regclass;策略四:批量 upsert 前排序
INSERT ... ON CONFLICT DO UPDATE 在多行、多事务下会因输入顺序不同而死锁。修正方式极简单:发送前排序。
-- 危险!输入顺序取决于调用参数,两个事务可能正好相反
INSERT INTO counters (id, n) VALUES (1, 1), (2, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;-- 正解:先按主键排序,再 upsert
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;-- 应用侧也可以先排序再拼 SQL(伪代码)
-- rows.sort(key = lambda r: r.id)
-- INSERT INTO counters (id, n) VALUES (r1), (r2), ... ON CONFLICT (id) DO UPDATE ...-- 单行 upsert 本身是安全的(无需排序),但要注意「先锁后更新」
INSERT INTO counters (id, n) VALUES (1, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;策略五:缩短事务与「不在事务内做慢操作」
持锁时间 = 从加锁到 COMMIT 的时长。任何让这段时间变长的操作,都会放大死锁概率。
-- 反例:事务里调用外部服务、做重计算、等待人工输入
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001'; -- 已持锁
-- ... 调用支付网关 HTTP 接口(可能耗时数秒甚至超时)...
COMMIT; -- 锁一直不放,别人一直等-- 正解:把「加锁写库」压缩成一个短事务,外部调用放在事务外
-- 第一步(事务外):调用支付网关、获取结果
-- 第二步(短事务):
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001';
INSERT INTO payments (order_id, status) VALUES (100, 'paid');
COMMIT;-- 找出当前最长的持锁事务(巡检用)
SELECT pid,
usename,
application_name,
state,
now() - xact_start AS xact_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND state <> 'idle'
ORDER BY xact_age DESC
LIMIT 10;-- 找出「开了事务不干活」的会话:这是锁等待事故的头号根因
SELECT pid, usename, state, now() - state_change AS idle_age, left(query, 50) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction';策略六:超时参数作为安全网
超时不能消除死锁,但能保证最坏情况是可控失败,而不是无限卡死。每个参数负责一个维度:
| 参数 | 防住什么 | 建议起点 | 错误码 |
|---|---|---|---|
lock_timeout | 语句无限期等锁 | OLTP 1s~3s,DDL 3s~5s | 55P03 |
statement_timeout | 单条语句跑太久并长期持锁 | OLTP 10s~30s | 57014 |
idle_in_transaction_session_timeout | 事务开着不干活,白占锁 | 30s~120s | FATAL(断连) |
transaction_timeout | 单事务总时长失控(PG17+) | 视业务,如 5min | FATAL(断连) |
deadlock_timeout | 死锁检测的响应速度 | 保持默认 1s | 40P01 |
-- 角色级基线:OLTP 与报表分开配置
ALTER ROLE app_oltp SET lock_timeout = '1s';
ALTER ROLE app_oltp SET statement_timeout = '10s';
ALTER ROLE app_oltp SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE app_report SET lock_timeout = '30s';
ALTER ROLE app_report SET statement_timeout = '15min';-- 线上 DDL:必须套上 lock_timeout,冲突就快速失败再重试
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN remark text;
COMMIT;# postgresql.conf:全局安全网
idle_in_transaction_session_timeout = 60s
log_lock_waits = on
deadlock_timeout = 1s策略七:幂等写入与带退避的重试
死锁被检测到后,数据库会中止环上的一方。这一方的事务必须能安全地重来一遍——否则重试就会产生重复数据。
-- 幂等示例:条件更新(只有满足条件才改,重复执行结果一致)
UPDATE orders
SET status = 'paid'
WHERE id = 100 AND status = 'unpaid';
-- 幂等示例:ON CONFLICT 吸收重复插入
INSERT INTO payments (order_id, status)
VALUES (100, 'paid')
ON CONFLICT (order_id) DO NOTHING;
-- 幂等示例:唯一键 + DO UPDATE,重复执行结果一致
INSERT INTO counters (id, n) VALUES (100, 1)
ON CONFLICT (id) DO UPDATE SET n = EXCLUDED.n;重试策略(应用层统一封装):
if SQLSTATE in ('40P01', '40001'):
最多重试 3~5 次
退避序列:50ms → 100ms → 200ms
叠加 0~30ms 随机抖动(jitter),打散重试风暴
每次重试前必须确认事务已 ROLLBACK
if SQLSTATE == '55P03' (lock_timeout):
退避后重试(通常 1~3 次足够)
if SQLSTATE == '57014' (statement_timeout):
不要盲目重试,先分析语句本身-- 应用层重试的「服务端配合」:把死锁与序列化失败一起纳入白名单
-- (在应用侧实现,这里只列出判定依据)
SHOW server_version;-- 常见可重试错误码速查
40P01 deadlock_detected → 立即重试(需幂等)
40001 serialization_failure → 立即重试(需幂等)
55P03 lock_not_available → 退避后重试
57014 query_canceled → 先分析再决定策略八:触发器与函数的加锁顺序审查
触发器(Trigger)和 PL/pgSQL 函数会把额外的加锁动作藏起来,是死锁最隐蔽的来源。审查时必须把它们内部执行的 SQL 也纳入顺序检查。
-- 触发器内部写入另一张表 → 悄悄改变了加锁顺序
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;
$$;-- 审查方法:列出所有触发器,逐个检查其函数体内部的 SQL
SELECT n.nspname AS schema,
c.relname AS table_name,
t.tgname AS trigger_name,
p.proname AS function_name
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_proc p ON p.oid = t.tgfoid
WHERE NOT t.tgisinternal
ORDER BY schema, table_name, trigger_name;-- 查看函数体,确认加锁顺序
SELECT p.proname, pg_get_functiondef(p.oid) AS definition
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname LIKE 'trg_%';-- 也可以用扩展一次性把「表 → 触发器 → 函数」串起来看(如 pg_trigger 已足够时不必装扩展)
-- 审查结论应落到:函数内部涉及的每张表,是否与主流程保持同一顺序策略九:DDL 与 DML 分离
DDL 会申请 ACCESS EXCLUSIVE 表锁,与所有锁模式冲突,一旦和长事务交错,阻塞面极大。
-- 危险!一个事务里混了 DML 与 DDL
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
ALTER TABLE order_items ADD COLUMN memo text; -- 需要 ACCESS EXCLUSIVE
COMMIT;-- 正解:DDL 独立成事务,加 lock_timeout,快速失败再重试
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE order_items ADD COLUMN memo text;
COMMIT;-- 大表加索引必须用 CONCURRENTLY,避免长时间 ACCESS EXCLUSIVE 阻塞读写
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);-- 发布脚本模板:失败可重试,不阻塞业务
-- 1) SET lock_timeout
-- 2) 执行 DDL
-- 3) 若收到 55P03 → 退避 N 秒后重试,最多 M 次
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN remark text;
COMMIT;实战示例
下面把「统一顺序」这条最核心的原则,用实验证明它的效果。
第 1 步:建表与初始数据
DROP TABLE IF EXISTS accounts;
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);DROP TABLE
CREATE TABLE
INSERT 0 2第 2 步:重现「错误顺序」的死锁
会话 1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;会话 2(几乎同时,反向):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;-- 一方会收到:
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"-- 记录本次死锁(用于和第 4 步对比)
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database(); datname | deadlocks
---------+-----------
appdb | 19第 3 步:改用「一条语句 + 统一顺序」
-- 无论转账方向如何,都用同一条语句:顺序由执行计划统一
BEGIN;
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (1, -100), (2, 100)) AS v(id, delta)
WHERE a.id = v.id;
COMMIT;UPDATE 2-- 反向转账:语句形态完全相同,只是 delta 不同
BEGIN;
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (1, 100), (2, -100)) AS v(id, delta)
WHERE a.id = v.id;
COMMIT;UPDATE 2两种方向的 SQL 都是「一条 UPDATE 更新一组行」,
加锁顺序由扫描 id 的顺序决定,与调用方向无关 → 不存在反向路径 → 不会闭环。第 4 步:并发压测并对比死锁计数
用 pgbench 或应用侧并发跑第 3 步的语句,观察死锁计数是否增长。
-- 压测前后各取一次,只看增量
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database(); datname | deadlocks
---------+-----------
appdb | 19 ← 压测前
-- 并发执行数千次「第 3 步」的语句后:
datname | deadlocks
---------+-----------
appdb | 19 ← 增量 0,死锁不再出现# 用 pgbench 自定义脚本并发压测(示意)
pgbench -n -c 16 -j 4 -T 60 -f unified_order.sql -d appdbtransaction type: unified_order.sql
scaling factor: 1
query mode: simple
number of clients: 16
number of threads: 4
duration: 60 s
number of transactions actually processed: 284913
latency average = 3.371 ms
tps = 4748.5 (including connections establishing)第 5 步:加一层超时安全网
即使顺序正确,也保留超时兜底,避免其它类型的锁冲突无限期卡住。
-- 应用连接基线
ALTER ROLE app_oltp SET lock_timeout = '1s';
ALTER ROLE app_oltp SET statement_timeout = '10s';
ALTER ROLE app_oltp SET idle_in_transaction_session_timeout = '60s';-- 会话内临时覆盖(例如执行关键批处理)
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (1, -100), (2, 100)) AS v(id, delta)
WHERE a.id = v.id;
COMMIT;ALTER ROLE
BEGIN
SET
SET
UPDATE 2
COMMIT第 6 步:把死锁指标接入监控
-- 采集脚本:每次采集写入时序库,告警规则看「5 分钟增量 > 0」
SELECT datname,
deadlocks,
xact_commit,
xact_rollback
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1')
AND datname IS NOT NULL; datname | deadlocks | xact_commit | xact_rollback
---------+-----------+-------------+---------------
appdb | 19 | 2910384 | 12931-- 同时监控「长等待」:死锁只是冰山一角
SELECT pid,
wait_event,
pg_blocking_pids(pid) AS blocked_by,
now() - query_start AS waited,
left(query, 50) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND now() - query_start > interval '5 seconds'
ORDER BY waited DESC; pid | wait_event | blocked_by | waited | query
-----+------------+------------+--------+-------
(0 rows)第 7 步:上线前审查清单
把下面这份清单固化到代码评审(Code Review)里,死锁问题基本能在上线前拦住。
【并发写审查清单】
□ 同一组行/表,是否会以不同顺序被访问?
→ 若会:统一顺序(排序 / 一条语句 / LOCK TABLE 固定表序)
□ 一个事务里是否包含多个「持有并等待」的步骤?
→ 若会:改一次性加锁,或拆成多个短事务
□ 事务里是否有外部调用(HTTP / RPC / 消息队列 / 人工等待)?
→ 若有:移出事务
□ 是否涉及外键写入?
→ 是:确认「父表更新」与「子表插入」的固定顺序
□ 是否有多行 upsert?
→ 是:发送前按主键排序
□ 触发器/函数内部是否写其它表?
→ 是:把内部 SQL 纳入加锁顺序审查
□ DDL 是否与 DML 混在同一事务?
→ 是:拆开,DDL 独立并加 lock_timeout
□ 写操作是否幂等?重试器是否带退避与抖动?
→ 否:补齐
□ 连接池是否设了 lock_timeout / idle_in_transaction_session_timeout?
→ 否:补齐-- 辅助:上线后第一时间看有无异常等待与死锁增长
SELECT (SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock') AS waiting,
(SELECT deadlocks FROM pg_stat_database WHERE datname = current_database()) AS deadlocks_total; waiting | deadlocks_total
---------+-----------------
0 | 19注意事项
错误!只做「重试」而不修加锁顺序。 重试是兜底,不是方案。高冲突下会退化成活锁:业务永远失败、日志和 CPU 被打满。
不推荐:while(true) { try { work(); break; } catch (Deadlock) { continue; } }
推荐: 统一加锁顺序(根治) + 幂等 + 有上限的退避重试(兜底)错误!在事务里做网络调用。 持锁时间会被网络延迟放大到不可控。
-- 危险!事务里等外部接口
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001';
-- ... HTTP 调用支付网关(可能 3s 甚至超时)...
COMMIT;错误!把 DDL 和 DML 写进同一个事务。
-- 危险!
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
ALTER TABLE order_items ADD COLUMN memo text;
COMMIT;错误!以为「无事务的自动提交」就没有死锁。 单条语句也是一个隐式事务,行锁同样持有到语句结束;多行 UPDATE 依然会按行加锁并可能形成环。
-- 单条语句也可能死锁:多行 UPDATE 的加锁顺序取决于扫描顺序
UPDATE accounts SET balance = balance + 1 WHERE id IN (1, 2);错误!给 deadlock_timeout 设极小值来「更快发现死锁」。 会让正常短锁等待频繁触发等待图搜索,白白消耗 CPU。
-- 不推荐
deadlock_timeout = 10ms不推荐:把 idle_in_transaction_session_timeout 设得过小(如 5s)。 ORM 在事务内的多次往返可能被误杀,表现为随机的「连接已关闭」,非常难排查。建议从 30~60 秒起步。
不推荐:依赖「数据库会杀掉另一个事务」来设计业务。 牺牲者不确定,环上每个事务都必须幂等且可重试。
-- 不推荐:假设自己一定不会被中止
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 可能被 40P01 中止的就是这一条最佳实践
- 把「统一加锁顺序」写成团队规范,并在代码评审清单里逐条核对——这是唯一能根治死锁的手段。
-- 规范落地示例:所有涉及多行的写操作,都用一条语句或显式排序
UPDATE accounts AS a
SET balance = a.balance + v.delta
FROM (VALUES (1, -100), (2, 100)) AS v(id, delta)
WHERE a.id = v.id;- 多表操作开事务就按固定表序
LOCK一次,把「持有并等待」变成「一次性申请」。
BEGIN;
LOCK TABLE orders, order_items, audit_log IN ROW EXCLUSIVE MODE;
-- 后续语句顺序已不重要
COMMIT;- 事务尽可能短:外部调用、重计算、等待人工输入一律移出事务。
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 100;
COMMIT;- 每个连接池都设好超时三件套,让最坏情况是「可控失败」而非「无限卡死」。
ALTER ROLE app_oltp SET lock_timeout = '1s';
ALTER ROLE app_oltp SET statement_timeout = '10s';
ALTER ROLE app_oltp SET idle_in_transaction_session_timeout = '60s';- 写操作必须幂等,并把
40P01/40001纳入统一重试器的白名单,重试带指数退避与抖动。
-- 幂等写入模板
INSERT INTO payments (order_id, amount, status)
VALUES (100, 199.00, 'paid')
ON CONFLICT (order_id) DO NOTHING;- 生产环境开启
log_lock_waits,并保持deadlock_timeout = 1s(默认)。
log_lock_waits = on
deadlock_timeout = 1s- 把
pg_stat_database.deadlocks做成增量告警,并同时监控「锁等待超过 5 秒」的会话。
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';- 审查所有触发器和 PL/pgSQL 函数内部的 SQL,它们的加锁顺序对调用者不可见。
SELECT c.relname AS table_name, t.tgname AS trigger_name, p.proname AS function_name
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_proc p ON p.oid = t.tgfoid
WHERE NOT t.tgisinternal;- DDL 与 DML 严格分离,DDL 独立事务 +
lock_timeout;大表加索引用CONCURRENTLY。
BEGIN;
SET LOCAL lock_timeout = '3s';
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);
COMMIT;- 把排查动作固定成 SOP:
pg_stat_activity看谁在等 →pg_blocking_pids()找阻塞者 → 日志找完整 SQL → 审查加锁顺序并在代码层统一。
SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by,
now() - query_start AS waited, left(query, 60) AS query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY waited DESC;下一节
继续学习:顾问锁概述
参考链接
- PostgreSQL 官方文档:显式锁(Explicit Locking,Deadlocks 一节)
- PostgreSQL 官方文档:锁管理参数(lock_timeout / deadlock_timeout / log_lock_waits)
- PostgreSQL 官方文档:客户端连接默认值与超时参数
- PostgreSQL 官方文档:LOCK(LOCK TABLE 语法)
- PostgreSQL 官方文档:SELECT(ON CONFLICT / 锁定子句)
- PostgreSQL 官方文档:系统信息函数(pg_blocking_pids)
- PostgreSQL 官方文档:监控统计(pg_stat_database.deadlocks)
- PostgreSQL 官方文档:错误代码(40P01 / 40001 / 55P03 / 57014)