☰
Druid连接池在Maven项目中的参数配置、监控与排障指南
2026/11/2 12:36:49 网站建设 项目流程

1. 连接池不是可选项,是 JDBC 项目的入场券

这篇聊的是在 Maven 中使用 Druid 数据库连接池这件事,从依赖坐标怎么定、参数怎么算,一直讲到监控台怎么开、线上出问题怎么排查。如果你手头有一个基于 Maven 构建的 Java 项目,正在用原生的DriverManager取连接,或者用着某个默认连接池但从来没看过它的参数,那这篇内容基本可以照着抄。文章面向的是已经能写 CRUD、但对连接池内部机制只停留在“配个 URL 就能跑”这一层的开发者,也照顾到需要给团队定参数基线、要处理线上连接打满告警的同学。

我先把结论摆前面:连接池解决的不是“性能优化”这种锦上添花的问题,而是“系统能不能扛住并发”的生死线问题。一个没有连接池的 Web 应用,在几十个并发请求下就会开始出现响应时间毛刺,上百并发基本上就是雪崩。原因不复杂,后面细说。而 Druid 之所以在很多项目里成为默认选择,也不是因为它性能最强,而是因为它把“监控”和“诊断”这两件事做进了连接池本身,这对排障的价值远超那一点点吞吐差异。

1.1 每次新建连接,成本到底花在哪儿

很多人对“建连接很贵”这句话没有体感,觉得不就是 new 一个对象吗。DriverManager.getConnection()背后干的事情远比想象中多:先要跟数据库服务端建立 TCP 连接,三次握手;然后发送认证包,服务端校验用户名密码、查询账号权限、加载该账号可见的库表元数据;再初始化会话变量、字符集、时区、事务隔离级别;最后才是返回一个可用的连接句柄。整个过程在网络条件正常的情况下通常要几毫秒到几十毫秒,跨机房或者 TLS 加密的情况下会更久。

单看一次几十毫秒好像不算什么,但问题在于它是串行阻塞的。一个请求进来,从建立连接到执行 SQL 再到关闭连接,如果建连占了 20ms,SQL 只花了 2ms,那 90% 的时间都浪费在“开门”上。并发一上来,连接建立的耗时还会因为服务端要维护大量握手而进一步拉长,形成正反馈式的恶化。

连接池的思路就很朴素:连接用完不真的关掉,而是还回池子里,下一个请求直接拿现成的。这就像去银行办事,每次去都重新开卡建档,和提前办好一张卡反复用,效率差着数量级。池子里的连接数是有上限的,这个上限同时保护了应用(不会把线程全耗在等连接上)和数据库(不会被瞬间打满max_connections)。

这里有个常见误解:连接池不是越大越好。连接数超过数据库的处理能力之后,多出来的连接只会在服务端排队,反而增加上下文切换开销。

1.2 为什么最后落到 Druid 上

Java 生态里的连接池选择不算少,常见的有 HikariCP、DBCP2、C3P0、Druid,还有 Tomcat JDBC Pool。每一款都有自己的定位,选型的时候要看的不是“谁跑分高”,而是“我的团队需要什么”。

连接池性能表现监控能力代码体量适用场景
HikariCP极高基础指标,需接 Micrometer精简追求极致吞吐、已有完善监控体系
DBCP2中等弱中等老项目沿用、依赖简单
C3P0偏低弱较大历史项目维护
Druid良好内置 Web 监控台、SQL 统计、慢 SQL较大需要自带诊断能力、SQL 审计、中小团队快速排障

我选 Druid 的核心原因有三个。第一是内置监控台,挂上去就能看到当前活跃连接数、等待线程数、累计执行 SQL 次数、慢 SQL 明细,不需要额外接一套 APM 就能定位“连接被谁借走没还”这类问题,这对没有专职运维的团队价值极大。第二是SQL 层面的统计和防火墙,wall过滤器能拦掉一些明显的注入式写法,stat过滤器能把执行次数、最慢耗时、执行错误都记下来。第三是诊断日志足够啰嗦,出问题时它会明确告诉你“discard connection”“wait millis 60000, active 20, maxActive 20”,比某些池子静默超时好排查太多。

代价也要说清楚:Druid 的代码量比 HikariCP 大一个数量级,极端高并发下的吞吐确实略逊;它对配置项的容忍度也不高,配错了不会报错,只会在运行时表现为各种奇怪现象。所以在 Maven 里引它,版本和参数都得认真对待。

2. Maven 依赖怎么引、版本怎么定

2.1 最小依赖集合与坐标

纯 Java 项目里,Druid 本身只需要一个坐标,没有强制传递依赖,这一点很省心。在pom.xml的<dependencies>里加上:

<dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency>

数据库驱动要单独引,Druid 不会帮你带。以 MySQL 为例,新版驱动坐标已经从mysql:mysql-connector-java迁移到了com.mysql:mysql-connector-j,老坐标仍然能用但已经不推荐:

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.3.0</version> <scope>runtime</scope> </dependency>

驱动的作用域建议写runtime,因为业务代码里正常情况下不应该出现任何com.mysql.*的 import,所有操作都通过javax.sql.DataSource和标准 JDBC 接口完成。写runtime能在编译期就把这种耦合挡住。

如果你用的是 Spring Boot,还有一个更省事的选择druid-spring-boot-starter,它把DruidDataSource的自动装配、监控 Servlet 的注册、配置项前缀映射都做好了:

<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency>

这里有个版本对齐的坑:starter 的版本要和 druid 核心包的版本保持一致,不要出现 starter 是 1.2.20、核心包被别的依赖拽到 1.1.x 的情况。用mvn dependency:tree -Dincludes=com.alibaba:druid查一下实际生效的版本,比看 pom 里写的更可靠。

2.2 版本选择与依赖冲突排查

版本号这块我的建议是:用当前最新的 1.2.x 稳定版,但不要用刚发布一周以内的版本。连接池这种东西一旦出问题影响面是全站,没必抢第一个吃螃蟹。1.2.x 系列相比 1.0.x 修复了大量keepAlive、phyTimeoutMillis相关的边界问题,还补了不少ValidConnectionChecker的实现,老版本在 MySQL 8 上容易出现连接有效性判断失效的情况。

依赖冲突最常见的三种表现:

第一种,项目里同时存在druid和druid-spring-boot-starter,两者版本不一致,运行时可能加载到旧的核心类。解决办法是在父 pom 里用<dependencyManagement>统一锁定版本,子模块只写 groupId 和 artifactId。

第二种,某些中间件或者老框架会传递依赖一个很老的druid版本,比如 1.0.9。这个时候mvn dependency:tree会看到同一条路径上出现两个版本,Maven 按“最近优先”原则只保留一个,很可能保留的是你不想要的那个。显式在依赖里排除掉传递版本即可。

<dependency> <groupId>某个中间件坐标</groupId> <artifactId>xxx</artifactId> <exclusions> <exclusion> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> </exclusion> </exclusions> </dependency>

第三种,日志门面冲突。Druid 支持slf4j过滤器,如果你的项目里同时有 log4j 和 logback,可能出现ClassNotFoundException或者日志重复输出。这种情况把filters里的slf4j保留,同时确保只有一个日志实现即可。

一个很实用的自检习惯:本地启动时在日志里搜一下DruidDataSource的初始化日志,它会打印实际生效的版本和全部参数。参数和你预期的不一样,说明配置没被读到,这时候再去看配置文件才有意义。

3. 核心参数:每一个都对应一个真实场景

Druid 的参数表很长,但真正决定行为的就是那么十来个。我按“连接数量”“有效性检测”“回收与保活”三组来讲,每一组都说明它解决的是什么问题,以及数值该怎么算。

3.1 连接数量三兄弟怎么算

