高峰期接口大面积超时:一次数据库连接池配置的复盘

流量高峰所有接口集体超时,日志刷满 Connection is not available。我以为数据库扛不住,登上去一看它很闲——问题在连接池。几天彻底治理:读懂报错、连接池不是越大越好、揪出连接泄漏、慢 SQL 头号杀手、超时参数全家桶、连接池监控告警。

2024 年我们一个核心服务在一次流量高峰里栽了跟头。现象很吓人:几乎所有接口在同一时间集体变慢、然后大面积超时,日志里刷满了一行报错——Connection is not available, request timed out after 30000ms。我第一反应是数据库扛不住了,赶紧登上去看,结果数据库 CPU、慢查询都很正常,它闲得很。问题不在数据库,在我们的服务和数据库之间那个叫"连接池"的东西上。连接池就那么几十个连接,高峰期被全部占满、又迟迟还不回来,后面的请求只能排队等,等够 30 秒就超时。投了几天把数据库连接池的原理和配置彻底搞清并重新调了一遍,本文复盘这次实战。

问题背景

业务:Spring Boot 服务,HikariCP 连接池,后端 MySQL
事故现象:
- 流量高峰时,几乎所有接口同时变慢、超时
- 日志大量:Connection is not available,
  request timed out after 30000ms
- 但 MySQL 本身很闲:CPU 低、慢查询不多
- 服务重启后短暂恢复,过一会儿又复现

现场排查:
# 1. 看那条核心报错
HikariPool-1 - Connection is not available,
request timed out after 30000ms.
# 含义:线程等了 30 秒也没从池里拿到一个空闲连接

# 2. 看连接池配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 10        # 整个服务才 10 个连接
      connection-timeout: 30000

# 3. 看 HikariCP 打印的池状态日志
HikariPool-1 - Pool stats (total=10, active=10,
               idle=0, waiting=47)
# 10 个连接全 active,47 个线程在排队等连接

# 4. 翻代码,发现一处手写 JDBC 没关连接
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
// ... 用完了,没有 close,也没有 try-with-resources

# 5. 还有一条报表 SQL,单条要跑 8 秒
SELECT ... FROM orders o JOIN ... GROUP BY ...
# 它一占就是 8 秒,期间这个连接谁也用不了

根因:
1. maximumPoolSize=10 太小,高峰期连接不够分
2. 有连接泄漏:手写 JDBC 用完不 close,连接有借无还
3. 慢 SQL 长期占用连接,等于变相减少了可用连接数
4. 没有连接池监控,池子要满了也没人知道

修复 1:先看懂连接池和那条报错

=== 连接池到底解决什么问题 ===
建立一个数据库连接是很贵的:TCP 三次握手、
MySQL 认证、SSL 协商……一次几毫秒到几十毫秒。
如果每个请求都新建、用完就关,高并发下光建连接
就把数据库和网络拖垮了。
连接池的思路:预先建好一批连接放在池子里,
请求来了就【借】一个,用完【还】回去,反复复用。

=== 连接池的几个核心参数(以 HikariCP 为例)===
maximumPoolSize : 池子里连接数的上限
minimumIdle     : 保持的最小空闲连接数
connectionTimeout: 借连接时,最多等多久(等不到就报错)
idleTimeout     : 一个连接空闲多久会被回收
maxLifetime     : 一个连接最长的存活时间,到点强制换新
leakDetectionThreshold: 连接借出超过这个时间没还,
                  打日志告警(疑似泄漏)

=== 那条报错到底在说什么 ===
"Connection is not available, request timed out
 after 30000ms"
逐字翻译:
- 一个线程想从池里借连接
- 池里所有连接都被占用,没有空闲的
- 它等啊等,等满了 connectionTimeout(30 秒)
- 还是没等到,HikariCP 放弃,抛出这个异常

=== 关键认知:这是"池"的问题,不一定是"库"的问题 ===
报这个错,说明【连接池被占满了】。
占满的原因可能是:
1. 池子本身太小(连接数不够)
2. 连接借出去不还(泄漏)
3. 每个连接被占用的时间太长(慢 SQL)
数据库本身可能根本不忙 —— 我们这次就是。

修复 2:连接池不是越大越好

# === 第一反应:池子太小,那就调大?===
# 我们最初想:10 个不够,直接调到 500 个总行了吧。
# 这是个危险的直觉。连接池【不是越大越好】。

