上周帮同事排查一个Redis启动报错,日志刷了半屏的 RedisConnectionFailureException: Connection refused。他第一反应是问我"Redis是不是密码错了",结果折腾了半小时,最后发现是服务器上Redis进程压根没起来。这种场景我见过太多次了,SpringBoot整合Redis已经成为Java后端的标配,但恰恰因为太常见,踩坑面特别广——连接、缓存操作、序列化这三块,几乎每个项目都会遇到那么几个令人头皮发麻的问题。
这篇文章就是把我在实际项目中遇到过的、以及帮别人擦过的Redis屁股汇总一遍,按"连接类→缓存操作类→序列化类→综合配置"四个维度拆开讲。所有报错都给出排查链路而不是只贴答案,适合正在用SpringBoot做开发、被Redis整得怀疑人生的同学,也适合刚入行想系统了解Redis常见坑的初级工程师。看完你至少能解决90%的日常Redis报错。
1. 从Connection refused到Lettuce连接池枯竭:连接类报错的完整排查链路
连接类报错是SpringBoot整合Redis的"第一道鬼门关",因为所有缓存操作的第一步都是建立连接。而且这类报错表面上看起来都差不多,实际原因却是五花八门,从Redis没启动到线程池被打满,每个原因的处理方式完全不一样。
1.1 最常见的连接报错清单
先把我实测中遇到过的连接类报错列个表格,方便你对照排查:
| 报错关键字 | 大概率原因 | 紧急度 |
|---|---|---|
Connection refused: no further information |
Redis进程未启动 / 端口被占用 / host写错 | 高 |
SocketTimeoutException: Read timed out |
网络不通 / Redis阻塞 / 大key操作导致超时 | 中 |
Could not get a resource from the pool |
Lettuce连接池耗尽 / 未配置合理的超时和池参数 | 中 |
NOAUTH Authentication required |
配置了密码但没写 / 密码写错 | 高 |
WRONGPASS invalid username-password pair |
密码错误,且Redis开启了ACL | 高 |
ERR Client sent AUTH, but no password is set |
Redis没设置密码,但代码里却传了密码 | 高 |
Connection reset by peer |
Redis配置了最大连接数 / 服务端主动断开 | 中 |
从表格可以看出,大多数连接报错的根源是"配置与Redis实际运行状态不匹配"。但很多人一看到Connection refused就先去改代码,这是最典型的误区。
1.2 Connection refused的排查顺序
遇到 Connection refused,我的排查顺序是固定的,每一步都是一行命令的事:
-
确认Redis进程是否存活。在Redis所在机器执行
ps -ef | grep redis或redis-cli ping,如果返回PONG,进程没问题;如果连接失败,先看Redis的日志文件(通常在/var/log/redis/redis-server.log),看有没有启动失败的记录。 -
确认SpringBoot配置的host和port是否正确。这里有个细节:如果Redis和应用在同一台机器,localhost没问题;如果是Docker部署或者云服务器,要确认Redis绑定的IP是不是只监听了127.0.0.1。Redis配置文件里的
bind字段如果只写了127.0.0.1,外部机器肯定连不上,更隐蔽的是有时候protected-mode yes也会导致外网直连失败。 -
确认端口是否真的在监听。用
netstat -tlnp | grep 6379查看。如果端口没监听,那Redis启动多半是失败了;如果监听在非6379端口,检查配置写的是不是对应端口。 -
确认防火墙相关策略。本地开发时最容易忽略,云服务器的话检查安全组规则是否放行了6379。这一步我通常放在最后,因为本地开发很少遇到防火墙问题,但一旦遇到就是"怎么排查都排查不出来"的鬼问题。
提示:本地开发时如果Redis是用Docker容器启动的,一定要检查端口映射。
docker ps里看PORTS那一列,0.0.0.0:6379->6379/tcp才说明映射成功。我见过redis-server在容器里正常,但没映射端口,SpringBoot怎么连都连不上。
1.3 Lettuce连接池耗尽的根因分析
Spring Boot 2.x之后,默认的Redis客户端从Jedis换成了Lettuce。Lettuce本身是基于Netty的异步驱动,连接池行为比Jedis更"隐晦",很多人在配置里没写连接池相关参数,结果高并发时直接吃一个 Could not get a resource from the pool。
这个报错的本质原因是:连接池中的连接被占满,且等待获取连接的超时时间到了。解决步骤如下:
- 引入连接池依赖(这一步最容易被漏掉)。Spring Boot的
spring-boot-starter-data-redis默认只带Lettuce核心包,不带commons-pool2连接池。如果不引入,配置了连接池参数也不会生效。
xml复制<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
- 在
application.yml中显式开启并配置连接池:
yaml复制spring:
data:
redis:
host: localhost
port: 6379
password:
timeout: 3000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 3000ms
- 如果配置没问题,检查Redis服务端的
maxclients限制。执行config get maxclients,如果连接数已经接近这个值,服务端会直接拒绝新连接,表现就是连接被重置或者超时。
实操中我的经验是:max-active 不要拍脑袋写太大,连接数的上限要结合业务并发量和Redis服务端maxclients来定。曾经有个项目把max-active设成200,Redis服务端默认maxclients是10000,看着没问题,但压测时发现连接数一路上涨不释放,最终打满服务端连接数。后来加了 min-idle 和合理的空闲回收,情况才好转。
1.4 Spring Boot 3.x的隐藏坑:javax与jakarta的换血
现在很多人已经切到Spring Boot 3.x了,这里有一个容易被忽略的坑:Spring Boot 3.x整体迁移到了javax到jakarta命名空间,如果项目里还有些老依赖用的是javax包,启动时可能出现 NoClassDefFoundError: jakarta/servlet/... 等诡异报错,表面看起来跟Redis无关,但如果你通过Redis做Session共享,就很容易连带踩中。
更隐蔽的是,Spring Boot 3.x要求JDK17及以上,Redis相关的starter如果版本不匹配,可能出现一些奇怪的编译错误或运行时异常。我的建议是:Spring Boot 3.x项目务必统一使用官方推荐的Redis配置前缀 spring.data.redis.*,而不是老版本的 spring.redis.*。这个前缀在Spring Boot 2.x后期就推出了,3.x全面切换,很多人还在旧配置上改,导致连接参数完全没被识别。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不是代码报错但结果不对:缓存操作层的穿透、击穿与注解失效
连接通了之后,第二个大坑在"缓存操作"这一层:代码不报异常,但是缓存数据明显不对——要么永远拿不到缓存,要么缓存穿透把数据库打挂了,要么@Cacheable注解怎么加都不生效。这类问题比报错更恶心,因为不弹红字,只能靠经验和日志去定位。
2.1 @Cacheable不生效的四大隐藏原因
先承认一个事实:Spring Cache的注解代理机制决定了它只能在"外部调用"时生效。同一个类里方法A调方法B,B上面加@Cacheable,B的缓存逻辑不会执行,因为调用发生在类内部,没有经过Spring代理对象。这是@Cacheable不生效的第一大原因,也是最常见的。
第二类原因是没有启动缓存注解开关。需要在配置类上加 @EnableCaching,漏掉的话所有缓存注解全部静默失效。这个检查很简单,但排错时经常被忽略。
第三类原因是 key生成策略导致缓存命中率极低。默认的KeyGenerator是 SimpleKeyGenerator,如果方法参数是自定义对象,且对象没有正确实现 equals 和 hashCode,每次生成的key都不一样,结果就是永远缓存不命中,每次都去查数据库。
第四类原因是 condition和unless写反了或者写错条件。condition 是进入方法前判断是否使用缓存,unless 是方法返回后判断是否不写入缓存。曾经见过一个同事在 condition 里写了 #result == null,但方法还没执行哪来的result,这种代码不会报错,只是缓存永远不生效。
一个推荐的做法是:明确指定缓存key,而不是依赖默认生成器。比如用方法入参的某个唯一字段:
java复制@Cacheable(value = "userCache", key = "#userId")
public User getById(Long userId) {
return userMapper.selectById(userId);
}
2.2 缓存穿透、击穿、雪崩的应对方案
这三个"缓存经典灾难"其实没有标准答案,只有针对业务场景的选择。我在项目里的落地经验如下:
缓存穿透——查询一个不存在的数据,每次都会打到数据库。最简单粗暴的解决办法是"缓存空值":把null也缓存起来,设置短过期时间,比如5分钟。另一种适合大规模场景的思路是布隆过滤器,但布隆过滤器的误判率需要根据数据量和预期精确度来调参,维护成本不低。如果业务量没有大到数据库扛不住的程度,用"缓存空值+NX命令"就足够了。
代码层面用Spring Cache实现"缓存空值"有个技巧:方法的返回值是null时,默认Spring Cache是不缓存null的。这时候需要自定义CacheManager的config,或者直接用RedisTemplate手动操作,判断结果是否为null,为null就执行 redisTemplate.opsForValue().set(redisKey, "", 5, TimeUnit.MINUTES)。注意:缓存空值时要和"数据真的存在但值为空字符串"区分开,建议统一用特化前缀,比如 EMPTY:。
缓存击穿——某个热点key过期瞬间,大量请求同时打到数据库。解决思路是"互斥锁",简单说就是:发现缓存miss时,先尝试获取分布式锁,只有拿到锁的线程去查数据库并回填缓存,其他线程等待一段时间后重读缓存。后面的分布式锁章节会详细给示例。
缓存雪崩——大量key在同一时间过期,导致请求全部落到数据库。常见应对有三个:过期时间加随机扰动、多级缓存(本地缓存Caffeine+Redis)、热点数据永不过期(后台任务定时刷新)。在实际项目中,我一般把规则做成配置中心的可配置项,而不是写死在代码里。
2.3 Redis分布式锁的setNx与释放锁的坑
分布式锁这块,坑是最多的。很多教程会给你看这段代码:
java复制Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock:order", "1");
if (lock) {
// do something
stringRedisTemplate.delete("lock:order");
}
这段代码犯了一个经典错误:setIfAbsent(即SETNX)确实可以抢锁,但之后忘了给锁设置过期时间。如果加锁后应用在释放锁之前宕机了,这个锁就是永久锁,所有线程都会卡在这个锁上。
正确写法是加锁同时设置过期时间:
java复制Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock:order", "1", 30, TimeUnit.SECONDS);
但只做到这一步还不够。真正的坑在释放锁的时候:如果线程A加锁后业务执行超过了30秒,锁自动过期了,线程B加锁成功;这时候线程A执行完业务,执行 delete("lock:order"),删除的其实是线程B的锁。这就是经典的"误删锁"问题。
解决策略是"释放锁前先比较value":加锁时value设置成唯一标识(比如UUID),释放锁时用Lua脚本先判断value是否一致再删除,保证原子性:
java复制String lockKey = "lock:order";
String lockValue = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 业务逻辑
} finally {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
这个Lua脚本是Redis官方推荐的释放锁方式,保证"判断+删除"是原子的。
不过如果你问我真实生产环境怎么办,我的答案是:不要自己造分布式锁,直接用Redisson。Redisson的RLock自带了看门狗机制,默认每10秒检查一次锁是否还持有,自动续期;而且它内部处理了锁重入、锁误删这些问题。自己写的锁看起来简单,但在高并发下边界情况非常多,与其花两天时间调试锁的边界,不如用成熟轮子。
3. \xAC\xED\x00\x05t这种乱码和ClassCastException:序列化器就是罪魁祸首
如果说连接报错是"第一道鬼门关",那序列化问题就是"最多的鬼故事来源"。redisTemplate 存进去的数据通过redis-cli一看,一堆 \xAC\xED\x00\x05t... 这种二进制乱码;读出来的时候直接抛 ClassCastException;更高级一点的,还会遇到反序列化安全漏洞问题。这些基本都和序列化器选错有关。
3.1 为什么默认的RedisTemplate会产生乱码
Spring Boot提供的 RedisTemplate<Object, Object> 默认使用的是 JdkSerializationRedisSerializer,也就是Java原生的序列化机制。Java原生序列化会在内容前写一堆类型头信息,包括类描述、序列化版本号等等,所以你在redis-cli里看到的value就是各种不可读的二进制字节。
这个设计的初衷是通用性——什么对象都能序列化,但实际使用中有两个致命问题:
- 可读性差。运维排查数据时根本没法看,值是一坨二进制,没法用Redis的字符串命令或工具直观分析。
- 存储膨胀。Java序列化会把类的全限定名、字段描述等信息都写进去,存储空间比原始数据大好几倍。
- 与外部系统互通性差。如果你有PHP、Python、Go的服务要读同一份Redis数据,它们大概率解析不了Java序列化的内容。
所以线上项目几乎都不会直接用默认RedisTemplate,而是重新定义Bean,把key序列化器换成String,value序列化器换成JSON方案。
3.2 主流序列化器选型对比
这是最核心的选型问题。我把实际用过的几种方案整理成了一张对比表:
| 序列化器 | 可读性 | 跨语言 | 存储体积 | 安全风险 | 适用场景 |
|---|---|---|---|---|---|
| JdkSerializationRedisSerializer | 差 | 差 | 大 | 高 | 仅限本地调试,不推荐生产 |
| GenericJackson2JsonRedisSerializer | 好 | 好 | 中 | 中 | 通用JSON缓存,大多数项目够用 |
| Jackson2JsonRedisSerializer<Object> | 好 | 好 | 中 | 中 | 需要限制具体类型时使用 |
| Fastjson2RedisSerializer | 好 | 好 | 中 | 可控 | 业务偏好fastjson的团队 |
| StringRedisSerializer | 好 | 好 | 小 | 低 | key一律用String,value自定义序列化 |
我个人最常用的是 key用StringRedisSerializer,value用GenericJackson2JsonRedisSerializer 的组合。先说为什么key用String:Redis的key基本是业务前缀+业务ID,比如 user:1001,用String最直观、可读性最好,而且方便在控制台用 keys user:* 这类模式匹配排查问题。
value选择GenericJackson2JsonRedisSerializer而不是Jackson2JsonRedisSerializer,原因在于:Jackson2JsonRedisSerializer 在反序列化时需要指定具体类型,如果缓存里存了不同类的对象,读取时类型不对就会报错;而 GenericJackson2JsonRedisSerializer 会在JSON里写入 @class 字段,反序列化时根据类型信息自动还原,适合Value可能是多种类型的场景。
这里有个安全细节要单独说。GenericJackson2JsonRedisSerializer 的 @class 字段如果没做白名单限制,攻击者可以构造恶意JSON,利用ObjectMapper的默认多态特性触发反序列化漏洞,也就是大家常说的"反序列化攻击"的入口之一。真实世界里fastjson的反序列化漏洞出过很多次大的安全事件,Java原生序列化的RMI链攻击案例也比比皆是。一句话总结:序列化器的选择不仅是性能问题,更是安全问题。
3.3 ClassCastException的常见场景与解决
与乱码并列的"序列化魂灵"就是 ClassCastException。我遇到比较多的情况是下面这几个:
-
缓存里存的是JSON字符串,读出来强制类型转成对象失败。比如你某次用
redisTemplate.opsForValue().set(key, user)存进去,但此时RedisTemplate的value序列化器是String,存进去的是User.toString();下次用配置了JSON序列化器的RedisTemplate读出来,拿到的是JSON字符串,转User就会失败。解决方法是在序列化器上统一:一个Redis实例对应一套序列化器,不要混用。 -
同一个key在不同项目中序列化器不同。项目A用JDK序列化器存进去了,项目B用JSON序列化器来读,必然ClassCastException。解决方法是约定key前缀,从源头隔离不同项目的数据。
-
字段类型变更导致反序列化失败。比如老版本缓存里User有个
int age,新版本改成了String age,反序列化时Jackson会尝试把数值转为字符串,如果类型差异过大就直接抛异常。解决方法是缓存数据增加版本号前缀,比如user:v2:1001,每次字段结构大调整时切换版本。
3.4 StringRedisTemplate和RedisTemplate的选择困惑
很多人会纠结到底用 StringRedisTemplate 还是自定义 RedisTemplate。其实Spring Boot内置的 StringRedisTemplate 只是key和value都用了String序列化器,它不会自动做对象和JSON的转换。如果你存的是对象,用StringRedisTemplate会把对象调 toString() 存成字符串,读出来的是字符串,还得自己反序列化。
所以我的建议是:与缓存有关的对象读写统一用自定义RedisTemplate,配置好序列化器后代码只管存对象读对象;而涉及计数器、分布式锁、简单字符串缓存的场景,直接用StringRedisTemplate做原子操作更清爽。
提示:如果你的业务里既有
StringRedisTemplate又有自定义RedisTemplate,千万不要对同一个key混用。我见过一个项目,用户会被踢下线。你放的是JSON字符串,另一个地方用JdkSerializer读出来的是二进制流,两者互不识别。要隔离这种问题,建议直接用key前缀约定:lock:开头用StringRedisTemplate,cache:开头用RedisTemplate。
4. 一份经过实测的RedisConfig配置与调试速查表
讲完了三大类报错的具体案例,最后给一份我项目里经常直接拿来用的Redis配置模板,以及一张"报错现象→排查方向"的速查表。这份配置在Spring Boot 2.7和3.x上都跑过,核心思路是:key用String序列化,value用JSON序列化,CacheManager统一管理过期时间。
4.1 自定义RedisTemplate和CacheManager的完整配置
先看 application.yml 部分:
yaml复制spring:
data:
redis:
host: localhost
port: 6379
password:
timeout: 3000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 3000ms
cache:
type: redis
redis:
time-to-live: 60000ms
cache-null-values: true
然后是一个RedisConfig配置类:
java复制@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
这里有个细节需要解释:为什么CacheManager的配置里我用了 disableCachingNullValues(),但前面的yml里又写了 cache-null-values: true?是因为如果项目里要用"缓存空值"来防穿透,就不应该全局禁用null缓存,而是要按具体缓存实例来配置。上面的示例是通用配置,生产环境中我的习惯是默认禁用null缓存,然后在需要对null做缓存的方法上单独建一个CacheManager或者用RedisTemplate手动实现。
4.2 缓存过期的"全局TLL"与"局部TTL"策略
全局统一过期时间是一把双刃剑。entryTtl(Duration.ofMinutes(30)) 写死成30分钟后,所有@Cacheable注解的缓存都按这个时间过期。但实际业务中,热点数据可能要1分钟过期,基础数据可能要1小时。搞一刀切,要么缓存命中率低,要么数据更新不及时。
Spring Cache解决这个问题的办法是:在 @Cacheable 里,cacheNames 可以指定不同的CacheManager?其实不是,Cache注解本身不直接支持单独的TTL。更优雅的方案是使用 @Cacheable(cacheNames = "shortCache") 这种"命名缓存"的方式,配置时给每个缓存名设置不同的TTL:
java复制RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withCacheConfiguration("shortCache",
config.entryTtl(Duration.ofMinutes(1)))
.withCacheConfiguration("longCache",
config.entryTtl(Duration.ofHours(1)))
.build();
这种配置在业务复杂、缓存维度多时非常实用。我建议项目一开始就把缓存的TTL策略设计成配置化,不要等写死在代码里再回头改。用配置中心(如Apollo、Nacos)管理TTL,后续调优时改配置不需要发版。
4.3 生产环境Redis报错排查速查表
最后整理了一张排查速查表,按报错现象直接跳转到对应检查点。这张表我实际上贴在团队wiki里,很多次同事排查问题时照着走就能解决一半问题。
| 报错现象 | 第一优先排查 | 第二优先排查 | 本文相关章节 |
|---|---|---|---|
| Connection refused | Redis进程是否存活 | bind地址和端口映射 | 1.2 |
| Could not get a resource from the pool | 是否引入commons-pool2依赖 | Lettuce连接池参数 | 1.3 |
| NOAUTH / WRONGPASS | Redis密码配置 | Redis ACL用户配置 | 1.1 |
| @Cacheable不生效 | 是否加了@EnableCaching | 是否同一类内部调用 | 2.1 |
| 缓存命中率极低 | key生成器是否合理 | 对象equals/hashCode实现 | 2.1 |
| Value乱码或二进制 | RedisTemplate序列化器 | 是否有混用序列化器 | 3.1 |
| ClassCastException | 序列化器是否混用 | @class类型信息是否完整 | 3.3 |
| 缓存穿透/数据库压力大 | 是否缓存null值 | 是否用布隆过滤器 | 2.2 |
| 锁一直卡住或误删 | 锁是否带过期时间 | 释放锁是否有Lua判断 | 2.3 |
4.4 团队协作中的Redis规范建议
写到这里,我还想分享一个"不是报错,但比报错更致命"的经验:同一个项目中Redis的key命名和序列化规范一定要统一。很多项目一开始没定规矩,有人用 user:1001,有人用 USER_1001,有人存对象用JDK序列化,有人存JSON。等到出问题了,排查成本成倍增加。
我们团队目前约定如下,供参考:
- key统一用
业务域:业务对象:ID的格式,冒号分隔,全小写。 - 缓存对象一律使用「key用String序列化器,value用GenericJackson2JsonRedisSerializer」的RedisTemplate。
- 分布式锁统一使用Redisson,禁止手写setNx+delete。
- 大对象缓存(比如超过10KB的)一律在value里显式声明最大长度,避免无脑缓存拖垮Redis内存。
- 所有缓存操作必须设置过期时间,禁止永不过期。
这些规范看起来简单,但能帮团队省掉大量踩坑时间。最后从实际操作角度总结一下:SpringBoot整合Redis真的不难,难的是对序列化器、连接池、缓存策略的理解。遇到报错别急着搜答案,按上面的排查链路一步步定位,实在不行看看Redis日志,大多数问题都能自己找到答案。
