本节目标:把缓存声明式地加到「图书借阅管理服务」上,并讲清抽象层能表达什么、不能表达什么——哪些活该交给注解,哪些必须落到 CacheManager 甚至 Redis 层面。
适用版本:Spring Boot 4.1.x(Java 21)
8.1 Spring Cache 抽象
入门卷用 Book、Member、Loan 三个实体把借阅服务串成了单个可交付应用,也讲过 @Transactional 的声明式事务。本节换一个横切关注点:缓存。图书详情、借阅统计这类「读多写少、算起来贵」的数据,是缓存最典型的客户。
Spring Cache 的价值在于:它把「读缓存、未命中回源、回填、写时失效」这套样板逻辑从业务代码里抽走,变成几个注解。但它的代价也很明确——注解只能表达「缓存什么」,表达不了「缓存多久、怎么淘汰、存哪里」。本节先讲注解语义,再讲抽象层止步的地方。
8.1.1 为什么用抽象而不是直接注入 RedisTemplate
最直接的写法是在 Service 里注入 RedisTemplate,手写 get / put / delete:
public Book detail(Long id) {
String key = "book:" + id;
Book cached = (Book) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
Book book = repository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, book, Duration.ofMinutes(10));
return book;
}
这段代码能跑,但它有三个问题:每个读方法都要重复一遍;缓存逻辑和业务逻辑缠在一起,单测时想跳过缓存得改代码;换存储(本地 Caffeine 换 Redis)要动所有方法。
Spring Cache 把上面这段收进 AOP 切面,业务方法只声明意图:
@Cacheable(cacheNames = "book", key = "#id")
public Book detail(Long id) {
return repository.findById(id).orElseThrow();
}
代价是「控制力下降」:你不再能随手控制超时、序列化、批量失效。所以正确的用法是先判断这个方法适不适合被抽象——答案是「读多写少、key 明确、失效规则简单」的方法适合,其余老老实实手写。
8.1.2 四个注解的语义差异
四个注解名字很像,语义却完全不同,混用是缓存不一致的头号来源。
| 注解 | 读缓存 | 执行方法体 | 写缓存 | 典型用途 |
|---|---|---|---|---|
@Cacheable | 命中则直接返回,不执行方法 | 仅未命中时执行 | 未命中执行后回填 | 查询 |
@CachePut | 不读 | 总是执行 | 执行后用返回值覆盖 | 更新后同步缓存 |
@CacheEvict | 不读 | 总是执行 | 删除缓存(默认删该 key) | 更新后失效 |
@Caching | — | — | 组合上面多个操作 | 一次操作动多个缓存 |
最容易踩的是把 @CachePut 当 @Cacheable 用。@CachePut 永远不会读缓存,它只负责「执行方法 + 把结果写回去」。如果你在查询方法上写了 @CachePut,缓存就形同虚设,每次请求都打库。
另一条铁律:@Cacheable 与 @CachePut 不要标在同一个方法上。前者命中时不执行方法体,后者要求每次都执行,语义直接冲突。需要「更新后同时改多个缓存」时,用 @Caching:
@Caching(
put = {
@CachePut(cacheNames = "book", key = "#result.id"),
@CachePut(cacheNames = "bookByIsbn", key = "#result.isbn")
},
evict = @CacheEvict(cacheNames = "bookList", allEntries = true)
)
public Book update(Book book) {
return repository.save(book);
}
@CacheEvict 的 allEntries = true 会清空整个 cacheName 下的所有条目——对 Redis 是 SCAN + 批量删,条目多时会阻塞,能精确删就别用清空。
8.1.3 key 与 keyGenerator:SpEL 的写法陷阱
默认 key 规则是:无参用 SimpleKey.EMPTY,单参直接用该参数对象,多参用 SimpleKey(参数数组的包装)。这条默认规则在生产上几乎一定要覆盖,因为 SimpleKey 的 toString 依赖参数 hashCode,可读性和稳定性都差。
key 用 SpEL 表达式,常见写法:
@Cacheable(cacheNames = "book", key = "#id") // 按参数名引用
@Cacheable(cacheNames = "book", key = "#book.isbn") // 引用对象属性
@Cacheable(cacheNames = "book", key = "#root.methodName + ':' + #id")
@Cacheable(cacheNames = "book", key = "#p0") // 按位置引用(p0 起)
@Cacheable(cacheNames = "book", key = "#a0") // 同上,a0 是别名
三个必须知道的陷阱:
陷阱一:参数名默认不可用。 #id 依赖编译时保留参数名。Spring Boot 的父 POM 会为 maven-compiler-plugin 加上 -parameters,所以按官方方式构建时可用;但如果你自定义了编译插件配置、或把代码编译进一个没开 -parameters 的模块,#id 会在运行时抛 SpelEvaluationException,报「找不到属性 id」。此时改用 #p0 / #a0 位置引用最稳。
陷阱二:多参数必须显式指定 key。 两个及以上参数而不写 key 时,Spring 用 SimpleKey 拼接,缓存里存进去的 key 形如 SimpleKey [42,zh-CN]。这本身没错,但只要参数的 hashCode 不稳定(比如传了 Optional、数组、可变对象),key 就会漂移,表现为「明明缓存了却总不命中」。
陷阱三:#result 只在写回阶段可用。 key = "#result.id" 只对 @CachePut 或 @Cacheable 的回填阶段成立——因为那时方法已经执行完。所以 @Cacheable(key = "#result.id") 的「查缓存」阶段拿不到 #result,命中判断会失败。想在查询时按返回值的字段做 key,等于放弃命中,应该改成用入参做 key。
当 key 逻辑复杂(要拼前缀、要做归一化)时,别硬写长 SpEL,注册一个 KeyGenerator:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
KeyGenerator bookKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder(method.getName());
for (Object p : params) {
sb.append(':').append(p);
}
return sb.toString();
};
}
}
再在方法上写 @Cacheable(cacheNames = "book", keyGenerator = "bookKeyGenerator")。key 与 keyGenerator 互斥,同时指定会抛异常。
8.1.4 condition 与 unless:执行时机才是关键
两者都写 SpEL,但求值时机不同,这是唯一要记的区别:
| 属性 | 求值时机 | 能否引用 #result | 作用 |
|---|---|---|---|
condition | 方法执行前 | 不能 | 不满足则整个缓存逻辑跳过,直接执行方法 |
unless | 方法执行后 | 能 | 满足则不写入缓存(但方法已执行) |
典型组合:
@Cacheable(cacheNames = "book", key = "#id",
condition = "#id > 0",
unless = "#result == null")
public Book detail(Long id) {
return repository.findById(id).orElse(null);
}
condition = "#id > 0":非法 id 根本不去查缓存,避免污染。unless = "#result == null":查不到就不缓存,避免把null缓存起来。
注意 unless 的语义是「满足条件则不缓存」,写法上容易反直觉:unless = "#result == null" 读作「结果为空时不缓存」。若想缓存空值来防穿透(8.3 会讲),就要把 unless 去掉,并确保缓存能存 null(Redis 侧由 spring.cache.redis.cache-null-values 控制,默认允许)。
condition 对 @CachePut 和 @CacheEvict 同样生效;unless 只对 @Cacheable 和 @CachePut 有意义(因为只有它们有「写回」动作)。
8.1.5 CacheManager:抽象层的真正配置入口
注解只声明「用哪个 cacheName」,真正的存储、序列化、TTL 都在 CacheManager 里。默认情况下,classpath 上有 spring-boot-starter-cache 而无其他缓存实现时,Boot 自动配置一个 ConcurrentMapCacheManager——一个进程内的 ConcurrentHashMap。它没有 TTL、没有容量上限、重启即失效,只适合本地开发。
要换成 Redis,加 spring-boot-starter-data-redis 后 Boot 会自动配置 RedisCacheManager,用 spring.cache.redis.* 调默认值:
spring:
cache:
type: redis
cache-names: book,bookByIsbn,bookList,loanStats
redis:
time-to-live: 10m
key-prefix: "cache:"
use-key-prefix: true
cache-null-values: false
enable-statistics: true
需要「不同 cacheName 不同 TTL」时,属性不够用,得自己定义 RedisCacheManager:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();
return RedisCacheManager.builder(cf)
.cacheDefaults(base)
.withCacheConfiguration("book", base.entryTtl(Duration.ofMinutes(30)))
.withCacheConfiguration("loanStats", base.entryTtl(Duration.ofMinutes(1)))
.build();
}
}
@EnableCaching 必须有(放在任意 @Configuration 上,或直接用 @SpringBootApplication 自带的自动配置——Boot 检测到 CacheManager 会启用,但显式写出来更不容易漏)。
多级缓存(本地 + Redis)抽象层不直接支持。 Spring Cache 没有内置「先查 Caffeine、再查 Redis」的组合。可行做法有两种:一是自己实现 CacheManager,让返回的 Cache 内部持有两层;二是用 CompositeCacheManager 做「主 + 兜底」——但它按顺序查、任一层命中即返回,不会把下层结果回填到上层,语义上和真正的两级缓存有差距。生产上要真做多级,通常直接引入 JetCache 这类专门框架,而不是在 Spring Cache 上硬拼。
8.1.6 同类内部调用失效:AOP 代理的老问题
缓存注解和 @Transactional 一样,靠 AOP 代理生效。代理只在「外部通过 bean 引用调用」时介入;同一个类内部的方法互调走的是 this,绕过代理,注解全部失效。
@Service
public class BookService {
public Book detailWithFallback(Long id) {
return detail(id); // ❌ 走 this,@Cacheable 不生效
}
@Cacheable(cacheNames = "book", key = "#id")
public Book detail(Long id) {
return repository.findById(id).orElseThrow();
}
}
三种解法:
| 解法 | 做法 | 代价 |
|---|---|---|
| 拆类 | 把缓存方法挪到独立 bean,由另一个 bean 调用 | 最干净,推荐 |
| 自注入 | 注入自己的代理 @Lazy BookService self,调 self.detail(id) | 有循环依赖风险,需 @Lazy |
AopContext.currentProxy() | 开 exposeProxy 后取当前代理 | 侵入 Spring API,不推荐 |
诊断方法很直接:在 detail 里打断点,如果内部调用时断点进了方法体但缓存没命中/没写,就是代理没生效。也可以打开 logging.level.org.springframework.cache=TRACE 看缓存读写日志。
8.1.7 抽象层表达不了什么
写缓存代码前,先认清 Spring Cache 的边界,避免在错误的地方使劲:
| 想做的事 | 抽象层支持吗 | 该去哪里做 |
|---|---|---|
| 设置 TTL | ❌ 注解无此属性 | RedisCacheConfiguration.entryTtl / spring.cache.redis.time-to-live |
| 容量上限与 LRU 淘汰 | ❌ | Caffeine maximumSize / Redis maxmemory-policy |
| 自定义序列化 | ❌ | RedisCacheConfiguration.serializeValuesWith |
| 批量按模式删除 | ❌(allEntries 是整名清空) | 手写 RedisTemplate,或维护 key 集合 |
| 缓存预热 | ❌ | 启动时主动调用被缓存的方法 |
| 命中率统计 | 部分(enable-statistics) | 再接 Micrometer 看指标 |
| 分布式一致性失效 | ❌ | 消息广播 / binlog 订阅(8.3 讲) |
一句话总结这张表:注解是「声明式读取」的糖,不是「缓存系统」的全部。 TTL、淘汰、序列化、多级,全在 CacheManager 及其下游。所以选型时不要问「Spring Cache 能不能做 X」,要问「X 该由注解、CacheManager 还是 Redis 本身承担」。
小结
- Spring Cache 把「读缓存、回源、回填、失效」收进 AOP,适合读多写少、key 明确、失效简单的查询;代价是控制力下降。
- 四个注解语义不同:
@Cacheable命中不执行方法,@CachePut只写不读且总执行,@CacheEvict删缓存,@Caching组合多个操作;@Cacheable与@CachePut不可同标一方法。 - key 三个陷阱:参数名依赖
-parameters(否则用#p0)、多参默认走SimpleKey易漂移、#result只在写回阶段可用。 condition在方法执行前求值(不能引用#result),unless在执行后求值(可引用#result),后者语义是「满足则不缓存」。- 存储、TTL、序列化都在
CacheManager:默认是进程内ConcurrentMapCacheManager(无 TTL),生产换RedisCacheManager或用spring.cache.redis.*。 - 多级缓存抽象层不直接支持,
CompositeCacheManager也不回填上层,真要做需自实现或用专用框架。 - 缓存注解靠 AOP 代理生效,同类内部调用(
this.xxx())会绕过代理导致失效;诊断看org.springframework.cache的 TRACE 日志。 - 注解表达不了 TTL、容量淘汰、自定义序列化、模式删除、预热——这些是
CacheManager与 Redis 的职责。
抽象层选好了,下一节把它真正落到 Redis 上:接入 starter、选序列化方案、配连接池、定键命名,并处理大 key 与热 key。
阅读导航:上一节:7.3 API 版本管理 · 下一节:8.2 Redis 缓存实践 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。