通过自定义AOP实现二级缓存注解功能

通过自定义AOP实现二级缓存注解功能

面试官问:你的项目中用到AOP了吗?

1 AOP是什么

AOP是面向切面编程

  • AOP将通用的代码逻辑从业务代码中抽离出来,然后在运行时自动放到指定位置进行运行
  • 让你不用手写重复代码,也能在某些方法执行前后自动加上这些通用逻辑

常用于:

  • 日志
  • 权限校验
  • 事务处理
  • 缓存

2 项目中如何使用AOP实现二级缓存的

2.1 概述

核心机制基于自定义注解@L2Cache,通过AOP切面L2CacheAspect拦截标注该注解的方法,实现缓存逻辑。该实现不依赖Spring Cache抽象,而是手动管理Caffeine和Redis实例,支持三种操作模式(FULL、PUT、DELETE)

2.2 配置与依赖注入

二级缓存的配置主要在SimpleCachePropertiesL2CacheConfig中定义,确保Caffeine缓存实例被正确初始化,并注入到切面中。

  • SimpleCacheProperties:这是一个@ConfigurationProperties类,从application.properties或YAML配置文件中读取以”l2cache”前缀的属性
  • L2CacheConfig:这是一个配置类,使用@EnableCaching启用Spring缓存(虽未直接使用Spring CacheManager,但为兼容性考虑)。它注入SimpleCacheProperties,并创建Caffeine缓存实例

2.3 自定义注解 @L2Cache

注解定义在L2Cache.java中,用于标记目标方法

  • @Target(ElementType.METHOD):仅适用于方法。

  • @Retention(RetentionPolicy.RUNTIME):运行时保留,便于AOP反射读取。

  • 参数:

    • cacheName():缓存命名空间,默认”aimin-cache”,用于前缀键(如”aimin-cache::user:1”)。
    • key():必填,SpEL表达式,用于动态生成键(如”#user.id”,基于方法参数)。
    • l2TimeOut():Redis过期时间(秒),默认120。
    • type():操作类型,默认FULL(详见CacheType枚举)。

示例使用:

1
2
3
4
@L2Cache(key = "#user.id", type = CacheType.FULL)
public User getUser(User user) {
// 查询数据库逻辑
}

2.4 AOP切面实现 (L2CacheAspect)

切面类使用@Aspect和@Component标记,是Spring管理的Bean。核心是@Around环绕通知,拦截所有标注@L2Cache的方法。实现缓存的逻辑。

2.4.1 切点定义

1
2
@Pointcut("@annotation(com.oimc.aimin.cache.simple.annotation.L2Cache)")
public void cacheAspect() {}

匹配任何带有@L2Cache的方法

2.4.2 环绕通知 doAround()

这是一个完整的AOP拦截逻辑,包围目标方法执行(point.proceed())。步骤如下:

  1. 获取方法信息和参数

    • 通过MethodSignature获取方法和注解。
    • 构建TreeMap<String, Object>映射参数名和值。
  2. 根据type执行不同逻辑

    • CacheType.PUT(强制更新):

      • 执行目标方法(point.proceed(),通常是数据库更新/插入)。
      • 将结果写入Redis(set with timeout)。
      • 将结果写入Caffeine(put)。
      • 返回结果。
      • 用途:确保缓存同步,常用于更新操作。
    • CacheType.DELETE(删除):

      • 从Redis删除键。
      • 从Caffeine invalidate键。
      • 执行目标方法(可能清理数据库)。
      • 返回结果。
      • 用途:清除失效缓存。
    • CacheType.FULL(默认,查询+缓存):

      • 查询流程
        • 先查Caffeine(getIfPresent):若命中,日志”get data from caffeine”并返回。
        • 若未命中,查Redis(opsForValue().get):若命中,日志”get data from redis”,回写到Caffeine,返回。
        • 若均未命中,执行目标方法(point.proceed(),日志”get data from database”)。
      • 回写流程(若结果非null):
        • 写入Redis(set with l2TimeOut)。
        • 写入Caffeine(put)。
      • 返回结果。
      • 空结果(null)不缓存,避免缓存穿透(但代码中未显式处理null,可能需业务层处理)。

2.5 整体工作流程示例

假设一个标注@L2Cache(key="#id", type=FULL)的方法getUserById(Long id)

  1. 调用getUserById(123)。
  2. AOP拦截,解析键为”aimin-cache::123”。
  3. 查Caffeine:若命中,返回。
  4. 查Redis:若命中,回写Caffeine,返回。
  5. 执行方法:查DB得到User,回写Redis(过期120s)和Caffeine,返回User。

对于更新:使用PUT类型,执行DB更新后强制回写两级缓存。

3. AOP起到的作用

所有缓存相关的重复操作(查缓存、写缓存、删缓存)全部由AOP自动完成

  1. 自动拦截方法
    你只要在方法上加一个 @L2Cache 注解,AOP就会自动“抓住”这个方法,不需要你在每个业务方法里手写任何缓存代码。

  2. 在不改业务代码的情况下,自动加上缓存逻辑
    业务方法本来只负责查数据库:

    1
    2
    3
    4
    @L2Cache(key = "#id", type = CacheType.FULL)
    public User getUserById(Long id) {
    return userMapper.selectById(id); // 只写这一句就够了!
    }

    AOP会自动在执行这句代码之前先查 Caffeine → 查 Redis
    如果都没命中,才执行上面的数据库查询
    查询完后又自动把结果写回 Redis 和 Caffeine
    你完全不用管这些,代码看起来超级干净!

  3. 统一处理所有缓存操作
    同样的缓存逻辑(查两级、写两级、删除、强制更新)在项目里可能有几十上百个地方。
    用了AOP之后,所有逻辑都集中在 L2CacheAspect 这一个类里:
    → 以后改缓存策略(比如换Redis过期时间、加布隆过滤器)只需要改一个文件
    → 不会出现“这个方法写了缓存、那个方法忘记写”的低级bug

END