Spring Boot缓存从入门到精通:Spring Cache、Caffeine与Redis实战
2026/9/15 22:09:55 网站建设 项目流程

做后端开发这几年,缓存几乎是每个项目都绕不开的坎。Spring Boot 作为目前 Java 后端最主流的开发框架,官方提供的 Spring Cache 抽象更是把缓存接入成本压缩到了最低:你只需要在方法上标一个注解,甚至不用关心底层到底是 ConcurrentHashMap、Caffeine 还是 Redis。但恰恰是这个“简单”,让很多人忽略了背后真正的难点——缓存什么时候失效、缓存和数据库怎么保持一致、多实例部署时缓存能不能共享、缓存穿透了怎么办。

这篇文章我打算把 Spring Boot 缓存从入门到精通的完整路径梳理清楚,既讲注解怎么用,也把底层原理、Caffeine 和 Redis 的整合方式、以及我在生产环境里踩过的坑一并讲透。适合刚开始学 Spring Boot 的初学者,也适合已经写了一阵子缓存、但总觉得哪里不对的进阶开发者。读完之后你至少能回答三个问题:缓存该放在哪一层?缓存怎么和数据库保持一致?线上缓存出了问题怎么查?

1. 先搞清楚缓存解决什么问题:业务场景与缓存分层

很多人一上来就写@Cacheable,写完发现没效果、或者数据不对,原因往往不是注解用错了,而是压根没想清楚“缓存到底要放在哪一层”。这一节我们先补一下缓存的地基。

1.1 后端缓存到底在扛什么压力

一个典型的后端请求链路是:Controller 调 Service,Service 查数据库,数据库返回结果,序列化成 JSON 返回给前端。大多数业务系统里,真正耗时的不是 Java 代码本身的运算,而是数据库查询和远程调用。

数据库查询慢的场景我列几个常见的:

  • 复杂关联查询,几张表 JOIN 再聚合,单次耗时几十毫秒甚至上百毫秒;
  • 高频访问的热点数据,比如首页推荐、商品详情、用户信息,同一份数据每秒被查几千次;
  • 报表统计类接口,每次实时从几百万行数据里 count、sum,数据库 CPU 直接被打满。

这些场景有一个共同特征:数据在一段时间内变化不大,但读取频率极高。这就是缓存存在的意义——把昂贵的数据源访问结果,保存在内存这种高速存储里,下次请求直接读内存,跳过数据库。

用生活化的话说,缓存就像家里的冰箱。你不会每次做饭都去一趟超市,而是提前把常用的菜放在冰箱里,做菜时直接从冰箱拿。但冰箱的问题也很明显:菜会过期、冰箱空间有限、如果两家共用一台冰箱还存在“我家拿走了你存的菜”这种协调问题。后端的缓存问题,本质上就是“怎么把冰箱管理好”的问题。

1.2 缓存金字塔:从本地缓存到分布式缓存

计算机科学里有个经典说法:缓存是分层的,越靠近 CPU 的层速度越快、容量越小、成本越高。后端业务缓存的层次也是同理。

按照我平时的习惯,业务缓存大致分三层:

缓存层次典型实现访问速度容量适用场景
本地缓存ConcurrentHashMap、Caffeine、Guava Cache纳秒级受 JVM 堆内存限制单机内热点数据、允许各实例短暂不一致
集中式缓存Redis、Memcached微秒级可横向扩展,容量大多实例共享数据、分布式锁、Session
数据库级缓存MySQL Buffer Pool、MyBatis 一级/二级缓存取决于磁盘和内存取决于数据库配置数据库内部优化、ORM 层局部优化

这里要说一个新手特别容易混淆的点:MyBatis 的一级缓存(SqlSession 级别)和二级缓存(Mapper 级别)属于 ORM 层的缓存,缓存的是 SQL 查询结果,生命周期和事务、SqlSession 绑定;而 Spring Cache 是应用服务层的业务缓存,缓存的是方法返回值,服务方法级别。两者不是一个层面的东西,不要混为一谈。实际项目中 MyBatis 二级缓存默认是关闭的(localCacheScope通常是 SESSION),而且我个人不建议轻易开启,因为一旦涉及多表操作,脏数据问题很难控制。

1.3 别把缓存当万能药:穿透、击穿、雪崩三座大山

聊缓存必定绕不开这三个词,面试高频、线上事故也高频。我用自己的话解释一下:

缓存穿透是指查询一个根本不存在的数据。请求来了,缓存里没有,数据库里也没有,于是每次都打到数据库,缓存形同虚设。攻击者如果故意构造一批不存在的 ID 来刷接口,数据库会被打到崩溃。

