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

【PG 内核】配置陷阱与生产最佳实践:11 个最危险的 GUC 和它们的正确设置

文章导航

分类入口
databasekernelops
标签入口
#postgresql#pg-kernel#guc#configuration#shared-buffers#work-mem#effective-cache-size#random-page-cost#fsync#synchronous-commit#huge-pages#maintenance-work-mem#idle-in-transaction#log-lock-waits#deadlock-timeout#log-min-duration-statement#auto-explain#postgresql-tuning

目录

配置陷阱与生产最佳实践:11 个最危险的 GUC 和它们的正确设置

PG 的配置系统(GUC,Grand Unified Configuration)是内核行为的外在控制面。postgresql.conf 里的每一个参数,背后都对应着源码中的特定分支、数据结构和算法路径。GUC 的核心理念是 “内置合理默认值,允许懂的人改”——但问题在于,“懂”的门槛远比多数人想象的高。

这套配置系统的坑不在于参数太多(PG 17 有 350+ 个 GUC),而在于参数之间相互联动、默认值的物理假设经常过时、以及错误配置的症状往往远离根因。你把 work_mem 调到了 256MB 想让查询跑快点,三天后 OOM killer 随机杀连接——这两件事之间的因果关系埋在进程模型、Hash Join 的内存分配路径和 Linux overcommit 三层机制之下。

本文不是 GUC 参数列表。本文从 11 个生产环境中最容易被误解和配错的参数出发,每条走这条路:这个参数在内核里控制什么 → 默认值在什么场景下是错的 → 配错后症状的内核机制解释 → 通过什么 SQL 和 shell 命令查验当前设置是否有问题。目标不是告诉你”设成 X 就好”,而是让你知道为什么、怎么验证

本文涉及的很多内核机制在前置章节中有详细拆解,建议先通读系列基础篇(至少第 1、4、5、10 章)。后文不再重复基础概念,直接引用相应章节。


一、shared_buffers:不是越大越好

这个参数在内核里控制什么

shared_buffers 决定了 PG 的 Buffer Pool 大小——这是共享内存中最核心的缓存区域,所有 Backend 进程通过共享内存直接访问(参见第 5 章:Buffer Manager的详细拆解)。它被组织为 N = shared_buffers / 8KB 个 buffer slot,每个 slot 由 BufferDesc + 8KB 数据页组成。页面替换由 Clock Sweep 算法驱动:usage_count 从 5 递减到 0,被选中的页面如果脏了就写回磁盘,然后分配给新请求。

为什么默认值在你的场景下可能是错的

默认值(通常是 128MB)在现代硬件上偏小,但盲目调大的后果比调小更隐蔽。超过 ~8-10GB 后,三重反噬开始显现:

第一重:double buffering。 PG 的 shared_buffers 和 OS page cache 是两套独立的缓存系统。同一个 8KB 数据页可能同时存在于 shared_buffers 和 OS page cache 中——白白占用两倍物理内存。当 shared_buffers 占比过高时,挤占了 OS page cache 的空间,而 PG 大量依赖 OS 的文件 I/O(WAL 写入、数据文件读入 buffer pool、大表 Seq Scan 绕过 ring buffer 后的文件读取),OS page cache 被压缩的后果是这些 I/O 路径全部退化到物理磁盘。

第二重:checkpoint 写脏页量变大。 Checkpoint 发生时,所有 shared_buffers 中的脏页必须被刷到磁盘(参见第 4 章:WAL 内部机制)。shared_buffers 越大,checkpoint 时需要写入的脏页总量越大。PG 通过 checkpoint_completion_target 将这些写入摊平到整个 checkpoint 间隔中,但如果脏页量太大,即使摊平也无法避免瞬时 I/O 尖峰——结果就是 checkpoint 期间的 TPS 雪崩式下降,查询延迟尖刺。

第三重:Clock Sweep 扫描开销。 当大量 buffer 的 usage_count 都降到 0 时,Clock Sweep 需要扫描大量 buffer 才能找到可替换的 slot。freelist(空闲 buffer 链表)为空时,每次 StrategyGetBuffer() 调用都在等待 Clock Sweep 完成扫描——在高并发下,多个 Backend 同时等待 buffer 分配,BufferAlloc 路径上的 LWLock 争用显著上升。

配错后的症状

查验方法

Step 1:观察 buffer 命中率的真实含义。

-- 查看 buffer 命中率(pg_stat_bgwriter 的口径)
SELECT
  buffers_checkpoint,
  buffers_clean,
  buffers_backend,
  buffers_alloc,
  ROUND(100.0 * (buffers_checkpoint + buffers_clean + buffers_backend) /
        NULLIF(buffers_checkpoint + buffers_clean + buffers_backend + buffers_alloc, 0), 2)
    AS sync_io_pct
FROM pg_stat_bgwriter;
```text

`sync_io_pct` 越接近 0,说明命中率越高——但这个指标存在"命中率骗局":如果大量 shared_buffers 都用在重复访问同一小批热页面,而大表 Seq Scan 每次都读磁盘,命中率仍然可以接近 99% 但查询可能很慢。

**Step 2:用 `pg_buffercache` 观察 buffer 的实际分布。**

```sql
-- 安装扩展
CREATE EXTENSION IF NOT EXISTS pg_buffercache;

-- 每个表占用了多少 shared_buffers
SELECT
  c.relname,
  COUNT(*) AS buffers,
  pg_size_pretty(COUNT(*) * 8192) AS size
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE b.reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
GROUP BY c.relname
ORDER BY COUNT(*) DESC
LIMIT 20;

如果排名前几的表占用了 shared_buffers 的绝大部分,说明访问模式集中。关键判断:这些表的数据量是否远大于 shared_buffers?如果是,说明每次访问大量页面在反复淘汰 shared_buffers 里的内容——此时更大的 shared_buffers 可能有帮助。如果不是(比如热表只有 500MB,但 shared_buffers 设了 8GB),那剩下的 7.5GB 基本是浪费。

