Skip to content
事务与锁

事务与锁

事务和锁是 PostgreSQL 事故复盘里出镜率最高的两个词。这一章不讲 ANSI 教科书定义,讲 PG 的真实实现:隔离级别实际防什么、锁在队列里怎么堵人、任务队列的标准抢法、以及长事务如何把整个集群拖下水。MVCC 快照的底层机制在 06-MVCC与Vacuum.md,这里从使用视角出发。

本章的转账/审计示例用两张独立的演示表(orders 沿用 02-数据类型与DDL.md 底座):

CREATE TABLE accounts (
    id      int PRIMARY KEY,
    owner   text,
    balance numeric(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0)
);
CREATE TABLE audit_log (
    id   serial PRIMARY KEY,
    msg  text,
    ts   timestamptz NOT NULL DEFAULT now()
);
INSERT INTO accounts (id, owner, balance) VALUES (1, 'alice', 1000), (2, 'bob', 500);

一、事务基础

三个动词与一个默认

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

psql 默认 autocommit:每条语句自成一个事务。显式 BEGIN 之后的所有语句同生共死,直到 COMMIT/ROLLBACK。驱动层的差异要记住:Go 里 pgx/database/sql 的连接级 Exec 是单语句隐式事务,显式事务用 conn.Begin(ctx) 拿到 Tx 对象——两种客户端的行为差异在 08-Go客户端pgx.md 展开。

SAVEPOINT:部分回滚

BEGIN;
INSERT INTO audit_log (msg) VALUES ('step1');
SAVEPOINT sp_batch;
INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price)
VALUES ('o-8001', 42, 7, 1, 10);                         -- 假设这条失败
ROLLBACK TO SAVEPOINT sp_batch;                          -- 只撤销这一条
INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price)
VALUES ('o-8002', 42, 7, 1, 20);                         -- 换个姿势再来
COMMIT;

SAVEPOINT 回滚不撤销事务本身,之后的语句照常可用。Go 业务代码很少手写它——常见归宿是批处理框架"坏一条跳过一条"的重试点,或迁移工具的语句级补偿;日常业务更推荐直接用条件 UPDATE 或 upsert 表达"失败即整事务重试"。

只读事务

BEGIN READ ONLY;
SELECT ...;   -- 任何写操作报错 25006: cannot execute UPDATE in a read-only transaction
COMMIT;

声明只读有三个实际收益:写误操作被数据库层拦截(比代码 review 可靠);只读事务可以在流复制备库执行;优化器对只读事务能更放心地复用快照、物化子查询。报表和纯查询接口建议默认加上。


二、隔离级别:PG 的真实实现

先纠正一个常见误区:PG 不提供 READ UNCOMMITTED——低于 READ COMMITTED 的级别一律按 READ COMMITTED 执行,因为 MVCC 下根本没有脏读这回事。

级别(PG 关键字) 快照粒度 允许的现象 说明
READ COMMITTED(默认) 每条语句一个快照 不可重复读、幻读 UPDATE 遇冲突会重读重判
REPEATABLE READ 整个事务一个快照 幻读、写偏斜 实际是"快照隔离",不是标准 RR
SERIALIZABLE 事务级快照 + 谓词检查 无 SSI 实现,冲突报 40001

READ COMMITTED:语句级快照与"冲突重判"

事务内每条 SELECT 拿一个新快照,所以同一事务里两次读同一行可能读到不同结果(不可重复读)。更微妙的是写:RC 下的 UPDATE/DELETE 如果撞上"目标行在你读后、你改前被别的事务改了",它不会报错,而是等对方结束后,对最新版本的行重新执行一次 WHERE 判断——匹配就改,不匹配就跳过。

这个语义值得停一秒,因为它是"用条件表达式实现原子检查"的地基:

UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- RC 下即使命名并发充值先一步改了这行,重判后的 balance 依然 >= 100,
-- 扣减基于最新版本继续,不会把并发的钱弄丢。

UPDATE ... WHERE 余额充足 这类条件更新在 RC 下是安全的;把同一段逻辑拆成"先 SELECT 判断再 UPDATE",就把原子性交给了运气。

REPEATABLE READ:只是快照隔离

