一、现象

订单关联数据变更监控任务(每天凌晨 3 点执行),部署到测试环境后第一次跑就报错

16:57:06.050 [qu_Worker-2] ERROR c.a.d.p.DruidDataSource  [handleFatalError,1921] - {conn-10001} discard
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet successfully received from the server was 10,007 milliseconds ago. The last packet sent successfully to the server was 10,016 milliseconds ago.
	at com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException(SQLError.java:174)
	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:64)
	at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:916)
	at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354)
	at com.alibaba.druid.filter.FilterChainImpl.preparedStatement_execute(FilterChainImpl.java:3446)
	at com.alibaba.druid.filter.FilterEventAdapter.preparedStatement_execute(FilterEventAdapter.java:434)
	at com.alibaba.druid.filter.FilterChainImpl.preparedStatement_execute(FilterChainImpl.java:3444)
	at com.alibaba.druid.filter.FilterEventAdapter.preparedStatement_execute(FilterEventAdapter.java:434)
	at com.alibaba.druid.filter.FilterChainImpl.preparedStatement_execute(FilterChainImpl.java:3444)
	at com.alibaba.druid.proxy.jdbc.PreparedStatementProxyImpl.execute(PreparedStatementProxyImpl.java:152)
	at com.alibaba.druid.pool.DruidPooledPreparedStatement.execute(DruidPooledPreparedStatement.java:483)
	at com.sun.proxy.$Proxy138.insert(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:272)
	at com.baomidou.mybatisplus.core.override.MybatisMapperMethod.execute(MybatisMapperMethod.java:59)
	at com.baomidou.mybatisplus.core.override.MybatisMapperProxy$PlainMethodInvoker.invoke(MybatisMapperProxy.java:148)
	at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:89)
	at com.sun.proxy.$Proxy680.upsertChangedOrders(Unknown Source)
	at com.bluebull.system.service.impl.OrderChangeRecordServiceImpl.checkAndRecordChange(OrderChangeRecordServiceImpl.java:32)
	at com.bluebull.quartz.task.OrderChangeMonitorTask.monitorOrderChange(OrderChangeMonitorTask.java:37)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)

连接才空闲 10 秒 就断开,并非经典的 “8 小时闲置被 MySQL 断开”。


二、关键线索:10 秒 vs 8 小时

因素 是否吻合 10 秒断开
MySQL wait_timeout=28800 ❌ 8 小时,对不上
OS TCP KeepAlive 默认 2 小时 ❌ 来不及防护
网络中间设备(SLB / NAT / 防火墙)空闲超时 ✅ 通常 10–60 秒
JDBC 驱动 socketTimeout 默认 0 = 无超时 ✅ 驱动不主动感知断连,沿用 OS 行为

排查方向:中间设备先断 + 驱动不感知 = 任务跑起来才暴露


三、根因(源码依据)

验证版本:com.mysql:mysql-connector-j:8.0.33

3.1 默认值就是 0(不限超时)

com/mysql/cj/conf/PropertyDefinitions.java

288: new IntegerPropertyDefinition(PropertyKey.connectTimeout, 0, RUNTIME_MODIFIABLE, ...)
306: new IntegerPropertyDefinition(PropertyKey.socketTimeout,  0, RUNTIME_MODIFIABLE, ...)
参数 默认 单位 范围
connectTimeout 0 永不超时 毫秒 [0, Integer.MAX_VALUE]
socketTimeout 0 永不超时 毫秒 [0, Integer.MAX_VALUE]

⚠️ “0 = 无超时”——不显式配置等于把超时交给 OS TCP 默认行为(Linux 由 tcp_syn_retries 决定,约 75~127 秒)。

3.2 参数确实是 JDBC URL 参数(大小写敏感)

com/mysql/cj/conf/PropertyKey.java

101: connectTimeout("connectTimeout", true), //   ← 第二个 true = 大小写敏感
218: socketTimeout ("socketTimeout",  true), //

SocketTimeout=... 这种写法会被识别为 unknown property 并静默失效。

3.3 实际调用点

connectTimeoutcom/mysql/cj/protocol/StandardSocketFactory.java

127: int connectTimeout = pset.getIntegerProperty(PropertyKey.connectTimeout).getValue();
153: this.rawSocket.connect(sockAddr, getRealTimeout(connectTimeout));

最终落到 JDK Socket#connect(SocketAddress, int),控制 TCP 三次握手阶段超时。

socketTimeoutcom/mysql/cj/protocol/a/NativeSocketConnection.java

