1. 两个 Template 的关键差异就一个字:序列化器
1.1 一个乱码问题引出的真相
我刚用 Spring Boot 接 Redis 那会儿,遇到过一个特别诡异的问题:用一个接口写数据,再用另一个接口去读,返回值竟然是 null。代码里明明用的是同一个 key,又没有到期时间,怎么看都不该读不到。后来打开 redis-cli 查了一下,才看到真凶——Redis 里躺着一个\xac\xed\x00\x05t\x00\x04name这样的 key。看到这个前缀我一下就明白了:这是 JDK 原生序列化打出来的二进制头,我写入的 "name" 早就不是我以为的那个 "name" 了。
这里面的关键,就是 Spring Data Redis 提供了两个长得几乎一样的核心类:RedisTemplate<K, V>和StringRedisTemplate。官方文档对它们的区别写得相当含蓄,只说是"方便用户使用",但实际开发里,这两个类序列化方式的差异能直接导致线上缓存失效、数据不可读、反序列化抛异常等一系列问题。这篇文章就把它们彻底拆开,讲清楚到底差在哪、为什么这么设计、以及你该在什么场景下选哪一个。
1.2 RedisTemplate 默认用什么,StringRedisTemplate 默认用什么
先给结论:这两个类的唯一本质区别,就是默认序列化器不同。
RedisTemplate没有指定任何序列化器时,key、value、hash 的 field 和 value 全部走JdkSerializationRedisSerializer。也就是说,你写入的字符串、对象,会先被转成 JDK 序列化之后的二进制字节,再存进 Redis。而StringRedisTemplate继承自RedisTemplate<String, String>,它在构造的时候就把 key、value、hashKey、hashValue 四个序列化器全部显式指定成了StringRedisSerializer,也就是按 UTF-8 编码存字符串。
我把这两个类默认的序列化器配置整理成了一张表:
| 序列化位置 | RedisTemplate 默认 | StringRedisTemplate 默认 |
|---|---|---|
| key | JdkSerializationRedisSerializer | StringRedisSerializer |
| value | JdkSerializationRedisSerializer | StringRedisSerializer |
| hashKey | JdkSerializationRedisSerializer | StringRedisSerializer |
| hashValue | JdkSerializationRedisSerializer | StringRedisSerializer |
| 泛型类型 | RedisTemplate<Object, Object> | RedisTemplate<String, String> |
所以同样执行一行opsForValue().set("name", "张三"),两个类写进 Redis 的东西是完全不同的。StringRedisTemplate写进去的是可读的name -> 张三,而RedisTemplate写进去的是一个以\xac\xed开头的二进制 key,和一堆二进制 value。这个差异往下游看,几乎会影响所有 Redis 操作:缓存读取、分布式锁、Hash 结构、消息队列的存储,通通跑不掉。
2. Spring 为什么非要拆出两套 Template,而不是一套通用实现
2.1 JDK 序列化:省心但很“重”
要搞懂为什么需要StringRedisTemplate,得先理解RedisTemplate默认走 JDK 序列化这件事是怎么来的。
JDK 原生序列化,也就是让 Java 对象实现Serializable接口,然后通过ObjectOutputStream把对象转换成字节流。这套机制的好处是省心:只要类实现了接口,不需要额外配置,就能把任意对象存进 Redis,读取的时候再反序列化回对象。对于纯 Java 技术栈的内部服务来说,这几乎是零成本方案。
但它的代价在开了 Redis 之后就开始显现。首先是不可读。JDK 序列化后的数据包含类描述、字段描述、各种头信息,在 redis-cli 里看起来就是一团乱码,人工排障非常痛苦。其次是体积大。JDK 序列化会把类名、字段名、类型签名等元数据写进字节流,一个简单的"name"字符串,序列化之后比原始数据大好几倍,存多了对 Redis 内存是实打实的压力。
更要命的是跨语言和跨版本问题。JDK 序列化是 Java 专属协议,其他语言写的服务根本读不了。而且一旦存进去的对象类改了包名、改了字段名,或者没显式声明 serialVersionUID,反序列化阶段动辄报InvalidClassException。我见过好几次因为类结构调整,线上缓存大面积报错的案例,根因都是默认 JDK 序列化惹的祸。
2.2 字符串序列化才是 Redis 最常见的打开方式
Redis 本身的设计,从命令层到协议层,都优先围绕字符串展开。它最典型的使用场景,其实就是"存一个字符串,取一个字符串":接口的 JSON 响应缓存、验证码、token、用户会话、计数器、分布式锁的 key,几乎全都可以建模成字符串键值对。
如果这些场景全用 JDK 序列化,就会面临一个很尴尬的现实:我要的是能直接读、能跨服务复用、能随时用 redis-cli 检查的数据,结果 Redis 里存的却是只有 Java 才能看懂的二进制流。这显然不合理。
所以 Spring Data Redis 专门提供了StringRedisTemplate,把四个序列化器全部换成StringRedisSerializer。字符串序列化的好处非常直观:存储内容与原始字符串一致,可以用 redis-cli、可视化工具直接查看;不依赖 Java 类定义,任何语言的客户端都能读写;体积小,内存开销低;数据可迁移、可导入导出,运维操作基本没有门槛。
说白了,RedisTemplate是为"Java 对象进 Redis"设计的,StringRedisTemplate是为"业务数据进 Redis"设计的。业务数据里绝大多数都是 JSON 字符串和普通字符串,这决定了StringRedisTemplate在大多数项目中才是真正高频使用的那个。
3. 从源码看 StringRedisTemplate 到底改了什么
3.1 构造器里的显式配置
我一直觉得,源码比文档讲得更直白。直接看StringRedisTemplate的构造器,你会发现它做得非常粗暴——就是调用super()之后,把所有序列化器全部覆盖掉。
public class StringRedisTemplate extends RedisTemplate<String, String> { public StringRedisTemplate() { RedisSerializer<String> stringSerializer = new StringRedisSerializer(); setKeySerializer(stringSerializer); setValueSerializer(stringSerializer); setHashKeySerializer(stringSerializer); setHashValueSerializer(stringSerializer); } public StringRedisTemplate(RedisConnectionFactory connectionFactory) { this(); setConnectionFactory(connectionFactory); afterPropertiesSet(); } }注意一个细节:无参构造器里只设置了序列化器,并没有调用afterPropertiesSet()。只有传入connectionFactory的构造器,才会在设置完连接工厂后执行初始化。很多人自定义RedisTemplate时会漏掉afterPropertiesSet(),然后在注入使用的时候发现连不上 Redis,多半就是这个原因。
3.2 afterPropertiesSet 的兜底逻辑
那RedisTemplate的默认序列化器又是从哪来的?答案在afterPropertiesSet()方法里。这个方法是 Spring 的InitializingBean接口定义的,会在 Bean 属性填充完成后自动执行。
@Override public void afterPropertiesSet() { if (this.defaultSerializer == null) { this.defaultSerializer = new JdkSerializationRedisSerializer(); } if (this.keySerializer == null) { this.keySerializer = this.defaultSerializer; } if (this.valueSerializer == null) { this.valueSerializer = this.defaultSerializer; } if (this.hashKeySerializer == null) { this.hashKeySerializer = this.defaultSerializer; } if (this.hashValueSerializer == null) { this.hashValueSerializer = this.defaultSerializer; } // ... 连接工厂相关初始化 }也就是说,只要你在配置时没有手动指定keySerializer、valueSerializer这些属性,RedisTemplate就会拿默认的 JDK 序列化器去兜底。这就是为什么很多人明明只是想在 Redis 里存个普通字符串,存完一看却是一堆乱码。「我什么都没配啊,为什么存进去是乱码?」——因为你没配,所以走了最不可读的那条路。
而StringRedisTemplate在构造阶段就把这四个序列化器全部指定完毕,afterPropertiesSet()执行的时候看到它们都已经有值,就不会再覆盖成 JDK 默认实现。这解释了为什么只要拿到StringRedisTemplate,默认行为就是可读的字符串存储。
4. 同一个 key,两个 Template 在 redis-cli 里是两副面孔
4.1 写进 Redis 以后看到的数据完全不同
光说不练没意义,我结合一段实际代码来演示。假设 Redis 里什么都没有,执行下面两段写入:
@Service public class DemoService { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedisTemplate<String, Object> redisTemplate; public void write() { stringRedisTemplate.opsForValue().set("name:str", "张三"); redisTemplate.opsForValue().set("name:obj", "张三"); } }注意:这里我用 RedisTemplate 存"name:obj"这个 key 的时候,key 和 value 都会被 JDK 序列化。两边写完,在 redis-cli 里执行keys *,你大概率会看到这样两个 entry:
127.0.0.1:6379> keys * 1) "name:str" 2) "\xac\xed\x00\x05t\x00\x08name:obj"再看看 value 长什么样:
127.0.0.1:6379> get name:str "\xe5\xbc\xa0\xe4\xb8\x89" 127.0.0.1:6379> get "\xac\xed\x00\x05t\x00\x08name:obj" "\xac\xed\x00\x05t\x00\x07\xe5\xbc\xa0\xe4\xb8\x89"StringRedisTemplate存进去的值是 UTF-8 编码的"张三",redis-cli 里只要带上--raw参数就能直接看到汉字。而RedisTemplate存进去的 key 和 value 都被套了一层 JDK 序列化外壳,肉眼根本没法直接辨认,更别提用脚本、用其他语言客户端去处理这些数据了。
这个差异在实际项目里会很快放大。比如你在排查缓存命中率,查一下 Redis 里的 key 分布,结果发现全是\xac\xed开头的二进制,你连是哪个业务写的都判断不出来。到时候你是靠猜还是靠改代码加日志?都很浪费时间。
4.2 key 对不上号才是混用的隐藏炸弹
更隐蔽的问题是 key 对不上。有人可能会想:既然RedisTemplate的 key 默认是 JDK 序列化,那我在存的时候直接传字符串 key,读取的时候也用同一个 Template 不就行了?确实可以,但如果一个系统里RedisTemplate和StringRedisTemplate混着用,问题就来了。
StringRedisTemplate读取时会把你的字符串 key 用 UTF-8 编码去匹配,RedisTemplate读取时会把你的字符串 key 用 JDK 序列化去匹配。结果就是:同一个字符串 key,在两套序列化规则下对应的是两个完全不同的 Redis 键。你用stringRedisTemplate存了一个"name:str",再用redisTemplate去读,读到的永远是 null——不是值为空,而是它压根没找到那个 key。
这也是我在开头说的那个诡异问题的完整解释。接口 A 用RedisTemplate写,接口 B 用StringRedisTemplate读,表面上大家传的都是同一个业务 key,实际上写的是一个二进制 key,读的是一个明文字符串 key,两边从未相遇。这类问题非常难排查,因为它不报错,代码也看不出问题,很多人会在这上面耗掉半天。
5. 业务里到底怎么选:这几种场景的答案很明确
5.1 闭眼用 StringRedisTemplate 的场景
缓存 JSON 字符串,这是最常见的。绝大多数缓存场景,我们前置流程已经把数据序列化成 JSON 了,存进 Redis 的就是一个字符串。这种场景完全没有必要用 JDK 序列化,直接用StringRedisTemplate就能把 JSON 原样写进去,其他服务、排查工具随读随看。
验证码、短信码、token、Session Key,也是典型的短字符串场景,存的是一串随机码或者一个 JWT 字符串,读取的时候只做字符串比对,用StringRedisTemplate最合适。计数器场景也建议用StringRedisTemplate,因为计数器的 value 在 Redis 服务端本身就是以字符串形式维护的,用字符串模板语义最清晰。
分布式锁更不用说了。锁的本质是"某个 key 是否存在、谁设置的、什么时候过期",很多人用setIfAbsent(lockKey, clientId, timeout, unit)实现。如果用的是RedisTemplate默认序列化器,锁 key 也会变成二进制乱码,虽然功能上可能能跑通,但排障、续期、和跨语言客户端协作的时候非常痛苦。统一用StringRedisTemplate或指定字符串序列化器的自定义模板,锁 key 可读、可维护、可审计。
一句话总结:只要 value 是或可以转成字符串,就优先用StringRedisTemplate。这不是保守,而是让数据在 Redis 里保持"原样",减少各种不必要的转换环节。
5.2 必须换序列化器的场景
如果你真的需要在 Redis 里直接缓存 Java 对象,那我的建议是:别用默认的RedisTemplate,但也别直接用StringRedisTemplate。正确做法是给RedisTemplate换掉默认序列化器,换成 JSON 序列化器。
具体来说,key 用StringRedisSerializer,value 用GenericJackson2JsonRedisSerializer。这样 key 在 Redis 里是可读的字符串,value 是 JSON 文本,Java 对象在写入时会自动转 JSON,读取时会自动反序列化回对象。相比 JDK 序列化,它保持了可读性、跨语言性,体积也更小。
这个方案特别适合 Hash 结构场景。比如你用opsForHash().put("user:1", "profile", userObj)存一个用户对象,用GenericJackson2JsonRedisSerializer后,field 是普通字符串,value 是 JSON,整个结构在 redis-cli 里一目了然,排障体验和 JDK 序列化完全是两个世界。
5.3 配置缓存管理器时最容易漏的序列化
还有一个经常被忽略的地方:RedisCacheManager。很多人只关注了RedisTemplate,没管缓存注解@Cacheable底层的 CacheManager。默认情况下,Spring Boot 自动配置的RedisCacheManager使用的序列化器和RedisTemplate一样,还是 JDK 序列化。结果就是,明明你在代码里统一用的是StringRedisTemplate,但一用缓存注解,Redis 里又冒出一堆乱码 key。
解决方式是在自定义RedisCacheManager时显式指定序列化器。这里给一个最常用的配置片段:
@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)); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); }这样配好之后,缓存注解写入的数据也是可读的 key + JSON value,和业务代码里手动用模板写入的数据保持一致,整体排查心智负担会小很多。
6. 混用两套 Template 踩过的坑和完整排查链路
6.1 现象一:读出来全是 null,第一反应别改代码
这种问题最常见。RedisTemplate写入、StringRedisTemplate读取,或者反过来,表现出来就是"代码逻辑没问题,连接池没问题,数据也在 Redis 里,但就是读出来 null"。
我给出的排查链路是固定的。第一步,先确定你用的到底是哪个 Template;第二步,打开 redis-cli 执行scan看一下 key 的实际形态;第三步,看看 key 是不是以\xac\xed开头;第四步,用type和debug object确认存储类型和编码。如果以上都确认了,结论基本就是序列化器不一致导致 key 对不上。
遇到这种情况,别第一时间怀疑分布式缓存过期策略或者连接池配置,先看一眼 Redis 里的 key 长什么样,90% 的序列化问题都能在 redis-cli 里一眼定位。
6.2 现象二:反序列化直接 ClassCastException
另一种典型场景是:同一个 key 在 A 服务用RedisTemplate写入了对象,B 服务读的时候直接抛ClassCastException或者IllegalArgumentException。这是因为 JDK 序列化要求读端和被读的类完全匹配,一旦类的包名、类名、字段结构发生变化,或者两个服务引入的依赖版本不一致,反序列化就会直接失败。
这类报错特别容易让人误判成"代码 bug"或者"数据被写坏了"。我可以负责任地说,绝大多数情况下数据没坏,坏的是序列化契约。所以我会建议团队里明确一条红线:跨服务共用的 Redis 数据,绝对不允许用 JDK 序列化,要么纯字符串,要么统一 JSON 格式,并且反序列化时带上类型信息时要格外注意安全问题。
6.3 现象三:对象没实现 Serializable,写入时就炸了
还有一个很直白的坑:用RedisTemplate存一个没有实现Serializable的普通对象时,写入阶段就会直接抛IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type。这个错误信息其实已经把原因说得很清楚了——默认 JDK 序列化器不认这个对象。
遇到这种报错,很多人第一反应是让对象去实现Serializable。这当然能让代码跑起来,但说实话只是把问题往后推。今天加了Serializable,明天改了包名,后天换了字段类型,反序列化阶段照样炸。我的建议是:如果这个对象要和 Redis 之外的系统交互,或者要供多个服务读取,就别走 JDK 序列化这条路,直接换成 JSON 序列化,从根上解决可读性和兼容性问题。
7. 生产环境我最终采用的方案
7.1 自定义 RedisTemplate Bean 的标准配置
踩过这些坑之后,我在项目里的选择就很固定了:不用默认的RedisTemplate,也不裸用StringRedisTemplate,而是自定义一个清晰配置的RedisTemplate<String, Object>。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } @Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { return new StringRedisTemplate(factory); } }这个配置下,所有 key 在 Redis 里都可读,所有 value 都以 JSON 形式存在。单独需要纯字符串读写时,直接注入StringRedisTemplate,互不干扰。这里有个关键点:配置完RedisTemplate一定记得调用afterPropertiesSet(),否则序列化器设置可能不会生效,这个问题排查起来也很隐蔽。
7.2 更朴素的方案:StringRedisTemplate + ObjectMapper
如果你不想引入GenericJackson2JsonRedisSerializer带来的类型信息,也可以走更朴素的路线:完全用StringRedisTemplate存 JSON 字符串,序列化和反序列化交给ObjectMapper手动控制。
public <T> void setObject(String key, T obj) { try { String json = objectMapper.writeValueAsString(obj); stringRedisTemplate.opsForValue().set(key, json); } catch (JsonProcessingException e) { throw new RuntimeException("对象序列化失败", e); } } public <T> T getObject(String key, Class<T> clazz) { String json = stringRedisTemplate.opsForValue().get(key); if (json == null) { return null; } return objectMapper.readValue(json, clazz); }这个方案没有魔法,所有转换都是显式代码,出问题的时候逻辑链路非常清晰。Redis 里存的内容就是干净的 JSON,用 redis-cli 直接能看,任何语言都能读,不需要关心序列化器内部的类型信息。代价就是每次读写多写一点代码,但换来的是长期可维护性。
7.3 团队协作时的三个约定
基于这些年的实际经验,我还想分享三点团队协作层面的约定,它们能帮你省掉很多不必要的麻烦。
第一,凡是跨服务要共享的 Redis 数据,一律用字符串或 JSON。JDK 序列化只适合单个服务内部的临时缓存,一旦牵扯到别的服务、别的语言,就一定会出问题。
第二,Redis 里的 key 风格必须可读。不管用什么 Template,最终写进 Redis 的 key 都应该是业务可理解的字符串,比如user:info:123、cart:list:456。key 不可读,等于给排障埋雷。
第三,排查问题时先看 Redis 里的原始数据。遇到缓存读写不一致、反序列化异常,第一步永远是打开 redis-cli 看数据形态,而不是改代码。看到\xac\xed开头,你就能立刻判断出这是 JDK 序列化导致的,再看一眼双方用的序列化器配置,问题基本就水落石出。
我自己在项目里的习惯是:默认能用StringRedisTemplate就绝不多绕一圈,真正需要对象缓存时统一走自定义的 JSON 序列化模板。这两条路覆盖了 99% 的 Redis 使用场景,剩下的 1% 特殊需求,只要记住"序列化器决定 Redis 里的数据长什么样"这个底层逻辑,也随时能做出正确判断。