缓存击穿是指某个热点 key 在缓存过期的瞬间,大量请求同时涌入数据库。比如某个爆款商品的详情缓存刚好失效,一瞬间几千个请求全打到数据库,直接把库拖垮。

缓存雪崩是指大量 key 在同一时间集中过期,或者 Redis 实例整个宕机,导致所有请求瞬间全部打到数据库,引起连锁反应。

这三个问题没有一劳永逸的解法,只有组合拳。穿透用空值缓存或布隆过滤器挡;击穿用互斥锁只放一个请求去重建缓存;雪崩用过期时间加随机值打散,加上 Redis 高可用架构兜底。后面第 4 章和第 7 章我会给出具体的 Spring Boot 实现代码。

2. Spring Cache:把缓存变成注解的艺术

理解了缓存背后的动机,我们再看 Spring Boot 官方推荐的第一种玩法——Spring Cache。如果你只想快速给项目加缓存,这章是性价比最高的一节。

2.1 为什么 Spring Boot 要抽象一层 Spring Cache

早年 Java 项目做缓存,最原始的方式是直接在 Service 里写一个ConcurrentHashMap,查之前先看 map 里有没有,没有再查库,查到后放回 map。代码大概长这样:

private final Map<String, Object> localCache = new ConcurrentHashMap<>(); public User getUser(String userId) { Object cached = localCache.get(userId); if (cached != null) { return (User) cached; } User user = userMapper.selectById(userId); localCache.put(userId, user); return user; }

这么写有四个明显问题:代码侵入业务逻辑、缓存读写逻辑散落在每个方法里、切换缓存实现要改所有方法、还有并发时重复查询数据库的风险。

Spring Cache 的价值在于声明式缓存:缓存逻辑和业务逻辑解耦,你只需要在方法上标注注解,Spring 通过 AOP 在方法执行前后自动完成缓存读写。底层用 ConcurrentMap、Caffeine 还是 Redis,通过配置切换,业务代码一行都不用改。

这就像你不需要自己在家发电,只需要把插头插到插座上,至于电来自火电还是水电,对你来说透明。Spring Cache 就是那个插座标准,Caffeine、Redis 就是不同发电厂。

2.2 核心注解与参数拆解

Spring Cache 提供了一套非常精简的注解模型,日常开发主要碰四个:

  • @Cacheable:先查缓存,缓存没有再执行方法,并把返回值放入缓存。适合查询方法。
  • @CachePut:不查缓存,直接执行方法,并把返回值写入缓存。适合更新缓存场景。
  • @CacheEvict:执行方法后删除缓存。适合删除数据场景。
  • @CacheConfig:类级别统一配置cacheNameskeyGenerator等公共信息。

最常用的@Cacheable写法:

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

这里cacheNames对应缓存分区名(Redis 里会变成 key 的前缀),key是 SpEL 表达式,#userId表示取方法参数中的 userId 值。当你有多个查询条件时,key 可以写成 SpEL 拼接,比如#userType + ":" + #userId

@CachePut@CacheEvict的典型用法是:更新用户信息时,直接刷新缓存;删除用户时,删除对应缓存。

@CachePut(cacheNames = "user", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); return user; } @CacheEvict(cacheNames = "user", key = "#userId") public void deleteUser(Long userId) { userMapper.deleteById(userId); }

这套注解用起来很简单,但有几个细节很容易踩坑,后面第 5 章和第 8 章会展开说。

2.3 key 生成策略与自定义 KeyGenerator

如果@Cacheable没指定 key,Spring 会使用默认的SimpleKeyGenerator。规则是:

  • 无参数方法:生成SimpleKey.EMPTY
  • 一个参数方法:直接用该参数作为 key;
  • 多个参数方法:生成包含所有参数的SimpleKey对象。

默认规则在多数情况下够用,但有两个隐患:一是多个参数时SimpleKey.toString()的可读性差;二是如果参数是复杂对象,默认直接用该对象作为 key,要求对象必须正确实现hashCode()equals()。我习惯显式声明 key,可读性更好,也避免序列化问题。

如果你希望全局统一 key 格式,可以实现一个KeyGenerator

@Configuration public class CacheKeyConfig { @Bean("customKeyGenerator") public KeyGenerator customKeyGenerator() { return (target, method, params) -> { String className = target.getClass().getSimpleName(); String methodName = method.getName(); return className + ":" + methodName + ":" + Arrays.stream(params) .map(String::valueOf) .collect(Collectors.joining("_")); }; } }

然后用的时候指定keyGenerator = "customKeyGenerator"即可。这种做法的优点是 key 带上类名和方法名,大幅降低不同方法间 key 冲突的概率。缺点是所有缓存 key 都会变长,Redis 里会占更多内存,需要根据场景取舍。

