自定义注解+切面+Redis实现简单的分布式锁

分布式锁使用场景

单机模式下,如果一段代码限制同一时刻只能一个线程去访问,我们会使用并发锁来解决;
但是现在项目一般是微服务集群部署,并发锁的锁作用域只在当前服务器的JVM中,多个服务器的并发锁不起作用。
这就有了分布式锁的概念:保证代码在不同服务器之间只允许一个线程执行

开干 冲冲冲!!!

这里做个测试用例,开放一个接口,先进行token校验,再返回请求的数据(假设这个是对某条对应数据的修改操作,不让调用方重复请求之类的操作)

1、定义一个自定义注解,凡使用到这个注解的方法都先进入切面去校验token

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LockAnnotation {
    //超时时间 默认3秒
    long expireTime() default 3;
    String lockKey();
}

2、创建controller接受请求

import com.example.demo.annotation.LockAnnotation;
import com.example.demo.vo.RequestVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 先获取token,然后再用这个token携带参数一起验证是否重复请求
 */
@RestController
@RequestMapping("/lock")
@Slf4j
public class LockController {
    private static Map<String, String> stringMap = new HashMap<>();
    static {
        stringMap.put("0", "000000");
        stringMap.put("1", "111111");
        stringMap.put("2", "222222");
        stringMap.put("3", "333333");
    }

    @GetMapping("/getToken")
    public String getToken() {
        return UUID.randomUUID().toString();
    }
    
	/**
	* RequestVo只有两个参数 token和num
	* 这个方法加上自定义注解,并定义这个方法专属的RedisKey
	**/
    @PostMapping("/handleLock")
    @LockAnnotation(lockKey = "HANDLE_LOCK_KEY")
    public String handleLock(@RequestBody RequestVo requestVo) {
        //此处模仿服务器处理业务逻辑,请求进来休眠2秒
        TimeUnit.SECONDS.sleep(2);

        log.error("请求num=" + requestVo.getNum());
        return stringMap.get(requestVo.getNum());
    }
}

3、定义切面

首先引入切面Maven依赖

        <!-- 切面依賴-->
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.8.10</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.13</version>
        </dependency>
package com.example.demo.ascpets;

import com.example.demo.annotation.LockAnnotation;
import com.example.demo.util.RedisUtil;
import com.example.demo.vo.RequestVo;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)     //如果多个切面,则该切面最先执行,默认是 Ordered.LOWEST_PRECEDENCE
@Slf4j
public class LockAscept {
    
    @Around("@annotation(com.example.demo.annotation.LockAnnotation)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = null;
        log.error("lockAscept=" + joinPoint.toString());

        //先获取方法和注解  因为只有注解的方法才会进来,所以不需要判空,直接获取
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        LockAnnotation annotation = method.getAnnotation(LockAnnotation.class);
        long expireTime = annotation.expireTime();
        String lockKey = annotation.lockKey();

        //获取参数
        RequestVo requestVo = parseParamsByJoinPoint(joinPoint);
        if (requestVo == null) {
            throw new Exception("无效参数!!!");
        }

        //token+num组成key
        String key = lockKey + requestVo.getToken() + requestVo.getNum();

        //设置单独token+num的成功次数  这个为了查看最终效果
        String tempKey=requestVo.getToken() + requestVo.getNum();
        Object o = RedisUtil.get(tempKey);
        if(o==null){
            RedisUtil.set(tempKey,0);
        }

        try {
            Boolean tryLock = RedisUtil.setIfAbsent(key, requestVo.getToken(), expireTime, TimeUnit.SECONDS);
            if (!tryLock) {
                log.info("##########加锁失败" + key);
                throw new Exception(expireTime + "秒内请勿重复请求相同数据!!!");
            }
            log.info("************成功加锁" + key);
            //继续执行切面方法
            proceed = joinPoint.proceed();
        } finally {
            RedisUtil.del(key);
            log.info("已释放锁" + key);
        }
        return proceed;
    }
    
    //从切面的注入点获取携带的参数
    private RequestVo parseParamsByJoinPoint(ProceedingJoinPoint joinPoint) {
        RequestVo requestVo = null;

        Object[] objects = joinPoint.getArgs();
        for (Object obj : objects
                ) {
            if (obj instanceof RequestVo) {
                requestVo = (RequestVo) obj;
                break;
            }
        }
        return requestVo;
    }
}

在redis的工具类中使用setnx,如果这个key不存在,则加锁成功,否则返回返回false

   public static Boolean setIfAbsent(String key,String value,long expireTime,TimeUnit unit){
        return redisTemplate.opsForValue().setIfAbsent(key,value,expireTime,unit);
    }

这里顺便测试下**@Order**,再定义一个切面,order默认是最低级别,所以这两个切面都around了自定义注解的方法,但是会先走LockAscept,高级别优先。
里面做了操作,获取在LockAscept定义的tempKey并+1,为了查看最终token携带不同的num请求成功的次数!

package com.example.demo.ascpets;

import com.example.demo.util.RedisUtil;
import com.example.demo.vo.RequestVo;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Order
@Slf4j
public class TestAscept {

