首页 / 资讯中心 / 文章详情

Redisson缓存报错missing @class?序列化配置与排查修复全指南

Redisson缓存报错missing @class?序列化配置与排查修复全指南 ★ FEATURED ARTICLE
如果你在项目里用 Redisson 做分布式缓存get()读取数据时突然抛出missing type id property class说明你写入缓存的数据格式和读取时使用的解析规则已经对不上了。这个报错我前阵子刚踩过第一反应以为是get()方法用错了后来才发现真正的病根不在读取动作本身而在 Redis 里面存的那一串 JSON 和 Redisson 的序列化器配置上。这篇文章会把报错原因、排查路径、修复方案和需要避开的安全坑一次性讲透适合正被 Redisson 序列化问题折磨的开发者也适合还没遇到但想做分布式缓存的人提前排雷。1. 报错现场与问题定性1.1 报错日志到底在说什么先看一个典型的报错日志长下面这样org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Missing type id property class at [Source: (String){name:zhangsan,age:28}; line: 1, column: 1] at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:1234) at org.redisson.codec.JsonJacksonCodec.getValueDecoder(...) ... Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Missing type id property class从堆栈可以看到异常发生在 Redisson 的JsonJacksonCodec里实际是 Jackson 在反序列化时找不到class这个字段。也就是说Jackson 读取的 JSON 字符串里没有类型标识但它又必须依赖这个标识才能把数据还原成 Java 对象。这里要明确一个概念这个异常的名字虽然带着Missing type id但它和Redis 中 key 不存在是两回事。Redis 只是负责存字符串Redisson 拿到字符串之后要用 Jackson 把 JSON 解析成目标类型。如果解析失败就会在get()这一步抛异常而且通常会包装成SerializationException或RedisSystemException很多同学一看到这个包装类就跑去查 Redis 连接偏离了方向。1.2 这类问题最容易出现在什么场景踩到这个坑的人往往不是第一次写缓存代码而是已经跑了一段时间才爆出来。根据我接触到的案例高频场景集中在三类。第一类是同一个服务升级了序列化配置。比如有人在RedissonClient初始化时自定义了ObjectMapper为了精简字段或统一时间格式却没有启用 Jackson 的默认类型机制结果旧数据是带class的新配置读不了。第二类是多个服务共用一个 Redis写入端和读取端用的不是同一套 Codec。服务 A 用 Redisson 默认的JsonJacksonCodec写缓存自动带上class服务 B 为了性能自己包装了一个不开启默认类型的ObjectMapper去读两边一碰就报错。第三类是缓存里的数据可能不是 Redisson 写的。比如订单服务用 Spring 的RedisTemplate写了一份 JSON或者用了别的序列化框架数据格式和 Redisson 期望的完全不一样。这种情况更隐蔽因为单看某一个服务都正常只有跨服务读数据时才暴露。2. 追根溯源Redis 里的数据为什么带 class2.1 Redisson 默认序列化链路Redisson 默认使用的编解码器是JsonJacksonCodec它的实现核心是 Jackson 的ObjectMapper。在 Redisson 的默认配置里这个ObjectMapper会开启 Jackson 的默认类型机制default typing序列化对象时会额外写入一个类型标识字段这个字段名就是class。所以如果你从来没做过任何特殊配置直接用redissonClient.getBucket(user: id)往 Redis 里写一个 POJO实际看到的存储内容会是这种带class的 JSON{ class: com.example.cache.dto.UserDTO, name: zhangsan, age: 28 }这一点很多人不知道因为平时直接用工具看 Redis 里存的是字符串不会太在意字段细节。可一旦读取端的配置和写入端不一致class就会成为第一个矛盾爆发点。2.2 class 字段是干什么用的用一个生活中的例子来理解class它相当于快递箱里的物品清单。快递员不需要打开箱子就知道里面是人名还是鞋盒因为有清单写在箱子上。Jackson 也一样当它看到class这个字段时就知道要去加载哪个 Java 类然后把后面的属性填充进去。为什么需要这个清单因为 Java 的类型系统在反序列化时存在“多态性”。比如你有一个接口类型CacheDTO实际存放的是UserDTO又比如你的字段类型是Object实际塞进去一个List。如果不告诉 Jackson 具体的类名它面对“一个 JSON 对象到底该变成哪个类”这个问题时根本无法决策。更关键的是Redisson 默认开启的NON_FINAL策略意味着只要目标类型不是final修饰的类就会在 JSON 里附加class。这也是为什么普通 POJO 也会被写上class的原因。2.3 为什么 get() 会突然踩雷很多人的第一反应是“昨天还好好的今天怎么突然报错了”。在这个现象背后真正变化的不一定是代码而是 Redis 里的存量数据。举例来说某套系统分为登录服务和用户服务登录服务写入用户缓存时一直用的是默认 Redisson 配置Redis 里全是带class的 JSON。后来用户服务为了接入一个第三方的序列化工具在自定义ObjectMapper时没有考虑默认类型上线后所有从 Redis 读数据的接口开始报missing type id property class。还有一种很常见的情况缓存 key 没变但数据被另一个调度任务用StringRedisTemplate覆盖写了普通 JSON。等到业务代码去get()时Jackson 看到的是一个不带类型信息的 JSON自然就炸了。所以不要只盯着get()方法本身。出现这个异常时先确认 Redis 里的真实数据长什么样再去看代码配置。3. 先看数据再动手两个排查命令3.1 用 redis-cli 查看缓存的真实内容遇到这个报错第一件事不是改代码而是直接看一下 Redis 里到底存的什么。命令很简单redis-cli GET user:10086如果返回的是这种格式{class:com.example.cache.dto.UserDTO,name:zhangsan,age:28}说明数据本身是带class的问题大概率出在读取端的配置也就是你的 Jackson 或者 Redisson Codec 没有按默认类型解析。如果返回的是这种不带class的普通 JSON{name:zhangsan,age:28}说明数据在写入端就没带类型信息可能是写入时用了别的序列化方式。这时候要顺着写入链路去查只改读取端是治标不治本。3.2 快速判断是哪一类序列化器写入的可以用一个更直观的小技巧把 Redis 返回的内容开头几个字节贴出来对比。如果是{class:com.xxx...开头说明是 Jackson default typing 写入的对应 Redisson 默认JsonJacksonCodec或者 Spring 的GenericJackson2JsonRedisSerializer。如果是\xAC\xED\x00\x05这类二进制开头说明是 JDK 原生序列化写入的对应JdkSerializationRedisSerializer。如果是普通 JSON 但没有class说明写入端用的是自定义的 Jackson并且没有开启默认类型机制或者干脆用的是StringRedisTemplate存字符串。这一步不需要什么高级工具直接把redis-cli GET的输出贴到 IDE 或终端里就能判断。很多时候问题就藏在“你以为写的是对象实际存的是字符串”这种低级差异里。4. 修复方案从统一配置到数据清理4.1 方案A恢复默认序列化规则让 class 正常写入和读取如果确定 Redis 里的数据是带class的而读取端是自定义配置导致解析失败修复思路就是让读取端的 Jackson 开启 default typing恢复 Redisson 默认的行为逻辑。对应初始化RedissonClient的配置代码大致如下Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379); ObjectMapper mapper new ObjectMapper(); mapper.activateDefaultTyping( mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); config.setCodec(new JsonJacksonCodec(mapper)); return Redisson.create(config); } }这里的关键是activateDefaultTyping这个方法。它有三个参数第一个是多态类型校验器第二个是DefaultTyping.NON_FINAL表示只有非 final 类才写入类型信息第三个是JsonTypeInfo.As.PROPERTY表示类型信息以属性形式放在 JSON 里属性名默认就是class。需要注意如果你的项目用的是 Jackson 2.10 之前的旧版本需要用enableDefaultTyping这个方法因为activateDefaultTyping是在新版中才引入的。如果版本太老强行照抄会直接编译报错。4.2 方案B多服务共用同一份 Codec 配置如果问题出在多个服务之间配置不一致最稳妥的做法就是把序列化配置放在一个公共模块里所有服务都引用同一个配置类。我见过的一个典型教训是服务 A 和服务 B 都是微服务各自初始化了RedissonClient。服务 A 用默认配置服务 B 为了给字段加脱敏逻辑自定义了ObjectMapper但忘了开 default typing。结果服务 A 写入的缓存服务 B 一读就报missing type id property class。两边互相排查了很久最后才发现在RedissonConfig这里差了配置。解决起来也不复杂把ObjectMapper的创建逻辑抽到一个公共的CacheCodecProvider里所有服务都通过这个Provider获取 Codec。这样既能保证默认类型配置一致也能统一序列化规则以后加了新服务也不会再出现类似问题。4.3 方案C清理历史脏数据与灰度切换改完代码后如果 Redis 里已经躺着大量不带class的旧数据那你必须处理历史数据否则存量 key 仍然会报错。最直接的方式是清理相关缓存让业务重新写入。删除可以使用redis-cli的 scan 方式避免在生产环境直接执行keys这种危险命令redis-cli --scan --pattern user:* | xargs redis-cli DEL如果不想停机清缓存可以考虑做一个兼容逻辑读取失败后尝试用旧格式反序列化一次成功后重新写入一个新格式的缓存。这样能实现平滑迁移但代码复杂度会高一些适合缓存数据量大、不能随便清的场景。需要注意的是清理缓存和切换配置要配合进行。先保证写入端已经恢复成带class的默认格式再清理旧数据然后再读取顺序反了会出现“清了但马上又写入错误格式”的情况。4.4 方案D换用其他序列化方案如果项目对 JSON 可读性要求不高或者业务对象多态场景太复杂可以考虑换掉默认的 JSON 序列化器。Redisson 支持多种编码器比如Kryo5Codec、FstCodec还有带类型信息的TypedJsonJacksonCodec。TypedJsonJacksonCodec是个有意思的选择它也需要额外的 class 信息但是使用方式和默认的JsonJacksonCodec不一样它要求你在get()的时候明确指定目标类型。如果缓存 key 对应的类型是固定的用这种方式可以绕过一部分class问题。不过要注意换序列化方案不是零成本的。第一新方案写入的数据旧数据不一定能读第二跨语言访问 Redis 时JSON 有天然的可读性优势换了二进制序列化会丧失这个优势第三引入新依赖还要考虑版本兼容和稳定性。所以除非你对 JSON 格式有明确的性能或者兼容性需求不然我建议还是优先修配置不要动底层的序列化方案。5. 深入原理Jackson default typing 详解5.1 为什么 Object、接口和父类场景必须带类型信息missing type id property class这个异常本质上并不是 Redisson 的 bug而是 Jackson 多态类型解析的正常报错。理解它需要知道 Jackson 在反序列化时怎么决定“要把 JSON 变成哪个 Java 类”。如果字段声明类型是具体的UserDTO那 Jackson 不需要class也知道要创建UserDTO实例。但如果字段类型是Object、接口、抽象类或者父类情况就不同了。JSON 本身只是{name:zhangsan,age:28}没有任何信息告诉 Jackson 它到底是UserDTO还是AdminDTO甚至可能是OrderDTO。这时候只有依赖类型标识Jackson 才能反射出正确的类并完成填充。所以DefaultTyping.NON_FINAL这么设计是有道理的对于final类不可能有子类多态解析不必要对于非 final 类存储时记录类型读取时才能准确还原。5.2 Jackson 是如何解析 class 的当 Jackson 的 ObjectMapper 开启了 default typing 后序列化一个 POJO 的过程可以简单拆成两步根据当前运行时对象的具体类型生成类型标识。默认情况下类型标识就是类的全限定名属性名固定为class。把对象的其他字段继续按正常流程序列化拼接到class后面。反序列化时则反过来先读取class的值通过反射得到对应的 Java 类然后按该类的字段结构去解析剩余 JSON 内容。如果 JSON 里没有class而目标类型又不是具体类Jackson 就直接抛出MismatchedInputException报错信息就是我们看到的missing type id property class。这里有一个很容易忽略的点如果你定义了一个类字段类型是Object那么即使你往里面放的是一个具体对象序列化时也会带上class。相反如果字段类型本身就是具体类即使没有开启 default typingJackson 也可能不需要类型信息。也就是说报不报这个错和你字段声明类型有直接关系。5.3 全局开启 Default Typing 的安全风险与白名单网上很多帖子会告诉你“在 ObjectMapper 里加一行 enableDefaultTyping 就好了”这句话本身不算错但放在生产环境要非常谨慎。全局开启 default typing 是历史上许多反序列化漏洞的根源。攻击者一旦能控制 JSON 内容而你的反序列化器又允许任意类名就可能通过构造恶意的class指向危险类进而触发任意代码执行。这也是为什么 Jackson 和 Fastjson 都出过大规模安全事件的原因。在 Jackson 2.10 之后的版本推荐做法是使用BasicPolymorphicTypeValidator来限制允许反序列化的类型白名单ObjectMapper mapper new ObjectMapper(); BasicPolymorphicTypeValidator validator BasicPolymorphicTypeValidator.builder() .allowIfSubType(com.example.cache.dto.) .allowIfSubType(java.util.) .build(); mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);这样即使 JSON 里被塞了恶意类名也会因为不在白名单里而被拒绝。不要把整个项目的 entity 包全放进去只放真正需要做多态解析的包缩小攻击面。6. 完整修复示例从报错到上线6.1 问题项目背景虚构演示假设有一个订单缓存服务业务代码里通过 Redisson 获取订单对象RBucketOrderDTO bucket redissonClient.getBucket(order: orderId); OrderDTO order bucket.get();某天上线一个新功能时有同事在基础设施配置里给JsonJacksonCodec传了一个自定义ObjectMapper目的是统一日期格式。但这个ObjectMapper没有开启 default typing。上线后凡是命中旧订单缓存的接口都在get()时报missing type id property class而新写入的订单因为还带了class反而不受影响。这个现象非常具有迷惑性看起来像是“有的订单能读有的不能读”很多人会怀疑是数据问题实际上只是旧数据格式和新读取配置不匹配。6.2 修复后的配置代码正确的做法是在自定义ObjectMapper时保留 default typing同时满足日期格式的需求ObjectMapper mapper new ObjectMapper(); mapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); BasicPolymorphicTypeValidator validator BasicPolymorphicTypeValidator.builder() .allowIfSubType(com.example.cache.dto.) .build(); mapper.activateDefaultTyping( validator, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); config.setCodec(new JsonJacksonCodec(mapper)); RedissonClient client Redisson.create(config);改完配置后新写入的缓存会正常带class读取端也能正确识别。对于存量旧数据按照 4.3 里说的方案先清理或迁移再灰度放量。6.3 上线前要检查的一致性清单在准备发上线之前建议按这个清单过一遍能省掉很多线上故障所有服务是否引用了同一个 Codec 配置而不是各自实现ObjectMapper。ObjectMapper是否启用了 default typing并且配置了类型白名单。项目使用的 Jackson 版本是 2.10 之前还是之后使用的是enableDefaultTyping还是activateDefaultTyping。Redis 中是否还留有旧格式数据如需保留是否已经做了兼容或清理方案。测试环境是否验证过“写入一次再读取一次”的完整流程。是否把OrderDTO等业务 DTO 的包路径加入了白名单。这份清单不是摆设。我见过不少项目改完配置后只测了新写入的数据忽略了线上旧缓存结果一上线立刻被打回原形。序列化问题最麻烦的地方就在于“配置错了但当时不报错等数据积累到一定量才统一爆掉”。7. 线上问题排查速查表7.1 六步定位法如果不小心在生产环境遇到类似报错按照下面的顺序排查一般能在半小时内定位到根因。第一步拿完整日志堆栈。重点看异常是哪个 Codec 抛出来的判断是不是JsonJacksonCodec。第二步用redis-cli GET key查看真实数据确认有没有class。第三步找写入端代码。搜索所有往该 key 写入数据的服务确认它们的RedissonClient初始化方式。第四步找读取端代码。确认读取时用的RedissonClient是不是同一个配置有没有自定义ObjectMapper。第五步对比新旧数据。如果 Redis 里同时存在带class和不带class的数据说明写入端中途改过配置或者有多个写入源。第六步根据结果选方案。数据普遍没带class就改写入端数据带了但读不了就改读取端两边都有问题就统一配置后清理存量。7.2 常见报错对照表报错信息可能原因解决方案missing type id property class读取端未启用 default typing或缓存数据未带类型信息统一 Codec 配置清理旧数据Unrecognized field class写入端带了class但读取端未按多态处理POJO 不认这个字段开启 default typing或加上忽略未知字段的配置Denied by PolymorphicTypeValidator白名单校验器拦截了目标类型把业务类型包路径加入白名单java.lang.ClassCastException: ArrayList cannot be cast to XxxDTO存入的容器类型和读取时声明的类型不一致使用TypeReference明确容器泛型或统一类型格式8. 踩过这次坑后的个人经验序列化报错往往是分布式系统里最“玄学”的问题之一因为它不像连接超时那样能被监控直接抓到也不像空指针那样能看到具体字段。它的问题真正出现在数据格式和代码配置的缝隙里两边各自运行都很正常结合在一起就炸了。我现在的习惯是涉及缓存序列化的配置全部放到公共基础设施模块里所有服务禁止私自 new 出来一个新的ObjectMapper覆盖 Codec。另外每次改完序列化配置我都会在测试环境做一次“写入对象 - 手动 GET 原始 JSON - 重新读取对象”的完整回归确保数据格式真的符合预期。最后再分享一个小技巧如果你实在改不动历史数据又急着恢复业务可以临时在读取端加一个兜底逻辑先按新格式解析失败后改用旧格式尝试。这个方案不优雅但能先把线上稳住再慢慢清理数据。记住序列化配置最重要的是“写和读必须站在同一边”只要能保证这一点class的恩怨自然就化解了。
阅读完成 · 觉得有帮助?
咨询建站