做Java后端这几年,Spring Boot项目接Redis几乎成了标配动作,但线上跑一阵子之后,多少都会撞见Read timed out这堵墙。我印象最深的一次,是某个交易链路在下午流量高峰突然告警,接口超时率半小时内从0.1%爬到6%,日志里整屏都是RedisConnection read timed out,连带数据库连接池也被拖垮,最后不得不重启部分节点保命。那次事后复盘,根因其实不复杂,但排查过程却绕了不少弯路。这篇文章就把完整的排查链路、根因判定方法和修复方案写下来,给同样被Spring Redis超时问题折腾过的朋友一份可以直接照抄的作业。
先把这个错误放对位置:它属于Redis客户端层面的超时异常,本质是客户端把命令发出去了,但在约定的时间窗口内没等到服务端响应。Read timed out这六个字看着简单,背后牵涉的环节却不少——命令本身执行慢、服务端卡顿、网络延迟、客户端连接池排队,甚至序列化器选错都会在这里汇合。这篇内容会按"报错解剖 → 逐层定位 → 根因判定 → 落地修复 → 长期防御"的顺序展开,适合正在排查线上故障的团队,也适合想提前避开这个坑的新手。
1. 报错链路解剖:Read timed out到底发生在哪一环
1.1 不同客户端的报错长相:Lettuce和Jedis各是什么样
Spring Boot 2.x开始默认用Lettuce作为Redis客户端,Spring Boot 1.x时代默认是Jedis,这两家的报错文案不一样,但含义相同。
Lettuce下面是这样的:
org.springframework.dao.QueryTimeoutException: RedisConnection read timed out at ...LettuceConnection... Caused by: io.lettuce.core.RedisCommandTimeoutException: Command timed out after 500 millisecond(s)Jedis下面则是:
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: Read timed out注意Lettuce报错里的"Command timed out after 500 millisecond(s)",这个500毫秒就是应用里配置的Redis命令超时时间;而Jedis版报错里的"Read timed out"是JDK底层Socket读取超时的标准文案,对应Jedis的socketTimeout配置。很多人一看到Read timed out就直接往网络方向查,其实它只是一个"客户端没等到响应"的最终表现,至于为什么没等到,得往下面几层找。
1.2 connect timeout与read timeout:两码事不能混着查
排查之前先把两个概念分开。连接超时(connect timeout)是客户端建立TCP连接时愿意等的最大时间,报错一般是Connection refused或connect timed out,这种通常指向Redis地址配置错误、服务没启动、防火墙拦截、连接数打满。读超时(read timeout)是连接建好之后,命令写出去到收到响应之间的最大等待时间,报错就是咱们这篇的主角Read timed out。
实际排障时这两类问题的排查方向完全不同,一个偏重网络连通性,一个偏重服务处理能力和客户端排队情况。如果日志里同时混着两种报错,先处理连接超时,因为连接都建不起来的时候,读超时往往会作为次生灾害一起出现。
1.3 超时阈值是怎么算进这次请求的
Spring Data Redis里的命令超时,最终会落到客户端的Socket参数上。以Lettuce为例,命令发出后客户端会启动一个计时器,到点没收到完整的响应就抛异常。这个定时器的时间来源就是spring.redis.timeout(Spring Boot 3.x是spring.data.redis.timeout)配置。
这里我想强调一个经常被忽略的点:Lettuce在默认情况下是多路复用模型,多个业务线程共享同一条连接。命令在连接上排队,前一个命令没返回,后一个命令只能等着。也就是说,即便你的单条命令本身只要10毫秒就能执行完,如果前面排了50个请求,第51个请求的等待时间就可能超过500毫秒的超时阈值。这一点在后面排查"为什么Redis明明很健康,应用却疯狂超时"时至关重要。
2. 从应用日志到Redis服务端的逐层定位过程
2.1 第一刀切分:全量超时还是局部接口超时
收到告警先别急着上服务器,先打开日志把超时报错的分布看清楚。
- 全链路所有Redis操作都超时?先怀疑Redis所在机器、网络或Redis进程本身。
- 只有某几个接口超时?优先怀疑这些接口触发的命令类型。
- 报错集中在某个时间窗口?看看是不是定时任务、数据补偿逻辑和业务高峰撞在一起。
- 错误伴随
Connection refused?先去查Redis的maxclients和系统连接数限制。
我在那次线上故障里,第一眼看到的是订单查询接口超时,但支付、库存接口正常,于是直接把注意力放到了订单相关的Redis操作上。后来查出来是订单详情缓存里存了一个几MB的大JSON,每次反序列化加网络传输要卡一两百毫秒,高峰期一排队就全线超时。
2.2 服务端健康检查三板斧:PING、INFO、SLOWLOG
判断是不是Redis服务端的锅,三件事依次做。
第一,确认存活和延迟:
redis-cli -h 你的Redis地址 -p 6379 ping redis-cli --latency -h 你的Redis地址 -p 6379 -i 1ping返回PONG只能说明进程活着,--latency才能反应RTT。正常情况下内网延迟应该在1ms以内,如果出现几十甚至几百ms的毛刺,网络或者宿主机就有问题。
第二,看关键指标:
redis-cli INFO | grep -E "connected_clients|blocked_clients|used_memory|mem_fragmentation_ratio|instantaneous_ops_per_sec"重点看used_memory是否接近maxmemory,mem_fragmentation_ratio是否超过1.5,connected_clients是否异常飙高。
第三,查慢命令:
redis-cli CONFIG GET slowlog-log-slower-than redis-cli SLOWLOG GET 50先把slowlog-log-slower-than设成10000(10毫秒)再观察一段时间,凡是超过10ms的命令都会记录在案。这条查出来基本能锁定大Key和慢命令。
2.3 网络层和连接数:最容易忽略的隐形杀手
如果服务端各项指标都正常,就要往网络层看。我见过不少案例,Redis和应用部署在两个可用区,中间隔了防火墙、负载均衡,一个TCP重传就能把延迟打到几百毫秒。
ping -c 100 Redis地址 | tail -1看丢包率和RTT抖动。另外用ss -s看系统TCP重传情况,用redis-cli info clients看connected_clients数值趋势。如果连接数从几十涨到上千,且TIME_WAIT状态的连接堆积严重,说明应用侧存在连接泄漏或连接创建过于频繁,这也是超时的重要诱因。
3. 四种高发根因:从大Key到连接池的真实判定方法
3.1 大Key与慢命令:Redis单线程模型下的头号元凶
Redis是单线程处理命令,这是理解所有超时问题的地基。任何一条命令执行过慢,不光这条命令遭殃,之后排队的全部命令都要陪跑。
典型的大Key场景:
- 一个String类型的value有几MB,写入和读取都要传输很久。
- 一个Hash或List里有几十万条记录,执行
HGETALL或LRANGE 0 -1直接把服务端卡住。 - 代码里用了
KEYS *,在几百万key的实例上执行,直接扫挂整个Redis。
判定方法很简单:SLOWLOG GET里频繁出现的命令,八成就是慢命令,再去对应的key上执行STRLEN、HLEN、LLEN确认大小。
3.2 连接池配置缺失:Lettuce默认单连接背后的排队效应
这里要着重提醒一点:Spring Boot 2.x默认的Lettuce在未配置连接池时,是多个线程共享一条连接的。你以为的高并发没问题,实际是请求在Lettuce内部的队列里排队。命令一旦排队,等待时间就不受你控制,超时概率随QPS上升指数级恶化。
怎么判断是这个原因?Redis服务端connected_clients数值非常低(往往只有个位数),但应用侧大量超时,同时应用线程的堆栈都堵在Lettuce的某个连接上。这就是典型的"服务端很闲,客户端排队严重"。
解决方案是引入Apache Commons Pool并开启Lettuce连接池,给足max-active和max-wait,具体参数放到第4节讲。
3.3 Redis自身的停顿窗口:RDB、AOF rewrite与内存压力
还有一种隐蔽情况:Redis本身没慢命令,但会周期性地"卡一下"。最常见的是bgsave做RDB快照时fork子进程,如果内存占用很大,fork瞬间会阻塞主进程几十甚至几百毫秒;AOF rewrite也会有类似影响。另外操作系统把Redis内存swap到磁盘时,Redis会大量阻塞。
这类问题的时间特征非常明显,超时往往集中在固定时间点,和备份任务、内存清理任务对齐。判定方法是看INFO persistence里的rdb_last_bgsave_status和latest_fork_usec,再用redis-cli --latency -i 1挂机观察,超时发生时延迟毛刺是不是正好在那个时间窗口。
3.4 超时参数与业务模型错配
最后一个高频原因反而是配置层面的:超时时间设得太激进。有些团队为了接口快速失败,把spring.redis.timeout设成200ms甚至100ms,但没有考虑业务数据的实际大小和网络RTT。内网RTT 0.5ms、单命令执行1ms的时候,200ms确实够用;但数据量一大、并发一高,排队时间稍微一涨,就会频繁越过阈值。
这类问题的特征是:超时不是持续性报错,而是偶发、跟流量峰谷正相关;把超时调大之后立刻好转。但我不建议用"把超时调到10秒"这种粗暴方式掩盖问题,后面会讲为什么。
为了方便对照,我把四种场景的特征整理成一个表:
| 场景 | 服务端特征 | 客户端特征 | 超时规律 | 优先排查手段 |
|---|---|---|---|---|
| 大Key慢命令 | SLOWLOG有记录,ops下降 | 所有接口都可能报 | 持续或高峰加重 | 查SLOWLOG、看Key大小 |
| 连接池排队 | connected_clients极低 | 堆栈堵在Lettuce连接 | 并发越高越严重 | 看连接数和线程堆栈 |
| Redis停顿 | fork耗时高、swap出现 | 报错呈时间窗聚集 | 固定时段集中爆发 | 看INFO persistence |
| 超时配置过紧 | 一切正常 | 偶发读超时 | 与流量正相关 | 压测对比不同阈值 |
4. 修复落地的组合拳:配置、代码与数据结构的协同调整
4.1 正确的连接池与超时参数:Spring Boot 2.x和3.x要分清
先说版本差异,这坑很多人踩过。Spring Boot 2.x的Redis配置前缀是spring.redis,Spring Boot 3.x改成了spring.data.redis。网上搜到的配置经常混着粘贴,版本不对直接不生效。
以Spring Boot 3.x为例,开启Lettuce连接池并调好参数的配置长这样:
spring: data: redis: host: 你的Redis地址 port: 6379 password: 你的密码 timeout: 3s connect-timeout: 2s lettuce: pool: enabled: true max-active: 32 max-idle: 16 min-idle: 4 max-wait: 3s time-between-eviction-runs: 30s注意两个关键点。
第一,lettuce.pool.enabled: true只有在你引入了commons-pool2依赖时才生效。Spring Boot默认不会自动带这个包,必须手动加:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>很多人配置了连接池却看不到连接数上涨,十有八九就是少了这个依赖。排查时先确认依赖有没有进来,再去纠结参数对不对。
第二,max-active到底设多少,不要拍脑袋。粗略估算:并发线程数 × 单个线程可能持有的Redis连接数,一般业务系统取16到32就够了,太大反而增加服务端连接管理开销。max-wait不要小于timeout,否则连接池耗尽时抛出的ExhaustedPoolException会抢在命令超时之前出现,干扰定位方向。
4.2 代码层改造:慢命令替换、批量与本地缓存
配置只是兜底,代码才是治本。以下三个方向是优先级最高的。
第一,把KEYS换成SCAN。KEYS在正式环境基本属于自杀操作,它会阻塞Redis单线程直到遍历完所有key。SCAN是游标式的,每次返回一小批,虽然多次循环,但不会阻塞服务。
第二,把频繁的单点查询合并成批量。比如原来循环里逐条GET,改成一次MGET;原来逐个SET,改成pipeline。批量操作把网络往返次数压缩一个数量级,命令排队时间自然下降。
第三,给热点数据加一层本地缓存。用Caffeine做一级缓存,Redis做二级缓存,命中率上来了,对Redis的QPS降下去,超时问题也就没机会发生。这个改造对读多写少的场景收益非常明显。
4.3 序列化方式与大Key拆解
序列化器影响的是数据大小和序列化耗时,间接影响网络传输和命令执行时间。Spring Data Redis默认的JdkSerializationRedisSerializer效率低还占空间,线上一般换掉。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }再讲大Key怎么治。一次性读出几MB数据这种事,从根上就要避免:
- 大String拆成Hash的多个field,或者按业务维度分多个key。
- 大List分页读取,别用
LRANGE 0 -1。 - 对大Value开启压缩,例如JSON压缩到原来的三分之一再存。
- 如果是缓存场景,给大对象设置合理的过期时间,避免长期堆积。
改完之后,用redis-cli --bigkeys扫一遍,把最大的那批key逐一治理,你会发现超时频率肉眼可见地下降。
5. 回归验证与长期防线:把超时问题挡在上线之前
5.1 压测场景设计:把超时问题暴露在发布前
修复完不能直接上线,我习惯先做一轮针对性的压力测试。这里的关键是场景要和生产贴近:
- 用和线上一致的数据规模,别拿空库压。
- 构造一个包含大Key和热Key混合的数据集,压测时同时触发。
- 并发从低到高分级加压,记录每个档位的超时率、P99延迟。
- 观察Redis服务端的SLOWLOG、连接数,确认瓶颈不在单命令执行。
压测命令方面,redis-benchmark可以快速摸Redis本身的吞吐上限,但真实业务场景还是建议用JMeter或Gatling从应用接口层面压,因为只有应用压测才能暴露Lettuce排队、连接池耗尽这类客户端侧问题。
5.2 监控告警:Redis侧与Spring侧双向布防
长期防线上,监控是最重要的一环。我现在的做法是Redis侧和应用侧各布一套指标。
Redis侧用Prometheus的redis_exporter采集,关注redis_slowlog_len、instantaneous_ops_per_sec、connected_clients、used_memory。慢日志数量一旦突增,说明有大Key或慢命令进场。
Spring侧配置spring-boot-starter-actuator,开启Redis健康检查,同时上报自定义的Redis操作耗时指标。我在实际操作里会埋一个简单的环绕通知,统计每次Redis命令的耗时分布,超过100ms的单独打点,日积月累就能看出来哪些操作在劣化。
告警阈值建议分两级:Redis P99延迟超过50ms先告警,超过100ms触发紧急;应用侧超时率超过0.5%就要人工介入,不要等接口大面积失败才处理。
5.3 三次踩坑后我保留的检查清单
最后交代几条只有实际踩过坑才会注意到的经验。
第一,出问题先抓线程堆栈。用jstack把应用线程dump下来,看业务线程到底阻塞在哪个方法上。这一条能快速区分"Redis真的太慢"和"客户端在排队",比猜原因高效得多。
第二,不要一上来就调大timeout。把timeout调到10秒,超时确实消失了,但响应变慢、线程堆积、数据库连接池被拖垮这些次生灾害会接踵而来。正确的顺序永远是先找慢命令、再看排队、最后才是合理调整超时阈值。
第三,注意大Value的网络传输时间。Redis执行一条命令很快,但把5MB数据在两个节点之间传一遍很慢。曾经有人排查半天,最后发现是缓存里存了一个把多表数据拼接起来的超大JSON,命令本身不到1ms,网络传输却要几百毫秒。
第四,如果Redis和应用部署在不同机器上,务必确认它们之间没有经过共享带宽的虚拟化环境。多个高流量虚拟机挤在同一张网卡上,延迟毛刺会周期性出现,这种环境下的超时排查往往要从基础设施侧入手。
经验都在这里了。每次遇到Redis超时,照着"慢命令、排队、停顿、参数"四个方向排查,基本不会跑偏;配置和代码按上面的组合拳动一遍,大部分问题都能落地解决。如果再配合压测和监控把防线前置,Read timed out大概率只会是你博客里的一篇回忆,而不是生产环境里的一封告警邮件。