Skip to content

避免死锁的最佳实践 ​

前两页讲了死锁怎么产生、怎么被检测。但工程上最有价值的永远是第三件事:怎么让它别发生。

死锁无法被 100% 消灭——只要并发存在、锁存在,理论上的环就有可能出现。但绝大多数生产死锁都是由少数几个可修正的编码习惯造成的。本页把这些习惯整理成一套可落地的工程规范:统一加锁顺序、缩短事务、一次性加锁、正确使用超时、幂等与重试、以及上线前的审查清单。

前置知识

阅读本节前,建议先了解:死锁产生原因、死锁检测机制、锁等待与超时

基础概念 ​

死锁防御的四个层次 ​

不要指望单一手段解决问题。有效的防御是分层的:从「让它别发生」到「发生了也能自愈」,每一层都有明确的职责。

text
第 4 层  可观测      log_lock_waits + deadlocks 指标 + 告警        ← 发现
第 3 层  可恢复      幂等 + 带退避的重试(40P01 / 40001)          ← 兜底
第 2 层  可预期      统一加锁顺序、一次性加锁、固定表序             ← 预防(核心)
第 1 层  少持锁      短事务、少语句、不在事务里做慢操作             ← 基础

第 1、2 层是根治手段,能把死锁概率压到接近于零;第 3、4 层是安全网,负责处理漏网之鱼。只做第 3 层(疯狂重试)而不做第 2 层,就是典型的「治标不治本」,还会退化成活锁。

三句话原则 ​

如果只能记住三件事,记这三句:

text
① 顺序一致:所有事务对同一组资源,按同一个顺序加锁。
② 事务要短:持锁时间越短,环形成的窗口越小。
③ 一次锁完:需要多个资源时,一次性申请,不要边做边等。

为什么「统一顺序」是唯一真正的根治手段 ​

回到 Coffman 条件:互斥、持有并等待、不可抢占都是锁机制的固有属性,改不掉。唯一能由应用消除的是「循环等待」。

而消除循环等待的方法只有一条:让等待关系永远是单向的。如果所有事务都按 1 → 2 → 3 的顺序加锁,那么「等 1 的人」不可能同时被「持有 3 的人」等待——因为持有 3 的人一定是按 1 → 2 → 3 走到 3 的,他不会回头等 1。图的边上就不可能闭环。

手段作用于哪个必要条件效果
统一加锁顺序循环等待根治,可完全消除该类死锁
一次性加锁(LOCK TABLE a, b)持有并等待根治,避免边做边等
缩短事务——大幅降低概率,缩小时间窗口
幂等 + 重试——兜底,不能预防
超时参数——兜底,把「永久卡死」变成「可控失败」

语法与用法 ​

统一顺序:一次 UPDATE 搞定一组行 ​

最有效的写法是把「多行、两次 UPDATE」压成「一组行、一次 UPDATE」,让顺序由数据决定而不是由代码路径决定。

sql
-- 危险!顺序由参数决定,反向调用即死锁
UPDATE accounts SET balance = balance - 100 WHERE id = :from_id;
UPDATE accounts SET balance = balance + 100 WHERE id = :to_id;
sql
-- 正确!一条语句更新两行,顺序由执行计划决定,与调用方向无关
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]);
text
UPDATE 2

-- 无论 :from_id 与 :to_id 谁大谁小,加锁顺序都由「扫描 id 的顺序」决定,
-- 因此不存在「反向路径」,环无法形成。

一次性加锁:LOCK TABLE 固定表序 ​

当一次操作必然涉及多张表时,先用一条 LOCK TABLE 把顺序固定下来。

sql
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 控制持锁时长 ​

sql
BEGIN;
-- 只做必要的写操作
UPDATE orders SET status = 'paid' WHERE id = 100;

-- 若必须做多次操作,尽量一次批处理,而不是逐条往返
UPDATE order_items SET shipped = true WHERE order_id = 100;

COMMIT;   -- 立刻提交,绝不在这里调用外部服务
sql
-- 兜底:即使代码写错了,也有超时保护
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;

详细说明 ​

策略一:统一多行更新的加锁顺序 ​

这是最高频的死锁来源,也是收益最大的修正点。判断标准很简单:如果一组行会被并发事务以不同顺序访问,就必须统一顺序。

