☰
Redis+fastjson 类型转换异常排查与根治方案
2026/10/24 4:54:29 网站建设 项目流程

这几年国内做 Java 后端的,基本都绕不开 Redis 缓存里塞 JSON 的场景:把对象序列化成一个字符串丢进缓存,取出来再用 fastjson 反序列化回对象。这套组合用起来确实顺手,我甚至见过不少项目里 RedisTemplate 的 value 序列化器直接配成 GenericFastJsonRedisSerializer,进去一个对象,出来还是一个对象,看起来完全透明。可一旦结构复杂一点,两个类型转换层面的坑就会冒出来,最常见的就是取数据之后执行强转的那一行,运行时直接抛ClassCastException,报错信息千奇百怪,什么LinkedHashMap cannot be cast to ProductInfo、Integer cannot be cast to Long,排查起来相当恶心。

这篇文章就把我在实际项目中踩过的两个典型类型转换异常讲透:一个是集合泛型信息丢失导致强转失败,一个是标量类型漂移导致类型对不上。同时也会给出定位手法和根治方案,后面还附了常见问题速查表,做 Java 后端的朋友应该都用得上。

1. 背景:为什么 Redis + fastjson 会撞出类型转换异常

1.1 常见序列化姿势与 fastjson 的流行原因

Redis 缓存 Java 对象,市面上主流方案无非这几种:JDK 原生序列化、JSON 序列化、Protobuf 等二进制序列化。早年很多项目直接用 JDK 序列化,对象写进去是二进制流,取出来再反序列化,类型信息是完整的,但缺点也很明显:序列化后的体积大、内容可读性差、跨语言基本没法用,一旦类结构调整还会出现反序列化兼容问题。后来 JSON 逐步成为主流,Java 生态里 fastjson、Jackson、Gson 三选一,其中 fastjson 因为速度快、API 简洁、对复杂对象支持度高,在国内项目里出镜率一直很高。

用 fastjson 配合 Redis 的典型写法大概是这样的:

// 写入缓存 stringRedisTemplate.opsForValue().set("user:" + userId, JSON.toJSONString(userInfo)); // 读取缓存 String json = stringRedisTemplate.opsForValue().get("user:" + userId); UserInfo userInfo = JSON.parseObject(json, UserInfo.class);

单对象场景下基本没问题,字段类型在反序列化时都能正确还原。但一旦涉及List<UserInfo>、Map<String, Long>这种带泛型参数的复合结构,或者字段类型是Object、接口、抽象类,问题就开始出现了。

1.2 类型转换异常的本质:类型信息在跨进程边界丢失

要理解这两个坑,先得想清楚一件事:Java 对象放进 Redis 再取出来,中间隔了一道 JSON 字符串。JSON 本身没有 Java 的类型系统,它只知道数组、对象、字符串、数字、布尔值这几种基础结构。序列化的时候,fastjson 把 Java 对象“拍平”成 JSON;反序列化的时候,它需要根据你传入的目标类型,把 JSON 还原成 Java 对象。

问题来了:如果你传入的目标类型不够精确,比如只给了List.class,没有告诉它元素类型是UserInfo,那 fastjson 就只能按自己的默认策略处理——把数组里的每个元素先还原成JSONObject(底层是LinkedHashMap)。等你在代码里强转成UserInfo时,JVM 发现实际对象是LinkedHashMap,不是UserInfo,直接抛ClassCastException。

说白了,这是 Java 泛型擦除在跨进程数据交换时暴露出的无奈:编译期List<UserInfo>里的UserInfo是明确的,但运行期拿到手的List.class里只有“这是一个 List”这个信息,元素类型早就被擦掉了。fastjson 再聪明,也没法凭空猜出你想要的元素类型。

2. 坑一:集合泛型信息丢失,取出来的元素强转报错

2.1 问题场景复现

最典型的一幕是缓存一个对象列表。比如商品模块有这样一个对象:

public class ProductInfo { private Long productId; private String productName; private BigDecimal price; // 省略 getter/setter }

业务代码里把列表写入 Redis:

List<ProductInfo> productList = getProductList(); stringRedisTemplate.opsForValue().set( "product:list", JSON.toJSONString(productList) );

读取时看着很正常的写法:

String json = stringRedisTemplate.opsForValue().get("product:list"); List<ProductInfo> productList = JSON.parseObject(json, List.class); for (ProductInfo product : productList) { System.out.println(product.getProductName()); }

结果运行到 for 循环这行,直接抛异常:

java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.ProductInfo

你要是第一次遇到,大概率会懵:序列化写进去的是List<ProductInfo>,取出来强转ProductInfo怎么就挂了?反序列化的时候JSON.parseObject不是能看到目标类型吗?

问题恰恰就出在List.class这个参数上。它代表“目标类型是 List”,仅此而已。JVM 拿到List.class时,泛型参数已经被擦除,fastjson 不知道列表元素应该是什么类型,于是把每个 JSON 对象都解析成了最通用的JSONObject(实际是LinkedHashMap)。后面你把它当ProductInfo用,强转自然失败。

2.2 为什么JSON.parseArray也救不了你

可能有人会说:那我用JSON.parseArray(json, ProductInfo.class)总行了吧?

List<ProductInfo> productList = JSON.parseArray(json, ProductInfo.class);

这一段确实能跑通,因为第二个参数明确告诉 fastjson,数组元素类型是ProductInfo。但这里有个前置条件:你的缓存数据本身就是数组结构。如果你存的 JSON 是一个带根节点的对象,或者你拿到的是一个 JSON 字符串但你只有List<SomeType>这个泛型声明,parseArray就派不上用场了。

更隐蔽的是嵌套泛型。比如:

public class OrderInfo { private Long orderId; private List<OrderItem> items; }

然后你用JSON.parseArray(json, OrderInfo.class)去解析:

List<OrderInfo> orderList = JSON.parseArray(json, OrderInfo.class); OrderInfo order = orderList.get(0); List<OrderItem> items = order.getItems(); // 能取出来 OrderItem item = items.get(0); // 这里可能报 LinkedHashMap cannot be cast to OrderItem

为什么order.getItems()不报错?因为OrderInfo里items字段声明了List<OrderItem>,fastjson 反序列化OrderInfo时能拿到这个字段的泛型签名,内部嵌套列表的元素类型可以正确还原。但如果你把items声明成List<Object>,或者直接声明成未指定泛型的原始类型,那内部元素又会退化成JSONObject。

所以这个坑不只是“顶层 List 没传泛型”的问题,而是“任何出现泛型信息丢失的地方,都可能引发后续强转异常”。嵌套越深,踩雷概率越大。

2.3 根治方案:TypeReference 和 toJavaList

正确的解法其实很直接:反序列化时尽量保留完整的泛型信息。fastjson 提供了TypeReference来包装带泛型的类型:

List<ProductInfo> productList = JSON.parseObject( json, new TypeReference<List<ProductInfo>>() {} );

这里有个很多同学不理解的点:new TypeReference<List<ProductInfo>>() {}为什么能把泛型信息留住?因为匿名内部类在创建时,会记录当前类继承的父类泛型参数的实际类型。TypeReference的构造器里通过getGenericSuperclass()拿到父类的Type,这个Type里保留了List<ProductInfo>的完整信息。这一步是编译期就固化下来的,不会因为运行时的类型擦除而丢失。fastjson 拿到这个Type以后,就知道数组元素该还原成ProductInfo。

如果你手里拿的是JSONArray,也可以直接转成强类型 List:

List<ProductInfo> productList = JSON.parseArray(json).toJavaList(ProductInfo.class);

toJavaList是JSONArray提供的方法,内部会逐个元素做类型转换,比手动 for 循环强转安全得多。

顺带提醒一句:若使用JSON.parseObject(json, new TypeReference<List<OrderInfo>>() {})解析嵌套泛型,内部字段List<OrderItem> items也会按字段声明还原,这是最省心的姿势。我一般只要涉及到缓存列表、缓存 Map,一律用TypeReference,宁可多敲两行代码,也不想在运行时猜类型。

2.4 另一个隐藏点:Redis 序列化器配置带来的二次干扰

如果你的项目不是像我上面那样手动JSON.toJSONString,而是直接用RedisTemplate配了GenericFastJsonRedisSerializer,那还会多一层干扰。GenericFastJsonRedisSerializer在序列化时会往 JSON 里塞一个@type字段,用来标记原始类型,反序列化时依靠这个标记还原对象。这套机制对顶层对象很友好,但如果你往 Redis 里塞的是一个List,@type记录的是java.util.ArrayList,元素类型依然没有可靠记录,取出来以后遍历元素强转还是要小心。

此外,这个序列化器还会带来安全上的坑:它默认开启了部分autoType,如果 Redis 数据源被污染或者被恶意写入,可能触发反序列化漏洞。所以我个人现在的倾向是:Redis 缓存层尽量不依赖 autoType 机制,序列化时就用普通 JSON 字符串,反序列化时显式传TypeReference,这样既清晰又可控,还顺带避开一类安全问题。

3. 坑二:标量字段类型漂移,Long 变 Integer、BigDecimal 变 Double

3.1 问题场景复现

如果说坑一是“容器类型”的问题,那坑二就是“容器里的标量字段”的问题。典型现象是缓存一个Map:

Map<String, Object> userStats = new HashMap<>(); userStats.put("userId", 10001L); userStats.put("orderCount", 5L); userStats.put("totalAmount", new BigDecimal("99.99")); stringRedisTemplate.opsForValue().set( "user:stats:" + userId, JSON.toJSONString(userStats) );

读取时的代码如下:

String json = stringRedisTemplate.opsForValue().get("user:stats:" + userId); Map<String, Object> stats = JSON.parseObject(json, Map.class); Long userId = (Long) stats.get("userId"); // 这里抛 ClassCastException

报错:

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.Long

为什么会这样?因为 JSON 里10001就是一个没有小数点的数字字面量。fastjson 在把 JSON 还原成Map<String, Object>时,Object目标类型太宽泛,它只能根据“长得像什么”来猜 Java 类型:看到整数,默认解析成Integer;看到带小数点的数字,默认解析成BigDecimal;看到字符串,解析成String。于是你在 Java 侧明明 put 了一个Long,序列化成 JSON 再反序列化回来,就变成了Integer。

同样的问题还发生在数值范围较大的场景。比如Long类型的雪花 ID:

Map<String, Object> data = new HashMap<>(); data.put("orderId", 739856438297561253L);

反序列化回来再强转:

Long orderId = (Long) data.get("orderId");

如果 fastjson 严格按“整数默认 Integer”来解析,这个值已经超过Integer.MAX_VALUE,它会被提升为Long,强转(Long)勉强还能凑合。但如果你的数值恰好在一两万这种小范围内,Long就会退化成Integer,强转必挂。

BigDecimal 也有类似情况。JSON 里如果出现99.99,fastjson 在目标类型不明确时默认解析成BigDecimal,这其实还算好;但如果目标类型明确是Double,你就可能面对精度问题;反过来,如果你 put 的是Double,拿 JSON 解析后默认可能是BigDecimal,强转又是异常。

3.2 根因分析:目标类型不明确时 fastjson 的类型推断规则

这个坑的根源在于 JVM 把目标类型告诉 fastjson 时,没有提供足够精确的信息。Map.class意味着“目标是一个 Map,但 key 和 value 类型未知”,fastjson 就会对所有 value 使用自己的默认推断逻辑。它的推断规则大致如下:

JSON 字面量形态默认推断结果说明
整数(未超出 int 范围)Integer最常见的漂移来源
整数(超出 int 范围)Long大整数才能保住 Long
小数BigDecimal保留精度的默认选择
布尔值Boolean相对稳定
字符串String相对稳定
数组JSONArray类似坑一的退化
对象JSONObject类似坑一的退化

所以只要你用Map或者Object兜底,然后又强行把 value 转回原来的类型,就会时不时撞见ClassCastException。有些人觉得“我 put 的时候明明是 Long,fastjson 序列化都成功了,取出来怎么变了”,其实原因就是 JSON 层已经丢掉“这个字段是 Long”的元信息,反序列化时很难再还原出来。

3.3 解决方案:强类型声明优先,取值 API 兜底

解法分三层,越靠前的越推荐。

第一层:反序列化时不要用Map.class,用TypeReference把 value 类型锁死:

Map<String, Long> stats = JSON.parseObject( json, new TypeReference<Map<String, Long>>() {} ); Long userId = stats.get("userId");

这里明确告诉 fastjson,Map 的 value 是Long,它就会把 JSON 数字解析成Long,不再瞎猜。

第二层:如果确实需要Map<String, Object>这种宽松结构,那就别用强转,改用JSONObject提供的便捷取值 API:

JSONObject stats = JSON.parseObject(json); Long userId = stats.getLong("userId"); Long orderCount = stats.getLong("orderCount"); BigDecimal totalAmount = stats.getBigDecimal("totalAmount");

getLong、getInteger、getBigDecimal、getString这些方法内部会根据实际存储的数值类型做一次安全的数值转换,而不是直接强转。比如内部存的是Integer,你调getLong也能得到一个Long,因为底层做的是((Number) value).longValue()之类的转换。这一步能规避掉绝大多数标量类型漂移问题。

第三层:设计上尽量避免Map<String, Object>作为缓存结构。把统计信息封装成一个强类型对象,例如:

public class UserStatsDTO { private Long userId; private Long orderCount; private BigDecimal totalAmount; // 省略 getter/setter }

然后用JSON.parseObject(json, UserStatsDTO.class),字段类型全部有明确声明,fastjson 会严格按照字段的 setter 参数类型进行转换,基本不会出现漂移。这一层才是最省心的:类型信息越明确,反序列化越可靠。

3.4 顺带踩过的日期坑:时间字段也会加入混战

标量类型漂移里,日期时间字段是重灾区。我见过一个项目缓存了一个含LocalDateTime字段的对象,序列化用JSON.toJSONString,取出来用JSON.parseObject(json, Object.class),然后再转成JSONObject调get("createTime"),发现返回的是一个时间戳字符串,不是LocalDateTime。更常见的是直接把LocalDateTime字段放在一个 Map 里序列化,反序列化时抛JSONException: syntax error,因为 fastjson 在不知道目标类型的情况下,不知道该把日期序列化结果还原成什么。

这个问题解决方案也简单:要么用强类型对象承接,给LocalDateTime字段加@JSONField(format = "yyyy-MM-dd HH:mm:ss")明确格式;要么在序列化时配置SerializerFeature.WriteDateUseDateFormat,让日期变成可读字符串,再手动解析。核心还是那句话:类型信息不明确,fastjson 的默认行为就可能和你预期不一致。

4. 排查与定位:遇到类型转换异常怎么下手

4.1 先看异常堆栈,确认异常发生的“代码行”

不管报了哪种ClassCastException,第一步永远是看堆栈定位到业务代码的具体行。很多异常是在局部变量赋值时抛出来的,不一定每次都能一眼看出问题源头。比如:

java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.ProductInfo at com.example.service.ProductService.getProductList(ProductService.java:105)

这说明第 105 行做了一次强转。接着看这行代码是从哪里取到数据的,如果是从 Redis 拿的 JSON,那基本可以锁定是反序列化类型不匹配。

有一个很实用的经验:在出问题的那一行前面,把原始 JSON 打印出来或者断点查看。很多时候你看到的不是“null”,而是一个结构看起来完全正常的 JSON。问题不在 JSON 本身,而在你解析它时给的目标类型不够精确。

4.2 写一个最小复现 Demo,甩开业务干扰

排查这类问题,我最喜欢先写一个最小复现,把业务逻辑剥掉,纯粹测试序列化和反序列化的表现。比如:

// 复现坑一 List<ProductInfo> list = new ArrayList<>(); list.add(new ProductInfo(1L, "商品A", new BigDecimal("9.90"))); String json = JSON.toJSONString(list); List<ProductInfo> result = JSON.parseObject(json, List.class); System.out.println(result.get(0).getClass()); // class java.util.LinkedHashMap

跑一遍,类型原形毕露。确认以后,再换成TypeReference跑一遍,看看是否能正确还原。这个节奏比在庞大的业务代码里翻来翻去高效得多,而且方便你向同事或者社区提问时给出可复现的代码片段。

4.3 观察 JSON 字符串本身,反向判断“为什么猜错类型”

拿到 JSON 后,可以盯着字符串多看一眼。如果一串数字是739856438297561253,你对“它会被解析成 Integer 还是 Long”就会有预期;如果是一个对象数组,你也更容易理解为什么强转成具体类会失败。JSON 是一个中间的、中性的表达层,Java 类型信息根本不包含在里面。理解这一点,很多匪夷所思的异常就变得合理了。

排查询问的时候我也建议直接在测试里打印实际类型,不要凭感觉:

Object userId = stats.get("userId"); System.out.println(userId.getClass());

看到输出class java.lang.Integer的时候,你就会无比确定:这不是玄学,是类型推断规则。

4.4 常见问题速查表

异常信息典型场景优先解法
LinkedHashMap cannot be cast to XxxClassList没传泛型,或Object字段被还原成通用结构用TypeReference或toJavaList(XxxClass.class)
Integer cannot be cast to LongMap<String, Object>里的小整数被默认解析成Integer用TypeReference锁类型,或getLong取值
BigDecimal cannot be cast to Double目标类型是Double,但枚举值被还原成BigDecimal强类型 DTO 或getDoubleValue/getBigDecimal
JSONException: syntax errorLocalDateTime等时间类型在类型不明确时无法反序列化@JSONField(format=...)或强类型对象
ClassCastException: JSONObject cannot be cast to Map使用了 JSONObject 与 Map 混用场景统一都用 JSONObject 或统一都用 Map

这个表基本覆盖了我实际遇到过的大多类型转换问题,也建议你们在自己的项目里维护类似的“踩坑清单”,比每次重新排查省时间得多。

5. 根治层面:这几条设计习惯能少踩一半以上的坑

5.1 缓存对象尽量用 DTO/VO,不要用裸 Map 和 Object

如果你愿意在项目设计上做一点调整,类型转换异常的数量会断崖式下降。我强烈建议 Redis 缓存的数据结构尽量使用业务 DTO,而不是Map<String, Object>或Object兜底。原因很简单:DTO 的字段类型是明确的,fastjson 在运行时能反射到字段的声明类型,反序列化时会做按需转换;而Map<String, Object>只告诉你“这里有点数据”,类型推断完全失效。你可以把 DTO 看作一份“契约”,它同时约束了序列化和反序列化两端的类型行为,也让你在写缓存代码时更容易发现类型不一致的问题。

5.2 统一封装 Redis JSON 序列化工具,收敛风险

团队项目里代码水平参差不齐,有人手动JSON.toJSONString,有人用JSON.parseObject(json, new TypeReference<...>(){}),有人直接JSON.parseObject(json, List.class)。时间久了,类型问题就会在隐蔽的角落里爆发。一个有效做法是统一封装缓存读写工具,把序列化和反序列化的规则集中收敛起来。比如提供一个专门缓存 List 对象的方法:

public <T> void setList(String key, List<T> list) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list)); } public <T> List<T> getList(String key, Class<T> clazz) { String json = stringRedisTemplate.opsForValue().get(key); return JSON.parseArray(json, clazz); }