    @Around("@annotation(com.example.demo.annotation.LockAnnotation)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {

        log.error("testAscept=" + joinPoint.toString());

        RequestVo requestVo = parseParamsByJoinPoint(joinPoint);
        RedisUtil.incr(requestVo.getToken()+requestVo.getNum(),1);
        log.info(requestVo.getToken()+requestVo.getNum()+"请求成功,数量incr");

        return joinPoint.proceed();
    }

    private RequestVo parseParamsByJoinPoint(ProceedingJoinPoint joinPoint) {
        RequestVo requestVo = null;

        Object[] objects = joinPoint.getArgs();
        for (Object obj : objects
                ) {
            if (obj instanceof RequestVo) {
                requestVo = (RequestVo) obj;
                break;
            }
        }
        return requestVo;
    }
}

5、 编写测试类

mport com.example.demo.util.HttpRequestUtil;
import org.springframework.boot.configurationprocessor.json.JSONObject;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class LockMainTest {

    private static final String GET_TOKEN="http://localhost:8001/lock/getToken";
    private static final String HANDLE_LOCK="http://localhost:8001/lock/handleLock";

    public static void main(String[] args){
    //       先请求到一个相同的token
        String token=HttpRequestUtil.sendGet(GET_TOKEN, "");
        System.err.println("token为"+token);


//        发送 POST 请求
//        Map<String,String> map=new HashMap<>();
//        map.put("token",token);
//        map.put("num","1");
//        String s = HttpRequestUtil.post(HANDLE_LOCK,new JSONObject(map),"UTF-8");
//        System.err.println(s);

        for (int i = 0; i < 100; i++) {
            int finalI = i;
            new Thread(()->{
                Map<String,String> map=new HashMap<>();
                map.put("token",token);         //携带相同的token
                map.put("num", finalI%3+"");   //这里自动生成 0 1 2的变量
                String result = null;
                try {
                    JSONObject jsonObject = new JSONObject(map);
                    System.err.println(Thread.currentThread().getName()+"请求请求参数为"+jsonObject.toString());
                    result = HttpRequestUtil.post(HANDLE_LOCK,jsonObject,"UTF-8");
                } catch (IOException e) {
                    System.err.println(Thread.currentThread().getName()+"请求失败!!!");
                }
                System.err.println(Thread.currentThread().getName()+"请求结果为"+result);
            },"T"+i).start();
        }
    }
}

关于Java发送GET、POST请求可以借鉴这两篇博文的工具类---->
java发送http的get、post请求
Java发送POST请求,参数为JSON格式,并接收返回JSON数据

6、测试结果

上面的代码中我先请求到一个token,然后开启了100个线程各携带三个不同的num来请求对应的数据。
在这里插入图片描述
首先不同的线程携带相同的token和0 1 2这三种num请求数据
在这里插入图片描述
由于我在controller中让成功请求的线程休眠两秒,而这100个请求的时间远远少于2秒,所以最终100个请求中只有3个线程分别携带不同的num可以成功加锁!
如果说设置的redis过期时间为3秒,业务处理时间两秒,那么请求的数量执行时间只要不超过3秒,那么最终都只能有三个请求成功)===》会因为服务器的CPU、网络延迟等因素影响
在这里插入图片描述](https://img-blog.csdnimg.cn/1d3de7f4f42f4e4989b68979f74449e7.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBATXIgWW9uZHU=,size_20,color_FFFFFF,t_70,g_se,x_16)

问题

为何要设置分布式锁的过期时间?明明执行完切面后的finally最终都会释放锁。

在这里插入图片描述

因为实际的服务器运行中可能会发生无法预料的情况,无论是try还是finally语句块中都有可能发生阻塞,造成死锁,会导致请求一直被卡住。利用redis自带的过期时间可以确保系统故障后还能自动的释放锁。

同样的会引出一个问题,如果说过期时间为3秒,但是程序正常执行的时间意外的超出了三秒,那么旧的key过期,新的线程进来成功加锁,则分布式锁就失效了!

那么这时可以在加锁之前开一条线程做个定时器,一直去刷新这个key的过期时间。

如果redis做的是集群部署,当一个线程在Master节点设置了key,就在主从复制数据时Master挂了,在slave节点选举新的Master节点时;或者多主多从部署的redis,线程在master1加了锁,其他的master没有这个锁的信息**------》**(总之就是一个线程正常加锁,但是由于redis集群导致其他线程也成功加了锁),这个时候上面的方法就做不到分布式锁了。

这时就可以用到Redisson工具类。
Redisson实现分布式锁(1)—原理

Redisson可以实现Redis的可重入锁的功能(和并发的ReentrantLock的效果相同),在tryLock前redisson.lock(),finally块unlock。保证了一个线程进到redis加锁后,只要没解锁,其他线程不能进到redis加锁。

多主部署的redis集群中可以用到RedissonRedLock,原理大概是向redis发出加锁的指令时,会向所有Master节点加锁,超过一半的Master加锁成功才算成功,保证所有Master节点都能获取到锁。

===========(//TODO 后期搭了集群再来补上!!!) ===========

lua脚本做加锁解锁的操作

在这个示例中我的tryLock是通过redisTemplate自带的setnx操作去完成的,如果业务逻辑复杂的,最好是通过封装一个lua脚本发给redis执行,保证操作的原子性。不然在目前的单线程Redis中,setnx可能会插入别的操作,导致加锁出现问题。。。

========== (//TODO Lua脚本还没研究,以后补上!!!) ==========

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值