65: int socketTimeout = propSet.getIntegerProperty(PropertyKey.socketTimeout).getValue();
66: if (socketTimeout != 0) {
68:     this.mysqlSocket.setSoTimeout(socketTimeout);

最终落到 JDK Socket#setSoTimeout(int),控制单次 read() 阻塞超时。 NativeProtocol#executeCommand() 在执行 query 时还会被 Statement#setQueryTimeout 临时覆盖,结束后恢复。

3.4 调用链总览

JDBC URL "?connectTimeout=3000&socketTimeout=30000"
   ↓ ConnectionUrlParser 解析
PropertyKey 枚举(大小写敏感字符串)
   ↓ PropertyDefinitions 注册(默认 0、范围、CATEGORY_NETWORK)
RuntimePropertyImpl.getValue()
   ├─► StandardSocketFactory#connect()    → Socket.connect(addr, connectTimeout)
   └─► NativeSocketConnection#connect()   → Socket.setSoTimeout(socketTimeout)
       NativeProtocol#executeCommand()     → Socket.setSoTimeout(queryTimeout) / 恢复

结论:不配置 = 驱动永远等下去。中间设备 10 秒断开 → 死连接残留在池中 → 下次借出执行 SQL 才报 Communications link failure。


四、解决方案

JDBC URL 关键三参数:

url: jdbc:mysql://<host>:<port>/<dbname>?
  useUnicode=true&characterEncoding=utf8&
  zeroDateTimeBehavior=convertToNull&useSSL=true&
  serverTimezone=GMT%2B8&
  connectTimeout=30000&     # 🔴 建连超时(30s)
  socketTimeout=30000&      # 🔴 读写超时(30s)
  tcpKeepAlive=true         # 互补:OS 层心跳保活
参数 作用 必要性
connectTimeout=30000 TCP 握手最长 30s,防止建连卡死 🔴 必配
socketTimeout=30000 单次 I/O 最长 30s,让驱动快速感知死连接 🔴 必配
tcpKeepAlive=true OS 周期发心跳包,对中间设备保活而非感知 ⚠️ 互补

三者关系socketTimeout 让驱动 fail-fast,tcpKeepAlive 让中间设备别断;不是二选一。


五、实践效果

阶段 URL 配置 凌晨任务结果
修复前 tcpKeepAlive=true,无 timeout 参数 ❌ 每次必报 CommunicationsException(10s 断连后下次借出立刻爆)
修复后 增加 connectTimeout=30000&socketTimeout=30000 ✅ 任务连续稳定运行(驱动可在 30s 内识别死连接并由连接池补连)

事实依据:socketTimeout=30000 配置后,setSoTimeout(30000) 通过 NativeSocketConnection.java:68 生效,下次 read() 阻塞超 30s 即抛 SocketTimeoutException,连接被驱动判定失效并关闭,连接池补连新建可用连接。


六、各层超时职责分工

参数 控制什么
连接池(Druid / HikariCP) maxWait / connectionTimeout 从池中获取连接的最大等待(应用层)
JDBC 驱动 connectTimeout 建立 TCP 连接的最大等待(驱动层)
JDBC 驱动 socketTimeout 单次 I/O 的最大等待(套接字层)
MySQL 服务端 wait_timeout / interactive_timeout 服务端空闲断开(DB 端)
OS tcp_keepalive_* TCP 层活性探测(OS 内核)

调优原则socketTimeout 必须大于业务最慢 SQL 的 p99 × 2,否则慢查询会被驱动判超时,反而放大故障面。


七、Druid 双保险(推荐补充)

druid:
    testOnBorrow: true                       # 借出时 SELECT 1 验证(防 testWhileIdle 检测窗口期)
    testWhileIdle: true
    timeBetweenEvictionRunsMillis: 10000     # 检测周期 10s,匹配中间设备超时

代价:每次借出多一次 SELECT 1(约 1ms 损耗),凌晨任务完全可接受。


八、最终推荐配置

spring:
    datasource:
        type: com.alibaba.druid.pool.DruidDataSource
        driverClassName: com.mysql.cj.jdbc.Driver
        druid:
            initialSize: 5
            minIdle: 10
            maxActive: 20
            validationQuery: SELECT 1 FROM DUAL
            testWhileIdle: true
            testOnBorrow: true
            timeBetweenEvictionRunsMillis: 10000
            url: jdbc:mysql://<host>:<port>/<dbname>?
                useUnicode=true&characterEncoding=utf8&
                zeroDateTimeBehavior=convertToNull&useSSL=true&
                serverTimezone=GMT%2B8&
                tcpKeepAlive=true&
                connectTimeout=30000&socketTimeout=30000

九、经验总结

  1. socketTimeout 不是"超时控制",是"死连接探测器"——默认 0 = 驱动永不感知断连。
  2. 参数大小写敏感SocketTimeoutsocketTimeout,建议从常量类引用避免笔误。
  3. MySQL 8.0.13+ 驱动才支持 tcpKeepAlive,老驱动需升级。
  4. 看清错误中的"空闲多久"(10s vs 8h)能快速分流定位:
    • 小时级 → 经典 wait_timeout 问题;
    • 秒级 → 中间设备 + 驱动 socketTimeout 缺失。
  5. testOnBorrow=true 是双保险,对凌晨低频任务零成本。

一句话:远程 MySQL(尤其经过 SLB/NAT)JDBC URL 必加 connectTimeout + socketTimeout,源码实证默认 0=永不超时,不配等于裸奔。


附录:源码版本

源码 jar:mysql-connector-j-8.0.33-sources.jar

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