# === 为什么连接数过大反而有害 ===
# 1. 每个数据库连接,在 MySQL 端都要占一份内存
#    (排序缓冲、连接缓冲等)。几百个连接,
#    内存开销很可观。
# 2. MySQL 真正能"并行干活"的能力,受限于
#    CPU 核数和磁盘 IO。连接数远超这个能力,
#    只是让大量连接在数据库内部排队、争抢锁和资源,
#    上下文切换的开销反而拖慢整体。
# 3. 你的服务可能有多个实例,每个实例都开 500,
#    10 个实例就是 5000 个连接,直接打爆
#    MySQL 的 max_connections。

# === 一个广为流传的经验公式 ===
# 连接数 = (CPU核数 * 2) + 有效磁盘数
# 它的含义:数据库同一时刻能真正"干活"的连接,
# 数量并不大。池子开到刚好喂饱数据库即可,
# 多出来的连接只是在排队,没有意义。

# === 我们最终的配置 ===
spring:
  datasource:
    hikari:
      maximum-pool-size: 20      # 不是越大越好,20 足够
      minimum-idle: 20           # 建议和 max 设成一样
      connection-timeout: 3000   # 借连接最多等 3 秒
      # 30 秒太长:与其让用户干等 30 秒,
      # 不如快速失败,把问题尽早暴露
=== minimumIdle 为什么建议等于 maximumPoolSize ===
如果 minIdle < maxSize,池子会"按需伸缩":
闲时只留少量连接,忙时再临时新建。
但"临时新建连接"本身是慢操作,而流量高峰
恰恰最不能容忍这种慢。
HikariCP 官方就建议:固定池大小,
minIdle = maxSize,池子不伸缩,反而最稳。

=== connectionTimeout 不要设太长 ===
设 30 秒,意味着池满时,每个请求线程都要
傻等 30 秒才失败。这 30 秒里线程被占着,
更多请求涌进来,雪崩得更快。
设成 3 秒甚至更短:快速失败,
让上层尽早感知、尽早降级,而不是集体卡死。

修复 3:揪出连接泄漏

// === 连接泄漏:连接池最隐蔽的杀手 ===
// "泄漏"指:连接从池里借出去了,但用完【没有还回来】。
// 池子里的连接只出不进,迟早被借光,然后所有人都借不到。

// === 我们代码里的泄漏点 ===
// 一段手写 JDBC,用完忘了 close
public List queryOrders(Long uid) {
    Connection conn = dataSource.getConnection();   // 借出
    PreparedStatement ps = conn.prepareStatement(SQL);
    ps.setLong(1, uid);
    ResultSet rs = ps.executeQuery();
    // ... 处理结果 ...
    return result;
    // ★ conn 没有 close!这个连接永远回不了池子
    // 如果中间抛异常,更是直接泄漏
}

// === 正确写法:try-with-resources,自动归还 ===
public List queryOrders(Long uid) {
    String sql = "SELECT ... FROM orders WHERE uid = ?";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setLong(1, uid);
        try (ResultSet rs = ps.executeQuery()) {
            List result = new ArrayList<>();
            while (rs.next()) {
                result.add(mapRow(rs));
            }
            return result;
        }
    } catch (SQLException e) {
        throw new DataAccessException("查询订单失败", e);
    }
    // try-with-resources:无论正常返回还是抛异常,
    // conn / ps / rs 都会被自动 close。
    // 对连接来说,close 不是真关闭,而是【还回池子】。
}

// === 关键认知:连接池里的 close 语义 ===
// 从连接池拿到的 Connection,它的 close() 被池
// 包装过了 —— 你调 close(),不是真的断开 TCP,
// 而是把这个连接【标记为空闲、放回池子】。
// 所以"用完必须 close"这条规矩,在用连接池时
// 不仅不过时,反而更重要:不 close = 不归还 = 泄漏。
# === 让连接池帮你抓泄漏:leakDetectionThreshold ===
spring:
  datasource:
    hikari:
      leak-detection-threshold: 60000   # 单位毫秒
# 含义:一个连接被借出去超过 60 秒还没还,
# HikariCP 就打印一条带【完整堆栈】的告警日志,
# 直接告诉你:是哪一行代码借了连接没还。
# 排查泄漏时,这是最快的定位手段。
# 注意:它的阈值要大于你最慢的正常 SQL 耗时,
# 否则会把"正常的慢"误报成"泄漏"。

修复 4:慢 SQL 是连接池的头号杀手

=== 一个反直觉的事实 ===
慢 SQL 伤害的,远不止它自己那一个请求。
它会顺带拖垮整个连接池。

=== 算一笔账 ===
连接池 20 个连接。正常 SQL 耗时 10ms,
那么 1 个连接 1 秒能服务 100 个请求,
20 个连接 1 秒能服务 2000 个请求。

