7.3 数据库连接池耗尽事故


文档摘要

7.3 数据库连接池耗尽事故 本节摘要:连接池耗尽的表现是全体请求排队超时,根因却常常是"某个慢查询占住连接不还"或"某段代码借了不还"。本节从一次大促事故复盘讲起,拆解 HikariCP 的核心参数、连接生命周期、泄漏的排查动作,以及池大小与数据库容量的数学关系。 事故现场:大促前十分钟的集体超时 大促预热流量刚爬到峰值的六成,订单接口突然整体超时。应用日志里没有 SQL 报错,只有一片: 这是 HikariCP 的取连接超时:池里 20 个连接全被占用,新请求等 30 秒( )还是拿不到,抛异常走人。为什么连接全被占用?往下查慢日志,发现一条新上的报表 SQL 没走索引,单次执行 8 秒——20 个连接,每 8 秒周转一次,池的供给能力只有约 2.

7.3 数据库连接池耗尽事故

本节摘要:连接池耗尽的表现是全体请求排队超时,根因却常常是"某个慢查询占住连接不还"或"某段代码借了不还"。本节从一次大促事故复盘讲起,拆解 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 秒超时后依然全站堵死,还顺手把数据库打趴。

💡 关键直觉:连接池是"借还系统",管理的是周转率不是数量。任何连接治理先问两个数:单次占用多久、每秒需要借多少次——吞吐供需一算,问题在哪层立刻清楚。

防坑清单

  • leakDetectionThreshold 生产常开,泄漏在告警里自首
  • 慢 SQL 与长事务是池耗尽的两大根因,监控"连接借出时长"分布
  • maxLifetime < 数据库端空闲超时,防僵尸连接
  • 池大小从数据库核数推起,压测定终值,多服务做全局预算
  • 事务块内禁 RPC/IO,手动 JDBC 一律 try-with-resources

本节要点回顾

  • 事故结构:慢 SQL/泄漏占用连接,伤害扩散全站排队超时
  • 吞吐模型:池大小 × 周转率 vs 需求速率,一算定位瓶颈层
  • 参数纪律:池宁小勿大、连接超时要短、maxLifetime 对齐数据库
  • 泄漏排查:借出堆栈日志直接指认病灶行
  • 事务边界:外部调用出事务块,注解换显式模板

第 7 章收束。最后一章回到人:设计、调优与排错的方法论。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U