☰
Redis缓存序列化实战:告别乱码与二进制,手动JSON最佳实践
2026/9/29 11:30:27 网站建设 项目流程

1. 先看一个最常见的翻车现场:取出来全是二进制和怪字符

先说我自己刚接触 Redis 缓存时的经历。项目里用 Spring Boot 整合 Redis,配置了 RedisTemplate,高高兴兴把用户信息set进去,然后get出来一看:\xAC\xED\x00\x05t\x00...这样一串乱码。打开 Redis Desktop Manager 看,key 前面还有一堆看不见的前缀字节,value 完全不可读。问同事,同事说“你要配置序列化器”,我当时一脸懵:Redis 不就应该存字符串吗,为什么还要序列化?

这个事儿其实不是个例。很多人第一次用 Redis 做缓存,都是死在序列化上。而等到你真正理解 Redis 的存储模型,你会发现一个扎心的结论:Redis 本身根本不关心你存的对象长什么样,它只认字节。你存一个字符串进去,底层是字节数组;你存一个 Java 对象进去,底层还是字节数组。谁来把 Java 对象变成字节,谁来把字节变回 Java 对象,这一步就是序列化和反序列化。

那问题就来了:Java 里最常见的“把对象转成可读字符串”的手段就是 JSON,而 JSON 序列化这个动作,如果你不手动处理,框架就会用一套默认方案代替你处理。Spring Data Redis 的默认方案是 JDK 自带的二进制序列化,它确实能“自动处理”,但处理出来的结果又大又丑又不安全,这就是大家在项目里必须手动接管 JSON 序列化的根源。

这篇文章我想把这套东西彻底讲透:Redis 底层到底怎么存数据、几种主流序列化器各自有什么坑、为什么业界普遍推荐把 JSON 序列化这个动作收回到业务代码里、以及怎么配置一个“可读、可控、可排错”的 Redis 缓存方案。内容偏实战,适合刚入坑 Redis 的开发者,也适合已经在用但经常被序列化问题折磨的同行。

2. Redis 不存对象,只存字节:先理解这个底层模型

聊序列化之前,必须先把 Redis 的存储模型讲清楚。很多人把 Redis 的 String 类型理解成“和 Java 的 String 一样的东西”,这是第一步误解。

2.1 String 类型的本质是什么

Redis 的 String 类型,底层是 SDS(Simple Dynamic String),简单说就是一个动态字符串结构。你通过 Redis 命令写入的任何内容,最终都会落到这个结构里。关键点在于:SDS 是二进制安全的,它不管数据是 UTF-8 文本、是 JSON 字符串、还是 Java 序列化后的二进制数据,它只负责把这串字节原样存下来、原样读出去。

这意味着什么?意味着你执行SET user:1 "张三",存的是这串字符的字节编码;如果你执行SET user:1 new User("张三", 20)——当然 Java 的命令客户端不会让你这么写——但框架内部确实会先把这个 User 对象“变成字节”,再交给 Redis。

所以严格来说,“Redis 存 JSON”和“Redis 存对象”在存储层没有区别,区别只在于“对象变字节”用的什么算法。这个算法,就是我们说的序列化方案。

2.2 Redis 为什么不像 MySQL 那样天然“懂类型”

MySQL 建表时要定字段类型,int 就是 int,varchar 就是 varchar,存储引擎知道每个字段的长度和格式。Redis 不是这样,它更像一个巨大的Map<byte[], byte[]>。KV 本身没有表结构、没有 schema、没有字段类型约束。你放进来的字节,Redis 不解释、不校验、不转换,它只负责存。

这个设计让 Redis 拥有极高的性能——不需要解析协议之外的任何格式,但是代价就是:“解释字节”这件事,完全甩给了客户端。你用 Java 客户端,就得 Java 这边负责解释;你用 Python 客户端,就得 Python 这边负责解释。两边如果解释的方式不一样,就会出现乱码、二进制、不可读。

这就是为什么“序列化方案选择”在 Redis 项目中是如此核心的架构决策:它决定了 Redis 里那堆字节,是能被所有人(包括运维、前端、同事)直接看懂,还是只有特定语言的特定类才能看懂。

