做后端开发的人,迟早会跟缓存打交道。“第 4-4 课:Spring Boot 中使用 Cache 缓存的使用”讲的就是 Spring 官方这套缓存抽象。说直白点,Spring Cache 就是给那些读多写少、短时间不变化的数据找个更快的存放地点,让高并发请求别一批批砸进数据库。它底层可以接 ConcurrentMap、Caffeine、Redis,甚至随便你自己实现的 CacheManager,业务代码只需要写注解。这篇文章就用一个可以实际运行的例子,把 @Cacheable、@CachePut、@CacheEvict 三个注解怎么用、Redis 缓存怎么配、生产环境里常见的坑怎么排查,从头到尾完整过一遍。对 Spring Cache 只有模糊概念的新手,以及已经在用 Redis 但想少写点缓存代码的同学,都能直接照着做。
1. 缓存选型与设计思路:先想清楚再动手
1.1 缓存到底解决什么问题
我在的某公司之前有个商品详情服务,单机 QPS 上去之后,数据库连接池先扛不住了,老是被打满,接口 P99 从 20ms 一路涨到 300ms。后来把详情数据按“商品 ID”做了缓存,读请求大部分打到 Redis,数据库 QPS 降到了原来的十分之一不到,接口 P99 也回到了 40ms 以内。这就是缓存最典型的使用场景:数据读的频率远高于写的频率,而且短时间内的变化不敏感。
缓存的核心价值,就是把“从数据库算出来”变成“从内存/Redis 里直接拿”。数据库擅长持久化和复杂查询,但面对同一份热点数据的反复读取,它一点都不划算。磁盘 IO 再快也有限度,连接数也有限度。而 Redis 这样的缓存组件,延迟通常在一毫秒以内,还能扛住每秒好几万的读请求。
不过得先说清楚:缓存不是银弹。适合放缓存的数据要满足两个条件。第一,读多写少,如果数据每秒被更新几百次,那缓存的意义就很有限了;第二,能接受短暂的不一致,缓存里存的是某个时间点的快照,和数据库之间一定存在微小的延迟窗口。做架构设计时,先把这两个前提聊明白,再动手加注解,不然最后就是给自己挖坑。
1.2 为什么选择 Spring Cache 抽象而不是手写 RedisTemplate
很多同学一开始接触缓存,都是用 RedisTemplate 直接在 Service 里写代码:先查缓存,没有就去查库,查完塞回 Redis,顺手设个 TTL。这套写法没错,但它有问题。第一,每次要写同样 get/set 的模板代码;第二,业务逻辑里混着缓存细节,读起来很累;第三,一旦以后想把 Redis 换成 Caffeine,Service 里的代码几乎要改一遍。
Spring Cache 做的事情,是把缓存行为抽取成声明式注解。业务方法还是写业务逻辑,缓存存哪、TTL 多久、key 怎么拼,全部交给 CacheManager 和注解参数来管理。数据库查询和缓存操作被分离开,模块边界特别清晰。底层实现切换时,业务代码基本不用动——只需要换掉 CacheManager 的 Bean 和依赖。
还有一层考量是测试。手写缓存逻辑,单元测试得 mock Redis;用 Spring Cache,测试时可以直接把底层 CacheManager 换成 ConcurrentMapCacheManager,过程很轻量。这在实际项目里特别好用,跑集成测试根本不需要起一个真实的 Redis 实例。
1.3 什么时候不适合用 Spring Cache
Spring Cache 也有它的边界,别硬套。如果你需要操作 Redis 原生的数据结构,比如 Hash、List、ZSet,那就别指望注解能帮你完成。注解缓存本质上是 value 整体读写,存进去一个对象,取出来一个对象,不适合把对象里的某个字段单独塞进一个 Hash 里。
再比如缓存逻辑特别复杂的时候,像是“先缓存不存在就查库,查完后要按返回结果动态决定缓存多久”,这种条件逻辑用注解虽然也能做,但会让 SpEL 表达式变得难读。我更建议直接用 RedisTemplate 自己控制。判断标准很简单:缓存操作是不是足够独立、足够简单?是,就用注解;不是,就老老实实手写。
1.4 把缓存经典三问题先摆出来
开始写代码之前,建议先把缓存世界的三个经典问题记住:穿透、击穿、雪崩。穿透是查询了一个不存在的 key,每次都要打到数据库;击穿是某个热点 key 失效的一瞬间,大量请求同时去打数据库;雪崩是大量 key 在同一时间段集体失效,数据库压力瞬间上涌。这三个问题并不是危言耸听,生产环境的缓存事故基本都绕不开它们。后面第 4 章会给出对应的解法,这一章先有个概念。
2. 三个核心注解逐个拆解
Spring Cache 注解从使用频率上讲,就三个主力:@Cacheable、@CachePut、@CacheEvict。搞清楚它们各自的触发时机,缓存这块地基就算打牢了。
2.1 @Cacheable:读操作的主力
@Cacheable 的作用是:方法调用前先检查缓存,命中就直接返回缓存结果,不再执行方法体;没命中,才执行方法,并把返回值按 key 存进缓存。这是缓存最常用的语义。
一个最基本的写法:
@Cacheable(cacheNames = "user", key = "#id") public User getById(Long id) { return userMapper.selectById(id); }cacheNames 是缓存分区名,你可以把它理解成一个大储物柜的柜区编号;key 是分区内的具体钥匙号。在 Redis 里,这个 key 实际会存成类似user::123的样子,双冒号是默认分隔符。
key 支持 Spring 自带的 SpEL 表达式。上面#id就是取参数 id 的值。如果方法有多个参数,可以组合,比如"#type + ':' + #id"。我强烈建议每个缓存方法都显式写 key,原因后面第四章会讲——默认 key 生成规则在参数多了之后很容易让人懵。
@Cacheable 还有几个常用参数需要注意:
- condition:在方法调用之前判断,条件成立才走缓存逻辑。比如
condition = "#id > 0",id 非法时根本不查缓存。 - unless:在方法调用之后判断,基于返回值做二次拦截。比如
unless = "#result == null",表示查询结果为空时不缓存。 - sync:是否对缓存的加载过程加锁。具体内容在第 4 章击穿部分展开。
这里有个容易踩的点:condition 里不能引用#result,因为方法还没执行;unless 里才可以。很多新手在这里搞反,导致表达式解析报错或者缓存逻辑不按预期走。
2.2 @CachePut:数据更新后同步缓存
@CachePut 和 @Cacheable 长得很像,但语义完全不同:它无论如何都会执行方法体,然后用方法的返回值更新缓存。所以它适合用在更新操作的场景里。
@CachePut(cacheNames = "user", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); return user; }这里有个非常关键的设计选择:当数据库更新成功后,到底是“更新缓存”还是“删除缓存”?业界普遍更推荐“先更新数据库,再删除缓存”,而不是直接更新缓存。原因有两个:第一,更新缓存需要保证写入的值和数据库精确一致,但如果后续还有其他字段被更新,之前写进去的缓存就成了脏数据;第二,并发场景下,两个线程同时更新数据库再更新缓存,可能出现后写数据库的线程先写缓存,导致缓存里存了旧值。删除缓存则简单得多,下次读取时重新加载,天然避免了覆盖竞争。这个策略后面第 4.4 节会再展开。
2.3 @CacheEvict:让缓存失效
@CacheEvict 用来删除缓存,最常见的就是删除和更新操作后面附带调用。
@CacheEvict(cacheNames = "user", key = "#id") public void deleteUser(Long id) { userMapper.deleteById(id); }它有两个参数在实战里很有用。一个是 allEntries,设置为 true 时,清空整个 cacheNames 分区下所有 key。比如用户所有角色信息都挂在role分区下,角色变更时一次性清空比一个个删更省事。另一个是 beforeInvocation,默认是 false,表示方法执行成功后才删除缓存;改成 true 后,会在方法执行之前就删。
beforeInvocation 的选择有点讲究。默认 false 更安全,因为方法如果执行失败,缓存不会被误删。但某些业务场景里,方法本身会抛异常,而缓存里的数据其实已经和数据库不一致了,这时候就需要“无论成功失败都删一次缓存”,就可以设成 true。我把这个参数理解为“宁可错删,不可留脏”。
2.4 一个完整的查询-更新-删除示例
把三个注解拼到同一套业务逻辑里,看起来是这样:
@Service public class UserService { @Resource private UserMapper userMapper; @Cacheable(cacheNames = "user", key = "#id", sync = true) public User getById(Long id) { // 第一次访问会走到这里,后续命中缓存不会打印 System.out.println("从数据库加载用户:" + id); return userMapper.selectById(id); } @CachePut(cacheNames = "user", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); return user; } @CacheEvict(cacheNames = "user", key = "#id") public void deleteUser(Long id) { userMapper.deleteById(id); } }这里演示的是最常见的模式:读走 @Cacheable,改走 @CachePut,删走 @CacheEvict。注意 updateUser 用 @CachePut 还是 delete+@Cacheable 的组合,取决于业务。如果更新操作返回的 User 对象结构完整,@CachePut 可以省一次后续的数据库查询;如果拿不准返回值可靠性,我倾向先 @CacheEvict 删掉缓存,下次读取再加载。
2.5 容易被忽略的代理调用问题
这是 Spring Cache 新手必踩的坑之一。Spring Cache 基于 AOP 实现,本质上是给你的 Bean 创建了一个代理对象,缓存逻辑都在代理里。当你在同一个类内部调用自身方法时,调用的是 this 本身的方法,而不是代理对象的方法,注解自然就不生效。
@Service public class UserService { public User getByIdWithCache(Long id) { // 这里调用的是 this.getById(),注解不生效! return this.getById(id); } @Cacheable(cacheNames = "user", key = "#id") public User getById(Long id) { return userMapper.selectById(id); } }解决办法有这么几种:一是把带缓存注解的方法拆到独立的另一个 Bean 里,再注入进来调用;二是用AopContext.currentProxy()来获取当前代理;三是自注入,也就是注入UserService自己的代理对象。我个人最推荐第一种,把缓存逻辑和内部调用逻辑物理隔离,代码和代理语义都更清晰。
3. Redis 作为底层缓存:从依赖到生产可用配置
前面第 2 章的代码,只要引入任意一个 CacheManager 都能跑。但生产环境里最常用的还是 Redis,这一章讲怎么把 Spring Cache 和 Redis 真正接起来。
3.1 引入依赖
在 Maven 的 pom.xml 里添加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>spring-boot-starter-cache 提供 Spring Cache 抽象;spring-boot-starter-data-redis 提供 Redis 连接和 RedisTemplate;commons-pool2 是连接池的实现依赖,没有它的话连接池参数配置会不生效。如果你的 Redis 不需要连接池,这第三个依赖可以不引,但生产环境我建议都加上。
3.2 application.yml 配置
Spring Boot 2.4 之后,Redis 配置项的前缀从 spring.redis 变成了 spring.data.redis,这一点在对照网上老教程时很容易被坑到。以 Spring Boot 3.x 为例:
spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 cache: type: redis只做这些配置时,Spring Boot 会自动装配一个默认的 RedisCacheManager。但默认配置有两个我们通常接受不了的短板:默认使用 JDK 序列化,Redis 里存的是一堆看不懂的二进制;默认 TTL 是永久,除非你额外设置了 spring.cache.redis.time-to-live。所以绝大多数项目都会自定义一个 CacheManager。
3.3 自定义 RedisCacheManager 解决 TTL 和序列化
直接上代码,这是整个 Spring Cache 集成里最重要的一段配置:
@Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .prefixCacheNameWith("cache:") .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)) .disableCachingNullValues(); Map<String, RedisCacheConfiguration> configMap = new HashMap<>(); configMap.put("user", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))); configMap.put("product", RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .build(); } }这里解释几个关键点。prefixCacheNameWith 会让 Redis 里的 key 变成cache:user::123这样的格式,实际业务中主要用来区分不同业务线的缓存,避免混淆。entryTtl 是默认过期时间,30 分钟是一个比较保险的起步值。disableCachingNullValues 表示不缓存空值;如果业务上要防穿透,可以改成 enableCachingNullValues,后面第四章再讲。
withInitialCacheConfigurations 允许你针对不同 cacheNames 单独设置 TTL。user 数据可以缓存久一点,product 数据变化更快就短一点,比所有分区共用一个 TTL 要灵活得多。
有一点必须提醒:一旦你自定义了 CacheManager,yml 里的 spring.cache.redis.time-to-live 和 spring.cache.redis.cache-null-values 就都不再生效了,因为自定义 Bean 覆盖了默认自动配置。很多人改半天 yml 发现没用,原因就出在这。
3.4 完整示例与验证
启动类上加上 @EnableCaching:
@SpringBootApplication @EnableCaching public class CacheDemoApplication { public static void main(String[] args) { SpringApplication.run(CacheDemoApplication.class, args); } }然后启动应用,连续调用两次获取同一个用户的接口。第一次调用,控制台会打印“从数据库加载用户:1”;第二次调用,方法体不再执行,直接返回缓存结果。配合 Spring Boot Actuator 或者 Redis 客户端,能看到 Redis 里新增了 key:cache:user::1。
这里补充一个序列化的重要细节:GenericJackson2JsonRedisSerializer 在存储对象时,会把对象的类型信息一并写入 JSON,保证反序列化时能还原成正确的类。但如果你的实体类里有 LocalDateTime 这类时间类型,直接使用默认的 GenericJackson2JsonRedisSerializer 有时候会报 Jackson 反序列化错误。稳妥的做法是自定义 ObjectMapper,注册 JavaTimeModule:
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);这个坑我印象很深。第一次用 LocalDateTime 字段的时候,缓存写入没问题,一读取就报InvalidDefinitionException,排查了半天才反应过来是 ObjectMapper 不认识 Java 8 时间类型。
4. 并发场景下的几个硬问题:穿透、击穿、雪崩、一致性
前面第 1.4 节提了三个经典问题,这一章给出对应方案。这部分不是可选项,生产环境一旦流量上来,这些问题都是会真实发生在你面前的。
4.1 缓存穿透:查根本不存在的 key
穿透的典型场景是用户恶意请求一个不存在的 ID,比如 ID = -1。缓存里永远没有,请求每次都穿透到数据库。假设每秒 1000 个这样的请求,数据库每秒就要白白扛 1000 次无效查询。
两种常见书面解法是缓存空值和使用布隆过滤器。缓存空值简单直接:把 null 也当成有效值缓存起来,设置一个较短的 TTL,比如 5 分钟。你可以把第 3.3 节的 disableCachingNullValues() 改成 enableCachingNullValues(),这样查询不存在的数据时,Redis 里会存一个空值占位,后续相同请求直接返回空结果,不会打到数据库。
布隆过滤器则是在缓存前面再加一层判断:如果 ID 在布隆过滤器中不存在,直接返回失败,连缓存都不用查。布隆过滤器的优势是内存占用极低,但实现上需要在启动时预热所有可能存在的 ID,对全量数据较大的项目来说,这个预热过程本身就有点麻烦。我个人的排序是:能忍受少量无效查询就选空值缓存;对无效查询比例很敏感就上布隆过滤器。
4.2 缓存击穿:热点 key 失效瞬间的并发冲击
击穿和穿透不一样。击穿针对的是“某个热点 key”,它在过期的一瞬间,刚好来了大量并发请求,这些请求全部没有命中缓存,于是全部打到数据库,数据库直接被冲垮。
Spring Cache 给了一个省心的开关:@Cacheable 的 sync 属性。看第 2.4 节代码里的sync = true,它表示当缓存 miss 时,同一个 key 的并发请求只允许一个线程去执行方法体,其他线程阻塞等待结果。这个机制在单机应用里非常管用,一行参数就解决了缓存击穿问题。
但要注意:sync 的粒度是 JVM 本地。如果你的服务是多实例部署,请求分摊到多台机器上,每台机器各自加锁,数据库依然可能收到多份查询。多实例场景需要借助分布式锁,典型做法是“双检锁”:先查缓存,miss 后尝试获取 Redis 分布式锁,拿到锁再查一次缓存,还是 miss 才查数据库,查完回填缓存并释放锁。这样能保证所有实例加起来,同一个 key 的数据库查询只有一次。
4.3 缓存雪崩:大面积 key 同时失效
雪崩是击穿的放大版。批量缓存设了统一过期时间,如果恰好都在同一时刻过期,数据库会被大量请求瞬间打满。这一类事故比击穿更隐蔽,因为它不一定发生在热点 key 上,而是发生在“缓存命周期整体节奏”上。
缓解手段主要就是打散过期时间。自定义 CacheManager 里设置 TTL 时,可以在固定值的基础上加一个随机偏移量,比如 30 分钟固定 TTL 基础上随机加 0 到 5 分钟。这样即使同一批缓存同时写入,它们的过期时刻也会错开,数据库压力曲线会平缓很多。更规范的做法是,在配置 TTL 时用一个固定的随机 seed 公式,保证每个 key 的过期时间既有随机性又有一定规律。
另外,给热门缓存设置“永不过期 + 定时刷新”也是一种激进但有效的方案。缓存本身不设 TTL,后台任务每隔一段时间主动更新这些热点 key。这样做彻底避免了雪崩,代价是你需要额外维护刷新逻辑。
4.4 缓存和数据库一致性怎么取舍
一致性是缓存系统里最绕不开的话题。先明确一个原则:缓存和数据库之间一定存在一个时间窗口的不一致,我们要做的是把这个窗口尽量缩小,而不是追求强一致。
前面第 2.2 节说过了,优先“先更新数据库,再删除缓存”。为什么?因为如果先删缓存再更新数据库,删除完成到更新完成之间,会有大量请求读到旧值并重新回填缓存,等数据库更新完了,缓存里还是旧的,这就是经典的脏数据回填。先更新数据库再删缓存,虽然删除前的一小段窗口里缓存还是旧值,但那个窗口已经被压缩到数据库更新和缓存删除之间的毫秒级距离了。
更进一步,还有“延迟双删”策略:先更新数据库,再删除缓存,然后等待几百毫秒,再删除一次缓存。第二次删除是为了清掉在上述窗口内可能被并发线程回填的旧值。延迟时间要根据业务实际耗时来定,一般 500ms 到 1 秒之间足够覆盖绝大多数慢查询。
再提醒一个 Java 应用特有的细节:如果缓存删除操作发生在数据库事务提交之前,事务一旦回滚,数据库恢复原样,但缓存已经被删了,下次读会把旧值重新加载回来,这时候反而没问题;但如果用的是 @CachePut 更新缓存,事务回滚了缓存却已经更新成新值,问题就大了。所以涉及事务时,最好把缓存操作挪到事务提交之后,用事务同步回调来处理。
5. 常见问题与排查技巧实录
5.1 注解加上了但缓存不生效
可以按这套顺序排查:启动类有没有加 @EnableCaching?方法是否被同一个类内部调用?CacheManager 是否存在但 cacheNames 不匹配?key 的 SpEL 表达式是不是写错了?代理是否生效(类是否是 final、方法是否 public)?
我见过最多的情况其实是同一个类内部调用的代理问题。第 2.5 节已经给了解决方案。另外一个很隐蔽的原因是:Spring Cache 默认要求方法必须是 public 的,如果你用了包级私有方法,代理会不生效,注解也不会被处理。
5.2 Redis 里出现乱码和反序列化失败
如果 Redis 客户端里看到的 key 是\xAC\xED\x00\x05t...这样的二进制,说明还在用 JDK 默认序列化。解决方法就是自定义 CacheManager,把序列化器换成 GenericJackson2JsonRedisSerializer 或自己定义的 JSON 序列化器。
反序列化失败还有一种常见情况:旧缓存里已经存了 JDK 序列化的数据,代码改成 JSON 序列化后,去读旧缓存会立刻报反序列化异常。处理方法是先清空对应缓存分区,或者给缓存 key 加版本号后缀,比如user:v2::123,让新旧缓存自然隔离。
5.3 过期时间不按预期走
这一条和第 3.3 节的提醒呼应:一旦自定义了 RedisCacheManager,yml 里的 TTL 配置就失效了。想改过期时间,要么改代码里的 entryTtl,要么把配置也做成动态读取。另一种情况是 withInitialCacheConfigurations 里的配置写错了 cacheNames,导致某个分区没匹配上,走了默认配置,TTL 自然不是你要的值。
5.4 key 不响应的排查角度
先说默认 key 生成器的问题。Spring Cache 在你不写 key 时,会用 SimpleKeyGenerator:方法无参数时生成 SimpleKey.EMPTY,一个参数时直接用这个参数,多个参数时生成 SimpleKey 对象。如果方法签名的参数列表正好和其他方法一样,两个缓存方法可能会共用同一个 key,相互覆盖。所以我一直建议显式声明 key。
再说 key 分隔符。默认情况下 key 在 Redis 里会带 cacheNames 前缀和双冒号分隔符,比如user::123。如果你在另一个服务里也想读这个缓存,必须知道这套规则,否则你用 RedisTemplate.get("123") 是永远拿不到的,得用user::123来读。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 处理方案 |
|---|---|---|
| 注解加了不起作用 | 没有 @EnableCaching,或同类内部调用 | 启动类加注解;方法拆到另一个 Bean |
| Redis 键乱码 | 使用了 JDK 默认序列化器 | 自定义 CacheManager,改用 JSON 序列化 |
| 反序列化报错 | 实体类含 LocalDateTime,ObjectMapper 缺少时间模块 | 注册 JavaTimeModule |
| TTL 设置没生效 | 自定义 CacheManager 覆盖了 yml 配置 | 在 CacheManager 代码中修改 TTL |
| key 永远不命中 | 默认 key 生成器或前缀不匹配 | 显式声明 key,检查 Redis 中实际 key 格式 |
| 缓存击穿 | 热点 key 同时过期 | @Cacheable(sync=true) 或分布式锁 |
我个人在实际排查中发现,很多“缓存不生效”其实不是 Spring 的问题,而是开发者对缓存更新时机的理解偏差。缓存这个东西,设计时多想一步,排查时就能少熬夜。现在的习惯是:每接一个缓存需求,先问三个问题——数据能接受多久的不一致?热点程度有多高?key 的粒度是否清晰?这三个问题想明白了,Spring Cache 用起来会顺手很多,也基本不会在生产环境里捅出大娄子。