Skip to content
Published on

PostgreSQL 17 性能实验室:查询、索引与运维调优

分享
Authors
PostgreSQL 17 性能实验室:查询、索引与运维调优

实验环境搭建

本文如其“实验室”之名,采用直接对 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 16PG 17改善率
Execution Time18.7 ms12.3 ms减少 34%
Buffers shared hit52103847减少 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_concurrencySeq Scan 耗时备注
1(默认)892 ms单页顺序读取
10534 ms改善 40%
50312 ms改善 65%
200(NVMe 推荐)245 ms改善 73%
500241 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 16PG 17改善
VACUUM 耗时14.73s9.12s减少 38%
Dead tuple 追踪内存152MB8MB减少 19x
Index scan 次数11相同

即便把 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 TPSPG 17 TPS改善率
812,40014,20015%
1614,10022,30058%
3215,82328,45680%
6414,90027,80087%
12813,20026,10098%

并发度越高,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_recordset34.5 ms兼容 PG 12+
JSON_TABLE31.2 msSQL/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 ms12.3 ms (-34%)leaf page 共享让连续键上的效果最大化
Streaming I/O892 ms245 ms (-73%)NVMe 上必须设置 effective_io_concurrency=200
VACUUM 内存152 MB8 MB (-95%)为 maintenance_work_mem 腾出余量
WAL 并发写入15.8K TPS28.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% 的开销。||

参考资料