事务与锁
事务和锁是 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:慢性毒药清单
长事务的伤害是系统级的,一条一条看:
- 拖住 vacuum 的地平线。 只要还有一个活跃事务的快照较早,所有表上"对它可能可见"的死元组都不能回收——一个两小时的报表能让全库膨胀加速一整晚,autovacuum 空转但什么也清不掉。机制(xmin/xmax、水平线)在
06-MVCC与Vacuum.md。 - 钉住 WAL。 逻辑复制槽/备份槽对应的未结束事务会让
pg_wal持续堆积,磁盘满就是全站宕(槽的运维见10-复制与高可用.md)。 - 堵 DDL。 ACCESS SHARE 拿得久,后面的 ALTER 排队,然后全库排队——第三节事故的另一半。
- 耗尽连接池。 事务开着、代码卡在下游 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 游标循环里偶尔好用。
实战建议
五条军规,按执行成本排序:
- 事务要短。 事务只圈住"必须一起变的那几行";RPC、HTTP、人工确认、长循环计算一律搬出去。代码 review 把"事务内网络调用"当红线事故处理。
- 40001 / 40P01 必须带重试。 凡是用了 REPEATABLE READ / SERIALIZABLE,或存在多行交叉锁的写路径,接入统一的事务重试封装:整事务重来、指数退避、次数封顶。没有重试的强隔离级别,等于在生产里装了随机故障按钮。
- DDL 必设 lock_timeout 并重试。 所有 ALTER/TRUNCATE/CREATE INDEX 包在"短死线 + 快速失败 + 有限重试"里发布;发布前看一眼
pg_stat_activity里有没有会堵它的大查询。 - 队列消费用 SKIP LOCKED。 领取任务一条 CTE 语句搞定,worker 天然分片、互不等待;别拿 NOWAIT 的报错当流量控制,也别用阻塞
FOR UPDATE把 worker 排成火车。 - 杜绝 idle in transaction。 服务端设
idle_in_transaction_session_timeout,池子用 transaction 模式,巡检 SQL 进监控——它同时是连接泄漏、膨胀和复制延迟三个问题的共同源头。
锁与事务的更多实战:MVCC 如何决定你的"读"看到什么见 06-MVCC与Vacuum.md;pgx 里事务与重试的代码写法见 08-Go客户端pgx.md;各类隔离级别翻车案例汇总在 16-常见陷阱与最佳实践.md。