土法炼钢兴趣小组的算法知识备份

【PG 内核】经典故障模式与排查手册:五个真实事故的内核根因

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#troubleshooting#connection-storm#transaction-id-wraparound#replication-slot#oom#idle-in-transaction#failure-modes#pg-stat-activity#pg-locks#pg-replication-slots

目录

经典故障模式与排查手册:五个真实事故的内核根因

数据库生产事故有一个共同特征:早期信号在监控面板上存在了几天甚至几周,但没人注意到它们和最终爆炸之间的因果关系。

本文挑出 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 = 200work_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 危机的完整因果链:

  1. 某张表的 autovacuum_vacuum_cost_delay 设太高(或 autovacuum_vacuum_cost_limit 设太低),导致 autovacuum 处理速度跟不上 UPDATE/DELETE 产生 dead tuple 的速度。
  2. 正常的 autovacuum 被不断触发但永远跑不完(或因为锁冲突被 cancel),age(relfrozenxid) 持续增长。
  3. age(relfrozenxid) 超过 autovacuum_freeze_max_age(2 亿),PG 强制启动 anti-wraparound VACUUM。此时 pg_stat_activity 会看到 autovacuum: VACUUM tablename (to prevent wraparound) 的进程。
  4. 如果 anti-wraparound VACUUM 仍然跑不完(表太大、cost delay 太高、被手动 cancel),age(relfrozenxid) 继续增长。
  5. 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

路径 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/300000000/500000000/A0000000
    Primary-->>WAL: 尝试回收旧段 → 被 slot 阻止
    Disk->>Disk: pg_wal 持续增长: 500MB2GB10GB

    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 peerserver 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 通过 SIGCHLDreaper() 感知到子进程异常退出。如果 restart_after_crash=on(默认),Postmaster 执行 full restart:给每个子进程发 SIGQUIT,回收所有共享内存资源,然后重新初始化共享内存并重新 fork 所有辅助进程和 Backend。这意味着:

  1. 所有正在运行的事务全部回滚。
  2. 所有客户端连接全部断开。
  3. 所有 idle in transaction 的连接也断开。
  4. 如果应用立即重连,可能再次触发相同查询,再次 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 的磁盘压力触发了重启决策——这比五个故障各自独立出现更常见。


七、关键要点

  1. 连接风暴的根因常不是 max_connections 本身——而是 fork 后的 work_mem 乘法、连接池重连和缺少 idle_in_transaction_session_timeout 的叠加。
  2. Wraparound 有可见的预警链——age(relfrozenxid)、autovacuum 日志、写阻塞、只读 shutdown;监控阈值应设在 1 亿–1.5 亿 XID,而不是等到 2 亿。
  3. Replication slot 溢出是磁盘事故——inactive slot 阻止 WAL 回收;修复边界是删除 slot 或恢复 standby,不存在”保留 slot 又释放 WAL”的选项。
  4. OOM kill 触发 full restart——Postmaster 的 restart_after_crash=on 会断开所有连接;排查需同时看 dmesg 和内存大户查询。
  5. 长事务是膨胀和 DDL 阻塞的隐形推手——idle in transaction 持有旧快照,VACUUM 无法回收其后 dead tuple;pg_terminate_backend() 能止血但不收缩已膨胀的文件。

上一章:监控体系与告警设计 下一章:性能异常调查方法论


参考资料

源码路径

官方文档

事故复盘

实验工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么

不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。

2026-06-16 · database / kernel

【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() 追踪生产锁等待链的诊断方法。

2026-06-16 · database / kernel

【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 解读。

2026-06-16 · database / kernel

【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大

拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。


By .