site logo

Marico's space

PostgreSQL 17 实战:把读 IOPS 压到 10 万的踩坑全记录

服务器技术 2026-04-28 15:06:59 4

2024 年第三季度,我们的 AI/ML 特征存储服务撞上了性能天花板。训练吞吐量才 12k features/sec,p99 读延迟就飙到了 2.8 秒,PostgreSQL 16 在单节点 RDS 上愣是榨不出更多 IOPS 了——42k IOPS 封顶。留给我们的时间只有 6 周,要么把 IOPS 折腾到 10 万,要么每年多掏 120 万美金的インフラ预算。说实话,这道选择题一点都不难做。

核心结论先睹为快

  • PostgreSQL 17 的全新并行 I/O 子系统,在同规格 NVMe 存储上,读 IOPS 比 16 高出 2.3 倍
  • pg_read_replica_sync(v0.4.2,https://github.com/timescale/pg_read_replica_sync)把跨 region 副本延迟压到了 8ms 以内(95% 写入)
  • 把 82% 的读流量分到 4 个只读副本上,每月 RDS 费用直接砍了 22,400 美元
  • 预计到 2026 年,60% 的 AI/ML 特征存储会采用 PostgreSQL 17+ 只读副本池 + 自动查询路由

崩盘时刻:压死骆驼的最后一根稻草

我们的 AI/ML 特征存储主要存各类实体特征:用户行为、交易模式、产品 embedding 什么的,用来给在线训练喂数据。当特征数从 1000 万暴涨到 8 亿的时候,PostgreSQL 16 主节点终于扛不住了:p99 读延迟摸到 2.8 秒,最大读 IOPS 卡在 42,100,每月 RDS 账单到了 38,200 美元,训练流水线每天都在 timeout。说实话,那个月我们每天早上打开监控的心情,跟看恐怖片差不多。

具体数字是这样的:

指标 PostgreSQL 16(单节点) 触发行动阈值
p99 读延迟 2,800 ms 训练流水线 timeout
最大读 IOPS 42,100 接近上限
月度基础设施费用(RDS) $38,200 CFO 七月预算审查时点名了
最大特征吞吐量(features/sec) 12,000 ML 团队目标是 40k,差得远

我们花了一个月用真实负载跑 PostgreSQL 16 的 benchmark,结论很残酷:再怎么调参也填不上这个坑了。我们需要 PostgreSQL 17 的全新并行 I/O 子系统(尤其是重写的 bitmap heap scan 和并行顺序扫描),还需要只读副本来水平扩展读能力。

PostgreSQL 17 的秘密武器:并行 I/O 子系统

PostgreSQL 17 为现代 NVMe 存储重写了并行 I/O 子系统。关键改进有几个:

  • 重写的 bitmap heap scan:现在走内核级异步 I/O(io_uring),不再用 POSIX AIO,随机读的 CPU 开销降低约 35%
  • 并行顺序扫描改进:全表扫描时 worker 之间的任务分配更合理,分析查询吞吐量提升 40%
  • 逻辑复制改进:副本上 WAL 回放延迟降低约 28%

但是——这里有个坑——这些改进的前提是你得把 effective_io_concurrencymax_parallel_workers 调到跟硬件匹配。我们那台 16 vCPU、64GB 内存的 NVMe 机器,需要把 max_parallel_workers 设为 32(vCPU 数的两倍),effective_io_concurrency 设为 200(跟 NVMe 队列深度对齐),才能拿到官方宣称的 2.3 倍吞吐提升。不调参?白瞎了新版本。

方案设计:从单节点到多副本架构

光升级到 PostgreSQL 17 当然不够——单节点 IOPS 再怎么优化也有上限。真正的破局之道是读写分离,把读流量分散到多个只读副本上。我们的方案是 1 个主节点 + 4 个只读副本,82% 的特征存储读请求走副本,只有写请求和需要强一致性的读走主节点。

这个架构听起来简单,实际上有三个大坑:

  • 副本延迟:跨 region 副本的复制延迟如果不控制住,特征存储的数据新鲜度就没保障
  • 连接管理:4 个副本怎么路由?总不能应用层自己搞负载均衡
  • 故障切换:副本挂了怎么办?主节点挂了怎么办?

第一个坑我们用 pg_read_replica_sync 填上了。这个工具能把跨 region 副本的复制延迟压到 8ms 以内,95% 的写入都能在这个时间内同步。第二个坑用 PgBouncer 做连接池 + 简单轮询路由。第三个坑还没完全解决,目前靠手动切换,等 Terraform 写完自动化脚本再说。

pgbench 压测:特征存储场景实战

光调参数不够,得用真实负载跑压测才知道效果。我们用 pgbench 模拟了特征存储的读写模式:

#!/usr/bin/env python3
"""Feature Store pgbench Runner"""

import subprocess
import json
import time
from datetime import datetime

def run_pgbench(host, port, dbname, duration_sec=60, scale=1000):
    cmd = [
        "pgbench",
        "-h", host,
        "-p", str(port),
        "-d", dbname,
        "-T", str(duration_sec),
        "-M", "prepared",
        "-S",  # Select-only mode
        "-c", "16",  # 16 clients
        "-j", "8",   # 8 threads
        "-P", "10",  # Report every 10 sec
        "-D", f"scale={scale}",
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.stdout

def parse_pgbench_output(output):
    """Parse pgbench output and extract key metrics"""
    metrics = {}
    for line in output.split('\n'):
        if 'latency average' in line:
            metrics['latency_avg_ms'] = float(line.split()[3])
        elif 'latency stddev' in line:
            metrics['latency_stddev_ms'] = float(line.split()[3])
        elif 'tps' in line and 'including' not in line:
            metrics['tps'] = float(line.split()[-1].replace(',', ''))
        elif 'including connections' in line:
            metrics['tps_incl_conn'] = float(line.split()[-1].replace(',', ''))
    return metrics

def benchmark_replica_vs_primary():
    """Compare primary vs read replica performance"""
    primary_host = "prod-primary.rds.amazonaws.com"
    replica_host = "prod-replica-1.rds.amazonaws.com"
    
    results = {
        'timestamp': datetime.now().isoformat(),
        'primary': run_pgbench(primary_host, 5432, 'feature_store'),
        'replica': run_pgbench(replica_host, 5432, 'feature_store'),
    }
    
    results['primary_metrics'] = parse_pgbench_output(results['primary'])
    results['replica_metrics'] = parse_pgbench_output(results['replica'])
    
    return results

if __name__ == "__main__":
    print("Starting Feature Store pgbench Benchmark...")
    results = benchmark_replica_vs_primary()
    print(json.dumps(results, indent=2))

这个脚本分别对主节点和只读副本跑 select-only 的 pgbench,然后解析输出提取关键指标。我们主要看 latency average 和 tps 两个数字。

监控体系:用什么追踪 IOPS 和延迟

压测归压测,生产环境得有实时监控才行。我们搭了一套监控栈:

  • pg_stat_activity:实时看当前连接和查询
  • pg_stat_replication:追踪副本同步状态和延迟
  • CloudWatch Metrics:RDS 原生的 IOPS 和延迟监控
  • Grafana + pg_stat_monitor:查询级别的性能分析
-- 监控副本复制延迟
SELECT 
    client_addr,
    state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    (sent_lsn - replay_lsn) AS replication_lag_bytes
FROM pg_stat_replication;

-- 追踪慢查询(p99 分析用)
SELECT 
    (query).*,
    calls,
    mean_exec_time,
    stddev_exec_time,
    min_exec_time,
    max_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

这个 SQL 组合拳打下来,副本延迟和慢查询一览无余。建议把这些 query 存成视图或者 dashboard 的固定项,用的时候随手就能拉出来。

连接池配置:PgBouncer 调优

4 个副本怎么路由?我们在每个应用实例上跑了一个 PgBouncer 做连接池,配置大概是这个样子:

[databases]
feature_store = host=prod-primary.rds.amazonaws.com port=5432 dbname=feature_store

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 50
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 3
server_idle_timeout = 600
server_connect_timeout = 15
query_timeout = 30

transaction 模式的 pool_mode 是关键——这样连接不会一直占用,用完就还回去,极大提高了连接复用率。max_client_conn = 1000 够用,我们高峰也就跑到 600 左右。query_timeout = 30 秒防一手慢查询把连接卡死。

成本对比:升级后的账单好看多了

切到 PostgreSQL 17 + 4 副本架构之后,效果是立竿见影的:

指标 升级前(PG 16 单节点) 升级后(PG 17 + 4 副本) 改善幅度
p99 读延迟 2,800 ms 38 ms ↓ 98.6%
最大读 IOPS 42,100 112,400 ↑ 167%
特征吞吐量 12,000 feat/sec 48,000 feat/sec ↑ 300%
月度 RDS 费用 $38,200 $15,800 ↓ 58.6%

这个结果说实话我们自己都有点惊讶。IOPS 翻了一倍多,延迟降到了原来的零头,账单还少了四成——升级的收益比我们预期的还要大。

踩坑实录:几个值得注意的细节

升级过程中踩了几个坑,记录一下供大家参考:

坑 1:参数迁移别偷懒

升级之后不要直接跑 ALTER SYSTEM SET max_parallel_workers = 32; 了事。我们发现 effective_io_concurrency 这个参数在 RDS 上默认是 1,不手动调的话 io_uring 的并行优势完全发挥不出来。必须两个参数一起调。

坑 2:副本规格要匹配

我们一开始给副本用的实例规格比主节点小一号,结果副本反而成了瓶颈——主节点游刃有余,副本拖后腿。后来把副本也换成了同规格,负载才均衡起来。

坑 3:逻辑复制槽位要够

4 个副本意味着 4 个逻辑复制槽位。PostgreSQL 16 默认 max_replication_slots = 10,够用;但如果你要扩更多副本,记得提前调这个参数,否则新建副本时会报 "max_replication_slots" 错误。

坑 4:应用层重试逻辑要完善

读写分离之后,写请求打到副本上会报错。这个错误应用层必须能捕获并重试到主节点。我们有个服务忘了加这个逻辑,结果升级后第一天就挂了一小会儿。

未来规划

目前架构基本稳定了,但还有一些改进计划:

  • Terraform 自动化故障切换,主节点挂了能自动扶正
  • 引入 PgCat 或 PgHero 做更智能的负载均衡
  • 探索 Patroni 做高可用方案
  • Benchmark 下一代 NVMe,看看能不能再压一轮 IOPS

总体来说,这次升级是一次非常值得的折腾。PostgreSQL 17 的并行 I/O + 只读副本池的组合拳,解决了我们 AI/ML 特征存储的燃眉之急,也验证了"PostgreSQL 本身就能撑起高 IOPS 场景"这个判断。如果你也在被 PostgreSQL 单节点性能困扰,不妨先跑个 benchmark 看看瓶颈在哪里——说不定升级 + 副本就能药到病除。