2.4 条件缓存:condition 与 unless 的正确用法

有些数据不适合缓存,比如临时数据、用户刚修改还未提交的数据。@Cacheable提供了两个过滤参数,看起来很像,其实作用时机完全不同:

  • condition:在方法执行前判断,满足条件才查缓存;如果 condition 为 false,相当于这个注解失效,直接执行方法。
  • unless:在方法执行后判断,满足条件就不把返回值写入缓存。

举个例子,只有状态为 1 的用户数据才允许缓存:

@Cacheable(cacheNames = "user", key = "#userId", condition = "#status == 1", unless = "#result == null") public User getUser(Long userId, Integer status) { return userMapper.selectById(userId); }

这个例子里,condition判断入参status,等于 1 时才走缓存逻辑;unless判断返回结果,如果返回 null 就不写入缓存,避免把 null 缓存进去。两者结合能覆盖大部分缓存过滤场景。

3. 从默认缓存到 Caffeine:本地缓存性能优化

Spring Cache 默认的缓存管理器有哪些、为什么推荐 Caffeine、Caffeine 参数怎么调,这一章解决本地缓存的核心问题。

3.1 Spring Boot 默认缓存实现了解一下

Spring Boot 在 classpath 下检测到缓存相关依赖时,会自动装配CacheManager。如果你什么都没引入,它会用ConcurrentMapCacheManager,底层就是 ConcurrentHashMap,缓存永远不会自动过期,也不能配置淘汰策略。这意味着如果不手动清理,缓存数据会一直堆积到内存溢出,所以只适合本地调试,生产环境一定要替换。

Spring Boot 自动装配机制会按照优先级寻找 classpath 中的缓存实现:Caffeine、Redis、EhCache、Simple 等。你引入了 Caffeine 依赖,Spring Boot 就会自动创建CaffeineCacheManager;引入了 Redis 依赖,就会创建RedisCacheManager。这也是为什么很多人发现“我只是加了个 Redis,怎么 @Cacheable 自动就走 Redis 了”的原因。

3.2 Caffeine 核心参数配置

Caffeine 是目前 Java 生态里最优秀的本地缓存库,性能比 Guava Cache 更好,内存淘汰策略更聪明。先看一个完整的配置类:

@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); // 给所有缓存设置统一默认配置 cacheManager.setCaffeine(Caffeine.newBuilder() // 缓存最大条数,超过后按淘汰策略移除 .maximumSize(10_000) // 写入后 5 分钟过期 .expireAfterWrite(5, TimeUnit.MINUTES) // 访问后 3 分钟过期(与 expireAfterWrite 同时配置时,以先到期为准) .expireAfterAccess(3, TimeUnit.MINUTES) // 统计缓存命中率,配合监控使用 .recordStats()); // 支持 cacheNames 按名称分别配置 cacheManager.setAllowNullValues(false); return cacheManager; } }

这里几个参数是日常调优的重点:

  • maximumSize:最多缓存多少条。设置太小命中率上不去,太大 JVM 内存压力大。一般根据堆内存和单条缓存大小估算,我习惯先给一个保守值,配合监控逐步调。
  • expireAfterWrite:写入后固定时间过期,适合绝大多数业务数据。
  • expireAfterAccess:最后一次访问后过期,适合“不活跃就清掉”的会话类数据。
  • refreshAfterWrite:写入后固定时间刷新,但不会真正淘汰数据,而是在访问时触发异步加载。这个参数适合读多写少、允许短暂旧数据的场景,能有效避免缓存击穿。

需要说明的是,expireAfterWriteexpireAfterAccess同时配置时,以最先到期的时间为准。实际项目中如果无法确定业务对数据新鲜度的要求,保守一点就只配expireAfterWrite

3.3 为什么本地缓存首选 Caffeine:W-TinyLFU 淘汰策略

本地缓存必须要回答一个问题:内存有限,放不下时淘汰谁?最朴素的做法是 LRU(最近最少使用),但 LRU 有个明显问题:如果一个偶发的大批量扫描把一批只访问一次的数据塞进缓存,那么真正高频访问的数据会被挤出去,之后再次访问时又要从数据库读,命中率瞬间下降。

Caffeine 用的是 W-TinyLFU(Window Tiny Least Frequently Used)策略。它把缓存分成两部分:一个很小的 Window 区(默认占总容量的 1%),一个主区。新数据先进入 Window 区,在 Window 区里用 LRU 管理;当数据要被淘汰时,会进入主区,主区根据“频率 + 最近性”的综合评分决定是否接纳这条数据。这种设计兼顾了突发热点(Window 区快速响应用户访问)和长期高频数据(主区保护高频 key),在实际业务中命中率明显优于纯 LRU。

