
2024 年第三季度,我们的 AI/ML 特征存储服务撞上了性能天花板。训练吞吐量才 12k features/sec,p99 读延迟就飙到了 2.8 秒,PostgreSQL 16 在单节点 RDS 上愣是榨不出更多 IOPS 了——42k IOPS 封顶。留给我们的时间只有 6 周,要么把 IOPS 折腾到 10 万,要么每年多掏 120 万美金的インフラ预算。说实话,这道选择题一点都不难做。
我们的 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 为现代 NVMe 存储重写了并行 I/O 子系统。关键改进有几个:
但是——这里有个坑——这些改进的前提是你得把 effective_io_concurrency 和 max_parallel_workers 调到跟硬件匹配。我们那台 16 vCPU、64GB 内存的 NVMe 机器,需要把 max_parallel_workers 设为 32(vCPU 数的两倍),effective_io_concurrency 设为 200(跟 NVMe 队列深度对齐),才能拿到官方宣称的 2.3 倍吞吐提升。不调参?白瞎了新版本。
光升级到 PostgreSQL 17 当然不够——单节点 IOPS 再怎么优化也有上限。真正的破局之道是读写分离,把读流量分散到多个只读副本上。我们的方案是 1 个主节点 + 4 个只读副本,82% 的特征存储读请求走副本,只有写请求和需要强一致性的读走主节点。
这个架构听起来简单,实际上有三个大坑:
第一个坑我们用 pg_read_replica_sync 填上了。这个工具能把跨 region 副本的复制延迟压到 8ms 以内,95% 的写入都能在这个时间内同步。第二个坑用 PgBouncer 做连接池 + 简单轮询路由。第三个坑还没完全解决,目前靠手动切换,等 Terraform 写完自动化脚本再说。
光调参数不够,得用真实负载跑压测才知道效果。我们用 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 两个数字。
压测归压测,生产环境得有实时监控才行。我们搭了一套监控栈:
-- 监控副本复制延迟
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 的固定项,用的时候随手就能拉出来。
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:应用层重试逻辑要完善
读写分离之后,写请求打到副本上会报错。这个错误应用层必须能捕获并重试到主节点。我们有个服务忘了加这个逻辑,结果升级后第一天就挂了一小会儿。
目前架构基本稳定了,但还有一些改进计划:
总体来说,这次升级是一次非常值得的折腾。PostgreSQL 17 的并行 I/O + 只读副本池的组合拳,解决了我们 AI/ML 特征存储的燃眉之急,也验证了"PostgreSQL 本身就能撑起高 IOPS 场景"这个判断。如果你也在被 PostgreSQL 单节点性能困扰,不妨先跑个 benchmark 看看瓶颈在哪里——说不定升级 + 副本就能药到病除。