Step 3:对比 shared_buffers 和 OS page cache 的实际使用。

# 查看 OS 层面的 page cache
free -h
# 查看 PG 的共享内存段大小
ipcs -m | grep postgres
# 用 pg_buffercache 统计 shared_buffers 中脏页比例
```text

```sql
SELECT
  isdirty,
  COUNT(*) AS buffers,
  pg_size_pretty(COUNT(*) * 8192) AS size
FROM pg_buffercache
GROUP BY isdirty;

如果 free -havailable 低于物理内存的 20%,而 shared_buffers 占了物理内存的 50% 以上,说明 double buffering 已经挤压到其他 I/O 路径——你需要减小 shared_buffers,把内存还给 OS page cache。

正确做法:shared_buffers 的合理范围通常是物理内存的 15%-25%,上限不超过 8-10GB(除非是纯 OLTP 且热数据集完全小于此值)。最终依据是 pg_buffercache 的实际观察,不是公式。


二、work_mem:最危险的 GUC

这个参数在内核里控制什么

work_mem 控制的是单个查询中每个 sort / hash / bitmap / materialize 操作各自的内存上限。注意三个关键词:“单个操作”(不是单个查询)、“各自独立”(一个查询有 3 个 sort + 1 个 hash join,就是 4 个 work_mem 配额)、“上限”(操作的内存可以低于此值,但不能超过)。

以 hash join 为例(参见第 12 章:执行器中对 ExecHashJoin() 的内存分配分析):PG 在构建 hash table 时,如果数据量超过 work_mem,会把超出部分写入临时文件(Hybrid Hash Join 的 spill 机制)。sort 同理——数据量超过 work_mem 时切换到 external merge sort。

PG 15 引入了 hash_mem_multiplier,将 hash 操作的内存上限从 work_mem 调整为 work_mem × hash_mem_multiplier(默认 2.0)。这意味着 PG 15+ 中 hash join 默认使用 2 倍 work_mem 的内存——这是对”hash table 写临时文件比 sort 写临时文件更痛苦”的补偿。

为什么默认值在你的场景下可能是错的

默认值 4MB 在 OLTP 场景(查询简单、操作少)可能够用。但 work_mem 的真正危险不是设太小——是设太大而你没有意识到它在连接数面前是乘法爆炸。

危险的算术:

\[\text{总内存} = \text{work\_mem} \times \text{每查询操作数} \times \text{平均并行度} \times \text{并发连接数}\]

拿一个典型的生产负载算一笔账:100 个并发连接,每个连接跑一个 parallel hash join(3 个 worker = 4 个进程),同时还有 sort + materialize。如果你把 work_mem 调到了 64MB:

如果你的服务器只有 32GB 物理内存,这就是一个即将触发的 OOM 炸弹。Linux overcommit(默认开启)不会阻止 malloc() 成功,但当这些内存真正被触及时,OOM killer 会随机选择进程杀掉。

更隐蔽的是:这个乘法中的”操作数”不是 EXPLAIN 里看到的第 1 行那几个——一个 CTE (WITH) 被多次引用时,每个引用生成独立的 Materialize 节点,各拿一份 work_mem。一个含多表 JOIN 的查询在生成中间结果时的 sort 操作也可能不止一个。

配错后的症状

查验方法

Step 1:观察临时文件生成。

SELECT datname, temp_files, temp_bytes,
       pg_size_pretty(temp_bytes) AS temp_size,
       stats_reset
FROM pg_stat_database
WHERE datname = current_database();
```text

如果 `temp_files` 增长很快,且你知道当前负载中有 hash join 或大 sort,说明 `work_mem` 太小——数据在 spill 到磁盘。

**Step 2:估计当前负载的实际内存需求。**

```sql
-- 当前有哪些查询在执行 hash join / sort
SELECT pid, query,
       wait_event_type, wait_event,
       state
FROM pg_stat_activity
WHERE state = 'active'
  AND (query ILIKE '%hash%' OR query ILIKE '%sort%')
  AND pid != pg_backend_pid();

-- 当前活跃连接数
SELECT COUNT(*) AS active_connections
FROM pg_stat_activity
WHERE state = 'active';
-- 每个数据库的临时文件统计
SELECT datname, temp_files,
       pg_size_pretty(CASE WHEN temp_bytes > 0
         THEN temp_bytes / NULLIF(temp_files, 0) ELSE 0 END) AS avg_temp_file_size
FROM pg_stat_database;
```text

如果 `avg_temp_file_size` 很小但 `temp_files` 很多,说明操作经常只超出 `work_mem` 一点就 spill——稍微增大 `work_mem` 就能消除大部分 spill。但如果 `avg_temp_file_size` 很大,意味着部分操作的数据量远超 `work_mem`——需要从查询优化角度减少参与 hash/sort 的行数,而不是无脑增 `work_mem`。

**Step 3:`hash_mem_multiplier` 的联动检查。**

```sql
SHOW hash_mem_multiplier;
SHOW work_mem;
-- 以人类可读的方式显示 hash 操作的实际内存上限
SELECT setting, unit,
       CASE WHEN unit = 'MB' THEN setting::numeric * 1024 * 1024
            WHEN unit = 'kB' THEN setting::numeric * 1024
            WHEN unit = 'GB' THEN setting::numeric * 1024 * 1024 * 1024
            ELSE setting::numeric
       END AS bytes