事务开始后的第一条语句确定快照,之后全事务看到的都是那一刻的世界。代价是写冲突不再宽容:UPDATE/DELETE 撞上被并发改过的行,直接报错:

ERROR:  40001  could not serialize access due to concurrent update

注意 PG 对 RR 的处理细节:SELECT 看到旧版本(快照语义),写却拒绝在旧版本上做——“读旧写新"的裂缝正是下面事故的温床。

快照隔离 ≠ 可串行化:一个能跑的最小 write-skew

业务规则:“至少一名医生在岗”。跨行规则,CHECK 约束表达不了,只能靠"先查再改”——两个会话按下面的交错跑(用两个 psql 窗口复现,必要时在第二条 BEGIN 前加 SELECT pg_sleep(1) 保证 B 在 A 提交前完成了读):

CREATE TABLE duty (
    id      int PRIMARY KEY,
    name    text UNIQUE NOT NULL,
    on_call bool NOT NULL DEFAULT true,
    CHECK (name IS NOT NULL)   -- 跨行的"至少一人在岗"约束表达不了
);
INSERT INTO duty (id, name) VALUES (1, 'Alice'), (2, 'Bob');

-- 会话 A                                      -- 会话 B
BEGIN ISOLATION LEVEL REPEATABLE READ;        BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM duty WHERE on_call;      SELECT count(*) FROM duty WHERE on_call;
--  2(可以排班)                               --  2(A 未提交,B 看不见)
UPDATE duty SET on_call = false                UPDATE duty SET on_call = false
 WHERE name = 'Alice';                         WHERE name = 'Bob';
COMMIT;                                        COMMIT;

两个事务各自"读了规则、查了条件、写了数据",单看都无可指摘,合起来岗上没人——写偏斜(write skew)。两个事务读的是同一批行,写的却是不同的行,PG 的行级并发检测根本不会触发:A 改 Alice 时 Bob 没被并发修改,B 改 Bob 时 Alice 的修改已经落定但 B 并未触碰 Alice。RC 下同样失守。这不是 bug,是快照隔离的定义性缺陷。

同构事故在业务里换个马甲就来了:双窗口同时取出同一账户余额、两个渠道各卖出一张库存为 1 的票、两人都判"今天没人在岗所以我能休"。

三种解法,按侵入性从小到大:

-- 解法一:把"读+判断+写"锁在同一批行上(悲观,但最直观)
BEGIN;
SELECT count(*) FROM duty WHERE on_call FOR UPDATE;   -- 锁住所有在岗行,B 在 A 之前拿不到
UPDATE duty SET on_call = false WHERE name = 'Alice';
COMMIT;

-- 解法二:把规则折进一条语句(条件聚合 + 单行状态,适合"计数器"类规则)
UPDATE duty SET on_call = false
WHERE name = 'Alice'
  AND (SELECT count(*) FROM duty WHERE on_call) > 1;   -- 判断与写在同一语句内原子

-- 解法三:SERIALIZABLE,见下节

SERIALIZABLE:SSI,真可串行化 + 必须重试

PG 的可串行化不是两阶段锁,而是 SSI(Serializable Snapshot Isolation):照常无锁读写,后台记录事务之间的"读写依赖"(谁读的版本后来被谁改了)。当依赖图里出现环、意味着结果无法串行化时,挑一个事务回滚掉,报错:

ERROR:  40001  could not serialize access due to read/write dependencies among transactions
DETAIL:  ... Retry the transaction.

也就是说:读旧值 + 写另一行 + 两者合起来违约 = 必有一方 40001。上面写偏斜的病例,只要两边都 BEGIN ISOLATION LEVEL SERIALIZABLE,第二个提交者会被中止——错误信息明确叫你重试,应用层照做即可得到正确结果(B 重试后读到在岗数 1,拒绝排班)。

实践三点:只有确实存在跨行/跨查询不变量的工作负载才上 SERIALIZABLE(审计、库存、排班、计费);它比 RC 贵(记录依赖的内存与 CPU,看 pg_stat_database.conflicts/序列化错误计数);重试逻辑是功能的一部分——没有重试的 SERIALIZABLE 等于随机故障发生器。重试要点:整个事务从头再来(指数退避,3–5 次封顶),不是从报错那条语句继续——事务已被中止,语句级恢复不存在。


三、表级锁:八级矩阵与一次事故复盘

PG 的表锁有八个模式,名字里有 “row” 的也都是表锁(历史包袱)。按强度从弱到强,以及谁会拿它:

模式 谁拿 与谁冲突
ACCESS SHARE SELECT 只与 ACCESS EXCLUSIVE 冲突
ROW SHARE SELECT FOR UPDATE / FOR SHARE 与 EXCLUSIVE、ACCESS EXCLUSIVE 冲突
ROW EXCLUSIVE INSERT / UPDATE / DELETE 与 SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突
SHARE UPDATE EXCLUSIVE VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY、REINDEX CONCURRENTLY、COMMENT ON、部分 ALTER TABLE 同类不互斥;与 SHARE 及以上冲突
SHARE CREATE INDEX(非并发)、REFRESH MATERIALIZED VIEW CONCURRENTLY 只放行 ACCESS SHARE(读可以,写不行)
SHARE ROW EXCLUSIVE CREATE TRIGGER、部分 ALTER TABLE 与 ROW EXCLUSIVE 及以上冲突
EXCLUSIVE 极少自动出现 只放行 ACCESS SHARE
ACCESS EXCLUSIVE DROP/TRUNCATE/CLUSTER/VACUUM FULL、REINDEX、REFRESH MV(非并发)、多数 ALTER TABLE/INDEX、LOCK TABLE 默认 与一切冲突

几个容易误解的点:

  • SELECT 拿的是最弱的 ACCESS SHARE,只会被 ACCESS EXCLUSIVE 堵住——官方文档原话:“Only an ACCESS EXCLUSIVE lock blocks a SELECT (without FOR UPDATE/SHARE)"。所以"查询慢"基本不会堵 DDL 里的写,但会堵 ALTER。
  • DML 的 ROW EXCLUSIVE 之间互不冲突:亿级并发 INSERT 不打架,这是 MVCC + 行锁撑起来的。
  • REFRESH MATERIALIZED VIEW 是隐形炸弹:不带 CONCURRENTLY 就是 ACCESS EXCLUSIVE(读都被停),带 CONCURRENTLY 是 SHARE(读继续、写排队)。刷新的代价对比见 07-高级SQL.md。

事故复盘:一个 ALTER 如何把全站 SELECT 全堵死

-- 发布窗口,02:00
ALTER TABLE users ADD COLUMN avatar_url text;   -- ACCESS EXCLUSIVE

此刻表上有一个跑了 30 分钟的大报表 SELECT(持 ACCESS SHARE)。ALTER 拿不到锁,进队列排队。关键在锁队列的行为:排在一个 ACCESS EXCLUSIVE 之后的所有锁——包括 SELECT 的 ACCESS SHARE——也继续排队。于是报表跑完之前,新来的每一个查询、每一次登录全部挂起;连接池几十秒内打满,健康检查超时,看起来整库"死了”。而 ALTER 自己没有任何等待上限,默认愿意排到天荒地老。

对策是把 DDL 包进限时重试:

-- 每次尝试自带死线,拿不到锁快速失败,绝不无限排队
DO $$
DECLARE
    i int := 0;
BEGIN
    LOOP
        BEGIN
            BEGIN;
            SET LOCAL lock_timeout = '3s';
            ALTER TABLE users ADD COLUMN avatar_url text;
            COMMIT;
            RETURN;
        EXCEPTION WHEN lock_not_available OR deadlock_detected THEN
            ROLLBACK;
            i := i + 1;
            IF i >= 20 THEN RAISE; END IF;
            PERFORM pg_sleep(2);
        END;
    END LOOP;
END $$;

配套动作:发布脚本统一 SET lock_timeout(毫秒级也可以,ALTER 本身很快,要的是"抢到锁"的确定性);避开长查询窗口先查 pg_stat_activity;能拆的 DDL 拆成不阻塞的形式(加无默认值的可空列只改元数据、瞬间完成,PG 11+ 的常量 DEFAULT 加列也不重写表,见 02-数据类型与DDL.md)。


四、行级锁:锁住你真正要动的那几行

表锁管并发礼仪,真正决定吞吐的是行锁。写法:

