显式缓存与隐式切面缓存
Spring 缓存机制对比:显式注解缓存 vs 隐式切面缓存
前言
在 Spring 项目中,"缓存"并不总是以 @Cacheable 等注解的显式形式出现。有一类基于 AOP 切面的隐式缓存机制,它没有任何注解标记在方法上,却能悄无声息地拦截请求并返回缓存结果,导致业务方法根本不被执行。
本文将对比这两种缓存模式,帮助在接手项目时快速识别和排查。
显式注解缓存
什么是显式缓存?
显式缓存是指通过 在方法上标注注解(如 @Cacheable、@CachePut、@CacheEvict)来声明缓存行为。开发者看到方法签名就能知道"这个方法有缓存"。
示例
@Service
public class ProductService {
@Cacheable(value = "products", key = "#productId")
public ProductDto getProductById(String productId) {
// 第一次调用会执行这里,结果被缓存
// 后续相同 productId 的调用直接返回缓存,不再执行
return productRepository.findById(productId);
}
@CachePut(value = "products", key = "#product.id")
public ProductDto updateProduct(ProductDto product) {
// 每次都执行,并用返回值更新缓存
return productRepository.save(product);
}
@CacheEvict(value = "products", key = "#productId")
public void deleteProduct(String productId) {
// 执行后清除对应缓存
productRepository.deleteById(productId);
}
}
特点
- 可见性高:方法上有
@Cacheable注解,一眼就能看到 - 粒度细:只对标注了注解的方法生效
- 缓存介质明确:通过
value指定缓存名称,底层可以是 Redis、Caffeine、EhCache 等 - Key 策略透明:通过
key表达式明确指定缓存键
工作原理
调用 getProductById("P001")
→ Spring AOP 代理拦截
→ 检查缓存 "products::P001" 是否存在
├─ 存在 → 直接返回缓存值,方法体不执行
└─ 不存在 → 执行方法体 → 将返回值写入缓存 → 返回
隐式切面缓存
什么是隐式缓存?
隐式缓存是指通过 AOP 切面按包路径批量拦截,在切面内部实现缓存/幂等逻辑。方法本身没有任何注解标记,开发者仅看方法签名完全无法察觉缓存的存在。
示例:幂等切面
@Aspect
@Component
@Order(2)
public class IdempotentAspect {
@Pointcut("execution(* com.example.rpc.service..impl.*.*(..))")
public void rpcProviderService() {
}
@Around("rpcProviderService()")
public Object handle(ProceedingJoinPoint pjp) throws Throwable {
// 从方法参数中提取幂等键
InvokeContext ctx = extractInvokeContext(pjp);
if (ctx == null || StringUtils.isBlank(ctx.getUniqueKey())) {
return pjp.proceed();
}
// 加分布式锁
Lock lock = lockService.getLock("idempotent:" + ctx.getUniqueKey());
try {
lock.tryLock(3000, 5000, TimeUnit.MILLISECONDS);
return checkAndExecute(ctx.getUniqueKey(), pjp);
} finally {
lock.unlock();
}
}
private Object checkAndExecute(String uniqueKey, ProceedingJoinPoint pjp) throws Throwable {
// 查询历史执行记录
Transaction transaction = transactionService.findByUniqueKey(uniqueKey);
if (transaction == null || transaction.getStatus() == FAIL) {
// 没执行过或上次失败了 → 真正执行业务逻辑
Object result = pjp.proceed();
transactionService.saveResult(uniqueKey, result, FINISH);
return result;
}
if (transaction.getStatus() == FINISH) {
// 已经成功执行过 → 直接返回上次的结果,不再执行业务方法!
return deserialize(transaction.getReturnValue());
}
throw new SystemException("Transaction in suspect state");
}
}
被拦截的业务方法(完全看不出有缓存)
public class OrderServiceImpl implements OrderService {
// 这个方法上没有任何缓存/幂等注解
// 但因为它在 com.example.rpc.service..impl 包下
// 所以会被 IdempotentAspect 自动拦截
@Override
public ResultSupport<Boolean> createOrder(OrderParam orderParam) {
return ResultSupport.success(orderAppService.create(orderParam));
}
}
特点
- 可见性极低:方法上没有任何标记,必须了解切面配置才能发现
- 粒度粗:对整个包路径下的所有方法生效
- 缓存介质隐蔽:通常存储在数据库的 transaction 表中
- Key 策略隐藏:由调用方在参数中传入
uniqueKey,不在方法签名上体现
工作原理
调用 createOrder(orderParam) // orderParam 中包含 uniqueKey="UK001"
→ CGLIB 代理拦截
→ ExceptionHandlerAspect(异常处理)
→ IdempotentAspect(幂等检查)
→ 查数据库 transaction 表,uniqueKey="UK001"
├─ 记录存在且 status=FINISH → 返回上次缓存的结果,createOrder 方法体不执行!
├─ 记录不存在 → 执行 createOrder → 结果存入 transaction 表
└─ 记录存在且 status=SUSPECT → 抛异常
对比总结
| 维度 | 显式注解缓存 | 隐式切面缓存 |
|---|---|---|
| 标识方式 | 方法上有 @Cacheable 等注解 |
方法上无任何标记 |
| 作用范围 | 仅标注了注解的方法 | 整个包路径下的所有方法 |
| 发现难度 | ⭐ 低,看方法签名即可 | ⭐⭐⭐⭐ 高,必须了解切面配置 |
| 缓存存储 | Redis / Caffeine / EhCache 等 | 通常是数据库 transaction 表 |
| 缓存键 | 注解中的 key 表达式 |
参数中的 uniqueKey 字段 |
| 典型用途 | 查询结果缓存,提升性能 | 写操作幂等,防止重复执行 |
| 调试影响 | 较小,清缓存即可 | 较大,断点可能完全不停 |
如何发现隐式切面缓存
1. 接手项目时扫描所有切面
grep -rn "@Aspect" --include="*.java" .
重点关注每个切面的:
@Pointcut:作用范围是什么@Around:是否有条件地跳过pjp.proceed()
2. 识别"提前返回"模式
隐式缓存的核心特征是在 pjp.proceed() 之前就 return 了:
// 危险信号:有条件地跳过 pjp.proceed()
@Around("somePointcut()")
public Object handle(ProceedingJoinPoint pjp) {
Object cachedResult = lookupCache(key);
if (cachedResult != null) {
return cachedResult; // ← 业务方法根本不会执行!
}
return pjp.proceed();
}
3. 关注切面执行顺序
多个切面通过 @Order 控制执行顺序,形成洋葱模型:
请求 → Aspect1(Order=1) → Aspect2(Order=2) → Aspect3(Order=3) → 业务方法
↑
如果这里返回了,后面的都不会执行
4. 调试时的排查清单
遇到"断点打了但不停"的情况,按以下顺序排查:
- 切面是否短路了? → 在每个切面的
pjp.proceed()前打断点 - 是否有幂等/缓存机制? → 检查是否用了相同的
uniqueKey - 编译产物是否一致? → Rebuild Project
- 请求是否到了本机? → 检查集群路由配置
最佳实践建议
对于显式缓存
- 合理设置 TTL(过期时间),避免脏数据
- 注意 缓存穿透(查不存在的数据)和缓存雪崩(大量缓存同时过期)
- 更新数据时使用
@CachePut或@CacheEvict保持一致性
对于隐式切面缓存
- 在切面类上添加充分的注释,说明作用范围和行为
- 考虑为被拦截的方法添加自定义注解(如
@Idempotent),提高可见性:
// 改进前:完全隐式,开发者无感知
@Pointcut("execution(* com.example.rpc.service..impl.*.*(..))")
// 改进后:通过注解显式标记,开发者一眼就能看到
@Pointcut("@annotation(com.example.annotation.Idempotent)")
// 改进后的业务方法,可见性大大提高
@Idempotent
@Override
public ResultSupport<Boolean> createOrder(OrderParam orderParam) {
return ResultSupport.success(orderAppService.create(orderParam));
}
- 非生产环境(如 UAT、日常)考虑跳过幂等检查,方便调试
- 在项目文档中记录所有切面的作用和执行顺序
总结
显式注解缓存和隐式切面缓存本质上都是通过 AOP 代理实现的,区别在于可见性。显式缓存通过注解"自我声明",而隐式缓存通过包路径"批量拦截"。
接手一个 Spring 项目时,第一件事就是扫描所有 @Aspect 类,了解有哪些全局拦截器、它们的作用范围和执行顺序。这能帮你避免在调试时遇到"断点不停"、"方法没执行"等令人困惑的问题。

浙公网安备 33010602011771号