Spring Boot Redis 缓存序列化踩坑记:GenericJackson2JsonRedisSerializer 的数组反序列化陷阱

前言

在 Spring Boot 项目中使用 Redis 做缓存是一件很常见的事。大多数教程会告诉你用 GenericJackson2JsonRedisSerializer 配合 activateDefaultTyping 就能搞定序列化问题。然而,当你缓存的方法返回 List<T> 类型时,你会发现自己掉进了一个深坑 ------ 反序列化时抛出 MismatchedInputException,而且无论怎么调整配置都无法同时兼容集合类型和单个对象。

本文记录了我在 Spring Boot 3.2.5 + Spring Data Redis 3.2.5 环境下,从发现问题到最终解决的完整过程。


问题现象

项目中有两个 @Cacheable 方法:

java 复制代码
@Cacheable(value = "questions", key = "#version")
public List<QuestionDTO> getQuestions(String version) { ... }

@Cacheable(value = "leaderboard", key = "'all'", unless = "#result == null")
public LeaderboardDTO getLeaderboard() { ... }

一个返回 List<QuestionDTO>,另一个返回单个 LeaderboardDTO 对象。

使用 GenericJackson2JsonRedisSerializer + activateDefaultTyping(NON_FINAL, As.PROPERTY) 配置后,两个缓存总有一个能工作,另一个报错

typescript 复制代码
SerializationException: Could not read JSON:
Unexpected token (START_OBJECT), expected VALUE_STRING:
need String, Number or Boolean value that contains type id (for subtype of java.lang.Object)

清空 Redis 缓存、重启服务、反复切换配置,问题始终存在。


排查过程

第一次尝试:As.PROPERTY(默认配置)

java 复制代码
mapper.activateDefaultTyping(
    LaissezFaireSubTypeValidator.instance,
    ObjectMapper.DefaultTyping.NON_FINAL,
    JsonTypeInfo.As.PROPERTY);

序列化 List<QuestionDTO> 产出的 JSON:

json 复制代码
[
  {"@class": "com.mbti.dto.QuestionDTO", "id": 1, "content": "..."},
  {"@class": "com.mbti.dto.QuestionDTO", "id": 2, "content": "..."}
]