sql
-- 反例:转账,两条 UPDATE 的顺序取决于业务方向
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = :from;
UPDATE accounts SET balance = balance + 100 WHERE id = :to;
COMMIT;
sql
-- 正解 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;
sql
-- 正解 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;
sql
-- 批量场景:永远按主键升序处理,每批限制行数
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」两条路径一定会在并发时闭环。把表序书面化,并写进团队规范。

sql
-- 正解:统一「主表 → 明细表 → 日志表」,所有代码都遵守
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;
sql
-- 更稳的做法:开事务就先按固定顺序把表锁好
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;
text
表序约定示例(团队规范的一部分):
  业务表 → 明细表 → 关联表 → 日志/审计表
  同层级内按表名字典序
  DDL 语句与 DML 语句分开提交,不要混在一个事务里

策略三:外键场景的固定顺序 ​

外键检查会在父表被引用行上加 FOR KEY SHARE 行锁,并一直持有到事务结束;而 UPDATE 父表需要在同一行上加 FOR NO KEY UPDATE——两者冲突。因此「一个事务里既插子表、又更新多行父表」时,若顺序与其它事务不一致,就会闭环。

sql
-- 危险!两个事务都「先插某行子表,再更新另一行父表」,且父表行号交叉
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) → 加锁顺序交叉 → 死锁
sql
-- 正解:统一「先更新全部父表行,再插子表」,所有事务保持同一顺序
BEGIN;
UPDATE parent SET note = 'x' WHERE id = 2;  -- 先做父表更新
INSERT INTO child (parent_id) VALUES (1);   -- 后做子表插入
COMMIT;
sql
-- 正解二:用 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;
sql
-- 排查辅助:确认外键真的会加锁(在 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 在多行、多事务下会因输入顺序不同而死锁。修正方式极简单:发送前排序。

sql
-- 危险!输入顺序取决于调用参数,两个事务可能正好相反
INSERT INTO counters (id, n) VALUES (1, 1), (2, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;
sql
-- 正解:先按主键排序,再 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
-- 应用侧也可以先排序再拼 SQL(伪代码)
--   rows.sort(key = lambda r: r.id)
--   INSERT INTO counters (id, n) VALUES (r1), (r2), ... ON CONFLICT (id) DO UPDATE ...
sql
-- 单行 upsert 本身是安全的(无需排序),但要注意「先锁后更新」
INSERT INTO counters (id, n) VALUES (1, 1)
ON CONFLICT (id) DO UPDATE SET n = counters.n + 1;

策略五:缩短事务与「不在事务内做慢操作」 ​

持锁时间 = 从加锁到 COMMIT 的时长。任何让这段时间变长的操作,都会放大死锁概率。

sql
-- 反例:事务里调用外部服务、做重计算、等待人工输入
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001';   -- 已持锁
-- ... 调用支付网关 HTTP 接口(可能耗时数秒甚至超时)...
COMMIT;   -- 锁一直不放,别人一直等
sql
-- 正解:把「加锁写库」压缩成一个短事务,外部调用放在事务外
-- 第一步(事务外):调用支付网关、获取结果
-- 第二步(短事务):
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001';
INSERT INTO payments (order_id, status) VALUES (100, 'paid');
COMMIT;
sql
-- 找出当前最长的持锁事务(巡检用)
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;
sql
-- 找出「开了事务不干活」的会话:这是锁等待事故的头号根因
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~5s55P03
statement_timeout单条语句跑太久并长期持锁OLTP 10s~30s57014
idle_in_transaction_session_timeout事务开着不干活,白占锁30s~120sFATAL(断连)
transaction_timeout单事务总时长失控(PG17+)视业务,如 5minFATAL(断连)
deadlock_timeout死锁检测的响应速度保持默认 1s40P01
sql
-- 角色级基线: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';
sql
-- 线上 DDL:必须套上 lock_timeout,冲突就快速失败再重试
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN remark text;
COMMIT;
ini
# postgresql.conf:全局安全网
idle_in_transaction_session_timeout = 60s
log_lock_waits = on
deadlock_timeout = 1s

策略七:幂等写入与带退避的重试 ​

死锁被检测到后,数据库会中止环上的一方。这一方的事务必须能安全地重来一遍——否则重试就会产生重复数据。

sql
-- 幂等示例:条件更新(只有满足条件才改,重复执行结果一致)
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;
text
重试策略(应用层统一封装):
  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):
      不要盲目重试,先分析语句本身
sql
-- 应用层重试的「服务端配合」:把死锁与序列化失败一起纳入白名单
-- (在应用侧实现,这里只列出判定依据)
SHOW server_version;
text
-- 常见可重试错误码速查
40P01  deadlock_detected            → 立即重试(需幂等)
40001  serialization_failure        → 立即重试(需幂等)
55P03  lock_not_available           → 退避后重试
57014  query_canceled               → 先分析再决定

策略八:触发器与函数的加锁顺序审查 ​

触发器(Trigger)和 PL/pgSQL 函数会把额外的加锁动作藏起来,是死锁最隐蔽的来源。审查时必须把它们内部执行的 SQL 也纳入顺序检查。

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
-- 审查方法:列出所有触发器,逐个检查其函数体内部的 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;
sql
-- 查看函数体,确认加锁顺序
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_%';
sql
-- 也可以用扩展一次性把「表 → 触发器 → 函数」串起来看(如 pg_trigger 已足够时不必装扩展)
-- 审查结论应落到:函数内部涉及的每张表,是否与主流程保持同一顺序

策略九:DDL 与 DML 分离 ​

DDL 会申请 ACCESS EXCLUSIVE 表锁,与所有锁模式冲突,一旦和长事务交错,阻塞面极大。

sql
-- 危险!一个事务里混了 DML 与 DDL
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
ALTER TABLE order_items ADD COLUMN memo text;   -- 需要 ACCESS EXCLUSIVE
COMMIT;
sql
-- 正解:DDL 独立成事务,加 lock_timeout,快速失败再重试
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE order_items ADD COLUMN memo text;
COMMIT;
sql
-- 大表加索引必须用 CONCURRENTLY,避免长时间 ACCESS EXCLUSIVE 阻塞读写
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);
sql
-- 发布脚本模板:失败可重试,不阻塞业务
-- 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 步:建表与初始数据 ​

