《Spring Boot 实战》8.1 Spring Cache 抽象

把缓存声明式地加到图书借阅服务上:讲清 @Cacheable / @CachePut / @CacheEvict / @Caching 的语义差异、key 与 SpEL 的写法陷阱、condition 与 unless 的执行时机、CacheManager 与多级缓存配置,以及同类内部调用失效和 TTL 表达边界。

本节目标:把缓存声明式地加到「图书借阅管理服务」上,并讲清抽象层能表达什么、不能表达什么——哪些活该交给注解,哪些必须落到 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 缓存实践 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计