FROM pg_settings
WHERE name = 'work_mem' OR name = 'hash_mem_multiplier';
```text

**正确做法**:`work_mem` 不要全局设大。用 `ALTER DATABASE`、`ALTER USER` 或连接池的 session 级别参数,为不同负载类型设定不同值:OLTP 报告查询 → 16-64MB,日常轻查询 → 4-16MB。上线前在 staging 环境用真实负载跑一轮,观察 `temp_files` 增量和物理内存占用,确认 `work_mem × max_connections × 平均并行度 × 操作数` 不超过物理内存的 60%。

---

## 三、effective_cache_size:不分配内存,只影响优化器的决策

### 这个参数在内核里控制什么

`effective_cache_size` 是优化器代价模型中用于估算 I/O 代价的参数。它本身**不分配任何内存**——它只是告诉优化器:"你可以假设查询用到的数据页中,有这么多能在这层缓存(shared_buffers + OS page cache)中被找到。"

在 `cost_index()` 函数(`src/backend/optimizer/path/costsize.c`)中,索引扫描的 I/O 代价计算依赖于一个关键假设:索引页中有一部分已经在缓存中,不需要物理 I/O。这个"已经在缓存中的比例"由以下公式估算:

$$\text{cache\_hit\_fraction} = \min\left(1, \frac{\text{effective\_cache\_size}}{\text{所有参与查询的表的页面数}}\right)$$

如果 `effective_cache_size` 远小于表大小,优化器认为大部分索引页和 heap 页需要从磁盘读取——于是索引扫描的估算代价偏高,优化器倾向于 Seq Scan。如果 `effective_cache_size` 大到能覆盖全部数据,优化器认为几乎所有页面都在缓存中——索引扫描代价显著降低,优化器倾向于使用索引。

(代价模型的完整拆解见[第 10 章:统计信息与代价模型](../10-statistics-cost/10-statistics-cost.html)。)

### 为什么默认值在你的场景下可能是错的

默认值 4GB 在内存只有 4-8GB 的系统上偏高,在内存 256GB 的服务器上则严重偏低。

关键误区:`effective_cache_size` 应该反映真实可用的缓存大小——包括 shared_buffers 和 OS page cache。在典型 PG 部署中,OS page cache 通常远大于 shared_buffers(因为 PG 开发者建议 shared_buffers 只占物理内存的 15-25%)。所以**真实的"缓存"主要是 OS page cache,不是 shared_buffers。**

设太小(如默认 4GB 在 128GB 服务器上)的后果:优化器不相信索引页能被缓存,高估索引扫描代价 → 对大于内存的表倾向于 Seq Scan → `EXPLAIN` 里看到大量不该出现 Seq Scan 的查询计划。

### 配错后的症状

- `EXPLAIN` 输出中,优化器对有明显索引可用且有过滤条件的查询仍然选择 Seq Scan,且代价估算的前几行就显示 `cost=0.00..XXXX`(Seq Scan 的启动代价为 0)。
- 查询响应时间比预期慢,且 `EXPLAIN (ANALYZE, BUFFERS)` 显示 Seq Scan 读取了大量 `shared read`(从磁盘读),而你知道索引存在且选择性很好。
- 使用 `SET enable_seqscan = off;` 后查询显著变快——说明优化器选 Seq Scan 不是因为索引差,而是代价估算失衡。

### 查验方法

```sql
-- 当前值
SHOW effective_cache_size;

-- 查看系统真实可用内存
-- 在 shell 中:free -h
-- 检查某张表是否被优化器错误估算
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT * FROM large_table WHERE indexed_column = 'rare_value';
```text

关注输出中的 `Settings: effective_cache_size`、`Buffers: shared hit/read` 对比,以及 `rows` 估算与实际行数的偏差——如果偏差一个数量级以上,问题可能不只在 `effective_cache_size`,也在统计信息(参见[统计信息漂移的诊断](../10-statistics-cost/10-statistics-cost.html))。

**正确做法**:设 `effective_cache_size` 为系统可用内存的 50-75%(不是物理总内存——要扣除其他应用和 OS 本身的开销)。例如 128GB 服务器上,减去 OS 和其他进程约 16GB,剩余 112GB 的 60% 约为 67GB——设 `effective_cache_size = 64GB` 是一个合理起点。最终依据是 `EXPLAIN` 输出的计划是否与实际最优计划一致。

---

## 四、random_page_cost:默认 4.0 在 NVMe 上严重高估随机 I/O

### 这个参数在内核里控制什么

`random_page_cost` 是优化器代价模型中的核心常量之一,表示从磁盘随机读取一个页面的相对代价。它的默认值 4.0,意味着优化器假定:随机读一页的代价是顺序读一页(`seq_page_cost`,默认 1.0)的 4 倍。

这个值直接影响所有索引扫描(Index Scan、Index-Only Scan、Bitmap Scan)的代价估算。索引扫描的本质是"索引页的随机读 + heap 页的随机读"——如果 `random_page_cost` 偏高,这两个操作的估算代价就偏高,优化器就倾向于放弃索引选择 Seq Scan。

历史上 4.0 对应的是机械硬盘(HDD)的寻道延迟(~4ms random seek vs ~1ms sequential read)。但 NVMe SSD 的随机读延迟通常在 0.1-0.3ms,与顺序读的差异很小——此时 `random_page_cost = 4.0` 意味着优化器认为索引扫描的代价是实际代价的 10-40 倍。

### 为什么默认值在你的场景下可能是错的

在 NVMe SSD(甚至高端 SATA SSD)上,默认值 4.0 严重偏差。`pg_test_fsync` 工具的实测数据可以帮助量化差距:

```bash
# PG 自带的 fsync 性能测试
pg_test_fsync
# 输出示例(NVMe SSD):
# Compare file sync methods using one 8kB write:
#         open_datasync                 n/a
#         fdatasync                     n/a   (fsync 约为 0.1ms)
# 磁盘寻道时间的主导因子已经降到微秒级

你也可以用 fio 做更精确的随机读 vs 顺序读对比:

fio --name=randread --ioengine=libaio --iodepth=1 --rw=randread \
    --bs=8k --direct=1 --size=1G --numjobs=1 --runtime=10 \
    --group_reporting
fio --name=seqread --ioengine=libaio --iodepth=1 --rw=read \
    --bs=8k --direct=1 --size=1G --numjobs=1 --runtime=10 \
    --group_reporting