现在混进一条 8 秒的报表 SQL:
执行它的那个连接,被它独占整整 8 秒。
这 8 秒里,这个连接服务不了任何别的请求。
如果同时有几条这种慢 SQL,连接池里
一大半连接都被"冻结",可用连接数雪崩式下降。

=== 慢 SQL 怎么"变相缩小"连接池 ===
连接池的真实吞吐 = 连接数 / 平均占用时长。
- 占用时长 10ms,20 个连接 = 2000 QPS 能力
- 占用时长 8s, 20 个连接 = 2.5 QPS 能力
同样 20 个连接,慢 SQL 让它的服务能力
塌掉了三个数量级。这就是为什么
"数据库不忙、连接池却满了"。

=== 治理方向 ===
1. 找出慢 SQL:开 MySQL 慢查询日志,
   或用 leakDetectionThreshold / APM 抓长占用
2. 优化它:加索引、改写、拆分(参考索引优化那套)
3. 报表类重查询,和在线业务【物理隔离】 ——
   走单独的从库、单独的连接池,甚至离线数仓,
   绝不让它和在线请求抢同一个池子
4. 给 SQL 设执行超时,别让单条 SQL 无限期占用连接
# === 给慢操作一个独立的连接池 ===
# 在线业务池:小而快
spring:
  datasource:
    online:
      hikari:
        maximum-pool-size: 20
        connection-timeout: 3000

# 报表 / 后台任务池:和在线池完全隔离
    report:
      hikari:
        maximum-pool-size: 5
        connection-timeout: 10000
        # 报表慢就慢,但它的慢绝不会传染给在线接口
# 资源隔离的本质:别让非核心的慢,拖垮核心的快。

修复 5:超时参数全家桶

// === HikariCP 几个关键的时间参数,各管一段 ===

// 1. connectionTimeout —— 借连接的等待上限
//    池满时,一个线程最多等多久。等不到就抛异常。
//    建议:几秒。宁可快速失败,不要长时间干等。

// 2. maxLifetime —— 一个连接的最长寿命
//    连接活到这个岁数,就算还能用,也强制关掉换新的。
//    为什么需要它:数据库 / 中间件 / 防火墙
//    可能会单方面掐掉"太老"的空闲连接。
//    如果连接池里留着一个已经被对端掐死的连接,
//    下次借到它的请求就会拿到一个"死连接"而报错。
//    ★ 铁律:maxLifetime 要比数据库的
//      wait_timeout 短(一般短 30~60 秒),
//      让连接池【抢在数据库之前】主动换掉老连接。

// 3. idleTimeout —— 空闲连接的回收时间
//    连接闲置超过这个时间会被回收。
//    注意:minIdle = maxSize 时,idleTimeout 不生效
//    (池子固定大小,不回收)。

// 4. validationTimeout —— 连接有效性校验的超时
//    HikariCP 借出连接前,会快速 ping 一下确认它还活着。
//    这个 ping 的超时时间就是 validationTimeout。

// === 一个常见生产事故:连接被对端掐死 ===
// 服务半夜没流量,连接长时间空闲。
// MySQL 的 wait_timeout 默认 8 小时,
// 超过就把这条空闲连接关掉 —— 但连接池不知道。
// 第二天早高峰,请求借到这条"看着还在、其实已死"
// 的连接,执行 SQL 直接报
// "Communications link failure"。
// 解法就是上面的 maxLifetime:让池子主动、
// 提前把老连接换掉,永远不给它变成死连接的机会。
# === 我们最终的完整超时配置 ===
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 3000      # 借连接等 3 秒
      max-lifetime: 1740000         # 29 分钟
      idle-timeout: 0               # 固定池,不回收
      validation-timeout: 2000
      keepalive-time: 120000        # 每 2 分钟探活一次空闲连接
# 注:此处 max-lifetime 29 分钟,是因为我们 MySQL
# 侧 wait_timeout 设的是 30 分钟,连接池要更短。
# 这两个值必须配套看,不能照抄。

修复 6:把连接池监控起来

=== 这次最大的教训:池子要满了,我们一无所知 ===
连接池是个"黑盒",平时根本不会去看它。
等它满了、开始报错,已经是事故了。
必须给它装上仪表盘。

=== 要监控连接池的哪些指标 ===
HikariCP 通过 Micrometer 暴露了一组关键指标:
- hikaricp_connections_active   当前借出去的连接数
- hikaricp_connections_idle     当前空闲的连接数
- hikaricp_connections_pending  正在排队等连接的线程数
- hikaricp_connections_max      池子上限
- hikaricp_connections_timeout  累计借连接超时的次数

