ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Druid连接池创建连接失败(SQLException)的六大根因排查与预防

Druid连接池创建连接失败(SQLException)的六大根因排查与预防

看到com.alibaba.druid.pool.DruidDataSource - create connection这行日志,很多人的第一反应是"数据库挂了"。我在处理类似问题的过程中发现,这行日志背后藏着至少六种完全不同的故障原因,从网络抖动到驱动版本不匹配,从连接池参数错配到数据库自身状态异常,每一类原因的处理方式都不一样。如果一上来就重启数据库,大概率治标不治本,问题过几个小时还会卷土重来。

这篇文章我从一次真实值班场景切入,把create connection SQLException的完整排查链路、高频根因、Druid 参数影响以及预防方案展开来讲。内容不挑框架,Spring Boot、裸 JDBC 或者其他容器里用 Druid 的都适用,适合正在被连接池报错折磨的Java后端开发者,也适合刚接触连接池概念、想理解底层机制的新手。

1. 报错现场还原:这行日志到底在说什么

1.1 一次典型报错的完整日志长什么样

先看一段我截取过的真实日志片段,这个格式非常典型:

2024-11-08 01:23:45.678 ERROR 12345 --- [http-nio-8080-exec-12] c.a.druid.pool.DruidDataSource : create connection SQLException, url: jdbc:mysql://192.168.1.12:3306/order_db, errorCode 0, state 08001 java.sql.SQLException: Communications link failure at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122) at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:828) ... Caused by: java.net.ConnectException: Connection timed out (Connection timed out) at java.base/java.net.PlainSocketImpl.socketConnect(Native Method)

注意几个关键信息:

  • errorCode 0和state 08001。08001 是 SQLState 里的"客户端无法与数据库建立连接",errorCode 0 表示 MySQL 服务端没有返回业务错误,问题大概率出在通信链路上。
  • 异常链的最底层是java.net.ConnectException。一旦看到Connection timed out或者Connection refused,就能确定是 TCP 层面没有建立成功,而不是 SQL 语法或认证问题。

create connection SQLException的完整含义是:Druid 在创建物理连接(也就是真正的 JDBC Connection)时抛出了异常。它发生在业务从连接池里getConnection()之前,是连接池在补充空闲连接或者首次初始化时触发的。

1.2 为什么"创建连接失败"不等于"数据库宕机"

这是我最早踩过的认知误区。create connection SQLException只代表"在某个时间点、从某台应用机器到数据库的某次连接尝试失败了",并不意味着数据库整个不可用。数据库可能在经历以下状态:

  • 单机过载,拒绝新建连接;
  • 正在重启,监听端口还没有起来;
  • 数据库负载均衡或代理层节点故障;
  • 网络链路闪断,应用和数据库之间的交换机短暂丢包。

有一次我们排查到凌晨,数据库服务本身一切正常,慢查询和错误日志都没有,但应用就是报create connection SQLException。后来定位到是应用所在机房的出口防火墙在凌晨执行了策略变更,把数据库端口的流量拦了几分钟。如果当时直接给数据库加索引或者重启应用,问题根本解决不了。

所以看到这行日志,第一件事不是修数据库,而是确认故障范围:是所有应用实例都报错,还是只有某一个实例报错?只有明确了范围,才能判断问题是出在数据库侧、网络侧还是单台应用机器的环境上。

2. 从堆栈往上倒的排错链路

2.1 先分辨三类连接池报错

Druid 运行时最常见的日志分三类,很多人混在一起排查,效率很低。我在维护某订单系统的时候,把这三类报错分别建了告警规则:

报错类型典型日志关键词故障方向
创建连接失败create connection SQLException数据库、网络、驱动、认证
获取连接超时get connection timeout/wait millis连接池耗尽、慢SQL、泄漏
连接泄漏告警connection leak/removeAbandoned代码未归还连接

create connection SQLException和另外两类有本质区别。它发生在"连接池试图新建物理连接"的阶段,而get connection timeout发生在"连接池里没有可用空闲连接且新建也失败"的阶段。实际场景里两者经常一起出现:先是一批create connection SQLException,接着业务线程等不到连接,开始刷get connection timeout。

如果你在日志里同时看到了两类报错,优先处理第一类。第一类是"病根",第二类是"并发症"。

2.2 抓取连接池运行态快照

