凌晨 1 点 47 分,运维群里突然炸出一句话:"Druid 挂了,线上全在报错!"紧接着我的手机开始连环震动——订单接口、支付回调、用户查询,全部超时。登录服务器一看,日志里密密麻麻全是GetConnectionTimeoutException,数据库连接池被彻底打满。
Druid 是 Java 生态里用得最广的数据库连接池之一,Spring Boot 项目里几乎都有它的身影。它一旦"崩了",表象是连接耗尽,本质却往往藏着一连串配置、代码和运维问题。这篇文章就拿一次真实事故当引子,把 Druid 从"连接池炸锅"到"定位根因",再到"用监控页面做排查"的完整链路讲清楚,最后结合若依框架顺手把数据库密码加密也落地。整个过程对后端开发、DBA、运维同学都有参考价值,尤其是那些线上跑了很久、从没认真看过连接池参数的团队,这篇文章值得反复看。
1. 事故现场:连接池耗尽到底长什么样
1.1 报错特征与第一反应
先说说当天的现象。线上的错误日志堆积速度肉眼可见地疯涨,核心报错就两类,第一类是这样的:
com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 10000, active 20, maxActive 20, creating 0第二类是各种业务方法的DataAccessException和CannotGetJdbcConnectionException。第一眼看到active 20, maxActive 20就明白了:不是 SQL 写错,不是数据库宕机,而是连接池里所有连接都被占满,新的请求拿不到连接,等满默认的 10 秒(maxWait)直接超时。
这里有个很重要的认知:Druid 本身不会"崩溃",它是被业务流量和资源耗尽拖垮的。连接池是个有上限的资源池,当并发请求超过池子上限,或者池子里的连接被人为"借走不还",后续请求就只能排队、超时、报错。我当时的判断顺序是:
- 确认数据库本身活着——查库负载、慢查询、CPU、连接数。
- 确认应用进程没挂——JVM 内存、GC 情况、线程数。
- 重点看 Druid 连接池的运行指标——活跃连接数、空闲连接数、等待线程数。
第 1、2 步通常很快就能排除掉,真正的戏都在第 3 步里。
1.2 为什么"重启大法"只能救急
当时的第一反应肯定是重启应用。重启确实有效,连接池被清空重建,服务三五分钟内就恢复了,业务看起来"正常"了。但所有人都知道,这只是把病压下去,病灶还在。
重启之所以只能救急,是因为连接池的状态是 JVM 内存里的运行时数据。一重启,所有连接断开重连,之前的"连接占用混乱"被抹掉了。但如果引发占用混乱的代码缺陷没修、参数不合理没调,流量一上来,同样的故障必然复现。那次事故更典型,重启后大概四个小时,连接池又满了,而且这次崩得更彻底,连监控页面都打不开——因为打开监控页面的请求也要走 Tomcat 线程,而 Tomcat 线程全堵在等待数据库连接上。
到这一步,必须静下来做正经排查了。
2. 定位根因:把 Druid 监控页面真正用起来
2.1 监控页面怎么开
很多项目里 Druid 依赖早就加上了,但监控页面从没开过,或者开了也没人看。这其实是把最好用的诊断工具白白浪费了。Druid 内置的StatViewServlet提供了完整的 Web 监控页面,Spring Boot 项目配合 Druid Starter 只需要在配置文件里加一段:
spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: "你的强密码" allow: 127.0.0.1,内网IP段 deny: web-stat-filter: enabled: true url-pattern: /* exclusions: '*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*'这里必须提醒一句:监控页面绝不能裸奔在公网。Druid 监控页面能直接看到所有 SQL 和连接池状态,这就是数据库的一张"透视 X 光片",泄露出去等于把库表结构、慢 SQL、连接池水位全部暴露给攻击者。allow一定要限制内网 IP 或本机,login-username和login-password不要用弱口令,更不要用默认的 admin/admin。
加上配置后重启应用,浏览器访问http://服务IP:端口/druid/index.html,输入账号密码就能进监控页。这个页面里的信息密度极高,也极容易被看花眼,关键要抓的指标其实就那几个。
2.2 三个关键指标怎么读
连接池信息页签里有大量数字:逻辑连接打开次数、逻辑连接关闭次数、活跃连接数、空闲连接数、等待线程数、初始连接数、最大活跃连接数等等。排查事故时,我不建议逐个看,先抓住三组:
| 指标 | 含义 | 危险信号 |
|---|---|---|
| 活跃连接数 | 当前被业务占用的连接数量 | 持续高位,长期 > maxActive 的 80% |
| 空闲连接数 | 当前空置、可被分配出去的连接 | 趋近于 0,且活跃连接在高位 |
| 等待线程数 | 请求在队列里等连接的线程数量 | 长期 > 0,说明连接不够用了 |
事故当天的数据非常典型:活跃连接数稳定在 20(等于 maxActive),空闲连接数 0,等待线程数一直在 15 到 40 之间抖动。看到这个组合拳,基本可以断定是"连接只借不还"加"池子太小"两个问题叠加。
但光看这几个数字还不够,得继续往下钻。
2.3 SQL 统计里藏着真凶
监控页面的 SQL 监控页签才是这次排查里真正立功的地方。这里能看到每条 SQL 的执行次数、总执行时间、最大执行时间、平均执行时间、错误次数。事故发生时,我按"执行时间最大值"倒序排了一遍,有两类 SQL 浮出水面:
一类是报表查询,单条 SQL 最大执行时间到了 8 秒多,执行次数还不少。这类 SQL 本身就是慢 SQL,在连接池资源紧张时雪上加霜,每执行一次就占住连接 8 秒,20 个连接很快就被这类查询消耗干净。
另一类更隐蔽,是一条简单的SELECT,看起来人畜无害,执行次数却异常高,而且在"连接获取"的记录里,它对应的"物理连接打开次数"远大于"逻辑连接打开次数"的合理范围。顺着代码一查,发现有个定时任务的finally块里漏写了connection.close(),每个任务跑一次就泄漏一个连接。定时任务每 5 分钟跑一次,跑一晚上,泄漏的连接数量可想而知。
到这里,事故的全貌基本浮出水面了:慢 SQL 拉长单连接占用时间,代码泄漏不断蚕食可用连接,连接池参数又没扛住,三者叠加,直接把 Druid 打崩。
3. 最常见的几种"Druid 崩溃"原因
3.1 连接泄漏:最隐蔽的杀手
连接泄漏是生产环境最常见、也最讨厌的问题。它的特点是:单个连接占用时间不长,但泄漏是持续性的,池子里的连接被一点一点抽干,等发现时已经晚了。
典型的代码写法是这样的:
// 错误示范:finally 块里忘了 close Connection conn = null; try { conn = dataSource.getConnection(); // 业务逻辑 } catch (Exception e) { log.error("查询失败", e); } // 完全没有归还连接!正确写法其实很简单,优先用 try-with-resources:
// 正确示范:自动归还连接 try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { // 业务逻辑 } catch (Exception e) { log.error("查询失败", e); }Java 7 开始就支持 try-with-resources,只要Connection、Statement、ResultSet都实现了AutoCloseable,就能在代码块结束后自动关闭,省掉手写finally的繁琐,也彻底避开忘记 close 的坑。我们团队现在 code review 时,凡是手写conn.close()的地方都会多看两眼,能用 try-with-resources 就统一用。
如果代码里连接的使用特别分散,还可以考虑动态代理的方式统一拦截,但这种改造量较大。更现实的做法是把removeAbandoned打开,让 Druid 在连接被借出超过设定时间后强制回收:
removeAbandoned: true removeAbandonedTimeout: 180 logAbandoned: trueremoveAbandonedTimeout单位是秒,180 表示一个连接被借出超过 180 秒就强制回收。这个配置是双刃剑,后面我会专门讲它误伤长事务的坑。
3.2 参数配置不合理:资源明明够,却被参数锁死
另一类崩溃纯粹是人祸。很多 Spring Boot 项目里的 Druid 参数是从网上抄的,抄的时候没理解含义,线上环境一换就出事。
比如maxActive默认只有 8,对一些稍微有点流量的服务完全不够用。而maxWait默认是 -1,也就是不等待、立即报错;有些项目把它设成 10000(10 秒),看似给了缓冲,但当连接长期排队时,10 秒的等待时间又让接口响应变得极慢,前端超时重试,重试又加重连接池压力,形成恶性循环。
参数配置问题引发的崩溃,有非常明显的特征:活跃连接数在高峰期顶到maxActive,但数据库本身 CPU、内存、连接数都低得很。排查时用监控页面一眼就能看出来,瓶颈不在数据库,也不在应用代码里,就是池子参数把并发量锁死了。
3.3 数据库主动断连:连接变成了"僵尸"
MySQL 服务端有个wait_timeout参数,默认 8 小时。如果一条连接空闲超过 8 小时,MySQL 会在服务端把它断开。但应用侧不知道,连接池里的连接还"活着",直到某次请求真的用到它才发现连接不可用。
这种情况的表现是:系统平稳运行了很久,某天突然出现大量CommunicationsException或Connection is not available, request timed out,然后连接池开始疯狂重建连接,数据库压力骤增,进而拖垮服务。
缓解思路是让 Druid 自动维护空闲连接,几项关键配置组合起来:
testWhileIdle: true testOnBorrow: false testOnReturn: false validationQuery: SELECT 1 timeBetweenEvictionRunsMillis: 60000 keepAlive: truetestWhileIdle会在连接空闲时用validationQuery验证连接是否还活着,keepAlive保证池子里的最小连接数一直有鲜活的连接补充。这套组合也是长期稳定运行的标配。
3.4 慢 SQL 拖垮连接池
慢 SQL 是连接池的"慢性毒药"。数据库连接是稀有资源,一条 SQL 执行 5 秒,就相当于把连接占住 5 秒。同一时刻有 10 条这样的 SQL,20 个连接就去了一半。
排查慢 SQL,Druid 监控页面的 SQL 监控页签结合慢 SQL 统计非常好用。如果开了druid.stat.slowSqlMillis配置,执行时间超过阈值的 SQL 会被单独统计出来。配合数据库端的慢查询日志,基本能锁定是哪条 SQL 出了问题。
慢 SQL 的治理是个大话题,常见思路无外乎加索引、改写 SQL、拆大查询、引入缓存。但落到连接池层面,至少要把慢 SQL 监控阈值设好,让它成为日常巡检的一部分,而不是等事故发生了再回头翻。
4. 根治实践:参数调整 + 代码层防治 + 告警闭环
4.1 一套可参考的连接池参数与计算逻辑
那次事故之后,我把核心服务的连接池参数整体重调了一遍。这里不能直接照抄别人的值,因为每个服务的 QPS、单请求数据库耗时、业务峰谷都不一样,但计算逻辑可以复用:
估算方式:
maxActive约等于高峰期QPS × 单次业务平均占用连接秒数,再乘 2~3 倍余量。比如高峰期 QPS 200,平均每个请求占用连接 50ms,理论并发连接数约 10,乘余量后maxActive定 30 比较稳。
我当时给其中一个核心服务定的参数如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| initialSize | 5 | 启动时预创建的连接数 |
| minIdle | 10 | 最小空闲数,防止突发流量没连接可用 |
| maxActive | 30 | 根据压测和 QPS 估算出来 |
| maxWait | 30000 | 获取连接最大等待 30 秒,超过即报错 |
| timeBetweenEvictionRunsMillis | 60000 | 每 60 秒扫描一次空闲连接 |
| minEvictableIdleTimeMillis | 300000 | 空闲 5 分钟以上的连接才考虑回收 |
| validationQuery | SELECT 1 | 轻量探活 SQL |
| testWhileIdle | true | 空闲时探活 |
| testOnBorrow | false | 获取连接时不额外探活,省开销 |
| testOnReturn | false | 归还时不探活 |
| keepAlive | true | 保持最少连接数存活 |
这里要注意,testOnBorrow设为 true 虽然能保证拿到的连接一定可用,但每次拿连接都多一次探活,高并发下对数据库是额外压力。testWhileIdle加keepAlive的组合对绝大多数场景已经足够。
4.2 连接泄漏的代码层防治
光调参数不行,代码里的泄漏源必须堵上。那次事故后我们做了三件事:
第一,全面排查项目中所有手写 JDBC 的地方,能改 try-with-resources 的全改掉。这个工作量大,但收益直接。
第二,打开logAbandoned: true。这样当 Druid 强制回收连接时,会把当时的调用栈打出来,方便定位是哪段代码长期占用连接。这个日志在排查泄漏时价值巨大,相当于 Druid 帮你把"谁借了不还"给标记了出来。
第三,引入连接池使用率的监控告警。Druid 的监控数据可以通过StatFilter的统计数据对外暴露,接入 Prometheus 的druid插件后,把"活跃连接数 / maxActive"配成一个指标。我定的告警规则是:活跃连接占比连续 5 分钟超过 80% 就发警告,超过 95% 直接进入 P1 告警。这样再也不用等业务方先发现系统卡了,我们能在连接池崩溃前 10 到 20 分钟收到预警。
4.3 建设监控告警闭环
这里多说一句:排查 Druid 事故,千万不要只盯着应用日志。数据库端的连接数趋势、网络抖动情况、应用所在机器的文件句柄数,都可能跟连接池异常有关。
我遇到过一次很隐蔽的情况:应用所在容器的文件句柄数满格,导致新建数据库连接时无法创建 socket,Druid 的连接数骤降,服务同样报获取连接失败。当时监控页面里连池子大小都不正常,后来一查是日志文件没做轮转,把文件句柄耗尽了。这类问题跟连接池参数没关系,属于运维层面,但也侧面说明故障排查要有多维度视角。
那次事故的完整修复方案包括:参数调优、泄漏代码修复、慢 SQL 治理、监控告警上线。做完这四件事之后,同一个服务再没有因为连接池问题崩过。
5. 进阶实践:结合若依框架做数据库密码加密
5.1 为什么要在配置层加密数据库密码
事故过后,团队复盘提了一个很尖锐的问题:"现在生产环境的数据库密码是明文放在配置中心里的,如果代码仓库泄露或者服务器被入侵,数据库直接裸奔,要不要顺便把密码加密做了?"
很多项目的application.yml或application-druid.yml里,数据库密码就是一串明文。稍微好一点的会放到配置中心或环境变量里,但本质上只要拿到服务器权限,就能从配置文件里捞出密码。Druid 官方提供了ConfigTools工具类,可以对数据库密码做非对称加密,私钥只保存在服务器本地,配置里只放公钥和密文,即使配置泄露,没有私钥也解不出原始密码。
对使用若依框架的项目来说,这件事尤其顺手。若依框架本身就把 Druid 集成得很完整,数据源配置集中在application-druid.yml,支持多数据源,改造起来不需要动业务代码。
5.2 ConfigTools 生成密钥的操作过程
具体操作分三步。
第一步,拿到 Druid 的 jar 包,执行命令行生成密钥对和密文:
java -cp druid-1.2.20.jar com.alibaba.druid.filter.config.ConfigTools 你的数据库密码命令执行后,控制台会输出三样东西:
privateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ... publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... password: OKb8o1w4iHj3kI9H8h/Y0A7uD8H6KbO5qC7y8x9s...第二步,把publicKey和password配置到若依框架的application-druid.yml里。注意privateKey千万不要放到配置文件或代码仓库里,它只应该以环境变量的形式存在于服务器上。
第三步,修改 Druid 的过滤器配置,把解密开关打开,并指定公钥。在若依框架里,配置大致是这样的:
spring: datasource: druid: # 主库数据源 master: url: jdbc:mysql://localhost:3306/ry?useUnicode=true&characterEncoding=utf8 username: root password: OKb8o1w4iHj3kI9H8h/Y0A7uD8H6KbO5qC7y8x9s... driver-class-name: com.mysql.cj.jdbc.Driver # 公钥配置 public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... # 开启 config 过滤器 filter: config: enabled: true connection-properties: config.decrypt=true;config.decrypt.key=${spring.datasource.druid.public-key}这里有个容易踩坑的地方:若依框架的数据源是通过DruidDataSourceBuilder.create().build()创建的,配置项前缀是spring.datasource.druid.master,public-key如果放在 master 节点外面,不一定能被自动绑定。稳妥做法是把公钥作为环境变量注入,然后通过${}占位符引用,例如:
connection-properties: config.decrypt=true;config.decrypt.key=${DRUID_PUBLIC_KEY}然后在服务器环境变量里设置DRUID_PUBLIC_KEY。这样配置文件和代码仓库里就只有一段占位符,解密密钥只在运行环境里存在。
5.3 加密后如何验证
改完配置重启应用,先看日志里有没有报ConfigFilter相关的异常。如果公钥配错或密文不完整,Druid 启动时就会报解密失败,根本不会等到第一个请求。
更直观的验证方式是打开 Druid 监控页面,进数据源信息页签。如果能看到数据源正常初始化、连接池成功建连,说明解密链路是通的。再用一个需要查数据库的接口跑一遍,确认业务没受影响,整个加密改造就算完成了。
留一个额外的安全建议:如果项目里用了配置中心,比如 Apollo、Nacos,加密改造后不要把privateKey也放进配置中心。原则是——配置中心可以存公钥、密文、连接地址,但私钥永远只存在于服务器本地文件或环境变量里。
6. 踩坑实录与日常巡检清单
6.1 我踩过的三个典型的坑
第一个坑是removeAbandonedTimeout设太小。当时为了尽快清理连接泄漏,把超时时间设成了 60 秒,结果有一些正常的业务长事务(比如批量导入、报表导出)被 Druid 误判成泄漏直接回收连接,导致事务中途失败,数据回滚,业务方投诉了好几次。这个参数默认 300 秒是有道理的,除非确认线上没有长事务,否则不要盲目调小。先定位泄漏代码、修复泄漏源头,再考虑用强制回收兜底。
第二个坑是开启监控页面的url-pattern配置错了。有一次把stat-view-servlet的路径配成了/*,导致所有请求都走了 Druid 的 servlet filter,静态资源加载变慢,接口响应也多了额外耗时。正确做法是/druid/*这种独立路径,并且web-stat-filter的exclusions要把静态资源后缀排除干净。
第三个坑是升级 Druid 版本后没有回归测试。低版本升级到 1.2.x 时,默认参数有变化,比如keepAlive的默认行为、Wall Filter 的拦截规则都可能调整。直接上生产后出现了一批 SQL 被 wall 过滤器拦截的报错。后续我再升级版本,都会先在测试环境跑一遍完整的 SQL 回归用例,重点看慢 SQL 和异常 SQL 的表现。
6.2 一套实用的日常巡检做法
经历过那次事故后,我整理了一套连接池巡检习惯,放在这里供参考:
- 每天早上看一眼 Druid 监控页面的活跃连接曲线,重点看夜间是否有异常爬升。
- 每周检查一次 SQL 监控里的慢 SQL 列表,新增的慢 SQL 要及时处理。
- 每周抽查代码仓库里新增的 JDBC 操作,确认都走了 try-with-resources。
- 每月检查一次连接池参数的合理性,结合业务量变化做微调。
- 每个季度至少做一次故障演练,人为制造连接泄漏,验证告警和快速定位能力。
- 数据库密码加密改造越早做越好,等出事了再补,代价远大于提前预防。
这里额外说一句:Druid 监控页面的数据是进程内统计,应用一重启数据就清零了。如果团队需要长期保存 SQL 性能数据做对比分析,可以考虑用 Prometheus 采集 Druid 的指标,或者定期导出监控数据到日志系统,不然历史趋势丢了,一些问题很难复盘。
6.3 说到底,Druid 崩了不是 Druid 的错
回到开头那句话,Druid"崩了"的瞬间确实吓人,但事后复盘会发现,几乎所有连接池事故都逃不出三个原因:代码泄漏、参数失配、慢 SQL 拖累。Druid 本身只是忠实地执行了配置——你把池子设成 20,到了 20 就线程排队,排队超时就报错,这套行为逻辑是清晰且符合预期的。真正要反思的,是我们有没有给它配一个合理的池子,有没有盯紧池子里的水位变化。
那次事故给我最深的体会是:连接池参数不是配一次就完事的配置项,它跟业务 QPS、慢 SQL、数据库负载强相关,需要持续观察、持续微调。而 Druid 监控页面,就是观察这口池子最好的窗口。打开它,读懂它,配合告警和加密加固,Druid 才能从"背锅侠"变成真正可靠的基础设施。希望这篇文章能帮你在下次线上炸锅之前,先把池子看住。