7.3 数据库连接池耗尽事故 本节摘要:连接池耗尽的表现是全体请求排队超时,根因却常常是"某个慢查询占住连接不还"或"某段代码借了不还"。本节从一次大促事故复盘讲起,拆解 HikariCP 的核心参数、连接生命周期、泄漏的排查动作,以及池大小与数据库容量的数学关系。 事故现场:大促前十分钟的集体超时 大促预热流量刚爬到峰值的六成,订单接口突然整体超时。应用日志里没有 SQL 报错,只有一片: 这是 HikariCP 的取连接超时:池里 20 个连接全被占用,新请求等 30 秒( )还是拿不到,抛异常走人。为什么连接全被占用?往下查慢日志,发现一条新上的报表 SQL 没走索引,单次执行 8 秒——20 个连接,每 8 秒周转一次,池的供给能力只有约 2.
本节摘要:连接池耗尽的表现是全体请求排队超时,根因却常常是"某个慢查询占住连接不还"或"某段代码借了不还"。本节从一次大促事故复盘讲起,拆解 HikariCP 的核心参数、连接生命周期、泄漏的排查动作,以及池大小与数据库容量的数学关系。
大促预热流量刚爬到峰值的六成,订单接口突然整体超时。应用日志里没有 SQL 报错,只有一片:
Connection is not available, request timed out after 30000ms.
这是 HikariCP 的取连接超时:池里 20 个连接全被占用,新请求等 30 秒(connectionTimeout)还是拿不到,抛异常走人。为什么连接全被占用?往下查慢日志,发现一条新上的报表 SQL 没走索引,单次执行 8 秒——20 个连接,每 8 秒周转一次,池的供给能力只有约 2.5 连接/秒,而当时的请求量每秒需要 50 个连接瞬间用完。一条慢 SQL 就把整个服务的数据库通道堵死了:它不只拖慢自己,还让所有不相干接口一起排队。
连接池事故的通用结构:池是共享瓶颈资源,任何"占住连接的行为"(慢 SQL、长事务、借了不还)都会把伤害扩散到全站。
先建立心智模型:一个连接从池里借出,经过 SQL 执行、事务提交,归还池内。周转率 = 1 / 单次占用时长。池的吞吐上限 ≈ 池大小 × 周转率。事故里 20 × (1/8s) = 2.5/s,需求 50/s,缺口一目了然——参数没配错,是占用时长出了问题。
HikariCP 的参数哲学是"少即是稳":
| 参数 | 默认 | 语义 | 调整注意 |
|---|---|---|---|
| maximumPoolSize | 10 | 池上限 | 不是越大越好 见下文公式 |
| minimumIdle | 同 max | 空闲保有 | 与 max 相同即固定池 最稳 |
| connectionTimeout | 30s | 借连接等待上限 | 超时即快速失败 宁可拒掉不排队 |
| maxLifetime | 30min | 连接最大存活 | 要小于数据库端连接超时 如 MySQL wait_timeout |
| leakDetectionThreshold | 无 | 借出超 N 毫秒告警 | 泄漏排查神器 上线就该开 |
两个被严重误解的问题。
池到底多大? 直觉说"越大越快",实际相反。数据库的有效并发由 CPU 与磁盘 IO 决定,通常单机几十个活跃连接就饱和;超出部分只是在数据库端排队,还白白增加上下文切换。经验起点:池大小 ≈ 核数 × 2 ~ 核数 × 4(对数据库主机的核数),再以压测曲线修正。HikariCP 官方的建议甚至更激进地偏小——小池 + 快周转优于大池 + 慢周转。多服务共享一个库时还要做全局预算:十个服务各 50 连接,数据库端 500 活跃连接早就被打爆,这是微服务架构下必须全局协调的容量。
maxLifetime 为什么必须小于数据库的连接超时? 如果数据库(或中间的防火墙/LB)先掐掉空闲连接,池里还留着这个"僵尸"连接,下次借出执行 SQL 直接报 Communications link failure——一堆"数据库莫名其妙断连"的夜间故障(防火墙空闲连接回收策略常见 1 小时)就是这么来的。maxLifetime 设为数据库超时的 2/3 留余量,配合 keepaliveTime 探活。
第二类事故是连接泄漏:代码借出连接后异常路径没归还,池慢慢被蛀空。典型病灶是手动事务/多数据源场景绕开了 Spring 的连接代理:
// 病灶:异常时 close 永远执行不到 Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery(); // 这里抛异常 while (rs.next()) { ... } rs.close(); ps.close(); conn.close(); // 全部跳过
正解第 3 章讲过:try-with-resources。Spring 事务管理下这个问题被框架兜住(连接绑定线程,事务结束归还),但自己 getConnection 的旁路代码(多数据源手动切换、原生 JDBC 工具类)是泄漏高发区。排查的钥匙是 leakDetectionThreshold:借出超过阈值(比如 60 秒)就在日志里打印借出时的堆栈——哪行代码借的、谁没还,一行日志定案。这个参数的代价几乎为零,我建议生产常开。
Spring 的 @Transactional 把连接占用从"SQL 执行时长"拉长到"整个事务方法时长"。方法里做一次 200ms 的 RPC,数据库连接就被多占 200ms——事务边界内的每一步外部调用都在消耗池容量。治理规则:事务块里只放数据库操作,RPC、消息发送、文件 IO 全部挪出去(发消息还应考虑事务提交后的语义,否则又引入"发了消息但事务回滚"的一致性坑)。用 TransactionTemplate 显式圈定边界,比注解贴方法头更能控制住范围。
回到事故的修复组合拳:报表 SQL 补索引(8 秒降到 80 毫秒,池供给能力回到 250/s);报表流量隔离到只读库(爆炸半径物理隔离);connectionTimeout 降到 5 秒(失败要快,排队 30 秒只是把超时伪装成卡顿);leakDetection 上线。四个动作里只有第三个是"参数",其余都是结构治理——又一次印证第 6 章的结论:参数缓冲,结构根治。
⚠️ 常见坑:把
maximumPoolSize调大当修复。慢 SQL 不解决,200 个连接只是把 30 秒超时变成 8 秒超时后依然全站堵死,还顺手把数据库打趴。
💡 关键直觉:连接池是"借还系统",管理的是周转率不是数量。任何连接治理先问两个数:单次占用多久、每秒需要借多少次——吞吐供需一算,问题在哪层立刻清楚。
第 7 章收束。最后一章回到人:设计、调优与排错的方法论。