这是我学习 Spring Boot 用 Redis 做缓存时整理的笔记。在《Redis 快速入门》里我提到"缓存注解默认用 JDK 序列化会有乱码问题",一句话带过了。这篇把缓存注解的序列化和 TTL 配置讲透:为什么默认序列化是坑、怎么正确配置、注解还有哪些容易踩的坑。
一、Spring Cache 是什么
Spring 把"缓存"抽象成三个注解,屏蔽底层是 Redis、Caffeine 还是其他缓存实现:
| 注解 | 作用 | 典型场景 |
|---|---|---|
@Cacheable | 先查缓存,命中直接返回,未命中才执行方法并回填缓存 | 查询接口 |
@CacheEvict | 删除缓存 | 更新/删除数据时清掉旧缓存 |
@CachePut | 执行完方法后强制更新缓存 | 需要"实时"刷新的场景(少用) |
最常用的是 @Cacheable + @CacheEvict 组合——就是"Cache Aside 旁路缓存"模式的注解版:读时查缓存、写时删缓存。
@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 里一目了然。
@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();
}
}配置后效果对比:
- key:
user: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))不会走代理,注解直接失效:
// ❌ 失效:内部调用不经过代理
public User getDetail(Long id) {
return this.getUser(id); // getUser 的 @Cacheable 不生效
}解法:把被缓存的方法放到独立的 Service/Bean 里,让调用经过代理。
2. 缓存 key 冲突
@Cacheable 如果不写 key,默认用"所有参数"拼接生成 key。两个方法参数个数、类型相同但语义不同,就会命中对方的缓存。永远显式写 key(key = "#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》里的缓存问答。
