数据库没慢,你的服务却卡死了:连接池里那把椅子,藏着 P99 崩塌的真凶

2026-10-23 9 0

凌晨两点,监控面板上只有一条曲线在抖:数据库 CPU 才 12%,QPS 才两百,连接数还不到上限,但接口 P99 从 30ms 跳到了 3 秒。数据库没慢,机器没满,代码没改。那慢在哪?

在连接池里。数据库的连接不是"用完就还"的公共资源,它是一把有编号的椅子。椅子数量有限,谁占上了,别人就得站着等。而站着等的时间,不是线性增长的。

一、连接池是放大器,不是缓冲区

大部分人把连接池当成"缓冲",直觉是:我开 20 个连接,扛 100 个并发,多出来的排队一下也无妨。这话对了一半,但漏掉了最要命的一点——

连接池的排队延迟,在利用率接近 1 的时候,是指数爆炸的。

用 M/M/1 排队论粗算:服务时间平均 10ms,利用率 0.8 时平均等待约 40ms;爬到 0.95,等待飙到 200ms;到 0.98,直接 500ms 起步。于是你会看到一个诡异现象:数据库 QPS 一点没涨,只是从"排队"变成了"排更长的队"。

这就是放大效应,我踩过最贵的一个坑:一次 2 秒的慢查询,在池子利用率 95% 的情况下,能让原本 30ms 的接口整体变成 2 秒。不是因为大家都在等那条慢 SQL,而是因为大家都在等被慢 SQL 占住的那把椅子。

二、复现:一条 1% 的慢查询,拖垮 100% 的流量

用 HikariCP 举例,配置看着挺美:

hikari:
  maximum-pool-size: 20
  connection-timeout: 3000
  leak-detection-threshold: 5000

线上有条报表查询走了坏索引,2.5 秒返回,只占 1% 流量。上线后日志长这样:

pool-1-thread-14  - waiting 2841ms for a connection
pool-1-thread-27  - waiting 2903ms for a connection
...每秒 800 条

教科书式的护航效应:一条慢车堵住整条高速,后面全在追尾。数据库侧看慢 SQL 就 1%,一切"正常";应用侧看,P99 已经上天。

三、被偷走的椅子:三种最常见的事故姿势

姿势一:事务边界溢出。最经典的写法:

@Transactional
public void order(OrderRequest req) {
    orderRepo.save(req);
    inventoryClient.lock(req.getSku());   // HTTP 调用,200ms+
    couponRepo.update(req.getCoupon());    // 又一次 RPC
}

事务开着,连接就持有到事务结束。中间夹着两次网络调用,椅子被占 300ms+。并发一上来,20 把椅子瞬间被"坐在会议室里开视频会"的人占满。规则很简单:事务里不允许出现不可控的 I/O。要 RPC?先在事务外拿数据,再开事务写入;或者用 TransactionSynchronization 挪到提交之后。

姿势二:连接泄漏。MyBatis 的 SqlSession 没 close、游标没关、异常路径上忘了释放——泄漏型占用最阴险,它不报错,只是安静地把椅子一张张拿走,直到某天池子空了,全站 503。所以 HikariCP 的 leak-detection-threshold 一定要开,它会直接打印"谁拿走了连接还没还"的完整调用栈。

com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered
  com.x.service.OrderService.create(OrderService.java:88)  <-- 破案现场

姿势三:把 Connection 缓存进 ThreadLocal。看着像"优化",实际上让连接生命周期脱离了业务事务,跨线程一用就出灵异事件。

四、池子之上还有池子:别忘了 PgBouncer

PostgreSQL 场景里很多人只盯应用连接池,其实中间还坐着一层 PgBouncer,三种模式坑位完全不同:

  • session 模式:全程绑定后端连接,等于没有池化,只是换了个地方排队。
  • transaction 模式:事务结束就归还连接,这才是真池化,前提是你不在事务里做慢 I/O。
  • statement 模式:一条语句一个连接,预编译基本没法用。