SELECT ... FROM jobs WHERE ... FOR UPDATE        -- 排他行锁:别人 FOR UPDATE/FOR SHARE/UPDATE 都等
SELECT ... FROM jobs WHERE ... FOR NO KEY UPDATE -- 弱一档:FK 校验不与你冲突
SELECT ... FROM jobs WHERE ... FOR SHARE         -- 共享行锁:彼此兼容,UPDATE 要等
SELECT ... FROM jobs WHERE ... FOR KEY SHARE     -- 最弱:仅在 PK 被改时冲突

四档的适用边界:FOR KEY SHARE 是外键检查自动拿的(子表插入时锁父行),FOR NO KEY UPDATE 是改父表非键列需要的;手工场景里记住"默认用 FOR UPDATE,只有想和 FK 校验和平共处时才降级"。

三种等待策略,对应三种业务人格:

SELECT ... FOR UPDATE;            -- 默认:排队等
SELECT ... FOR UPDATE NOWAIT;     -- 拿不到立刻报错 55P03 lock_not_available
SELECT ... FOR UPDATE SKIP LOCKED; -- 跳过被锁行,拿"剩下的"

SKIP LOCKED:任务队列的标准姿势

自 PG 9.5 起可用。多个 worker 同时抢任务,各拿各的、互不等待、永不重复:

CREATE TABLE jobs (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status    text NOT NULL DEFAULT 'pending',
    payload   jsonb NOT NULL,
    locked_at timestamptz
);
CREATE INDEX idx_jobs_pending ON jobs (id) WHERE status = 'pending';

-- 一条语句原子领取 10 个任务(worker 循环执行本语句即可)
WITH batch AS (
    SELECT id
    FROM jobs
    WHERE status = 'pending'
    ORDER BY id
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running', locked_at = now()
FROM batch b
WHERE j.id = b.id
RETURNING j.id, j.payload;

对比常见的错误姿势:用乐观状态机 UPDATE ... WHERE status='pending' 逐行 CAS,高并发下大量 UPDATE 打在同一热点页上互相冲突空转;用 FOR UPDATE(不带 SKIP LOCKED)领取,worker 们在锁队列里排成一列火车。SKIP LOCKED 让每个 worker 天然分片,这就是 asynq/队列类系统落在 PG 上时的推荐实现(呼应 16-常见陷阱与最佳实践.md 的队列陷阱条目)。

NOWAIT 的适用面是"抢不到就放弃这一轮"的幂等入口(如 Webhook 去重锁),而不是队列消费。

防死锁:按固定顺序拿锁

-- 转账:两行余额必须都锁住。按主键升序锁,所有路径同一顺序,锁等待图不成环
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

多行加锁先排序再锁、锁全部拿齐再开始改——顺序一致时两个事务最多排队、不会互咬,这是成本最低的防死锁手段,优先级高于事后重试。


五、死锁:观测与处置

顺序没管好、或多路径交叉更新,死锁终究会出现。PG 不提前检测(检测本身昂贵),而是等:持锁事务等待超过 deadlock_timeout(默认 1s)后,后台做一次等待图环检测,发现环就回滚图里代价较小的一方,输家收到:

ERROR:  40P01  deadlock detected
DETAIL:  Process 1234 waits for ShareLock on transaction ... ; Process 5678 ...

把默认 1s 调小能更早止损,但每次超过阈值的等待都会触发检测、CPU 开销随之上升——除非死锁频发,保持默认。

观测:谁在等谁

pg_locks.waits_for 列给出每个等待锁的锁号(较新版本的字段名就是它;拿不到时退回 pg_stat_activity 的 wait_event):

SELECT w.pid                                   AS waiting_pid,
       b.pid                                   AS blocking_pid,
       left(split_part(wa.query, E'\n', 1), 60) AS waiting_query,
       left(split_part(ba.query, E'\n', 1), 60) AS blocking_query,
       w.mode                                  AS wanted_lock
FROM pg_locks w
LEFT JOIN pg_locks b
       ON b.virtualtransaction = w.waits_for
      AND b.pid <> w.pid
JOIN pg_stat_activity wa ON wa.pid = w.pid
LEFT JOIN pg_stat_activity ba ON ba.pid = b.pid
WHERE NOT w.granted;

deadlock_timeout 调大(比如 10s)时,这条查询就是人工看门狗:还没到检测窗口,先看清是谁堵谁,必要时 pg_terminate_backend(blocking_pid)。

应用层:捕获 40P01,整事务重试

死锁回滚的是整个事务,SAVEPOINT 救不回来。Go 侧在 pgx 里按错误码分流:

// 示意,完整 pgx 用法见 08-Go客户端pgx.md
if pgErr, ok := err.(*pgconn.PgError); ok && (pgErr.Code == "40001" || pgErr.Code == "40P01") {
    // serialization_failure / deadlock_detected:退避后整事务重试
}

重试次数封顶、退避随机抖动,超过上限把错误抛给业务而不是死循环。

advisory lock:单实例任务的姿势

不碰业务表的互斥(定时任务防双跑、按 key 串行化某类操作),用咨询锁——它由应用自己约定语义,数据库只负责仲裁:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('daily_report'));  -- 拿到锁,事务结束自动释放
-- ... 跑报表;另一个实例会在这里排队 ...
COMMIT;

要点:定时任务优先 pg_advisory_xact_lock(事务级,随提交/回滚自动释放,不怕代码跑飞忘解锁);会话级 pg_advisory_lock 必须配对 pg_advisory_unlock,连接池环境里容易泄露,慎用。不想等待就 pg_try_advisory_xact_lock 拿返回值决定本轮跳过。key 是 bigint,用 hashtext('名字') 或自己拼 (class, objnum) 两段式即可。它保护的是"同时只有一个执行者",不是数据本身——数据一致性仍要靠事务和约束。


六、长事务与 idle in transaction:慢性毒药清单

长事务的伤害是系统级的,一条一条看:

  1. 拖住 vacuum 的地平线。 只要还有一个活跃事务的快照较早,所有表上"对它可能可见"的死元组都不能回收——一个两小时的报表能让全库膨胀加速一整晚,autovacuum 空转但什么也清不掉。机制(xmin/xmax、水平线)在 06-MVCC与Vacuum.md。
  2. 钉住 WAL。 逻辑复制槽/备份槽对应的未结束事务会让 pg_wal 持续堆积,磁盘满就是全站宕(槽的运维见 10-复制与高可用.md)。
  3. 堵 DDL。 ACCESS SHARE 拿得久,后面的 ALTER 排队,然后全库排队——第三节事故的另一半。
  4. 耗尽连接池。 事务开着、代码卡在下游 RPC/人工确认上,连接状态是 idle in transaction:不干活、不释放、还带着前两条伤害。排查连接池打不完的问题,先查它。

巡检 SQL,两条都建议进监控:

-- 最老的活跃事务
SELECT pid, usename, application_name, state,
       now() - xact_start   AS xact_age,
       now() - query_start  AS query_age,
       left(query, 100)     AS last_query
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;

-- idle in transaction 名单
SELECT pid, usename, application_name,
       now() - state_change AS idle_age,
       left(query, 100)     AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY state_change
LIMIT 20;

