Spring Cache(@Cacheable)是怎么工作的?它和具体的缓存实现(Redis/Caffeine)是什么关系?
简化版
Spring Cache 是一套「缓存抽象」——它用 @Cacheable/@CachePut/@CacheEvict 等注解,让你声明式地给方法加缓存,而不用关心底层用的是哪种缓存(Redis、Caffeine、EhCache 还是本地 Map)。怎么工作:本质是 AOP——给标了 @Cacheable 的方法织入一个「环绕通知」,方法调用时:先按 key 去缓存查,命中就直接返回缓存值、不执行方法(省掉查库);没命中才执行方法,把返回值存进缓存再返回。和具体缓存实现的关系:Spring Cache 只是「抽象层」,定义了 Cache、CacheManager 两个接口;真正存哪、怎么存由「缓存提供者」决定——引入 spring-boot-starter-cache + Redis,CacheManager 就是 RedisCacheManager(存 Redis);引入 Caffeine,就是本地内存缓存。换缓存实现只需换依赖和配置,注解不用动——这就是「抽象」的价值:面向注解编程,屏蔽底层缓存差异。
详细版
核心注解:
| 注解 | 作用 | 行为 |
|---|---|---|
@Cacheable | 查缓存 | 命中则返回缓存、不执行方法;未命中执行方法并缓存结果 |
@CachePut | 更新缓存 | 总是执行方法,把结果写入缓存(用于更新场景) |
@CacheEvict | 删缓存 | 执行方法后删除缓存条目(allEntries=true 清空) |
@Caching | 组合 | 一个方法上组合多个缓存操作 |
@CacheConfig | 类级配置 | 类上统一配 cacheNames 等 |
@Cacheable 的执行逻辑:
调用 getUser(1) —— 方法上有 @Cacheable(value="users", key="#id")
↓
① 用 keyGenerator/SpEL 算出 key(这里 key = 1)
② 去 CacheManager 的 "users" 这个 Cache 里查 key=1
↓
命中?── 是 → 直接返回缓存值,方法体不执行(省掉查库)
└ 否 → 执行方法体(查库)→ 把返回值存入缓存 → 返回
@Service
public class UserService {
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User getUser(Long id) { // 查:命中不执行、未命中查库并缓存
return userMapper.selectById(id);
}
@CachePut(value = "users", key = "#user.id")
public User update(User user) { // 改:总执行,结果写入缓存
userMapper.update(user); return user;
}
@CacheEvict(value = "users", key = "#id")
public void delete(Long id) { // 删:执行后清缓存
userMapper.deleteById(id);
}
}
⚠️ Spring Cache 是 AOP 实现的,所以「AOP 失效场景」它全中招:① 同类内部自调用(一个方法调本类的
@Cacheable方法)不走代理,缓存注解失效;②@Cacheable方法必须是public、通过代理对象调用才生效。另外几个易错点:unless="#result==null"常用来「结果为 null 就不缓存」(否则缓存空值可能不是你想要的,但要防缓存穿透时又可能故意缓存空值);@CachePut和@Cacheable区别——@CachePut总会执行方法(用于「改完同步缓存」),@Cacheable命中就跳过方法;key 用 SpEL(#id引用参数),写成key="id"会变成固定字符串导致所有调用共享一个缓存条目。
完整版教学
一、Spring Cache 是「抽象」不是「实现」
理解 Spring Cache 的第一要点:它是「抽象层」,本身不存数据:
Spring Cache 提供的是"缓存的编程模型"(注解 + 接口):
- 注解:@Cacheable / @CachePut / @CacheEvict
- 接口:Cache(一块缓存的抽象)、CacheManager(管理多个 Cache)
它本身不实现"数据存哪"——那是"缓存提供者"的事:
Redis → RedisCacheManager(存 Redis)
Caffeine → CaffeineCacheManager(本地内存)
EhCache → EhCacheCacheManager
简单 Map → ConcurrentMapCacheManager(默认,仅测试用)
类比 JDBC:
Spring Cache ≈ JDBC 接口(统一 API)
Redis/Caffeine ≈ 具体数据库驱动(真正干活)
→ 面向抽象编程,换实现不改业务代码
Spring Cache 是「缓存的抽象层」——它定义注解(@Cacheable 等)和接口(Cache/CacheManager),但不实现「数据存哪」,那由「缓存提供者」(Redis/Caffeine/EhCache)实现。这和 JDBC 一样:Spring Cache 是统一 API,具体缓存是「驱动」。价值是「面向抽象编程,换缓存实现不改业务代码」。理解「Spring Cache 是抽象层(注解+Cache/CacheManager 接口)、不实现存储、由缓存提供者实现、类比 JDBC」,就抓住了它的本质。
二、CacheManager 与 Cache:两个核心接口
Spring Cache 抽象的核心是两个接口:
Cache(一块命名缓存的抽象):
get(key) / put(key, value) / evict(key) / clear()
→ 代表一个具体的缓存区域(如 "users" 缓存、"products" 缓存)
CacheManager(缓存管理器,管多个 Cache):
getCache(name) → 按名字拿到一个 Cache
→ @Cacheable(value="users") 里的 "users" 就是 Cache 的名字
CacheManager.getCache("users") 拿到这块缓存
不同提供者的实现:
RedisCacheManager.getCache("users")
→ 返回一个 RedisCache,底层操作 Redis
CaffeineCacheManager.getCache("users")
→ 返回一个 CaffeineCache,底层是本地 Caffeine 缓存
所以:@Cacheable 注解 → 找 CacheManager → getCache(名字)
→ 在这个 Cache 上 get/put → 底层落到 Redis/Caffeine
两个核心接口:Cache(一块命名缓存,get/put/evict);CacheManager(管多个 Cache,getCache(name) 按名字取)。@Cacheable(value="users") 里的 "users" 就是 Cache 的名字——注解触发时,从 CacheManager 取名为 “users” 的 Cache,在上面 get/put,底层落到 Redis/Caffeine。不同提供者提供不同的 CacheManager/Cache 实现。理解「Cache 是一块命名缓存(get/put)、CacheManager 管多个 Cache(getCache 按名取)、@Cacheable 的 value 是 Cache 名、不同提供者不同实现」,就理解了抽象的核心结构。
三、@Cacheable 的 AOP 工作原理
Spring Cache 靠 AOP 工作——理解这点就理解了它的一切行为和坑:
@Cacheable 本质是给方法织入一个"环绕通知"(Around Advice):
代理对象拦截 getUser(1) 调用:
1. 前置:算 key(SpEL #id → 1),去 Cache 查
2. 命中 → 直接返回缓存值,★根本不执行原方法(不查库)
3. 未命中 → 执行原方法(查库)→ 拿到结果 → put 进 Cache → 返回
关键推论(因为是 AOP 代理):
- 只有"通过代理对象"调用才生效
- 同类内部自调用(this.getUser())不走代理 → 缓存失效
- private/final 方法无法被代理 → 注解无效
- 和 @Transactional、@Async 是同一类 AOP 失效问题
Spring Cache 的引擎是 AOP——@Cacheable 给方法织入「环绕通知」:调用时先查缓存,命中直接返回不执行原方法,未命中才执行并缓存。这解释了它的所有行为和坑:因为是代理,同类自调用(this.method())不走代理导致缓存失效、private/final 方法无法代理。它和 @Transactional、@Async 是同一类 AOP 失效问题(前面 AOP 题讲过自调用)。理解「@Cacheable 靠 AOP 环绕通知、命中不执行原方法、同类自调用失效(和 @Transactional 同类问题)」,就理解了它的工作原理和失效根源。
四、三大注解的区别与配合
@Cacheable、@CachePut、@CacheEvict 分工不同,配合使用保证「缓存与数据库一致」:
@Cacheable(读):命中就返回、不执行方法;未命中执行并缓存
用于:查询方法(getUser)
@CachePut(写):★总是执行方法,然后把结果写入缓存
用于:更新方法(updateUser)——改完库,顺便把新值同步进缓存
区别 @Cacheable:@Cacheable 命中会跳过方法,@CachePut 从不跳过
@CacheEvict(删):执行方法后删除缓存条目
用于:删除方法(deleteUser)——删库后把缓存也清掉
allEntries=true:清空整个 Cache
beforeInvocation=true:方法执行前就删(防方法异常导致没删成)
配合保证一致性(典型「更新数据库 + 维护缓存」):
查 → @Cacheable
改 → @CachePut(同步新值)或 @CacheEvict(直接删,下次查重新加载)
删 → @CacheEvict
三大注解分工:@Cacheable(读,命中跳过方法)、@CachePut(写,总执行方法并更新缓存)、@CacheEvict(删,清除缓存)。关键区别:@CachePut 从不跳过方法(用于更新时同步缓存),@Cacheable 命中就跳过。更新数据时有两种缓存策略:@CachePut 同步新值,或 @CacheEvict 直接删(下次查重新加载,更常用更安全)。@CacheEvict 的 beforeInvocation 控制删除时机。理解「@Cacheable 读跳过/@CachePut 写总执行/@CacheEvict 删、更新时用 CachePut 同步或 CacheEvict 删除保一致」,就掌握了三大注解的配合。
五、key 生成与条件控制
Spring Cache 用 SpEL 灵活控制「用什么 key、什么时候缓存」:
key(缓存的键):
key = "#id" 用方法参数 id 作 key(SpEL)
key = "#user.id" 用参数对象的属性
key = "#a0 + '_' + #a1" 多参数组合
不写 key → 用 KeyGenerator 默认生成(所有参数组合)
★ 注意:key="id" 是字符串字面量 "id",不是参数!要写 #id
condition(满足才缓存/查缓存):
condition = "#id > 10" 只有 id>10 才走缓存
unless(满足则不缓存结果):
unless = "#result == null" 结果为 null 不缓存
区别:condition 在方法执行前判断(能拿参数);
unless 在方法执行后判断(能拿 #result 结果)
sync(并发时只让一个线程加载,防缓存击穿):
@Cacheable(sync = true)
key 和条件控制都用 SpEL:key 定缓存键(#id 引用参数,注意 key="id" 是字面量不是参数);condition 定「满足才缓存」(方法前判断,能拿参数);unless 定「满足则不缓存」(方法后判断,能拿 #result);sync=true 并发时只让一个线程加载(防缓存击穿)。这些让缓存行为可精细控制。理解「key 用 SpEL #参数(别写成字面量)、condition 方法前判断、unless 方法后判断能用#result、sync 防击穿」,就掌握了缓存的精细控制。
六、换缓存实现:抽象的价值兑现
Spring Cache 抽象的价值,在「换缓存实现」时兑现:
同样的业务代码(@Cacheable 注解不变),换底层缓存:
用本地缓存 Caffeine(单机、快、无网络开销):
引入 caffeine 依赖 + 配置 CaffeineCacheManager
→ 缓存存 JVM 内存
用分布式缓存 Redis(多实例共享、可持久化):
引入 spring-boot-starter-data-redis + 配置 RedisCacheManager
→ 缓存存 Redis,多个应用实例共享同一份缓存
甚至多级缓存(Caffeine + Redis):
自定义 CacheManager,先查本地再查 Redis
★ 业务代码里的 @Cacheable/@CacheEvict 一个字都不用改!
只改依赖和配置 → 这就是"缓存抽象"的核心价值
选择:
单机/读多写少/能容忍短暂不一致 → 本地 Caffeine
多实例要共享缓存/要一致 → Redis
又要快又要共享 → 多级(本地 + Redis)
抽象的价值在「换实现不改代码」时兑现——同样的 @Cacheable 注解,换依赖和配置就能从本地 Caffeine 切到分布式 Redis,甚至组合成多级缓存,业务代码一个字不改。选择:单机读多写少用本地 Caffeine(快)、多实例共享用 Redis、又快又共享用多级。这正是「面向抽象编程」的回报。理解「换缓存实现只改依赖和配置、@Cacheable 注解不动、Caffeine(本地快)/Redis(分布式共享)/多级、抽象价值在换实现时兑现」,就理解了 Spring Cache 抽象的终极意义。
记忆钩子:「Spring Cache = 缓存抽象层(注解 @Cacheable/@CachePut/@CacheEvict + 接口 Cache/CacheManager),不实现存储、由提供者(Redis/Caffeine)实现,类比 JDBC;靠 AOP 环绕通知工作:@Cacheable 命中直接返回不执行方法、未命中执行并缓存→所以同类自调用失效(和 @Transactional 同类问题);三注解:@Cacheable 读(命中跳过)/@CachePut 写(总执行更新缓存)/@CacheEvict 删;key/condition/unless 用 SpEL(key=#id 别写字面量 id、unless 用#result、sync 防击穿);换缓存只改依赖配置、注解不动」。
七、常见误区与追问
- 误区:Spring Cache 自己会存数据。 不会——它只是抽象层(注解 + Cache/CacheManager 接口),真正存哪由缓存提供者实现(Redis/Caffeine/EhCache);不引入提供者时默认用 ConcurrentMapCacheManager(内存 Map,仅测试用)。
- 误区:@Cacheable 加了就一定生效。 它靠 AOP 代理——同类内部自调用(this.method())不走代理会失效、private/final 方法无法代理、必须 public 且通过代理对象调用;和 @Transactional/@Async 是同一类失效问题。
- 误区:@Cacheable 和 @CachePut 一样。 @Cacheable 命中缓存就跳过方法(读场景);@CachePut 总是执行方法再把结果写入缓存(更新场景,「改完同步缓存」);用错会导致该更新时没执行、或该缓存时重复查库。
- 误区:key=“id” 能用方法参数 id。 key 是 SpEL,key=“id” 是字符串字面量 “id”(所有调用共用一个 key,缓存串数据);引用参数要写 key=“#id”。
- 追问:更新数据时,缓存该用 @CachePut 还是 @CacheEvict? 两种策略:@CachePut 把新值同步进缓存(读多且更新后立刻会读时好);@CacheEvict 直接删缓存、下次查再重新加载(更简单更安全,避免 @CachePut 和缓存的复杂一致性问题)——实践中 @CacheEvict「删除」更常用。
- 追问:condition 和 unless 有什么区别? condition 在方法执行「前」判断(只能用参数,如 #id>10 才走缓存);unless 在方法执行「后」判断(能用 #result 结果,如 #result==null 就不缓存);一个控制「要不要走缓存流程」,一个控制「拿到结果后要不要缓存」。
- 追问:怎么防缓存击穿(热点 key 失效瞬间大量请求打到库)? @Cacheable(sync=true)——同一个 key 未命中时,只让一个线程执行方法加载、其他线程等待并共享结果,避免大量并发同时查库;本质是加载加锁。
八、加强记忆
Spring Cache 是一套「缓存抽象」——用 @Cacheable/@CachePut/@CacheEvict 注解声明式给方法加缓存,不关心底层是 Redis/Caffeine/EhCache(类比 JDBC:它是统一 API,具体缓存是「驱动」)。它定义两个核心接口:Cache(一块命名缓存,get/put/evict)和 CacheManager(管多个 Cache,getCache(name) 按名取,@Cacheable(value="users") 的 “users” 就是 Cache 名);不实现存储,由缓存提供者提供 RedisCacheManager/CaffeineCacheManager 等实现。工作原理是 AOP——@Cacheable 织入环绕通知:命中缓存直接返回、不执行原方法,未命中才执行并缓存;所以同类内部自调用会失效(和 @Transactional/@Async 同类问题)。三注解:@Cacheable(读,命中跳过方法)、@CachePut(写,总执行方法并更新缓存)、@CacheEvict(删缓存);更新数据时用 @CachePut 同步或 @CacheEvict 删除(后者更常用)。key/condition/unless 用 SpEL(key="#id" 引用参数,别写成字面量 "id";condition 方法前判断、unless 方法后判断能用 #result;sync=true 防缓存击穿)。换缓存实现只改依赖和配置、注解不动——这就是抽象的价值。一句话「Spring Cache 是缓存抽象(注解+Cache/CacheManager,不实现存储由 Redis/Caffeine 实现),靠 AOP 环绕通知(命中不执行方法、同类自调用失效);@Cacheable 读/@CachePut 写总执行/@CacheEvict 删,key 用#参数别写字面量;换实现只改配置注解不动」。