```text

取随机读延迟与顺序读延迟的比值作为 `random_page_cost` 的基准。

### 配错后的症状

- 优化器在有索引且过滤条件选择性极好时仍然走 Seq Scan——与 `effective_cache_size` 偏小的症状类似,但根因不同。
- `EXPLAIN (ANALYZE, BUFFERS)` 强行走 Index Scan(`SET enable_seqscan = off`)后,实际执行时间远低于优化器为 Seq Scan 估算的代价——说明优化器对索引扫描的代价估算高了数倍。
- 多个小查询的 `mean_exec_time` 偏高,`pg_stat_statements`  `shared_blks_read` 远高于期望。

### 查验方法

```sql
-- 当前值
SHOW random_page_cost;

-- 强制索引扫描对比
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM large_table WHERE indexed_column = 'rare_value';
-- 记录实际执行时间

SET enable_seqscan = on;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM large_table WHERE indexed_column = 'rare_value';
-- 对比两者的 actual time 和 optimizer cost

如果强走索引的 actual time 远低于 Seq Scan 的 actual time,但优化器默认选择了 Seq Scan,说明 random_page_cost 设高了。

-- 批量检查:pg_stat_statements 中哪些查询的 shared_blks_read 异常高
SELECT queryid, query,
       shared_blks_read, shared_blks_hit,
       mean_exec_time,
       ROUND(100.0 * shared_blks_read /
             NULLIF(shared_blks_read + shared_blks_hit, 0), 2) AS read_pct
FROM pg_stat_statements
WHERE shared_blks_read + shared_blks_hit > 100
ORDER BY shared_blks_read DESC
LIMIT 10;
```text

**正确做法**:NVMe SSD → `random_page_cost = 1.1`;高端 SATA SSD → `1.5-2.0`;SAN/NAS → 需要实测。如果用 `fio` 测得随机读延迟是顺序读的 1.5 倍,设 `random_page_cost = 1.5`。上线后用上面的强制索引扫描对比法验证。

---

## 五、fsync=off:最危险的配置

### 这个参数在内核里控制什么

`fsync` 控制 PG 是否调用 `fsync()` 系统调用来保证 WAL 和数据文件写入已落到持久化存储(磁盘或等效设备)。当 `fsync=on`(默认)时,每次 WAL 写入和 checkpoint 的数据文件写入最终都会通过 `fsync()`(或 `fdatasync()`,取决于 `wal_sync_method`)确保数据不只在 OS buffer 中。

关掉它意味着:`COMMIT` 时 WAL 数据写到了 OS buffer 但没有调用 `fsync()` 刷盘。操作系统会在它觉得方便时(通常在几秒到几十秒内)把脏页写回磁盘——但在那之前,如果掉电或内核 crash,这些数据就丢了。

(WAL 的完整写入路径和 `wal_sync_method` 的语义差异见[第 4 章:WAL 内部机制](../04-wal-internals/04-wal-internals.html)。)

### 为什么有人会动它

`fsync` 是 PG 中最大的性能开关之一——关掉它,所有 WAL 写入的 `fsync()` 系统调用延迟消失,`COMMIT` 延迟从 ~0.1ms(NVMe)降到 ~0.01ms。在批量数据导入(`COPY`、`pg_restore`)场景中,导入速度可能提升 10-50 倍。

但代价是:**crash 之后 PG 可能无法恢复,或者恢复后数据不一致。** 这不是丢"最近几秒"数据的问题——这是整个集群可能在 crash 之后不可用的问题。WAL 恢复的逻辑依赖 WAL 记录的持久化顺序和完整性——如果 OS buffer 中的 WAL 没有完整刷盘,crash recovery 时 `StartupXLOG()` 可能遇到截断的 WAL 记录、缺失的 checkpoint record、或者 WAL 序列号不连续,导致恢复失败,数据库无法启动。

### 唯一合法的使用场景

唯一能接受 `fsync=off` 的场景是:**你正在做一次性批量数据导入,导入后有完整的源数据可以重建整个集群,并且你在导入完成后用 `pg_dump` 重新导出并恢复到 `fsync=on` 的干净集群中。**

即使在这个场景下,也有更安全的替代方案:用 `synchronous_commit=off`(见下一节)代替 `fsync=off`——`synchronous_commit=off` 只影响事务提交的 WAL 刷盘,不影响 checkpoint 的 `fsync()`。crash 后丢的是已提交事务,但集群本身是可恢复的。

### 查验方法

```sql
SHOW fsync;

如果结果是 off,立即查以下:

  1. 这个集群是不是只用于批量导入?如果不是,这是严重配置错误。
  2. 最近一次崩溃测试(crash recovery 测试)是否通过?如果没有做过,不能信任当前数据。
  3. 即使没有 crash,检查数据一致性:pg_checksums --check(如果启用了 data checksums)。

正确做法:永远不要在生产环境关 fsync。批量导入场景用 synchronous_commit=off 替代。如果确实需要极高导入速度且不能接受 synchronous_commit=off 的速度,改用 pg_bulkloadCOPY FREEZE 等绕过 WAL 的批量导入工具,而不是关 fsync


六、synchronous_commit=off:最多丢 wal_writer_delay 的已提交事务

这个参数在内核里控制什么

synchronous_commit 控制事务 COMMIT 是否等待 WAL 刷盘。它有多个级别:

含义
on 等待 WAL 刷到磁盘(默认)
remote_apply 流复制:等待 standby apply 了 WAL
remote_write 流复制:等待 standby 写到了 OS buffer
local 只等本地刷盘
off 不等待 WAL 刷盘——COMMIT 成功只表示 WAL 已写入 OS buffer

off 的关键机制:COMMIT 后 WAL record 被写入 WAL buffer → XLogFlush() 把 WAL buffer 写入 OS buffer → 但不调用 fsync()。WAL Writer 进程(walwriter)会定期(每 wal_writer_delay 毫秒,默认 200ms)执行 XLogBackgroundFlush(),把积累的 WAL 数据刷到磁盘。

为什么默认值在特定场景下是错的

