fastjson+Redis缓存类型转换异常:从ClassCastException到泛型擦除的排查实战
凌晨两点半,监控群里的告警图标亮起来,某订单服务的缓存接口异常率突然往上跳。翻开日志,被高频刷屏的是同一条ClassCastException:com.alibaba.fastjson.JSONObject cannot be cast to ...。第一眼很迷惑:Redis里明明放的是JSON字符串,读出来也是标准JSON,为什么在强转时就崩了?排查到最后发现,问题不在Redis,也不在数据,而在fastjson序列化和反序列化时的类型信息处理上。这篇就写我在这个方向踩过的两个典型"类型转换异常"坑,一个是泛型擦除,一个是写入端和读取端序列化风格不一致。如果你用Redis做缓存、用fastjson做JSON转换,尤其遇到过"本地好好的,一上生产就报ClassCastException/JSONException",这几个点建议看完。
1. 从一封异常日志说起:类型转换异常发生在哪一层
1.1 先分清ClassCastException和JSONException,方向才不会跑偏
看到日志里带"JSON"字样,很多人第一反应是"JSON格式坏了"。其实类型转换相关的异常通常分两类,一开始定错了方向,后面排查就会绕远路。
| 异常类型 | 典型报错形态 | 说明 |
|---|---|---|
| ClassCastException | com.alibaba.fastjson.JSONObject cannot be cast to com.demo.entity.User | JSON已经成功解析,但实际对象类型和目标类型不匹配,通常发生在你手动强转、或者增强for循环隐式强转的时候 |
| JSONException | autoType not support/syntax error/ 类型不存在 | 发生在parseObject/parseArray解析阶段,可能是JSON字符串格式问题,也可能是fastjson安全校验拒绝了某个@type指定的类型 |
这两个坑里,第一个坑的落点是ClassCastException,第二个坑则可能在解析阶段直接报JSONException,也可能在强转时报ClassCastException,取决于fastjson是否接受了@type信息,以及两端序列化配置的差异。定位前先在异常栈里看清楚异常类型,能节省大量时间。
1.2 为什么Redis这个场景会放大类型问题
Redis本身不挑食,你存进去的是字节序列,取出来也是字节序列,它不负责理解JSON里是什么类型。类型信息在哪一端保留,完全取决于业务代码怎么写。
fastjson默认序列化时不写类型信息,反序列化时依赖调用方显式传入目标Class或TypeReference。一旦调用方把类型搞丢,fastjson就会用JSONObject/JSONArray这种通用结构代替真实对象。本地开发时这类问题很难暴露,因为测试数据量小、对象结构简单,等上到生产、数据变成多层嵌套数组后,一个强转就能把整个接口打挂。
可以这么理解:你寄快递时只在外面写"一箱零件",没写清零件的规格型号,收货方拿到后想直接把里面东西当灯泡装上,当然装不上。
1.3 一个表面正常、暗藏风险的读写链
先看一段非常常见的Redis缓存读写代码:
String cacheKey = "order:users:list"; // 写入 SomeResult result = userService.listUsers(); redisUtil.set(cacheKey, JSON.toJSONString(result)); // 读取 SomeResult cached = JSON.parseObject(redisUtil.get(cacheKey), SomeResult.class); List<User> users = (List<User>) cached.getUsers();每一行看上去都符合日常习惯,问题就藏在SomeResult.class这个目标类型和泛型List<User>之间。接下来展开第一个坑,也是我见过出现频率最高的一个。
2. 第一个坑:泛型擦除之后,List 取出来变成List
2.1 复现路径:Result 作为通用包装的缓存读写
很多服务都会定义类似ApiResult<T>、UserResult<T>的通用返回对象,用来包装接口数据,也给缓存用。这种设计本身没问题,但问题也藏在里面:Result.class在运行时没有泛型参数,fastjson解析时只知道外层是Result,并不知道Result里的data到底应该是什么类型。
我用两种报错形态来复现:
// 报错形态一:data是单个对象 String json1 = "{\"code\":0,\"data\":{\"userId\":1,\"name\":\"用户A\"}}"; Result result = JSON.parseObject(json1, Result.class); User user = (User) result.getData(); // ClassCastException: JSONObject cannot be cast to User// 报错形态二:data是List String json2 = "{\"code\":0,\"data\":[{\"userId\":1,\"name\":\"用户A\"},{\"userId\":2,\"name\":\"用户B\"}]}"; Result result = JSON.parseObject(json2, Result.class); List<User> users = (List<User>) result.getData(); for (User u : users) { // 这一行抛ClassCastException System.out.println(u.getName()); }形态二里,result.getData()拿到的实际对象是JSONArray,它是ArrayList的子类,所以强转成List<User>时因为泛型擦除不会立刻报错,真正的爆炸点在遍历时:每个元素是JSONObject,被隐式强转成User,于是报出JSONObject cannot be cast to User。
2.2 根因:fastjson失去泛型参数时,会退化出JSONObject
Java的泛型在运行期会被擦除。Result.class这个Class对象里并没有List<User>这类信息,fastjson能感知的只有调用方传进来的目标类型。当你只给它Result.class,它对data字段就只知道"这是一个Object",于是按JSON内容的结构,单个对象就反序列化成JSONObject,数组结构就反序列化成JSONArray。
这不是fastjson故意拆台,而是它手上没有更多信息可用。类比一下:你给快递员一个箱子框架,告诉他"箱子到了",但没告诉他箱子里每一个格子装的具体零件,他只能按通用件来包一层。
解决办法很简单,就是别让fastjson猜,把泛型信息显式传进去。但在给出修复代码前,建议先跟着走一遍排查链路,因为我已经不止一次看到有同事在改完第一个坑之后又踩到第二个坑。
2.3 排查链路:异常栈、Redis原值、代码逐层定位
我当时按三步推进,基本每次都能锁定:
- 看异常栈:注意ClassCastException的报错行,一定是你手写强转的那一行,不是parseObject那一行。这说明JSON解析已经成功了,只是类型不对。
- 看Redis里的原始value:用
redis-cli get key确认value是正常的JSON字符串,data字段是一个JSON对象或数组。到这里基本能排除"Redis存取异常"。 - 看代码里的解析目标类型:确认是否写了
Result.class,而不是new TypeReference<Result<List<User>>>() {}。九成问题就出在这。
| 检查点 | 要看什么 | 判断结论 |
|---|---|---|
| 异常栈 | 报错行落在哪个方法 | 落在强转/循环处,说明解析成功、类型错误 |
| Redis原始值 | value是不是纯JSON文本 | 是纯JSON,说明问题不在存储层 |
| 解析代码 | parseObject用了哪个Class | 用了Result.class,则泛型信息必然丢失 |
额外提醒一个容易干扰判断的情况:如果RedisTemplate的value序列化器配置的是某个fastjson的序列化器,那么写入Redis的可能是二进制或带类型包装的内容,用redis-cli看到的就不是纯JSON文本。这时候问题性质会变成"序列化器叠加后的二次包装",先改成纯字符串存储再继续排查,能少走很多弯路。
2.4 修复写法:TypeReference把泛型信息传给fastjson
正确写法是使用TypeReference:
TypeReference<Result<List<User>>> type = new TypeReference<Result<List<User>>>() {}; Result<List<User>> result = JSON.parseObject(json, type); List<User> users = result.getData(); // 类型已经明确,不用强转TypeReference通过匿名内部类的父类信息把完整泛型暴露给fastjson,fastjson拿到后,就知道data字段里的数组元素要转成User,而不是JSONObject。
如果你手里已经拿到了JSONObject,也可以分步做显式转换:
JSONObject obj = JSON.parseObject(json); JSONArray dataList = obj.getJSONArray("data"); List<User> users = dataList.toJavaList(User.class);对比直接强转,toJavaList(User.class)是fastjson提供的显式转换入口,它会逐个把JSONObject转成User,就算某个元素转不了,报的也是可读的JSONException而不是让人摸不着头脑的强转异常。
2.5 为什么本地怼不出来,一上生产就炸
本地开发时测试数据少、缓存经常清空、接口返回结构也比较简单,就算泛型丢失,有时候一个简单对象刚好能勉强对上,或者测试脚本根本没覆盖到嵌套List的读取路径,异常自然不出现。生产环境数据一多,data字段一旦是数组或嵌套对象,强转直接触发。
这让我养成一个习惯:本地验证一定要准备几条接近线上真实结构的样本数据,尤其带泛型、带嵌套、带多态字段的JSON,哪怕只有一个用例,也要先把解析路径跑通。真实比例的样本比一百个mock数据都有用。
3. 第二个坑:写入端多写了@type,读取端被类型信息反噬
3.1 事故现场:同一个Key,两种序列化风格并存
第二位同学踩坑的过程是这样:系统改造时为了让缓存保留多态信息,写入端调用了JSON.toJSONString(obj, SerializerFeature.WriteClassName)。于是Redis里的JSON长这样:
{ "@type": "com.demo.entity.VipUser", "userId": 1001, "name": "用户A", "vipLevel": 3 }读取端他直接写JSON.parseObject(redisValue, User.class)。结果有的接口正常,有的接口报ClassCastException,有的接口报JSONException,日志形态还不一样,让人非常困惑。奇怪的是,同一个Key可能被多个服务读写,A服务写入带@type的数据,B服务又写入不带@type的普通JSON,最终缓存里同一类数据存在两种"长相",读取逻辑完全无法统一。
3.2 根因:WriteClassName和autoType的合力与冲突
默认情况下,JSON.toJSONString(obj)不会把类型写进JSON,只会保留普通字段。一旦加了SerializerFeature.WriteClassName,fastjson就会在JSON里塞一个@type字段,值是对象真实类名。这对多态字段很有用,但同时带来两个后果:
- 读取端如果没开启autoType,或者安全配置不在白名单里,遇到未知的@type,直接抛JSONException,缓存读不出来。
- 读取端如果开启了autoType,fastjson会优先按照@type指定的类来实例化。如果@type指向一个和目标Class没有继承关系的类,后续把对象赋值给目标类型的变量、或者在业务里强转时,就会触发ClassCastException。
也就是说,写入端加类型信息的决定,给读取端埋了一个雷,具体什么时候炸取决于读取端怎么处理。如果多个服务共用同一个Redis Key,而它们的fastjson版本、autoType配置、目标类路径都不一致,同一个Key在不同服务里读出来的结构就是对不齐的,接口行为完全无法预测。
3.3 排查链路:先看清Redis里的原始JSON,再对比两端配置
这个坑的定位链条相对固定:
redis-cli get order:detail:xxx,一旦发现JSON里有@type字段,立刻能确认写入端用了WriteClassName或类似配置。- 进写入端服务,找
JSON.toJSONString调用,确认是否加了SerializerFeature.WriteClassName。 - 进读取端服务,确认fastjson版本是否默认开启autoType,代码里有没有手动执行打开autoType的配置。
- 对比两端类结构:如果同一个类的包路径或类名已经重构过,而Redis里旧缓存的@type写的是旧类名,读取端根本找不到这个类,直接报JSONException。
| 检查点 | 可能结果 | 快速判断 |
|---|---|---|
| Redis原始值 | 带@type | 写入端加了类型写入开关 |
| 写入端序列化代码 | 配置了WriteClassName | 类型信息是主动写入的 |
| 读取端autoType配置 | 未开启/白名单外 | 解析阶段直接拒绝 |
| 两端类路径 | 旧类名与新类名不一致 | 需要清缓存或做兼容映射 |
3.4 修复思路:先定协议,再处理存量数据
这个坑的修复不是简单把WriteClassName去掉就行,因为可能真的有服务依赖多态信息。我建议按顺序处理:
- 先统一序列化协议:能不带@type就不带。只要字段本身不是多态接口、不需要保留真实子类信息,就用默认的
JSON.toJSONString(obj)。普通业务对象完全没有必要把类名写进缓存。 - 如果必须带类型信息,读取端就用匹配的方式:
JSON.parseObject(json, VipUser.class),或继续使用TypeReference。不要用一个基类去接一个已经明确为子类的JSON,这相当于告诉fastjson"我要User",实际拿到的却是VipUser,后续一切强转都不可靠。 - 存量数据兼容:最省事的方法是清掉相关Key,让服务在低峰期回源重建缓存,但要评估缓存击穿窗口,不要让流量在清除瞬间全打到数据库。如果清缓存风险高,就写一个兼容层,先parse成JSONObject,读取@type字段,再决定转成哪个具体类。
- 在代码评审层面,把"序列化协议"当成接口契约来对待:谁写、谁读、字段改没改、包名改没改,都应该有记录。缓存虽然是临时数据,但它跨服务存在的时候,就已经是接口的一部分了。
3.5 一句安全提醒:不要全局开autoType
这里穿插一个和类型安全直接相关的提醒:fastjson的autoType在早年出现过不少安全风险,所以新版默认不开启,是有充分理由的。如果你的服务为了"不加@type就读不出来"而全局打开autoType,大概率是把一个小问题引向更危险的方向。
更合理的做法是维护一份明确的白名单,或者在反序列化时用TypeReference显式声明类型。相比打开全局开关,这既解决了类型转换问题,也避免了把一个远程可触发的解析接口暴露到不可控的类型实例化场景里。
4. 预防这两个坑的缓存读写规范
4.1 只往Redis写JSON字符串,不直接塞Object
无论你用的是RedisTemplate还是Jedis,我都建议在业务层先把对象转成JSON字符串,再写入Redis。好处在排查时特别明显:redis-cli一眼能看到纯JSON,数据结构清清楚楚。
如果让RedisTemplate的value序列化器(尤其是fastjson相关序列化器)直接把Object写成字节,redis-cli看到的可能是乱码或带类型包装的二进制结构,排查成本立刻翻倍。更糟的是,这种序列化结果往往只能被同一个序列化器读回去,另一个服务如果用的是Jackson或Gson,很可能解析不出来。缓存的本质是跨模块间的临时数据交换,序列化细节不应该紧紧耦合在某一套组件上。
4.2 读取端模板:TypeReference加分段转换
我整理了一套比较稳的读取模板,供参考:
public <T> T getJson(String key, TypeReference<T> type) { String json = redisTemplate.opsForValue().get(key); if (json == null || json.isEmpty()) { return null; } return JSON.parseObject(json, type); } // 调用方 List<User> users = cacheService.getJson("user:list", new TypeReference<List<User>>() {});如果JSONObject或JSONArray已经在你手上了,不要直接强转具体类,优先用fastjson的toJavaObject和toJavaList完成转换。强转只适用于你确定实际类型就是目标类型的情况;而只要数据经过了Redis、经过了JSON字符串,你就很难100%确定,除非读写两端的协议锁得非常死。
4.3 上线前的缓存兼容性检查清单
我列了一份5项检查清单,每次涉及Java对象结构变更时都会过一遍:
- 类新增、删除、改名,或者包路径重构时,评估Redis里旧缓存是否还能被反序列化。
- 字段类型改动,比如Integer变Long、String变List,先考虑清缓存或换Key,不要指望fastjson能自动兼容所有类型变化。
- 多个服务共用的Key,确认所有读写方的序列化配置一致,至少保证"写入方不带类型信息"或"读取方按指定的TypeReference解析"。
- 加
SerializerFeature.WriteClassName之前,确认读取端对@type的处理是明确且可控的。 - 升级fastjson版本时,在单测里加一条"带@type的存量JSON"解析用例,防止新版本默认行为变化导致线上旧缓存直接读崩。
这一条在项目代码经常升级的环境里特别有用,因为fastjson各版本对autoType、对未知类型字段的处理策略确实有差异。
5. 后续我沉淀下来的排查习惯
5.1 一套三段式定位方法
现在遇到Redis缓存相关类型转换异常,我基本固定走三步:
- 异常栈只用来定位触发点,不用来猜原因。先在报错行确认,是parse阶段挂了,还是强转阶段挂了。
- 用redis-cli看Redis里的原始value,确认数据是JSON字符串、带不带@type、结构长什么样。
- 再回代码里确认读取端用的是哪个Class、哪个TypeReference,序列化配置是什么。
| 定位阶段 | 要看的点 | 典型结论 |
|---|---|---|
| 异常栈 | 报错行位置 | 强转行报错=解析成功类型错;解析行报错=autotype或语法问题 |
| Redis原始值 | 是否有@type、结构嵌套 | 判断写入端序列化风格 |
| 读取端代码 | Class还是TypeReference | 泛型丢失还是目标类型不匹配 |
这套方法排掉了大部分fastjson+Redis的类型问题,剩下的基本都属于业务字段结构不一致,需要从上游接口改造去解决。
5.2 强转能少则少,缓存的数据结构就是隐式协议
说到底,这两个坑的共性在于:读写双方对"缓存里这份数据长什么样"的约定没有对齐。你可以把缓存里的JSON理解成一种隐式接口,写入端是生产者,读取端是消费者。生产者消费者只要有一个版本变了,就会出现类型转换异常这类连锁反应。
我个人现在的习惯是:不管对象多简单,从Redis里拿出来的JSON都不要直接强转。能用TypeReference就用TypeReference,不能用的就分步toJavaObject。强转一时爽,排查火葬场,这句话在缓存场景里尤其成立。