定位create connection SQLException之前,先把连接池当时的状态抓出来。Druid 内置了监控接口,我习惯在排查时通过 JMX 或者应用内自定义接口拿到如下指标:

  • ActiveCount:当前活跃连接数
  • PoolingCount:池中空闲连接数
  • WaitThreadCount:等待获取连接的线程数
  • CreateCount和CreateErrorCount:累计创建成功/失败次数
  • CloseCount:累计关闭连接数

重点观察CreateErrorCount的变化曲线。如果它跟着 CPU 或网络波动一起涨,说明是间歇性故障;如果它从某个时间点开始只涨不回,说明故障是持续性的。

我自己的排查习惯是,在报错期间连续抓三次快照,间隔 30 秒。如果三次快照中PoolingCount都为 0 且ActiveCount接近maxActive,则说明连接池已经被打满,新建连接又在失败,这通常意味着数据库端还有"Too many connections"的报错在等着你。

2.3 数据库端的同步排查

应用日志只是故障的一半,数据库端的同步排查必不可少。我会同时执行以下几步:

  1. 查看数据库最大连接数和当前连接数:

    SHOW VARIABLES LIKE 'max_connections'; SHOW STATUS LIKE 'Threads_connected';

    如果Threads_connected已经接近max_connections,说明连接数被占满,新建连接会被拒绝。

  2. 查看连接来源分布:

    SELECT host, user, db, COUNT(*) FROM information_schema.processlist GROUP BY host, user, db ORDER BY COUNT(*) DESC;

    这一步可以快速确认是不是某一个应用实例占用了大量连接。

  3. 查看最近的错误日志,重点找Aborted connection、Access denied、Too many connections等关键字。

数据库端的结果能帮你把故障范围从"应用问题"和"数据库问题"里快速分开。如果数据库端一切正常,但应用就是连不上,方向就要转向网络链路和应用侧配置。

3. 高频根因逐个拆解:我踩过的和见过别人踩的

3.1 网络链路不通或防火墙拦截

create connection SQLException搭配底层异常ConnectException: Connection timed out或No route to host,基本就是网络不通。

这类问题隐蔽性很强,因为应用和数据库之间往往隔着云安全组、机房防火墙、K8s 网络策略多层。我遇到过一次很典型的场景:某开发者在本地调试正常,代码推到测试环境就报这个错,查了半天才发现是测试环境的安全组只放行了办公网 IP,应用所在 Pod 的网段根本没有加入白名单。

快速验证网络问题的手段:

# 先测端口连通性 telnet 192.168.1.12 3306 # 从应用所在的机器上测试 nc -vz 192.168.1.12 3306

如果端口不通,再用traceroute或者mtr看链路在哪一跳中断。需要提醒的是,K8s 环境里 Pod 内部不一定有telnet和nc命令,可以用curl的--connect-timeout参数做近似探测,或者临时起一个带有网络工具的调试 Pod。

提示:如果应用和数据库之间走了云服务商的内网负载均衡,别忘了检查负载均衡的健康检查是否通过。很多时候应用报连接失败,只是负载均衡后面某一台数据库节点掉线了。

3.2 MySQL wait_timeout 与空闲连接被服务端断开

这是我个人踩得最狠的一个坑,也是线上最常见的隐性问题。

MySQL 服务端有个wait_timeout参数,默认 8 小时。空闲超过这个时间的连接会被服务端主动断开,但应用侧并不知道。如果 Druid 的空闲连接回收机制没配好,池里的"僵尸连接"会被业务线程拿到,然后在真正执行 SQL 时才发现连接已死。

这种情况下报错日志往往不是create connection SQLException,而是Connection is not available, request timed out或The last packet successfully received from the server was ... milliseconds ago。但如果配合testOnBorrow=false且连接恰好在下一次创建新连接时失败,也会表现为create connection SQLException。

解决思路很简单:开启 Druid 的有效性检测,并设置合理的空闲回收频率。具体配置我在第 4 章详细说。这里先记住结论——validationQuery=SELECT 1配合testWhileIdle=true,是绝大多数 MySQL 场景下的标准做法。

3.3 认证失败与权限不足

底层异常不是网络异常,而是Access denied for user 'xxx'@'yyy'时,问题出在账号密码或权限配置上。

我在某项目组看到过一个很有意思的案例:配置文件里的密码是正确的,应用却持续报认证失败。后来发现是配置中心在发布时对密码做了特殊字符转义,#被当成注释截断了。从那以后我要求所有数据库密码在配置中心里一律用 Base64 编码,取出来时再解码,从源头避开转义问题。