initialSize、minIdle、maxActive是三个必须一起看的参数,单独调某一个基本没意义。

initialSize是池子初始化时预先建立的物理连接数。设成 0 意味着完全懒加载,第一个请求进来才建连,代价是首次访问特别慢;设成和minIdle一样,启动时就把连接备齐,服务一起来就能扛流量。生产环境我一般设 5 到 10,不要设成最大值,否则应用一启动就给数据库来一次连接风暴,多个实例同时重启时数据库可能直接拒绝服务。

minIdle是最小空闲连接数,池子会保证至少留这么多空闲连接。它和keepAlive强相关,后面会讲。

maxActive是最大连接数,也是最需要计算的参数。计算逻辑不是拍脑袋,而是要跟数据库侧的max_connections对齐:

单实例 maxActive × 应用实例数 + 其他系统占用 + 运维预留 ≤ 数据库 max_connections

举个具体的例子。数据库max_connections是 500,同一套库上还有一个报表服务占了 30 个连接,留 50 个给运维和临时排查,那么业务侧可用的上限是 420。如果业务应用部署 6 个实例,每个实例maxActive就不该超过420 ÷ 6 = 70,实际配置时再打个八折取 50 到 56,留出突发余量。

单个实例到底需要多少连接,还有一个更细的算法:连接数 ≈ CPU核心数 × 2 + 磁盘数。这个公式来自数据库侧的经验值,因为连接真正在干活的时间只占其生命周期的一部分,大部分时间在等 IO。一个 8 核的机器跑 20 个连接通常已经足够,把maxActive设成 200 的收益很小,反而会让数据库的连接管理开销变大。

maxWait是获取连接的最大等待时间,单位毫秒。当池子里没有空闲连接、活跃数已达maxActive时,请求线程会在这里排队,超过这个时间抛GetConnectionTimeoutException。这个值要跟你的接口超时对齐:如果网关超时是 3 秒,maxWait设成 60 秒毫无意义,线程早就被网关掐了,还占着 Tomcat 的工作线程。我一般设 1000 到 3000 毫秒,让失败快速暴露出来。

提示:maxWait调小之后,GetConnectionTimeoutException的日志会变多,这是好事而不是坏事。它说明连接池在保护系统,而不是让请求无限堆积。

3.2 超时与有效性检测

连接池里最隐蔽的问题不是“拿不到连接”,而是“拿到一个已经死掉的连接”。物理连接可能因为数据库重启、中间网络设备清理空闲会话、服务端wait_timeout到期等原因被单方面关闭,而池子这边还以为它是活的。检测机制就是解决这个问题的。

validationQuery是检测连接是否有效的 SQL。MySQL 用SELECT 1,其他数据库按方言调整。虽然新版 Druid 在部分驱动上能自动推断,但显式配置更可控,也避免驱动升级后行为变化。

testWhileIdle建议设为true,这是性价比最高的检测方式:只有当连接空闲时间超过timeBetweenEvictionRunsMillis时,后台线程才会去检测它。检测发生在“用之前”,但又不是每次都用,兼顾了可靠性和性能。

timeBetweenEvictionRunsMillis是后台检测线程的运行间隔,默认 60 秒,我一般设 30 到 60 秒。这个值必须小于数据库的wait_timeout(MySQL 默认 28800 秒,但很多云数据库会改小到几百秒),否则连接在被检测之前就已经被服务端踢了。

testOnBorrow和testOnReturn我建议都设false。设成true意味着每次借出、归还都要执行一次validationQuery,高并发下这就是白白多出来的往返开销。有了testWhileIdle加phyTimeoutMillis,这两个开关没有打开的必要。

phyTimeoutMillis是物理连接的最大存活时间,超过这个时间的连接会被丢弃重建,默认 -1 表示不限制。这个参数专门对付“中间设备静默断开空闲连接”的情况。有些网络设备会在连接空闲 5 分钟或 30 分钟后清理会话表项,两端都不知道连接已经断了,这时候testWhileIdle检测出来的连接其实是“TCP 层看起来还活着”的假活连接。设一个比设备空闲超时更短的值,比如 1800000 毫秒(30 分钟),能有效规避这类问题。