=== 怎么看这些指标 ===
最该盯的是 active 和 pending:
- active 长期贴近 max          -> 池子快不够用了
- pending 持续 > 0             -> 已经有请求在排队等了
- pending 突然飙高             -> 池子被打满,事故进行时
- timeout 计数在涨             -> 已经有请求拿不到连接了

=== 配上告警 ===
1. active / max > 80% 持续一段时间 -> 预警
2. pending > 0 持续 N 秒           -> 严重告警
3. timeout 次数在增长             -> 严重告警
有了这套,池子在"快满"的阶段你就知道了,
而不是等它"全满、雪崩"之后才从用户投诉里知道。
// === 暴露 HikariCP 指标给 Prometheus ===
// 引入 micrometer-registry-prometheus 后,
// HikariCP 的指标会自动注册。确认 DataSource
// 设置了 MetricsTrackerFactory(Spring Boot 默认会)。

// === 自己写个轻量的池状态打印,兜底也好用 ===
@Scheduled(fixedRate = 10000)
public void logPoolStats() {
    HikariPoolMXBean pool =
        ((HikariDataSource) dataSource).getHikariPoolMXBean();
    log.info("连接池: total={} active={} idle={} waiting={}",
        pool.getTotalConnections(),
        pool.getActiveConnections(),
        pool.getIdleConnections(),
        pool.getThreadsAwaitingConnection());
    // 这几个数,就是连接池的"心电图"。
    // 把它打进日志,出问题时能快速回溯当时的池状态。
}

优化效果

指标                      治理前              治理后
=============================================================
maximumPoolSize           10                  20(够用即可)
minimumIdle               1(默认伸缩)        20(固定池)
connectionTimeout         30 秒(干等)        3 秒(快速失败)
连接泄漏                  手写 JDBC 有泄漏     try-with-resources
leakDetection             未开启              60 秒,泄漏即告警
maxLifetime               默认                29 分钟,短于库侧
慢 SQL 隔离               和在线共用池         报表走独立连接池
高峰 pending(排队线程)    47                  0
连接超时报错              高峰大量             消失
连接池可观测性            黑盒                 Micrometer + 告警

治理过程:
- 定位"池满非库忙"+ 读懂报错:0.5 天
- 排查并修复连接泄漏:1 天
- 连接池参数重新配置 + 压测:1 天
- 慢 SQL 治理 + 报表连接池隔离:1.5 天
- 接入连接池监控告警:1 天

避坑清单

  1. "Connection is not available, request timed out" 是连接池满了,不一定是数据库忙
  2. 连接池不是越大越好,连接数远超数据库并行能力只会徒增内存和排队开销
  3. 建议 minimumIdle 等于 maximumPoolSize,固定池大小,避免高峰临时建连接的慢
  4. connectionTimeout 别设太长,几秒即可,快速失败优于让线程集体干等 30 秒
  5. 从连接池拿到的连接,close() 是归还而非真关闭,用完不 close 就是连接泄漏
  6. 用 try-with-resources 管理 Connection / Statement / ResultSet,保证异常时也归还
  7. 开启 leakDetectionThreshold,泄漏时它会打印借连接那行代码的完整堆栈
  8. 慢 SQL 会长期独占连接,变相把连接池缩小几个数量级,是连接池头号杀手
  9. 报表等重查询要走独立连接池或独立从库,做资源隔离,别和在线接口抢连接
  10. maxLifetime 必须短于数据库 wait_timeout,让连接池抢先换掉老连接,避免死连接

总结

这次连接池的故障,最值得记下来的是它教会我的那个排查方向上的转弯。接口大面积超时、日志里刷满"拿不到连接"的报错,几乎所有人的第一反应都和我一样——数据库扛不住了。这个直觉太强了,强到我差点没登数据库看一眼就准备去扩容。但我还是登上去看了,然后发现数据库闲得发慌:CPU 很低,慢查询列表干干净净。那一刻我才意识到,问题压根不在数据库这一端,而在我的服务和数据库之间那一小段被我长期忽视的东西——连接池。这就引出了这次复盘的第一条、也是最重要的一条经验:看到"拿不到连接"的报错,先分清这到底是"池"的问题还是"库"的问题。报这个错,百分之百说明连接池被占满了,但池子被占满,和数据库忙不忙,是两件完全独立的事。池子可能因为它自己太小而满,可能因为连接借出去不还而满,也可能因为每个连接被占用得太久而满——这三种情况下,数据库都可以是清闲的。把这一层想清楚,后面的排查方向才不会一开始就跑偏。想清楚是池子的问题之后,我做的第一件事差点又是个错误:既然 10 个连接不够,那就调到 500 个。幸好我忍住了,因为连接池真的不是越大越好。一个数据库在同一时刻能真正"并行干活"的连接数,是被它的 CPU 核数和磁盘 IO 能力死死限制住的,这个数其实并不大。你把连接池开到几百上千,多出来的那几百个连接并不会让数据库变快,它们只会挤在数据库内部排队、争抢锁和缓冲区,上下文切换的开销反而把整体拖慢;更别说你的服务往往有好几个实例,每个实例都开几百,几个实例叠加起来就足以把 MySQL 的 max_connections 直接打爆。所以连接池的大小,应该是"刚好喂饱数据库"的那个值,通常就是几十,多一个都是浪费。真正让池子在高峰期被占满的元凶,其实是另外两个:连接泄漏和慢 SQL。连接泄漏是最隐蔽的,我们代码里有一段手写的 JDBC,借了连接、用完却没有 close,这条连接就这么有借无还地从池子里消失了——这里有个很多人会忽略的点:从连接池拿到的连接,它的 close 方法早已被池子包装过,你调 close 并不是真的去断开那条 TCP,而是把连接归还回池子,所以"用完必须 close"这条老规矩在用了连接池之后非但没有过时,反而变得更加要命,不 close 就等于不归还,不归还就是泄漏。修复它的办法很简单,把所有连接、语句、结果集都交给 try-with-resources 去管,无论正常返回还是中途抛异常,它们都会被自动归还;同时把 HikariCP 的 leakDetectionThreshold 打开,一旦某个连接借出去太久没还,它会直接把当初借连接那一行代码的完整堆栈打到日志里,定位泄漏几乎是一秒钟的事。慢 SQL 的危害则更违反直觉:一条要跑 8 秒的报表 SQL,它伤害的根本不只是它自己那一个请求,而是会拖垮整个连接池——因为执行它的那个连接,会被它独占整整 8 秒,这 8 秒里这个连接服务不了任何别的请求。连接池的真实吞吐,等于连接数除以每个连接的平均占用时长,当占用时长从 10 毫秒劣化到 8 秒,同样的 20 个连接,服务能力会塌掉三个数量级。这就是"数据库明明很闲、连接池却满了"这个怪现象的全部答案。治理它,除了优化慢 SQL 本身,更重要的是做资源隔离:把报表、后台任务这类天生就重、就慢的查询,从在线业务的连接池里彻底拎出去,让它们走自己独立的连接池甚至独立的从库,这样它再慢,也只是它自己慢,绝不会传染给在线接口。最后还有一类很坑的问题是连接的"假死":服务半夜没流量,连接长时间空闲,数据库那边的 wait_timeout 一到就单方面把这条空闲连接关掉了,可连接池并不知道,它还以为这条连接好好的;等第二天早高峰一个请求借到这条"看着还在、其实早死了"的连接,执行 SQL 直接就是一个通信失败。解法是把 maxLifetime 设得比数据库的 wait_timeout 短一截,让连接池总是抢在数据库动手之前,主动把年纪大的连接换成新的,从根上不给"死连接"留在池子里的机会。回头看,这次故障真正让我转变的,是我对连接池这个东西的态度。在出事之前,连接池对我来说就是配置文件里几行抄来的、从来没认真读过的参数,是一个我默认它"会自己工作好"的黑盒。可这次它告诉我,它每一个参数背后都连着一个具体的故障场景:maximumPoolSize 连着数据库的并行能力,connectionTimeout 连着雪崩时是快速失败还是集体卡死,maxLifetime 连着半夜的死连接,leakDetectionThreshold 连着你迟早会写出的那段忘记 close 的代码。所以治理的最后一步,我给这个黑盒装上了仪表盘——把 HikariCP 的 active、idle、pending 这几个指标接进监控,配上告警。active 长期贴着上限、pending 开始大于零,这些都是池子"快不行了"的明确前兆,有了这套仪表盘,我能在它快满的时候就知道,而不是像这次一样,等它彻底满了、雪崩了,再从用户的投诉里知道。一个你能看见、看得懂、也愿意为它的每个参数负责的连接池,才不会再在下一个流量高峰里给你来这么一下。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

搜索接口越来越慢:一次 ElasticSearch 慢查询优化的复盘

2026-5-20 16:57:47

技术教程

用户付了两次款:一次接口幂等性设计的复盘

2026-5-20 17:04:35

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索