先把结论摆出来
2023 年 4 月,我们一个订单查询服务在压测里卡在 1180 QPS 上不去,P99 210ms。当时我第一反应跟大多数人一样:连接池太小了。HikariCP 默认 maximumPoolSize 就是 10,我把它改成了 200,两行配置的事。
改完上线,QPS 掉到 742,P99 飙到 1.24 秒。
后来我才想明白,连接池该开多大,不该由“上游有多少并发”决定,而该由“下游每秒能干净利落地处理完多少个请求”倒推。这两个数在慢 SQL 面前能差一个数量级。
环境交代一下:4 vCPU / 8G 容器,PostgreSQL 13.4 单实例,Spring Boot 2.5 + HikariCP 4.0.3,Tomcat maxThreads=200,压测用 wrk2,每轮跑 5 分钟取中位数。
| 配置 | QPS | 平均 RT | P99 | PG 侧 load average |
|---|---|---|---|---|
| pool=10 | 1180 | 8.4ms | 210ms | 3.1 |
| pool=200 | 742 | 53ms | 1240ms | 19.6 |
| pool=16 | 1460 | 5.6ms | 88ms | 2.4 |
pool=200 那一轮,vmstat 1 里的 cs(上下文切换)从 12k/s 涨到 87k/s。我们 PG 的 max_connections 设的是 300,200 个连接没打满,但 4 个核要去轮转 200 个活跃会话,每次轮转都得换寄存器、换 L1/L2 缓存现场,CPU 有相当一部分时间花在切换本身,而不是执行 SQL。
我踩的三个坑,现在想想都挺蠢的
第一个坑是信了“连接数跟着并发线程数走”。Tomcat 的 maxThreads 是 200,我就顺手把 pool 设成了 200,觉得这样线程不会被阻塞。实际上线程不阻塞了,数据库被压死了,请求只是换了个地方排队。
第二个坑是压测库的数据量不对。生产 order 表 1.4 亿行,压测库我图省事只灌了 200 万行。那条 SQL 是运营后台用的 ORDER BY id DESC LIMIT 20 OFFSET 80000,生产上 PG 得先扫 8 万行再全扔掉,单次 260ms;压测库同一条只花 12ms。等于我在一个不存在的问题上,调了三天参数。
第三个坑最阴:HikariCP 的 connectionTimeout 默认 30 秒。池子加到 200 之后,超时请求不是直接报错,而是先在连接等待队列里挂 30 秒再抛异常,上游 Nginx 60 秒才断。结果表现在监控上就是“变慢”,不是“报错”——慢比错难查十倍。
真正该看的三个东西
如果你是第一次做这类调优,我建议按这个顺序来,别跳步:
SELECT query, calls, total_time, mean_time, rows FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20;先看谁在吃时间。当时排第一的就是那条 OFFSET 深分页,total_time占 DB 总耗时的 41%,calls只有 3 万多次——典型的“调用少、单次贵”。- 把它改成 keyset 分页:
WHERE id < :last_id ORDER BY id DESC LIMIT 20,配(id)主键索引,单次从 260ms 降到 0.7ms。 - 再回头调池子。SQL 打下去之后,10 个连接就能撑住原来的 1180 QPS,P99 还掉到 140ms。
所以表格里 pool=16 那 24% 的提升,我说实话也不全是池子的功劳——那一轮我们顺手把 minIdle 从 10 提到 16,JDBC 侧开了 prepareThreshold=3 缓存 prepared statement。单拎池子大小出来,16 和 10 的差距在 3% 以内,压测噪声都比这大。
我知道这句写出来挺扫兴的。但这就是那次调优里唯一真实的部分:池子大小几乎从来不是瓶颈,它只是把瓶颈的形状换了个样子。
现在的默认动作
- 池子上限先按「下游核数 × 2」试水,但一定要把
connectionTimeout从 30s 改成 1000ms。拿不到连接就立刻失败,别让请求在队列里熬着。快速失败能查到东西,慢速成功什么也查不到。 maxLifetime设成比中间件的空闲回收阈值小 30 秒。PG 自己没有 wait_timeout,但 PgBouncer、云厂商代理都有,不设的话你会看到凌晨三点莫名其妙的一批Connection reset。- 压测库数据量至少灌到生产的 30%,跑完必须
ANALYZE。索引统计信息不新鲜,执行计划就是假的,你调的是压测库的参数,不是生产的。 - 别在事务里发 HTTP 请求。这条跟池子没关系,但它造成的连接占用时间,比任何参数都致命。
最后留个尾巴:那个服务后来接了 PgBouncer,应用侧 pool 直接降到 4,QPS 反而到了 1620。但那是另一个坑了——PgBouncer 的 pool_mode=transaction 跟业务代码里的 SET LOCAL 撞了,来来回回改了两周才利索。等有空再写。