处置:服务端兜底设 idle_in_transaction_session_timeout(自 PG 9.6;默认 0 关闭,生产建议 5min 量级,ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min'; SELECT pg_reload_conf();——超时即断开,应用要能扛重连);池侧用 pgbouncer 的 transaction 模式(见 11-备份恢复与运维.md);代码 review 的红线:事务里不做 RPC/HTTP 调用、不等人工、不跑循环计算。确认是事故源头的进程,SELECT pg_terminate_backend(pid); 先放血。


七、提交耐久性:synchronous_commit 的档位

synchronous_commit 管的是"COMMIT 返回前,WAL 必须走到哪里"。它是承诺强度,不是性能开关,档位从轻到重:

取值 COMMIT 等待 断电后果 典型场景
off 只写入本地 OS 页缓存 机器崩溃丢最近数毫秒至 ~1s 的提交 日志入库、可重放管道
on(默认) 本地 fsync 不丢 绝大多数业务
remote_write(需配同步备库) 备库 OS 收到 本地+备库双双断电才丢 多机房容灾 + 延迟敏感
remote_flush(需配同步备库) 备库 fsync 完成 两机同挂才丢 强持久复制
remote_apply(需配同步备库) 备库回放完成 同上 写完立刻读备库的读扩展

理解两笔账:off 换来的不是 fsync 的省一次 IO,是把 COMMIT 延迟从"等盘"变成"等内存"——机械盘/高负载下常常是数量级差距;代价是 OS/整机崩溃时一个"已返回成功"的小窗口内的提交会蒸发(wal_writer 周期兜底)。注意 off 不破坏原子性:丢的窗口是整笔整笔的提交,不会出现半笔事务——数据依旧一致,只是"承诺过的没发生"。能不能接受,取决于这笔账有没有别处可补(Kafka/源文件可重放就可以,金融流水就不可以)。同步备库档位的更多细节(synchronous_standby_names、一个都不等)见 10-复制与高可用.md。


八、事务大小与批量更新:分批哲学

一条 UPDATE 改五百万行意味着:五百万个行锁从头持到尾、五百万组新索引条目与死元组、WAL 洪峰、vacuum 事后收拾整夜、期间任何 DDL 排队窒息。事务的大小是锁持有时间和膨胀速率的直接乘积,批量作业的第一原则是把它切成一口能吃下的量:

CREATE PROCEDURE expire_old_orders()
LANGUAGE plpgsql AS $$
DECLARE
    n int;
BEGIN
    LOOP
        WITH batch AS (
            SELECT id FROM orders
            WHERE status = 'pending'
              AND created_at < now() - interval '90 days'
            ORDER BY id
            LIMIT 50000
            FOR UPDATE SKIP LOCKED          -- 绕开业务正在动的热点行
        )
        UPDATE orders o
        SET status = 'expired'
        FROM batch b
        WHERE o.id = b.id;

        GET DIAGNOSTICS n = ROW_COUNT;
        EXIT WHEN n = 0;
        COMMIT;                              -- 每批独立提交(PG 11+ 过程内事务控制)
    END LOOP;
END $$;

CALL expire_old_orders();

每批提交后各行的死元组立刻进入可回收状态,autovacuum 能持续小口消化而不是攒成大雷;批间可以 SELECT pg_sleep(0.5),既是给 vacuum 喘气,也是给复制延迟设节流阀(见 10-复制与高可用.md)。规模再大、停机可接受,另一条路是先建新表/分区交换,DDL 路线见 02-数据类型与DDL.md。

游标场景的补一句:DECLARE cur CURSOR FOR SELECT ... FOR UPDATE; 之后可以 UPDATE ... WHERE CURRENT OF cur;——改"游标正停着的那一行",不用重抄主键条件,PL/pgSQL 游标循环里偶尔好用。


实战建议

五条军规,按执行成本排序:

  1. 事务要短。 事务只圈住"必须一起变的那几行";RPC、HTTP、人工确认、长循环计算一律搬出去。代码 review 把"事务内网络调用"当红线事故处理。
  2. 40001 / 40P01 必须带重试。 凡是用了 REPEATABLE READ / SERIALIZABLE,或存在多行交叉锁的写路径,接入统一的事务重试封装:整事务重来、指数退避、次数封顶。没有重试的强隔离级别,等于在生产里装了随机故障按钮。
  3. DDL 必设 lock_timeout 并重试。 所有 ALTER/TRUNCATE/CREATE INDEX 包在"短死线 + 快速失败 + 有限重试"里发布;发布前看一眼 pg_stat_activity 里有没有会堵它的大查询。
  4. 队列消费用 SKIP LOCKED。 领取任务一条 CTE 语句搞定,worker 天然分片、互不等待;别拿 NOWAIT 的报错当流量控制,也别用阻塞 FOR UPDATE 把 worker 排成火车。
  5. 杜绝 idle in transaction。 服务端设 idle_in_transaction_session_timeout,池子用 transaction 模式,巡检 SQL 进监控——它同时是连接泄漏、膨胀和复制延迟三个问题的共同源头。

锁与事务的更多实战:MVCC 如何决定你的"读"看到什么见 06-MVCC与Vacuum.md;pgx 里事务与重试的代码写法见 08-Go客户端pgx.md;各类隔离级别翻车案例汇总在 16-常见陷阱与最佳实践.md。