对金融交易、支付、会计等需要”提交即持久化”语义的应用,synchronous_commit=off 意味着:如果 crash 发生在 COMMIT 之后、WAL Writer 下一次刷新之前,已提交的事务会在 WAL 恢复时丢失

最坏情况下丢失的窗口是 wal_writer_delay(默认 200ms)+ OS 自身的脏页回写延迟 + WAL 从 OS buffer 刷到磁盘控制器缓存再刷到持久化介质的时间。在极端负载下(大量小事务,每个都更新同一行),这个窗口可能更大——因为 WAL Writer 一次刷盘的 WAL 量有上限。

synchronous_commit=offfsync=off 有本质区别:前者丢的是”已提交事务”,后者可能导致”集群不可恢复”。synchronous_commit=off 下 crash 后 PG 能正常启动并恢复——恢复过程会找到最近一个完整刷盘的 WAL checkpoint,从那里开始 REDO——丢失的事务会被静默忽略,不会导致数据损坏或不一致。

配错后的症状

查验方法

SHOW synchronous_commit;

-- 查看当前 WAL writer 的配置
SELECT name, setting, unit FROM pg_settings
WHERE name IN ('synchronous_commit', 'wal_writer_delay', 'wal_writer_flush_after');
```text

```sql
-- 查看 WAL 写入速率——如果写入量极大,WAL writer 的刷新频率可能跟不上
SELECT pg_current_wal_lsn(), pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS current_offset;
-- 两次查询间隔中,WAL LSN 的增量可以估算丢失窗口的数据量

正确做法:对需要”提交即持久化”的应用,保持 synchronous_commit=on。对”丢了也能重做”的 session(如批量导入、日志写入、缓存预热),用 SET SESSION synchronous_commit = off; 在 session 级别关闭,而不是全局关。主从复制场景,如果同时需要持久性和低延迟,用 synchronous_commit = remote_write 代替 off——至少保证 standby 收到了 WAL。


七、huge_pages:Docker/K8s 中的静默退化

这个参数在内核里控制什么

huge_pages 控制 PG 是否使用 Linux 的 huge pages(大页内存)来分配 shared_buffers。默认 2MB 的 huge page(或 1GB 的 gigantic page)能显著减少 TLB(Translation Lookaside Buffer)miss——对于 shared_buffers 通常是几 GB 到几十 GB 的场景,如果使用 4KB 小页,TLB entries 不够用,每次页面遍历都需要额外的页表访问。

PG 在 src/backend/port/sysv_shmem.c 中分配共享内存时,根据 huge_pages 设置选择分配方式:

为什么默认值在你的场景下可能是错的

默认值 try 在物理机/VM 上通常工作良好——OS 如果配置了 huge page,try 会自动使用。但在 Docker 容器和 Kubernetes Pod 中,容器默认没有 SYSVIPC 相关的 huge page 权限,try 会静默退化为 4KB 页——你的 PG 开心地启动了,shared_buffers 也分配了 8GB,但全用的是 4KB 页,TLB miss 显著增加。

这种退化在运行一段时间后表现为 CPU 使用率异常偏高(5-15% 的额外开销),但你几乎不可能从应用层面定位到原因——pg_stat_statements 显示所有查询都正常,EXPLAIN 也没问题,就是 CPU “莫名其妙”高。

配错后的症状

查验方法

# 查看系统大页状态
grep -E "HugePages|Hugepagesize" /proc/meminfo
# HugePages_Total: 0   <-- 系统没有配置大页
# HugePages_Free:  0
# Hugepagesize:    2048 kB
```text

```bash
# 查看 PG 启动日志中关于 huge page 的信息
# 如果是物理机部署:
grep -i "huge" /var/log/postgresql/postgresql-*.log
# 或用 journalctl
journalctl -u postgresql | grep -i huge
# 查看容器的内存限制和 huge page 配置
# Docker:
docker inspect <container> | jq '.[0].HostConfig.Memory, .[0].HostConfig.MemoryReservation'
# K8s:
kubectl describe pod <pod> | grep -A5 -E "Limits|hugepage"
```text

输出为空或 "huge pages: 0" 即说明退化。

```bash
# 确认当前 PG 实际使用的页大小
# PG 没有直接 GUC 暴露此信息,但可以通过共享内存段大小推算
# 如果 shared_buffers=8GB 但 ipcs -m 显示多个小段,很可能没用大页
ipcs -m | grep postgres

正确做法

如果无法在容器中使用 huge page,评估 TLB miss 的实际影响:在无 huge page 的环境跑 perf stat -e dTLB-load-misses,dTLB-loads -p <pg_pid> 观察 TLB miss 率。如果 miss 率超过 1%,考虑换物理机部署或减少 shared_buffers


八、maintenance_work_mem:慢 VACUUM 和慢 CREATE INDEX 的隐形瓶颈

这个参数在内核里控制什么

maintenance_work_mem 控制的是维护操作的内存上限,主要包括三类:

为什么默认值在你的场景下可能是错的

默认值 64MB 在大于 10GB 的表上执行 VACUUMCREATE INDEX 时严重不足。

以 VACUUM 为例(详细流程见第 8 章:VACUUM 与 Freezing):VACUUM 扫描 heap 页面收集 dead tuple 的 TID → 对于每个索引,用维护工作内存中的数组批量查找并标记 dead TID → 如果数组装不下所有 dead tuple,就要多趟 scan index。一个大表(100GB)上的 VACUUM,如果产生了 2GB 的 dead tuple,以 64MB 数组的大小需要大约 32 趟索引扫描——耗时是单趟扫描的几十倍。

CREATE INDEX 的 external merge sort 阶段:PG 的 B-Tree 创建过程(_bt_spools_heapscan()tuplesort_performsort())需要排序所有 tuple。64MB 在面对几千万行时要 spill 大量临时数据到磁盘,索引创建时间可以差一个数量级。

配错后的症状

查验方法

SHOW maintenance_work_mem;

