1. 从一次线上缓存乱码说起
前阵子帮一个朋友排查他们新上线不久的服务,现象很怪:本地跑得好好的,一部署到测试环境,从缓存里读出来的对象字段全是null,偶尔还抛一个ClassCastException。日志里打印出来的缓存值长这样:{"@type":"com.xxx.User","name":"张三"},多了一个莫名其妙的@type字段,而且反序列化回来的时候类型对不上。他们用的就是 SpringBoot 2.x 默认的 Redis 序列化方案,也就是JdkSerializationRedisSerializer,后来有人图省事换成了 Jackson,结果又踩了默认类型信息的坑。
这个场景其实特别典型。SpringBoot 2.x 整合 Redis 做缓存,默认的 JDK 序列化有几个让人难受的地方:存进去的是二进制字节流,用 redis-cli 直接看是一堆乱码,排查问题基本靠猜;序列化后的体积也偏大,同样的对象比 JSON 大出不少;跨语言、跨服务共享缓存的时候更是没法用。所以很多团队会换成 JSON 序列化,而 FastJson 因为 API 简单、性能不错,在国内项目里被大量使用。
这篇内容就是围绕"SpringBoot 2.x 整合 Redis 并使用 FastJson 序列化"这件事展开的。我会把为什么要换、怎么换、换的过程中有哪些坑、生产环境要注意什么,从头到尾讲清楚。适合正在做缓存改造的后端开发、刚接触 SpringBoot 整合 Redis 的同学,也适合那些已经用了 FastJson 但总觉得哪里不对劲、想搞清楚底层逻辑的人。看完你应该能独立完成一套稳定可用的配置,并且知道每个参数为什么这么设。
2. 为什么默认序列化方案总让人不踏实
2.1 JDK 序列化到底差在哪
SpringBoot 的RedisTemplate默认用的是JdkSerializationRedisSerializer。它的原理是把 Java 对象通过ObjectOutputStream写成一串二进制,要求被序列化的类实现Serializable接口。这套机制在纯 Java 单机环境里没问题,但放到 Redis 这个场景就暴露了几个硬伤。
第一是可读性几乎为零。你用redis-cli执行get拿到的是一串带转义字符的二进制,肉眼根本没法判断存的对不对。排查缓存问题时,你只能写代码去反序列化,效率极低。第二是体积大,JDK 序列化会写入类的元信息、serialVersionUID 等一堆东西,同样的对象比 JSON 大 30% 到 50% 很常见,缓存量大时内存成本很可观。第三是强耦合,序列化结果和类的全限定名、字段结构强绑定,你改个包名或者挪个字段,老缓存直接反序列化失败。第四是跨语言基本没戏,别的语言读不了 Java 的序列化格式。
2.2 Jackson 和 FastJson 的取舍
换成 JSON 序列化是共识,那选 Jackson 还是 FastJson?Jackson 是 Spring 生态的默认 JSON 库,和 SpringBoot 集成度最高,稳定性、安全性经过大量验证,社区活跃。FastJson 的优势在于 API 极其简洁,JSON.toJSONString和JSON.parseObject两个方法走天下,序列化性能在多数基准测试里也略占优势,而且国内文档和案例多,遇到问题好搜。
但 FastJson 也有它的问题,历史上出过几次反序列化相关的安全漏洞,所以选版本要谨慎,尽量用较新的、修复过漏洞的版本。另外 FastJson 默认会写入类型信息(就是开头那个@type),这个特性在缓存场景下如果处理不当,会带来类型混淆的风险。所以我的建议是:如果团队没有特别偏好,Jackson 更稳妥;如果已经在用 FastJson 或者对性能有明确要求,那就用 FastJson,但一定要把SerializerFeature配置对,尤其是关掉WriteClassName。
这里要强调一点,无论选哪个,核心思路都是一样的:自定义一个RedisSerializer,把它设置到RedisTemplate的 key 和 value 序列化器上。区别只在于内部调的是哪个 JSON 库。
2.3 一个容易被忽略的前提:key 的序列化
很多人只关注 value 的序列化,忘了 key 也是要序列化的。默认情况下 key 用的也是 JDK 序列化,结果就是你在 redis-cli 里看到的 key 是一串乱码,keys *出来的东西完全没法认。正确的做法是 key 用StringRedisSerializer,value 用 JSON 序列化器。这样 key 在 Redis 里就是明文的字符串,方便运维排查和手动清理,value 则是可读的 JSON。这个搭配几乎是行业标准做法,后面配置的时候会具体写。
3. 手把手配置 FastJson 序列化器
3.1 依赖引入与版本选择
先看依赖。SpringBoot 2.x 整合 Redis 的核心依赖是spring-boot-starter-data-redis,它底层用的是 Lettuce 客户端(SpringBoot 2.x 默认,1.x 是 Jedis)。FastJson 单独引入。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency>版本这里多说一句。FastJson 1.2.x 系列里,1.2.83 是一个相对较新且修复了已知反序列化漏洞的版本,如果项目允许,优先选它或者更高的 1.2.x 版本。不要用那些很老的版本,安全风险实打实存在。如果你的项目已经全面迁移到 FastJson2,那用com.alibaba.fastjson2:fastjson2也可以,API 略有差异,但思路完全一致。
3.2 自定义 RedisSerializer 的完整实现
核心就是写一个类实现RedisSerializer<T>接口,把序列化和反序列化委托给 FastJson。下面是一个通用实现,泛型化处理,key 和 value 都能用。
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.serializer.SerializerFeature; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public class FastJsonRedisSerializer<T> implements RedisSerializer<T> { public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private final Class<T> clazz; public FastJsonRedisSerializer(Class<T> clazz) { this.clazz = clazz; } @Override public byte[] serialize(T t) throws SerializationException { if (t == null) { return new byte[0]; } try { String json = JSON.toJSONString(t, SerializerFeature.WriteClassName); return json.getBytes(DEFAULT_CHARSET); } catch (Exception e) { throw new SerializationException("FastJson 序列化失败", e); } } @Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length == 0) { return null; } try { String json = new String(bytes, DEFAULT_CHARSET); return JSON.parseObject(json, clazz); } catch (Exception e) { throw new SerializationException("FastJson 反序列化失败", e); } } }这段代码有几个点值得展开。第一,serialize里对null的处理返回空数组而不是null,这是为了和 Spring Data Redis 的约定保持一致,避免上层拿到null字节数组时出现空指针。第二,deserialize里对空数组返回null,这样缓存未命中时逻辑是干净的。第三,异常统一包装成SerializationException,方便上层统一处理。
3.3 关于 WriteClassName 的取舍
上面代码里我用了SerializerFeature.WriteClassName,这个特性会把类的全限定名写进 JSON,也就是那个@type字段。它的好处是反序列化时能自动还原成正确的子类型,对于多态场景(比如缓存里存的是接口或抽象类的实现)很有用。但它的代价是:JSON 里多了冗余信息,体积变大;更重要的是,如果反序列化的目标类型不受控,可能被利用来做类型混淆攻击。
所以这里要分场景。如果你的缓存 value 类型是确定的、单一的(比如就是某个具体的实体类),那完全可以去掉WriteClassName,用下面这种更干净的写法:
String json = JSON.toJSONString(t);反过来,如果你确实需要处理多态,比如缓存里存的是List<Animal>,里面有Dog和Cat,那WriteClassName就必要了。但这时候一定要确保反序列化的入口是可信的,不要对来路不明的 JSON 做parseObject。我的经验是:能用确定类型就别开 WriteClassName,能少一个风险点就少一个。
3.4 把序列化器装配到 RedisTemplate
有了序列化器,接下来配置RedisTemplate。这里要注意,SpringBoot 自动配置的RedisTemplate<Object, Object>和StringRedisTemplate是两个不同的 bean,我们通常自定义一个RedisTemplate<String, Object>来替代默认的。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); FastJsonRedisSerializer<Object> fastJsonSerializer = new FastJsonRedisSerializer<>(Object.class); StringRedisSerializer stringSerializer = new StringRedisSerializer(); // key 用 String 序列化,保证 redis-cli 里可读 template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); // value 用 FastJson 序列化 template.setValueSerializer(fastJsonSerializer); template.setHashValueSerializer(fastJsonSerializer); template.afterPropertiesSet(); return template; } }afterPropertiesSet()这行别漏了,它会校验必要的属性是否设置,早发现问题比运行时才报错强。另外注意FastJsonRedisSerializer<Object>这里传的是Object.class,因为 value 类型是多样的。反序列化时JSON.parseObject(json, Object.class)会返回JSONObject或JSONArray,如果你希望拿到原始类型,要么在业务层做转换,要么针对具体类型单独定义序列化器。这是 FastJson 方案里一个需要提前想清楚的点,后面会专门讲。
4. 那些配置完才发现的坑
4.1 反序列化回来变成 JSONObject 了
这是用 FastJson 做通用 value 序列化时最常遇到的问题。你往缓存里存了一个User对象,取出来的时候发现是个JSONObject,强转User直接ClassCastException。原因就在上面说的:FastJsonRedisSerializer<Object>反序列化时用的是Object.class,FastJson 不知道目标类型,只能返回JSONObject。
解决办法有几种。第一种是业务层用JSON.toJavaObject手动转换,简单但啰嗦。第二种是给每种类型定义专属的RedisTemplate,比如RedisTemplate<String, User>,但这样 bean 会很多,不现实。第三种,也是我比较推荐的,是在序列化时开启WriteClassName,反序列化时用JSON.parse而不是parseObject,让 FastJson 根据@type自动还原类型:
@Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length == 0) { return null; } try { String json = new String(bytes, DEFAULT_CHARSET); return (T) JSON.parse(json); } catch (Exception e) { throw new SerializationException("FastJson 反序列化失败", e); } }注意这里JSON.parse返回的是Object,强转成T。配合序列化时的WriteClassName,就能自动还原成User。但前面说过,WriteClassName有安全考量,所以这个方案适合内部可信的缓存场景,不要用在对外接口的数据上。
4.2 泛型集合的反序列化陷阱
存List<User>的时候,即使开了WriteClassName,取出来也可能变成JSONArray,里面的元素是JSONObject。这是因为泛型在运行时被擦除了,FastJson 拿不到List<User>里的User信息。处理办法是用TypeReference:
List<User> users = JSON.parseObject(json, new TypeReference<List<User>>() {});但在通用的RedisSerializer里你没法知道具体泛型,所以集合类型的缓存,建议在业务层单独处理,或者干脆把集合包装成一个具体的类再存。我个人的习惯是:缓存里尽量存单一对象,集合类数据要么拆开存,要么用专门的序列化逻辑,这样能避开大部分泛型坑。
4.3 时间类型序列化格式不统一
Date、LocalDateTime这些时间类型,FastJson 默认序列化成时间戳(毫秒数),存到 Redis 里是一串数字,可读性差,而且不同版本行为可能不一致。解决办法是在序列化时指定格式:
String json = JSON.toJSONString(t, SerializerFeature.WriteClassName, SerializerFeature.WriteDateUseDateFormat);WriteDateUseDateFormat会让日期按yyyy-MM-dd HH:mm:ss格式输出。但LocalDateTime在 FastJson 1.2.x 里默认可能不被很好支持,需要额外注册序列化器,或者干脆在实体类字段上加@JSONField(format = "yyyy-MM-dd HH:mm:ss")。这个坑很隐蔽,因为本地测试用Date没问题,一换成LocalDateTime就出幺蛾子。建议统一团队的时间类型规范,要么全用Date,要么全用LocalDateTime并配好格式。
4.4 缓存穿透与空值处理
序列化配置好了,还有个业务层面的坑:缓存穿透。如果查询一个不存在的 key,每次都打到数据库,缓存就形同虚设。常规做法是缓存空值,但空值怎么序列化?null经过我们的序列化器会变成空字节数组,存到 Redis 里是个空字符串,取出来又是null,逻辑上能跑通,但要和"缓存未命中"区分开。更稳妥的做法是缓存一个特殊标记对象,比如一个NullValue单例,或者用Optional包装。这块和序列化本身关系不大,但配置完序列化后紧接着就会遇到,提前想好能省不少事。
5. 生产环境下的稳定性考量
5.1 序列化性能与缓存体积实测
配置完成后我做过一轮简单对比。同一个User对象(约 10 个字段),JDK 序列化后大约 380 字节,FastJson 不带WriteClassName约 180 字节,带WriteClassName约 240 字节。体积上 FastJson 优势明显,尤其是缓存量大、QPS 高的场景,省下来的内存和网络带宽很可观。性能方面,单次序列化 FastJson 比 JDK 快,但差距在微秒级,真正影响大的是体积带来的网络传输和内存占用。
不过要注意,WriteClassName带来的体积增长在多态场景下是值得的,但在单一类型场景下就是纯浪费。所以再次强调:按需开启,不要无脑加。
5.2 版本升级与兼容性
FastJson 的版本升级要谨慎。1.2.x 系列不同小版本之间,序列化行为可能有细微差异,比如日期格式、null字段是否输出等。升级前一定要在测试环境跑一遍完整的缓存读写回归,重点验证:老缓存能否被新版本正确反序列化、新写入的缓存老版本能否读。如果做不到平滑兼容,就要考虑缓存双写或者版本隔离方案。
另外,如果项目里同时存在 Jackson 和 FastJson(比如 SpringMVC 用 Jackson,Redis 用 FastJson),要注意两者的配置不要互相干扰。它们是独立的,一般没问题,但ObjectMapper的全局配置和 FastJson 的全局配置是两套东西,别搞混了。
5.3 缓存 key 的命名规范
序列化搞定后,key 的规范同样重要。建议用冒号分隔的层级结构,比如user:info:1001、order:detail:20240101:888。这样在 redis-cli 里keys user:*能快速定位,也方便做批量清理。key 里不要塞太长的内容,避免占用过多内存。另外,如果用了StringRedisSerializer,key 就是明文字符串,注意不要包含特殊字符和空格,否则运维脚本处理起来麻烦。
5.4 监控与问题排查
上线后要关注几个指标:Redis 的内存使用量、命中率、慢查询。序列化相关的异常通常会以SerializationException的形式抛出,建议在全局异常处理里单独捕获并打日志,日志里带上 key 和原始字节的前若干字节,方便定位是哪个缓存出的问题。我见过有团队因为序列化异常没处理好,导致整个查询链路雪崩,这种问题提前加监控就能避免。
6. 几个实际项目里的经验之谈
先说一个我踩过的坑。有次做缓存改造,把RedisTemplate的 value 序列化器从 JDK 换成 FastJson,测试环境没问题,上线后部分老缓存读取报错。原因是老缓存是 JDK 序列化的二进制,新序列化器用 FastJson 去解析当然失败。解决办法是上线时做缓存预热或者干脆清空重建,如果缓存可以重建,直接flushdb最省事;如果不能,就要做兼容读取,先尝试 FastJson,失败再回退 JDK。这个教训是:换序列化方案等于换缓存格式,一定要考虑存量数据。
第二个经验是关于WriteClassName的。有个项目为了图方便全局开了这个特性,结果缓存里全是@type,体积涨了不少,而且后来做安全扫描时被标了风险。后来改成只在真正需要多态的几个 key 上单独处理,通用序列化器关掉WriteClassName,体积和安全性都改善了。所以配置要精细,不要一刀切。
第三个是团队协作层面的。序列化方案一旦定下来,要写进团队规范,因为不同人可能各自 new 一个RedisTemplate,配置不一致会导致同一个 key 被不同格式写入,读的时候互相打架。建议统一封装一个工具类或者 starter,所有人用同一套配置,从源头保证一致性。
最后分享一个排查小技巧。当你怀疑缓存里的数据格式不对时,别急着写代码,先用redis-cli连上去,get一下 key,如果是明文 JSON 就一眼能看出问题;如果是乱码,说明序列化器没配对。再配合object encoding key看底层编码,type key看数据类型,基本能快速定位。这些命令花不了几分钟,比翻代码高效得多。
这套配置我在几个项目里都用过,稳定运行没什么问题。核心就三件事:key 用 String 序列化保证可读,value 用 FastJson 序列化保证体积和通用性,WriteClassName按需开启兼顾多态和安全。把这三点想清楚,剩下的就是细节打磨了。