
今天接手了一个上了年龄的项目,用的是 SSM,数据库连接池用的 druid,数据库版本也比较老,原本一路跑得虽然不算新潮,但也算安稳。结果这次一升级,把 MySQL 从 5.0 提到了 8.0,系统表面上看起来风平浪静,实际上却像一位年纪不小的老伙计忽然换了新环境,嘴上说着“没事没事我能适应”,转头就开始时不时掉链子。
一开始真的没看出有什么问题。项目能启动,接口能调通,页面也能正常出数据。那种感觉就像你搬了新办公室,电脑通电、网络正常、显示器也亮了,于是你拍了拍桌子,觉得今天这次迁移大概比想象中顺利。可开发里最怕的往往就是这种“刚开始一切正常”,因为很多问题不是一上来就跳出来吓你,而是躲在角落里,专门等你放松警惕之后突然伸脚绊你一下。
这个问题就是这样来的。
大概每次无连接两分钟之后,数据库连接就会自动断开。刚开始前端妹子过来说接口报错的时候,我心里还只是轻轻抖了一下,没有完全意识到事情的性质。因为后台日志本来就刷得很多,那会儿我没第一时间盯住具体异常,只是顺手让前端妹子帮忙截个图,想看看到底报了什么。结果她过了一会儿跟我说,哦,又好了。
这句话一出来,我心里其实已经有点不对劲了。
开发里有一种很微妙的危险信号,就是“它不是一直坏,它是隔一阵坏一次,而且刷新一下又恢复”。这种问题特别会伪装。因为它不给你持续性崩溃的压迫感,也不让你一眼看出明确规律,它像一个捣乱但不恋战的小毛病,专挑你不方便的时候蹦出来一下,然后又若无其事地缩回去。你本来还想说也许只是偶发,可它偏偏又会在之后重复几次,像是在故意提醒你:别装没看见,我还在。
果然,类似的情况又出了几次。每次都是接口突然报错,但只要一刷新,往往又恢复正常。到了这时候,我才真正意识到,这不是普通的偶发异常,而是连接层面出了问题。因为如果刷新一下就能恢复,那大概率不是业务逻辑永久性出错,而是某些资源在空闲一段时间后失效了,新的请求过来又重新建立连接,所以看起来像“抽风”一样,时好时坏。
于是我开始认真盯异常信息。
最开始我复制下来的核心报错,是这一段:
Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure
The last packet successfully received from the server was 8848 milliseconds ago. The last packet sent successfully to the server was 8848 milliseconds ago.看到 Communications link failure 这几个词的时候,问题方向一下子就清楚了很多。它不像 SQL 语法错了那样会直接指向某条语句,也不像字段不匹配那样会直白地告诉你哪里不兼容。它更像数据库连接站在网络那头,隔着一段时间冷不丁传来一句:“不好意思,我这边这条通道已经断了。”
这种错最烦人的地方就在于,它不总在高并发、重负载的时候出现,反而更喜欢出现在“闲了一会儿再访问”的时候。系统平时跑着没问题,一旦空闲一阵子,再来请求,就容易踩雷。活像一个睡着了的门卫,别人刚走他不拦,等你隔了半天再回来敲门,他突然发现自己钥匙丢了。
我开始网上搜解决方案。结果你也知道,这类问题一搜,出来的答案五花八门,但高频建议通常都集中在连接池配置上。很多文章都提到,要在 druid 的配置里加校验相关属性,比如:
<property name="testOnBorrow" value="true" />
<property name="testWhileIdle" value="true" />乍一看很有道理。testOnBorrow 的意思大致就是每次从连接池里借连接时先检查一下;testWhileIdle 则是在空闲时做连接检测。逻辑上听起来很靠谱,像是在连接正式上岗前先过安检、空闲时再安排体检,尽量避免把坏掉的连接交给业务线程去用。
于是我就把这两行加进了原来的 druid 配置里:
<!-- 数据库连接池 -->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSourc
e" destroy-method="close">
<property name="url" value="${jdbc.url}" />
<property name="username" value="${jdbc.username}" />
<property name="password" value="${jdbc.password}" />
<property name="driverClassName" value="${jdbc.driver}" />
<property name="maxActive" value="10" />
<property name="minIdle" value="5" />
<property name="testOnBorrow" value="true" />
<property name="testWhileIdle" value="true" />
</bean>加完之后,我当时心里其实已经开始提前庆祝了,觉得这回多半能消停。可现实很快就给了我一个朴素的提醒:别急着高兴,问题没那么听话。
因为无事发生。
不是那种“问题解决了所以无事发生”,而是那种“你明明改了配置,但两分钟后它依旧准时出来报错”的无事发生。
这一下就有点尴尬了。那种感觉特别像你认真给旧机器上了润滑油,结果它还是在原来的时间点准时嘎吱作响。你开始意识到,这问题可能不是简单加两个检测开关就能糊过去的。连接池当然有责任,但根因大概率还在数据库连接本身的空闲失效机制、驱动兼容或者连接检测策略没完全打通这几个方向上。
于是我开始换思路。
既然它的表现是“空闲一段时间后连接断开”,那我能不能反着来,不让它真的空闲那么久?既然它两分钟左右断开,那我是不是只要在这两分钟之前,主动给数据库发一次很轻量的请求,让连接一直处于活跃状态,就能绕过去?
这个念头一冒出来,思路突然就通了。
就像冬天里一辆老车容易熄火,那你就别让它长时间完全冷下来;它不是怕跑,它是怕冷。数据库连接很多时候也是这样,你让它一直保持最低限度的“呼吸”,它反而不容易掉。
于是我想到了最经典也最轻量的方式:执行 select (1)。
这是数据库世界里非常有代表性的一种“我还活着吗”问候语。它没有业务意义,不碰复杂表结构,不依赖额外逻辑,执行代价又极低,唯一的任务就是让连接维持存在感。可以说它就像一个值班室里每隔一段时间响一次的签到铃,告诉系统:这条连接还在,不要把它当成彻底沉寂的对象。
然后我就写了个定时任务:
@Resource
RefreshMysqlConnectionMapper refreshMysqlConnectionMapper;
@Scheduled(cron = "*/60 * * * * ?")
public void refreshMysqlConnection() {
System.out.println("-----------------Performed task scheduling and maintained database connection" ); // 每60秒执行一次
refreshMysqlConnectionMapper.refreshMysqlConnection();
}思路非常简单粗暴。既然两分钟左右会掉,那我每 60 秒执行一次,始终赶在它失联之前,先把这根线轻轻拽一下。这样 MySQL 和服务端之间的连接就不会长时间完全闲置,自然也就不那么容易断开。
定时任务当然还得配上对应的 Spring 配置,于是我把任务调度也开了:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:p="http://www.springframework.org/schema/p"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:task="http://www.springframework.org/schema/task"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task-4.2.xsd">
<context:component-scan base-package="com.ruben.task" />
<!-- 开启task注解驱动 -->
<task:annotation-driven />
</beans>然后在 RefreshMysqlConnectionMapper.xml 里面,SQL 语句就写了最朴实的一条:
<select id="refreshMysqlConnection" resultType="int">
select (1)
</select>整套方案看下来,可以说是非常朴素,甚至朴素到有点“土法炼钢”的意思。没有复杂的连接池深层参数调优,也没有去大改数据库服务端设置,就是简单地给连接加了一次定时“报平安”。
但你别说,它真的好使。
加上之后,再也没出现过那种空闲一阵子之后数据库自动断开的错误。系统像是终于被摸准了脾气,不再隔三差五给人来一下惊吓。前端妹子也不用一边报错一边又跟我说“诶怎么刷新一下又好了”,我自己的心脏也终于不用隔一阵就被接口异常轻轻捏一下。
当然,冷静下来讲,这并不是最标准、最优雅、也未必是最根本的解法。它更像是一种在现实条件受限情况下的临时但有效的工程处理方式。
因为这类问题从根上讲,确实还有其他解决路径。比如去调整数据库配置,把无连接自动断开的时间设置得更长;或者检查 MySQL 8.0 和当前老项目中的驱动、连接池版本是否完全兼容;再或者进一步完善 druid 的连接校验 SQL、空闲回收策略、心跳检测机制,让连接池自己更聪明地发现并剔除失效连接。
这些路子理论上都更“正规”。
但开发现场很多时候就是这样,理想方案和你当下能做的方案,未必是同一个东西。比如这次,数据库层的配置我其实也想到过,改断开时间、调服务端参数,这些都可能更直接。但无奈公司的 DBA 不在,数据库我碰不了。权限不在你手里,再正确的方案也只能先停留在脑子里。这时候你能做的,就不是站在原地感叹“如果能改数据库就好了”,而是得赶紧在应用层找一条能走通的路。
所以最后我用了定时执行 select (1) 这种办法。它不是最华丽的,但它能解决问题;它不是最彻底的,但它在当前场景里足够有效。
这其实也是接手老项目时非常常见的一种状态。你面对的不是一个可以按照理想架构自由调整的新系统,而是一套已经运行多年、依赖关系复杂、权限边界清晰、甚至还带点历史包袱的老系统。它像一位有年纪的老师傅,不会因为你理论上知道更优方案,就立刻按你的想法配合。你得先顺着它的脾气来,先让它稳住,再慢慢谈优化。
从这次问题里,我自己最大的感受是:老项目升级最怕的,不是一上来就启动失败,而是这种“能跑,但跑得不稳”的隐性故障。它不像直接报错那样干脆,反而更考验人对异常规律的敏感度。前端说“刚才报错了,现在又好了”的那一刻,其实就已经是非常关键的信号。那不是问题变轻了,而是问题开始具备“时序规律”了。一旦一个 bug 和“空闲多久”“多久访问一次”“刷新后恢复”这种时间特征挂上钩,排查方向就该往连接、会话、缓存、池化资源这些地方靠。
现在回头看,这次处理过程其实很有代表性。
先是发现异常现象;
再是收集错误信息;
接着按常规思路去调连接池配置;
发现无效后,再根据“空闲两分钟后断开”这个关键特征转换思路;
最后用定时任务保活把问题压住。
整个过程没有什么惊天动地的大招,更多是一种典型的工程排障节奏:先观察,再假设,再验证,最后在现实限制下选一个当下最可行的方案。
问题解决之后,确实会有一种松口气的感觉。不是因为这个方案有多漂亮,而是因为你终于把那个隔一阵就跳出来吓人的隐患按住了。对于线上系统来说,很多时候“稳定”本身就是最有价值的结果。
当然,如果后面条件允许,我还是会更希望把这个问题从根上再梳理一遍。比如确认 MySQL 8.0 驱动版本、druid 参数、服务端超时配置之间的关系,把这次临时保活方案升级成更规范的连接管理方案。因为临时方案能救火,但长期维护还是得靠系统性调整。
不过在当下,能先把火灭掉,已经很重要了。
好在,问题最后确实解决了。
这也让我再次感受到,开发这件事很多时候并不是比谁一下子就能给出最标准答案,而是比谁能在限制条件里更快找到有效答案。老项目不会因为你想优雅就自动变优雅,线上故障也不会因为你理论懂得多就自己消失。很多时候,真正能帮你渡过难关的,恰恰是那些看起来不那么华丽、但足够接地气的处理方式。
这次的 select (1),就是这样一个办法。
不惊艳,但靠谱。
不完美,但有效。
对于当时的我来说,它已经足够好了。