fastjson+Redis缓存类型转换异常:从ClassCastException到泛型擦除的排查实战 凌晨两点半监控群里的告警图标亮起来某订单服务的缓存接口异常率突然往上跳。翻开日志被高频刷屏的是同一条ClassCastExceptioncom.alibaba.fastjson.JSONObject cannot be cast to ...。第一眼很迷惑Redis里明明放的是JSON字符串读出来也是标准JSON为什么在强转时就崩了排查到最后发现问题不在Redis也不在数据而在fastjson序列化和反序列化时的类型信息处理上。这篇就写我在这个方向踩过的两个典型类型转换异常坑一个是泛型擦除一个是写入端和读取端序列化风格不一致。如果你用Redis做缓存、用fastjson做JSON转换尤其遇到过本地好好的一上生产就报ClassCastException/JSONException这几个点建议看完。1. 从一封异常日志说起类型转换异常发生在哪一层1.1 先分清ClassCastException和JSONException方向才不会跑偏看到日志里带JSON字样很多人第一反应是JSON格式坏了。其实类型转换相关的异常通常分两类一开始定错了方向后面排查就会绕远路。异常类型典型报错形态说明ClassCastExceptioncom.alibaba.fastjson.JSONObject cannot be cast to com.demo.entity.UserJSON已经成功解析但实际对象类型和目标类型不匹配通常发生在你手动强转、或者增强for循环隐式强转的时候JSONExceptionautoType 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); ListUser users (ListUser) cached.getUsers();每一行看上去都符合日常习惯问题就藏在SomeResult.class这个目标类型和泛型ListUser之间。接下来展开第一个坑也是我见过出现频率最高的一个。2. 第一个坑泛型擦除之后List 取出来变成List2.1 复现路径Result 作为通用包装的缓存读写很多服务都会定义类似ApiResultT、UserResultT的通用返回对象用来包装接口数据也给缓存用。这种设计本身没问题但问题也藏在里面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); ListUser users (ListUser) result.getData(); for (User u : users) { // 这一行抛ClassCastException System.out.println(u.getName()); }形态二里result.getData()拿到的实际对象是JSONArray它是ArrayList的子类所以强转成ListUser时因为泛型擦除不会立刻报错真正的爆炸点在遍历时每个元素是JSONObject被隐式强转成User于是报出JSONObject cannot be cast to User。2.2 根因fastjson失去泛型参数时会退化出JSONObjectJava的泛型在运行期会被擦除。Result.class这个Class对象里并没有ListUser这类信息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 TypeReferenceResultListUser() {}。九成问题就出在这。检查点要看什么判断结论异常栈报错行落在哪个方法落在强转/循环处说明解析成功、类型错误Redis原始值value是不是纯JSON文本是纯JSON说明问题不在存储层解析代码parseObject用了哪个Class用了Result.class则泛型信息必然丢失额外提醒一个容易干扰判断的情况如果RedisTemplate的value序列化器配置的是某个fastjson的序列化器那么写入Redis的可能是二进制或带类型包装的内容用redis-cli看到的就不是纯JSON文本。这时候问题性质会变成序列化器叠加后的二次包装先改成纯字符串存储再继续排查能少走很多弯路。2.4 修复写法TypeReference把泛型信息传给fastjson正确写法是使用TypeReferenceTypeReferenceResultListUser type new TypeReferenceResultListUser() {}; ResultListUser result JSON.parseObject(json, type); ListUser users result.getData(); // 类型已经明确不用强转TypeReference通过匿名内部类的父类信息把完整泛型暴露给fastjsonfastjson拿到后就知道data字段里的数组元素要转成User而不是JSONObject。如果你手里已经拿到了JSONObject也可以分步做显式转换JSONObject obj JSON.parseObject(json); JSONArray dataList obj.getJSONArray(data); ListUser 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.WriteClassNamefastjson就会在JSON里塞一个type字段值是对象真实类名。这对多态字段很有用但同时带来两个后果读取端如果没开启autoType或者安全配置不在白名单里遇到未知的type直接抛JSONException缓存读不出来。读取端如果开启了autoTypefastjson会优先按照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, TypeReferenceT type) { String json redisTemplate.opsForValue().get(key); if (json null || json.isEmpty()) { return null; } return JSON.parseObject(json, type); } // 调用方 ListUser users cacheService.getJson(user:list, new TypeReferenceListUser() {});如果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泛型丢失还是目标类型不匹配这套方法排掉了大部分fastjsonRedis的类型问题剩下的基本都属于业务字段结构不一致需要从上游接口改造去解决。5.2 强转能少则少缓存的数据结构就是隐式协议说到底这两个坑的共性在于读写双方对缓存里这份数据长什么样的约定没有对齐。你可以把缓存里的JSON理解成一种隐式接口写入端是生产者读取端是消费者。生产者消费者只要有一个版本变了就会出现类型转换异常这类连锁反应。我个人现在的习惯是不管对象多简单从Redis里拿出来的JSON都不要直接强转。能用TypeReference就用TypeReference不能用的就分步toJavaObject。强转一时爽排查火葬场这句话在缓存场景里尤其成立。