-- 查看最近一次 VACUUM 是否有多趟索引扫描
-- 手动跑一次 verbose VACUUM 来诊断(在低峰期)
VACUUM (VERBOSE) target_table;
-- 关注输出中的:
-- "index scans: N"  -- N>1 说明 maintenance_work_mem 不够
-- "pages: X removed, Y remain"
```text

```sql
-- 查看当前 VACUUM 的进度
SELECT
  relid::regclass AS table_name,
  phase,
  heap_blks_total, heap_blks_scanned,
  index_vacuum_count,
  max_dead_tuples
FROM pg_stat_progress_vacuum;

max_dead_tuples 是当前这一趟能装下的最大 dead tuple 数——如果表上的 dead tuple 远大于此值,VACUUM 需要多趟索引扫描。

-- 预估表上的 dead tuple 量
SELECT relname,
       n_dead_tup,
       n_live_tup,
       ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 100
ORDER BY n_dead_tup DESC
LIMIT 10;
```text

**正确做法**:`maintenance_work_mem` 可以设得比较大(1GB-8GB 在 64GB+ 服务器上合理),因为 **`maintenance_work_mem` 不在每个连接的 work_mem 乘数公式中**——它只在执行维护操作的进程中使用,而 VACUUM worker 的数量由 `autovacuum_max_workers`(默认 3)限制。最坏情况是 `autovacuum_max_workers` 个 VACUUM worker 同时做索引清理,峰值内存为 `maintenance_work_mem × autovacuum_max_workers`——如果设 1GB × 3 = 3GB,在 64GB 服务器上完全可控。

也可以只增大 `autovacuum_work_mem`(PG 9.4+)——它只影响 autovacuum worker,不影响手动的 `VACUUM` 和 `CREATE INDEX`。

---

## 九、idle_in_transaction_session_timeout:为什么必须设

### 这个参数在内核里控制什么

`idle_in_transaction_session_timeout` 控制一个 session 在事务中但空闲(没有执行任何查询)多久后被自动终止。事务中空闲意味着:session 执行了 `BEGIN` 或前一条 SQL 开启了事务,但在那之后没有再发任何查询——通常是应用程序 `BEGIN` → 查询 → 在代码中做了非 DB 操作(调 API、读取文件、等待用户输入)→ 忘了 `COMMIT` → 事务就这么开着。

这个参数默认值是 0——表示禁用。这就是问题。

### 为什么默认值在你的场景下可能是错的

一个忘了 `COMMIT` 的 session(哪怕是开发者的 `psql` 连接)可以造成连锁灾难:

1. **持锁不释放。** 事务中获取的锁(行级锁、表级 `ACCESS EXCLUSIVE` 等)在事务结束前不会释放。如果这个事务曾经 `UPDATE` 了一行(哪怕没修改任何列值),它持有该行的 `RowExclusiveLock` 和对应的事务 ID。其他 session 想对同一行做 `UPDATE` 或 `DELETE` 会阻塞。更糟的是,如果这个事务曾执行过 `ALTER TABLE`(哪怕是 `ALTER TABLE t ADD COLUMN x INT DEFAULT 0`,它需要 `ACCESS EXCLUSIVE` 锁),所有后续对该表的任何 DML/DDL 都会被阻塞。

2. **阻止 VACUUM 回收死行。** VACUUM 只能回收那些"在所有活跃事务开始之前就 dead"的元组(参见[第 8 章:VACUUM 与 Freezing](../08-vacuum-freezing/08-vacuum-freezing.html))。一个长时间开着的 `idle in transaction` session 带着一个旧的 `xmin`——VACUUM 在那个 `xmin` 之后产生的 dead tuple 都不能被回收。一天不提交,一天的死 tuple 都在累积膨胀。

3. **阻止 HOT 更新。** HOT(Heap-Only Tuple)更新的前提是更新的列不在任何索引中,且更新后的 tuple 能放在同一页面。但即使满足这些条件,如果更新涉及的行被另一个事务锁定,HOT 优化也可能无法生效——从而产生额外的索引更新和膨胀。

### 配错后的症状

- `pg_stat_activity` 中出现大量 `state = 'idle in transaction'` 且 `xact_start` 时间很久的 session。
- `pg_locks` 中有大量 `granted = true` 但 `transactionid` 类型的锁——每个 `idle in transaction` session 持有一个事务 ID 锁,阻止 VACUUM 推进。
- 表膨胀加速——`pg_stat_user_tables.n_dead_tup` 持续增长,但 `last_autovacuum` 也正常在执行(说明 VACUUM 在跑,但无法回收死行)。
- DDL 操作(`ALTER TABLE`、`CREATE INDEX CONCURRENTLY`)hang 住,`pg_stat_activity.wait_event = 'Lock'`。

### 查验方法

```sql
SHOW idle_in_transaction_session_timeout;

-- 查找所有 idle in transaction 的 session 及其持锁情况
SELECT
  pid,
  usename,
  application_name,
  state,
  xact_start,
  NOW() - xact_start AS xact_duration,
  query,
  wait_event_type,
  wait_event
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND pid != pg_backend_pid()
ORDER BY xact_start;
-- 查找被 idle in transaction session 阻塞的查询
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_xact_duration
FROM pg_stat_activity blocked
JOIN pg_locks bl ON blocked.pid = bl.pid AND NOT bl.granted
JOIN pg_locks lb ON bl.locktype = lb.locktype
  AND bl.database = lb.database
  AND bl.relation = lb.relation
  AND lb.granted
