【JDK和JSON混搭把我整破防:一次Redis序列化切换导致线上大面积缓存读挂】

发布时间:2026/6/19 10:21:28

【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.UserDTORedis里GET同一个key有的值是一坨二进制JDK序列化有的是可读JSON。变更点这次把RedisTemplate从JDK序列化切到JSON想让缓存“更可读、可跨语言”。结果一刀下去线上马上给我上课。排查脑回路先怀疑缓存击穿不对能看到key是存在的就是反序列化失败或类型不对。redis-cli看数据部分key是\xac\xed\x00\x05...熟悉的JDK序列化头部分是{id:...}JSON。嗯混搭现场。搜配置发现历史代码// 旧默认RedisTemplateJDK序列化 Bean public RedisTemplateObject, Object redisTemplate(RedisConnectionFactory cf) { RedisTemplateObject, Object t new RedisTemplate(); t.setConnectionFactory(cf); // key默认JdkSerializationRedisSerializer离谱 return t; }这次我们改成了// 新统一用String/JSON Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory cf) { RedisTemplateString, 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部分用RedisTemplateCacheable又走了CacheManager三套序列化器各玩各的。于是旧JDK序列化的value现在用JSON去解码 - 直接SerializationException另一些用Jackson2JsonRedisSerializer反序列化时没开类型信息取出来是LinkedHashMap强转成UserDTO就ClassCastException。板上钉钉序列化器切换没迁移且JSON序列化配置不完整。问题复现最小Demo// 旧版本写入JDK RedisTemplate oldTpl old(); oldTpl.opsForValue().set(user:1, new UserDTO(1L, Tom)); // 新版本读取JSON RedisTemplateString, Object newTpl json(); UserDTO u (UserDTO) newTpl.opsForValue().get(user:1); // - SerializationException or ClassCastException修复方案止血 迁移 规范目标线上快速止血不能让请求全砸DB平滑迁移老数据统一序列化规范彻底杜绝混搭。1) 统一序列化配置含类型信息Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory cf) { RedisTemplateString, 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.SerializationPairString keyPair RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()); RedisSerializationContext.SerializationPairObject 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 RedisTemplateString, Object jsonTpl; Resource(name jdkRedisTemplate) private RedisTemplateObject, 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(SetString 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) 代码规范与避坑约定统一用RedisTemplateString, ObjectString/GenericJackson2Json严禁模块自配Cacheable走同一个CacheManager不要“有的用template、有的用cache”Key命名统一加namespace版本号灰度时双写/双读可控多语言共享缓存时明确JSON Schema并做兼容处理。验证发布过渡版后命中率从20%回升到92%DB QPS降回正常一周内完成热点Key迁移并下线双读逻辑命中率稳定95%线上再无SerializationException/ClassCastException告警。踩坑总结切序列化器线上数据都要跟着迁不然就是“读到鬼”JSON反序列化要么带类型信息要么自己做对象组装别对LinkedHashMap硬转强烈建议给缓存Key加版本前缀灰度期双读回写稳定后统一切换少挨两巴掌。】

相关新闻