3.3 保活、泄漏回收与 PSCache

keepAlive是 Druid 1.0.28 之后提供的保活开关,开启后后台线程会定期对空闲连接发一次轻量探测,防止连接因为长时间不用被服务端回收。但它有一个很容易踩的坑:keepAlive只对minIdle范围内的空闲连接生效。很多人minIdle设成 0 或者 1,然后打开keepAlive发现没效果,原因就是池子里的空闲连接早就超过minIdle被判定为“多余”,在minEvictableIdleTimeMillis到期后被销毁了,根本轮不到保活。想让保活真正起作用,minIdle至少设成和日常并发所需连接数相当的量级。

removeAbandoned是泄漏回收开关,配合removeAbandonedTimeout(单位秒)和logAbandoned使用。开启后,如果一个连接被借出超过指定时间还没归还,池子会强制回收它,并在日志里打印借出时的堆栈。这个功能在排查泄漏时是神器,但有一个必须警惕的副作用:长事务会被误杀。如果业务里存在执行几分钟的批量任务,而removeAbandonedTimeout设成 180 秒,那这个任务手里的连接就会在中途被强行拿走,表现为“连接已关闭”异常。所以要么把超时设得远大于最长事务时间,要么干脆关掉这个开关,改用监控台观察活跃连接趋势。

poolPreparedStatements在 MySQL 场景下我建议设false。MySQL 驱动自身有cachePrepStmts机制,Druid 的 PSCache 再叠一层容易出现游标泄漏和内存膨胀,而且 MySQL 的预编译语句收益本来就有限。这个参数在 Oracle 场景下才有明显价值。与之相关的maxPoolPreparedStatementPerConnectionSize也就一并保持默认或 0。

filters是过滤器配置,逗号分隔。stat用于统计,wall用于 SQL 防火墙,slf4j用于把内部日志输出到应用日志。三个都开的话内存占用会上升,但换来的是完整的可观测性。需要注意的是wall开启后对 SQL 语法有校验,某些动态拼接的 SQL 可能被误判拦截,上线前要在预发环境跑一遍全量业务。

4. 三种落地方式,按项目形态挑

同一个连接池,在不同的项目结构里配置方式完全不同。这里给三种最常见的方式,从最简单的单文件工具类到 Spring Boot 集成,按需选择。

4.1 纯 Java 硬编码方式

适合小工具、命令行程序、单元测试。核心就三步:创建DruidDataSource、设置参数、调用init()。

import com.alibaba.druid.pool.DruidDataSource; import javax.sql.DataSource; public class DataSourceHolder { private static final DruidDataSource DS = new DruidDataSource(); static { DS.setUrl("jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false"); DS.setUsername("demo"); DS.setPassword("******"); DS.setDriverClassName("com.mysql.cj.jdbc.Driver"); DS.setInitialSize(5); DS.setMinIdle(5); DS.setMaxActive(20); DS.setMaxWait(3000); DS.setTimeBetweenEvictionRunsMillis(60000); DS.setMinEvictableIdleTimeMillis(300000); DS.setValidationQuery("SELECT 1"); DS.setTestWhileIdle(true); DS.setTestOnBorrow(false); DS.setTestOnReturn(false); DS.setPoolPreparedStatements(false); DS.setKeepAlive(true); DS.setPhyTimeoutMillis(1800000L); try { DS.setFilters("stat,slf4j"); DS.init(); } catch (Exception e) { throw new IllegalStateException("连接池初始化失败", e); } } public static DataSource get() { return DS; } }

这段代码有几个细节值得说一下。init()必须显式调用,DruidDataSource不会在第一次getConnection()时自动初始化,如果漏了这一步,initialSize就不生效。setFilters必须在init()之前调用,init()之后再设过滤器会抛异常。phyTimeoutMillis是long类型,传 int 虽然有自动提升但写法上容易让人误以为是秒。

还有一个不那么明显的点:这个静态初始化的写法,在类加载失败时会抛ExceptionInInitializerError,堆栈信息会被吞掉一层。生产环境里建议把init()包一层 try-catch 转成明确的异常,日志里能看清到底是 URL 错了还是密码错了。

4.2 properties 配置文件加工厂模式

项目里不想硬编码,就把参数外置到druid.properties,用DruidDataSourceFactory加载。这种方式在非 Spring 的传统项目里很常见。

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false username=demo password=****** initialSize=5 minIdle=5 maxActive=20 maxWait=3000 timeBetweenEvictionRunsMillis=60000 minEvictableIdleTimeMillis=300000 validationQuery=SELECT 1 testWhileIdle=true testOnBorrow=false testOnReturn=false poolPreparedStatements=false keepAlive=true phyTimeoutMillis=1800000 filters=stat,slf4j connectionProperties=druid.stat.mergeSql=true;druid.stat.slowSqlMillis=2000

加载代码:

Properties props = new Properties(); try (InputStream in = DataSourceHolder.class.getClassLoader() .getResourceAsStream("druid.properties")) { props.load(in); } DataSource dataSource = DruidDataSourceFactory.createDataSource(props);

这里的头号大坑是键名大小写。DruidDataSourceFactory是通过反射调用 setter 来赋值的,键名必须严格匹配属性名,也就是驼峰写法initialSize,写成initial-size或者initial_size都不会报错,只是静默失效。同理,maxActive不能写成max-active。这个坑极其隐蔽,因为配置读进去了、池子也起来了,就是参数不生效。

第二个坑是filters里的过滤器如果加载失败(比如类路径下没有对应的实现),createDataSource会直接抛异常,而不是忽略。这在裁剪依赖的时候容易踩到。

第三个是connectionProperties的值本身是分号分隔的键值对,写在 properties 文件里时要注意特殊字符转义问题。稳妥的做法是尽量简化,只保留mergeSql和slowSqlMillis这两个最常用的。

实操心得:写完 properties 之后,在启动日志里核对 Druid 打印的参数快照,重点看maxActive、minIdle、validationQuery三项。这三项对不上,基本就是键名写错了。

4.3 Spring Boot 下的 starter 集成

Spring Boot 项目用 starter 是最省事的,配置写在application.yml里,自动装配会处理掉大部分细节。