这样使用者不需要关心TypeReference,只要传入元素类型即可。对于复杂的嵌套结构,再单独提供TypeReference重载版本。收敛以后,至少不会出现“同一个缓存,有人存的时候是 List,有人取的时候当单对象解析”这种低级事故。

5.3 强转之前先做 instanceof 防御,争取容错空间

在高并发、多版本迭代的环境下,哪怕你前面做得再好,也可能遇到历史缓存数据格式不一致的情况。比如老版本缓存的 key 是Map结构,新版本上线后改成了 DTO,旧数据还在 Redis 里没有过期。这时取缓存再解析,即使你传的TypeReference是对的,也会因为 JSON 结构差异解析失败。所以反序列化之后,如果确实存在强转场景,不妨加一层防御:

Object cacheObj = JSON.parseObject(json, new TypeReference<Object>() {}); if (cacheObj instanceof List) { List<?> rawList = (List<?>) cacheObj; for (Object item : rawList) { if (item instanceof ProductInfo) { // 正常处理 } else { // 打日志,按旧格式兜底或清缓存 } } }

这种防御虽然丑,但在兼容历史数据时非常有用。我一般在缓存结构升级时都会在读取侧写一段兼容逻辑,等旧数据全部过期后再简化掉。

5.4 升级 Fastjson2 还是坚持 Fastjson?

很多人问我要不要从 fastjson 1.x 升级到 Fastjson2。我的观点是:如果有条件,建议升级。Fastjson2 重新实现了核心解析引擎,对泛型信息的处理更加稳定,很多 1.x 里因为类型推断导致的怪问题在 2.x 里已经改进。API 大体兼容,多数项目从com.alibaba.fastjson换成com.alibaba.fastjson2只需要改包名和个别配置。但升级本身也有成本,特别是一些老项目依赖了 1.x 的私有行为,所以最好在有完整测试覆盖的前提下进行。

如果暂时不能升级,那就老老实实遵守上面的规则:强类型 DTO、TypeReference、取值用getLong/getBigDecimal这类安全 API。只要做到这三点,fastjson 1.x 依然能在生产环境稳定工作。

6. 实操总结:这份避坑清单直接抄作业

写到这里,把关键经验浓缩成一份可以直接抄的清单:

  • 反序列化List<T>时,永远用TypeReference或JSON.parseArray(json, T.class),不要用List.class
  • 反序列化Map<K, V>时,尽量用TypeReference锁住 K 和 V 的类型,不要用裸Map.class
  • 从JSONObject里取数值,优先用getLong、getInteger、getBigDecimal,不要直接强转
  • 缓存结构设计上,优先 DTO/VO,避免Map<String, Object>这种大杂烩
  • 日期时间字段加上统一的格式配置,不要依赖 fastjson 的默认日期处理
  • Redis 序列化器选择上,尽量用可控的 JSON 字符串,而非依赖 autoType 的机制
  • 排查异常时,先看堆栈定位行,再写最小复现,再观察 JSON 字符串,最后根据实际类型修正解析方式
  • 老数据兼容期间,读取侧加一层 instanceof 防御,避免新老格式交接时直接挂掉

我个人在实际操作中的体会是,Redis + fastjson 的组合本身没问题,问题几乎都出在“类型信息不明确”这一件事上。很多时候你以为自己写的是List<ProductInfo>,但传进解析方法的却只有List.class;你以为Map<String, Long>足够精确,但 JVM 运行时只留下了Map.class。把这些“自以为”逐一改成“显式声明”,类型转换异常的频率会骤降。最后再分享一个小技巧:每当你对 fastjson 的行为拿不准时,不要搜“为什么报错”,直接在本地跑一个三行的最小 demo,看一眼实际返回的类型,比什么文档都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询