前言
在 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 写在了每个元素内部,但数组本身([)没有类型标识。反序列化时,GenericJackson2JsonRedisSerializer 的 resolveType 方法读到第一个 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([),报错信息一模一样。GenericJackson2JsonRedisSerializer 的 resolveType 无法从 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 的内部机制
该类有两套类型解析机制:
- Jackson 的
DefaultTyping:通过ObjectMapper.setDefaultTyping()配置,Jackson 在序列化/反序列化时自动嵌入/读取类型信息 - 内部的
TypeResolver:GenericJackson2JsonRedisSerializer自己的类型解析器,在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();
}
}
工作原理
序列化流程:
mapper.valueToTree(value)------ 将对象转为 JsonNode(不包含类型信息,纯数据)- 创建
ObjectNode包装器,写入@type(运行时类名)和value(JsonNode) mapper.writeValueAsBytes(wrapper)------ 输出 JSON 字节
反序列化流程:
mapper.readTree(bytes)------ 解析为 JsonNode- 读取
@type字段,Class.forName()获取 Class 对象 mapper.getTypeFactory().constructType(type)构造 JavaType(保留泛型信息)mapper.treeToValue(wrapper.get("value"), javaType)------ 按正确类型反序列化
为什么这个方案能同时处理集合和单个对象?
List<QuestionDTO>的运行时类型是ImmutableCollections$ListN,@type存储这个类名- 反序列化时
Class.forName("java.util.ImmutableCollections$ListN")拿到List类型 constructType(List.class)返回CollectionTypetreeToValue按List类型解析 JSON 数组,每个元素用 Jackson 的默认行为处理(QuestionDTO的字段名直接映射)
方案对比
| 方案 | List | 单个对象 | 复杂度 | 侵入性 |
|---|---|---|---|---|
GenericJackson2JsonRedisSerializer 默认构造器 |
崩 | OK | 低 | 无 |
GenericJackson2JsonRedisSerializer + As.PROPERTY |
崩 | OK | 低 | 无 |
GenericJackson2JsonRedisSerializer + As.WRAPPER_ARRAY |
崩 | OK | 低 | 无 |
GenericJackson2JsonRedisSerializer + 无 DefaultTyping |
LinkedHashMap | LinkedHashMap | 低 | 无 |
自定义 TypedJsonRedisSerializer |
OK | OK | 中 | 无 |
注意事项
-
缓存数据格式变更 :切换序列化器后,旧的缓存数据无法被新序列化器读取。部署时需要清空 Redis 缓存(
FLUSHDB或逐个删除缓存 key)。 -
类名稳定性 :
@type存储的是完整的类名(如com.mbti.dto.QuestionDTO)。如果重构时修改了包名或类名,旧缓存会反序列化失败。缓存有 TTL 机制,短期内容错。 -
Class.forName的限制 :只能加载应用 ClassLoader 能访问的类。对于 JDK 内部类(如java.util.ImmutableCollections$ListN),需要注意不同 JDK 版本的兼容性。实际上treeToValue会将其正确解析为List,因为 Jackson 知道如何处理集合类型。 -
mapper.valueToTree不触发 DefaultTyping :因为我们使用的是 plain ObjectMapper(没有activateDefaultTyping),valueToTree只会序列化对象的字段,不会嵌入@class。类型信息完全由我们的@type字段承担。
总结
GenericJackson2JsonRedisSerializer 是 Spring Data Redis 推荐的通用序列化器,但它与 Jackson 的 DefaultTyping 机制配合时,在处理 JSON 数组(集合类型)时存在根本性的缺陷。这不是配置问题,而是 Jackson 类型解析器在遇到数组起始 token 时的预期行为与实际 JSON 结构不匹配。
当你的 @Cacheable 方法需要同时缓存集合类型和单个对象时,自定义序列化器是最可靠的方案。核心思想是将类型信息显式存储为 JSON 对象的一个字段,而不是依赖 Jackson 的自动类型推断。