JOIN pg_stat_activity blocking ON lb.pid = blocking.pid
WHERE blocked.wait_event_type = 'Lock';
```text

**正确做法**:`idle_in_transaction_session_timeout` 必须设一个非零值。典型值是 `5min`-`15min`(取决于业务中合法长事务的最大持续时间)。设置后,超时的 session 会被终止并释放其持有的所有锁和事务 ID——错误日志中会记录类似 `terminating connection due to idle-in-transaction timeout` 的信息,方便你回溯是哪个应用连接出了问题。

---

## 十、log_lock_waits + deadlock_timeout 的组合

### 这些参数在内核里控制什么

`deadlock_timeout`(默认 1s)有两个作用:

1. **死锁检测的触发间隔。** 死锁检测器(`DeadLockCheck()`,`src/backend/storage/lmgr/deadlock.c`)不是每发生一次锁等待就跑——那样太贵。它每隔 `deadlock_timeout` 被调度一次,检测当前等待图中是否有环。有环 → 选一个事务 abort。

2. **`log_lock_waits` 的报告阈值。** 当 `log_lock_waits = on`(默认 `off`)时,如果一个 session 等待某个锁超过 `deadlock_timeout`,PG 会在日志中写入一条 WARNING,记录等待信息(等待的锁类型、持锁者的 PID 等)。

关键细节:**你不是只能等满 `deadlock_timeout` 才知道死锁了。** 即使 `log_lock_waits = off`,死锁检测仍然按 `deadlock_timeout` 间隔运行——只是不会在日志中记录"非死锁"的锁等待超时。

### 为什么默认值在排查场景下是错的

`log_lock_waits = off`(默认)意味着:当一个 session 被锁住了很久,你不知道——直到用户投诉"系统卡住了"。而设了 `log_lock_waits = on` 之后,你可以在日志中看到两种关键信号:

- **锁等待超时但最终获取到锁**:说明存在临时的锁竞争——可能是某个 DDL 短暂锁住了表,或者某个 UPDATE 的事务持续时间稍长。这不是死锁,但可以帮你定位性能异常的源头。
- **锁等待超时且最终被死锁检测 abort**:说明检测到了死锁——日志中会先有锁等待超时的 WARNING,再有死锁检测的 ERROR。

`deadlock_timeout` 的默认值 1s 也值得重新评估:如果设太短(如 100ms),高并发下正常的短暂锁等待也会频繁触发日志写入,增加 I/O 开销且产生噪声。如果设太长(如 10s),死锁检测间隔太长,死锁的 session 要等 10s 才被 abort——这段时间持锁的 session 可能阻塞了一大片其他 session。

### 配错后的症状

- 用户报告"查询卡住",但你不知道是锁等待、慢 I/O 还是网络延迟——因为没有 `log_lock_waits` 日志。
- 死锁检测的 ERROR 日志出现频率高——但你需要它们之外的信息(哪个 session 持锁、持了多久)来定位根因。

### 查验方法

```sql
SHOW log_lock_waits;
SHOW deadlock_timeout;

-- 查看当前的锁等待情况
SELECT
  l.pid, l.locktype, l.mode, l.granted,
  a.usename, a.query, a.state,
  NOW() - a.query_start AS query_duration,
  a.wait_event_type, a.wait_event
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted
  AND a.pid != pg_backend_pid()
ORDER BY a.query_start;
-- 追踪锁等待链
SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocked.wait_event_type AS blocked_wait_type,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query,
  blocking.state AS blocking_state,
  NOW() - blocking.query_start AS blocking_duration
FROM pg_locks bl
JOIN pg_stat_activity blocked ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks lb ON bl.locktype = lb.locktype
  AND bl.database = lb.database
  AND bl.relation = lb.relation
  AND lb.granted
JOIN pg_stat_activity blocking ON lb.pid = blocking.pid
WHERE blocked.pid != pg_backend_pid();
```text

**正确做法**:`log_lock_waits = on`。`deadlock_timeout` 保持 1s 或适当缩短到 500ms——前提是你的日志系统能承受这个频率的写入。同时配 `log_line_prefix` 带 `%p`(PID)和 `%x`(事务 ID),以便在日志中关联锁等待事件与特定 session。

---

## 十一、log_min_duration_statement + auto_explain 的组合陷阱

### 这些参数在内核里控制什么

`log_min_duration_statement`(默认 `-1`,即禁用):执行时间超过此值(毫秒)的 SQL 会被记录到日志中。

`auto_explain`(PG 的 contrib 扩展)是一个自动 EXPLAIN 工具:在 `shared_preload_libraries` 中加载后,当查询的执行时间超过 `auto_explain.log_min_duration` 时自动记录查询的 EXPLAIN 输出。它的关键选项:

- `auto_explain.log_min_duration`:记录阈值(-1 = 禁用)
- `auto_explain.log_analyze`:是否实际执行查询并收集 `ANALYZE` 数据(`EXPLAIN ANALYZE`)
- `auto_explain.log_buffers`:是否统计 buffer 访问
- `auto_explain.log_timing`:是否记录每个节点的实际执行时间
- `auto_explain.log_level`:输出日志的级别

### 为什么组合在一起是陷阱

**陷阱 1:阈值设太低导致日志洪水。** `log_min_duration_statement = 0`(记录所有查询)和 `auto_explain.log_min_duration = 0`(为所有查询生成 EXPLAIN)——在你的本地开发环境可能无害(几百条查询/分钟),但在生产环境有 10000 QPS 时,日志写入 I/O 会吃掉存储带宽,而且多数日志是你不需要看的正常快速查询。

**陷阱 2:`auto_explain.log_analyze = on` + 低阈值的双重执行。** 注意 `auto_explain.log_analyze = on` 意味着 `auto_explain` 不仅输出查询计划——它需要**实际执行这个查询**来收集 `ANALYZE` 数据(`actual time`、`rows`、`loops`)。对于本身就是慢查询的操作,这等于**把慢查询跑了两次**——一次是用户请求的正常执行,一次是 `auto_explain` 的 ANALYZE 执行。一个本来要跑 5s 的慢查询,现在跑了两遍 5s,消耗翻倍。

**陷阱 3:`log_min_duration_statement` 和 `auto_explain.log_min_duration` 不一致。** 如果 `log_min_duration_statement = 1000`(1s)但 `auto_explain.log_min_duration = 5000`(5s),你会看到日志中有很多"慢查询"1-5s)但没有对应的 EXPLAIN 输出——因为它们跑得还不够慢,未触发 `auto_explain`。排查时你不知道这些查询为什么慢。反过来,如果 `auto_explain.log_min_duration` 小于 `log_min_duration_statement`,你会看到 EXPLAIN 输出但看不到原始查询的日志行——日志中的信息断片了。

### 配错后的症状

- PG 日志文件快速增长,`pg_current_logfile()` 的大小持续增加。
- 观察到的查询延迟远高于 `pg_stat_statements` 中记录的值——可能是因为 `auto_explain.log_analyze` 的二次执行放大了延迟。
- `pg_stat_statements` 中 `calls` 计数比预期高一倍(正常查询 + auto_explain 的 ANALYZE 执行各计数一次——虽然 PG 15+ 中 auto_explain 的 ANALYZE 不再更新 `pg_stat_statements`,但 CPU 和 IO 的双重消耗仍然存在)。

### 查验方法

```sql
-- 当前设置
SHOW log_min_duration_statement;

