文章标题:【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,想让缓存“更可读、可跨语言”。结果一刀下去,线上马上给我上课。


排查脑回路

  1. 先怀疑缓存击穿?不对,能看到key是存在的,就是反序列化失败或类型不对。
  2. redis-cli看数据:部分key是\xac\xed\x00\x05...(熟悉的JDK序列化头),部分是{"id":...}(JSON)。嗯,混搭现场。
  3. 搜配置,发现历史代码:
// 旧:默认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;
}
  1. 更离谱的是,部分模块用StringRedisTemplate,部分用RedisTemplate,@Cacheable又走了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)
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加版本前缀,灰度期双读回写,稳定后统一切换,少挨两巴掌。】
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