如果应用池 20、PgBouncer 50、PG 的 max_connections 100,那你实际只拥有 20 条真连接,另外 30 条是幻觉。排障时经常有人盯着 PgBouncer 显示的"100 个 client 连接"说"连接数明明很少啊"——典型的把不同层的数字混在一起看。三层各自看起来都不满,但应用层的椅子只有 20 把。

五、超时的正确排序:链路必须从外到内递减

最经典的配置错误:

connection-timeout: 30000   # 等椅子 30 秒
query-timeout: 2000        # 坐下后只干 2 秒活
request-timeout: 5000     # 客户端 5 秒就撤了

三层超时的包含关系必须外层 ≥ 内层并留余量。否则就是:客户端早断了,数据库还在卖力跑那条 SQL,连接照样被占着,池子照样被拖垮——你超时了,但你的资源没释放,最冤的一种超时。

我的推荐链路:网关 15s → 应用请求 12s → 连接获取 3s → SQL 执行 8s → 数据库 statement_timeout 6s。层层递减,每层留 1~2 秒缓冲给清理动作(写日志、放连接、回报状态)。

六、真正该盯的指标,不是 CPU

现在的监控面板上,连接池相关指标比数据库指标还多:

hikaricp_connections_pending      # 等待连接的任务数 > 0 就该警觉
hikaricp_connections_active       # 活跃连接数 / max 的比例
hikaricp_connections_acquire_ms   # 获取连接的耗时直方图
hikaricp_connections_usage_ms    # 连接被持有的时长分布

其中 usage_ms 最被低估:它直接告诉你连接在你的代码里被占多久。如果 P99 持有时长是 800ms,你的代码里一定藏着一个本不该在事务内做的事——哪怕慢 SQL 只占 1%,这 800ms 也够把池子压死。

另外强烈建议加一条告警:待分配连接数连续 3 秒大于 0。它测的是排队而不是负载,比"数据库 CPU 90%"早得多触发。很多团队习惯等到接口超时才排查,而那时用户已经在骂了。

七、实战清单

  1. 池子大小别拍脑袋。别照抄"核心数乘 2"之类的公式。数据库是共享资源,池子开太大,多个服务加起来能把 PG 打爆。宁可池子小一点多等一会儿,也别把数据库连接吃光。
  2. 开 leak detection。代价极低,收益极高。
  3. 连接获取超时设得比请求超时短。拿不到就快速失败,比干等 30 秒后一起超时好得多。
  4. 拆慢查询。报表、导出、批处理挪到独立小池 + 独立线程池,跟在线流量物理隔离。
  5. 事务里不放 I/O。写进 code review checklist,不靠自觉。
  6. 给每层超时写注释。三个月后没人知道 3 秒和 8 秒谁是谁。

写在最后

这次事故最后定位到的原因挺俗的:一段代码在事务里调了第三方风控接口,第三方偶发超时 1.5 秒。平时没事,一旦第三方抽风,连接池就被"合法地"按住了。

但我记住的不是那个第三方接口,而是那个反直觉的事实:系统最脆弱的地方,从来不是最慢的那条请求,而是它占住的那把椅子。

慢查询只让你亏几秒,排队让你亏掉整个服务。而所有这一切,都发生在监控面板显示"一切正常"的时刻。

所以,别再盯着 CPU 了。去数椅子。

相关文章

你的服务平均值 8ms,P99 却要 3 秒:排队论视角下的尾延迟凶案现场
你的分页接口正在偷偷拖垮数据库:OFFSET 的谎言,和游标分页的真相
B+树统治了数据库界三十年,然后被一台”缝纫机”干翻了:LSM-Tree 的暴力美学
服务器上的时间是假的:分布式系统里那三个互相打架的时钟
为什么你的API总被吐槽?聊聊那些让人又爱又恨的接口设计
为什么你的API总被吐槽?聊聊那些让人又爱又恨的接口设计

发布评论