Skip to content

这是我学习 Spring Boot 用 Redis 做缓存时整理的笔记。在《Redis 快速入门》里我提到"缓存注解默认用 JDK 序列化会有乱码问题",一句话带过了。这篇把缓存注解的序列化和 TTL 配置讲透:为什么默认序列化是坑、怎么正确配置、注解还有哪些容易踩的坑。


一、Spring Cache 是什么

Spring 把"缓存"抽象成三个注解,屏蔽底层是 Redis、Caffeine 还是其他缓存实现:

注解作用典型场景
@Cacheable先查缓存,命中直接返回,未命中才执行方法并回填缓存查询接口
@CacheEvict删除缓存更新/删除数据时清掉旧缓存
@CachePut执行完方法后强制更新缓存需要"实时"刷新的场景(少用)

最常用的是 @Cacheable + @CacheEvict 组合——就是"Cache Aside 旁路缓存"模式的注解版:读时查缓存、写时删缓存。

java
@Service
public class UserService {

    @Cacheable(cacheNames = "user", key = "#id")
    public User getUser(Long id) {
        return userMapper.selectById(id);
    }

    @CacheEvict(cacheNames = "user", key = "#id")
    public void updateUser(Long id) {
        userMapper.updateById(...);
    }
}

二、序列化:缓存对象是怎么进 Redis 的

关键问题来了:Redis 只能存字节(字符串或二进制)。你要缓存一个 User 对象,就必须先把它"拍扁"成字节流存进去,读的时候再"还原"回来——这个拍扁和还原的过程就是序列化 / 反序列化

Spring 把序列化抽象成一个 RedisSerializer 接口,默认实现是 JdkSerializationRedisSerializer(JDK 序列化)。问题就出在这个默认值上。

三、JDK 序列化的三个坑

1. key 带乱码前缀

JDK 序列化会在数据前面加一串二进制头(Java 序列化魔数 AC ED 00 05)。你以为缓存 key 是 user:1,实际存进 Redis 是 \xac\xed\x00\x05t\x00\x06user:1 这种——keys * 一查全是乱码,跟代码里写的对不上。

2. value 是二进制乱码

value 也一样,get 出来是一串 sr com.demo.User... 的二进制,看不到内容。缓存出了问题,想在 redis-cli 里看看缓存里到底存了啥,结果一片乱码,排查非常痛苦。

3. 要求 Serializable + 兼容性差

被缓存的对象必须实现 java.io.Serializable,忘了就报 NotSerializableException;而且 JDK 序列化跨 JVM 版本、跨系统兼容性差,别的语言(Python/Go)根本读不了这个缓存。

一句话:JDK 序列化是给"Java 自己内部临时用"设计的,不适合当缓存格式。

四、正确配置:JSON + 统一 TTL

生产环境的正确姿势:key 用 String 序列化、value 用 JSON 序列化、统一过期时间。这样 key 干净可读、value 是 JSON 字符串,redis-cli 里一目了然。

java
@Configuration
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            // 1. 统一 TTL:30 分钟过期,防止缓存无限堆积撑爆内存
            .entryTtl(Duration.ofMinutes(30))
            // 2. key 用 String 序列化:去掉 JDK 的乱码前缀
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            // 3. value 用 JSON 序列化:可读、跨语言
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

配置后效果对比:

  • keyuser:1(干净,不再是 \xac\xed... 乱码)
  • value{"id":1,"name":"tom"}(可读的 JSON)

几个说明:

  • TTL 为什么重要:默认 RedisCacheManager 不设过期时间,缓存永不过期,越堆越多,最终把 Redis 内存撑满。统一设一个合理的 TTL(如 30 分钟)是必须的。
  • JSON 序列化的小坑GenericJackson2JsonRedisSerializer 默认会把类型信息(@class)写进 JSON,跨模块反序列化时如果类路径不一致会报错,必要时自定义 ObjectMapper
  • 自定义 key 前缀:如果多个服务共用同一个 Redis,建议再加 .prefixCacheNameWith("xxx:") 给缓存 key 加命名空间,避免不同服务的缓存 key 冲突。

五、注解还有哪些容易踩的坑

1. 注解不生效(同类内部调用)

@Cacheable 是通过 AOP 代理实现的,同类方法内部调用this.getUser(id))不会走代理,注解直接失效:

java
// ❌ 失效:内部调用不经过代理
public User getDetail(Long id) {
    return this.getUser(id);   // getUser 的 @Cacheable 不生效
}

解法:把被缓存的方法放到独立的 Service/Bean 里,让调用经过代理。

2. 缓存 key 冲突

@Cacheable 如果不写 key,默认用"所有参数"拼接生成 key。两个方法参数个数、类型相同但语义不同,就会命中对方的缓存。永远显式写 keykey = "#id"),别依赖默认策略。

3. 缓存穿透,注解层解决不了

@Cacheable 遇到"缓存和数据库都没有"的 key,每次都会穿透到数据库——它不会自动缓存空值。防穿透(缓存空值、布隆过滤器)需要自己在方法里处理,注解只负责"命中回缓存"这一件事。

4. 缓存击穿,注解默认无互斥

热点 key 过期瞬间大量请求同时打库,@Cacheable 默认不会加互斥锁。需要的话配 sync = true@Cacheable(sync = true)),Spring 会对同一 key 的加载加锁,只放一个请求去查库。

小结

  • Spring Cache 三注解:@Cacheable(读缓存)、@CacheEvict(删缓存)、@CachePut(写缓存)
  • 默认 JDK 序列化的三个坑:key 乱码前缀、value 二进制不可读、要求 Serializable 且跨语言差
  • 正确配置三件事:key 用 String 序列化 + value 用 JSON 序列化 + 统一 TTL
  • 注解四个坑:同类内部调用失效、key 不显式写会冲突、穿透要自己处理、击穿配 sync = true

想了解 Redis 本身的数据结构和用法,看《Redis 快速入门》;想了解缓存和数据库的一致性怎么保证(Cache Aside、延迟双删),看《系统架构设计常见问题 FAQ》里的缓存问答。