全栈开发那些事

全栈开发那些事

分布式锁+AOP实现缓存

2024-06-25
分布式锁+AOP实现缓存

1、分布式锁+AOP实现思想

随着业务中缓存及分布式锁的加入,业务代码变的复杂起来,除了需要考虑业务逻辑本身,还要考虑缓存及分布式锁的问题,增加了程序员的工作量及开发难度。而缓存的玩法套路特别类似于事务,而声明式事务就是用了aop的思想实现的。

img

  1. 以 @Transactional 注解为植入点的切点,这样才能知道@Transactional注解标注的方法需要被代理。

  2. @Transactional注解的切面逻辑类似于@Around

我们的思想就是模拟事务的实现方式,缓存可以这样实现:

  • 自定义缓存注解@GmallCache(类似于事务@Transactional)

  • 编写切面类,使用环绕通知实现缓存的逻辑封装

2、不使用AOP的情况

2.1 没有使用缓存时代码

在这里将使用AOP思想和不适用AOP思想做一个对比

假设现在我的业务是根据skuId查询skuInfo对象,未使用分布式锁时的代码如下:

   //根据skuId查询skuInfo信息和图片列表
    @Override
    public SkuInfo getSkuInfo(Long skuId) {
        //查询数据库mysql获取数据
        return getSkuInfoDB(skuId);
    }
    //查询数据库获取skuInfo信息
    private SkuInfo getSkuInfoDB(Long skuId) {
        //查询skuInfo
        SkuInfo skuInfo = skuInfoMapper.selectById(skuId);
        //根据skuId查询图片列表
        LambdaQueryWrapper<SkuImage> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SkuImage::getSkuId, skuId);
        List<SkuImage> skuImages = skuImageMapper.selectList(wrapper);
        //设置当前图片列表
        if(skuInfo!=null){
            skuInfo.setSkuImageList(skuImages);
        }
        return skuInfo;
    }

2.2 使用Redis实现分布式锁的代码

步骤如下:

1、定义获取sku信息的key--skuKey

2、根据skuKey从Redis中获取数据:

有数据就直接返回结果

没有数据执行下一步

3、定义skuLock

4、获取锁:如果没有获取到锁,设置睡眠时间继续自旋获取锁。

如果获取到了锁,执行下一步。

5、查询数据库获取sku数据,如果数据库中有数据,则存储数据到缓存,返回数据。

如果数据库中没有数据,存储null到缓存,返回数据(这样做的目的是防止缓存穿透)

6、释放锁

7、写一个兜底的方式(其实就是查询数据库),目的是上面的代码发生异常的时候,也能正常返回数据。

 //根据skuId查询skuInfo信息和图片列表
    @Override
    public SkuInfo getSkuInfo(Long skuId) {
        //使用redis实现分布式锁缓存数据
        return getSkuInfoRedis(skuId);
    }
/**
 * 获取skuInfo,从缓存中获取数据
 * Redis实现分布式锁
 * 实现步骤:
 * 1、定义存储skuInfo的key
 * 2、根据skyKey获取skuInfo的缓存数据
 * 3、判断
 * 有:直接返回结束
 * 没有:定义锁的key,尝试加锁(失败:睡眠,重试自旋;成功:查询数据库,判断是否有值,有的话直接返回,缓存到数据库,没有,创建空值,返回数据)
 */
