【JDK和JSON混搭把我整破防:一次Redis序列化切换导致线上大面积缓存读挂】
·
文章标题:【JDK和JSON混搭把我整破防:一次Redis序列化切换导致线上大面积缓存读挂】 文章简述:【发布后部分接口疯狂打DB,日志里全是SerializationException或ClassCast。最后定位到旧版本用JDK序列化、这次改了JSON,两个序列化器混用,读出来不是null就是乱码。做了双读迁移+按namespace隔离并统一配置,才止血。】 文章标签:【Java,Redis,Serialization,Bug排查】 文章内容: 【今天下午正准备摸鱼,接口监控开始尖叫:缓存命中率从95%直线掉到20%,DB QPS把我吓出一身汗。测试在群里问我:“你是不是把缓存删光了?”我只回了俩字:裂开。】
事故现场
- 现象:
- 刚发完版,几个读多写少的接口命中率暴跌,DB被锤;
- 日志里同时出现两种离谱异常:
org.springframework.data.redis.serializer.SerializationException: Could not deserialize; nested exception is org.springframework.core.convert.ConversionFailedException: ...
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.xxx.user.UserDTO
-
Redis里
GET同一个key,有的值是一坨二进制(JDK序列化),有的是可读JSON。 -
变更点:这次把
RedisTemplate从JDK序列化切到JSON,想让缓存“更可读、可跨语言”。结果一刀下去,线上马上给我上课。
排查脑回路
- 先怀疑缓存击穿?不对,能看到key是存在的,就是反序列化失败或类型不对。
- redis-cli看数据:部分key是
\xac\xed\x00\x05...(熟悉的JDK序列化头),部分是{"id":...}(JSON)。嗯,混搭现场。 - 搜配置,发现历史代码:
// 旧:默认RedisTemplate(JDK序列化)
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<Object, Object> t = new RedisTemplate<>();
t.setConnectionFactory(cf);
// key默认JdkSerializationRedisSerializer(离谱)
return t;
}
这次我们改成了:
// 新:统一用String/JSON
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<String, Object> t = new RedisTemplate<>();
t.setConnectionFactory(cf);
t.setKeySerializer(new StringRedisSerializer());
t.setHashKeySerializer(new StringRedisSerializer());
t.setValueSerializer(new GenericJackson2JsonRedisSerializer());
t.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
t.afterPropertiesSet();
return t;
}
- 更离谱的是,部分模块用
StringRedisTemplate,部分用RedisTemplate,@Cacheable又走了CacheManager,三套序列化器各玩各的。于是:- 旧JDK序列化的value现在用JSON去解码 -> 直接
SerializationException; - 另一些用
Jackson2JsonRedisSerializer反序列化时没开类型信息,取出来是LinkedHashMap,强转成UserDTO就ClassCastException。
- 旧JDK序列化的value现在用JSON去解码 -> 直接
板上钉钉:序列化器切换没迁移,且JSON序列化配置不完整。
问题复现(最小Demo)
// 旧版本写入(JDK)
RedisTemplate oldTpl = old();
oldTpl.opsForValue().set("user:1", new UserDTO(1L, "Tom"));
// 新版本读取(JSON)
RedisTemplate<String, Object> newTpl = json();
UserDTO u = (UserDTO) newTpl.opsForValue().get("user:1");
// -> SerializationException or ClassCastException
修复方案(止血 + 迁移 + 规范)
目标:
- 线上快速止血,不能让请求全砸DB;
- 平滑迁移老数据;
- 统一序列化规范,彻底杜绝混搭。
1) 统一序列化配置(含类型信息)
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
RedisTemplate<String, Object> t = new RedisTemplate<>();
t.setConnectionFactory(cf);
StringRedisSerializer ks = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer vs = new GenericJackson2JsonRedisSerializer();
t.setKeySerializer(ks);
t.setHashKeySerializer(ks);
t.setValueSerializer(vs);
t.setHashValueSerializer(vs);
t.afterPropertiesSet();
return t;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
RedisSerializationContext.SerializationPair<String> keyPair =
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer());
RedisSerializationContext.SerializationPair<Object> valPair =
RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());
RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(keyPair)
.serializeValuesWith(valPair)
.entryTtl(Duration.ofMinutes(30));
return RedisCacheManager.builder(cf).cacheDefaults(cfg).build();
}
}
- 用
GenericJackson2JsonRedisSerializer省心,默认带@class类型信息,反序列化不再变成LinkedHashMap。 - 如果你坚持
Jackson2JsonRedisSerializer,务必手动开启DefaultTyping或写入TypeReference,别强转硬刚。
2) 快速止血:读失败走“兜底双读+重写”
上线一个短期过渡方案:读取失败时,用旧JDK模板兜底读取,然后回写新格式(带TTL)。
@Service
public class UserCache {
@Resource(name = "jsonRedisTemplate")
private RedisTemplate<String, Object> jsonTpl;
@Resource(name = "jdkRedisTemplate")
private RedisTemplate<Object, Object> jdkTpl; // 仅用于迁移期
public UserDTO getUser(Long id){
String key = ns("user:v2:") + id; // 新namespace,避免老数据干扰
Object v = jsonTpl.opsForValue().get(key);
if (v instanceof UserDTO) return (UserDTO) v;
// 兜底:尝试读取旧namespace
String oldKey = ns("user:") + id;
Object old = null;
try { old = jdkTpl.opsForValue().get(oldKey); } catch (Exception ignore) {}
if (old instanceof UserDTO u) {
// 回写新格式并设TTL
jsonTpl.opsForValue().set(key, u, Duration.ofHours(1));
// 可选:删除旧key
jdkTpl.delete(oldKey);
return u;
}
return null;
}
}
要点:
- 新老namespace分离(比如加
v2前缀),避免“新代码读老值”继续踩坑; - 兜底只在迁移期存在,上线稳定后移除,防止长期技术债。
3) 批量离线迁移(可选)
对热点Key提前离线搬迁,减少线上双读压力:
public void migrateHotKeys(Set<String> ids){
for (String id : ids) {
String oldKey = "user:" + id;
Object old = jdkTpl.opsForValue().get(oldKey);
if (old instanceof UserDTO u) {
jsonTpl.opsForValue().set("user:v2:" + id, u, Duration.ofHours(1));
jdkTpl.delete(oldKey);
}
}
}
4) 代码规范与避坑
- 约定统一用
RedisTemplate<String, Object>+String/GenericJackson2Json,严禁模块自配; @Cacheable走同一个CacheManager,不要“有的用template、有的用cache”;- Key命名统一加namespace+版本号,灰度时双写/双读可控;
- 多语言共享缓存时,明确JSON Schema并做兼容处理。
验证
- 发布过渡版后:命中率从20%回升到92%+,DB QPS降回正常;
- 一周内完成热点Key迁移并下线双读逻辑,命中率稳定95%+;
- 线上再无
SerializationException/ClassCastException告警。
踩坑总结
- 切序列化器=线上数据都要跟着迁,不然就是“读到鬼”;
- JSON反序列化要么带类型信息,要么自己做对象组装,别对
LinkedHashMap硬转; - 强烈建议给缓存Key加版本前缀,灰度期双读回写,稳定后统一切换,少挨两巴掌。】
更多推荐


所有评论(0)