-- 检查 auto_explain 扩展是否加载
SELECT * FROM pg_extension WHERE extname = 'auto_explain';

-- 如果加载了,检查其设置
SELECT name, setting FROM pg_settings WHERE name LIKE 'auto_explain%';

-- 如果没有加载但需要排查,在 session 中临时加载
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 5000;  -- 5s
SET auto_explain.log_analyze = true;
SET auto_explain.log_buffers = true;
SET auto_explain.log_timing = true;
# 检查日志当前大小和增长速度
du -sh /var/log/postgresql/
```text

```sql
-- 从 pg_stat_statements 观察 overhead
SELECT query,
       calls, mean_exec_time, max_exec_time,
       ROUND(stddev_exec_time, 2) AS stddev_ms,
       shared_blks_read, shared_blks_hit
FROM pg_stat_statements
WHERE query LIKE '%auto_explain%'
ORDER BY total_exec_time DESC
LIMIT 5;

正确做法


十二、总结:配置审计的思维框架

上面 11 个 GUC 都是具体的参数,但单独记每个参数的”正确值”意义不大——同一台服务器上的 OLTP 和 OLAP 数据库的配置完全不同,今天的正确值在负载变化后可能变成明天的错误值。比记住数值更重要的是审计框架

做一次完整的 PG 配置审计,按这个顺序走:

  1. 内存安全边界检查——shared_buffers + work_mem × max_connections × 平均操作数 + maintenance_work_mem × autovacuum_max_workers 的总和,是否在物理内存容量的 60-70% 以内?/proc/meminfo + pg_buffercache 同时确认。

  2. 优化器决策验证——不只看 EXPLAIN 输出,要用 SET enable_seqscan = off 强制索引扫描对比实际执行时间。如果强制索引更快但优化器默认选了 Seq Scan,random_page_costeffective_cache_size 可能不准。

  3. 持久性语义审计——fsync 必须是 onsynchronous_commit 按 session 级别区分:金融交易写 → on,日志上报 → off。不要全局关。

  4. 维护操作效率检查——VACUUM VERBOSEindex scans 是否大于 1,pg_stat_progress_vacuummax_dead_tuples 是否太小。如果多趟索引扫描频繁,增大 maintenance_work_mem

  5. 阻塞与超时防护——idle_in_transaction_session_timeout 是否设了非零值?deadlock_timeout 是否保持在合理范围(500ms-2s)?log_lock_waits 是否 on

  6. 可观测性开销评估——log_min_duration_statement 是否设了合理的阈值(而不是 0 或 -1)?auto_explain.log_analyze 是否在不必要时保持开启?

每一步都配查验 SQL 或 shell 命令(见上面各节),而不是凭感觉改参数。PG 的配置文件中每改一个参数,都应该能回答这三个问题:你为什么认为当前值有问题?你通过什么视图/日志确认了这个问题?你改完后通过什么指标验证改善?

上一章:大版本升级与迁移实战 系列索引:PostgreSQL 内核机制深度拆解


参考资料

官方文档

源码(PG 17)

相关章节

同主题继续阅读

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

2026-06-16 · database / kernel

【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界

拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。

2026-06-16 · database / kernel

【PG 内核】大版本升级与迁移实战:pg_upgrade --link 为什么快以及为什么没有回滚

拆解 pg_upgrade 的三种模式(--link 硬链接零拷贝、--clone CoW 快照、--copy 物理复制)的执行流程、内部机制和不可回滚的根本原因;逻辑复制跨版本迁移的低停机方案及序列/large object/DDL 三大盲区;四种常见坑的根因与应对;附带迁移方案决策树,从小库到大库选哪种方案一次说清。

2026-06-16 · database / kernel

【PG 内核】Buffer Manager:为什么 shared_buffers 不是越大越好

拆解 PostgreSQL Buffer Manager 的核心机制:shared_buffers 的内部组织(BufferDescriptors 数组、Buffer Table hash table、buffer pool)、Clock sweep 替换算法的完整源码路径、buffer 四态状态机与 pin/unpin 协议、bgwriter 的触发条件与脏页写入策略、BAS_BULKREAD/BAS_VACUUM ring buffer 的缓存隔离机制。用 pg_buffercache 实验观察 buffer 分布和 clock sweep 行为,解释为什么 shared_buffers 超过 8-10GB 后回报递减——double buffering、checkpoint IO 尖峰和 clock sweep 扫描延迟的三重反噬。

2026-06-16 · database / kernel

【PG 内核】查询规划器 — 统计信息与代价模型:优化器为什么选错了索引

拆解 PostgreSQL 查询优化器的决策基础:pg_statistic 中 MCV/histogram/correlation 的存储结构、ANALYZE 的采样流程与精度边界、clauselist_selectivity 如何逐层估算选择率、seq_page_cost 等代价常量的物理意义与调优依据、CREATE STATISTICS 解决多列相关性问题、以及统计信息漂移的诊断 SQL 与排查路径。读完你能回答:优化器为什么选 Seq Scan 而不是你建的索引,以及怎么定位根因。


By .