这里补充一个直觉类比:LRU 只看“最近来过没有”,W-TinyLFU 还要看“总共来过几次”。一个老用户每天来一次,一个陌生用户今天来了十次,缓存应该优先留住谁?多数场景下老用户(高频但可能最近没来)更值得留,这就是频率维度发挥的价值。

3.4 整合 Spring Cache 与 Caffeine 的完整配置

把 Caffeine 接入 Spring Cache 有两种方式。

第一种是使用CaffeineCacheManager,可以直接指定统一的 Caffeine 配置,代码如上。第二种是使用Caffeine提供 builder 模式手动创建CaffeineCache放进 manager,适合不同缓存分区使用不同过期时间的场景:

@Bean public CacheManager cacheManager() { SimpleCacheManager manager = new SimpleCacheManager(); manager.setCaches(Arrays.asList( new CaffeineCache("user", Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(10, TimeUnit.MINUTES) .build()), new CaffeineCache("product", Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(30, TimeUnit.MINUTES) .build()) )); return manager; }

这种“一个缓存名一个配置”的方式更灵活,适合业务上对不同数据有不同时效性要求的场景。要注意的是,SimpleCacheManager不支持动态创建缓存,如果你在@Cacheable里写了没定义过的 cacheNames,启动不会报错,但运行时缓存逻辑不会生效。CaffeineCacheManager默认支持动态创建,没有配置的缓存名会按默认规则创建,这点要记清楚。

4. Redis 做分布式缓存:多实例共享的那一层

本地缓存解决的是单机上的热点访问问题。一旦应用部署了多个实例,或者需要跨服务共享数据,本地缓存就无能为力了——每个实例各存一份,A 实例更新了缓存,B 实例还是旧数据。这时候需要引入 Redis。

4.1 Redis 在 Spring Boot 缓存里的正确姿态

Redis 在 Spring Boot 缓存体系里扮演的是“集中式缓存层”角色。它适合以下几种场景:

  • 多个应用实例共享同一份缓存数据;
  • 缓存数据需要被多个服务读写(比如订单服务写,库存服务读);
  • 缓存数据量较大,超出单机 JVM 内存承载能力;
  • 需要缓存持久化或数据恢复能力。

使用 Redis 做缓存,前提是先把 Redis 依赖引入项目。Spring Boot 生态里最常用的就是官方spring-boot-starter-data-redis

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

引入依赖后,Spring Boot 会自动配置RedisConnectionFactoryRedisTemplate。但默认的RedisTemplate使用的序列化器是 JdkSerializationRedisSerializer,存进 Redis 的 key 和 value 会带一串\xac\xed\x00\x05t\x00这样的二进制头,肉眼不可读,跨语言访问也不方便。所以生产环境第一步要做的是替换序列化器。

4.2 RedisCacheManager 配置与序列化器选型

配置一个生产可用的RedisCacheManager,重点在于 key 使用StringRedisSerializer,value 使用GenericJackson2JsonRedisSerializer。这样 key 可读,value 是 JSON 格式,方便排查问题和跨语言读取。完整配置如下:

@Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { // key 序列化器 RedisSerializer<String> stringSerializer = new StringRedisSerializer(); // value 序列化器:存储为 JSON RedisSerializer<Object> jsonSerializer = new GenericJackson2JsonRedisSerializer(); RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringSerializer)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)) // 统一设置缓存过期时间,实际项目建议按业务拆分 .entryTtl(Duration.ofMinutes(30)) // key 前缀,Redis 中可见为 "user::123" .computePrefixWith(cacheName -> cacheName + "::") // 禁止缓存 null,避免缓存击穿问题扩展 .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } }

这里有三个容易被忽略的坑,我逐个说:

第一,GenericJackson2JsonRedisSerializer序列化对象时,会在 JSON 里写入@class类型信息,用于反序列化时还原类型。但如果你用了 CGLIB 代理对象、或者对象内部有特殊类型,反序列化可能失败。解决办法是确保缓存的对象是无状态、可序列化的 POJO。

第二,disableCachingNullValues()会导致值为 null 时抛异常。设置了这个选项时,@Cacheable方法如果返回 null,启动时不会报错,但运行时会报NullValue写入失败。如果你业务上确实要缓存空值来防穿透,就不要开这个选项,而是用allowNullValues(true)

第三,如果你同时存在多个 cacheName 且 TTL 不同,建议用withCacheConfiguration单独配置,比如:

RedisCacheManager.builder(factory) .cacheDefaults(config) .withCacheConfiguration("user", config.entryTtl(Duration.ofMinutes(10))) .withCacheConfiguration("product", config.entryTtl(Duration.ofMinutes(60))) .build();

4.3 TTL、CacheName 与分区设计

Redis 缓存设计需要提前想清楚几个概念。

cacheNames相当于缓存分区。把不同业务的缓存分到不同的分区,好处是:TTL 隔离、key 前缀清晰、运维排查方便。我的习惯是缓存 key 统一采用业务域:业务对象:业务ID的结构,比如user:info:123product:detail:456。这样即使出问题,看一眼 key 就知道是什么业务的数据。

TTL(过期时间)的设置原则是“比业务可接受的数据延迟稍微大一点”。比如商品详情页允许最长 5 分钟的延迟,TTL 就设 5 分钟;用户基本信息允许 1 小时,TTL 就设 1 小时。设置过短,缓存频繁失效,压力还是打在数据库上;设置过长,数据不一致窗口变大。这里没有标准答案,和业务方对齐预期才是关键。

4.4 多级缓存:本地缓存加 Redis 的分层玩法

到了这一步,懂行的人通常会问:能不能既用本地缓存扛单机热点,又用 Redis 做多实例共享?完全可以,这就是多级缓存。

我常用的一种多级缓存方案是:本地 Caffeine 作为 L1 缓存,Redis 作为 L2 缓存,数据库在最后。读取顺序为:L1 命中直接返回;L1 未命中读 L2,L2 命中后回填 L1;L2 也未命中才查数据库,查完同时回填 L1 和 L2。更新时,先更新数据库,再删除 Redis key 和本地缓存 key。

本地缓存天然存在各实例不一致的问题,所以 L1 的 TTL 要设置得比较短,比如 1 到 3 分钟。这样即使某实例本地缓存过期了,还有 Redis 兜底,不会被击穿到数据库。多级缓存能显著降低 Redis 的压力,代价是代码复杂度上升,而且必须做好“本地缓存删除失败”的容错。实际项目中,如果 Redis 本身压力不大,我建议先别上多级缓存,越简单越好。

4.5 缓存重建的并发控制

多级缓存引入后,一个必须处理的问题是缓存重建时的并发。假设某个 key 在 L1、L2 全部 miss,此时有 100 个请求同时进来,如果不加控制,这 100 个请求都会去查数据库,数据库立刻被打爆。

最简单的处理方式是加互斥锁,保证同一时刻只有一个线程去数据库查并重建缓存。Spring 里可以用 Redis 分布式锁,也可以用 JVM 本地锁结合 Caffeine 的特性。代码实现上我一般封装一个loadCache方法,逻辑是:

public User getAndRefresh(String userId) { User user = localCache.getIfPresent(userId); if (user != null) { return user; } // 尝试获取本地锁,防止同一实例内并发重建 synchronized (userId.intern()) { user = localCache.getIfPresent(userId); if (user != null) { return user; } user = redisTemplate.opsForValue().get("user:" + userId); if (user != null) { localCache.put(userId, user); return user; } user = userMapper.selectById(userId); localCache.put(userId, user); redisTemplate.opsForValue().set("user:" + userId, user, 30, TimeUnit.MINUTES); return user; } }

这个例子用了synchronized的方式,简单有效,但String.intern()在大量动态字符串下可能有性能问题,生产环境建议用更安全的锁对象策略。另外要注意,分布式多实例场景下单机锁只能拦住本实例的并发,实例之间还是会有并发透传,此时需要引入 Redis 分布式锁。不过如果数据库能扛住偶发的小流量并发,也不是非要加锁不可。

5. 缓存一致性:改了数据库,缓存怎么办

缓存最棘手的问题不是“怎么存”,而是“怎么保证一致性”。这一章我们讲清楚 Cache Aside 模式、延迟双删的原理,以及 Spring Cache 注解在一致性场景下的隐藏行为。

5.1 Cache Aside 模式:先写库再删缓存

业界最主流的缓存维护模式是 Cache Aside,也叫旁路缓存。核心规则就两条:

  • 读请求:先查缓存,缓存没有查数据库,再回填缓存;
  • 写请求:先更新数据库,再删除缓存。

很多人不理解为什么写的时候不是“先更新缓存”而是“先删缓存”。原因是:直接更新缓存,在并发写的情况下,容易出现旧值覆盖新值的问题;而删除缓存,下一次读请求发现缓存 miss,自然会把数据库的新数据加载回缓存,相当于把“更新细节”交给读流程去完成,逻辑最简单,容错性也最高。

但是,先更新数据库再删除缓存,也存在一个并发窗口:请求 A 更新数据库为“新值”,请求 B 在 A 删除缓存之前读取了旧的缓存值,然后把旧值返回给用户。虽然这个窗口非常小,但理论上是存在的。

5.2 延迟双删到底有没有用

针对上面的窗口,很多人会采用“延迟双删”方案:

  1. 先更新数据库;
  2. 删除缓存;
  3. 休眠几百毫秒(比如 500ms);
  4. 再次删除缓存。

第二步删除后,第二步和第三步之间如果又有读请求把旧值写回缓存,第三步的第二次删除会把旧缓存再次清掉。这个方案在原理想上是合理的,但实际效果经常被人高估。

如果你问我实际项目要不要用延迟双删,我的回答是:大部分业务场景不需要。因为 500ms 的等待意味着你的写接口吞吐量直接砍半,收益却只是消除一个非常罕见的并发窗口。对一致性要求如此苛刻的场景,更好的做法是引入版本号机制:每次写缓存时带上版本号,读请求发现版本号落后就丢弃并使用数据库新值。对大多数读多写少、允许秒级延迟的业务,Cache Aside 的“先写库再删缓存”已经足够,不要过度设计。

5.3 缓存更新里的两个坑:@CachePut 返回值和 @CacheEvict 时机

Spring Cache 注解在一致性场景下有两个隐藏行为,很容易踩。

第一个坑是@CachePut的返回值写缓存。@CachePut会执行方法,然后把方法的返回值写入缓存。这意味着如果你的更新方法返回值是 void,或者返回的是其他无关对象,缓存写入的就是 null 或无意义值。正确做法是让更新方法返回更新后的完整对象,确保缓存里存的是正确的新数据。

@CachePut(cacheNames = "user", key = "#user.id") public User updateUser(User user) { userMapper.updateById(user); return userMapper.selectById(user.getId()); // 返回最新数据 }

第二个坑是@CacheEvict的默认时机。默认情况下,@CacheEvict方法执行成功后才会删除缓存。如果方法抛异常,缓存不会被删除。这在多数场景是合理的,但如果你希望“无论方法成功与否都清理缓存”,可以设置beforeInvocation = true

@CacheEvict(cacheNames = "user", key = "#userId", beforeInvocation = true) public void deleteUser(Long userId) { userMapper.deleteById(userId); }

beforeInvocation = true时,Spring 在方法执行前先删除缓存。这样做的好处是不会因为业务方法执行失败而导致缓存残留,缺点是一旦方法失败,原本有效的缓存也被删了,下次查询会 miss。两种方式没有绝对的对错,要看业务更在意“缓存准确”还是“缓存可用”。

5.4 主动更新 vs 被动过期:消息队列方案

缓存一致性的终极形态,是让数据库的变更事件主动驱动缓存更新。比较成熟的做法是:数据库变更后,发送一条消息到 MQ(RocketMQ、RabbitMQ、Kafka 都可以),消费者收到消息后删除或更新对应的 Redis key。

好处是显而易见的:解耦了业务代码和缓存代码;即使删除缓存失败,还可以通过 MQ 重试机制保证最终一致。坏处是引入了消息中间件,系统复杂度上了一个台阶。

我的建议是:项目初期用 Cache Aside 模式 + 设置合理 TTL;如果后续业务上出现了“缓存更新不及时导致用户投诉”的明确问题,再考虑引入 MQ 主动失效。缓存这块最怕的就是为了一个还没出现的问题,先上一套复杂架构。

6. 一个绕不开的误会:Spring 三级缓存和业务缓存不是一回事

Spring 缓存相关的话题在社区里搜索热度很高,但“spring三级缓存”这个词经常和业务缓存混在一起,很多新手被搞晕。这一章我们花一点篇幅把它彻底理清。

6.1 Spring IOC 容器三级缓存全貌

Spring 容器里的“三级缓存”指的是DefaultSingletonBeanRegistry中维护的三个 Map,用来解决单例 Bean 的循环依赖问题:

  • 一级缓存singletonObjects:存放完全初始化好的单例 Bean;
  • 二级缓存earlySingletonObjects:存放已经实例化但尚未完成属性填充和初始化的早期 Bean(半成品);
  • 三级缓存singletonFactories:存放 ObjectFactory,用于提前生成 Bean 的代理对象。

当一个 Bean A 依赖 Bean B,而 Bean B 又依赖 Bean A 时,Spring 创建 A 后,先把 A 的 ObjectFactory 放到三级缓存,再填充属性时发现需要 B,于是创建 B;B 填充属性时需要 A,此时从三级缓存里拿到 A 的 ObjectFactory,生成一个早期 A 引用(可能是代理对象),放入二级缓存并注入给 B。B 创建完成后,A 继续完成剩余初始化,最终放入一级缓存。

这个过程里的“三级缓存”是 Spring 框架内部解决循环依赖的一种机制,缓存的是“对象实例”,维护的是 Bean 生命周期。它和@Cacheable、Redis 这种业务数据缓存没有任何关系。你项目里写的 Redis 缓存、Caffeine 缓存,跟 Spring 内部这三张 Map 八竿子打不着。

6.2 为什么业务缓存和容器三级缓存要分开理解

之所以专门写这一节,是因为我在技术社群里见过太多人把这两个概念混在一起提问。有人说“‘Spring 三级缓存’是干什么的,怎么配置 Redis”,这明显是没分清。

顺带一提,Spring 的三级缓存不是“缓存优化性能”的手段,它纯粹是为了解决单例 Bean 的循环依赖问题。它不能、也不应该用于缓存业务数据。很多人误以为 Spring 启动慢是因为缓存没配,其实完全不是一回事。

如果你理解了这两者的区别,再回头看 Spring Cache 里的CacheManagerCache@Cacheable,就清晰多了:Spring Cache 是面向业务方法调用的缓存抽象,底层是外置缓存存储;而容器三级缓存是 IoC 容器内部的实现细节,用户基本感知不到。

7. 缓存治理与线上排查:从指标到问题定位

缓存加好之后,真正的挑战才刚刚开始:线上缓存是否真的在生效?命中率多少?有没有大 key 导致 Redis 阻塞?这一章讲缓存治理的实操方法。

7.1 缓存监控:Actuator Metrics 与 Redis 命中率

先养成一个习惯:缓存一定要有监控指标,否则就是黑盒。Spring Boot Actuator 的 Metrics 端点天然支持缓存指标统计。你只需要引入spring-boot-starter-actuator,并开启相关配置:

management.endpoints.web.exposure.include=health,info,metrics management.endpoint.metrics.enabled=true

然后访问/actuator/metrics/cache.gets就能看到缓存的命中次数和未命中次数。如果使用了 Caffeine 并开启了recordStats(),还能看到cache.hitcache.misscache.eviction等更细粒度的指标。把这些指标接入 Prometheus + Grafana,是最常用的缓存可观测方案。

Redis 侧也有现成的监控指标。通过INFO stats命令可以看到keyspace_hitskeyspace_misses,两者相除就是 Redis 的整体命中率。如果命中率长期很低,说明缓存策略有问题,要么 TTL 设置太短,要么缓存的数据本身就不是热点数据。

这里提醒一句:Actuator 在生产环境要谨慎暴露端点,尤其是envheapdump这类敏感端点,建议通过management.endpoints.web.exposure.include严格控制,只开放必要的健康检查和指标端点。内部监控系统采集指标时,也要通过网络安全策略限制访问范围,避免未授权访问风险。

7.2 典型线上问题的排查手段:大 key、热 key、Key 堆积

线上缓存问题,我遇到最多的有三类。

第一类是大 key。Redis 里某个 key 的 value 特别大(比如一个 List 存了几万条数据),读写成本高,容易阻塞 Redis 单线程主进程。排查方式是用redis-cli --bigkeys扫描,或者定期在低峰期用MEMORY USAGE key查看大 key 内存占用。治理手段是拆分 key,把大 value 拆成多个小 key,或者把大对象压缩后存储,以及给 value 设置合理的过期时间。

第二类是热 key。某个 key 访问量特别高,单台 Redis 实例 CPU 被打满。排查方式是监控 Redis 的 hot key,命令比较多,常见的有redis-cli --hotkeys(需要开启 LFU 策略)或者通过业务侧埋点。治理手段包括:本地缓存兜底一层、读写分离、或者把热 key 复制多份分散到不同分片。

第三类是Key 堆积。缓存 key 数量持续增长,但访问量低,这类 key 长期占内存。很多是忘记设置 TTL 导致的。治理手段有两个:一是代码层面要求所有写入缓存的 key 必须带 TTL;二是定期扫描空转 key,使用SCAN配合类型检查批量清理。

7.3 缓存空值穿透的工程化解法

前面提到的缓存穿透问题,工程化做法有两种:空值缓存和布隆过滤器。

空值缓存最简单:查询接口发现数据库没有数据时,也把一个“空标记”写入缓存,并设置较短的 TTL(比如 30 秒),这样同一个不存在的查询短时间内不会再打到数据库。用 Spring Cache 实现时,只需要允许缓存 null 值,并在业务方法里手动 put 一个空对象。注意空值缓存 TTL 要比正常数据短,否则一个不存在的 key 长期占着缓存空间也很浪费。

布隆过滤器的思路更高级:在查询前先用布隆过滤器判断“这个 key 是否存在”,不存在直接返回,存在才允许进入缓存和数据库查询。布隆过滤器的优点是内存占用极小,缺点是存在误判率(可能在存在性判断时误认为存在,但不会误认为不存在),而且删除 key 困难。Redis 官方模块Bloom Filter可以直接在 Redis 里使用,Spring Boot 里也可以通过Redisson的 RBloomFilter 快速集成。如果你的业务有海量“必然不存在”的请求,布隆过滤器是更好的选择;如果穿透量不大,空值缓存成本更低,推荐从空值缓存起步。

8. 我踩过的那些缓存坑:问题排查实录

最后一章,分享几个我实际开发中遇到过的典型问题,有些是代码上的坑,有些是架构上的坑,希望能帮你少走弯路。

8.1 自调用导致 @Cacheable 失效

这是 Spring Cache 最经典的坑,没有之一。

@Service public class UserService { public User getUserWrapper(Long userId) { // 这里调用同类方法,@Cacheable 不会生效 return this.getUserById(userId); } @Cacheable(cacheNames = "user", key = "#userId") public User getUserById(Long userId) { return userMapper.selectById(userId); } }

原因很简单:Spring Cache 基于 AOP 实现,@Cacheable会被代理对象拦截。当你在类内部通过this调用另一个方法时,走的是当前对象,不是代理对象,所以拦截器不会执行,缓存逻辑自然失效。解决办法有三种:把方法拆到不同的 Service 类里,让外部调用走代理;或者注入自身代理(@Autowired自己,听说过吗);或者在同一个类里把缓存方法单独抽成一个配置类方法。我推荐第一种,代码结构最清晰。

8.2 RedisTemplate 序列化乱码

配置了 RedisCacheManager 后,缓存数据用 Redis 命令看是 JSON,一切正常。但如果你直接用RedisTemplate手动读写同一个 key,很可能出现乱码或数据读不到。原因还是序列化器不一致:RedisCacheManager的 key/value 序列化器和你手动配置的RedisTemplate序列化器不是同一个。

解决办法是统一序列化配置。我通常在配置类中同时定义RedisTemplateRedisCacheManager,并且都指定StringRedisSerializer+GenericJackson2JsonRedisSerializer。这样无论注解缓存还是手动缓存,数据的存储格式完全一致,排查问题也轻松很多。

8.3 缓存 key 冲突的设计失误

Spring Cache 里cacheNames是分区,但如果你没有合理分区,不同方法的 key 可能互相覆盖。经典反面教材是:一个缓存用户信息,一个缓存订单信息,key 都写成了#id,当 id 相同时,两个缓存共用同一个 key,数据互相污染。

解决办法前面提过:要么cacheNames严格区分,要么自定义 key 加上类名方法名前缀。我的习惯是缓存 key 一律采用业务前缀:ID结构,比如user:123order:456,与cacheNames搭配使用。另外,要注意 Redis 中 key 的命名可读性,排查问题时能省大量时间。

8.4 版本升级中的缓存配置迁移

Spring Boot 版本升级时,缓存相关配置也发生过一些变化。比如 Spring Boot 2.x 时代自动装配文件是spring.factories,到了 Spring Boot 3.x 改成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果你自定义过缓存相关的 Starter 或者要排查自动装配问题,需要知道这个文件的迁移路径。实际升级时,如果发现缓存自动配置失效,优先检查依赖版本是否配套、配置文件是否有新的属性名。至于升级后自动配置类包路径的变化,建议参考官方迁移文档,不同小版本之间差异不明显,总体思路是:先看启动日志里 CacheAutoConfiguration 是否生效,再看 CacheManager 类型是否符合预期。

8.5 给新手的避坑清单

用一张表总结我经验里最值得记住的注意事项:

问题现象解决建议
@Cacheable 同类自调用不生效缓存一直 miss拆类调用,避免 this 调用
Redis 序列化乱码数据读不到、Redis 里是二进制统一序列化器配置
key 冲突不同数据互相覆盖cacheNames 分区 + key 前缀
缓存时间过长数据长期不一致设置合理 TTL,按业务拆分
本地缓存各实例不一致不同实例返回不同数据缩短 L1 缓存 TTL,核心数据不加本地缓存
缓存 key 无 TTLRedis 内存持续增长所有 key 必须设置过期时间

缓存的问题就是这样,表面看起来很简单,真正深入进去全是细节。上文提到的很多思路,比如先简单后复杂、监控先行、不要过度设计,都是我在实际项目里换过学费才总结出来的。

根据我个人的体会,最稳妥的落地方案是:先用 Spring Cache 注解 + Redis 把缓存跑起来,配好监控指标,再根据监控数据决定要不要引入 Caffeine 本地缓存、要不要加多级缓存。缓存不像功能需求,没有“做完”的一天,它更像是一个持续治理的过程。你能做的最重要的一件事,就是让每一条缓存都能被观测、被管理、被快速定位问题。做到这一点,你的缓存体系就算称得上“精通”了。

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

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

立即咨询