sql
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);
text
DROP TABLE
CREATE TABLE
INSERT 0 2

第 2 步:重现「错误顺序」的死锁 ​

会话 1:

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

会话 2(几乎同时,反向):

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
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
-- 记录本次死锁(用于和第 4 步对比)
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();
text
 datname | deadlocks
---------+-----------
 appdb   |        19

第 3 步:改用「一条语句 + 统一顺序」 ​

sql
-- 无论转账方向如何,都用同一条语句:顺序由执行计划统一
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;
text
UPDATE 2
sql
-- 反向转账:语句形态完全相同,只是 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;
text
UPDATE 2
text
两种方向的 SQL 都是「一条 UPDATE 更新一组行」,
加锁顺序由扫描 id 的顺序决定,与调用方向无关 → 不存在反向路径 → 不会闭环。

第 4 步:并发压测并对比死锁计数 ​

用 pgbench 或应用侧并发跑第 3 步的语句,观察死锁计数是否增长。

sql
-- 压测前后各取一次,只看增量
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();
text
 datname | deadlocks
---------+-----------
 appdb   |        19     ← 压测前

-- 并发执行数千次「第 3 步」的语句后:
 datname | deadlocks
---------+-----------
 appdb   |        19     ← 增量 0,死锁不再出现
bash
# 用 pgbench 自定义脚本并发压测(示意)
pgbench -n -c 16 -j 4 -T 60 -f unified_order.sql -d appdb
text
transaction 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 步:加一层超时安全网 ​

即使顺序正确,也保留超时兜底,避免其它类型的锁冲突无限期卡住。

sql
-- 应用连接基线
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';
sql
-- 会话内临时覆盖(例如执行关键批处理)
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;
text
ALTER ROLE
BEGIN
SET
SET
UPDATE 2
COMMIT

第 6 步:把死锁指标接入监控 ​

sql
-- 采集脚本:每次采集写入时序库,告警规则看「5 分钟增量 > 0」
SELECT datname,
       deadlocks,
       xact_commit,
       xact_rollback
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1')
  AND datname IS NOT NULL;
text
 datname | deadlocks | xact_commit | xact_rollback
---------+-----------+-------------+---------------
 appdb   |        19 |     2910384 |         12931
sql
-- 同时监控「长等待」:死锁只是冰山一角
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;
text
 pid | wait_event | blocked_by | waited | query
-----+------------+------------+--------+-------
(0 rows)

第 7 步:上线前审查清单 ​

把下面这份清单固化到代码评审(Code Review)里,死锁问题基本能在上线前拦住。

text
【并发写审查清单】

□ 同一组行/表,是否会以不同顺序被访问?
    → 若会:统一顺序(排序 / 一条语句 / LOCK TABLE 固定表序)