2.3 顺带解释一下“缓存穿透、缓存击穿、缓存雪崩”和序列化的关系

你可能在面试题里看到过“Redis 缓存穿透、击穿、雪崩”,它和序列化有什么关系?坦白说关系不大,但它属于同一个知识域——缓存治理。序列化是做缓存时的第一道坎,而穿透、击穿、雪崩是缓存设计层面的第二道坎。实际项目里,我见过很多团队为了解决 JK 序列化的乱码问题,把缓存逻辑重写了一遍,然后顺带把缓存 key 的设计也优化了。所以建议你在理解序列化之后,再去关注缓存穿透这类问题,否则你连缓存里的数据都读不出来,谈什么穿透治理呢。

3. 主流序列化方案横评:没有银弹,只有权衡

在 Spring Data Redis 里,默认的序列化器是JdkSerializationRedisSerializer。这就是乱码的根源之一。但市面上还有 Jackson、Fastjson2、Gson、String、Kryo、Protobuf 等一堆选择,它们各自适配不同场景。我先把这个横评给你讲透,然后你就知道为什么“手动 JSON”是目前 Java 生态里最务实的一条路。

3.1 JdkSerializationRedisSerializer:默认值,也是一号坑位

Jdk 自带的序列化就是把对象转成 Java 特有的二进制格式。它的特征:开头是AC ED 00 05(著名的魔数),然后是类名、字段名、类描述信息、继承结构、serialVersionUID 等等。

这个方案有几个硬伤:

  • 体积大。一个只有几个字段的小对象,序列化出来可能几百字节,因为类名、字段签名、描述符全被写进去了。缓存数据大,内存成本就高,网络 IO 也大。
  • 不可读。redis-cli 里看 value 全是一堆二进制,线上排查问题极其痛苦。
  • 强依赖 Java 类结构。你加了字段、改了字段名、升级了 jar 包,老数据很可能反序列化失败,抛InvalidClassException。
  • 安全性差。Java 原生反序列化是公认的高危入口,一旦接口能让外部控制序列化字节流,就可能被攻击利用。业内对 Java 原生反序列化都是能不用就不用。

那为什么 Spring Data Redis 默认用它?因为它是 JDK 内置能力,零依赖、开箱即用,作为框架的“默认兜底”足够省事。但生产环境用它做缓存,基本属于自己给自己埋雷。

3.2 GenericJackson2JsonRedisSerializer:省事,但引入了类型信息

这套方案是 Spring Data Redis 提供的 Jackson 封装,它会把 Java 对象序列化成 JSON 字符串,但会在 JSON 里额外写入一个@class字段,记录全类名。比如:

{ "@class": "com.example.User", "name": "张三", "age": 20 }

写入@class的目的是:反序列化时,Jackson 能根据这个类型标记,直接把 JSON 还原成原来的User对象,不需要你手动指定类型参数。这确实解决了“取出来是 LinkedHashMap 转不回去”的问题,使用起来很省心。

但它有几个新问题:

  1. 缓存里全是@class类型信息,这是冗余数据,存储多一份、序列化字符串更长;
  2. 暴露了完整类路径,如果你对网络安全敏感,这种设计相当于把内部结构写在缓存里;
  3. 一旦你不知道为什么在 Jackson 全局配置里开了默认类型(default typing),再配合一些自动反序列化的入口,会引入反序列化安全风险。这个问题后面我会单独聊;
  4. 跨语言访问不方便。如果有一天你用 Python 脚本去读这个 Redis 里的数据,你得先解析并忽略@class字段。

3.3 StringRedisSerializer:只处理 String 的“残缺方案”

StringRedisSerializer只声明“我会把 String 和字节互转”,其他类型它一概不接。Spring 里有个现成的StringRedisTemplate,key 和 value 默认都是这个序列化器,所以用它存字符串很舒服。

但它的局限是:如果你直接redisTemplate.opsForValue().set("user", user),编译器不会报错,运行期会抛异常——因为它不认 User 这个类型。所以你必须先自己把 User 转换成 String(比如 JSON 字符串),再交给它。这看起来是麻烦,但实际上就是我们要的“手动处理 JSON 序列化”的思路。

3.4 Fastjson2、Gson、Kryo、Protobuf:各有所长,场景为先

Fastjson2 的序列化速度在 Java JSON 库里常年排在前列,而且 API 顺手,问世时主打高性能。但如果你是做安全敏感的业务,必须关注它的版本更新和漏洞通告,Fastjson 系列历史上出过不少反序列化安全问题。我的建议是:能用后续稳定版本就用稳定版,不要使用来路不明的老版本;业务对性能没有极致要求时,Jackson 是更稳妥的默认选择。

Kryo、Protobuf 这类二进制序列化的体积和性能都很好,适合高并发、数据量大的内部 RPC 或缓存。但它们的代价是可读性差——redis-cli 里看 buffer 同样是一堆二进制,想要定位线上数据问题时非常被动,而且 Protobuf 还得维护.proto文件,团队协作成本不低。我见过有的高并发中台确实用二进制序列化,但那是建立在“缓存不可读可接受 + 团队有完善的监控大盘”的前提下。

3.5 序列化器选型速查表

序列化器可读性存储体积跨语言安全风险注意点适用场景
JdkSerialization极差大差高风险,谨慎使用仅测试或内部极短生命周期数据
GenericJackson2Json好,但有@class中一般默认类型需配置白名单快速开发期,团队对 Redis 可读性有要求
Jackson2Json(不启default typing)好较小好较低生产环境推荐方案
Fastjson2好较小好版本安全需持续关注对性能有要求且能保证版本管理
Kryo / Protobuf差小差较低内部高并发、监控完善的大规模场景
String好小好无适合与业务代码手动序列化搭配

从这张表能看出一个趋势:可读性和跨语言友好度,是生产环境绕不开的两个指标。除非你团队规模和技术栈完全锁死 Java 且永远不查缓存,否则 JSON 字符串是性价比最高的方案。

4. 动手配置:把 JSON 序列化主动权拿回到业务层

讲完了方案,直接上实操。我给出一套我在生产环境验证过的配置:Redis 缓存里只存可读的 JSON 字符串,对象和 JSON 之间的转换全部在业务层手动完成,RedisTemplate 只负责读写 String。

4.1 基础依赖和版本说明

这里以 Spring Boot 3.x + spring-data-redis 3.2 为例,核心依赖就一个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

Jackson 相关的依赖直接用spring-boot-starter-json(Boot 一般已经引入),不用额外加。

4.2 配置一个“纯 String”的 RedisTemplate

我在配置类里明确指定 key 和 value 都用StringRedisSerializer,并单独命名一个 bean,避免覆盖 Spring 默认的 RedisTemplate 行为:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, String> stringRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // key 和 value 都使用 String 序列化器 StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }

注意几个细节:

  • setKeySerializer、setValueSerializer这两行决定缓存 key 和 value 的字节格式。如果 key 不用 String 序列化器,key 前面会出现一堆二进制前缀,这就是前面说的“key 乱码”问题。
  • Hash 的 key 和 value 序列化器也要一起设置。很多人只配了 SetKeySerializer 和 SetValueSerializer,hash 操作还是走默认JDK序列化,结果opsForHash().put("h", "field", json)存出来的 field 还是乱码。
  • 用RedisTemplate<String, String>类型,等于在编译期就约束了“我只接受字符串”。这比用RedisTemplate<Object, Object>然后靠运行期检查要安全得多。

4.3 Service 层封装:手动 JSON 序列化怎么写

配置好了 RedisTemplate,下面就是在业务层手动处理 JSON 序列化。核心思路是:写的时候把对象writeValueAsString成 JSON,读的时候把 JSONreadValue还原成对象。

@Service public class CacheService { private final ObjectMapper objectMapper; private final RedisTemplate<String, String> redisTemplate; public CacheService(ObjectMapper objectMapper, RedisTemplate<String, String> redisTemplate) { this.objectMapper = objectMapper; this.redisTemplate = redisTemplate; } public void setObject(String key, Object value, long timeout, TimeUnit unit) { try { String json = objectMapper.writeValueAsString(value); redisTemplate.opsForValue().set(key, json, timeout, unit); } catch (JsonProcessingException e) { // 生产环境建议打印日志并抛出业务异常 throw new RuntimeException("JSON序列化失败, key=" + key, e); } } public <T> T getObject(String key, TypeReference<T> typeReference) { String json = redisTemplate.opsForValue().get(key); if (json == null) { return null; } try { return objectMapper.readValue(json, typeReference); } catch (JsonProcessingException e) { // 生产环境要做降级处理,注意这里面的坑,后面会细说 throw new RuntimeException("JSON反序列化失败, key=" + key, e); } } public void delete(String key) { redisTemplate.delete(key); } }

业务方的调用长这样:

User user = new User(); user.setId(1L); user.setName("张三"); cacheService.setObject("user:1", user, 30, TimeUnit.MINUTES); TypeReference<User> type = new TypeReference<User>() {}; User cached = cacheService.getObject("user:1", type);

这里最容易被忽略的,是TypeReference这个类型参数。如果你偷懒直接写成:

User cached = objectMapper.readValue(json, User.class);

当你的 Redis 存的是List<User>这种东西时就会出问题。这个点坑了非常多人,我们放到第 5 章专门讲。

4.4 ObjectMapper 的一站式配置

Jackson 的 ObjectMapper 不是拿来就能用得很顺手的,到了生产环境,建议把常用配置集中处理:时间格式、空值处理、未知字段容错等。

@Configuration public class JacksonConfig { public static ObjectMapper buildObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setTimeZone(TimeZone.getTimeZone("GMT+8")); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return mapper; } @Bean public ObjectMapper objectMapper() { return buildObjectMapper(); } }

每一项配置的意义:

  • JavaTimeModule是 Java 8 时间类型(LocalDateTime、LocalDate)的反序列化依赖模块,不注册的话,很多版本会直接报错或者序列化成奇怪的数组;
  • WRITE_DATES_AS_TIMESTAMPS关闭后,LocalDateTime 会输出yyyy-MM-dd HH:mm:ss可读格式,而不是一长串毫秒时间戳;
  • FAIL_ON_UNKNOWN_PROPERTIES关掉后,如果老数据里有多余字段,新代码反序列化不会直接炸掉,这对缓存兼容很关键;
  • setSerializationInclusion(NON_NULL)让序列化结果里不输出 null 字段,减少缓存体积。但要注意:你要是用“是否需要字段是否存在”来判断业务逻辑,这会改变行为,需要视情况使用。

4.5 为什么我不推荐直接配置一个 JSON 类型的 RedisTemplate

你可能会问:我不手动转 JSON 行不行?我就设置GenericJackson2JsonRedisSerializer作为 value 序列化器,然后直接redisTemplate.opsForValue().set("user", user)。确实能跑通,而且代码更短。但我在生产里踩过它的坑之后,现在还是倾向于手动 JSON 方案,核心原因有三个。

第一个是存储冗余。Generic 方案在 JSON 里写@class字段,每个 value 都多出一截全类名字符串。以 User 对象为例,多出"@class":"com.xxx.xxx.User"这 20 多个字符,如果你缓存的是千万级别的 key,这是非常可观的内存浪费。

第二个是排错体验。手动 JSON 方案里,redis 里存的就是一个纯 JSON 字符串,运维拿到 key 就能看懂内容。Generic 方案里,多一个@class字段虽然也能看,但总有一种“这不是我写的数据”的别扭感,尤其当你用 Python、Go 脚本去读数据做清洗时,还要额外处理这个字段。

第三个是安全性。手动 JSON 方案里,反序列化类型完全由业务代码控制,不会出现“框架根据缓存里的类型标识自动还原成任意类”的情况。Generic 方案再加上不严谨的配置,会变成反序列化攻击的高发入口。

4.6 一个小小的对比实验

我自己做过一个内存对比实验,同样是 100 万个 User 对象写入 Redis:

  • JDK 序列化后 value 平均 300+ 字节;
  • JSON 字符串(不含类型标识)平均 120 字节;
  • GenericJackson2Json 平均 140 字节左右。

虽然单个差距不大,但量级上来后差异感人。更不用说 JDK 序列化出来的二进制在排查、迁移、跨语言读取上有多痛苦。所以“手动 JSON”不是技术洁癖,而是为了让缓存系统更透明、更可控。

5. 核心细节深挖:泛型擦除、日期问题与反序列化安全

手动 JSON 方案会让存储变得可读,但这不意味着反序列化就万无一失。接下来是几个非常容易踩的细节,我在代码审阅里经常见到,一次给你说清楚。

5.1 为什么从 Redis 取出来总是 LinkedHashMap

这是 Redis + Jackson 最经典的坑。你往缓存里放了一个List<User>,然后用一个接受List.class的方式把它读出来,结果发现强制转换成List<User>时抛了ClassCastException,或者你打印列表元素,发现元素类型是LinkedHashMap,不是User。

原因在于 Java 泛型的类型擦除。List.class不携带泛型参数,Jackson 在反序列化时只知道目标是 List,不知道元素类型,于是它用自己内部的LinkedHashMap来装每个元素。这不是 Redis 的问题,是 Jackson 的默认行为。

解决办法就是用TypeReference显式传递泛型信息:

TypeReference<List<User>> typeReference = new TypeReference<List<User>>() {}; List<User> userList = cacheService.getObject("user:list", typeReference);

TypeReference在编译期间通过匿名内部类的方式把泛型类型保留下来,Jackson 就能拿到完整的List<User>类型,正确还原元素对象。

这个坑同样适用于Map<String, User>、List<Map<String, Object>>等嵌套泛型场景。我见过好几个同事踩完这个坑后的第一反应是“改成 JSON 字符串解决了”,其实不是改 JSON 的问题,是反序列化时没带类型信息。你现在手动 JSON,反而必须搞明白 TypeReference,这算是一份买一送一的必修功课。

5.2 日期反序列化:LocalDateTime 的“灾难现场”

Jackson 处理最麻烦的两个类型:Date 和 LocalDateTime。如果你不是用我们上面统一的 ObjectMapper 配置,LocalDateTime 序列化出来的东西可能是一个嵌套数组,里面是年、月、日、时、分、秒的数字,完全不可读。

假设你没注册JavaTimeModule,直接往 Redis 里塞了一个带 LocalDateTime 的订单对象,Value 里长这样:

{ "createTime": [2026, 1, 12, 14, 30, 25] }

看着就头大。但更头疼的是反序列化:LocalDateTime 没有默认构造函数,报错信息经常是InvalidDefinitionException: Cannot construct instance of java.time.LocalDateTime。

正确的做法是我们刚才统一配置的:

mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

这样序列化结果是:

{ "createTime": "2026-01-12 14:30:25" }

可读性好了,反序列化也稳定。如果你还想统一格式,可以针对 LocalDateTime 自定义serializer/deserializer。但尽量别在缓存 JSON 里混用不同日期格式,否则排查时很崩溃。

5.3 反序列化安全:不要开“默认类型”这个潘多拉魔盒

前面提到过反序列化安全问题,这里单独展开说。Jackson 有一个“默认类型(Default Typing)”的开关,开启后,序列化 JSON 时会把类名写进 JSON,反序列化时根据这个类名动态创建对象。GenericJackson2JsonRedisSerializer 默认就是干这个的。

问题在于:如果这个开关应用在“不可信输入”上,攻击者可以构造一个 JSON,@class指向一个危险的 gadget 类,诱导 Jackson 在反序列化时执行恶意逻辑。类似的问题 Fastjson 也出过不少,这也是为什么安全圈反复强调反序列化攻击的严重性。

规避思路并不复杂:

  1. 缓存数据只存 JSON 字符串,不要往 JSON 里写@class类型标识,也就是别开默认类型;
  2. 反序列化类型由业务代码的 TypeReference 或 Class 参数控制,不要从数据里拿类型;
  3. 如果你无法避免使用带默认类型的反序列化方案,必须配置白名单,只允许业务包下的类参与反序列化;
  4. 无论用哪个 JSON 库,都要跟进安全版本。这不是一句口号,是真事。

5.4 手动 JSON 序列化的局限和补救

手动 JSON 方案当然也不是银弹,它有一个天然短板:缓存数据类型和 Java 类型之间的映射,完全靠代码维护。项目大了之后,一个 key 可能被多个服务读写,如果一个服务写入时字段名是userName,另一个服务读取时字段名是name,那数据就全部对不上了。

我的补救方式是:为缓存的 value 单独定义 DTO 对象,用@JsonProperty显式声明字段名,同时写单元测试保证序列化和反序列化是可逆的。等哪天团队规模大了,再用 Apifox、OpenAPI 这类工具把缓存接口固化下来,让前端和其他部门也知道“这个缓存里到底存了什么”。

6. 从零动手:一个完整的用户信息缓存实战

理论聊了这么多,我把一个常见业务场景串起来做一遍:用户信息缓存。从 service 到 controller 全流程展示,帮你建立“手动 JSON 序列化 + Redis 缓存”的整体观感。

6.1 定义一个用户 DTO

public class User implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String name; private Integer age; private LocalDateTime createTime; // getter/setter 省略 }

这里的serializableVersionUID其实是 JDK 序列化用的,我用 JSON 方案后并不依赖它,但保留它没坏处,万一某个环节还得用 JDK 序列化,不至于升级版本后直接崩。

6.2 Service 层:先查缓存,再查数据库

@Service public class UserService { private final CacheService cacheService; private final UserMapper userMapper; public User getUserById(Long id) { String key = "user:info:" + id; TypeReference<User> typeReference = new TypeReference<User>() {}; User cachedUser = cacheService.getObject(key, typeReference); if (cachedUser != null) { return cachedUser; } User user = userMapper.selectById(id); if (user != null) { cacheService.setObject(key, user, 30, TimeUnit.MINUTES); } return user; } public void updateUser(User user) { userMapper.updateById(user); String key = "user:info:" + user.getId(); cacheService.delete(key); } }

两个容易忽略的点:

  • updateUser里要主动删缓存,而不是直接覆盖写。因为你可能只更新了 user 表的一列,而缓存里还有关联数据(比如角色列表),直接覆盖写入容易留下脏数据。先删缓存,下次读时再回源,是最稳妥的做法。
  • setObject时要考虑缓存穿透场景。如果数据库查不到,缓存里就不会有值,大量恶意请求会直接打到数据库。业界常见的做法是缓存空值并设置较短的过期时间,或者用布隆过滤器拦截。这个和序列化无关,但属于缓存链路必备知识。

6.3 回源停顿与缓存击穿的小建议

如果你的业务里,某个热 key 过期后瞬间有大量请求打进来,全部落到数据库,这就是缓存击穿。手动 JSON 序列化不解决这类问题,它只解决“怎么让缓存数据安全可读”。实际项目中,我见到的做法是加分布式锁(Redisson),让回源逻辑只允许一个线程去查库,其他线程等锁后直接读新缓存。

要注意的是,锁的粒度和缓存 key 绑定,别在整个 service 上锁,否则并发效率全没了。伪代码:

public User getUserByIdWithLock(Long id) { String key = "user:info:" + id; User cached = cacheService.getObject(key, new TypeReference<User>() {}); if (cached != null) { return cached; } String lockKey = "lock:user:info:" + id; boolean locked = lockService.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { // 没拿到锁,短暂自旋或直接返回降级结果 return fallback(id); } try { // 双重检查,避免拿到锁后重复查库 User cachedAgain = cacheService.getObject(key, new TypeReference<User>() {}); if (cachedAgain != null) { return cachedAgain; } User user = userMapper.selectById(id); if (user != null) { cacheService.setObject(key, user, 30, TimeUnit.MINUTES); } return user; } finally { lockService.unlock(lockKey); } }

这段代码比普通教科书里的例子多了“双重检查”,实际项目里非常有效,能大幅减少重复回源。

6.4 缓存 key 设计的一些习惯

再补一个实用的小细节:手动 JSON 序列化后,redis 里的 key 和 value 都可读了,但你依然要设计好 key 的命名空间。我喜欢用业务模块作为前缀,中间用冒号隔开,比如user:info:1、order:detail:10086、sys:config:1。Redis Desktop Manager 里会自动按冒号分组,看着层次分明。

另外,value 是 JSON 字符串时,我习惯在 JSON 里加一个@version字段或者使用字段名版本化(如userName变name就新建 key),方便后续字段升级时做迁移。这个在缓存治理里叫“数据版本控制”,手动 JSON 方案做起来非常顺,因为数据本身就是文本。

7. 常见问题排查与避坑速查表

这部分是实际运维中最常用的东西,我把它整理成一张速查表,再补充一些排查经验。

现象根因解法
key 出现\xAC\xED前缀key 的序列化器是 JDK 默认设置 keySerializer 为 String
value 全是二进制不可读value 没配 JSON 序列化,或用了 JDK 序列化配置 StringRedisSerializer,手动存 JSON 字符串
取出的 List 元素是 LinkedHashMap泛型擦除,反序列化时没有类型信息使用 TypeReference
反序列化报 LocalDateTime 相关异常缺少 JavaTimeModule 或时间戳配置错误注册 JavaTimeModule 并关闭 WRITE_DATES_AS_TIMESTAMPS
老版本数据读出来字段缺失或报错类结构变更、字段重命名、删除字段升级缓存版本号或写兼容逻辑
redis-cli 查 value 前面多出长度前缀协议层面数据其实是 bytes,redis-cli 正常显示加--raw参数查询
某字段为 null 导致序列化结果体积大没配置 NON_NULL资源配置 Inclusion.NON_NULL

排查经验第一条:永远先用 redis-cli 手动 get 一下。很多人遇到乱码第一反应是去改代码,其实用 redis-cli 能立刻判断是存储层问题还是客户端显示问题。如果 redis-cli 看到的是正常 JSON,说明字节本身没问题,八成是客户端工具显示问题或者代码里反序列化类型不对。

第二条:不要一次性把所有缓存都换成 JSON 方案,然后直接上线。缓存是最怕“一刀切”的,最好按 key 维度灰度迁移,上线前写个扫描任务,统计老数据的比例;迁移后用旧 key + 新 key 双读双写过渡几天。我见过有团队一把梭,上线后老缓存全读不出来,数据库瞬间被打挂,这就是把缓存升级做成了事故。

第三条:序列化失败时,日志里一定要带上完整的 key 和原始 value。你排查问题时最绝望的瞬间就是日志只告诉你“反序列化失败”,却不知道是哪个 key、哪个 value。我的习惯是在catch里把 key 和 value 的前 500 个字符都打出来,虽然有人觉得日志太长,但关键时刻能救命。

第四条:定时清理无效缓存。手动 JSON 方案里 key 和 value 都可读,但也意味着垃圾数据一眼就能看出来,比如某个 list 的 key 下面残留了过期 key。可以定期扫一遍,把长期不访问且不在业务白名单里的 key 清理掉,给 Redis 内存减负。这个动作在 Redis 侧可以做,也可以用脚本定时处理。

8. 最后聊一点我个人的体会

这套“手动 JSON 序列化”方案,本质上不是选了个更麻烦的技术方案,而是选了“把主动权握在自己手里”的架构思路。Redis 底层只存字节,框架的默认序列化器虽然能省一行代码,却把数据格式的黑盒留给了你;手动 JSON 多写了几行代码,但换来的是缓存完全透明、可读、可排查、可跨语言。我在多个项目里切换到这个方案后,线上排查缓存问题的速度提升不是一点点——以前自己是“盲人摸象”,现在直接看 JSON 就能定位是数据问题、类型问题还是过期问题。

最后再分享一个小技巧:如果你的团队刚开始做这件事,可以先定一个约束——所有走 Redis 缓存的对象,都必须有对应的 DTO 类,并且配套序列化/反序列化的单元测试。测试里写清楚“存进去的 JSON 长什么样,读出来之后能不能精确还原”。这一步看起来麻烦,但能拦住后面一大半的乱码问题和类型转换问题。缓存这东西,线上出了故障往往都是静默发生的,还不如一开始就把格式定死、测试补齐,让问题暴露在开发阶段。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询