spring: datasource: type: com.alibaba.druid.pool.DruidDataSource druid: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false username: demo password: "******" initial-size: 5 min-idle: 5 max-active: 20 max-wait: 3000 time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false pool-prepared-statements: false keep-alive: true phy-timeout-millis: 1800000 filters: stat,slf4j connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=2000 stat-view-servlet: enabled: true url-pattern: /druid/* login-username: monitor login-password: "******" allow: 127.0.0.1 reset-enable: false web-stat-filter: enabled: true url-pattern: /* exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 2000 wall: enabled: false

注意这里有两套写法容易混:filters: stat,slf4j是 Druid 原生的过滤器开关,而filter.stat.enabled是 starter 提供的细粒度配置。两者可以并存,但filter下面的配置更细,能单独控制慢 SQL 阈值和是否打印日志。wall我默认关掉,因为它的误伤率取决于业务 SQL 的写法,需要单独验证后再开。

还有一个隐含前提:使用druid-spring-boot-starter时,项目里不要同时引入spring-boot-starter-jdbc里的默认连接池配置类去覆盖DataSource。如果启动日志里看到的是HikariPool而不是 Druid 的初始化信息,说明type没生效或者被其他自动配置抢先了。排查方法是看DataSource的实际类型,或者直接看启动时有没有打印 Druid 的参数列表。

5. 把监控台开起来,这是 Druid 最值钱的部分

连接池参数配好了只是能用,真正让 Druid 区别于其他池子的是它自带的监控台。挂上去之后,连接被谁借走、哪条 SQL 最慢、当前有多少线程在排队,全都一目了然。

5.1 Servlet 与 Filter 的注册方式

非 Spring Boot 的项目,在web.xml里注册两个组件:

<servlet> <servlet-name>DruidStatView</servlet-name> <servlet-class>com.alibaba.druid.support.jakarta.StatViewServlet</servlet-class> <init-param> <param-name>loginUsername</param-name> <param-value>monitor</param-value> </init-param> <init-param> <param-name>loginPassword</param-name> <param-value>******</param-value> </init-param> <init-param> <param-name>allow</param-name> <param-value>127.0.0.1</param-value> </init-param> <init-param> <param-name>resetEnable</param-name> <param-value>false</param-value> </init-param> </servlet> <servlet-mapping> <servlet-name>DruidStatView</servlet-name> <url-pattern>/druid/*</url-pattern> </servlet-mapping> <filter> <filter-name>DruidWebStatFilter</filter-name> <filter-class>com.alibaba.druid.support.jakarta.WebStatFilter</filter-class> <init-param> <param-name>exclusions</param-name> <param-value>*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*</param-value> </init-param> </filter> <filter-mapping> <filter-name>DruidWebStatFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

这里要特别留意包名。Java EE 迁移到 Jakarta EE 之后,Servlet API 的包名从javax.servlet变成了jakarta.servlet,Druid 对应的类也分了com.alibaba.druid.support.http.StatViewServlet和com.alibaba.druid.support.jakarta.StatViewServlet两套。用错了不会报编译错误,只会在访问时 404 或者 500。判断依据很简单:项目里 import 的是哪个包,就用哪一套。

allow参数一定要配,最好只允许内网网段或者本机访问。监控台默认没有强认证,暴露在公网等于把数据库的 SQL 明细和连接状态公开了。resetEnable设成false也很重要,否则任何人都能点一下“重置”把统计数据清零,排查问题时数据就没了。

Spring Boot 项目不用手写这些,靠stat-view-servlet和web-stat-filter两段配置就够了,starter 会自动注册ServletRegistrationBean和FilterRegistrationBean。

5.2 监控台里该盯哪几个数

监控台页面很多,但实际排障常用的就那么几个:

数据源页面的“活跃连接数”曲线。这条线长期贴近maxActive,说明连接不够用或者有泄漏。如果它呈现“锯齿状”——涨到顶、掉下来、再涨到顶,通常是某个定时任务在批量取连接,属于正常;如果它一路上涨不回落,基本可以确定是泄漏。

“等待线程数”。这个值大于 0 就说明有请求在排队等连接,持续时间越长问题越严重。它和maxWait是配套看的,等待线程数飙升往往伴随GetConnectionTimeoutException。

SQL 监控页的“执行时间”排序。按平均耗时或者最大耗时倒序,能看到哪些 SQL 拖慢了整体。配合slowSqlMillis配置,超过阈值的 SQL 会被单独记录,包含完整 SQL 文本和执行次数,这是优化索引最直接的输入。

“执行数”和“错误数”。错误数突然上涨,可能是连接失效导致的,也可能是 SQL 本身报错。点进去能看到具体异常。

一个实战技巧:把/druid/datasource.json这个接口接进你自己的健康检查,定期拉取ActiveCount和WaitThreadCount上报到监控系统,比人肉登录页面看可靠得多。

6. 踩坑实录与排查手册

参数和配置讲完了,真正耗时间的是出问题之后的定位。下面这几类是我遇到过最频繁的。

6.1 连接泄漏:池子被慢慢抽干

表现很典型:系统跑上几个小时或者一两天之后,接口开始变慢,然后大面积超时,重启就恢复,过一段时间再来一次。监控台上活跃连接数持续爬升不回落,最终顶到maxActive。

泄漏的根因几乎都是“借了没还”。JDBC 里需要关闭的资源有三个:ResultSet、Statement、Connection,任何一个漏关都会导致连接无法归还。常见写法错误有这么几种:在 try 块里return导致finally里的关闭逻辑被跳过;在 catch 里重新抛异常但没关连接;用连接池包装的对象做了多层嵌套,只关了最外层。

定位手段是把removeAbandoned和logAbandoned打开:

DS.setRemoveAbandoned(true); DS.setRemoveAbandonedTimeout(300); DS.setLogAbandoned(true);

开启之后,超过 300 秒还没归还的连接会被强制回收,同时在日志里打印借出时的完整堆栈。拿着这个堆栈去代码里搜,基本能精确到行。注意这个功能只应该作为排查期的临时手段,长期开着有误杀长事务的风险,定位到问题后就应该关掉。

根治办法还是规范资源管理。Java 7 之后一律用 try-with-resources:

String sql = "select id, name from demo_user where id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, userId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 处理结果 } } }

这个写法无论中间是否抛异常,三个资源都会按相反顺序关闭,连接一定会归还。团队里如果有代码规范检查,把“禁止手动 close”写进规则里,比事后排查省事得多。

另外一个容易被忽略的泄漏点是事务没提交也没回滚。某些框架里手动开了事务,代码走到一半抛异常,catch 里只打了日志没回滚,连接一直挂在事务中,归还时池子做清理会失败,这个连接就废了。

6.2 数据库侧把连接掐了

症状和泄漏很像但成因完全相反:活跃连接数并不高,但每隔一段时间就集中报一批“连接已关闭”“Communications link failure”的错误,过一会儿又恢复正常。

这类问题的根源通常在中间链路。数据库服务端的wait_timeout到期会关闭空闲会话;负载均衡设备、防火墙、容器网络都有空闲连接清理策略,短则 5 分钟长则 1 小时。池子这边不知道连接已经断了,下次借出去用的时候直接报错。

处理思路是三层防线一起上:

第一层,testWhileIdle=true配合timeBetweenEvictionRunsMillis小于服务端的空闲超时,让池子在借出之前就发现死连接。

第二层,keepAlive=true配合足够的minIdle,让常驻的空闲连接定期被探测,不停刷新活跃状态。这里再强调一次,minIdle太小的话保活不生效。

第三层,phyTimeoutMillis设一个比中间设备空闲超时更短的值,主动淘汰老连接重建,不给设备清理的机会。

三层的优先级是这样的:如果中间设备超时未知,phyTimeoutMillis是最可靠的一道;如果服务端wait_timeout明确,testWhileIdle就够了。实际配置里我一般三个都开,代价只是后台多几个定时任务。

排查时可以先确认一件事:错误日志里的异常是不是集中在应用长时间无请求之后的第一批请求上。如果是,基本可以断定是空闲连接失效,而不是池子配置错误。

6.3 常见问题速查表

现象大概率原因排查动作
启动即报GetConnectionTimeoutExceptionURL、账号密码错误,或数据库不可达用命令行客户端验证连通性,看 Druid 初始化日志的异常根因
运行中偶发GetConnectionTimeoutExceptionmaxActive偏小、存在泄漏、慢 SQL 占住连接看监控台活跃连接与等待线程曲线,按耗时排序 SQL
配置的参数不生效properties 键名大小写错误,或配置文件未被加载对比启动日志里打印的参数快照
监控台 404Servlet 未注册,或用了错误的 javax/jakarta 包检查 web.xml 或 starter 配置,确认包名与项目一致
监控台能打开但数据全空WebStatFilter未生效,或请求被 exclusions 排除检查 filter 的 url-pattern 与排除列表
日志大量discard connectionvalidationQuery执行失败,连接确实已失效检查网络链路、phyTimeoutMillis、服务端空闲超时
定时任务批量执行时连接打满批处理一次性取了过多连接分批处理,控制单次批量大小,避免和在线业务抢连接
批量任务中途报“连接已关闭”removeAbandoned误杀了长事务连接调大removeAbandonedTimeout或关闭该功能
内存持续增长poolPreparedStatements开启导致 PSCache 膨胀关闭 PSCache,改用驱动自身的语句缓存

7. 一份可以直接抄的生产参数基线

讲完原理和坑,最后给一份我常用的参数组合。这不是万能配置,但作为一个起点是可靠的,落地时按实际压测结果微调。

7.1 参数基线与取值理由

参数建议值取值理由
initialSize5启动不至于给数据库造成连接风暴,又能快速响应首批请求
minIdle与日常并发所需相当,如 10保证keepAlive有作用对象,避免空闲连接被全部淘汰
maxActive按max_connections反推,通常 20 到 50超过数据库处理能力的连接只会增加排队开销
maxWait2000 到 3000 毫秒与接口超时对齐,快速失败优于线程堆积
timeBetweenEvictionRunsMillis60000小于服务端空闲超时,兼顾检测及时性与后台开销
minEvictableIdleTimeMillis300000空闲五分钟以上的多余连接才回收,避免频繁抖动
validationQuerySELECT 1足够轻量,能验证连接可用性
testWhileIdletrue性价比最高的检测方式
testOnBorrow/testOnReturnfalse避免每次借还都多一次往返
keepAlivetrue主动维持常驻空闲连接
phyTimeoutMillis1800000比多数网络设备的空闲清理阈值短,主动重建
poolPreparedStatementsfalseMySQL 场景下驱动自带缓存更合适
removeAbandoned排查期开启,稳定后关闭长事务场景下长期开启有误杀风险
filtersstat,slf4j统计与日志,wall按业务验证后再开
connectionPropertiesdruid.stat.mergeSql=true;druid.stat.slowSqlMillis=2000合并同构 SQL,慢 SQL 阈值两秒

关于connectionProperties里的mergeSql多说一句。它会把参数不同但结构相同的 SQL 合并统计,比如select * from t where id=1和select * from t where id=2会被归到同一条。开不开取决于你的使用习惯:想看“哪类 SQL 最慢”就开,想看“具体哪条 SQL 执行最多”就关。合并之后慢 SQL 日志里记录的是带占位符的 SQL,定位具体参数值需要看执行明细。

7.2 上线前必须做的三件事

第一件事,压测验证连接数。用接近生产峰值的并发跑一轮,看监控台的活跃连接峰值和等待线程峰值。如果等待线程长时间大于 0,先别急着调大maxActive,去看是不是有慢 SQL 占着连接不放。加连接是最贵也最没技术含量的解法,往往治标不治本。

第二件事,模拟数据库重启。在预发环境手动重启一次数据库,观察应用能否自动恢复。这个测试能暴露很多配置问题:有的配置在数据库重启后会持续抛异常直到应用重启,有的能自动重连。恢复时间主要取决于timeBetweenConnectErrorMillis和connectionErrorRetryAttempts这两个参数,默认值一般够用,但值得实测一次。

第三件事,把监控接入告警。连接池这种组件,出问题是渐进的,等人发现接口变慢已经晚了。把活跃连接数(超过maxActive的 80% 告警)、等待线程数(大于 0 持续一分钟告警)、以及GetConnectionTimeoutException的日志计数接进监控,能在用户感知之前介入。

最后一个提醒:改了连接池参数一定要重启验证。Druid 的多数参数在init()之后是只读的,运行期修改不会生效,也不会报错,很容易误以为改成功了。

我个人在实际操作中的体会是,连接池配置这件事,八成的精力应该花在“确认服务端和中间链路的超时是多少”上,两成才花在调参数上。见过太多项目把maxActive从 20 调到 200 之后问题依旧,最后发现是一条忘加索引的查询占着连接跑了几十秒。参数是护栏,真正决定通行效率的还是路上有没有事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询