显式缓存与隐式切面缓存

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. 调试时的排查清单

遇到"断点打了但不停"的情况,按以下顺序排查:

  1. 切面是否短路了? → 在每个切面的 pjp.proceed() 前打断点
  2. 是否有幂等/缓存机制? → 检查是否用了相同的 uniqueKey
  3. 编译产物是否一致? → Rebuild Project
  4. 请求是否到了本机? → 检查集群路由配置

最佳实践建议

对于显式缓存

  • 合理设置 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,了解有哪些全局拦截器、它们的作用范围和执行顺序。这能帮你避免在调试时遇到"断点不停"、"方法没执行"等令人困惑的问题。

posted @ 2026-04-02 11:04  cwp0  阅读(6)  评论(0)    收藏  举报