1. 为什么你的Redis里存的全是乱码
先讲个我实际遇到过的场景。某个同事在项目里引入Redis做缓存,代码写得行云流水:
java复制ValueOperations<String, Object> ops = redisTemplate.opsForValue();
ops.set("user:1001", userInfo);
写入成功,日志没报错,但一查Redis客户端,看到的是一串以\xAC\xED\x00\x05t...开头的二进制乱码。等会儿再读出来,发现类型对不上,或者根本反序列化不出来,程序还抛了一堆ClassCastException。
这个问题几乎每个用RedisTemplate的团队都会碰到,本质是:Spring Data Redis默认给value用的序列化器是JdkSerializationRedisSerializer,它会把Java对象序列化成JDK二进制格式。这种格式直接裸奔在Redis里,不是给人看的,更麻烦的是它把类结构信息一起序列化进去,将来反序列化时对类路径、serialVersionUID特别敏感,稍微改个类名或者加个字段,老数据就读不出来了。
很多新手的第一反应是换Jackson序列化,然后随手写上:
java复制redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
跑起来好像也没问题。但它接住String还行,一旦塞一个含日期、LocalDateTime、或者泛型多层的对象进去,要么转换异常,要么存进去变成一坨结构奇怪的JSON,读回来还得做一堆类型强转。真正的坑往往不是“能不能用”,而是“换掉默认序列化器之后,你能不能把这套配置用明白”。
我的建议是把序列化配置拆开看,RedisTemplate涉及四类序列化点:
keySerializer:缓存key的序列化方式,一般用StringRedisSerializervalueSerializer:缓存value的序列化方式,存对象建议用Jackson系列,存String可以不用改hashKeySerializer:Hash结构的field序列化,同上,一般用StringhashValueSerializer:Hash结构的value序列化
如果这四类混着配,或者key用了序列化器、查数据时又用了另一个RedisTemplate,就会出现很隐蔽的数据读不到问题。我见过一个团队,写入用StringRedisSerializer,读取时用了另一个RedisTemplate实例,默认的key序列化器还会给字符串加前缀,一读一个null,排查了一下午。
所以第一步别急着写业务代码,先把一个统一的序列化配置类搞定。网上有很多配置示例,其中很多配置直接加在RedisTemplate上,但忽略了Hash结构的单独设置。如果不配hash相关序列化器,默认又会退回JDK序列化,然后Hash里的value照样一堆乱码。
我自己在项目里比较常用这样一套配置,虽然不是唯一答案,但对大部分业务足够稳:
java复制@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
这里要说明几点:
StringRedisSerializer用于key,是为了保证Redis里的key是清晰可读的字符串,方便在客户端里排查,也避免不同服务用不同序列化器导致读不到同一个key。- value用
GenericJackson2JsonRedisSerializer最大的好处是它会在JSON里写入一个@class字段,反序列化时能知道目标类型,泛型处理比普通Jackson更省心。坏处是占空间,而且有类信息迁移成本,后面会细说。 - 如果你能确定业务里只存取String类型,直接用
StringRedisTemplate是真的省事,它是RedisTemplate的String全量版,key和value都用String序列化器。但正因为它是String全量版,往里面塞对象就会报类型错误,这也是很多人误以为RedisTemplate不能存对象的原因。
1.1 一个容易被忽略的坑:泛型与反序列化的类型信息
很多人用了Jackson序列化,会出现“写入没问题、读取报错”的情况。比如:
java复制redisTemplate.opsForValue().set("cache:list", list);
list是一个List<User>,写入时GenericJackson2JsonRedisSerializer会把@class信息写在JSON里,读出来没问题。但如果你用另一个模板,或者用了不含类型信息的自定义ObjectMapper,读出来的可能是一个ArrayList,遍历到元素时才抛ClassCastException。
很多时候不是代码写错,而是序列化器在类型信息的保存策略上不一致。GenericJackson2JsonRedisSerializer是“把类信息写进JSON里”的,而你自己new的Jackson2JsonRedisSerializer如果不指定ObjectMapper,默认是不带类型信息的,反序列化时只能靠右边泛型推断。于是“读出来的时候,类型信息丢了”,代码就出问题了。
如果你们项目里存在多个微服务共享同一个Redis key,而不同服务又各配一套序列化器,这块就是重灾区。最好的办法是:所有服务统一使用同一套RedisConfig,别各写各的。如果实在没法统一,那就约定所有value都手动转JSON字符串再存。
1.2 别把@class字段当成无害的额外开销
用GenericJackson2JsonRedisSerializer时,你存进去的对象会多一个@class: "com.example.User"字段。有个实际影响是:这个类路径一旦被后来的版本重构了(比如换了包名、类名,或者把这个类挪到了另一个模块),Redis里的旧数据全部无法反序列化,直接报错。
所以这类配置更适合缓存场景,不适合持久化场景。如果你要拿Redis存订单流水、任务状态这类需要长期保留的数据,最好在写入时自己控制存储结构,比如统一存JSON字符串,不依赖模板的自动序列化。一旦模板升级、类重构,你的历史数据依旧能解析,最多多一步手写类型转换。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接工厂与线程安全:RedisTemplate到底是不是线程安全的
很多文章会告诉你“RedisTemplate是线程安全的”,这个结论本身没错,但没说全。它是线程安全的,不代表你在所有场景下乱用都安全,真正需要警惕的是它背后的连接工厂以及连接的使用方式。
大家最常接触到的有两个连接方案:Lettuce和Jedis。Spring Boot 2.0之后默认用的是Lettuce,默认共享连接。也就是说,绝大多数情况下,一个Redis连接工厂只维护少量连接,所有线程共用,而不是为每个线程创建独立连接。
Lettuce基于Netty,连接是异步非阻塞的,同一个连接可以被多个线程并发访问。它在设计上确实做到了线程安全,不会像某些老库那样一个连接同时被两个线程用就崩了。但要注意,单连接并发高时会有排队等待,如果服务端有大量慢命令(比如超大key的SMEMBERS、HGETALL),还是会互相影响。高并发场景下,建议显式配置连接池。
连接池配置在Spring Boot里看起来很简单:
yaml复制spring:
data:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
但有一个坑:光配max-active和max-idle没用,必须引入连接池依赖。如果你用的是Spring Boot默认的starter,lettuce-core自带了,但commons-pool2是可选依赖,不引进来的话,配置文件里的pool参数会被静默忽略,你连个报错都看不到。
xml复制<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
这个坑很经典,表现形式是:高并发一上去,Redis操作开始出现超时和连接拒绝,但你配的pool好像没生效。查了半天,才发现是少了池化的依赖。
2.1 Lettuce和Jedis怎么选
早期的Spring Boot默认用Jedis,后来换成了Lettuce,主要原因是Lettuce支持异步、响应式、连接复用率更高。Jedis则更直观,它本身不是线程安全的,每个线程得拿自己的连接实例或者从连接池里借还。
选择上不需要太纠结。中小型项目、并发不是特别极端的场景,用默认的Lettuce是完全够的。你要真选Jedis,也得自己理解它的线程模型,别拿同一个实例在多线程里用,否则轻则数据串乱,重则连接直接被毁。
我在实际项目里也见过一个比较典型的案例:团队用了Jedis,为了省事把Jedis实例直接丢到Spring单例Bean里,结果高并发下Redis客户端报JedisConnectionException,而且每次出错的时间点还不一样。原因就是Jedis实例内部状态有共享,多个线程并发调用底层socket时会互相干扰。换成Lettuce之后,这个问题就消失了。
如果你还在用Jedis,听我一句劝:要么给进出都做好池化,要么直接切到Lettuce。现在的Spring Boot默认就是Lettuce,维护成本低很多。
2.2 连接泄漏:黄线警告
连接池对象用完不还,是一个很高频的线上事故。它不是RedisTemplate本身的问题,而是很多人图方便直接用ConnectionFactoryUtils.getConnection()这类底层API。拿出来的连接如果不在finally里释放或归还,连接池很快就被耗尽,表现是:其他请求拿不到连接,工厂一直报“Cannot get Jedis connection”。
我建议绝大部分业务代码不要手搓底层连接,RedisTemplate已经封装好了绝大部分能力。真有特殊需求,比如要执行Lua脚本,也用DefaultRedisScript去执行,不要自己去openConnection。真要拿连接,务必这样写:
java复制RedisConnection connection = null;
try {
connection = RedisConnectionUtils.getConnection(redisTemplate.getConnectionFactory());
// 执行业务
} finally {
RedisConnectionUtils.releaseConnection(connection, redisTemplate.getConnectionFactory());
}
这段代码虽然绕,但能保证归还连接。不过,能用RedisTemplate API解决的,尽量别走这条路,理由很简单:你少写一个try-finally,连接泄漏的隐患就大一分。
3. 事务:RedisTemplate里最反直觉的延迟行为
先说结论:RedisTemplate不是默认开启事务的。它默认的每个操作都是独立的,自动提交。但你可以用setEnableTransactionSupport(true)开启事务,开启之后,你拿到的Connection会绑定到当前线程的事务上下文里,事务提交或回滚之前,命令都是“排队”的,不会真正执行。
这里最反直觉的一点是:你在同一个方法里连续写了好几条命令,你以为第一行就立刻写进去了,其实它在内存里排队,等transaction.commit()之后才真正发给Redis。如果你在事务中间读数据,读到的不是你自己刚写的,而是操作之前的历史值。这在业务上特别容易踩坑。
举个例子:
java复制redisTemplate.setEnableTransactionSupport(true);
redisTemplate.opsForValue().set("key:1", "v1");
String value = (String) redisTemplate.opsForValue().get("key:1"); // 这里可能是null!
你刚set完,转身get,结果get到null,这还不是最严重的。更麻烦的是事务队列里的命令如果出错,整个事务会被标记为回滚,但Redis的事务默认不支持回滚到某个中间点。也就是说,即使你只希望第一条命令失败、其余正常,MULTI/EXEC也只能全体成功或全体失败。
3.1 WATCH与乐观锁:模拟CAS操作
Redis的WATCH命令是乐观锁的基石。watch(key)监视一个key,当另一个连接修改这个key时,当前事务会执行失败。用RedisTemplate实现CAS的同学要注意:WATCH必须在MULTI之前执行,否则达不到监视效果。
手动写法是:
java复制connection.watch("stock:10010".getBytes());
connection.multi();
connection.set("stock:10010".getBytes(), "5".getBytes());
List<Object> result = connection.exec();
if (result == null) {
// 说明watch期间key被改过,事务执行失败
}
注意exec()返回null表示事务被放弃,不是提交失败。很多人以为返回空集合就是失败,其实空集合表示事务正常提交,只是没有返回值。如果事务被WATCH中断,返回的才是null。
但有句话想提醒:Redis事务不是万能的,它没有“读-判断-写”的原子性保护,别指望它像数据库事务一样,能帮你搞定复杂的并发一致性。真正应用在秒杀扣库存时,更需要配合分布式锁、Lua脚本把整个判断和写入做成原子操作。RedisTemplate提供了DefaultRedisScript,配合Lua脚本执行,这才是高并发场景下的正解。
3.2 事务过程中的连接不可共享
RedisTemplate开启事务支持后,连接会被绑定到当前线程。这个机制在Spring容器里通常会跟@Transactional协同工作,但如果你在事务方法里又启动了异步线程去操作同一个RedisTemplate,那个异步线程拿不到当前线程绑定的连接,就会报“No connection bound to thread”之类的问题。
我遇到过一个业务场景:用户在更新订单时,主线程写完Redis事务,然后扔了一个异步任务去刷新缓存。结果异步任务拿不到连接,报错。排查到最后就是连接绑定在线程上的问题。
面对这种情况,要么让异步任务别用同一个开启了事务的RedisTemplate,要么把异步操作放到事务提交之后触发(比如事务同步器afterCommit回调),要么干脆别用Redis事务,改用Lua脚本。很多时候Lua脚本能替代RedisTemplate事务的绝大部分需求,而且心智负担小得多。
4. 代码之外的功夫:一次“读取Redis结果无法反序列化”的排查链路复盘
理论说多了容易飘,我拿一次完整的排查过程来讲。项目背景是:一个支付回调服务,通过RedisTemplate写入支付流水号对应的业务结果,另一个查询服务用同一套模板去读。某天线上出现偶发的“查询不到结果”,但Redis里明明有这条数据。
刚开始以为是并发写覆盖的问题,排查后发现根本不是。后来用Redis客户端去看数据,发现key和value都正常,就是格式有点奇怪,value前面带着一段乱码前缀。查询服务这边读出来时,抛了异常。再仔细看两边的配置,发现写入服务的RedisTemplate自定义了json序列化器,查询服务用的却是默认的模板,因此查询时用jdk默认序列化器去解json序列化器写入的东西。
两边配置不一致,但不报错,因为JdkSerializationRedisSerializer读到一个非JDK格式的字节流时,会直接反序列化失败。而这种失败又被上层的try-catch吞掉了,返回了null。你从Redis客户端看数据明明是好的,一读却读不到,就这种效果。
这类问题的排查思路供参考:
- 先看Redis里的数据:用
redis-cli或者可视化工具,确认key和value是否存在,再看key是否带前缀(比如\xac\xed开头),value是否是正常可读的JSON。 - 对比写入与读取两侧的序列化配置:重点看keySerializer、valueSerializer是否完全一致。
- 检查是否有类路径变化:如果value里带
@class,确认当前代码里类的全路径是否还能对应上。 - 看异常栈:不要被外层try-catch骗了,一定要去日志里找到原始异常。如果日志被吞了,建议临时去掉catch,或者打印stack trace。
处理方案最终是统一所有服务的RedisTemplate配置,并且把value统一成JSON字符串,避免对类路径的强依赖。这次之后,我养成了一个习惯:所有涉及Redis的工程,第一件事就是确认序列化配置,而不是着急写业务代码。
4.1 为什么偶发会出现,而不是必然出现
有人在上面那个例子里会问:如果两边序列化配置不一致,那应该每次读都会有错,为什么是偶发?
答案在于:查询服务虽然用了默认模板,但部分操作是走StringRedisTemplate的,而String类型用默认jdk序列化器去解json写进去的数据,可能在某些情况下可以侥幸解析出来(比如数据内容恰好不需要类型信息),但一旦遇到复杂对象结构,就报错返回null。所以“偶发”的根本原因不是概率问题,而是数据内容的差异。简单数据能蒙混过关,复杂数据就现原形。
这类偶发问题最坑人,因为复现率不高,光靠测试很难发现问题。我的经验是:凡是Redis读多写少、并且不同服务共用key的场景,直接做一个串行化的强制约定——所有业务统一以String作为value传输层格式,要存对象就显式转JSON,谁也不要依赖模板自动序列化。这样虽然麻烦,但稳定。
5. 当RedisTemplate遇上Spring Cache:统一才是硬道理
很多项目用RedisTemplate做缓存操作,同时又用@Cacheable注解去缓存Service层结果,这种混用很常见。但如果你不加处理,等真正上线时会发现两套缓存数据格式完全不一样。
原因是:Spring Cache底层如果配置的是RedisCacheManager,它默认的序列化策略跟纯粹的RedisTemplate不完全一致。你通过@Cacheable写入的key,默认会有前缀(比如cacheName::key),而通过RedisTemplate直接写的key又只有裸key本身,查询时两边完全对不上。
解决思路是:要么统一走RedisTemplate封装缓存工具类,不用@Cacheable;要么把RedisCacheManager的key序列化改成和RedisTemplate一致,并为@Cacheable指定合适的缓存名。
我更推荐的做法是,在基础框架层就定死一套规则:
- Redis的key统一用
business:module:id这种格式,且用String序列化。 - value统一用JSON字符串,对象手动转JSON。
@Cacheable只用于方法级缓存,且缓存名称与key前缀保持同一套规则。
这样虽然牺牲了一些自动序列化的便利,但从运维角度看,任何人在Redis客户端里都能读懂数据,排查问题的时候不会一脸懵。
还有人会问:用@Cacheable和RedisTemplate同时操作同一个key,会不会冲突?我的回答是:如果序列化和key规范统一,就不会;如果不统一,那么两边写的数据就是两个世界的东西,用任何一边去读另一边,都读不通。
5.1 自定义RedisCacheManager的序列化
一旦决定用@Cacheable,且希望它与RedisTemplate的风格统一,推荐这样做:
java复制@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringSerializer = new StringRedisSerializer();
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
.disableKeyPrefix();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
这里disableKeyPrefix()是把缓存名前缀去掉,这样@Cacheable缓存出来的key就只包含key本身,跟RedisTemplate直接写的key格式一致。如果你习惯带缓存名前缀,也可以不关,但两边的key规则必须心中有数。
这段代码写起来不难,真正考验人的是“为什么这么写”。核心就一句话:让Spring Cache和RedisTemplate读者视角完全一致,不要让同一份数据出现两套序列化规则。
6. 几个让运维少救火的小习惯
最后聊几个我在项目中沉淀下来的具体习惯,有些问题看着不起眼,但线上出一次事故就够喝一壶的。
6.1 大key清理与过期时间设计
用RedisTemplate写入的数据,要养成“带上过期时间”的习惯。一个长期不失效的缓存Key,很可能因为业务逻辑里的一个bug,变成永远不清除的僵尸数据。更可怕的是大JSON对象的写入:一个value十几MB,写入时网络消耗大,读取时更慢,还会拖垮Redis单线程模型。
判断“大key”的标准,以Redis官方说法为准,字符串value超过10KB就要警惕,超过1MB基本是事故候选。如果业务上确实需要存大对象,优先考虑拆分存储,或者换成其他存储方案。Redis最适合做的是短平快的缓存,不是大对象仓库。
6.2 用setIfAbsent做分布式锁时的注意点
很多人用RedisTemplate实现分布式锁,最长见的写法是:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value);
注意这里有个版本差异:RedisTemplate在Spring Data Redis 2.x里,setIfAbsent默认不带过期时间。如果你想要“加锁+过期时间”原子完成,必须传超时参数:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(10));
如果你分两步走,先setIfAbsent,再expire,中间一旦宕机,锁就永远不会过期,变成死锁。这类bug不一定会马上出现,但一旦出现,整个业务都会被卡死。所以分布式锁一定要用带过期时间的原子操作。
6.3 Pipeline批量操作用对姿势
批量写入场景,比如缓存预热,逐条set并发不高时倒没什么,但如果一次性写1000条key,每条都同步等待,效率会糟糕。此时应该用Pipeline:
java复制RedisCallback<Object> callback = connection -> {
connection.openPipeline();
for (String key : keys) {
connection.stringCommands().set(key.getBytes(), value.getBytes());
}
connection.closePipeline();
return null;
};
redisTemplate.execute(callback);
Pipeline的核心收益是减少RTT次数,客户端把命令攒在一起批量发出。代价是不能立即获取每条命令的结果。如果你需要精确返回值,那Pipeline就不合适,得改用普通连续操作或者Lua脚本。块内存溢出也需要关注,批量太大时Pipeline里的命令都堆在内存里,同样会出问题。
还有一个体积巨大的坑:Pipeline里如果有非幂等命令,比如INCR,会出现重复执行的问题,因为你不知道上一条命令是否已经被服务端处理了。业务上要特别小心。
6.4 监控Redis命令耗时
我在线上经常开SLOWLOG,配合RedisInsight、SkyWalking这类工具看Redis命令的耗时统计。有些时间损耗根本不在Redis本身,而是在序列化和反序列化上。比如你用的是JDK序列化,序列化和反序列化开销大,尤其是对象结构复杂时,CPU占比相当可观。换成Jackson或者手动JSON后,耗时能明显下降。
所以在RedisTemplate的配置里,序列化器不只是“兼容性”问题,它直接影响性能和可观测性。上线前最好做一次简单的JVM压测,对比不同序列化器间的耗时差异。这属于“配置改了但没有代码量变化”的优化,特别适合作为性能优化的一项动作。
7. 关于RedisTemplate的一些补充思考
写到这里,再零零散散分享一些我自己的体会。
RedisTemplate本身不是一个高深的东西,它只是对Redis原生命令的Java封装。真正难的不是学会怎么用,而是知道每个配置背后的取舍。比如序列化器,JDK序列化省代码但反人类;Jackson序列化好用但会存一坨类型信息;String序列化最透明但所有东西都要手动转。每一种选择都会影响你将来排障时的体验。
我在团队里最常强调的一条规则是:**看Redis里的数据,要像看MySQL里的表一样清楚。**如果你一眼看不出某个key存储的是什么结构、什么业务含义,那这层封装就失去了可控性。技术选型时,优先选择“可读性”最高的方案,而不是“代码量最少”的方案。
还有一件事,是关于RedisTemplate的封装。很多团队会再包一层CacheService,把RedisTemplate的操作收敛起来,比如统一加key前缀、统一做异常处理、统一过期时间。这种做法我觉得是好的,关键是要把“底层细节”暴露出来,不要藏得太死。比如缓存穿透时你需要直接绕过工具类操作某个key,如果你的工具连直接查询都不支持,那就过度封装了。
另外一个很常见的误解是:RedisTemplate支持异步操作吗?Spring Data Redis 2.x之后有ReactiveRedisTemplate可以做响应式操作,但普通RedisTemplate还是同步阻塞的。如果你的项目用WebFlux,建议直接上ReactiveRedisTemplate,否则在响应式链路里调用同步RedisTemplate,即使没什么大问题,也无法发挥响应式的优势。
基于我个人踩过的坑,结尾再给大家一句实用性最高的建议:项目初期就定好Redis的规范,包括key命名、序列化方式、过期时间约定、缓存击穿处理策略,不要等到线上出问题再回头治理。RedisTemplate只是工具,规范才是日常运维最依赖的东西。
你要是前期把序列化统一、key规范定清楚,后面基本不会遇到那些“玄学问题”。真遇到了,也先别怀疑Redis,先把Redis命令行里能看到的数据格式打开,对照一下写入端和读取端的序列化配置,大概率就能找到原因。