问题@class 写在了每个元素内部,但数组本身([)没有类型标识。反序列化时,GenericJackson2JsonRedisSerializerresolveType 方法读到第一个 token 是 START_ARRAY[),期望的是 VALUE_STRING(类名字符串),直接报错。

单个对象 LeaderboardDTO 能正常工作,因为它序列化为 {"@class": "...", ...},第一个 token 就是类型属性。

第二次尝试:As.WRAPPER_ARRAY

java 复制代码
mapper.activateDefaultTyping(
    LaissezFaireSubTypeValidator.instance,
    ObjectMapper.DefaultTyping.NON_FINAL,
    JsonTypeInfo.As.WRAPPER_ARRAY);

序列化 List<QuestionDTO> 产出的 JSON:

json 复制代码
[
  ["com.mbti.dto.QuestionDTO", {"id": 1, "content": "..."}],
  ["com.mbti.dto.QuestionDTO", {"id": 2, "content": "..."}]
]

问题 :第一个 token 仍然是 START_ARRAY[),报错信息一模一样。GenericJackson2JsonRedisSerializerresolveType 无法从 wrapper array 中提取类型信息。

第三次尝试:去掉 activateDefaultTyping

不配置 DefaultTyping,让 GenericJackson2JsonRedisSerializer 自己处理。

问题 :序列化时不写入任何类型信息,反序列化时 Jackson 默认把对象解析为 LinkedHashMap

vbnet 复制代码
ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.mbti.dto.LeaderboardDTO

根因分析

通过反编译 spring-data-redis-3.2.5.jar 中的 GenericJackson2JsonRedisSerializer 字节码,我发现了问题的根源。

GenericJackson2JsonRedisSerializer 的内部机制

该类有两套类型解析机制:

  1. Jackson 的 DefaultTyping :通过 ObjectMapper.setDefaultTyping() 配置,Jackson 在序列化/反序列化时自动嵌入/读取类型信息
  2. 内部的 TypeResolverGenericJackson2JsonRedisSerializer 自己的类型解析器,在 deserialize() 方法中被调用

关键发现 :当 ObjectMapper 上配置了 DefaultTyping 时,Jackson 的类型解析器会先于 TypeResolver 执行。

反编译字节码显示,默认构造器的流程:

scss 复制代码
GenericJackson2JsonRedisSerializer()
  → this((String) null)
    → this(null, JacksonObjectReader.create(), JacksonObjectWriter.create())
      → new ObjectMapper()                              // 创建新的 ObjectMapper
      → registerNullValueSerializer(mapper, null)       // 注册空值序列化器
      → TypeResolverBuilder.forEverything(mapper)       // 构建类型解析器
        → init(Id.CLASS, null)                          // 使用 @class 作为类型标识
        → inclusion(As.PROPERTY)                        // 作为属性嵌入
        → typeProperty("@class")                        // 属性名为 @class
      → mapper.setDefaultTyping(resolverBuilder)        // ← 关键:设置了 DefaultTyping

而接受 ObjectMapper 参数的构造器:

scss 复制代码
GenericJackson2JsonRedisSerializer(ObjectMapper mapper)
  → this(mapper, JacksonObjectReader.create(), JacksonObjectWriter.create())
    → 仅保存 mapper 引用,不调用 setDefaultTyping()

为什么数组会崩?

当 Jackson 的 DefaultTyping + As.PROPERTY 生效时,序列化/反序列化的流程是:

css 复制代码
序列化 List<QuestionDTO>:
  Jackson 类型解析器检查类型 → 是 List(final 类型,NON_FINAL 不处理)→ 不添加类型信息
  → 输出:[{...}, {...}](裸数组)

反序列化 [{...}, {...}]:
  Jackson 类型解析器读第一个 token → 是 [(START_ARRAY)  → 期望 VALUE_STRING(类名)→ 抛出 MismatchedInputException

As.WRAPPER_ARRAY 也不行,因为 wrapper array 的第一个 token 仍然是 [

css 复制代码
反序列化 [["className", {...}], ...]:
  Jackson 类型解析器读第一个 token → 是 [(START_ARRAY)
  → 同样期望 VALUE_STRING → 同样报错

核心矛盾 :Jackson 的 DefaultTyping 机制要求类型标识是一个字符串值(As.PROPERTY@class 属性值,或 As.WRAPPER_ARRAY 的第一个元素),但它在解析时遇到的是数组结构的 [ 符号,而不是字符串。

为什么单个对象没问题?

单个对象序列化为 {"@class": "...", ...},第一个 token 是 {START_OBJECT),Jackson 的类型解析器能正确从中提取 @class 属性值。

结论DefaultTyping + As.PROPERTY 在处理 JSON 数组(集合类型)时存在根本性的设计缺陷,这不是配置问题,而是机制问题。


解决方案

既然 GenericJackson2JsonRedisSerializer 的两种类型解析机制都无法正确处理数组,我决定完全绕开它们,实现一个自定义序列化器。

核心思路

将类型信息显式包装在一个 JSON 对象中,而不是依赖 Jackson 的 DefaultTyping

json 复制代码
{
  "@type": "java.util.ImmutableCollections$ListN",
  "value": [
    {"id": 1, "content": "问题1", "options": [...]},
    {"id": 2, "content": "问题2", "options": [...]}
  ]
}

实现代码

java 复制代码
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

public class TypedJsonRedisSerializer implements RedisSerializer<Object> {

    private final ObjectMapper mapper;
    private static final byte[] EMPTY_ARRAY = new byte[0];

    public TypedJsonRedisSerializer(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public byte[] serialize(Object value) throws SerializationException {
        if (value == null) {
            return EMPTY_ARRAY;
        }
        try {
            ObjectNode wrapper = mapper.createObjectNode();
            wrapper.put("@type", value.getClass().getName());
            wrapper.set("value", mapper.valueToTree(value));
            return mapper.writeValueAsBytes(wrapper);
        } catch (Exception e) {
            throw new SerializationException("serialize error: " + e.getMessage(), e);
        }
    }

    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        try {
            ObjectNode wrapper = (ObjectNode) mapper.readTree(bytes);
            String typeName = wrapper.get("@type").asText();
            Class<?> type = Class.forName(typeName);
            JavaType javaType = mapper.getTypeFactory().constructType(type);
            return mapper.treeToValue(wrapper.get("value"), javaType);
        } catch (Exception e) {
            throw new SerializationException("deserialize error: " + e.getMessage(), e);
        }
    }
}

RedisConfig 配置

java 复制代码
@Configuration
public class RedisConfig {

    private ObjectMapper redisObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        // 注意:不需要 activateDefaultTyping
        return mapper;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        RedisSerializer<Object> serializer = new TypedJsonRedisSerializer(redisObjectMapper());
        StringRedisSerializer stringSerializer = new StringRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<Object> serializer = new TypedJsonRedisSerializer(redisObjectMapper());

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(serializer))
                .disableCachingNullValues();

        return RedisCacheManager.builder(factory).cacheDefaults(config).build();
    }
}

工作原理

序列化流程

  1. mapper.valueToTree(value) ------ 将对象转为 JsonNode(不包含类型信息,纯数据)
  2. 创建 ObjectNode 包装器,写入 @type(运行时类名)和 value(JsonNode)
  3. mapper.writeValueAsBytes(wrapper) ------ 输出 JSON 字节

反序列化流程

  1. mapper.readTree(bytes) ------ 解析为 JsonNode
  2. 读取 @type 字段,Class.forName() 获取 Class 对象
  3. mapper.getTypeFactory().constructType(type) 构造 JavaType(保留泛型信息)
  4. mapper.treeToValue(wrapper.get("value"), javaType) ------ 按正确类型反序列化

为什么这个方案能同时处理集合和单个对象?

  • List<QuestionDTO> 的运行时类型是 ImmutableCollections$ListN@type 存储这个类名
  • 反序列化时 Class.forName("java.util.ImmutableCollections$ListN") 拿到 List 类型
  • constructType(List.class) 返回 CollectionType
  • treeToValueList 类型解析 JSON 数组,每个元素用 Jackson 的默认行为处理(QuestionDTO 的字段名直接映射)

方案对比

方案 List 单个对象 复杂度 侵入性
GenericJackson2JsonRedisSerializer 默认构造器 OK
GenericJackson2JsonRedisSerializer + As.PROPERTY OK
GenericJackson2JsonRedisSerializer + As.WRAPPER_ARRAY OK
GenericJackson2JsonRedisSerializer + 无 DefaultTyping LinkedHashMap LinkedHashMap
自定义 TypedJsonRedisSerializer OK OK

注意事项

  1. 缓存数据格式变更 :切换序列化器后,旧的缓存数据无法被新序列化器读取。部署时需要清空 Redis 缓存(FLUSHDB 或逐个删除缓存 key)。

  2. 类名稳定性@type 存储的是完整的类名(如 com.mbti.dto.QuestionDTO)。如果重构时修改了包名或类名,旧缓存会反序列化失败。缓存有 TTL 机制,短期内容错。

  3. Class.forName 的限制 :只能加载应用 ClassLoader 能访问的类。对于 JDK 内部类(如 java.util.ImmutableCollections$ListN),需要注意不同 JDK 版本的兼容性。实际上 treeToValue 会将其正确解析为 List,因为 Jackson 知道如何处理集合类型。

  4. mapper.valueToTree 不触发 DefaultTyping :因为我们使用的是 plain ObjectMapper(没有 activateDefaultTyping),valueToTree 只会序列化对象的字段,不会嵌入 @class。类型信息完全由我们的 @type 字段承担。


总结

GenericJackson2JsonRedisSerializer 是 Spring Data Redis 推荐的通用序列化器,但它与 Jackson 的 DefaultTyping 机制配合时,在处理 JSON 数组(集合类型)时存在根本性的缺陷。这不是配置问题,而是 Jackson 类型解析器在遇到数组起始 token 时的预期行为与实际 JSON 结构不匹配。

当你的 @Cacheable 方法需要同时缓存集合类型和单个对象时,自定义序列化器是最可靠的方案。核心思想是将类型信息显式存储为 JSON 对象的一个字段,而不是依赖 Jackson 的自动类型推断。

相关推荐
pq2171 小时前
LambdaMetafactory(fastjson2使用的黑科技)
java
SamDeepThinking1 小时前
你认为从0-1开发一个项目最难的地方是什么?
java·后端·架构
Devin~Y1 小时前
大厂Java面试实战:Spring Boot/Cloud、Redis/Kafka、JVM调优与Spring AI RAG(内容社区UGC+AIGC客服场景)
java·jvm·spring boot·redis·spring cloud·kafka·mybatis
青山师1 小时前
CompletableFuture深度解析:异步编程范式与源码实现
java·单例模式·面试·性能优化·并发编程
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第42题】【JVM篇】第2题:JVM内存模型有哪些组成部分?
java·开发语言·jvm·面试
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第43题】【JVM篇】第3题:GC分为哪两种?Young GC 和 Full GC有什么区别?
java·开发语言·jvm·后端·面试
努力努力再努力wz2 小时前
【Redis 入门系列】为什么需要 Redis?一文串起缓存、分布式、读写分离、分库分表与微服务
数据库·redis·分布式·sql·mysql·缓存·微服务
Carino_U2 小时前
并发编程之CPU缓存架构&Disruptor
java·缓存·架构
小雅痞2 小时前
[Java][Leetcode middle] 54. 螺旋矩阵
java·leetcode·矩阵