□ 一个事务里是否包含多个「持有并等待」的步骤?
    → 若会:改一次性加锁,或拆成多个短事务

□ 事务里是否有外部调用(HTTP / RPC / 消息队列 / 人工等待)?
    → 若有:移出事务

□ 是否涉及外键写入?
    → 是:确认「父表更新」与「子表插入」的固定顺序

□ 是否有多行 upsert?
    → 是:发送前按主键排序

□ 触发器/函数内部是否写其它表?
    → 是:把内部 SQL 纳入加锁顺序审查

□ DDL 是否与 DML 混在同一事务?
    → 是:拆开,DDL 独立并加 lock_timeout

□ 写操作是否幂等?重试器是否带退避与抖动?
    → 否:补齐

□ 连接池是否设了 lock_timeout / idle_in_transaction_session_timeout?
    → 否:补齐
sql
-- 辅助:上线后第一时间看有无异常等待与死锁增长
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;
text
 waiting | deadlocks_total
---------+-----------------
       0 |              19

注意事项 ​

错误!只做「重试」而不修加锁顺序。 重试是兜底,不是方案。高冲突下会退化成活锁:业务永远失败、日志和 CPU 被打满。

text
不推荐:while(true) { try { work(); break; } catch (Deadlock) { continue; } }
推荐:  统一加锁顺序(根治) + 幂等 + 有上限的退避重试(兜底)

错误!在事务里做网络调用。 持锁时间会被网络延迟放大到不可控。

sql
-- 危险!事务里等外部接口
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A001';
-- ... HTTP 调用支付网关(可能 3s 甚至超时)...
COMMIT;

错误!把 DDL 和 DML 写进同一个事务。

sql
-- 危险!
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
ALTER TABLE order_items ADD COLUMN memo text;
COMMIT;

错误!以为「无事务的自动提交」就没有死锁。 单条语句也是一个隐式事务,行锁同样持有到语句结束;多行 UPDATE 依然会按行加锁并可能形成环。

sql
-- 单条语句也可能死锁:多行 UPDATE 的加锁顺序取决于扫描顺序
UPDATE accounts SET balance = balance + 1 WHERE id IN (1, 2);

错误!给 deadlock_timeout 设极小值来「更快发现死锁」。 会让正常短锁等待频繁触发等待图搜索,白白消耗 CPU。

ini
-- 不推荐
deadlock_timeout = 10ms

不推荐:把 idle_in_transaction_session_timeout 设得过小(如 5s)。 ORM 在事务内的多次往返可能被误杀,表现为随机的「连接已关闭」,非常难排查。建议从 30~60 秒起步。

不推荐:依赖「数据库会杀掉另一个事务」来设计业务。 牺牲者不确定,环上每个事务都必须幂等且可重试。

sql
-- 不推荐:假设自己一定不会被中止
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;   -- 可能被 40P01 中止的就是这一条

最佳实践 ​

  • 把「统一加锁顺序」写成团队规范,并在代码评审清单里逐条核对——这是唯一能根治死锁的手段。
sql
-- 规范落地示例:所有涉及多行的写操作,都用一条语句或显式排序
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 一次,把「持有并等待」变成「一次性申请」。
sql
BEGIN;
LOCK TABLE orders, order_items, audit_log IN ROW EXCLUSIVE MODE;
-- 后续语句顺序已不重要
COMMIT;
  • 事务尽可能短:外部调用、重计算、等待人工输入一律移出事务。
sql
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 100;
COMMIT;
  • 每个连接池都设好超时三件套,让最坏情况是「可控失败」而非「无限卡死」。
sql
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 纳入统一重试器的白名单,重试带指数退避与抖动。
sql
-- 幂等写入模板
INSERT INTO payments (order_id, amount, status)
VALUES (100, 199.00, 'paid')
ON CONFLICT (order_id) DO NOTHING;
  • 生产环境开启 log_lock_waits,并保持 deadlock_timeout = 1s(默认)。
ini
log_lock_waits = on
deadlock_timeout = 1s
  • 把 pg_stat_database.deadlocks 做成增量告警,并同时监控「锁等待超过 5 秒」的会话。
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';
  • 审查所有触发器和 PL/pgSQL 函数内部的 SQL,它们的加锁顺序对调用者不可见。
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。
sql
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 → 审查加锁顺序并在代码层统一。
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;

下一节

继续学习:顾问锁概述

参考链接 ​