经典故障模式与排查手册:五个真实事故的内核根因
数据库生产事故有一个共同特征:早期信号在监控面板上存在了几天甚至几周,但没人注意到它们和最终爆炸之间的因果关系。
本文挑出 PG 生产环境中最危险的五种故障模式——“最危险”不意味着出现频率最高,而是指它们要么能直接让数据库进入只读状态(wraparound / slot 溢出),要么是连锁反应的起点(连接风暴 / OOM kill),要么是缓慢的破坏性积累直到某个 DDL 在凌晨 3 点 hang 住(长事务)。每个故障按「现象 → 内核根因 → 时序推演 → 排查 SQL → 修复边界 → 监控埋点」组织。
一、连接风暴及 work_mem 连锁效应
1.1 现象
数据库突然无响应。pg_stat_activity
显示连接数接近
max_connections,但大部分连接状态是
active 而非
idle——不是应用忘了关连接,是真的都在干活。同时
OS 层 dmesg 可能出现 OOM kill 日志,PG 日志出现
out of memory 错误。
1.2 根因
问题不在连接数本身,而在每个连接同时持有的
work_mem 配额之和。
work_mem
的语义被广泛误解。它配置的不是”每个查询 4MB”或者”每个连接
4MB”,而是每个操作的内存上限——一个查询可能同时运行
hash join 的 build 阶段、一个 sort 节点、一个 materialize
节点,每个节点各自申请最多 work_mem 的内存。PG
15 引入的 hash_mem_multiplier(默认
2.0)进一步让 hash join 可以使用的内存 =
work_mem × hash_mem_multiplier。
假设
max_connections = 200,work_mem = 4MB(默认),hash_mem_multiplier = 2.0:
\[ \text{峰值内存} = 200 \times (4\text{MB}_{\text{sort}} + 8\text{MB}_{\text{hash}} + 4\text{MB}_{\text{materialize}}) = 200 \times 16\text{MB} = 3.2\text{GB} \]
这还没算 shared_buffers(通常 2-8GB)、每个
Backend 的私有内存(~5-10MB)、catalog cache 和 plan
cache。如果 200
个连接真的同时做复杂查询,实际内存需求可以轻松超过
10GB。问题不在于连接数——如果所有连接都在跑
SELECT 1,1000 个连接也不会有问题——而在于
work_mem
的乘法效应在没有流控的情况下被连接数放大。
更隐蔽的场景是连接风暴本身由上游服务扩容或故障切换触发:某服务重启后连接池初始化,瞬间创建 100 个连接,每个连接执行同一个业务查询(可能包含 hash join 或 ORDER BY),work_mem 的乘法效应在 1 秒内兑现。
1.3 时序推演
sequenceDiagram
participant App as 应用层
participant Pool as 连接池
participant PM as Postmaster
participant BE as 100+ Backend
participant OS as OS 内核
App->>Pool: 服务重启,初始化连接池
Pool->>PM: 瞬间发起 100 个连接请求
PM->>BE: fork() × 100(每个 ~8MB 私有内存)
Note over BE,OS: 100 Backend 各执行首条业务查询
BE->>BE: 每个查询并行启动 hash join + sort
Note over BE: work_mem 4MB × 3 ops × 100 连接 = 1.2GB
BE->>OS: 申请内存超过 OS 可用
OS-->>BE: OOM Killer 介入
OS->>PM: 杀死 Postmaster 或若干 Backend
PM->>PM: restart_after_crash=on → 触发 full restart
PM->>BE: 断开所有现有连接,重新 fork
Note over App,OS: 应用重连 → 再次风暴 → 可能死循环
```text
关键断点:`restart_after_crash=on` 意味着 Postmaster 收到子进程异常退出信号(`SIGCHLD` → `reaper()`)后,会执行一次 full restart——**断开所有连接**,然后重新从 `postgresql.conf` 启动所有辅助进程。这会把"一个 Backend 被 OOM kill"变成"所有 200 个用户连接全部断开"。源码路径:
```c
// src/backend/postmaster/postmaster.c, reaper()
// 当 HandleChildCrash 被调用时,标记所有 Backend 需要退出
static void
HandleChildCrash(int pid, int exitstatus, const char *procname) {
// 设置 FatalError = true
// 遍历 PMChildFlags,对每个 active process 设置退出信号
// SignalChildren(SIGQUIT) — 所有子进程收到 SIGQUIT
}
1.4 排查 SQL
-- 1. 快速确认连接数和状态分布
SELECT state, count(*) AS cnt
FROM pg_stat_activity
GROUP BY state ORDER BY cnt DESC;
-- 2. 定位可疑连接:同时 active 且运行时间异常
SELECT pid, usename, application_name, state,
wait_event_type, wait_event,
now() - xact_start AS txn_duration,
now() - query_start AS query_duration,
query
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '10 seconds'
ORDER BY query_start;
-- 3. 如果有 pg_stat_statements,找出 memory 消耗最高的 query
-- 用 temp_bytes 做 proxies(需要 track_io_timing=on + PG 16+):
SELECT queryid, calls,
mean_exec_time::numeric(10,1) AS avg_ms,
(temp_blks_read + temp_blks_written) * 8192 / calls AS avg_temp_bytes_per_call,
LEFT(query, 200) AS query_preview
FROM pg_stat_statements
WHERE calls > 0
ORDER BY (temp_blks_read + temp_blks_written) * 8192 / calls DESC
LIMIT 10;
-- 高 temp_bytes 意味着查询使用了磁盘临时文件(work_mem 不够)
```bash
### 1.5 修复边界
| 操作 | 效果 | 边界 |
|------|------|------|
| `ALTER SYSTEM SET work_mem = '2MB'` + `pg_reload_conf()` | 降低单操作内存,给现有连接腾空间 | 对已在执行的查询不生效;太小会导致 sort 频繁写 temp file |
| `SELECT pg_terminate_backend(pid)` | 杀掉特定 Backend | 被杀的连接无法重做未提交事务;如果所有连接都在执行同一条业务查询,杀完还会重来 |
| `ALTER SYSTEM SET max_connections = 'X'` + restart | 限制连接数上限 | 需要重启;太小会导致连接排队,应用端必须设 `connect_timeout` |
| `ALTER DATABASE db SET statement_timeout = '30s'` | 防止单个查询无限跑 | 只影响新连接 |
| 应用层加连接池限流(`pgbouncer` transaction pooling) | 将 200 个应用连接映射到 20 个 PG 连接 | 需要部署 pgbouncer;`SET` 语句和 prepared statement 在 transaction pooling 下有约束 |
将 `work_mem` 从默认 4MB 调到 2MB 并不是万能的——真正需要 4MB 的 hash join 会溢出到 temp file,造成新的 I/O 瓶颈。正确做法是同时调整 `hash_mem_multiplier`:
```sql
-- PG 15+: 降低 hash join 的额外内存权重
ALTER SYSTEM SET hash_mem_multiplier = 1.0;
ALTER SYSTEM SET work_mem = '4MB';
SELECT pg_reload_conf();1.6 监控埋点
-- 连接使用率(与 max_connections 的比值)
-- 告警阈值:> 80%
SELECT count(*) * 100.0 / current_setting('max_connections')::int AS pct_conn_used
FROM pg_stat_activity;
-- 有 temp_file 写入的查询占比
-- 告警阈值:> 10%
SELECT count(*) FILTER (WHERE temp_blks_written > 0) * 100.0 / count(*) AS pct_with_temp
FROM pg_stat_statements WHERE calls > 0;
-- OS 内存水位
-- 告警阈值:MemAvailable < 10% 总内存
-- 用 cron + grep /proc/meminfo,或 node_exporter + Prometheus
```text
---
## 二、事务 ID wraparound 危机完整时间线
### 2.1 现象
某天凌晨,所有写操作突然报错:ERROR: database is not accepting commands to avoid wraparound data loss in database “mydb” HINT: Stop the postmaster and vacuum that database in single-user mode.
此时数据库进入**只读 shutdown 状态**——任何带写的 DML 和 DDL 都被拒绝,只有 `SELECT` 和少数 `VACUUM` 相关操作能执行。这是 PG 保护机制的最后一道防线:如果继续允许写入,已经 vacuum 过的死 tuple 的 xmin 会和新的 32 位事务 ID 发生回卷冲突,所有历史数据变成不可见。
### 2.2 根因
PG 的事务 ID(`TransactionId`,又名 `xid`)是 32 位无符号整数——只有 $2^{32} \approx 42$ 亿个值。PG 把 xid 空间视为一个圆环,规则是:**任何 xid 比当前 xid 早 $2^{31}$(约 20 亿)以上的事务,都被视为"未来事务"**。这意味着单库生命周期内,真实使用的事务 ID 不能跨越 20 亿的窗口。
PG 通过 **freezing** 机制来对抗 wraparound:把一个 tuple 的 `xmin` 特殊标记为 `FrozenTransactionId`(值为 2),表示"这个 tuple 的创建事务一定在 20 亿个 xid 之前,无需比较,直接视为过去"。freezing 由 `VACUUM` 触发,按照 `vacuum_freeze_min_age` 和 `vacuum_freeze_table_age` 控制。
`autovacuum_freeze_max_age`(默认 2 亿)是 PG 的内置安全网:当某个表的 `age(pg_class.relfrozenxid)` 超过这个阈值时,autovacuum 会对该表强制执行一次 **anti-wraparound VACUUM**——即使正常的 autovacuum 还没到触发阈值(即 `n_dead_tup` 不够多),也会启动 VACUUM 来冻结旧 xid。
```c
// src/backend/postmaster/autovacuum.c, relation_needs_vacanalyze()
// 计算 age(relfrozenxid),与 autovacuum_freeze_max_age 比较
if (TransactionIdIsNormal(classForm->relfrozenxid)) {
xidForceLimit = recentXid - autovacuum_freeze_max_age;
if (TransactionIdPrecedes(classForm->relfrozenxid, xidForceLimit)) {
// 强制触发 anti-wraparound VACUUM
*dovacuum = true;
}
}
Wraparound 危机的完整因果链:
- 某张表的
autovacuum_vacuum_cost_delay设太高(或autovacuum_vacuum_cost_limit设太低),导致 autovacuum 处理速度跟不上 UPDATE/DELETE 产生 dead tuple 的速度。 - 正常的 autovacuum 被不断触发但永远跑不完(或因为锁冲突被
cancel),
age(relfrozenxid)持续增长。 - 当
age(relfrozenxid)超过autovacuum_freeze_max_age(2 亿),PG 强制启动 anti-wraparound VACUUM。此时pg_stat_activity会看到autovacuum: VACUUM tablename (to prevent wraparound)的进程。 - 如果 anti-wraparound VACUUM 仍然跑不完(表太大、cost
delay 太高、被手动 cancel),
age(relfrozenxid)继续增长。 - 当
age(relfrozenxid)达到ForceTransactionId的范围——源码中xidStopLimit(从当前 xid 往前推 1000 万)先触发只写警告不阻塞,然后是xidWrapLimit(往前推 100 万)触发只读 shutdown。
// src/backend/access/transam/varsup.c, SetTransactionIdLimit()
#define XID_WRAP_LIMIT 1000000 // 100 万,之后数据库进入只读
#define XID_STOP_LIMIT 10000000 // 1000 万,之后发出警告但允许写入
void SetTransactionIdLimit(TransactionId oldest_datfrozenxid, Oid oldest_datoid) {
// xidWrapLimit = oldest_datfrozenxid + (MaxTransactionId >> 1) - XID_WRAP_LIMIT
// xidStopLimit = oldest_datfrozenxid + (MaxTransactionId >> 1) - XID_STOP_LIMIT
}
```bash
### 2.3 时序推演
```mermaid
sequenceDiagram
participant App as 应用写入
participant AV as Autovacuum Worker
participant Cat as pg_catalog
participant Xid as XID 分配器
participant Prot as Wraparound 保护
Note over App,Prot: T0: 正常运作,age(relfrozenxid) 缓慢增长
App->>Cat: UPDATE/DELETE 频繁
Cat->>Cat: dead tuple 堆积,age(relfrozenxid) 增加
AV->>Cat: autovacuum 正常触发,但 cost_delay 太高
Note over AV: VACUUM 跟不上 dead tuple 产生速度
Note over App,Prot: T1: 跨过 autovacuum_freeze_max_age (2 亿)
AV->>Cat: anti-wraparound VACUUM 强制触发
Note over AV: pg_stat_activity: "VACUUM (to prevent wraparound)"
AV-->>Cat: 大表扫描 + index cleanup 耗时数小时
Note over App,Prot: T2: anti-wraparound VACUUM 被取消或卡住
App->>Cat: DDL 持锁 → autovacuum 等锁
Note over AV: autovacuum 多次被 lock_timeout 取消
Xid->>Xid: age(relfrozenxid) 继续增长
Note over App,Prot: T3: xidStopLimit 触发 (1000 万)
Prot->>App: WARNING: database must be vacuumed within 10000000 transactions
Note over App,Prot: T4: xidWrapLimit 触发 (100 万)
Prot->>App: ERROR: database is not accepting commands to avoid wraparound
Note over App: 数据库进入只读 shutdown 状态2.4 排查 SQL
-- 第一步:查看全库最危险的 age
SELECT datname, age(datfrozenxid) AS age_xid,
pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- 第二步:找出具体拖后腿的表和数据库
SELECT c.oid::regclass AS table_name,
age(c.relfrozenxid) AS age_xid,
pg_size_pretty(pg_total_relation_size(c.oid)) AS table_size,
n.nspname AS schema
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relkind IN ('r', 't', 'm') -- 普通表、toast 表、物化视图
AND age(c.relfrozenxid) > 100000000 -- 1 亿,重点关注
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
-- 第三步:检查 autovacuum 到底在干什么
SELECT pid, datname, usename, backend_start,
now() - backend_start AS running_for,
query
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker'
ORDER BY backend_start;
-- 第四步:看当前 anti-wraparound 的进度(PG 14+)
SELECT p.datname, p.relid::regclass,
p.phase,
p.heap_blks_total, p.heap_blks_scanned,
CASE WHEN p.heap_blks_total > 0
THEN round(100.0 * p.heap_blks_scanned / p.heap_blks_total, 1)
ELSE 0 END AS pct_scanned,
p.index_vacuum_count, p.max_dead_tuple_bytes
FROM pg_stat_progress_vacuum p
WHERE p.phase IS NOT NULL;
```bash
### 2.5 修复边界
数据库已经进入只读 shutdown 时,只有两条路:
**路径 A:单用户模式 VACUUM(标准方案)**
```bash
# 1. 停掉 PG
pg_ctl stop -D $PGDATA
# 2. 单用户模式启动(不启动 postmaster,不监听端口)
postgres --single -D $PGDATA dbname
# 3. 在 single-user 提示符下执行
VACUUM FREEZE;
-- 或者针对特定表
VACUUM (FREEZE, VERBOSE) the_problem_table;
# 4. 退出(Ctrl+D),正常启动
pg_ctl start -D $PGDATA- 适用:
pg_catalog表(如pg_authid)的 age 过大——单用户模式唯一能处理 catalog 表 VACUUM 的场景。 - 风险:单用户模式下没有并发控制,也没有
autovacuum。大库的
VACUUM FREEZE可能跑数小时甚至数天。这期间业务完全不可用。
路径 B:放宽保护阈值(紧急方案,风险高)
-- 在进入 shutdown 之前可以用,但已经 shutdown 了就无效
ALTER SYSTEM SET autovacuum_freeze_max_age = 2000000000; -- 20 亿
SELECT pg_reload_conf();
```text
- **风险巨大**:如果 age 确实已经到了 20 亿附近,取消保护意味着 tuples 可能变成不可见——数据没有物理删除,但查询看不到。唯一恢复手段是单用户模式 VACUUM FREEZE。
- **适用**:仅在 age 离真实 wraparound 还有很大差距、但保护阈值触发过早时作为临时放开手段。
**路径 C:预防——永远别到这里**
确保以下检查在 age 到 1 亿之前就触发告警:
```sql
-- 监控查询:放在 cron 里每小时跑一次
SELECT datname, age(datfrozenxid),
CASE WHEN age(datfrozenxid) > 150000000 THEN 'CRITICAL'
WHEN age(datfrozenxid) > 100000000 THEN 'WARNING'
ELSE 'OK' END AS status
FROM pg_database;常见根因和对应措施:
| 根因 | 排查方式 | 修复 |
|---|---|---|
autovacuum_vacuum_cost_delay 太高 |
SHOW autovacuum_vacuum_cost_delay |
调低到 2-5ms,或给大表单独设
ALTER TABLE t SET (autovacuum_vacuum_cost_delay = 1) |
| autovacuum worker 不够 | SHOW autovacuum_max_workers |
增大到 5-10(注意
maintenance_work_mem × worker 数
的总内存) |
| 长事务阻止 VACUUM 回收 | SELECT pid, now()-xact_start FROM pg_stat_activity WHERE state='idle in transaction' |
pg_terminate_backend() |
| DDL 持锁导致 autovacuum 等锁 | SELECT * FROM pg_locks WHERE NOT granted |
取消 DDL 或等它完成 |
三、replication slot 溢出多米诺效应
3.1 现象
Standby 宕机几个小时后,Primary 也宕机了。检查发现
Primary 的磁盘被 pg_wal 目录占满——而
wal_keep_size 设的并不大。重启后 PG
无法启动,因为它需要写 WAL 但磁盘满了。
3.2 根因
Replication slot 是 PG 流复制的核心机制:Primary 上的
slot 记录 standby 当前消费到哪个 WAL
LSN(restart_lsn),Primary 保证不会回收这个
LSN 之前的 WAL 段——这样即使 standby
短暂断开,重连后也能从上次读到的位置继续。
但 slot 的保护逻辑是无条件的:只要 slot
存在,restart_lsn 之前的 WAL
段就绝不回收,不管 standby
是否还活着、不管磁盘还有多少空间。
源码路径:
// src/backend/replication/slot.c, GetOldestSafeRestartLSN()
// 遍历 pg_replication_slots,找到最老的 restart_lsn
// 这个 LSN 即为 WAL 回收的"安全边界"
// 如果某个 slot 的 restart_lsn 非常老,所有 WAL 段都不能回收
```text
`wal_keep_size` 和 slot 的关系经常被误解:它们不是"取最大值",而是**叠加**。`wal_keep_size` 说"至少保留 X MB 的 WAL",slot 说"不管 X 是多少,restart_lsn 之前的都要保留"。如果 slot 的 restart_lsn 比 `wal_keep_size` 覆盖的范围更老,slot 的约束生效,保留下来的 WAL 会远超 `wal_keep_size`。
多米诺效应:
1. Standby 因网络、磁盘或进程问题宕机。
2. Primary 上的 slot 状态变为 `active = false`(因为 WAL Sender 进程断开),但 `restart_lsn` 停在上次 standby 确认的位置。
3. Primary 继续接受写入,WAL 持续产生,但因为 slot 不推进,旧 WAL 段无法回收。
4. `pg_wal` 目录开始膨胀。`wal_keep_size` 不限制 slot 保留的 WAL。
5. 磁盘满 → Primary 无法继续写 WAL → PANIC → crash。
6. 重启后磁盘仍满,PG 无法写 WAL 进入 recovery → 无法启动。
### 3.3 时序推演
```mermaid
sequenceDiagram
participant Primary as Primary
participant Slot as Replication Slot
participant WAL as pg_wal 目录
participant Disk as 磁盘
participant Standby as Standby
Note over Primary,Standby: T0: 正常复制
Standby->>Primary: 确认消费到 LSN = 0/30000000
Primary->>Slot: 更新 restart_lsn = 0/30000000
Primary->>WAL: 回收 0/30000000 之前的 WAL 段
Note over Primary,Standby: T1: Standby 宕机
Standby--xPrimary: 连接断开
Slot->>Slot: active = false, restart_lsn 冻结在 0/30000000
Note over Primary,Standby: T2: Primary 持续写入
Primary->>WAL: 新 WAL: 0/30000000 → 0/50000000 → 0/A0000000
Primary-->>WAL: 尝试回收旧段 → 被 slot 阻止
Disk->>Disk: pg_wal 持续增长: 500MB → 2GB → 10GB
Note over Primary,Standby: T3: 磁盘满
Primary->>WAL: 申请新 WAL 段
WAL-->>Primary: 磁盘空间不足
Primary->>Primary: PANIC: could not write to WAL
Note over Primary: 数据库 crash
Note over Primary,Standby: T4: 尝试重启
Primary->>Primary: 启动 → 需要写 WAL → 磁盘满 → 失败
Note over Primary: 死锁:需要删文件腾空间,但删 WAL 可能丢掉未归档的数据3.4 排查 SQL
-- 第一步:查看所有 slot 的状态,重点关注 active=f 且 restart_lsn 落后很多的
SELECT slot_name, slot_type, database, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS behind,
restart_lsn,
CASE WHEN active = false AND pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 1073741824
THEN 'DANGER' -- 落后 1GB 以上
WHEN active = false THEN 'WARNING'
ELSE 'OK' END AS status
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
-- 第二步:检查 pg_wal 目录的实际占用
SELECT pg_size_pretty(sum(size)) AS pg_wal_size
FROM pg_ls_waldir();
-- 第三步:确认磁盘剩余空间
--(需要在 OS 层执行 df -h $PGDATA/pg_wal,或者用 pg_ls_dir 间接观察)
```bash
### 3.5 修复边界
| 场景 | 操作 | 后果 |
|------|------|------|
| Standby 可恢复(临时网络故障) | 修复网络/重启 standby → 它会自动重连 → slot restart_lsn 推进 → WAL 自动回收 | 无需操作 |
| Standby 永久性不可恢复(硬件损坏) | `SELECT pg_drop_replication_slot('slot_name')` | 删除 slot。后果:必须重建 standby(重新 pg_basebackup),因为删掉的 WAL 可能包含 standby 需要的记录 |
| 磁盘已满但还没 crash | 立即 `pg_drop_replication_slot()` → 等 checkpoint 回收 WAL | 如果 slot 是 sync standby 的,删除意味着放弃同步复制保护 |
| 磁盘已满且已 crash | OS 层排查:确认 `pg_wal` 是否真的占满 → 如果有未归档 WAL,手动 `pg_waldump` 确认内容 → 决定是删 WAL 腾空间还是先 dd 到其他盘 | `pg_resetwal` 可以清空 WAL 重启,但代价极大(见第 24 章) |
**关键决策点**:删除 replication slot 之前,必须确认 standby 是否还在用这个 slot。如果 standby 只是临时不可用但很快会恢复,删掉 slot 意味着必须重做 base backup——这是一个数小时的操作,取决于数据量。
### 3.6 监控埋点
```sql
-- slot 落后 WAL 超过 1GB 的报警
-- 放在 cron 每分钟跑
SELECT slot_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots
WHERE pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 1073741824;
-- pg_wal 磁盘占大盘百分比(需要 OS 层配合)
-- node_exporter + disk usage alert on $PGDATA/pg_wal四、OOM 连锁 kill
4.1 现象
PG 运行正常,突然所有连接断开。应用日志显示
connection reset by peer 或
server closed the connection unexpectedly。PG
日志没有明显异常(因为 PG 进程被 OS
杀掉,来不及写日志),但几分钟后 PG 自己启动了(前提是
restart_after_crash=on)。用 dmesg
能看到:
Out of memory: Killed process 12345 (postgres) total-vm:8388608kB, anon-rss:3145728kB
4.2 根因
Linux OOM Killer 在系统内存耗尽时,根据进程的
oom_score 选择并 kill 进程。PG 的 Backend
进程(尤其是正在执行大查询、持有大量 work_mem
的进程)通常是内存消耗大户,OOM Killer 很可能选中它们。
OOM Killer 杀掉单个 Backend 后,Postmaster 通过
SIGCHLD → reaper()
感知到子进程异常退出。如果
restart_after_crash=on(默认),Postmaster 执行
full restart:给每个子进程发
SIGQUIT,回收所有共享内存资源,然后重新初始化共享内存并重新
fork 所有辅助进程和 Backend。这意味着:
- 所有正在运行的事务全部回滚。
- 所有客户端连接全部断开。
- 所有 idle in transaction 的连接也断开。
- 如果应用立即重连,可能再次触发相同查询,再次 OOM——进入 kill-restart 循环。
// src/backend/postmaster/postmaster.c, HandleChildCrash()
// 关键逻辑:
// 1. 确定 pmdie 模式(是否正在 shutdown?如果 crash 发生在 shutdown 期间,不 restart)
// 2. 如果 restart_after_crash=on → FatalError = true → 进入 PM_RUN 重新初始化
// 3. FatalError = true 时,所有 Backend 在下一次 ReadCommand() 时检查并退出
```bash
### 4.3 时序推演
```mermaid
sequenceDiagram
participant App as 应用
participant BE as Backend (大查询)
participant PM as Postmaster
participant OS as Linux OOM Killer
Note over App,OS: 状态:100 个连接,其中 1 个执行复杂 hash join
BE->>BE: hash join build 阶段申请 work_mem × hash_mem_multiplier
BE->>OS: 多次 malloc,累计 500MB+
Note over OS: 系统空闲内存不足
OS->>BE: OOM Killer 选中此 Backend (oom_score 高)
OS->>BE: SIGKILL
BE--xPM: 子进程异常退出
PM->>PM: SIGCHLD → reaper() → HandleChildCrash()
PM->>PM: restart_after_crash=on → 标记 FatalError=true
PM->>App: 给所有剩余 Backend 发 SIGQUIT
App--xPM: 200 个连接全部断开
PM->>PM: 重新 InitPostgres → fork 辅助进程
Note over App,OS: PG 重新可用,但所有连接需重建
App->>PM: 连接池自动重连
App->>BE: 再次执行相同查询
Note over App,OS: 如果 work_mem 配置没变 → 再次 OOM → 循环4.4 排查 SQL
-- 第一步:确认 crash 后 PG 状态
SELECT pg_postmaster_start_time(); -- 如果时间很近,说明刚 restart
-- 第二步(如果 PG 已恢复):找出内存消耗大户
-- 需要 pg_stat_statements 有足够历史
SELECT queryid,
calls,
mean_exec_time::numeric(10,1) AS avg_ms,
(shared_blks_dirtied + local_blks_dirtied) * 8192 / calls AS avg_dirty_bytes_per_call,
(temp_blks_read + temp_blks_written) * 8192 / calls AS avg_temp_bytes_per_call,
LEFT(query, 150)
FROM pg_stat_statements
WHERE calls > 10
ORDER BY (temp_blks_read + temp_blks_written) * 8192 / calls DESC
LIMIT 10;
-- 第三步:确认当前配置
SHOW work_mem;
SHOW hash_mem_multiplier;
SHOW maintenance_work_mem;
SHOW shared_buffers;
SHOW max_connections;
-- 第四步:事后查 OS 日志(在 shell 中执行)
-- dmesg | grep -i "killed process"
-- journalctl -u postgresql --since "1 hour ago"
```bash
### 4.5 修复边界
| 操作 | 效果 | 边界 |
|------|------|------|
| 降低 `work_mem` + `hash_mem_multiplier` | 减少单个查询的峰值内存 | 可能导致 temp file 增多,I/O 升高 |
| 降低 `max_connections` | 减少并发连接数,降低乘法效应 | 需要重启;太小导致连接排队 |
| 增加物理内存 / swap | 提高系统内存总量 | 硬件升级;swap 太慢不能替代真实内存 |
| 查询优化:替换 hash join 为 nestloop(对于小表) | 避免 hash table 的大内存分配 | 需要确认 nestloop 确实不慢;可能需要 hint 或 session 级 `SET enable_hashjoin = off` |
| `ALTER USER app_user SET statement_timeout = '30s'` | 防止单个查询跑太久 | 也会超时 kill 正常的长报表查询 |
| 部署 pgbouncer(transaction pooling) | 减少 PG 实际连接数 | 见第 1 节的边界 |
**关于 `restart_after_crash`**:关了它(`ALTER SYSTEM SET restart_after_crash = off`)可以防止 OOM kill 后的连锁 restart——但代价是:如果 PG crash 了,它不会自己起来,你需要手动启动。生产环境通常保留 `on`,但搭配进程监控(systemd `Restart=on-failure`)作为双重保险。
---
## 五、长事务(idle in transaction)的隐性破坏
### 5.1 现象
症状分散、隐蔽,但共同指向一个根因:
- 凌晨 3 点跑 `ALTER TABLE ... ADD COLUMN` 的 DDL hang 住,永远不返回。
- 表在膨胀——`pg_stat_user_tables.n_dead_tup` 持续增长,但 autovacuum 正常触发,VACUUM 也正常完成。
- `pg_stat_activity` 里有一个 `idle in transaction` 状态的连接,已经存在了 8 小时。
### 5.2 根因
PG 的 `idle in transaction` 是一个很容易被忽略但破坏力极强的状态——它和 `idle`(正常空闲连接)不同:
- **`idle`**:连接在事务中没有执行任何 DML,或者事务已经提交。实际上连接完全空闲,不会阻塞 VACUUM。
- **`idle in transaction`**:连接已经执行了至少一条 DML(例如 `SELECT * FROM t WHERE id = 1`),事务还没有 `COMMIT` 或 `ROLLBACK`。此时连接持有快照(snapshot),持有表级或行级锁。
长事务的两种破坏路径:
**路径一:VACUUM 无法回收 dead tuple**
VACUUM 回收 dead tuple 的核心判断是:**这个 dead tuple 对于所有现存事务是否都是不可见的?** 如果连接 A 的事务开始于 8 小时前,它持有的快照只包含 8 小时前已提交的数据。那么这 8 小时内产生的所有 dead tuple——即使 `xmax` 的事务早已提交——对连接 A 的快照来说,仍然是应当可见的旧版本。VACUUM 必须保留它们。
```c
// src/backend/access/heap/vacuumlazy.c, lazy_scan_heap()
// 对所有 dead tuple 调用 HeapTupleSatisfiesVacuumHorizon()
// 如果 dead tuple 的 xmax 比任何现存事务的 xmin 更新,不能回收
// OldestXmin = 所有活跃事务 + idle in transaction 的最小 xmin这意味着:一个 8 小时的长事务,相当于制造了一个 8
小时的”死 tuple 保护区”。在这 8 小时内产生的所有 dead
tuple,VACUUM 都不敢碰。表的膨胀和 n_dead_tup
的增长不会因为你配了激进的 autovacuum 而停止。
路径二:持锁阻塞 DDL
idle in transaction 事务在执行第一条 DML
时,可能获取了各种锁——对表的
AccessShareLock(SELECT 获取)、对行的
RowExclusiveLock(UPDATE/DELETE
获取)、甚至是更高等级的锁。ALTER TABLE 需要
AccessExclusiveLock——它必须等所有持有该表任何级别锁的事务释放锁。
这就是凌晨 DDL 被阻塞的经典场景:有人白天在 psql 里
BEGIN; SELECT * FROM big_table LIMIT 1;,然后离开座位。这个事务持有一个
AccessShareLock on big_table。晚上
DBA 跑 ALTER TABLE big_table ADD COLUMN ...
需要
AccessExclusiveLock——它排在等锁队列里,而队列是
FIFO 的。在它被锁住期间,所有后续需要
AccessShareLock
的查询(即几乎所有读查询)也会被堵在等锁队列里。
-- 可以用这个查询直观看到锁等待链:
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocking.state AS blocking_state,
now() - blocking.xact_start AS blocking_txn_age
FROM pg_stat_activity blocked
JOIN pg_locks blk_l ON blocked.pid = blk_l.pid AND NOT blk_l.granted
JOIN pg_locks blg_l ON blk_l.locktype = blg_l.locktype
AND blk_l.database = blg_l.database
AND blk_l.relation = blg_l.relation
AND blg_l.granted
JOIN pg_stat_activity blocking ON blg_l.pid = blocking.pid
WHERE blocked.wait_event_type = 'Lock';
```bash
### 5.3 时序推演
```mermaid
sequenceDiagram
participant User as 用户 A
participant TXN as 长事务连接
participant App as 应用写入
participant VAC as Autovacuum
participant DBA as DBA (DDL)
Note over User,DBA: T0: 用户 A 在 psql 中开启事务并读取
User->>TXN: BEGIN; SELECT * FROM orders LIMIT 1;
TXN->>TXN: 获取 AccessShareLock on orders
TXN->>TXN: 创建 snapshot (xmin = 1000000)
Note over TXN: 用户离开座位,事务保持 "idle in transaction"
Note over User,DBA: T0+1h: 应用正常读写
App->>TXN: UPDATE orders SET status = 'shipped' WHERE id = 42
App->>App: COMMIT(生成 dead tuple)
VAC->>TXN: autovacuum: 尝试回收 dead tuple
TXN-->>VAC: OldestXmin = 1000000 (长事务的 xmin)
Note over VAC: 无法回收 xmin > 1000000 之后的 dead tuple
Note over User,DBA: T0+8h: DBA 执行 DDL
DBA->>TXN: ALTER TABLE orders ADD COLUMN note TEXT;
DBA->>DBA: 需要 AccessExclusiveLock
DBA-->>DBA: 排队等锁(长事务持有 AccessShareLock)
Note over DBA: DDL hang 住,后续读查询也排队
Note over User,DBA: T0+8h+5min: 排查
DBA->>DBA: SELECT * FROM pg_stat_activity WHERE state='idle in transaction'
DBA->>DBA: SELECT pg_terminate_backend(长事务 pid)
Note over VAC: VACUUM 可以继续、DDL 获得锁、膨胀开始修复5.4 排查 SQL
-- 第一步:找出所有 idle in transaction 状态且超过一定时长的连接
SELECT pid, usename, application_name, client_addr,
state, now() - xact_start AS txn_age,
now() - state_change AS state_age,
wait_event_type, wait_event,
query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
-- 第二步:看长事务持有哪些锁
SELECT l.pid, l.locktype, l.database, l.relation::regclass AS locked_table,
l.mode, l.granted,
a.state, now() - a.xact_start AS txn_age,
a.query AS last_query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE a.state = 'idle in transaction'
AND now() - a.xact_start > interval '5 minutes'
ORDER BY a.xact_start, l.relation;
-- 第三步:看是否有 DDL 在等锁
SELECT pid, usename, now() - query_start AS waiting_for,
wait_event_type, wait_event,
query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND wait_event = 'relation'
ORDER BY query_start;
-- 第四步:追踪完整的锁等待链
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS holding_query,
blocking.state AS holding_state,
now() - blocking.xact_start AS holding_txn_age
FROM pg_stat_activity blocked
JOIN pg_locks blk_l ON blocked.pid = blk_l.pid AND NOT blk_l.granted
JOIN pg_locks blg_l ON blk_l.locktype = blg_l.locktype
AND blk_l.database = blg_l.database
AND blk_l.relation = blg_l.relation
AND blg_l.granted
JOIN pg_stat_activity blocking ON blg_l.pid = blocking.pid;
```bash
### 5.5 修复边界
| 操作 | 效果 | 边界 |
|------|------|------|
| `SELECT pg_terminate_backend(pid)` | 立即终止长事务,释放锁和快照 | 事务回滚:该事务内所有未提交的 DML 丢失。如果 DML 已经持久化到磁盘但所在事务被回滚,这部分变更丢失 |
| `ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min'` | 超过 10 分钟的 idle in transaction 自动断开 | 只影响新连接。需要 `pg_reload_conf()` |
| 应用程序端加事务超时 | 在应用层控制 `SET statement_timeout` 或使用连接池的事务超时 | 需要应用代码配合 |
| 禁止在交互式 psql 中使用 `AUTOCOMMIT=off` | 防止用户忘记 commit | 可能影响需要手动事务的工作流 |
| 检查 ORM 框架的事务管理 | 很多 ORM 默认 `autocommit=false` 且在异常路径中不 rollback | 需检查 Django / SQLAlchemy / Hibernate 等框架的配置 |
**关于 `pg_terminate_backend`**:杀掉长事务后,VACUUM 可以马上回收之前被阻塞的 dead tuple,但已经积累的膨胀不会自动消失——VACUUM 回收空间但不收缩物理文件。如果需要回收磁盘空间到 OS,必须执行 `VACUUM FULL`(会锁表,阻塞读写)或 `pg_repack`(需要扩展,不锁表但需要额外磁盘空间)。
### 5.6 监控埋点
```sql
-- idle in transaction 的持续时间和数量
-- 告警阈值:任何连接 > 15 分钟,或数量 > 5
SELECT count(*) AS idle_in_txn_cnt,
max(now() - xact_start) AS max_txn_age
FROM pg_stat_activity
WHERE state = 'idle in transaction';
-- 锁等待超过 30 秒的查询
-- 告警阈值:> 0
SELECT count(*) AS lock_waiting_cnt
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND now() - query_start > interval '30 seconds';六、五个故障的内核关联
这五个故障不是孤立的。它们的交叉点构成了 PG 运维的”风险拓扑”:
| 故障 | 被什么触发 | 会触发什么 |
|---|---|---|
| 连接风暴 | 服务重启、流量尖峰、连接池配置错误 | OOM kill(work_mem 乘法效应) |
| OOM kill | work_mem 过大、max_connections 过多、连接风暴 | 连锁 restart(restart_after_crash=on)→
连接风暴二次冲击 |
| Wraparound | autovacuum 配置不当、长事务阻止 VACUUM | 数据库只读 |
| Replication slot 溢出 | standby 宕机、slot 未清理 | 磁盘满 → Primary crash |
| 长事务 (idle in transaction) | 应用程序 bug、psql 交互式误操作 | 膨胀加速 × wraparound 风险上升 × DDL 阻塞 |
一个不那么罕见的多故障耦合场景:Standby 宕机 → slot 保留 WAL 导致磁盘使用率上升到 90% → DBA 重启 Primary 想腾资源 → 重启后连接池瞬间重连 200 个连接 → 每个连接执行缓存的查询计划 → work_mem 乘法效应 → OOM kill → restart_after_crash → full restart → 连接再次涌入 → OOM 循环。排查时看到的是 OOM,但根因是 replication slot 的磁盘压力触发了重启决策——这比五个故障各自独立出现更常见。
七、关键要点
- 连接风暴的根因常不是
max_connections本身——而是 fork 后的work_mem乘法、连接池重连和缺少idle_in_transaction_session_timeout的叠加。 - Wraparound
有可见的预警链——
age(relfrozenxid)、autovacuum 日志、写阻塞、只读 shutdown;监控阈值应设在 1 亿–1.5 亿 XID,而不是等到 2 亿。 - Replication slot 溢出是磁盘事故——inactive slot 阻止 WAL 回收;修复边界是删除 slot 或恢复 standby,不存在”保留 slot 又释放 WAL”的选项。
- OOM kill 触发 full restart——Postmaster
的
restart_after_crash=on会断开所有连接;排查需同时看dmesg和内存大户查询。 - 长事务是膨胀和 DDL
阻塞的隐形推手——
idle in transaction持有旧快照,VACUUM 无法回收其后 dead tuple;pg_terminate_backend()能止血但不收缩已膨胀的文件。
参考资料
源码路径
src/backend/postmaster/postmaster.c—PostmasterMain(),ServerLoop(),reaper(),HandleChildCrash()src/backend/access/transam/varsup.c—SetTransactionIdLimit(),ForceTransactionId(),xidStopLimit/xidWrapLimitsrc/backend/postmaster/autovacuum.c—relation_needs_vacanalyze(),autovacuum_freeze_max_age的判断逻辑src/backend/replication/slot.c—CreateSlot(),SaveSlotToDisk(), WAL 回收的 LSN 边界计算src/backend/storage/lmgr/lock.c—LockAcquire(),LockReleaseAll(), 锁等待队列src/backend/access/heap/vacuumlazy.c—lazy_scan_heap(),OldestXmin的计算与 dead tuple 回收判断src/backend/utils/time/snapmgr.c—GetSnapshotData(), 快照的 xmin/xmax 计算
官方文档
- PostgreSQL Documentation, Section 24.1 — “Routine Vacuuming”(事务 ID wraparound 的详细机制和预防)
- PostgreSQL Documentation, Section 27.2 — “Write-Ahead Log”(WAL 回收与 slot 的约束关系)
- PostgreSQL Documentation, Section 27.2.4 — “Replication
Slots”(slot 的 restart_lsn 语义和
active状态) - PostgreSQL Documentation, Section 19.4 — “Managing
Kernel Resources”(OOM Killer 和
vm.overcommit_memory) - PostgreSQL Documentation, Section 19.11 — “Client
Connection
Defaults”(
idle_in_transaction_session_timeout) - PostgreSQL Documentation, Section 20.4 — “Lock Management”(锁模式与锁等待队列)
事故复盘
- “A Postgres outage: What we learned about replication
slots” —
pg_wal膨胀导致 primary crash 的真实案例 - “Transaction ID wraparound in Postgres — a walk through the nightmare” — 单用户模式 VACUUM 8 小时的实战记录
- pgsql-hackers: “autovacuum_freeze_max_age default value discussion” — 2 亿阈值为什么不是 1 亿或 4 亿
实验工具
pg_stat_activity— 进程级状态视图(state,wait_event_type,wait_event,xact_start,query_start)pg_stat_user_tables— 表级统计(n_dead_tup,n_live_tup,last_autovacuum,last_autoanalyze)pg_stat_progress_vacuum— VACUUM 进度视图(PG 14+,phase,heap_blks_scanned)pg_locks— 锁视图(locktype,relation,mode,granted,pid)pg_blocking_pids(pid)— 找出谁在阻塞这个进程pg_replication_slots— slot 状态和 LSN 位置pg_ls_waldir()—pg_wal目录文件列表和大小
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么
不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。
【PG 内核】锁管理器:从 SpinLock 到死锁检测的三层体系
拆解 PostgreSQL 锁管理器的完整架构:SpinLock 自旋锁的硬件原语与使用边界、LWLock 从 PG 9.4 前到 PG 16 LWLockWaitListLock 的三代演进、Heavyweight Lock 的 LockAcquire() 完整路径和锁表结构、死锁检测 DeadLockCheck() 的等待图 DFS 算法、行级锁 FOR UPDATE/FOR SHARE/FOR KEY SHARE 的 t_infomask 实现,以及用 pg_locks 和 pg_blocking_pids() 追踪生产锁等待链的诊断方法。
【PG 内核】流复制:从 WAL Sender 到 Slot 溢出的多米诺效应
拆解 PostgreSQL 流复制的完整内核路径:WAL Sender 的 WalSndLoop→XLogSendPhysical 发送链路、WAL Receiver 的 WalRcvLoop 接收与恢复链路、同步复制的三种语义与等待机制、Failover 时 Timeline 的 fork 原理与 split-brain 风险、Primary-standby 冲突的本质与 max_standby_streaming_delay 的 trade-off、Replication Slot 的内部结构。重点剖析 Slot 溢出多米诺效应——standby 宕机→slot 阻止 WAL 回收→pg_wal 填满磁盘→primary PANIC 的完整事件链,以及 wal_keep_size 与 slot 的互相影响。配合 pg_stat_replication 的三层延迟指标排查与 conflict_reason 解读。
【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大
拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。