把连接池从 10 调到 200 之后,QPS 掉了 37%——一次真实的性能调优翻车记录

🔑 关键词:性能调优,数据库连接池,HikariCP,PostgreSQL,P99延迟

📖 摘要:一个订单查询服务的调优实录:连接池从 10 加到 200,QPS 不升反降。附三轮压测的原始数字、pg_stat_statements 定位步骤,以及一个不太讨喜的结论——池子大小几乎从来不是瓶颈。

先把结论摆出来

图片

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 秒才断。结果表现在监控上就是“变慢”,不是“报错”——慢比错难查十倍。

真正该看的三个东西

如果你是第一次做这类调优,我建议按这个顺序来,别跳步:

图片

  1. 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 万多次——典型的“调用少、单次贵”。
  2. 把它改成 keyset 分页:WHERE id < :last_id ORDER BY id DESC LIMIT 20,配 (id) 主键索引,单次从 260ms 降到 0.7ms。
  3. 再回头调池子。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 撞了,来来回回改了两周才利索。等有空再写。

🏷️ 标签: