先说结论:MySQL 数据库连接池爆满,十次里有八次不是数据库本身挂了,而是连接借出去之后迟迟不归还,或者归还的速度赶不上请求涌入的速度。前阵子帮某业务团队排查一个线上故障,日志里刷得最多的就是这句话:HikariPool-1 - Connection is not available, request timed out after 30001ms。从看到这条日志到定位到根因,前后花了大概一个半小时;事后复盘时发现,如果一开始就用对排查顺序,这个时间能压缩到二十分钟以内。
这篇文章把整个排查过程、根因分析和落地修复方案完整写出来,适合正在维护线上服务、被连接池问题困扰过的后端开发者参考,也适合刚入门的同学理解连接池的运作逻辑。文中的案例都是处理过的真实问题,只是隐去了业务名和细节,方法和结论完全可以照抄。
1. 连接池爆满的事故现场:从这条报错说起
1.1 典型报错长什么样:不同连接池的报错解读
先解读那行经典的报错:Connection is not available, request timed out after 30001ms。HikariPool-1 是 HikariCP 连接池在 Spring Boot 默认配置下的池名;Connection is not available的意思是连接池里当前已经没有可用连接;request timed out after 30001ms表示这个请求在池子里等了 30 秒还没等到连接,超过了connectionTimeout配置。
如果用 Druid 连接池,报错措辞又是另一种风格,常见的是wait millis 10000, active 50, maxActive 50,翻译过来就是:池子已经达到最大活跃数 50,新请求等了 10 秒还是没位置。Tomcat JDBC 连接池则报Timeout: Pool empty. Unable to fetch a connection。不同框架的报错措辞不一样,但本质上都在说同一件事:连接池的“借出”速度大于“归还”速度,新请求在池子里排不到队。
这条报错还有一个容易被忽略的信息点:超时时间。如果报错里显示等待了将近 30 秒,说明应用侧给连接获取设置的超时比较长。很多团队习惯用默认值,这会导致一个问题:当连接池打满时,请求不会立刻失败,而是集体阻塞在“等连接”这个环节,整体表现就是接口大面积超时、线程池被占满,故障范围进一步扩大。
1.2 链路上下游的连带症状:接口、数据库与业务影响
连接池爆满从来不是单个请求的问题。连接池通常被整个服务共用,池子满了之后,所有需要操作数据库的接口都会开始排队。线上表现通常是接口 P99 延迟从几十毫秒飙升到几秒,错误率同步上升,业务方反馈查询变慢、超时。
有个很容易被忽视的特征:如果服务是多副本部署,故障往往只出现在部分实例上,而不是所有实例同时挂。每个进程各自维护自己的连接池,某台实例的池子被打满,只会拖垮路由到这台实例的请求,表现为“部分请求失败”。这个特征在排查时很关键——如果所有实例同时爆,大概率是数据库或某个公共依赖出问题;如果只有几台实例爆,那就更倾向于是应用进程内部的问题。
数据库侧的表现要看根因。如果根因是慢查询,数据库 CPU 和 IO 通常会同步升高;如果根因是连接泄漏,数据库侧可能一片平静,Threads_connected甚至远低于max_connections。也就是说,连接池爆满和“数据库连接数达到上限”并不是同一个概念,后者只是前者的一个子集。
1.3 为什么连接池会“不够用”:借出与归还的底层机制
要理解连接池爆满,得先把连接池的工作原理捋清楚。连接池本质是应用进程内维护的一批数据库连接的集合,目的是省掉每个请求重新建立 TCP 连接、进行 MySQL 认证的开销。正常的一次数据库操作流程是:请求进来后从池子里borrow一条连接,执行 SQL,用完return回池子。
池子容量受maximumPoolSize限制,一旦同时借出的连接数量达到上限,后续请求就只能排队等待,超过connectionTimeout就抛出超时异常。用一个餐厅的比喻:座位是连接,客人是请求,后厨上菜速度是 SQL 执行速度。如果一桌客人点太多菜迟迟不走(一条慢 SQL 占着连接不放),翻台率就会下降,门口排队的人越来越多,等不及的客人只能离开。
这个机制同时解释了为什么“把连接池调大”不是万能药。连接池是进程内的共享资源,调大只是增加了座位数,如果后厨出菜慢的问题没解决,客人依然会越积越多,只是爆的时间往后推迟了一点。真正要解决的是“谁借走了连接”“为什么归还这么慢”这两个问题。
2. 排查链路的核心动作:日志、processlist 与慢查询三道关卡
2.1 第一步:从应用日志定位报错来源与时间点
排查连接池爆满,第一个动作永远是看应用日志,别一上来就连数据库。原因是日志里记录了报错类型、发生时间窗口、涉及哪些实例,这些信息直接决定后续排查方向。
先确认报错出现的起始时间,再回看这个时间点前后有没有发布事件、流量高峰、依赖变更。如果只有某几台实例报错,说明问题是局部性的;如果所有实例同一时间开始报错,更要怀疑某个公共依赖或数据库本身发生了变化。
这里有一个新手容易踩的坑:应用日志时间和数据库服务器时间不一致。有些团队的应用容器和数据库主机没做时间同步,日志里显示 14:00 开始报错,数据库慢日志却定位在 13:00,中间差出一个小时,很容易把排查方向带偏。
2.2 第二步:在数据库侧抓现场,processlist 是最快的信息入口
连接池爆满时,数据库侧最重要的工具是show full processlist。它会列出当前所有连接以及它们的状态,是判断“连接到底在干什么”的最直接手段。
SHOW FULL PROCESSLIST;我习惯每隔两三秒抓一次,连续抓五六次,因为单次快照可能正好错过关键状态。重点看三列:Time、State、Command。
Time特别大的 SQL,要么是慢查询,要么是事务没结束;State里出现Sending data、Copying to tmp table、Sorting result,通常意味着 SQL 在真干活,出现Waiting for table metadata lock则是典型的锁等待;Command这一列很关键:大量Query状态说明 SQL 在跑,大量Sleep状态则说明连接被借出去之后空闲挂起,这往往是连接泄漏的信号。
再配合下面两条命令看整体水位:
SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';如果Threads_connected都在正常范围,但应用侧大量报连接池超时,那基本可以断定池子的瓶颈在应用进程内部,而不是数据库连不上了。
2.3 第三步:慢查询日志与 performance_schema 交叉验证
processlist只能看到“当前”的瞬间,要还原时间窗口内的全貌,还得看慢查询日志和 performance_schema。
慢查询日志能看到执行时间超过阈值的 SQL,把时间窗口内出现次数最多的慢 SQL 拉出来,基本能锁定几条嫌疑。拿到慢 SQL 之后不要急着下结论,先用EXPLAIN看执行计划:有没有走索引、有没有Using filesort、有没有全表扫描。
EXPLAIN SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20;这里更推荐的一种做法是直接查 performance_schema 里的语句聚合表:
SELECT SCHEMA_NAME, DIGEST_TEXT, COUNT_STAR, SUM_ROWS_EXAMINED, SUM_TIMER_WAIT FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;这个视图按 SQL 模板聚合统计执行次数和总耗时,适合发现那种“单条不慢,但被高频调用累计耗时可观”的 SQL。这类问题在processlist里几乎看不到,因为任何一瞬间它都只是几百毫秒的普通查询。
2.4 顺手好用的定位技巧:从 innodb_trx 找长事务与锁等待
补一个我常用的技巧。连接池爆满不一定只有一条根因,很多时候是慢 SQL 叠加连接泄漏再加配置过小,三个因素共同作用。为了不遗漏,可以查一下当前事务表:
SELECT * FROM information_schema.innodb_trx\G它能列出正在运行的事务、事务启动时间和 SQL 文本。如果发现某个事务的trx_started时间很长,再结合SHOW FULL PROCESSLIST里对应的连接,基本就能定位到谁在占着连接不放。
整个过程归纳下来就是三件事:先看现象确认方向,再在数据库侧抓现场定位嫌疑,最后用日志和系统表交叉验证,把嫌疑坐实。按这个顺序走,大多数连接池问题都能在一个小时内定位到根因。
3. 根因分类讨论:慢SQL、连接泄漏、配置失配与外部拖累
3.1 根因A:慢查询把连接“借走不还”的时间拉长
慢 SQL 是连接池爆满最常见的原因。一条 SQL 执行 2 秒,池子大小 50,那它最多同时容纳 25 条这样的查询,剩下 25 条连接被其他请求占用,后面的请求只能排队。处理过一个典型案例如下:
一条报表查询对三张大表做LEFT JOIN,执行计划里出现Using join buffer,实际是全表扫描,执行一次要两分多钟。这条 SQL 一上线,连接池立刻被打满。
这类问题的特征很鲜明:processlist里能看到大量Time很大的Query,数据库 CPU 和 IO 会同步升高。深分页也是重灾区,比如select * from orders limit 1000000, 20,MySQL 会先把前 100 万行读出来再丢弃,耗时自然爆炸。索引不是万能的,但绝大多数慢 SQL 的根本解法还是索引设计和 SQL 改写。
3.2 根因B:连接泄漏——借出去之后再也没回来
连接泄漏的表现和慢 SQL 完全不同。慢 SQL 至少还在执行,泄漏是连接被取走之后压根没还回来。常见的触发场景包括:手写 JDBC 代码在结果集处理完后忘了close;在方法里return之前没用finally把连接归还;把Connection对象放到静态变量或者缓存里反复使用。
泄漏有个非常典型的周期性特征:数据库侧Threads_connected会缓慢上升,重启应用后立刻下降,过一段时间又缓慢涨上来,周而复始。processlist里会看到大量Sleep状态的长连接。
HikariCP 提供了leakDetectionThreshold参数,连接被借出超过阈值仍未归还时,日志里会输出连接泄漏相关告警。这一项建议在测试环境常开,线上环境确认无误报后再开。
3.3 根因C:连接池大小与实际并发严重失配
还有一类问题纯粹是配置不对。比如某服务的 Tomcat 线程池配置了 200,HikariCP 的maximumPoolSize却只有 10,高峰期 200 个线程同时进来,只有 10 个能拿到连接,其余 190 个全部阻塞等待,连接池队列瞬间堆积。
这里要打破一个直觉:连接池不是越大越好。连接数太多会导致数据库侧线程数膨胀,锁竞争和上下文切换加剧,整体吞吐反而下降。连接池大小应该和应用的并发模型、数据库的性能测试结果匹配,而不是拍脑袋填一个 100 或 300。
HikariCP 官方文档给过一个估算起点公式:connections = ((core_count * 2) + effective_spindle_count),其中core_count是 CPU 核数,effective_spindle_count是磁盘数。这只是起点,最终要拿压测数据修正。
3.4 根因D:数据库锁等待和事务边界过宽
锁等待和长事务也是隐藏的凶手。一条UPDATE在事务里一直不提交,行锁就不会释放,其他请求如果也要更新同一行,就会停在Waiting for lock状态。数据库连接被占着却不干活,池子自然越挤越满。
更常见的是长事务:业务代码在事务里调了外部接口,比如发短信、调支付网关,一个事务要跑几十秒甚至一分钟,连接被这几十秒的空等白白占用。information_schema.innodb_trx里能看到这些长时间未提交的事务,trx_started字段直接暴露了启动时间。
这类问题比慢 SQL 更隐蔽,因为数据库 CPU 可能完全正常,processlist里可能只是显示一条Sleep或空Query,看起来人畜无害,实际上已经拖住了整个连接池。
3.5 根因E:持连接期间调用外部依赖,连接被“偷走”
最后一类根因要特别警惕:业务代码在持有连接期间做与数据库无关的事情。最典型的是在事务中调用第三方 HTTP 接口、缓存、消息队列,一旦外部依赖响应变慢,连接的持有时间就被无限拉长。
遇到过的一个案例:某个接口在数据库事务里调用支付网关,超时时间设了 60 秒,支付网关在高峰期响应要 30 秒以上,一条连接被占住 30 秒。池子大小 20,高峰期 50 QPS 直接把连接池打穿。
问题定位到这一步,基本已经进入修复阶段了。排查方法也很简单:翻代码,看去拿连接和释放连接之间,到底是不是只做了数据库操作。只要中间出现远程调用、文件读写、线程等待,就都有嫌疑。
4. 组合拳修复:连接池参数调优、代码层治理与数据库侧整改
4.1 连接池参数调整:HikariCP 核心参数怎么改
修复的第一步是调连接池参数,但这里要强调一个顺序:先止血,先生效,再根治。线上已经爆满时,最快的手段是重启服务让连接全部归还,同时临时调大maximumPoolSize,把排队时间先压下来。
如果一个服务用的是 HikariCP,核心参数的调整逻辑如下:
| 参数 | 默认值 | 调整建议 | 作用 |
|---|---|---|---|
maximumPoolSize | 10 | 按压测结果设置 | 池子最大连接数 |
minimumIdle | 与 max 保持一致 | 建议两者一致 | 避免动态伸缩引发建连开销 |
connectionTimeout | 30000ms | 治本前可调短至 5000ms | 获取连接的最大等待时间 |
idleTimeout | 600000ms | 保持默认 | 空闲连接回收时间 |
maxLifetime | 1800000ms | 小于数据库wait_timeout | 连接最大存活时间 |
leakDetectionThreshold | 0(关闭) | 测试环境建议 30000ms | 连接泄漏检测阈值 |
这里有两个容易踩的坑。一是maxLifetime必须小于数据库的wait_timeout,否则连接会被数据库服务端先断开,应用还在池子里用,拿到的是坏连接。二是leakDetectionThreshold不能大于maxLifetime,否则泄漏告警还没来得及输出,连接已经被池子回收了,检测形同虚设。
4.2 代码层治理:从 try-finally 到统一连接管理
参数调整只能缓解症状,根治要回到代码。最基础的治理是统一连接获取与释放:能交给框架管理的就不要手写 JDBC。比如使用 MyBatis 管理SqlSession,连接生命周期由框架保证;使用 Spring 的JdbcTemplate,连接也由模板类统一处理。实在需要手写,也必须使用try-with-resources或者try-catch-finally确保连接归还。
另一个容易被忽略的点是数据库操作超时。连接池超时只是排队超时,SQL 本身执行多久还取决于数据库。建议给事务方法设置@Transactional(timeout = ...),或者在 JDBC 上设置setQueryTimeout,避免一条跑三分钟的 SQL 拖着连接不放。
此外,“事务里禁止调用远程接口”这条规则应该写进团队规范。凡是需要调外部服务的操作,一律先提交事务再调。这一点能从根上消掉一大半的长事务问题。
4.3 数据库侧整改:索引、执行计划与事务边界
数据库侧的整改重点围绕慢查询展开。第一步是给高频查询和关联字段建合适的索引,注意联合索引的字段顺序要匹配查询条件。第二步是改写 SQL:深分页场景可以用游标分页或者延迟关联替代offset过大的写法,大范围IN查询可以拆分为多次小范围查询。
第三步是设置执行保护。MySQL 5.7 之后的版本支持max_execution_time,可以给只读账号设置超时上限,让超长 SQL 直接被数据库拒绝,而不是在业务侧傻等:
SET GLOBAL max_execution_time = 10000; SET SESSION max_execution_time = 10000;针对报表类的重型查询,更彻底的做法是把实时查大表改成查询预聚合的汇总表。跑批任务定时把结果算好,前端只查结果表,既能保证响应速度,又不占连接。还有一个常见的架构手段:把读流量拆分到只读实例,降低主库的连接压力,这需要在压测充分之后再上。
4.4 案例复盘:一次修复中应用的组合策略
拿之前提到的某业务系统串一遍完整处理过程。该系统的连接池配置是maximumPoolSize=20,故障表现为部分实例接口超时报连接池错误。
排查发现:processlist里有一条深分页 SQL 每次执行 4 秒,同时一个批处理模块手动获取连接却没有关闭,两个问题叠加,把 20 条连接全部吃光。
处理顺序是:先重启出问题的实例,临时把连接池调到 60 止血;然后把深分页 SQL 改成延迟关联,执行时间从 4 秒降到 50 毫秒;再修复批处理模块的连接泄漏;最后压测确认 60 的池子对数据库无压力后,逐步下调到 40 并稳定运行。
这个顺序值得记住:止血、治因、验证、回稳。如果一上来就只顾调大参数,泄漏和慢 SQL 还埋在那里,下一次故障只是时间问题。
5. 从救火到防火:连接池监控指标与容量规划
5.1 必盯的几个连接池监控指标
连接池问题靠人盯是盯不住的,必须靠监控。应用侧重点盯四个指标:活跃连接数、等待获取连接的线程数、连接获取等待时间、连接创建和销毁速率。这些指标在 Spring Boot 场景下可以通过 Micrometer 暴露,由监控系统抓取并在看板上展示,是比较通用的方案。
数据库侧要看Threads_connected、Threads_running、慢查询次数、连接数触顶比例。告警阈值不要等爆了才设:活跃连接数达到池大小的 70% 就该提醒,等待线程数超过 0 持续一分钟也要提醒。
监控的意义在于,连接池爆满之前通常已经经历了从 50% 到 80% 再到 95% 的爬坡过程。早发现就能早干预,而不是深夜被叫起来看现场。
5.2 连接池容量规划:从经验公式到压测验证
容量规划是决定连接池设多少的底层依据。一个健康的服务,在日常峰值流量下,活跃连接数不应该长时间超过池大小的 60% 到 70%,要为突发流量留出缓冲。
压测是唯一可靠的验证方式:用压测工具按预估峰值流量的 1.5 倍打流,观察活跃连接数和响应延迟,直到延迟拐点出现,那个点对应的连接数就是这台实例的连接池上限。
经验公式connections = ((core_count * 2) + effective_spindle_count)可以作为初始值,但不要死守,最终以压测为准。另外要区分场景:连接持有期主要是在等 IO 的服务,连接池可以略大;大量占用 CPU 的服务,连接池要保守。前者连接是“在等待”,后者连接是“在干活”,两种模型对池大小的需求完全不同。
5.3 长效告警与例行巡检
最后一件事是建立例行巡检。每周花半小时看三样东西:慢查询 Top 列表、连接池使用率趋势、连接泄漏告警。配置里保留连接的打开与关闭日志,配一条定时任务把连接池关键指标拉出来画趋势图,出现持续上升的曲线就要留意。
发布流程里也建议加一道检查,把“本次变更是否涉及数据库连接使用”列为必填项,涉及连接变更的必须走代码评审。这套机制不需要很重的平台,一个定时脚本加一个监控面板就能跑起来,但遇到实际故障的时候能省掉大量时间。
连接池问题的本质是资源管理问题,和内存泄漏一样,预防永远比抢救容易得多。
最后分享一个我自己的经验:这些年排查过的 MySQL 连接池爆满案例,几乎没有一个是一句“把连接池调大”就能真正解决的,大多数都是慢 SQL、连接泄漏、配置失配、长事务这几类问题叠加出来的结果。所以我现在遇到连接池报错,第一反应永远是先看processlist的Time和State,三分钟就能判断这波是 SQL 在埋头干活,还是连接被悄悄“偷走”了。
另外有个小习惯可以分享:把connectionTimeout从默认的 30 秒调短到 5 秒左右。线上如果出问题,报错会来得更快、更明显,虽然会让部分请求快速失败,但总比所有请求都卡死要好。希望这篇排查思路能帮你在下次看到连接池报错时少走弯路。