private SkuInfo getSkuInfoRedis(Long skuId) {
    try {
        //定义存储skuKey sku:1314:info
        String skuKey = RedisConst.SKUKEY_PREFIX + skuId + RedisConst.SKUKEY_SUFFIX;
        //尝试获取缓存中的数据
        SkuInfo skuInfo = (SkuInfo) redisTemplate.opsForValue().get(skuKey);
        //判断是否有值
        if (skuInfo == null) {
            //说明缓存中没有数据
            //定义锁的key
            String lockKey = RedisConst.SKUKEY_PREFIX + skuId + RedisConst.SKULOCK_SUFFIX;
            //生成uuid标识
            String uuid = UUID.randomUUID().toString().replaceAll("-", "");
            //获取锁
            Boolean flag = redisTemplate.opsForValue().setIfAbsent(lockKey, uuid, RedisConst.SKULOCK_EXPIRE_PX2, TimeUnit.SECONDS);
            //判断是否获取到了锁
            if (flag) {//获取到了锁
                //查询数据库
                SkuInfo skuInfoDB = getSkuInfoDB(skuId);
                //判断数据库中是否有值
                if (skuInfoDB == null) {
                    SkuInfo skuInfo1 = new SkuInfo();
                    redisTemplate.opsForValue().set(skuKey, skuInfo1, RedisConst.SKUKEY_TEMPORARY_TIMEOUT, TimeUnit.SECONDS);
                    return skuInfo1;
                }
                //数据库查询的数据不为空
                //存储到缓存
                redisTemplate.opsForValue().set(skuKey, skuInfoDB, RedisConst.SKUKEY_TIMEOUT, TimeUnit.SECONDS);

                //释放锁-lua脚本
                //定义lua脚本
                String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                        "then\n" +
                        "    return redis.call(\"del\",KEYS[1])\n" +
                        "else\n" +
                        "    return 0\n" +
                        "end";
                //创建脚本对象
                DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
                //设置脚本
                defaultRedisScript.setScriptText(script);
                //设置返回值类型
                defaultRedisScript.setResultType(Long.class);

                //执行删除
                redisTemplate.execute(defaultRedisScript, Arrays.asList(lockKey), uuid);
                //返回数据
                return skuInfoDB;
            } else {
                Thread.sleep(100);
                return getSkuInfoRedis(skuId);
            }

        } else {
            return skuInfo;
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    //兜底,在上面从缓存中获取的过程中出现异常,这行代码也必须执行
    return getSkuInfoDB(skuId);
}

2.3 使用Redisson实现分布式锁

这个步骤和2.2是一样的

 //根据skuId查询skuInfo信息和图片列表
    @Override
    
    public SkuInfo getSkuInfo(Long skuId) {
        //使用Redisson实现分布式锁
        return getSkuInfoRedisson(skuId);
    }
 /**
     *使用Redisson改造skuInfo信息
     */
    private SkuInfo getSkuInfoRedisson(Long skuId) {
        try {
            //定义sku数据获取的Key
            String skuKey=RedisConst.SKUKEY_PREFIX+skuId+RedisConst.SKUKEY_SUFFIX;
            //尝试从缓存中获取数据
            SkuInfo skuInfo = (SkuInfo) redisTemplate.opsForValue().get(skuKey);
            //判断缓存中是否有数据
            if(skuInfo==null){
                //定义锁的key
                String skuLock=RedisConst.SKUKEY_PREFIX+skuId+RedisConst.SKULOCK_SUFFIX;
                //获取锁
                RLock lock = redissonClient.getLock(skuLock);
                //加锁
                boolean res = lock.tryLock(RedisConst.SKULOCK_EXPIRE_PX1, RedisConst.SKULOCK_EXPIRE_PX2, TimeUnit.SECONDS);
                //判断
                if(res){
                    try {
                        //获取到了锁,查询数据库
                        skuInfo= getSkuInfoDB(skuId);
                        //判断
                        if(skuInfo==null){
                            //存储null,避免缓存穿透
                            skuInfo=new SkuInfo();
                            redisTemplate.opsForValue().set(skuKey,skuInfo,RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
                            return skuInfo;
                        }else{
                            //存储
                            redisTemplate.opsForValue().set(skuKey,skuInfo,RedisConst.SKUKEY_TIMEOUT,TimeUnit.SECONDS);
//                            redisTemplate.opsForValue().set(skuKey,skuInfo);
                            //返回
                            return skuInfo;
                        }
                    } finally {
                        //释放锁
                        lock.unlock();
                    }

                }else{
                    //没有获取到锁
                    Thread.sleep(100);
                    return getSkuInfoRedisson(skuId);
                }
            }else{
                //缓存中有数据
                return skuInfo;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //兜底方法,前面代码异常,这里会执行
        return getSkuInfoDB(skuId);
    }

2.4 测试缓存命中

这里直接在Swagger中测试,该接口格式如下:

image-20230419211718178

第一次点击发送,从响应可以看出请求时成功的

image-20230419211834707

观察该服务的控制台,发现第一次是查询了控制台的。

image-20230419211901963

观察Redis中的数据:

image-20230419211944633

然后清空该服务的控制台之后,再次发送同样的请求再观察控制台的输出

image-20230419212111425

可以看到,此时已经不用查数据库了,而是直接取的Redis中的数据

2.5 存在问题

每次实现分布式锁的时候都需要写一大段重复代码,增加了工作量,代码也不优雅。

解决方案:借助AOP思想,用自定义注解封装下这段重复的代码,这样后面需要分布式锁的时候我们直接加个注解再修改个参数就行。

3、分布式锁+AOP实现

3.1 定义注解

import java.lang.annotation.*;

/**
 * 元注解:简单理解就是修饰注解的注解
 * @Target:用于描述注解的使用范围,简单理解就是当前注解可以用在什么地方
 * @Retention:表示注解的生命周期
 *      SOURCE:只存在类文件中,在class字节码不存在
 *      CLASS:存在到字节码文件中
 *      RUNTIME:运行时
 * @Inherited:表示被GmallCache修饰的类的子类会不会继承GmallCache
 * @Documented:表明这个注解应该被javadoc工具记录,因此可悲javadoc类的工具文档化
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface GmallCache {

    //缓存的前缀
    String prefix() default "cache:";

    //缓存的后缀
    String suffix() default ":info";

}

3.2 定义一个切面类加上注解

参考文档:https://docs.spring.io/spring-framework/docs/5.3.27/reference/html/core.html#aop-ataspectj-example

实现步骤和2.2中的一样,不过我们需要借助反射获取一些参数和方法返回值等。

@Component
@Aspect
public class GmallCacheAspect {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 使用AOP实现分布式锁和缓存
     *  Around:环绕通知
     *      value:切入的位置
     * 1、定义获取数据的key
     *  例如获取skuInfo  key === sku:skuId
     *      (1)获取添加了@GmallCache注解的方法
     *          可以获取注解、注解的属性、方法的参数
     *      (2)可以尝试获取数据
     */
    @Around("@annotation(com.atguigu.gmall.common.cache.GmallCache)")
    public Object cacheGmallAspect(ProceedingJoinPoint joinPoint) throws Throwable {
        //创建返回对象
        Object object=new Object();
        //获取添加了注解的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        //获取注解
        GmallCache gmallCache = signature.getMethod().getAnnotation(GmallCache.class);
        //获取属性前缀
        String prefix = gmallCache.prefix();
        //获取属性后缀
        String suffix = gmallCache.suffix();
        //获取方法传入的参数
        Object[] args = joinPoint.getArgs();
        //组合获取数据的key
        String key=prefix+ Arrays.asList(args).toString()+suffix;
        //从缓存中获取数据
        object=cacheHit(key,signature);
        try {
            //判断
            if(object==null){
                //缓存中没有数据,需要从数据库查询
                //定义锁的key
                String lockKey=prefix+":lock";
                //准备上锁 redis/redisson
                RLock lock = redissonClient.getLock(lockKey);
                //上锁
                boolean flag = lock.tryLock(RedisConst.SKULOCK_EXPIRE_PX1, RedisConst.SKULOCK_EXPIRE_PX2, TimeUnit.SECONDS);
                //判断是否成功
                if(flag){
                    try {
                        //获取到了锁
                        //查询数据库,执行切入的方法体实际上就是查询数据库
                        object= joinPoint.proceed(args);

                        //判断是否从mysql查询到了数据
                        if(object==null){
                            //反射
                            Class aClass = signature.getReturnType();
                            //创建对象
                            object= aClass.newInstance();
                            //存储
                            redisTemplate.opsForValue().set(key,JSON.toJSONString(object),RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
                            return object;
                        }else{
                            //存储
                            redisTemplate.opsForValue().set(key,JSON.toJSONString(object),RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
                            return object;
                        }
                    } finally {
                        //释放锁
                        lock.unlock();
                    }
                }else{
                    //睡眠
                    Thread.sleep(100);
                    //自旋
                    return cacheGmallAspect(joinPoint);
                }
            }else{
                //从缓存中获取了数据
                return object;
            }
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        //兜底的方法--查询数据库,实际上执行方法体就是查询数据库
        return joinPoint.proceed(args);
    }

    //从缓存中获取数据
    private Object cacheHit(String key, MethodSignature signature) {
        //获取数据--存储的时候,转换成JSON字符串,所以从Redis取出来的时候是个字符串
        String strJson = (String) redisTemplate.opsForValue().get(key);
        //判断
        if(!StringUtils.isEmpty(strJson)){
            //获取当前方法的返回值类型
            Class returnType = signature.getReturnType();
            //将字符串转换成指定的类型
            return JSON.parseObject(strJson,returnType);
        }
        return null;
    }
}

3.3 使用注解完成缓存

此时实现类如下:

 //根据skuId查询skuInfo信息和图片列表
    @Override
    @GmallCache(prefix ="sku:")  //key:  sku:1314:info
    public SkuInfo getSkuInfo(Long skuId) {
        //查询数据库mysql获取数据
        return getSkuInfoDB(skuId);
        //使用redis实现分布式锁缓存数据
//        return getSkuInfoRedis(skuId);
        //使用Redisson实现分布式锁
//        return getSkuInfoRedisson(skuId);
    }

现在这个方法中写的是调用数据库查询的代码,不过我们在这里加了一个@GmallCache自定义注解,其中参数prefix是缓存中key的前缀,可以自定义。

这样每次在进入到这个方法的时候会执行我们定义的那个切面类,把分布式锁的步骤走一遍,可以看到,这样代码侵入性就比较低了,如果在其他地方也想使用分布式锁,那就直接加上这个注解,再给个前缀参数即可。

image-20230419213623463