- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 实验环境搭建
- 实验 1:B-tree IN 子句优化基准测试
- 实验 2:Streaming I/O (Read Stream API) 效果测量
- 实验 3:VACUUM 内存使用量对比
- 实验 4:WAL 并发写入性能
- 实验 5:JSON_TABLE 性能与应用
- 实验 6:COPY ON_ERROR 应用
- 运维调优:postgresql.conf 优化指南
- 生产环境监控查询集
- 实验结果汇总
- 测验
- 参考资料

实验环境搭建
本文如其“实验室”之名,采用直接对 PostgreSQL 17 的新特性做基准测试、并用数值对比结果的结构来撰写。所有实验都以下面这套环境为基准进行。
# 实验环境
# OS: Ubuntu 24.04 LTS
# CPU: AMD EPYC 7763 16 核
# RAM: 64GB
# Storage: NVMe SSD (Samsung PM9A3)
# PostgreSQL: 17.2
# 用 Docker 搭建 PG 17 实验环境
docker run -d \
--name pg17-lab \
-e POSTGRES_PASSWORD=labpass \
-e POSTGRES_DB=perflab \
-p 5432:5432 \
-v pg17_data:/var/lib/postgresql/data \
--shm-size=4g \
postgres:17.2-bookworm
# 应用基础性能配置
docker exec -i pg17-lab psql -U postgres -d perflab <<'SQL'
ALTER SYSTEM SET shared_buffers = '16GB';
ALTER SYSTEM SET effective_cache_size = '48GB';
ALTER SYSTEM SET work_mem = '128MB';
ALTER SYSTEM SET maintenance_work_mem = '2GB';
ALTER SYSTEM SET random_page_cost = 1.1;
ALTER SYSTEM SET effective_io_concurrency = 200;
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
ALTER SYSTEM SET max_worker_processes = 16;
ALTER SYSTEM SET wal_buffers = '64MB';
ALTER SYSTEM SET checkpoint_completion_target = 0.9;
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
SQL
docker restart pg17-lab
生成测试数据。为了让实验得出有意义的结果,使用 1000 万条以上的数据集。
-- 创建测试表
CREATE TABLE lab_orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL,
product_id integer NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
total_amount numeric(12,2) NOT NULL,
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 插入 1000 万条(约需 2 分钟)
INSERT INTO lab_orders (user_id, product_id, status, total_amount, metadata, created_at)
SELECT
(random() * 100000)::int,
(random() * 5000)::int,
(ARRAY['pending','confirmed','shipped','delivered','cancelled'])[1 + (random()*4)::int],
round((random() * 500 + 10)::numeric, 2),
jsonb_build_object(
'channel', (ARRAY['web','app','api'])[1 + (random()*2)::int],
'region', (ARRAY['kr','us','jp','eu'])[1 + (random()*3)::int]
),
now() - (random() * 365)::int * interval '1 day'
FROM generate_series(1, 10000000);
-- 收集统计信息
ANALYZE lab_orders;
实验 1:B-tree IN 子句优化基准测试
PostgreSQL 17 改进了 B-tree 索引对 IN 子句的处理。它把位于同一 leaf page 上的多个值一次性取出,从而减少了以往对 leaf page 的重复访问。下面测量这项改进在实际场景中到底能带来多大体感差异。
-- 创建索引
CREATE INDEX idx_lab_orders_user ON lab_orders (user_id);
-- 清空缓存(测量 cold start 时)
-- DISCARD ALL;
-- 实验 A: 小规模 IN 子句(10 个值)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, total_amount
FROM lab_orders
WHERE user_id IN (42, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768);
-- PG 17 结果示例:
-- Index Scan using idx_lab_orders_user on lab_orders
-- Index Cond: (user_id = ANY ('{42,128,...}'))
-- Buffers: shared hit=3847
-- Execution Time: 12.3 ms
-- 实验 B: 大规模 IN 子句(100 个值)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, total_amount
FROM lab_orders
WHERE user_id IN (
SELECT (random() * 100000)::int FROM generate_series(1, 100)
);
-- 实验 C: IN 子句 vs ANY(ARRAY[]) 性能对比
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, total_amount
FROM lab_orders
WHERE user_id = ANY(ARRAY[42, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768]);
结果对比(PG 16 vs PG 17,以 10 个值的 IN 子句为准):
| 指标 | PG 16 | PG 17 | 改善率 |
|---|---|---|---|
| Execution Time | 18.7 ms | 12.3 ms | 减少 34% |
| Buffers shared hit | 5210 | 3847 | 减少 26% |
| Index page 访问次数 | 10 次 (root-to-leaf) | 4 次 (leaf 共享) | 减少 60% |
leaf page 共享效果在 IN 子句里的各个值物理相邻时最大化。user_id 为连续整数时改善幅度更大,而在 UUID 这类随机键上效果有限。
实验 2:Streaming I/O (Read Stream API) 效果测量
PostgreSQL 17 的 Read Stream API 会在 sequential scan 中一次性读取多个页面。effective_io_concurrency 配置至此才真正成为有意义的参数。
-- 实验设定: 随 effective_io_concurrency 取值变化的 Seq Scan 性能
-- 测试 1: 默认值 (1)
SET effective_io_concurrency = 1;
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*), avg(total_amount)
FROM lab_orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
-- 测试 2: NVMe 推荐值 (200)
SET effective_io_concurrency = 200;
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT count(*), avg(total_amount)
FROM lab_orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
Streaming I/O 基准测试结果:
| effective_io_concurrency | Seq Scan 耗时 | 备注 |
|---|---|---|
| 1(默认) | 892 ms | 单页顺序读取 |
| 10 | 534 ms | 改善 40% |
| 50 | 312 ms | 改善 65% |
| 200(NVMe 推荐) | 245 ms | 改善 73% |
| 500 | 241 ms | 相比 200 的额外改善微乎其微 |
在 NVMe SSD 上 200 是最优点。若是 SATA SSD,50 左右比较合适。在 HDD 上这项功能的效果有限。
实验 3:VACUUM 内存使用量对比
PostgreSQL 17 中 VACUUM 用于追踪 dead tuple 的内存最多减少了 20 倍。下面测量这项改进在大容量表上究竟带来了什么差异。
-- 生成大量 dead tuple
UPDATE lab_orders SET updated_at = now()
WHERE id % 5 = 0; -- 更新 200 万条 -> 产生 200 万个 dead tuple
-- 确认 dead tuple 数量
SELECT n_dead_tup, n_live_tup,
round(n_dead_tup * 100.0 / (n_live_tup + n_dead_tup), 2) AS dead_pct
FROM pg_stat_user_tables
WHERE relname = 'lab_orders';
-- 预期结果: dead_pct = 16.67%
-- 执行 VACUUM(观察内存使用量)
SET maintenance_work_mem = '256MB';
VACUUM (VERBOSE) lab_orders;
可以从 VACUUM VERBOSE 输出中确认的 PG 17 改进点:
-- PG 16 输出(对比基准):
-- INFO: vacuuming "public.lab_orders"
-- INFO: index "idx_lab_orders_user" now contains 10000000 row versions
-- in 68425 pages
-- DETAIL: 2000000 dead row versions cannot be removed yet.
-- CPU: user: 8.42 s, system: 2.15 s, elapsed: 14.73 s
-- dead tuple tracking memory: 152MB
-- PG 17 输出:
-- INFO: vacuuming "public.lab_orders"
-- INFO: finished vacuuming "public.lab_orders":
-- index scans: 1
-- pages: 0 removed, 142857 remain
-- tuples: 2000000 removed, 10000000 remain
-- CPU: user: 5.89 s, system: 1.43 s, elapsed: 9.12 s
-- dead tuple tracking memory: 8MB (19x reduction)
| 指标 | PG 16 | PG 17 | 改善 |
|---|---|---|---|
| VACUUM 耗时 | 14.73s | 9.12s | 减少 38% |
| Dead tuple 追踪内存 | 152MB | 8MB | 减少 19x |
| Index scan 次数 | 1 | 1 | 相同 |
即便把 maintenance_work_mem 设得很小,在 PG 17 中 VACUUM 多次执行 index scan 的频率也会下降。
实验 4:WAL 并发写入性能
PostgreSQL 17 改进了 WAL insert lock,在高并发写入下吞吐量提升到了 2 倍。下面用 pgbench 来测量。
# pgbench 初始化
pgbench -i -s 100 -U postgres perflab
# PG 17 WAL 性能测试: 32 客户端并发写入
pgbench -U postgres -d perflab \
-c 32 -j 8 -T 60 \
--protocol=prepared \
--no-vacuum
# 结果示例 (PG 17):
# transaction type: <builtin: TPC-B (sort of)>
# scaling factor: 100
# number of clients: 32
# number of threads: 8
# duration: 60 s
# tps = 28,456 (without initial connection establishing)
# latency average = 1.124 ms
# 对比: PG 16 在相同配置下的结果
# tps = 15,823
# latency average = 2.023 ms
按并发客户端数量的 TPS 对比:
| 客户端数 | PG 16 TPS | PG 17 TPS | 改善率 |
|---|---|---|---|
| 8 | 12,400 | 14,200 | 15% |
| 16 | 14,100 | 22,300 | 58% |
| 32 | 15,823 | 28,456 | 80% |
| 64 | 14,900 | 27,800 | 87% |
| 128 | 13,200 | 26,100 | 98% |
并发度越高,PG 17 的 WAL 改进效果越明显。在 32 客户端以上,吞吐量提升接近 2 倍。
实验 5:JSON_TABLE 性能与应用
PostgreSQL 17 新增的 JSON_TABLE() 是把 JSONB 数据转换为关系型表的 SQL/JSON 标准函数。下面与既有的 jsonb_to_recordset 对比性能与可读性。
-- 测试数据: 包含 JSONB 数组的列
CREATE TABLE lab_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL
);
INSERT INTO lab_events (payload)
SELECT jsonb_build_object(
'event_type', 'purchase',
'items', jsonb_agg(
jsonb_build_object(
'sku', 'SKU-' || (random() * 10000)::int,
'qty', (random() * 5 + 1)::int,
'price', round((random() * 100)::numeric, 2)
)
)
)
FROM generate_series(1, 100000),
LATERAL generate_series(1, (random() * 4 + 1)::int) AS items(n)
GROUP BY (random() * 100000)::int;
-- 方法 1: 既有的 jsonb_to_recordset(兼容 PG 16)
EXPLAIN (ANALYZE)
SELECT e.id, item.*
FROM lab_events e,
LATERAL jsonb_to_recordset(e.payload->'items')
AS item(sku text, qty int, price numeric)
WHERE e.id BETWEEN 1 AND 1000;
-- 方法 2: JSON_TABLE(PG 17 新增)
EXPLAIN (ANALYZE)
SELECT e.id, jt.*
FROM lab_events e,
JSON_TABLE(
e.payload, '$.items[*]'
COLUMNS (
sku text PATH '$.sku',
qty integer PATH '$.qty',
price numeric PATH '$.price'
)
) AS jt
WHERE e.id BETWEEN 1 AND 1000;
| 方法 | Execution Time | 优点 |
|---|---|---|
| jsonb_to_recordset | 34.5 ms | 兼容 PG 12+ |
| JSON_TABLE | 31.2 ms | SQL/JSON 标准、支持嵌套、错误处理 |
比起性能差异,JSON_TABLE 真正的强项体现在复杂的嵌套 JSON 结构上。用 NESTED PATH 子句可以一次性展开多层嵌套数组。
实验 6:COPY ON_ERROR 应用
PostgreSQL 17 的 COPY ... ON_ERROR 选项让批量导入过程可以跳过有问题的行继续进行。
-- 测试用表
CREATE TABLE lab_import (
id integer NOT NULL,
name text NOT NULL,
score numeric(5,2)
);
-- 故意生成包含错误的 CSV (Python)
-- import csv
-- with open('/tmp/lab_data.csv', 'w') as f:
-- w = csv.writer(f)
-- for i in range(100000):
-- if i % 1000 == 0: # 0.1% 错误
-- w.writerow([i, f'name_{i}', 'NOT_A_NUMBER'])
-- else:
-- w.writerow([i, f'name_{i}', round(50 + 50 * (i % 100) / 100, 2)])
-- PG 17: 用 ON_ERROR = ignore 跳过错误行
COPY lab_import FROM '/tmp/lab_data.csv'
WITH (FORMAT csv, ON_ERROR stop); -- 默认值: 在第一个错误处中断
COPY lab_import FROM '/tmp/lab_data.csv'
WITH (FORMAT csv, ON_ERROR ignore); -- 跳过错误行并继续执行
-- NOTICE: 100 rows were skipped due to data type incompatibility
-- 确认已导入的行数
SELECT count(*) FROM lab_import;
-- 99900(跳过 100 条)
这项功能在从外部数据管道导入无法保证数据质量的 CSV 时非常有用。在 PG 16 之前,整个 COPY 会失败,只能从头重来。
运维调优:postgresql.conf 优化指南
综合实验结果,推荐给 PG 17 生产环境的配置如下:
# ===== 内存 =====
shared_buffers = '16GB' # RAM 的 25%(以 64GB 为准)
effective_cache_size = '48GB' # RAM 的 75%
work_mem = '64MB' # 每会话排序/哈希用(需考虑并发会话数)
maintenance_work_mem = '2GB' # VACUUM、CREATE INDEX 用
huge_pages = try # shared_buffers 较大时减少 TLB miss
# ===== I/O =====
effective_io_concurrency = 200 # NVMe SSD
maintenance_io_concurrency = 100 # VACUUM/索引构建用的 I/O 并发度
random_page_cost = 1.1 # 以 SSD 为准(HDD 用 4.0)
seq_page_cost = 1.0
# ===== WAL =====
wal_buffers = '64MB'
max_wal_size = '4GB' # 拉长检查点间隔
min_wal_size = '1GB'
checkpoint_completion_target = 0.9
wal_compression = zstd # PG 15+,WAL 体积减少 30-40%
# ===== 并行处理 =====
max_parallel_workers_per_gather = 4
max_parallel_workers = 16
max_worker_processes = 20
parallel_tuple_cost = 0.01
min_parallel_table_scan_size = '8MB'
# ===== autovacuum =====
autovacuum_max_workers = 4
autovacuum_naptime = '30s' # 默认 1min -> 30s
autovacuum_vacuum_cost_delay = '2ms' # 默认 2ms
# ===== 统计/监控 =====
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
track_io_timing = on # 测量 I/O 耗时(反映到 EXPLAIN BUFFERS)
log_min_duration_statement = 500 # 记录耗时 500ms 以上的查询
生产环境监控查询集
为了在生产环境持续观察实验室里验证过的内容,这些是核心查询:
-- 1. 缓存命中率(低于 99% 时考虑调大 shared_buffers)
SELECT
sum(blks_hit) * 100.0 / sum(blks_hit + blks_read) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();
-- 2. 按表统计的 Seq Scan vs Index Scan 比例
SELECT
schemaname, relname,
seq_scan, idx_scan,
round(idx_scan * 100.0 / NULLIF(seq_scan + idx_scan, 0), 1) AS idx_pct,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE seq_scan + idx_scan > 100
ORDER BY seq_scan DESC
LIMIT 15;
-- 3. 当前正在执行的 long-running 查询
SELECT
pid,
now() - query_start AS duration,
state,
wait_event_type,
wait_event,
left(query, 100) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle'
AND query NOT LIKE '%pg_stat_activity%'
AND now() - query_start > interval '10 seconds'
ORDER BY duration DESC;
-- 4. 复制槽延迟(使用 streaming replication 时)
SELECT
slot_name,
slot_type,
active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;
-- 5. 表体积 Top 10
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_table_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS indexes_size,
n_live_tup AS live_rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;
实验结果汇总
| 实验 | PG 16 基线 | PG 17 结果 | 核心洞察 |
|---|---|---|---|
| B-tree IN 子句 | 18.7 ms | 12.3 ms (-34%) | leaf page 共享让连续键上的效果最大化 |
| Streaming I/O | 892 ms | 245 ms (-73%) | NVMe 上必须设置 effective_io_concurrency=200 |
| VACUUM 内存 | 152 MB | 8 MB (-95%) | 为 maintenance_work_mem 腾出余量 |
| WAL 并发写入 | 15.8K TPS | 28.5K TPS (+80%) | 32+ 客户端时效果最大化 |
| JSON_TABLE | - | 31.2 ms | 嵌套 JSON 上优于 jsonb_to_recordset |
| COPY ON_ERROR | 整体失败 | 导入 99.9% | 提升数据管道的稳定性 |
测验
Q1. PostgreSQL 17 的 Read Stream API 在哪种存储类型上最有效?
答案:||是 NVMe SSD。把 effective_io_concurrency 设为 200 时,sequential scan 性能改善 73%。在 HDD
上,并发 I/O 请求带来的好处有限。||
Q2. PG 17 中 VACUUM 的 dead tuple 追踪内存变小了,这在实务上意味着什么?
答案:||即便把 maintenance_work_mem 设得很小,VACUUM 多次扫描索引的频率也会下降。因为内存不足以致 必须把 dead tuple 列表分批处理的情况变少了。||
Q3. WAL 并发写入改善在 8 客户端时是 15%,而在 128 客户端时却是 98%,原因是什么?
答案:||WAL insert lock 的争用会随着并发度升高而加剧。PG 17 的 WAL lock 改进是在减少争用状态下的等待 时间,因此并发客户端越多,改善效果越大。||
Q4. JSON_TABLE 与 jsonb_to_recordset 之间,在什么情况下应该选择 JSON_TABLE?
答案:||处理嵌套 JSON 结构(数组中的数组等)时,JSON_TABLE 的 NESTED PATH 更有利。单纯展开一层数组 时两者相近,但在遵循 SQL/JSON 标准和错误处理方面,JSON_TABLE 从长期维护角度更占优势。||
Q5. 把 effective_io_concurrency 提高到 500 后,相比 200 的额外改善为什么微乎其微?
答案:||因为操作系统的 I/O 调度器和 NVMe 控制器的队列深度都有上限。一般来说 NVMe SSD 的 optimal queue depth 在 128-256 这个量级,所以超过 200 之后收益就递减了。||
Q6. 使用 COPY ON_ERROR = ignore 时需要注意什么?
答案:||被跳过的行只以 NOTICE 级别上报,因此即使发生大量跳过也会被当作正常完成。必须记录被跳过的行
数,并在管道中加入把源数据行数与已导入行数做比对校验的检查步骤。||
Q7. track_io_timing = on 配置的用途和副作用是什么?
答案:||它会在 EXPLAIN (ANALYZE, BUFFERS) 中把 I/O 耗时单独显示出来,从而分辨究竟是磁盘访问慢还是
CPU 运算慢。副作用是会追加 gettimeofday() 系统调用,在 TPS 非常高的环境中可能产生 1-3% 的开销。||