完美解决:应用版本更新,增加字段导致 Redis 旧数据反序列化报错

完美解决:应用版本更新,增加字段导致 Redis 旧数据反序列化报错

前言

在敏捷开发和快速迭代的今天,我们经常需要为现有的业务模型增加新的字段。但一个看似简单的操作,却可能给正在稳定运行的系统埋下"地雷"。

一个典型的场景是:我们的 Java 应用使用 Spring Data Redis 缓存对象,序列化方式为 JSON。当 V2 版本发布时,我们给 User 对象增加了一个 email 字段。部署新版本后,系统开始频繁报错,日志显示在从 Redis 读取旧的 User 数据时发生了反序列化异常。

这篇文章将深入剖析这个问题背后的原因,并提供在实际项目中行之有效的解决方案,无论你使用的是 Jackson 还是 Fastjson。

问题复现

假设我们的系统 V1 版本有这样一个用户类:

java 复制代码
// V1 版本
public class User {
    private String name;
    private int age;
    // ... getters and setters
}

线上 Redis 缓存中存储了大量序列化后的 User 对象,其 JSON 格式如下:

json 复制代码
{
  "name": "Alice",
  "age": 30
}

在 V2 版本中,我们为 User 类增加了一个 address 字段:

java 复制代码
// V2 版本
public class User {
    private String name;
    private int age;
    private String address; // 新增字段
    // ... getters and setters
}

问题来了:当 V2 版本的应用启动后,尝试从 Redis 读取 V1 版本存入的旧数据时,一切正常。但是,如果 V2 版本存入了一条新数据,而 V1 版本的(未下线的)服务尝试读取这条新数据时,就会立刻触发致命错误!

V2 版本存入的数据:

json 复制代码
{
  "name": "Bob",
  "age": 25,
  "address": "123 Main St" // 新增字段
}

V1 版本的服务在读取它时,会抛出类似这样的异常:
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "address" ...

这个错误会中断业务逻辑,如果发生在核心流程上,甚至可能导致服务不可用。

为什么会报错?深入 Jackson 的默认机制

在 Spring Boot 生态中,spring-boot-starter-data-redis 默认推荐使用 GenericJackson2JsonRedisSerializer 作为值的序列化器。它底层依赖于强大的 Jackson 库。

问题的根源在于 Jackson 的一项默认安全特性

DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES

这个特性的默认值是 true。它意味着,当 Jackson 在反序列化一个 JSON 字符串时,如果在 JSON 中发现了目标 Java 类里不存在 的属性,它会认为这是一种潜在的错误或数据污染,并选择立即抛出异常来提醒开发者。

这是一个"严格模式"的设计,旨在确保数据的精确匹配,防止意外的数据注入。但在版本迭代、字段只增不减的场景下,这个特性就成了我们需要解决的"麻烦"。

解决方案:配置你的 RedisTemplate

要解决这个问题,我们不能改变 Redis 中已存在的数据,只能让我们的应用程序变得更加"宽容"和"健壮",能够向后兼容。

核心思路是:创建一个自定义配置的 ObjectMapper,关闭 FAIL_ON_UNKNOWN_PROPERTIES 特性,并将其应用到 RedisTemplate 中。

Spring Boot 配置实例

在你的配置类(如 RedisConfig.java)中,添加如下 Bean:

java 复制代码
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

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

        // --- 核心配置:创建自定义的 Jackson 序列化器 ---
        
        // 1. 创建 ObjectMapper
        ObjectMapper objectMapper = new ObjectMapper();

        // 2. 配置 ObjectMapper:忽略在 JSON 中存在但 Java 对象中没有的属性
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        
        // 3. 注册 Java 8 日期时间模块,处理 LocalDateTime, LocalDate 等类型
        objectMapper.registerModule(new JavaTimeModule());
        
        // 4. 创建 GenericJackson2JsonRedisSerializer
        GenericJackson2JsonRedisSerializer jacksonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);

        // --- 设置 RedisTemplate 的序列化器 ---
        
        // Key 使用 String 序列化器
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // Value 使用我们自定义的 Jackson 序列化器
        template.setValueSerializer(jacksonSerializer);
        template.setHashValueSerializer(jacksonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

配置完成后,重启你的应用。现在,即使应用读取到包含未知字段的 JSON 数据,也不会再抛出异常,而是会优雅地忽略掉这些新字段,只解析它认识的字段。

如果我用的是 Fastjson 呢?

对于使用 Fastjson 的开发者来说,情况恰好相反。Fastjson 默认行为就非常"宽容"。

  • 当 JSON 字段比 Java 对象多时 :Fastjson 默认会忽略 未知字段,不会报错。这正是我们期望的行为。
  • 当 Java 对象字段比 JSON 多时 :和 Jackson 一样,Fastjson 也不会报错 ,缺失的字段会被赋予 null 或 Java 默认值。

下表总结了二者的核心区别:

不匹配情况 Fastjson 默认行为 Jackson 默认行为
JSON 字段 > Java 字段<br>(JSON 中有未知字段) 忽略 未知字段,不报错 抛出异常报错
Java 字段 > JSON 字段<br>(JSON 中缺少字段) 缺失字段赋予默认值不报错 缺失字段赋予默认值不报错

如果你因为某些原因,希望 Fastjson 像 Jackson 一样实行严格模式,可以在解析时传入 Feature.FailOnUnmatchedProperties

⚠️ 安全提醒 :虽然 Fastjson 在此场景下行为友好,但其历史上因 autoType 功能(@type)存在多个严重的安全漏洞。请务必使用最新版本,并绝对不要开启 autoType,除非你完全了解其风险。

简单的验证过程

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
        <version>2.7.15</version> </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.15.2</version> </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>2.15.2</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-annotations</artifactId>
        <version>2.15.2</version>
    </dependency>
    
</dependencies>
java 复制代码
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.io.Serializable;
import java.util.Arrays;

public class JacksonSerializerTest {

    // V1 版本的学生类
    static class StudentV1 implements Serializable {
        private String name;
        private int age;

        // 必须有无参构造函数
        public StudentV1() {}

        public StudentV1(String name, int age) {
            this.name = name;
            this.age = age;
        }
        
        // getters and setters...
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public int getAge() { return age; }
        public void setAge(int age) { this.age = age; }

        @Override
        public String toString() {
            return "StudentV1{" + "name='" + name + '\'' + ", age=" + age + '}';
        }
    }

    // V2 版本的学生类(增加了 address 字段)
    static class StudentV2 implements Serializable {
        private String name;
        private int age;
        private String address; // 新增字段

        public StudentV2() {}
        
        // getters and setters...
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public int getAge() { return age; }
        public void setAge(int age) { this.age = age; }
        public String getAddress() { return address; }
        public void setAddress(String address) { this.address = address; }

        @Override
        public String toString() {
            return "StudentV2{" + "name='" + name + '\'' + ", age=" + age + ", address='" + address + '\'' + '}';
        }
    }


    public static void main(String[] args) {
        // 创建默认的序列化器(FAIL_ON_UNKNOWN_PROPERTIES = true)
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

        // 1. 模拟场景:新版代码(V2)序列化,旧版代码(V1)反序列化
        System.out.println("--- 场景1:JSON字段比Java对象多 (默认会报错) ---");
        StudentV2 newStudent = new StudentV2();
        newStudent.setName("Charlie");
        newStudent.setAge(22);
        newStudent.setAddress("456 Park Ave");

        // 序列化 V2 对象
        byte[] serializedData = serializer.serialize(newStudent);
        System.out.println("V2对象序列化后的JSON: " + new String(serializedData));

        // 尝试用 V1 的类去反序列化
        try {
            StudentV1 oldStudent = (StudentV1) serializer.deserialize(serializedData, StudentV1.class);
            System.out.println("反序列化成功: " + oldStudent);
        } catch (SerializationException e) {
            System.err.println("反序列化失败,符合预期!错误: " + e.getCause().getMessage());
        }

        System.out.println("\n--- 场景2:JSON字段比Java对象少 (默认不报错) ---");
        StudentV1 oldStudent = new StudentV1("David", 35);
        
        // 序列化 V1 对象
        byte[] oldSerializedData = serializer.serialize(oldStudent);
        System.out.println("V1对象序列化后的JSON: " + new String(oldSerializedData));

        // 尝试用 V2 的类去反序列化
        try {
            StudentV2 studentWithNewField = (StudentV2) serializer.deserialize(oldSerializedData, StudentV2.class);
            System.out.println("反序列化成功,符合预期!结果: " + studentWithNewField);
            System.out.println("新增的 address 字段值为: " + studentWithNewField.getAddress());
        } catch (SerializationException e) {
            System.err.println("反序列化失败: " + e.getMessage());
        }
    }
}

结论

在分布式和微服务架构中,保证不同版本服务之间的兼容性至关重要。由于增加字段而导致的反序列化失败是一个常见但容易被忽视的问题。

最佳实践是:

  1. 预见性地配置 :在项目初期就为你的 RedisTemplate 配置一个"宽容模式"的 JSON 序列化器。
  2. 明确序列化策略:团队内应统一 JSON 库的选型和核心配置,避免因默认行为不一致导致问题。
  3. 拥抱兼容性设计:在设计数据模型时,应始终考虑未来的扩展性,尽量做到只增不减,并确保你的应用能够优雅地处理新旧数据格式。

通过上述简单的配置,你就可以让你的应用在版本迭代中更加健壮,从容应对数据结构的变化。

相关推荐
咖啡八杯20 小时前
GoF设计模式——备忘录模式
java·后端·spring·设计模式
vivo互联网技术1 天前
从 10 分钟到 1 秒:ES 深度分页任意跳页的三轮优化实战
服务器·数据库·redis·elasticsearch·深度分页
Flittly2 天前
【AgentScope Java新手村系列】(16)从RAG到多路检索
java·spring boot·spring
咖啡八杯3 天前
GoF设计模式——中介者模式
java·后端·spring·设计模式
用户3074596982074 天前
Redis 延时队列详解
redis
烤代码的吐司君4 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly4 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
leeyi6 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
云技纵横7 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis