概述
为什么需要缓存、手写缓存会踩哪些坑,在通用缓存SpringCache:问题引入中已经推导过,这里不再重复。本文解决另一个问题:不写任何缓存读写代码,只靠注解,能不能把缓存做出来。答案是 SpringCache,十秒钟就能让一个查库方法拥有缓存能力,下面从概念、注解体系到第一个可运行的案例完整走一遍。
纲要
- SpringCache 是什么:缓存抽象层,接口与实现分离
- 注解体系:@EnableCaching / @Cacheable / @CachePut / @CacheEvict / @Caching 速查表
- 入门案例:引入依赖 → 开启缓存 → @Cacheable 标注方法,运行验证缓存命中
- 底层实现切换:默认 ConcurrentMap,生产环境换 Redis
- AOP 代理原理:为什么同类内部调用会让注解失效
- 踩坑速查与常见误解澄清
SpringCache 是什么
SpringCache(官方名 Spring Cache,属于 spring-context-support 模块)是 Spring 提供的缓存抽象层。它利用 AOP 实现了基于注解的缓存功能:开发者只在方法上标注解声明「这个方法的结果要缓存」,至于「缓存读写到哪里、怎么存怎么取」,全部由框架的代理拦截器完成。
这里有个容易忽略的定位问题:SpringCache 本身不实现缓存。它只定义了一套接口,Cache(一次缓存操作)和 CacheManager(管理一组缓存),真正的存取动作由底层实现完成。这个「接口与实现分离」的设计带来的直接好处是:注解代码一行不改,底层可以从内存缓存换成 Redis。
与声明式事务 @Transactional 是同一个思想:方法级的声明式缓存。事务不用手写 commit/rollback,缓存也不用自己读写 Map,都是 AOP 代理在方法前后织入了通用逻辑。
注解体系
SpringCache 的全部日常使用就是五个注解:
| 注解 | 作用 | 典型位置 | 注意事项 |
|---|---|---|---|
@EnableCaching | 开启缓存功能,没有它其余注解全部无效 | 启动引导类 | 只需加一次 |
@Cacheable | 先查缓存,命中直接返回;未命中执行方法并把结果放入缓存 | 查询方法 | 最重要的注解;value 指定命名空间(必填),key 指定缓存键(支持 SpEL) |
@CachePut | 总是执行方法,并用返回值更新缓存 | 写操作需要同步刷新缓存时 | 与 @Cacheable 的本质区别:不查缓存、方法必执行 |
@CacheEvict | 清除缓存 | 更新/删除方法 | key 清单个,allEntries = true 清整个命名空间 |
@Caching | 组合多个缓存注解 | 一个方法要同时做多种缓存操作 | 例如同时 evict 两个 key |
@Cacheable 与 @CachePut 的区别值得单独记住:
| 对比项 | @Cacheable | @CachePut |
|---|---|---|
| 先查缓存? | 是,命中就不执行方法 | 否 |
| 方法何时执行 | 仅缓存未命中时 | 每次都执行 |
| 返回值去向 | 未命中时写入缓存 | 总是写入缓存 |
| 适用场景 | 查询 | 写操作后同步缓存 |
@CacheEvict 的两种清除粒度:key = "#id" 只删某一条,allEntries = true 把 value 命名空间下的全部条目一次清空,列表类数据被写操作污染后往往整批失效,后者反而更常用(交友项目发布视频清空视频列表缓存就是这么做的)。
入门案例
使用 SpringCache 一共三步:引依赖 → 开启缓存 → 方法上加注解。下面配套了一个基础工程 spring-cache-demo,先看它的结构:
spring-cache-demo
├── pom.xml
└── src
├── main
│ ├── java/com/wb/cache
│ │ ├── CachingApplication.java # 引导类,加 @EnableCaching
│ │ ├── domain/User.java # 实体类,实现 Serializable
│ │ ├── dao/UserDao.java # 模拟数据库查询,睡眠 2 秒
│ │ └── service/UserService.java # 业务层,缓存注解加在这里
│ └── resources/application.yml
└── test/java/com/wb/cache/test
└── UserServiceTest.java # 单元测试,循环调用验证
这个场景在交友项目里随处可见:用户资料、视频列表这类读多写少的数据,每次都查库代价太高。
第一步:引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
第二步:开启缓存支持
package com.wb.cache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching //开启缓存
public class CachingApplication {
public static void main(String[] args) {
SpringApplication.run(CachingApplication.class, args);
}
}
第三步:@Cacheable 标注查询方法
先看没有缓存时的 Dao 层,用睡眠 2 秒模拟查库耗时:
package com.wb.cache.dao;
import com.wb.cache.domain.User;
import org.springframework.stereotype.Repository;
@Repository
public class UserDao {
public User findById(Long id) {
System.out.println("查询数据库");
try {
// 睡眠 2 秒,模拟查询数据库的耗时
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
return new User(id, "张三");
}
public void update(Long id) {
System.out.println("根据id更新");
}
}
业务层加上 @Cacheable:
package com.wb.cache.service;
import com.wb.cache.dao.UserDao;
import com.wb.cache.domain.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private UserDao userDao;
/**
* value:缓存名称空间(分组)
* key:缓存键,支持 SpEL,#id 引用方法参数
* Redis 下的键命名规则:value + "::" + key
*/
@Cacheable(value = "user", key = "#id")
public User findById(Long id) {
return userDao.findById(id);
}
}
value 是必填的,它指定了缓存存放在哪个命名空间;key 用 SpEL 表达式引用方法参数,最终 Redis 里的键形如 user::2。同一个 value 下不同 key 互不干扰,不同 value 之间完全隔离。
运行验证
写一个循环调用五次的测试:
package com.wb.cache.test;
import com.wb.cache.domain.User;
import com.wb.cache.service.UserService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testFindById() {
// 循环调用五次,观察耗时与数据库访问次数
for (int i = 0; i < 5; i++) {
User user = userService.findById(2L);
System.out.println(user);
}
}
}
先注释掉 @Cacheable 跑一遍做对照:五次调用全部落到数据库,控制台打印五次「查询数据库」,总耗时 10 秒以上。
加回 @Cacheable 再跑:第一次打印「查询数据库」(约 2 秒),后四次没有任何数据库日志,瞬间返回。这就是缓存生效的直接证据。
想看得更细,可以把日志级别调到 debug 观察 CacheInterceptor 的命中记录:
logging:
level:
org.springframework.cache: debug
底层实现切换
入门案例默认跑在 ConcurrentMapCacheManager 上,缓存存在 JVM 内存 Map 里。它有两个致命限制:
| 对比项 | ConcurrentMap(默认) | Redis |
|---|---|---|
| 存储位置 | JVM 内存 | 独立缓存服务 |
| 重启后 | 缓存全部丢失 | 数据还在 |
| 过期时间 | 不支持 TTL | 支持 |
| 多实例共享 | 不支持(各存各的) | 支持 |
| 适用场景 | 单机演示、开发调试 | 生产环境 |
好消息是切换成本几乎为零:引入 spring-boot-starter-data-redis 并在 yml 里配上连接,Spring Boot 检测到 Redis 后自动把 CacheManager 换成 RedisCacheManager,业务注解一行不动,这就是开头说的「接口与实现分离」。
spring:
redis:
host: 192.168.136.160
port: 6379
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
按缓存名称空间设置不同 TTL(如视频列表缓存 30 秒)的 RedisCacheManagerBuilderCustomizer 详细配置,属于通用缓存SpringCache:配置视频列表缓存功能的内容,这里只需要知道「换了 Redis 就能配过期时间」。
AOP 代理的原理
@Cacheable 生效的前提是调用方拿到的是 Spring 创建的代理对象:外部注入 UserService 时拿到的其实是缓存的代理子类/包装类,调用 findById 会先经过 CacheInterceptor。推论就是那条经典大坑,同类内部调用不走代理:
@Service
public class UserService {
public void someMethod() {
// 内部 this 调用,绕过了代理,@Cacheable 完全失效,方法体照样执行
this.findById(2L);
}
@Cacheable(value = "user", key = "#id")
public User findById(Long id) { ... }
}
this.findById(2L) 里的 this 是原始对象不是代理,缓存拦截器根本没机会介入。和 @Transactional、@Async 是同一个坑,根源也相同:Spring 只能增强它自己创建的对象,你绕过它 new 出来或 this 调用的,它管不着。解法是把被调方法拆到另一个 Bean,或注入自身的代理。
踩坑速查
| 坑 | 现象 | 原因与解法 |
|---|---|---|
| 同类自调用注解失效 | 内部调用了带 @Cacheable 的方法,缓存不生效、方法照常执行 | this 调用不走 AOP 代理。拆分到另一个 Bean,或注入自身代理 |
@Cacheable 方法返回 null | 默认 null 也会被缓存(底层存 NullValue 占位对象),下次直接命中 null,数据库永远不再被查 | 加 unless = "#result == null" 让空结果不进缓存 |
| key 冲突 / 漏配 | 不同方法共用同一个 value 却忘了写 key,数据互相覆盖;或两个入口用不同 key 写了同一份数据,出现两份缓存 | 同一命名空间内明确每个方法的 key 规则,坚持 value + key 二元组全局唯一 |
| 底层实现未配 Redis | 演示环境一切正常,上线后重启缓存全丢、多实例数据不一致 | 默认是内存缓存。引入 Redis 依赖 + yml 配置,确认 CacheManager 是 RedisCacheManager |
@CachePut 与 @Cacheable 混用 | 想着「先查缓存、没有再放」,两个注解同时加在方法上,结果每次都执行方法 | @CachePut 总是执行方法。查询用 @Cacheable,写操作同步缓存才用 @CachePut,二选一 |
常见误解澄清
| 误解 | 事实 |
|---|---|
| SpringCache 是一个缓存中间件,和 Redis 是竞品 | 它是缓存抽象层,不含任何缓存实现;Redis 是它最常见的底层实现之一 |
加了 @Cacheable 缓存就自动生效 | 前提是启动类有 @EnableCaching,且方法是通过代理对象调用的 |
value 只是随便起的名字 | 它决定缓存的命名空间,Redis 下直接参与真实键的构成(value::key),也是 allEntries 清除的范围 |
@Cacheable 返回 null 会抛异常 | 默认不会抛异常,而是把 null 也缓存起来;要用 unless 显式排除 |
| 注解方法内再调用其他缓存方法也走缓存 | 同类内部调用不走代理,不走缓存 |
官方文档
总结
- SpringCache 的本质是缓存抽象层 + AOP 声明式缓存:注解声明意图,代理拦截执行,底层实现可替换。
- 入门三步:
spring-boot-starter-cache依赖、启动类@EnableCaching、查询方法上@Cacheable(value = "...", key = "...")。 @Cacheable管读(命中即返回),@CachePut管写后同步,@CacheEvict管失效,@Caching负责组合。- 默认的 ConcurrentMap 只适合演示,生产环境引入 Redis 依赖即自动切换到
RedisCacheManager。 - 自调用失效是所有 AOP 注解的通病,记住「Spring 只增强它创建的对象」。
后续可以顺着这里的工程继续,把 @CachePut、@Caching 等注解逐个跑一遍,对应通用缓存SpringCache:入门案例,之后进入通用缓存SpringCache:常用注解的完整注解详解。
5172



被折叠的 条评论
为什么被折叠?



