资讯动态

Fastjson与Redis序列化:泛型擦除和AutoType导致的ClassCastException排查指南

发布时间:2026/10/24 6:05:08 来源:尧图企业网站定制
前几天排查一个线上问题业务同学把堆栈发过来我一眼就看到了那行熟悉的异常ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.example.User。这几乎是 Redis 用 fastjson 做序列化时最容易踩到坑的“标准签名”。准确说这类问题通常不是一个坑而是两个坑结伴出现泛型擦除导致的类型转换异常以及 fastjson 的 AutoType 类名机制在 Redis 缓存场景下引发的类型错乱。如果正在用 RedisTemplate 加 fastjson或者正准备改造缓存序列化方案这篇文章值得花十分钟读完。我会把两个坑的报错现场、背后的原理、修复代码和排查思路一次说透。1. 先说结论这两个坑到底长什么样1.1 坑一从 Redis 取出来的对象“变形”了很多团队会把 RedisTemplate 的 value 序列化器换成 fastjson图的是可读性好、性能高。这么做之后最常见的翻车现场长这样// 存的时候一切正常 ListUser userList new ArrayList(); userList.add(new User(张三, 18)); redisTemplate.opsForValue().set(cache:user:list, userList); // 取的时候报错瞬间出现 ListUser cacheList (ListUser) redisTemplate.opsForValue().get(cache:user:list);这段代码的问题很隐蔽。从编译器的角度看存进去是ListUser取出来再强转成ListUser语法完全合理。但运行时你会发现真正从 Redis 反序列化出来的对象并不是ListUser而是ListJSONObject。也就是说cacheList里的每个元素其实都是 fastjson 内部的 JSONObject只在表面上“伪装”成了 User 的列表。一旦代码里执行cacheList.get(0).getName()JVM 就会在运行时抛出ClassCastException。这种问题最坑的地方在于它不一定会立刻报错。如果只是把 list 再序列化传给别人或者遍历时直接toString程序可能跑很久都没事但只要一碰到强转或反射就会炸出异常。线上排查的时候报错时间早、触发场景随机几乎不可能一眼定位到序列化器上。1.2 坑二类名信息在序列化链路里惹祸第二个坑跟 fastjson 的 AutoType 机制有关。有些场景下开发者需要保存多态对象比如一个接口Animal实际存的是Dog和Cat反序列化时希望 fastjson 能“自动识别”原始类型。为了做到这点序列化时会写入type字段{type:com.example.Dog,name:旺财,sound:汪汪}问题就出在这个type上。从 fastjson 1.2.25 开始AutoType 默认是关闭的因为历史上有过多次反序列化漏洞。你在本地测试时一切正常因为本地代码里有Dog类但线上某个历史 Redis key 里存的还是带type的旧数据代码升级后类名变了、包路径变了或者 fastjson 版本升级后安全校验策略变了反序列化就会直接抛JSONException: autoType is not support运气差一点的会遇到ClassNotFoundException。如果说坑一是“类型信息缺失”导致变形那坑二就是“类型信息失真”导致认错。两个坑本质上是同一个主题序列化链路里类型信息一旦不可靠Redis 里的数据就和 Java 对象对不上账。1.3 两个坑的共同本质类型信息不可靠把两个坑放在一起看会发现它们其实是同一枚硬币的两面。Java 序列化靠的是 class 元数据Redis 里的字符串只是字节。fastjson 把对象转换成 JSON 字符串时泛型信息会被擦除类名信息可能被写入也可能被丢弃整个过程完全取决于你用了哪个 API、传了什么参数、配置了哪些 Feature。用个生活化的类比来解释序列化相当于把一箱苹果送进仓库外面贴了一张单子。单子上如果只写“一箱东西”取货的时候就只能拿回去猜如果写着“一箱苹果”那还能对上最怕单子上写了“一箱水果”却又在货箱底部藏了一张“其实有一袋是香蕉”的注释——取货的人看走眼箱子到了餐桌上才发现货不对板。fastjson 的type机制就是那张“注释”处理不好货不对板就是必然结果。2. 为什么这么多人用 fastjson 做 Redis 序列化2.1 默认 JDK 序列化的三个痛点聊坑之前得先知道大家为什么要换序列化方案。Spring Data Redis 默认用的JdkSerializationRedisSerializer它在本地开发时一般不会出问题一旦上了生产环境痛点简直写在脸上。第一可读性极差。往 Redis 里写一个简单的 User 对象用Redis Desktop Manager看存储内容是一片\xAC\xED\x00\x05sr\x00 com.example.User...之类的二进制乱码。想手工确认缓存内容是否正常根本不可能。第二存储膨胀。JDK 序列化会把类结构描述、字段元数据全量写入一个小对象序列化后可能膨胀好几倍。在高并发缓存场景下这直接推高 Redis 内存和网络带宽成本。第三兼容性脆弱。实体类只要改动字段比如加一个属性、改一个字段名老缓存数据基本就废了——反序列化时要么抛异常要么字段值全部丢失。这也是很多团队下定决心换掉 JDK 序列化的直接导火索。2.2 主流序列化方案选型横评换掉 JDK 序列化并不只有 fastjson 一个选择常见的替代方案有 Jackson、fastjson、fastjson2、Protobuf 等。我在项目里都试过或观察过简单做个对比序列化方案可读性空间效率性能类型安全上手成本JDK 默认差差中强靠全类名零成本Jackson好中中弱需显式指定类型中等fastjson好中高弱容易踩泛型坑低fastjson2好中高有所改善中Protobuf差最优最高强靠约定的 schema高fastjson 之所以被广泛使用是因为 API 确实顺手JSON.toJSONString(obj)和JSON.parseObject(json, User.class)就两个方法没有太多配置性能也不错。但它把类型安全的重担完全交给了开发者——你没写 TypeReference它就猜你没关 WriteClassName它就写type。这套“默认帮你做选择”的设计在 Redis 这种纯字符串存储的场景里就是定时炸弹。2.3 自定义 RedisSerializer 时埋下的隐患换序列化方案大多数人会自定义一个RedisSerializer注册进 RedisTemplatepublic class FastJsonRedisSerializerT implements RedisSerializerT { private final ClassT clazz; public FastJsonRedisSerializer(ClassT clazz) { this.clazz clazz; } Override public byte[] serialize(T t) throws SerializationException { return JSON.toJSONString(t).getBytes(StandardCharsets.UTF_8); } Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes null || bytes.length 0) { return null; } return JSON.parseObject(new String(bytes, StandardCharsets.UTF_8), clazz); } }表面看没什么问题。但注意这只适用于简单对象比如缓存一个User。如果缓存的是ListUser你把 clazz 传成List.class反序列化时 fastjson 只能把 JSON 数组解析成JSONArray数组里每个元素是什么类型它不知道。于是元素全成了JSONObject。很多同学写到这里就停了根本没意识到这个“不知道”后面藏着一个大坑。3. 坑一拆解泛型擦除引发的类型转换异常3.1 完整复现路径从正常到 ClassCastException复现这个坑特别简单。首先引入 fastjson 依赖然后定义一个User类再跑下面这段代码public class Demo { public static void main(String[] args) { ListUser userList new ArrayList(); userList.add(new User(张三, 18)); userList.add(new User(李四, 20)); // 序列化 String json JSON.toJSONString(userList); // 错误的反序列化没有指定泛型 ListUser wrongList JSON.parseObject(json, List.class); User user wrongList.get(0); // 这里不报错 String name user.getName(); // 这里抛 ClassCastException } }实际就两步序列化时把ListUser变成了字符串反序列化时用List.class而不用TypeReference。运行到user.getName()那行会看到Exception in thread main java.lang.ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.example.User如果把断点打在wrongList.get(0)上你会看到 IDE 里的元素类型显示为JSONObject。这说明不是“User 的属性丢了”而是整个对象压根不是 User。在 Redis 场景里这个流程会更隐蔽。因为很多团队用了前面那种自定义 RedisSerializer把clazz绑定成了List.class或某个具体类。缓存写入时走的是 fastjson 序列化缓存读取时走的是 fastjson 反序列化中间值拿到业务层才放大招。3.2 为什么泛型信息会丢失Java 泛型擦除速讲要理解这个问题得先搞明白 Java 泛型擦除。JVM 在运行期并不认识ListUser这种“带泛型的类型”它在字节码层面上只能看到List。这个机制叫泛型擦除Type Erasure。举个例子你写了一个方法ListUser getUsers()编译后的字节码签名其实是List getUsers()泛型信息被存到了Signature属性里供编译器在源码层面做类型检查。但一旦跨过序列化这个边界JSON 字符串里只保留了数据没有保留List内部的元素类型。fastjson 解析List.class时只会创建一个ArrayList往里塞数据时不知道应该创建User于是统一塞了JSONObject。很多开发者误以为“我强转一下(ListUser)就行了”但强转只是让编译器不再报错运行时的对象结构不会因此改变。这就像你把一箱苹果标成“一箱水果”收货的人拆箱后看到的确实是水果但有些水果是纸糊的模型你想吃的时候一口咬下去牙就崩了。3.3 修复方案在“取”的那一侧显式补上类型修复思路很明确既然泛型信息在序列化时丢了那就在反序列化时显式告诉 fastjson 要什么类型。最标准的写法是用 TypeReferenceString json redisTemplate.opsForValue().get(cache:user:list); // 实际取出来是 Object ListUser userList JSON.parseObject(json, new TypeReferenceListUser() {});在 RedisTemplate 场景下如果取出来的是Object需要先用String.valueOf()或强转字符串再交给JSON.parseObject。这里有一个很容易写错的点new TypeReferenceListUser() {}的泛型参数必须写完整new TypeReferenceList() {}等同于用List.class又回到了原来的坑里。如果你的所有缓存读取都封装在一个工具类里建议把工具方法直接设计成泛型方法public static T T getCache(String key, TypeReferenceT typeReference) { Object value redisTemplate.opsForValue().get(key); if (value null) { return null; } return JSON.parseObject(String.valueOf(value), typeReference); } // 调用 ListUser userList getCache(cache:user:list, new TypeReferenceListUser() {});多套一层泛型封装看起来麻烦但好处是调用方在编译阶段就明确了目标类型把问题从“运行时才炸”提前到“编译期就发现”。注意在自定义 RedisSerializer 的场景里如果你一定要在序列化器内部完成集合类型反序列化最简单的办法是不做。让序列化器只负责对象与字符串互转集合类型的类型信息由业务层通过 TypeReference 指定这样职责清晰不会互相牵制。4. 坑二拆解AutoType 与多态反序列化的类型转换问题4.1 另一种现场写入了类名读取时却不认账第二个坑的典型报错是com.alibaba.fastjson.JSONException: autoType is not support. com.example.Dog先看它是怎么产生的。假设代码里用WriteClassName特性序列化了一个对象Animal animal new Dog(旺财); String json JSON.toJSONString(animal, SerializerFeature.WriteClassName); redisTemplate.opsForValue().set(cache:animal, json);这时的 JSON 字符串长这样{type:com.example.Dog,name:旺财}读取时的代码如果这样写String json String.valueOf(redisTemplate.opsForValue().get(cache:animal)); Animal animal JSON.parseObject(json, Animal.class);本地开发时一切正常因为 fastjson 看到type后会用反射加载com.example.Dog完成多态还原。但到了生产环境只要满足下面任何一个条件就会报错fastjson 版本升到了 1.2.25 及以上AutoType 默认关闭也没配置白名单Dog类的包路径变了旧缓存里写的是com.example.old.Dog新代码里只有com.example.new.Dog你用的是租户隔离环境类加载器不同反射找不到类。就算不报错还有一种更隐蔽的情况如果同一个type的类在新版本里字段结构变了反序列化出来的对象某些属性是 null某些多出的属性直接丢失。这种“静默变异”在缓存场景下危害很大因为它不报错业务方可能直接拿着残缺对象去算得到错误结果。4.2 为什么 fastjson 默认不认 AutoType 了要理解这个问题得知道 AutoType 机制的来龙去脉。早期 fastjson 为了支持多态默认在反序列化时读取 JSON 中的type字段并根据类名直接反射创建对象。这个机制的便利性很强但也带来了严重的安全问题攻击者可以构造恶意type指向危险类触发 JNDI 注入或反序列化漏洞。所以从 1.2.25 版本开始fastjson 把 AutoType 开关默认关闭要求开发者通过ParserConfig.getGlobalInstance().addAccept(...)维护一个白名单。很多老项目升级 fastjson 版本后突然发现 Redis 缓存读不出来了就是这个原因——不是代码逻辑变了而是安全策略变了。另一个容易忽视的场景是跨系统读写。A 系统负责写缓存B 系统负责读缓存两个系统依赖的是同一个实体的不同版本。A 系统序列化时写入typeB 系统依赖的类路径已经改了B 反序列化时要么类加载失败要么字段对不上。我把这种问题归纳为“类名耦合”一旦 JSON 字符串里写了全限定类名所有读这个缓存的系统都被迫依赖这个类名的存在。而缓存本身就是个跨版本、跨团队共享的存储把“类名”这种编译期的东西写进运行期的数据里就是在制造耦合。4.3 修复与收敛方案优先去掉 type先说结论在 Redis 缓存场景下绝大多数情况下type都是不该存在的东西。首先如果缓存的数据只是普通 VO、业务对象不需要多态那就彻底不要用SerializerFeature.WriteClassName。序列化时直接JSON.toJSONString(obj)反序列化时用clazz或TypeReference指定具体目标类型这样 JSON 里没有type也不会触发 AutoType 安全校验。如果你确实需要多态比如一个动物列表里有Dog和Cat反序列化时还想还原成具体类型那么有两种做法。一种是配置白名单ParserConfig.getGlobalInstance().addAccept(com.example.pet.);这里要注意addAccept的写法支持前缀匹配com.example.pet.会把com.example.pet.Dog、com.example.pet.Cat都放行。但白名单不是万能的它解决不了类路径变更问题。更稳妥的做法是序列化时不依赖 fastjson 的type而是自己在对象里加一个业务类型字段比如public class Animal { private String type; // 业务类型 private String name; }反序列化时先解析出JSONObject根据type手动转成对应的Dog或Cat。这样虽然有手写代码的成本但类型信息完全由业务控制和 fastjson 的 AutoType 底线彻底解耦。这个方案看起来土但在跨系统缓存场景里是最稳的。提示如果项目已经迁移到了 fastjson2AutoType 的默认策略和配置方法也有变化而且两个版本的包名、注解、API 都有差异。迁移时不要只改依赖坐标要把JSON.toJSONString、JSON.parseObject、ParserConfig这些调用点全部核对一遍特别是涉及到type的历史数据最好做标记字段兼容。5. 实操复盘一次缓存服务故障的完整排除过程5.1 故障现象与初步排查某内部系统上线了一个“登录用户信息缓存”模块功能很简单用户登录成功后把用户对象和角色列表写入 Redis后续请求从 Redis 读取。上线当天一切正常到了第二周监控平台开始零星报 500 错误。业务同学抓到的异常堆栈是java.lang.ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.example.system.Role at com.example.system.service.UserService.getRolesForUser(UserService.java:87)异常指向的代码是ListRole roles (ListRole) userInfo.get(roles);这里有两个疑点。第一userInfo是刚从 Redis 取出来的缓存对象第二代码里缓存逻辑和读取逻辑是不同人写的序列化配置有细微差异。按照“先复现、后定位、再修复”的思路往前推。5.2 定位过程从“序列化配置不一致”到“真凶”第一步看 Redis 里的真实存储内容。连上 Redis执行GET cache:user:info:12345返回的结果是{type:com.example.system.UserInfo,userId:12345,roles:[{type:com.example.system.Role,roleName:admin}]}到这里已经发现两个线索Redis 里带type说明序列化时有人用了WriteClassName。而读取端反序列化的对象是用另一个版本的实体类去解析的两个版本里Role类的包路径并不完全一致导致 fastjson 在某个环节走了错误的类型分支。第二步本地复现。把 Redis 里的字符串原样复制到本地然后执行JSON.parseObject(json, UserInfo.class)发现本地并不会报错。于是换了一个操作先解析成一个JSONObject再手动get(roles)并强转成ListRole结果复现了同样的ClassCastException。这一步确认了问题根源不只出在type上还出在读取代码的强转方式上。第三步梳理完整链路。最后发现是写端代码为了省事直接用JSON.parseObject(json, UserInfo.class)拿到UserInfo然后从userInfo.get(roles)强转了ListRole。由于UserInfo内部roles字段在 fastjson 解析时被还原成了JSONArray而JSONArray里元素的真实类型是JSONObject于是强转必然失败。5.3 最终修复三处改动一起做定位清楚后改动其实不复杂但要做完整第一处所有缓存写入去掉WriteClassName。只存纯 JSON避免type进入 Redis。这一步是消除根因防止后续版本变更引发读不出来。第二处缓存读取端统一使用TypeReference。不管是直接读集合还是从对象里取嵌套集合先取字符串再按目标类型反序列化。简单说代码里不要再出现“把一个 Object 强转成 List”的写法。第三处增加缓存版本号。在 Redis key 里加一个项目代号或版本后缀比如cache:user:info:v2:12345。部署时通过配置切换到新 key旧 key 让其自然过期。这样实体类结构变更时不需要手动清缓存也不会因为新旧数据混用引发类型转换问题。修复后观察了两周500 错误没有再出现。这次故障最大的教训是缓存写入和读取两侧的序列化配置必须保持一致且尽量不依赖 fastjson 的“黑魔法”特性。6. 常见问题速查与避坑清单6.1 高频问题对照速查表把这段时间里见过和听到过的相关经典问题整理成一张速查表开发时可以随时对照。现象根因处理建议ClassCastException: JSONObject cannot be cast to User反序列化时未指定泛型集合元素被解析成了 JSONObject用 TypeReference 指定完整泛型类型JSONException: autoType is not supportJSON 里带type但 fastjson 默认关闭 AutoType去掉 WriteClassName或配置白名单ClassNotFoundException: com.example.xxxJSON 里type指向的类在读取端不存在或包路径变了清理旧缓存不要跨版本依赖全类名反序列化后某些字段为 null实体类字段结构变动或type映射到旧版本类加缓存版本号部署时切 key直接从 Redis 取出的值变成带引号的字符串序列化类里用了 String 类型与自定义 RedisSerializer 注册的目标类型不一致检查 RedisTemplate 序列化配置泛型嵌套对象反序列化后内部对象为 null复杂嵌套泛型未用 TypeReferencefastjson 无法推测嵌套类型嵌套类型用 TypeReference 完整声明6.2 五条独家避坑建议第一条序列化器里不要绑定具体泛型。自定义 RedisSerializer 时尽量保持通用性不要在内部用死绑定某个类的parseObject。业务层的类型信息应该由业务规则决定。第二条所有集合类型读写出入口一律显式 TypeReference。这条规矩简单粗暴但有效。ListUser、MapString, User、ListMapString, Object只要涉及泛型超过一层就不要偷懒用.class。第三条Redis 缓存里不要存 fastjson 专属字段。type是 fastjson 自己的元数据一旦换了 JSON 库、升级了版本、或跨系统通信都会成为负资产。缓存数据应当尽量保持成“平台无关的纯 JSON”。第四条给缓存加版本号。这个成本极低但收益很大。当实体类结构变化时直接切到新 key同时保证旧 key 过期。别等线上出现反序列化异常了才去清缓存。第五条String.valueOf 也不是万能的。从 RedisTemplate 取出的 Object 如果本身就是 String直接用String.valueOf没问题如果 RedisTemplate 用的序列化器在反序列化时返回的不是 String可能得到 JSONObject 再转字符串这时已经丢了一层类型信息。最好在工具类里统一处理先判类型再转换而不是盲目 String.valueOf。7. 个人经验分享序列化方案不能“先跑起来再说”经过几次线上故障后我给自己定了几条规矩。第一所有 Redis 缓存读写必须走统一封装工具类工具类里每一处反序列化都明确写出目标类型不允许业务散写JSON.parseObject。第二缓存里不允许type出现数据要保证任何 Json 库都能读。第三只要是实体类改动一定会走“加版本号→切 key→观察→删旧 key”这个流程绝不直接原地改类名。最后再分享一个小技巧。排查这类问题时第一件事永远不是翻代码而是连上 Redis 看 value。一秒钟看清楚是纯 JSON、带type、还是二进制乱码直接缩小一半排查范围。序列化问题有很强的“现场性”一旦缓存数据写入后就定型代码版本都切了也没用。所以别急着改代码先看数据长什么样往往答案就在那一眼里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