ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL连接池爆满排查指南:从报错到根因,一次讲透

MySQL连接池爆满排查指南:从报错到根因,一次讲透

先说结论: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,核心参数的调整逻辑如下:

参数默认值调整建议作用
maximumPoolSize10按压测结果设置池子最大连接数
minimumIdle与 max 保持一致建议两者一致避免动态伸缩引发建连开销
connectionTimeout30000ms治本前可调短至 5000ms获取连接的最大等待时间
idleTimeout600000ms保持默认空闲连接回收时间
maxLifetime1800000ms小于数据库wait_timeout连接最大存活时间
leakDetectionThreshold0(关闭)测试环境建议 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 秒左右。线上如果出问题,报错会来得更快、更明显,虽然会让部分请求快速失败,但总比所有请求都卡死要好。希望这篇排查思路能帮你在下次看到连接池报错时少走弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进