Spring Cloud 服务突然连不上 Redis 和数据库?这套排查思路送给你
前两周我这边一套 Spring Cloud 微服务在下午高峰期突然开始连环报错,日志里同时出现 Redis 连接失败、MyBatisSystemException、Failed to obtain JDBC Connection 三兄弟,新进来的请求几乎全军覆没。当时群里已经有人喊"重启大法",我一个做运维的老同事也准备直接重启服务。我说先别急,这三个错同时出现,大概率不是 Redis 和数据库同时挂了,而是某个底层因素把两条链路一起拖垮了。最后果然不是因为外部中间件宕机,而是我们自己埋的坑。
这篇文章我就把整套排查思路、底层原理和实际操作过程完整记录下来。不管你是刚接触 Spring Cloud 的初学者,还是已经在生产环境里踩过坑的同学,看完之后遇到这类报错都能有条理地排查,而不是靠重启碰运气。整套方案不依赖具体某个框架版本,Spring Boot 2.x 和 3.x 都能适用。
1. 三个异常同时出现,先别急着重启
1.1 先看现场:这些报错长什么样
我先把当时日志里最扎眼的几段贴出来,你对照一下自己遇到的场景。
Redis 客户端用的是 Lettuce,报错是这样:
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to localhost/127.0.0.1:6379数据库这边,HikariCP 的报错是:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.MyBatis 层把上面的异常包了一层:
org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.exceptions.PersistenceException: nested exception is org.springframework.dao.DataAccessResourceFailureException: Failed to obtain JDBC Connection看到这个组合,很多人的第一反应是"中间件全挂了"。但请注意Connection is not available, request timed out after 30000ms这句话,它的意思是连接池里拿不到空闲连接,等 30 秒也没等到。这说明数据库服务可能根本没问题,问题出在连接池本身——要么被占满了,要么拿连接的操作被什么东西卡住了。
1.2 理顺异常链:谁是谁的根因
这三个异常不是并列关系,而是层层嵌套的关系,这一点特别重要。
从最外层看,业务代码调用 Mapper 接口时,MyBatis 会通过 SqlSessionTemplate 执行 SQL,这个过程中如果底层拿不到数据库连接,MyBatis 就会抛 MyBatisSystemException。而 MyBatisSystemException 只是"包装袋",里面真正的内容是 Failed to obtain JDBC Connection,也就是连接池获取连接失败。
Redis 连接失败则是另一条独立的链路,但经常和数据库连接失败同时出现,原因不是巧合,而是通常有一个共同的"放大器"。
我做了个简单类比:MyBatisSystemException 就像你去饭馆吃饭,等了一个小时没上菜,服务员告诉你"后厨出不了餐"。你真正要排查的不是服务员的态度,而是后厨——也就是数据源连接池。如果同一时间 Redis 也连不上,那相当于后厨的水电气也出了问题,你要看的是管线哪里堵了。
1.3 第一步永远不是改代码,而是分清故障范围
动手排查前,先回答两个问题:
- Redis 和数据库是部署在同一台机器上,还是分别部署?
- 报错是从某个服务实例开始,还是所有服务实例同时出现?
这两个问题的答案决定了根因方向。如果是同一台物理机,先看机器负载,CPU、内存、磁盘 I/O 任何一个打满都会让两个中间件同时"假死"。如果是从某个实例开始,多半是那个实例的连接池出了问题。如果是所有实例同时报错,那基本可以确定是网络或者中间件本身的故障。
我见过最离谱的一次,是因为服务器磁盘满了,Redis 的 AOF 持久化写不进去,加上数据库 binlog 也写不进去,两个中间件同时进入不可用状态。如果当时直接重启服务,重启完还是连不上,白白浪费几分钟关键时间。
2. Redis 连接失败的排查套路
2.1 一条命令先确认 Redis 本身是死是活
排查 Redis 连接问题,永远先从服务端开始验证。在应用所在机器上执行:
redis-cli -h 127.0.0.1 -p 6379 ping能正常返回PONG,说明 Redis 服务本身没问题。如果这一步就失败,再执行:
telnet 127.0.0.1 6379检查端口是否可达。telnet都连不上,问题出在网络层(防火墙、安全组)或者 Redis 进程没起来。用ps -ef | grep redis确认进程是否存活,redis-cli info检查内存使用情况,CONFIG GET maxmemory看内存限制。
这里有个容易忽略的点:即使 Redis 服务正常,如果应用所在机器的网络与 Redis 之间有防火墙规则,连接同样会超时。公司内部网络的机器变更、安全组策略调整,都是引发突发连接失败的常见原因。
如果 Redis 需要密码认证,用命令行验证时要加上密码参数:
redis-cli -a 你的密码 ping如果密码配置错了,应用日志里会出现NOAUTH Authentication required或者WRONGPASS invalid username-password pair。这类报错和"连接失败"不一样,它表明 TCP 连接是通的,只是认证没过。排查方向完全是两码事。
2.2 配置文件里最坑的三个点
确认 Redis 服务正常后,回头看应用配置。我几乎每次排查都能从配置里找到问题,常见的有三种。
第一个是spring.redis.host配置成了localhost,而 Redis 部署在远程服务器上。开发环境用 localhost 没问题,打包部署到服务器后就废了。这种低级错误在团队成员各自维护配置文件时特别常见。
第二个是密码字段没对齐。Spring Boot 2.x 里 Redis 密码配置项是spring.redis.password,但如果你用了 Spring Cloud Config,配置中心的 key 可能被共同前缀覆盖了。我真实遇到过spring.redis.password被误写成spring.data.redis.password,Spring Boot 版本不同解析结果也不同,导致认证一直失败。
第三个是超时时间配置不合理。默认的连接超时是 60 秒,如果 Redis 压力很大或者网络不稳定,客户端可能很长时间都在等连接建立,表现就是服务响应慢、线程大量堆积。建议主动设置超时时间:
spring: redis: host: 你的redis地址 port: 6379 password: 你的密码 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: 3000ms把超时调短一点,如果 Redis 有问题就快速失败,而不是让线程池被"长等连接"的线程占满。
2.3 连接池参数引发"假死"的经典场景
Redis 连接池参数错误导致的故障,表现往往非常具有迷惑性,因为 Redis 服务端日志看起来完全正常,但应用就是连不上。
Lettuce 是 Spring Boot 默认的 Redis 客户端,基于 Netty 实现,底层复用一个连接。它的max-active参数控制的是并发命令的个数,不是 TCP 连接数。很多人在 HikariCP 用惯了连接池的概念,容易把 MySQL 连接池的参数习惯套到 Redis 上,结果调出来的参数并不合理。
有个很容易踩的坑是这样的:连接池的max-wait设置太短,比如 100ms,而某个 Redis 命令执行时间稍长,比如涉及大 key 的查询耗时 200ms,此时并发的其他请求拿不到连接,直接抛异常。日志里会出现:
RedisConnectionFailureException: Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect但实际 Redis 服务端没有任何问题,就是连接池分配不到命令执行名额。调整max-total、max-idle、max-wait后恢复正常。
2.4 序列化问题带来的伪连接异常
还有一种情况看上去像连接异常,实际上根本不是连接问题,而是序列化问题。
Spring Data Redis 提供了多种序列化器,默认的JdkSerializationRedisSerializer会把对象序列化成二进制格式。如果你在 Redis 里存的数据是字符串相关类型,但代码里读取时用一个设置了错误序列化器的 RedisTemplate,反序列化阶段就会抛异常,异常信息里可能包含:
Caused by: java.io.StreamCorruptedException: invalid stream header: 7B22636F6465223A2031看到这种日志,不懂底层的人很容易误判为 Redis 连接问题。其实 TCP 连接是好的,数据也读回来了,只是反序列化失败。
我的建议是,在配置 RedisTemplate 时显式指定 StringRedisSerializer 和 Jackson2JsonRedisSerializer,不要依赖默认的 JDK 序列化。这样不同服务之间、不同语言之间读写同一个 key 都能正常解析。
3. Failed to obtain JDBC Connection 的深层原因
3.1 连接池的工作机制决定了你会遇到哪些问题
HikariCP 是目前 Spring Boot 默认的数据库连接池,它维护了一组到数据库的物理连接。应用每次执行 SQL 不是直接新建 TCP 连接,而是从池里借一个空闲连接,用完再还回去。这个机制的目的很明确:避免频繁建连和断连的开销,提升性能。
但池化机制也带来了新的故障模式:如果池里没有空闲连接,新的请求就必须等待。HikariCP 默认的最大等待时间是 30 秒,超过这个时间就抛出SQLTransientConnectionException: Connection is not available, request timed out after 30000ms.也就是我们遇到的这个报错。
这就像银行柜台办理业务:连接池里的连接是柜台窗口,如果 100 个客户同时进来,而只有 10 个窗口,那后面 90 个人只能排队。排队超过规定时间,系统就告诉你"办理不了,下次再来"。
3.2 连接池耗尽的典型特征
连接池耗尽时有几个明显的特征,截图日志就能判断。
HikariCP 在连接池耗尽时,除了抛Connection is not available异常,还会在日志里周期性打印池状态的指标,类似:
HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=95)注意看active=10表示 10 个连接全部被占用,idle=0表示没有空闲连接,waiting=95表示有 95 个线程在排队等连接。出现这个状态,基本坐实了连接池耗尽。
连接池耗尽的直接原因通常有以下几种:
- 某个操作持有连接时间过长(慢 SQL)
- 事务中执行了外部调用,事务迟迟不提交
- 连接泄漏,用完没归还
- 并发量突增,超过了连接池容量
排查时刻重点关注活动连接数变化趋势。我习惯用 Grafana 观察数据源指标,HikariCP 提供了很完整的 Micrometer 指标,包括hikaricp.connections.active、hikaricp.connections.idle、hikaricp.connections.pending。如果活跃连接数一路走高最后触及上限,说明业务侧有"只借不还"的连接。
3.3 慢 SQL 如何压垮连接池
给大家讲一个我印象很深的案例。某服务上线一个新功能后,开始出现间歇性的Failed to obtain JDBC Connection。数据库 CPU 正常,连接数却不断上涨。
后来查到问题出在一张表数据量暴涨,某个查询没走索引,单次查询耗时十几秒。业务并发几十个请求进来,每个请求都拿着连接卡在慢查询上,连接池迅速被占满。后面的请求全在排队等连接,等不到就报错。
这个问题有个很大的迷惑性:数据库本身没有宕机,CPU 也不算高,恰恰是慢 SQL 把连接池的资源耗尽了。排查方法很直接,在数据库里执行:
SHOW FULL PROCESSLIST;能看到大量Query类型的连接长时间处于执行中的状态,Time字段显示执行耗时,Info字段显示正在执行的 SQL。找到耗时最长的几条 SQL,用EXPLAIN分析执行计划,把缺的索引补上,问题就解决了。
我记得那个查询语句条件列上有一行WHERE status = 1 AND created_at > '...',created_at 有索引,但 status 没有。数据量小的时候没问题,数据量涨到几百万行之后,查询走了全表扫描。补上(status, created_at)联合索引之后,单次查询从 12 秒降到 50 毫秒。
3.4 连接泄漏的排查手段
慢 SQL 好揪,连接泄漏就隐蔽得多。连接泄漏指的是业务代码里取到了连接,但用完之后没有归还。短时间几秒可能看不出来问题,运行一段时间后连接池就会彻底被"偷走"的连接占满。
HikariCP 自带连接泄漏检测机制。在配置中开启泄露检测时间阈值:
spring: datasource: hikari: connection-timeout: 30000 leak-detection-threshold: 60000 maximum-pool-size: 20leak-detection-threshold设置为 60000 毫秒,表示如果某个连接被借出超过 60 秒没有归还,HikariCP 会记录一条告警日志,包含当时的堆栈信息。堆栈里可以看到是哪个业务方法借了连接没还。这个机制在排查阶段非常有用,因为异常日志看不懂的时候,堆栈能帮你直接定位到具体代码行。
Spring 框架里最常见的连接泄漏来源是TransactionTemplate和@Transactional混用,以及手动DataSourceUtils.getConnection()后忘记释放。大家在写工具类时要注意,尽量交给 Spring 管理事务,不要手动获取连接。
4. MyBatisSystemException:是症状不是病因
4.1 看懂 MyBatis 的异常链
MyBatisSystemException 是 MyBatis-Spring 整合包里的异常类型,官方文档的说法是"表示在 MyBatis 操作过程中发生了无法分类的系统性错误"。这句话比较抽象,我的理解是:它不是点名某个具体技术,而是一个包装器,把底层各种异常重新包装后进行抛出。
异常链一层层打开,最内层的cause才是真相。比如我们前面遇到的场景:
MyBatisSystemException └── PersistenceException └── DataAccessResourceFailureException └── SQLTransientConnectionException └── HikariCP: Connection is not available看到SQLTransientConnectionException就知道是连接池的问题。如果最内层是SQLSyntaxErrorException,那就要检查 SQL 语句语法。如果是最内层是DataIntegrityViolationException,那是数据约束冲突。每层异常都有自己的含义,耐心剥开就行。
4.2 MyBatis 中常见的系统异常场景
除了数据库连接问题,MyBatisSystemException 还可能由下面几种情况触发,排查时要区分清楚。
第一个是 Mapper 接口和 XML 映射文件不匹配。比如 Mapper 接口方法名改了,但 XML 里的statementid 没同步改,执行时会报Invalid bound statement (not found)。这种情况不属于 MyBatisSystemException 而是 BindingException,但如果 MyBatis-Spring 版本较旧或者配置了configuration.addMapper的方式,也可能被包成 MyBatisSystemException。
第二个是参数类型不匹配。Mapper 方法传入的参数和 XML 中parameterType不一致,报TypeException,同样可能被包装。我见过一个坑,方法签名里是Map<String, Object>,XML 里用的却是单个对象属性,结果运行时报There is no getter for property named 'xx' in 'class java.util.HashMap'。
第三个是执行 SQL 时数据库连接已经失效。比如数据库发生了主从切换,旧连接被服务端断开,但连接池里的连接不知道已经失效,应用拿到旧连接执行查询时抛异常。HikariCP 虽然默认会做连接有效性检测,但检测频率不高,如果使用默认配置可能检测不到失效连接。此时配置:
spring: datasource: hikari: validation-timeout: 3000 keepalive-time: 30000keepalive-time让 HikariCP 每隔 30 秒主动发一次心跳,保持连接有效,避免拿到已失效的连接。
4.3 从堆栈快速定位问题层
排查 MyBatisSystemException 最有效的方法是看完整堆栈,不要只看第一行。我建议在日志配置里把异常堆栈完整打出来,生产环境可以用独立的 error.log 单独记录异常,避免堆栈和业务日志混在一起。
定位时有一个小技巧:先看堆栈里org.apache.ibatis和org.mybatis.spring这些包在哪一层,再一层层往下找com.zaxxer.hikari、com.mysql.cj.jdbc、redis.clients.jedis这些具体实现相关的包。Java 异常堆栈是从上往下的调用顺序,最底部的 cause 是根因。如果堆栈里有大量HikariCP相关的帧,问题基本出在连接获取阶段。如果堆栈在 MyBatis 执行器层就断了,可能问题出在 SQL 解析或参数映射阶段。
5. 一次连环故障的完整复盘
5.1 故障现象记录
我把这次故障完整复盘一遍,供各位对比。
某天 14:30 左右,业务监控突然报警,订单查询接口超时率飙升到 60%。接警后查看服务日志,发现大量MyBatisSystemException和RedisConnectionFailureException。15:00 时,服务已经出现明显的请求堆积,线程池打满。
看监控面板,服务所在服务器的 CPU 只有 35%,内存 50%,网络流量正常。数据库服务器 CPU 20%,连接数 120,上限 500,远未耗尽。Redis 服务器 CPU 35%,内存正常,INFO命令响应正常。
表面上一切正常,但服务就是连不上。当时团队里有人提议扩容服务,其实这个方向完全错了,因为即使扩容再多的实例,连接池依然拿不到连接,问题反而会蔓延到新实例。
5.2 逐步排查过程
我当时的排查顺序是:先看异常堆栈最内层,定位到SQLTransientConnectionException和RedisConnectionFailureException都是连接建立或获取超时。接着在数据库执行SHOW FULL PROCESSLIST,没有发现慢 SQL,所有连接都处于Sleep状态。
然后查看 HikariCP 指标,active=20, idle=0,最大连接池就是 20,说明连接全部被占满。但数据库侧Sleep状态说明连接没有在执行任务,而是"被借走不用"。
接着要判断这些连接是被哪些线程借走的。我们使用 HikariCP 的 leak-detection 功能,开启了leak-detection-threshold: 60000,五分钟后日志出现了泄露告警,堆栈指向一个外部接口调用。
再看下这个外部接口调用的发生位置,业务代码里有这样一个方法:
@Transactional public OrderInfo getOrderDetail(String orderId) { OrderInfo order = orderMapper.selectById(orderId); // 调用外部订单服务查询附加信息,耗时平均 8 秒 String extraInfo = restTemplate.getForObject("http://order-external-service/api/info/" + orderId, String.class); order.setExtraInfo(extraInfo); return order; }问题一目了然:@Transactional开启的事务,在方法执行期间始终占着数据库连接,而方法内同步调外部接口花了 8 秒。8 秒内又有并发请求进来,每个请求都占一个连接,连接池 20 个连接很快全被占满。
Redis 那边的情况也类似。业务代码里有个 Redis 分布式锁的工具类,锁的获取和释放之间调用了同一个外部接口,导致锁被持有 8 秒。高并发情况下,Redis 连接池也被长等待的线程耗尽。
5.3 解决方案和最终效果
找到根因后,方案就很清晰了。
第一,把外部接口调用从事务方法里移出去,先查询本地订单数据,再调用外部接口,最后用编程式事务提交本地更新。这样事务方法和调用耗时解耦,数据库连接不再被长任务占用。
第二,给外部接口调用设置合理的超时时间。RestTemplate 默认没有超时限制,业务高峰期外部接口响应慢,会一直阻塞。配置连接超时和读取超时:
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(2000); factory.setReadTimeout(3000);第三,Redis 分布式锁的释放放在 finally 块里,确保任何情况下都能释放锁,并且给锁设置合理的过期时间,防止持有锁的服务实例崩溃导致死锁。
调整完成后,压测模拟高峰流量,服务响应时间恢复正常,连接池的active连接数最高只有 8,池子不再被打满。这个 case 后续还被写进了部门的技术分享,核心结论就一句话:别在事务里做耗时的外部调用,别在锁的持有期间做不可控操作。
6. 典型报错信息速查表
日常排查时,对照日志关键词能快速缩小范围。我整理了一张表,看一眼就能定位问题的大致方向。
| 报错关键词 | 典型场景 | 排查方向 |
|---|---|---|
| Connection is not available, request timed out after 30000ms | 连接池耗尽 / 慢 SQL / 连接泄漏 | 看 HikariCP active 指标、数据库 PROCESSLIST |
| Failed to obtain JDBC Connection | 连接池获取连接失败 | 检查连接池配置、网络连通性、数据库状态 |
| Unable to connect to Redis | Redis 服务不可达或认证失败 | ping Redis、检查防火墙、检查密码配置 |
| NOAUTH Authentication required | Redis 密码错误或未填写 | 检查 spring.redis.password |
| WRONGPASS invalid username-password pair | Redis 用户名/密码错误 | 确认连接账号密码是否匹配服务端配置 |
| Could not get a resource from the pool | Jedis 连接池耗尽 | 检查 Redis 连接池 max-total/max-wait |
| invalid stream header | 反序列化失败(非连接问题) | 检查 RedisTemplate 序列化器配置 |
| Communications link failure | MySQL 连接链路异常/服务端断开 | 检查网络、MySQL 实例状态、空闲连接最大存活时间 wait_timeout |
| Invalid bound statement (not found) | Mapper 接口与 XML 不匹配 | 检查 Mapper 扫描配置和 XML namespace |
| Connection is closed | 拿到了已失效的连接 | 调整 HikariCP keepalive-time / max-lifetime |
这张表是我平时排查问题的起点,看到关键词就能快速判断该往哪个方向查。实际工作中省了很多时间。
7. 最后分享一点实操经验
踩过这么多次坑,我最大的体会是:遇到连环异常,第一件事不是修复,而是分清楚哪些是根因,哪些只是表象。很多问题看起来像中间件故障,实际是应用层调用了不该放在事务里的操作,或者是连接池配置不合理。盲目的重启服务、扩容实例,往往掩盖问题而解决不了问题的本质。
再多说一个小技巧:生产环境的服务一定要配上完整的监控指标,至少要有 HikariCP 连接池的active和idle指标、Redis 连接池的活跃连接数、线程池的活跃线程数。这三个数据在故障发生时能快速帮你判断瓶颈在哪。你可以先用下面的核心配置做兜底,避免因为连接池耗尽导致服务整体不可用:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 leak-detection-threshold: 60000 keepalive-time: 30000 connection-timeout: 30000 redis: timeout: 3000ms lettuce: pool: max-active: 8 max-wait: 3000ms最后再分享一句实在话:线上没有玄学,只有没看够的日志和没理清的依赖关系。耐心剥开每一层异常,根因一定会浮出来。