另一个常见情况是数据库账号的主机限制。MySQL 账号是"用户名+来源主机"绑定的,如果应用所在机器的 IP 不在账号允许列表里,同样会报认证失败。排查时可以看 MySQL 的user表授权,确认无误后再用同一个账号在应用机器上手动跑一次客户端连接:

mysql -h 192.168.1.12 -P 3306 -u order_app -p'password' order_db

这一步如果能连上,说明账号和密码没问题,问题在应用侧的数据库连接配置。

3.4 驱动版本和数据库版本不匹配

驱动问题比网络问题更隐蔽,因为它的报错信息千奇百怪。我见过最典型的是:MySQL 5.7 的数据库,应用里用的是带cj前缀的新版驱动,同时 URL 里又没有正确配置字符集和时区参数,结果报错信息指向Public Key Retrieval is not allowed。

这个报错是因为新版 MySQL 驱动默认要求加密连接,客户端需要从服务端获取 RSA 公钥,而连接 URL 里没有开启allowPublicKeyRetrieval=true。解决方案有两个:要么在 URL 里加上这个参数,要么把useSSL=false显式声明。

另一个驱动问题在新版本数据库上更容易出现:老的驱动连接新版本 MySQL 时,可能因为服务端默认认证插件变化而报认证失败。排查这类问题最快的办法是直接看驱动版本:

mvn dependency:tree | grep mysql

对比数据库大版本,如果不匹配,升级驱动到对应的大版本系列即可。驱动版本升级属于低风险操作,但要注意升级后在测试环境跑一遍核心链路,重点看日期时间类型、时区、大字段读写结果有没有变化。

3.5 URL 参数与字符集、时区配置错位

serverTimezone和characterEncoding这两个参数是连接 URL 里最容易出错的地方。

国内服务器时区通常是Asia/Shanghai,而 MySQL 驱动默认使用服务器 JVM 时区。如果数据库连接 URL 没有指定serverTimezone=Asia/Shanghai,驱动可能读到数据库的系统时区(很多云数据库默认是 UTC),导致java.sql.SQLException: The server time zone value '...' is unrecognized这一类的创建连接失败。

推荐一套我在新项目里直接用的 MySQL 连接 URL 模板:

jdbc:mysql://192.168.1.12:3306/order_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&connectTimeout=3000&socketTimeout=60000

重点解释几个容易被忽略的参数:

  • connectTimeout=3000:TCP 连接建立的超时时间,防止数据库不可达时应用线程长时间卡死。
  • socketTimeout=60000:SQL 执行超时时间,避免慢 SQL 无限期占用连接。
  • rewriteBatchedStatements=true:批量插入时能显著提升性能,但对 SQL 兼容性有要求,需要小流量验证。

URL 参数配置属于"不报错就没人动"的部分,但恰恰是这类底层配置最容易在环境迁移、驱动升级时埋雷。建议所有连接串统一收口到配置管理平台,修改走评审流程。

3.6 数据库重启窗口期的连接风暴

数据库升级、重启、主备切换期间,create connection SQLException几乎是必然出现的。原因很简单:重启期间数据库端口没有监听,所有新建连接都会失败;重启完成后,所有应用实例会同时尝试重新建立连接池,瞬间对数据库产生连接风暴。

在某个跨机房数据迁移项目里,我经历过一次 24 个应用实例同时重连的场景。数据库主备切换完成后 30 秒内,连接数直接冲到上限,反过来触发Too many connections,形成了新的故障。

应对连接风暴的两个有效手段:

  1. 数据库侧把max_connections临时调高 1.5~2 倍,等连接池重建完再调回来。
  2. 应用侧对连接池初始化做随机延迟。Druid 没有内置初始化延迟参数,但可以在应用启动时通过@PostConstruct方法里Thread.sleep(随机毫秒)再触发数据源初始化,降低同步冲击。

4. Druid 参数配置对报错频次的决定性影响

4.1 关键参数逐个过一遍

排错排到后面,你会发现大部分create connection SQLException都是间歇性的,单独看某一次失败可能查不出问题,但长期看连接池稳定性与参数配置强相关。

我梳理了 Druid 中和连接创建、回收、检测相关的全部关键参数,整理成一张表:

参数默认值作用我常用的取值
initialSize0启动时初始化的物理连接数5
minIdle0池中最小空闲连接数5
maxActive8池中最大连接数20~50
maxWait-1获取连接的最大等待毫秒数,-1 表示无限等待3000
timeBetweenEvictionRunsMillis60s每隔多久执行一次空闲连接检测60000
minEvictableIdleTimeMillis30min连接最小空闲时间,超过才能被回收300000
validationQuery无连接有效性检测 SQLSELECT 1
testWhileIdletrue空闲检测时是否执行 validationQuerytrue
testOnBorrowfalse获取连接时立即检测有效性false
testOnReturnfalse归还连接时检测有效性false
keepAlivefalse空闲连接保活,防止被数据库断开true
removeAbandonedfalse是否清理长时间未归还的连接视情况开
removeAbandonedTimeoutMillis300s连接被认为泄漏的阈值60s

testOnBorrow=true是最安全的,每次拿到连接都验证一次,但代价是每个连接在获取时多一次 SQL 往返,高并发场景下性能损失明显。所以我默认用testWhileIdle=true,靠后台线程定期检测空闲连接,而不是在每次获取时检测。这个思路的核心是:只要空闲连接是健康的,获取时就是安全的。

4.2 一组比较稳妥的初始配置示例

以一个中等流量的订单服务为例,数据源配置可以这样写(Spring Boot + Druid starter):

spring: datasource: druid: 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 keep-alive: true pool-prepared-statements: true max-pool-prepared-statement-per-connection-size: 20 connection-error-retry-attempts: 3 break-after-acquire-failure: false

这里有一个容易被忽略但很重要的参数组合:connection-error-retry-attempts和break-after-acquire-failure。

  • connection-error-retry-attempts=3:创建连接失败后的重试次数,默认值 1。
  • break-after-acquire-failure=false:获取失败后不熔断,继续允许后续尝试。

线上遇到过一种情况:数据库短暂不可用 2 秒,但某个应用实例因为break-after-acquire-failure被置成 true,导致连接池在一段时间内直接拒绝创建新连接,恢复了也连不上,只能重启应用。所以没有特殊理由,break-after-acquire-failure保持默认的 false 即可。

4.3 参数调优的节奏与验证方法

参数调优最大的风险是一次性改动过多,出了问题不知道是哪一项导致的。我的习惯是每次只改一个参数,然后观察至少一个业务周期(通常 24 小时),看三类指标:

  1. CreateErrorCount是否有增量;
  2. 业务侧的获取连接耗时 P99 是否变化;
  3. 数据库侧的Threads_connected是否保持平稳。

改maxActive时尤其要注意:连接数不是越大越好。每一个连接在数据库端都对应一个线程和一块内存,maxActive=50在 QPS 只有几十的服务上纯属浪费资源。一个相对靠谱的估算方法是:maxActive = 核心线程数 × (单个请求占用连接时长 / 平均请求耗时)。先按这个公式估算,再用压测验证。

5. 三个实战案例复盘

5.1 凌晨批量任务引发的连接风暴

某项目的日报批处理任务每天凌晨 1 点启动,启动后 10 分钟内必现大量create connection SQLException。从日志看,报错集中在批处理任务刚开始创建数据库连接的窗口期,但数据库本身的负载并不高。

排查后发现,批处理任务在启动时一次性创建了 200 个线程,每个线程都通过同一个 Druid 数据源获取连接。maxActive只有 20,且maxWait是默认值 -1,也就是无限等待。当业务线程发现池里没有连接时,会同时发起新的物理连接创建请求,瞬间把数据库连接数打满。

修复方案分两步:第一步把批处理的并发度从 200 降到 20,与maxActive对齐;第二步把maxWait设置为 3000ms,让获取不到连接的线程快速失败,而不是无限阻塞,这样可以暴露问题而不是拖垮整个应用。改动上线后,报错消失,批处理任务时长反而因为无锁竞争减少了 30%。

5.2 网络切换后旧连接全部失效

某系统的应用和数据库分布在两个机房,中间走专线。机房网络设备升级时,应用侧连接池里的存量连接被设备静默断开,但应用不知道,还在继续复用这些连接。结果就是第一波请求全部超时,日志里大量出现create connection SQLException和Communications link failure交替出现。

当时的修复方案是调整 Druid 的保活和回收机制:

  • keepAlive: true
  • timeBetweenEvictionRunsMillis: 30000
  • minEvictableIdleTimeMillis: 180000

