如果你搜到这篇文章,大概率是某个深夜或者大促前夕,日志里突然躺了一行RedisConnectionFailureException: Unable to connect to Redis; nested exception is java.net.SocketTimeoutException: Read timed out,然后业务方、运维、DBA 开始互相艾特。Java 连 Redis 报 timed out,表面看是“超时”两个字,但实际上根因可能分散在客户端配置、网络链路、Redis 服务端状态、代码写法四个层面。这篇文章我把这几年排查类似问题的完整思路和亲手验证过的解决方案梳理一遍,希望能帮你在下次遇到timed out时,不用再靠重启和加大超时时间“玄学修复”。
1. timed out 到底卡在哪一环:先分清超时类型再说排查
1.1 连接建立超时与读取响应超时是两码事
很多人一看到timed out就急着翻 Redis 配置、调大超时时间,这是最容易走偏的地方。timed out只是一个笼统描述,Java 应用里实际报错会分好几种,指向的故障环节完全不同。
排障第一步,先看异常堆栈里的关键短语:
java.net.SocketTimeoutException: Connect timed out:发生在 TCP 握手阶段,客户端发出 SYN 包后迟迟没等到服务端的 SYN-ACK。问题大概率在网络连通性、防火墙、服务端监听状态上。java.net.SocketTimeoutException: Read timed out:TCP 连接已经建立成功,客户端把命令发出去了,但服务端在设定时间内没有返回任何数据。问题大概率在 Redis 服务端阻塞、慢命令、网络丢包、或客户端与服务端中间的代理层断连。redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: Read timed out:Jedis 客户端最常见的外层包装,根因还是要看最后一个 cause。org.springframework.data.redis.RedisSystemException下的RedisCommandTimeoutException:Spring Data Redis 2.x 以上版本基于 Lettuce 时常见,意思是命令级别超时,默认 60 秒,部分版本是 10 秒。
为了让你一眼定位方向,我在下面整理了一个对照表。遇到报错先别慌,先回答“我现在是哪种超时”。
| 报错关键字 | 所处阶段 | 优先排查方向 |
|---|---|---|
Connect timed out | TCP 握手阶段 | 网络是否通、防火墙/安全组、Redis 是否监听在外网 IP 上 |
Read timed out | 连接已建立,等待响应 | Redis 阻塞、慢命令、大 Key、代理层超时、连接被服务端关闭 |
Command timed out(Lettuce) | 命令已发出,等待响应 | 共享连接被慢操作占住、命令排队严重、服务端吞吐不足 |
Connection reset | 连接中途被断开 | 服务端超时关闭、中间网络设备 RST、连接池回收后仍被使用 |
1.2 注意和 Redis 服务端 timeout 参数的方向区别
这里有个概念很容易被绕进去:Redis 配置文件redis.conf里有一个timeout参数,默认是 0,代表服务端不会主动关闭空闲连接。如果你把服务端timeout设置成了比如 300,指的是“客户端连接空闲超过 300 秒,服务端主动断开它”,这跟客户端等待响应的超时完全不同。
我遇到过有人把服务端timeout调到很大,想解决客户端 timed out 问题,结果根本没作用。方向反了:服务端timeout是服务端清理空闲连接的策略,而客户端 timed out 是客户端等待的阈值。这俩是两条方向相反的逻辑。
1.3 建议先做一次“格式化的报错登记”
在往下查之前,先把报错原文、出现频率、出现时间点、对应 Redis 实例、当时业务并发情况记下来。尤其是“是不是每次必现,还是偶发”。我排查过一次诡异超时,最后定位到是每小时的定时任务整点触发,那一刻 Redis 的 QPS 从 2000 飙到 4 万,触发了慢命令。没有这些登记信息,很容易在错误的方向上空转。
2. 客户端配置雷区:连接池耗尽比网络故障更常见
2.1 Jedis 默认连接池只有 8 个连接
先给你一个反直觉的事实:Java 项目里最经典的 Jedis 连接池,默认maxTotal=8、maxIdle=8、minIdle=0。注意,maxTotal=8是 JedisPool 的默认值,不是某个项目自己配的。如果你们的代码没有显式配置连接池参数,那实际可用连接数就是 8 个。
用一个具体例子算笔账:假设一个订单服务的 Redis 操作平均耗时 5ms,那么单连接的理论 QPS 大约 200。如果把 maxTotal 留在默认 8,理论最大 QPS 大约 1600。当业务瞬时并发超过这个值,新来的请求只能进入等待队列。而默认情况下maxWaitMillis是 -1,也就是无限等待,表现为所有线程卡在getResource()上,大量线程 Blocked,请求超时堆积,最后看起来就像 Redis 连接超时。
这时候如果不看线程 dump,你甚至会以为 Redis 挂了。但实际上 Redis 本身负载很低,是客户端连接池被打满了。
建议的连接池配置:
JedisPoolConfig config = new JedisPoolConfig(); // 核心是 maxTotal,不是越大越好,要和业务 QPS、Redis 实例能力匹配 config.setMaxTotal(200); // 控制 idle 连接数,避免流量低谷时大量连接空转 config.setMaxIdle(50); config.setMinIdle(10); // 拿连接池的最大等待时间,一定不要设 -1,否则线程会无限等下去 config.setMaxWaitMillis(3000); // 从池里拿连接时做一次 ping 检测,剔除坏连接,但会增加一次 RTT config.setTestOnBorrow(true); // 定期检测空闲连接是否可用,避免服务端把连接悄悄关了 config.setTestWhileIdle(true); config.setTimeBetweenEvictionRunsMillis(30000);关于 maxTotal 具体应该设多少,我的经验是按“业务峰值 QPS × 单次 Redis 操作平均耗时(秒)”得到的理论连接数,再乘 1.5~2 倍余量。比如峰值 QPS 5000、平均耗时 5ms,理论需要 25 个连接,考虑到网络抖动和 GC 停顿,设置 50~60 个比较安全。设得过高反而容易造成 Redis 侧连接数过多、文件描述符压力大、内存占用上升。
2.2 Lettuce 的共享连接模型:一个人拖垮所有人
如果你用的是 Spring Boot,那默认的 Redis 客户端其实是 Lettuce 而不是 Jedis(Spring Boot 1.x 默认 Jedis,2.x 之后默认 Lettuce)。Lettuce 基于 Netty,核心特点是一个连接可以多线程共享,默认不会像 Jedis 那样每个线程拿一个连接。
这本来是个优点:线程安全、无需连接池、资源占用少。但在实际使用中,它有一个致命场景——如果某个线程通过共享连接发起了一个慢命令,比如KEYS *或者一次获取超大 Hash 的所有字段,Netty 的 event loop 会被这个命令阻塞,后续所有线程的命令都在排队等候。等你兜不住了,就会出现雪崩式的Command timed out。
Spring Boot 2.x 时代,因为单个慢命令导致整个 Redis 集群客户端集体超时的案例非常多。解决思路有几个方向:
- 在配置层把命令超时调低,例如
spring.redis.timeout=3s,至少让单次超时的时间可控,不拖到默认 60 秒。 - 严格排查业务代码里是否存在大 Key、大集合操作、
KEYS等全局命令。 - 如果并发量很高且部分操作确实慢,可以考虑让 Lettuce 也使用连接池(
commons-pool2),隔离慢操作的影响面。
2.3 为什么重启应用后总能“好一阵子”
碰到 Redis 连接超时,很多人的惯性操作是重启应用,重启完确实能好一段时间,于是以为问题解决了。其实大部分情况下,重启只是让连接池重建、让堆积的异常连接全部清空、让 TIME_WAIT 状态的 socket 消失,属于物理层面的“眼不见为净”。
根因没解决,过几天会在某个流量高峰重新出现。我有一个客户项目,每个月都要重启一次订单服务,直到最后排查才发现是maxTotal=8加某段代码每次都申请连接不归还。所以要清醒一点:重启能恢复业务,但绝不能当修复方案。
3. 网络链路排查:从 Redis 服务端一路回溯到操作系统
3.1 先确认 Redis 服务端不是“假死”
当客户端报Read timed out时,很多人第一反应是去ping一下 Redis 服务器,发现能 ping 通,就判断网络没问题。这个判断太粗糙了。ping通只说明主机在网络层存活,不代表 Redis 进程能正常处理命令。
我习惯先用redis-cli直接连上去做一次小延迟测试:
redis-cli -h 你的RedisIP -p 6379 --latency -i 2--latency之后 Redis 会周期性地发送 PING 并统计响应延迟。正常情况下应该看到min: 0, max: 1, avg: 0.09左右这样的结果。如果 max 值跳到几十毫秒甚至几百毫秒,说明服务端存在阻塞。
同时建议看一下 Redis 的慢日志:
# 查看最近的慢命令 SLOWLOG GET 50 # 查看慢日志阈值,单位微秒,默认 10000 即 10ms CONFIG GET slowlog-log-slower-than如果发现大量大 Key 相关的慢命令,那么客户端超时的真相基本就找到了。Redis 是单线程处理命令的,一旦某个命令执行时间过长,后面所有命令都会排队,导致客户端普遍读超时。
3.2 报错 “getsockopt” 还在,问题可能出在 accept 队列
热搜词里有个Connection timed out: getsockopt,这个报错文字通常在 Linux 环境的 C/C++ 客户端或某些 Java 底层 native 库中比较明显,它的本质还是 connect 阶段失败。但这里有个容易漏掉的地方:不是 Redis 没启动,而是 Redis 的 accept 队列满了。
Linux 的 TCP 协议栈处理新连接时,会经历半连接队列(SYN Queue)和全连接队列(Accept Queue)。当 Redis 进程 accept 速度跟不上连接建立速度时,全连接队列满了,内核会直接丢弃新到达的 SYN 包,客户端表现为“连接超时”而不是“连接拒绝”。
排查方法很简单:
# 查看 6379 端口的连接队列情况 ss -lnt | grep 6379输出结果里Recv-Q表示当前 accept 队列中的连接数,Send-Q表示队列最大长度。如果Recv-Q长期接近Send-Q的值,说明 Redis 的 accept 队列已经满了。常见处理手段是调大tcp_max_syn_backlog和 Redis 侧的tcp-backlog配置。Redis 配置文件中tcp-backlog默认 511,在访问量很大时可以适当调高,同时同步调整内核参数。
3.3 bind 配置导致的外部不可连
还有一个特别经典的低级问题:Redis 服务端只监听了127.0.0.1,Java 应用部署在另一台机器上,直接连接当然超时。
# 检查 Redis 实际监听地址 ss -lntp | grep 6379如果监听的是127.0.0.1:6379而不是0.0.0.0:6379或内网 IP,那外部就是连不上的。修复方式是修改 redis.conf 里的bind配置。这一步虽然基础,但踩坑的人着实不少。
3.4 中间链路:云安全组、防火墙、LB 超时
现在大部分 Java 应用部署在云上,Redis 可能是云厂商提供的,也可能是自建的。中间链路有大量潜在点:VPC 安全组、防火墙策略、负载均衡器空闲超时、Docker/ K8s 的 NAT 映射。
我遇到过最隐蔽的一次:K8s 集群里的应用通过 Service 访问 Redis,Service 是 ClusterIP 类型,后端 Pod 在某次重启后 IP 变了,但 Service 的 Endpoint 更新存在延迟,流量被转发到一个已经不存在的 Pod IP,结果就是一部分请求连接超时。这个不涉及任何配置错误,纯粹是基础设施的一致性延迟。
所以排查网络类超时,我习惯按下面的链路逐层确认:
| 排查对象 | 检查命令操作 | 确认内容 |
|---|---|---|
| 本机到目标主机 | ping、telnet ip 6379 | 基础连通性 |
| 应用所在主机到 Redis 端口 | nc -vz ip 6379 | 端口是否可达 |
| K8s Service / LB 映射 | kubectl get endpoints | 后端是否真实存在 |
| 防火墙/安全组 | 云控制台或iptables -L | 是否有拦截规则 |
| 中间链路超时策略 | 查看 LB/网关配置 | 是否有 idle timeout 切断连接 |
3.5 Redis 自身的问题也会造成超时
除了网络和客户端,Redis 服务端自身的几个状态也会导致客户端报超时:
- 内存淘汰触发:当 Redis 内存达到
maxmemory上限,开始执行淘汰策略。如果淘汰的是超大 Key,这个删除操作可能阻塞服务端几十到几百毫秒。 - RDB 持久化 fork:
bgsave执行时 fork 子进程,如果内存占用巨大,fork 瞬间会卡顿。 - AOF rewrite:重写 AOF 文件时也有类似问题。
- 操作系统内存 swap:Redis 进程如果被交换到磁盘,那性能会断崖式下降,客户端超时在所难免。
检查 Redis 是否在这些状态下,可以用INFO命令查看instantaneous_ops_per_sec、blocked_clients、latest_fork_usec这几个指标。如果latest_fork_usec很大,说明 fork 阻塞严重;如果blocked_clients不为 0,说明有客户端阻塞在 BLPOP / BRPOP 这类阻塞命令上。
4. 藏在代码里的隐形杀手:连接泄漏与线程阻塞
4.1 连接池连接被“借走”后没有归还
前面聊了连接池参数,但参数没问题时,代码也可能把连接池“掏空”。最常见的写法错误是:
Jedis jedis = null; try { jedis = jedisPool.getResource(); jedis.set("key", "value"); } finally { // 以为释放了,其实没有显式调用 jedis.close() }或者更隐蔽的形式:getResource()成功之后发生了异常,但异常处理分支里把close()漏掉了。连接被借走但没归还,久而久之连接池被耗尽,后续请求全部等待,最终表现为超时。
正确的写法很简单,用 try-with-resources:
try (Jedis jedis = jedisPool.getResource()) { jedis.set("key", "value"); }close()方法在连接池中的语义是“归还连接”,不是真正断开连接。用 try-with-resources 可以自动保证归还。如果是自己封装了 Redis 工具类,务必检查所有 return 分支之前都执行了 close。
4.2 大 Key 与慢命令连锁反应
有一次排查一个支付服务的超时告警,发现 Redis 监控里出现了一个占用几百兆内存的 Hash Key,里面的字段数有几百万。某个定时任务每隔几分钟就执行一次HGETALL,把整个 Hash 全部拉回内存。服务端执行这个命令要数秒,期间所有其他命令全部等待,客户端一个接一个超时。
这类问题属于慢命令引发的连锁故障,排查时除了看 Redis 慢日志,还可以在代码里全局搜索这些危险命令:KEYS *、HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE 0 -1。工程上建议把这些命令在 Redis 侧直接改名(通过rename-command),让它们在线上根本无法执行,这样能在语法层面阻断误用。
4.3 业务线程阻塞连累连接池
另一种情况是 Redis 本身没问题,但业务线程池满了。举个例子:某个接口接收到请求后先操作 Redis,然后调外部 HTTP 接口,外部接口响应很慢,每个请求都占着一个 Tomcat 工作线程。这些线程持有的 Redis 连接一直不归还,等线程释放时又去执行下一条命令,连接池逐渐被占满。在现象层面,业务方会先看到“获取连接超时”或“Redis 读超时”。
排查手段是抓线程 dump:
jstack 应用PID > thread_dump.txt重点看大量处于WAITING或TIMED_WAITING状态的线程,它们在等什么锁、等什么外部资源。如果发现几百个线程都卡在同一个getResource()调用上,基本可以确认是连接池耗尽;如果线程卡在socketRead0,则要结合网络层再判断。
线程 dump 也是面试常考的点,真实处理问题的场景跟背诵八股文完全不是一回事,收藏一份排查模板会管用很久。
4.4 连接池回收策略与 TIME_WAIT 堆积
连接池参数里还有一个容易被忽略的点:如果服务端的 Redis 配置了空闲超时主动关闭连接,那客户端连接池里的连接可能已经失效。此时如果客户端继续用这个失效连接发送命令,会收到Connection reset或读超时。
这种情况需要依赖连接池的保活机制:testWhileIdle和timeBetweenEvictionRunsMillis。Jedis 连接池默认就会做空闲检测,但如果你自己封装了连接管理,一定要确认这些参数生效。另外,大量短连接频繁断开会让客户端主机累积 TIME_WAIT socket,可通过ss -s查看协议栈统计,必要时开启tcp_tw_reuse。
5. 一套可落地的调优配置与验证方案
5.1 综合配置参考
到这里,把上面所有排查路径走一遍之后,如果确实需要从配置层面优化,下面是一套我在生产环境验证过的参考配置。
Spring Boot(Lettuce 场景):
spring: redis: host: 你的Redis地址 port: 6379 # 连接超时和读超时统一设置,单位毫秒 timeout: 3000 connect-timeout: 2000 lettuce: pool: # 连接池最大连接数 max-active: 64 # 最大空闲连接数 max-idle: 16 # 最小空闲连接数 min-idle: 8 # 获取连接最大等待时间 max-wait: 3000ms # 连接建立后做一次 PING 校验 test-on-borrow: true纯 Jedis 硬编码场景:
JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(64); config.setMaxIdle(16); config.setMinIdle(8); config.setMaxWaitMillis(3000); config.setTestOnBorrow(true); config.setTestWhileIdle(true); config.setTimeBetweenEvictionRunsMillis(30000); JedisPool jedisPool = new JedisPool(config, "你的Redis地址", 6379, 2000);注意new JedisPool的最后一个参数是连接超时时间,我这里设了 2000ms,这是 TCP 建连阶段的超时,通常不需要太长。如果是跨机房访问,可以适当放宽到 3000~5000ms,但超过 5 秒仍然连不上,大概率不是延迟问题,而是网络链路本身有问题。
5.2 验证到底有没有效果:不要靠“感觉”
调完配置后,怎样确认问题真的解决了?光看业务告警消失是不够的,需要做一轮有意识的验证。
第一层验证是单命令时延。在应用服务器上用 redis-cli 配合--latency参数测试 Redis 的响应延迟,确认基准值是否正常。
第二层验证是并发压测。用自写脚本或者压测工具模拟线上高峰,观察两个指标:请求超时率和获取连接平均等待时间。如果并发打上去后超时率明显下降、等待时间在 100ms 内,说明配置基本合理。
第三层验证是观测系统层面。用jstat -gcutil看 GC 停顿是否导致 Redis 命令执行间歇性卡顿;用ss -s看 TIME_WAIT 连接是否在快速堆积;用INFO命令看 Redis 的connected_clients是否超出预期。
我在一次压测中对配置做了几组对比,效果如下:
| 配置组 | maxTotal | 并发 1000 超时率 | 获取连接平均等待 |
|---|---|---|---|
| 默认配置 | 8 | 38% | 2.7s |
| 中等配置 | 64 | 0% | 35ms |
| 激进配置 | 256 | 0% | 15ms |
可以看到,maxTotal 从默认 8 调到 64 收益巨大,但从 64 调到 256 收益很小。这符合一个基本规律:连接池尺寸够用就好,堆太大并不会带来等比例的性能提升。
5.3 监控比事后排查更重要
最后说一句,这类超时问题之所以难排查,很多时候是因为没有提前埋好监控。最简单的做法是给 Redis 访问增加一个“慢请求日志”的拦截点,比如基于 Spring AOP 做一层 Redis 操作耗时统计,超过 100ms 就记录 key 和命令类型。这样在下一次超时发生之前,就能从慢请求趋势中提前发现苗头。
对于 Redis 服务端,至少要把latency历史监控、内存使用率、慢日志、关键命令调用量这几类指标接入告警。做到“超时还没发生,趋势已经预警”,才是治本。
最后再分享一个经验:不要在日志里把所有 Redis 命令的返回值都打出来,尤其是有大数据内容的 key。我曾经为了排查问题在日志里直接打印了一个超大集合的内容,结果日志本身把磁盘打爆,间接加剧了服务的卡顿。排查超时问题,打印耗时和 key 名就够了,内容数据留到专门的离线工具里去分析。