接口太慢?Spring Boot 缓存体系 @Cacheable 全链路拆解
每次查接口都要 200ms,加了缓存直接降到 2ms。但 @Cacheable 到底是怎么工作的?缓存什么时候生效、什么时候失效?多级缓存怎么搭?这篇把 Spring Boot 缓存体系从注解到源码一次性拆透。
一、问题:为什么需要缓存
一个典型的商品详情接口,每次请求都要:
markdown
用户请求 → 查商品基本信息 → 查 SKU 列表 → 查库存 → 查价格策略 → 查评价摘要
↓ ↓ ↓ ↓ ↓
DB 20ms DB 30ms DB 15ms DB 20ms DB 25ms
总计 ≈ 110ms
加上网络开销,接口响应 200ms+。但商品信息一天才变几次?99% 的请求都在重复查数据库。
缓存的核心思路:第一次查完把结果存起来,后续请求直接返回存储的结果,跳过数据库。
css
用户请求 → [缓存层] → 命中?→ 直接返回(2ms)
↓ 未命中
数据库查询 → 写入缓存 → 返回(110ms)
Spring Boot 提供了一套完整的缓存抽象,核心是三个注解:@Cacheable、@CachePut、@CacheEvict。
二、三大核心注解
2.1 @Cacheable:查缓存,没有就查库并写入
java
@Service
public class ProductService {
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
// 只有缓存未命中时才执行这个方法
return productMapper.selectById(id);
}
}
执行流程:
scss
调用 getProductById(1)
↓
┌─────────────────────────────────┐
│ 1. 生成 Key: product::1 │
│ 2. 查缓存 │
│ 3. 命中?→ 返回缓存值,方法不执行 │
│ 4. 未命中?→ 执行方法 │
│ 5. 将返回值写入缓存 │
│ 6. 返回结果 │
└─────────────────────────────────┘
2.2 @CachePut:总是执行方法,更新缓存
java
@CachePut(value = "product", key = "#product.id")
public Product updateProduct(Product product) {
productMapper.updateById(product);
return product; // 返回值会覆盖缓存
}
| 对比 | @Cacheable | @CachePut |
|---|---|---|
| 方法是否执行 | 缓存命中时不执行 | 总是执行 |
| 缓存操作 | 读 + 写(按需) | 只写(覆盖) |
| 典型场景 | 查询方法 | 更新方法 |
2.3 @CacheEvict:删除缓存
java
@CacheEvict(value = "product", key = "#id")
public void deleteProduct(Long id) {
productMapper.deleteById(id);
}
// 清除整个 product 缓存空间的所有 key
@CacheEvict(value = "product", allEntries = true)
public void refreshAllProducts() {
// 批量更新后清空整个缓存
}
2.4 组合使用
java
@Service
public class ProductService {
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
@CachePut(value = "product", key = "#product.id")
public Product updateProduct(Product product) {
productMapper.updateById(product);
return product;
}
@CacheEvict(value = "product", key = "#id")
public void deleteProduct(Long id) {
productMapper.deleteById(id);
}
}
三、缓存抽象层:CacheManager SPI
Spring 不绑定具体缓存实现,而是定义了 CacheManager 接口:
scss
┌─────────────────────────────────────────────┐
│ Spring 缓存抽象层 │
│ CacheOperations ← 注解驱动 │
│ CacheManager ← SPI 接口 │
│ Cache ← 缓存操作接口 │
└───────────────────┬─────────────────────────┘
│ 实现
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────────┐ ┌───────────┐
│Caffeine│ │ Redis │ │ EhCache │
│(本地) │ │ (分布式) │ │ (本地/分布式)│
└────────┘ └────────────┘ └───────────┘
3.1 常用缓存实现对比
| 维度 | Caffeine | Redis | EhCache |
|---|---|---|---|
| 类型 | 本地缓存 | 分布式缓存 | 本地/分布式 |
| 性能 | 极高(纳秒级) | 高(毫秒级,受网络影响) | 高 |
| 容量 | 受 JVM 内存限制 | 受服务器内存限制 | 可持久化到磁盘 |
| 过期策略 | W-TinyLFU + 时间 | TTL + LRU | LRU + 时间 |
| 多实例同步 | 不支持 | 天然支持 | 支持(集群模式) |
| 适用场景 | 单机、读多写少 | 分布式、跨实例共享 | 大数据量本地缓存 |
3.2 引入 Caffeine(本地缓存)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
java
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES) // 写入后 30 分钟过期
.expireAfterAccess(10, TimeUnit.MINUTES) // 最后一次访问后 10 分钟过期
.initialCapacity(100) // 初始容量
.maximumSize(1000)); // 最大缓存条目
return cacheManager;
}
}
3.3 引入 Redis(分布式缓存)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml
spring:
cache:
type: redis
redis:
time-to-live: 30m # 默认过期时间
cache-null-values: false # 是否缓存 null 值
key-prefix: "myapp::" # key 前缀
use-key-prefix: true
redis:
host: 127.0.0.1
port: 6379
四、@Cacheable 源码拆解
Spring 缓存的本质是 AOP。当调用 @Cacheable 方法时,实际调用的是代理对象,经过 CacheInterceptor 拦截后才到目标方法。
4.1 AOP 拦截链路
scss
调用方
↓
代理对象 (Proxy)
↓
CacheInterceptor.invoke() ← AOP 拦截器
↓
CacheAspectSupport.execute() ← 核心缓存逻辑
↓
目标方法 (getProductById)
↓
返回结果
4.2 CacheInterceptor 源码骨架
java
// 缓存拦截器,继承自 CacheAspectSupport
public class CacheInterceptor extends CacheAspectSupport
implements MethodInterceptor, Serializable {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
CacheOperationInvoker aopAllianceInvoker = () -> {
try {
return invocation.proceed(); // 执行目标方法
} catch (Throwable ex) {
throw new ThrowableWrapper(ex);
}
};
// 调用父类的 execute 方法,处理缓存逻辑
return execute(aopAllianceInvoker, invocation.getThis(), method,
invocation.getArguments());
}
}
4.3 CacheAspectSupport.execute 核心流程
java
public Object execute(CacheOperationInvoker invoker, Object target,
Method method, Object[] args) {
// 1. 解析方法上的缓存注解,获取 CacheOperation
Collection<CacheOperation> operations = getOperations(method, targetClass);
// 2. 创建 CacheOperationContexts(封装所有操作的上下文)
CacheOperationContexts contexts = new CacheOperationContexts(operations, args, target, method);
// 3. 处理 @Cacheable ------ 查缓存
Object result = processCacheable(contexts, invoker);
// 4. 处理 @CachePut ------ 更新缓存
processCachePut(contexts, result);
// 5. 处理 @CacheEvict ------ 删除缓存
processCacheEvict(contexts, result, true); // beforeInvocation=false
processCacheEvict(contexts, result, false); // beforeInvocation=true
return result;
}
4.4 @Cacheable 核心判断逻辑
java
private Object processCacheable(CacheOperationContexts contexts,
CacheOperationInvoker invoker) {
for (CacheOperationContext context : contexts.get(CacheableOperation.class)) {
// 1. 生成 cache key
Object key = generateKey(context);
// 2. 遍历所有缓存名称
for (Cache cache : context.getCaches()) {
// 3. 查缓存
Optional<Object> cachedValue = cache.get(key);
if (cachedValue.isPresent()) {
// 4. 命中 → 直接返回,不执行方法
return cachedValue.get();
}
}
}
// 5. 未命中 → 执行目标方法
Object result = invoker.invoke();
// 6. 将结果写入所有缓存
for (CacheOperationContext context : contexts.get(CacheableOperation.class)) {
Object key = generateKey(context);
for (Cache cache : context.getCaches()) {
cache.put(key, result);
}
}
return result;
}
4.5 Key 生成机制
java
// 默认使用 SimpleKeyGenerator
public class SimpleKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
if (params.length == 0) {
return SimpleKey.EMPTY; // 无参数 → SimpleKey.EMPTY
}
if (params.length == 1) {
return params[0]; // 单参数 → 直接用参数值
}
return new SimpleKey(params); // 多参数 → SimpleKey 包裹
}
}
Key 格式:{cacheName}::{key}
| 注解配置 | 生成的 Key |
|---|---|
@Cacheable(value="product", key="#id") |
product::1 |
@Cacheable(value="product", key="#p0") |
product::1 |
@Cacheable(value="product")(无 key) |
product::SimpleKey[1] |
@Cacheable(value="product", key="#user.id + '_' + #type") |
product::1001_VIP |
4.6 自定义 Key 生成器
java
@Component
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName()).append(":");
sb.append(method.getName()).append(":");
for (Object param : params) {
sb.append(param != null ? param.toString() : "null").append(":");
}
return sb.toString().hashCode();
}
}
// 使用
@Cacheable(value = "product", keyGenerator = "customKeyGenerator")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
五、条件缓存
5.1 condition vs unless
java
// condition:方法执行前判断,满足条件才查/写缓存
@Cacheable(value = "product", key = "#id", condition = "#id > 0")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
// unless:方法执行后判断,满足条件不写入缓存
@Cacheable(value = "product", key = "#id", unless = "#result == null")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
// 组合使用:id > 0 才查缓存,结果为 null 不写入缓存
@Cacheable(value = "product", key = "#id",
condition = "#id > 0",
unless = "#result == null || #result.stock == 0")
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
| 维度 | condition | unless |
|---|---|---|
| 判断时机 | 方法执行前 | 方法执行后 |
| 影响 | 是否查/写缓存 | 是否写入缓存 |
| 能否用到 result | 不能 | 能 |
| 典型场景 | 参数过滤 | 过滤空值/异常值 |
5.2 SpEL 上下文变量
| 变量 | 说明 | 示例 |
|---|---|---|
#id |
方法参数名 | key="#id" |
#p0 |
方法参数索引 | key="#p0" |
#root.methodName |
方法名 | condition="#root.methodName=='getXxx'" |
#root.target |
目标对象 | |
#result |
返回值(仅 unless 可用) | unless="#result==null" |
六、多级缓存架构
单层缓存在分布式场景下有局限:本地缓存无法跨实例共享,分布式缓存每次都要网络请求。多级缓存可以兼顾两者优势。
yaml
┌────────────────────────────────────────────────────┐
│ 请求入口 │
└────────────────────┬───────────────────────────────┘
▼
┌─────────────────┐
│ L1: 本地缓存 │ ← Caffeine (纳秒级)
│ (进程内) │
└────────┬────────┘
│ 未命中
▼
┌─────────────────┐
│ L2: 分布式缓存 │ ← Redis (毫秒级)
│ (跨实例共享) │
└────────┬────────┘
│ 未命中
▼
┌─────────────────┐
│ 数据库 │ ← MySQL (十毫秒级)
└─────────────────┘
6.1 自定义多级 CacheManager
java
public class MultiLevelCacheManager implements CacheManager {
private final CacheManager localCache; // Caffeine
private final CacheManager remoteCache; // Redis
public MultiLevelCacheManager(CacheManager local, CacheManager remote) {
this.localCache = local;
this.remoteCache = remote;
}
@Override
public Cache getCache(String name) {
Cache local = localCache.getCache(name);
Cache remote = remoteCache.getCache(name);
return new MultiLevelCache(local, remote);
}
@Override
public Collection<String> getCacheNames() {
return localCache.getCacheNames();
}
}
6.2 MultiLevelCache 实现
java
public class MultiLevelCache implements Cache {
private final Cache localCache;
private final Cache remoteCache;
@Override
public ValueWrapper get(Object key) {
// 1. 先查 L1 本地缓存
ValueWrapper value = localCache.get(key);
if (value != null) {
return value; // L1 命中
}
// 2. L1 未命中,查 L2 分布式缓存
value = remoteCache.get(key);
if (value != null) {
// 3. 回填 L1
localCache.put(key, value.get());
return value;
}
return null; // 都未命中
}
@Override
public void put(Object key, Object value) {
// 同时写入 L1 和 L2
localCache.put(key, value);
remoteCache.put(key, value);
}
@Override
public void evict(Object key) {
// 同时清除 L1 和 L2
localCache.evict(key);
remoteCache.evict(key);
}
}
6.3 多级缓存效果对比
| 场景 | 无缓存 | 仅 L1 (Caffeine) | 仅 L2 (Redis) | L1 + L2 |
|---|---|---|---|---|
| 首次请求 | 110ms | 110ms | 110ms | 110ms |
| 同实例重复 | 110ms | 0.01ms | 2ms | 0.01ms |
| 跨实例重复 | 110ms | 110ms | 2ms | 2ms |
| 数据更新 | - | 需广播清除 | 自动清除 | 自动清除 L2 |
七、缓存三大经典问题
7.1 缓存穿透
问题:查询一定不存在的数据,缓存永远不会命中,每次都查数据库。
ini
请求(id=-1) → 缓存未命中 → DB 查不到 → 不写缓存 → 下次还是穿透
解法:缓存空值
java
@Cacheable(value = "product", key = "#id", unless = "#result == null")
public Product getProductById(Long id) {
Product product = productMapper.selectById(id);
if (product == null) {
// 返回一个空对象标记,而不是 null
// unless 条件中 #result == null 会让 null 不被缓存
// 这里可以改为缓存空标记
}
return product;
}
// 或者用布隆过滤器
public Product getProductById(Long id) {
// 布隆过滤器先判断是否存在
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,直接返回
}
return getProductFromCache(id);
}
7.2 缓存雪崩
问题:大量缓存同时过期,请求全部打到数据库。
解法:过期时间加随机值
java
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
// 过期时间 = 基础时间 + 随机偏移,避免同时过期
.expireAfterWrite(Duration.ofMinutes(25 + ThreadLocalRandom.current().nextInt(10)))
.maximumSize(10000));
return manager;
}
}
7.3 缓存击穿
问题:一个热点 key 过期瞬间,大量并发请求同时查数据库。
解法:加锁,只让一个请求查数据库
java
@Cacheable(value = "product", key = "#id")
public Product getProductById(Long id) {
// Spring Cache 本身不加锁
return productMapper.selectById(id);
}
// 手动加锁
public Product getProductWithLock(Long id) {
String cacheKey = "product:" + id;
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 双重检查锁
synchronized (this) {
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
product = productMapper.selectById(id);
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
}
7.4 三大问题对比
| 问题 | 触发条件 | 根因 | 解法 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存没存空值 | 缓存空值/布隆过滤器 |
| 雪崩 | 大量缓存同时过期 | 过期时间相同 | 随机过期时间 |
| 击穿 | 热点 key 过期瞬间 | 并发查库无锁 | 互斥锁/永不过期 |
八、序列化踩坑
8.1 Redis 缓存序列化问题
Spring Boot 默认使用 JDK 序列化,存入 Redis 的是二进制,可读性差且跨语言不兼容。
java
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
// 自定义序列化配置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
8.2 序列化方案对比
| 序列化方式 | 可读性 | 性能 | 跨语言 | 体积 |
|---|---|---|---|---|
| JDK | 差 | 中 | 否 | 大 |
| JSON(Jackson) | 好 | 中 | 是 | 小 |
| Protobuf | 差 | 高 | 是 | 最小 |
| Hessian | 差 | 高 | 是 | 小 |
8.3 实体类必须实现 Serializable
java
// 如果使用 JDK 序列化,实体类必须实现 Serializable
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
// ...
}
// JSON 序列化不需要,但建议加默认构造方法
public class Product {
private Long id;
private String name;
private BigDecimal price;
// 必须有无参构造方法
public Product() {}
// getter/setter...
}
九、@Caching 组合注解
需要同时执行多个缓存操作时,用 @Caching:
java
@Caching(
put = {
@CachePut(value = "product", key = "#product.id"),
@CachePut(value = "product:detail", key = "#product.id + ':detail'"),
@CachePut(value = "product:summary", key = "#product.categoryId + ':summary'")
}
)
public Product updateProduct(Product product) {
productMapper.updateById(product);
return product;
}
9.1 自定义注解简化
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Caching(
cacheable = {
@Cacheable(value = "product", key = "#id")
},
put = {
@CachePut(value = "product:detail", key = "#id + ':detail'", condition = "#result != null")
}
)
public @interface CacheableProduct {
}
java
@CacheableProduct
public Product getProductById(Long id) {
return productMapper.selectById(id);
}
十、缓存监控与指标
10.1 Caffeine 内置统计
java
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()); // 开启统计
return manager;
}
// 查看统计信息
@Autowired
private CacheManager cacheManager;
public void printCacheStats() {
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("product");
CacheStats stats = cache.getNativeCache().stats();
log.info("命中率: {}", stats.hitRate());
log.info("命中次数: {}", stats.hitCount());
log.info("未命中次数: {}", stats.missCount());
log.info("加载次数: {}", stats.loadCount());
log.info("加载总耗时: {}ms", stats.totalLoadTime() / 1_000_000);
log.info("驱逐次数: {}", stats.evictionCount());
}
10.2 典型监控指标
| 指标 | 含义 | 关注点 |
|---|---|---|
| hitRate | 命中率 | 低于 80% 需排查 |
| missCount | 未命中次数 | 突增可能有穿透 |
| evictionCount | 驱逐次数 | 频繁驱逐说明容量不够 |
| totalLoadTime | 加载耗时 | 判断缓存回源性能 |
| averageLoadTime | 平均加载时间 | 判断 DB 查询性能 |
十一、总结:Spring 缓存体系全景
less
┌─────────────────────────────────────────────────────────┐
│ 开发者视角 │
│ @Cacheable @CachePut @CacheEvict @Caching │
│ │ │ │ │ │
│ └──────────┴──────────┴───────────┘ │
│ ↓ │
│ Spring Cache Abstraction │
│ (CacheManager / Cache / KeyGenerator) │
│ ↓ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Caffeine Redis EhCache │
│ (本地) (分布式) (混合) │
│ │ │ │ │
│ └──────────────┴──────────────┘ │
│ ↓ │
│ CacheInterceptor (AOP) │
│ 1. 解析注解 2. 生成Key 3. 查缓存 │
│ 4. 执行方法 5. 写缓存 6. 清缓存 │
└─────────────────────────────────────────────────────────┘
核心要点:
| 要点 | 说明 |
|---|---|
| 注解驱动 | @Cacheable 查缓存,@CachePut 更新缓存,@CacheEvict 清除缓存 |
| AOP 拦截 | 缓存逻辑通过 CacheInterceptor 在方法调用前/后介入 |
| CacheManager SPI | 解耦缓存实现,切换 Caffeine/Redis 只需改配置 |
| Key 生成 | 默认 SimpleKeyGenerator,支持 SpEL 和自定义 KeyGenerator |
| 条件缓存 | condition 方法前判断,unless 方法后判断 |
| 多级缓存 | 本地缓存 + 分布式缓存,兼顾性能和一致性 |
| 三大问题 | 穿透 → 缓存空值,雪崩 → 随机过期,击穿 → 互斥锁 |
| 序列化 | Redis 推荐用 JSON 序列化,注意实体类与构造方法 |
下一篇,我们会拆解 Spring Boot 定时任务体系,从 @Scheduled 注解到分布式调度的全链路。