网络切换这类事件无法完全避免,但可以让连接池更快感知连接失效并重建。保活线程会定时对空闲连接执行validationQuery,如果检测到连接不可用,会立即关闭并创建新连接替换,避免业务线程拿到死连接。

事后我总结了一条防线:所有涉及网络变更的操作窗口,提前把连接池的空闲检测频率调高,业务低峰期再改回来。这个操作不用动代码,只需要改配置中心里的动态配置。

5.3 驱动版本过旧导致的偶发创建失败

某老项目的数据库从 MySQL 5.6 迁移到 MySQL 8.0 后,应用没有升级驱动,仍使用 5.1.x 的驱动。迁移后测试环境功能一切正常,但生产环境每天会出现几次create connection SQLException,频率不高,非常难复现。

后来在驱动 5.1.x 的官方变更说明里发现一个已知问题:老驱动与 MySQL 8.0 的 caching_sha2_password 认证插件存在兼容性问题,部分网络环境下握手会偶发失败。升级驱动到 8.0.x 后,连续观察一周,CreateErrorCount归零。

这类根因有一个共同特征:没有任何配置变更,问题却偶发出现。遇到这种情况,把驱动升级到与数据库大版本一致是最优先的尝试方向。升级前注意对比驱动版本的 API 兼容性,尤其是用到com.mysql.jdbc.Driver的代码,在 8.0 驱动里要换成com.mysql.cj.jdbc.Driver。

6. 预防体系:让这类报错不再半夜叫醒你

6.1 从日志监控到连接池指标监控

只看错误日志做告警,永远是被动的。日志里出现create connection SQLException时,故障已经发生了,业务已经开始受影响了。真正有效的预防体系要监控连接池的指标趋势。

Druid 的监控数据通过DruidStatManagerFacade暴露,Spring Boot 应用接入 Actuator 后可以直接暴露给监控系统。我建议至少采集以下指标并配置告警:

指标告警条件含义
CreateErrorCount增长率5 分钟内增长超过 3 次连接创建持续失败
WaitThreadCount大于 0 持续 1 分钟连接池打满,业务在等待
ActiveCount/maxActive比值超过 0.8连接池接近上限
maxWait实际耗时P99 超过 1000ms获取连接变慢

我自己还会把DataSource的创建失败数做成一个独立的业务指标,接入值班告警。宁可多收几条噪音,也不要漏掉一次真实故障。

6.2 健康检查与自动恢复机制

除了监控,应用侧还要有自动恢复的能力。Druid 的validationQuery和keepAlive承担了一部分恢复职责,但对于数据库长时间不可用、恢复后连接池异常的情况,光靠保活不够。

我在系统里加了一个轻量级的数据库健康检查接口,逻辑很简单:

@RestController public class HealthController { private final DataSource dataSource; public HealthController(DataSource dataSource) { this.dataSource = dataSource; } @GetMapping("/health/db") public Map<String, Object> check() { Map<String, Object> result = new HashMap<>(); try (Connection conn = dataSource.getConnection(1000)) { try (Statement st = conn.createStatement()) { st.executeQuery("SELECT 1").close(); result.put("status", "UP"); } } catch (SQLException e) { result.put("status", "DOWN"); result.put("error", e.getMessage()); } return result; } }

这个接口接入注册中心的健康检查机制后,如果数据库不可用,注册中心会自动摘除这个实例的流量,避免请求持续被打进一个连不上数据库的服务,造成更长的超时链。

6.3 故障预案和兜底策略

最后说兜底。数据库连接是不可完全避免故障的,所以应用层面要有降级思路。我在负责的系统中给核心读接口做了一层本地缓存兜底,数据库不可用时,读接口直接返回缓存数据,写接口则快速失败并记录到一个本地消息表里,数据库恢复后再补偿。

这套机制上线后,后续几次数据库重启窗口期的报错再没有影响用户侧体验。从值班的角度看,数据库重启不再需要半夜跟进处理,第二天早上看补偿任务的数据即可。

回到开头的问题——create connection SQLException本身只是一串日志,它背后的可能性太多了。每一次排查这类问题,都是在加深对应用、数据库、网络三者关系的理解。我的体会是:把连接池参数理解透,把监控指标建全,把故障预案想清楚,这类报错虽然还会出现在日志里,但你已经可以从容应对了。最后再分享一个小技巧:无论有多忙,遇到这类报错一定要把完整的堆栈和当时的连接池快照一起保存下来,它们是你复盘时最可靠的线索。

RELATED READING

延伸阅读

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