AOP+自定义注解实现Redis分布式锁

一、场景

定时任务,有过项目经历的开发者估计都不陌生,是实现一些定时执行重复操作需求的常见解决方案。

在单机的情况下,定时任务当然是越用越爽,简单粗暴直接cron表达式走起就行了,但是在微服务的场景下,要考虑多实例的问题。比如一个定时任务,由于被部署了在多台机器上(或同一台不同端口),这时候,可能会出现定时任务在同一时间被多次执行的问题。
为了保证在同一周期内,只有一个定时任务在执行,其他的不执行,可以采用redis分布式锁、数据库锁、zookeeper锁等方式去实现。

本文采用redis分布式锁的思路去实现。

二、场景复现

创建一个springboot项目,导入web依赖包,新建一个定时任务demo,每隔5秒执行一次,代码如下


/**
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 11:58
 */
@Component
@Slf4j
public class TaskDemo {

    @Scheduled(cron = "0/5 * * * * *")
    public void runTask(){
        log.info("机器【1】上的 demo 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
        try {
            //延迟,模拟业务逻辑
            Thread.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

然后分别在 8090端口,和8091端口启动这个项目。模拟多实例的情况,可以看到他们分别的执行结果如下:

A 服务器

AOP+自定义注解实现Redis分布式锁_第1张图片

B 服务器

AOP+自定义注解实现Redis分布式锁_第2张图片
如上可以看到,在同一个启动时间点,两台服务器上的同个定时任务都执行了。这显然不太符合需求。

三、解决方案

使用redis分布式锁

  • 每个定时任务执行之前,先去redis那边获取锁,如果获取到了,则执行代码逻辑,获取失败则直接 return。
  • 这个可以使用redis的setNX操作来实现,这个操作是原子性的,不过有个缺陷是没有失效时间,这时如果服务器A拿到锁了,由于宕机或者其他网络不可达情况没有释放掉,则其他的服务器永远拿不到这个锁,存在死锁的情况。
  • 所以这里存redis的key是任务的名称,value就是当前的时间戳+锁过期时间。如果其他服务器获取锁失败了,看看上一个锁是否已经过期。如果过期了则直接重新获取锁。

对于加锁和解锁操作,封装成了工具类来实现,代码如下:

PS:这个获取锁的getLock方法,第二个参数为锁的过期时间,这里一般设置为在任务预估执行时间定时任务周期时间 这个时间段内,如果设置过短,可能会导致锁提前失效,任务还没跑完的问题


/**
 * 实现分布式redis锁.
 *
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 13:53
 */
@Component
public class RedisLockUtils {

    /**
     * 锁名称前缀.
     */
    public static final String TASK_LOCK_PREFIX = "TASK_LOCK_";

    /**
     * redis操作.
     */
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 获取分布式redis锁.
     * 逻辑:
     * 1、使用setNX原子操作设置锁(返回 true-代表加锁成功,false-代表加锁失败)
     * 2、加锁成功直接返回
     * 3、加锁失败,进行监测,是否存在死锁的情况,检查上一个锁是否已经过期
     * 4、如果过期,重新让当前线程获取新的锁。
     * 5、这里可能会出现多个获取锁失败的线程执行到这一步,所以判断是否是加锁成功,如果没有,则返回失败
     *
     * @param taskName       任务名称
     * @param lockExpireTime 锁的过期时间
     * @return true-获取锁成功 false-获取锁失败
     */
    public Boolean getLock(String taskName, long lockExpireTime) {
        //锁的名称:前缀 + 任务名称
        String lockName = TASK_LOCK_PREFIX + taskName;

        return (Boolean) redisTemplate.execute((RedisCallback<?>) connection -> {
            // 计算此次过期时间:当前时间往后延申一个expireTIme
            long expireAt = System.currentTimeMillis() + lockExpireTime + 1;
            // 获取锁(setNX 原子操作)
            Boolean acquire = connection.setNX(lockName.getBytes(), String.valueOf(expireAt).getBytes());
            // 如果设置成功
            if (Objects.nonNull(acquire) && acquire) {
                return true;
            } else {
                //防止死锁,获取旧的过期时间,与当前系统时间比是否过期,如果过期则允许其他的线程再次获取。
                byte[] bytes = connection.get(lockName.getBytes());
                if (Objects.nonNull(bytes) && bytes.length > 0) {
                    long expireTime = Long.parseLong(new String(bytes));
                    // 如果旧的锁已经过期
                    if (expireTime < System.currentTimeMillis()) {
                        // 重新让当前线程加锁
                        byte[] oldLockExpireTime = connection.getSet(lockName.getBytes(),
                                String.valueOf(System.currentTimeMillis() + lockExpireTime + 1).getBytes());
                        //这里为null证明这个新锁加锁成功,上一个旧锁已被释放
                        if (Objects.isNull(oldLockExpireTime)) {
                            return true;
                        }
                        // 防止在并发场景下,被其他线程抢先加锁成功的问题
                        return Long.parseLong(new String(oldLockExpireTime)) < System.currentTimeMillis();
                    }
                }
            }
            return false;
        });
    }

    /**
     * 删除锁.
     * 这里可能会存在一种异常情况,即如果线程A加锁成功
     * 但是由于io或GC等原因在有效期内没有执行完逻辑,这时线程B也可拿到锁去执行。
     * (方案,可以加锁的时候新增当前线程的id作为标识,释放锁时,判断一下,只能释放自己加的锁)
     *
     * @param lockName 锁名称
     */
    public void delLock(String lockName) {
        // 直接删除key释放redis锁
        redisTemplate.delete(lockName);
    }
}

逻辑已经在注释里写得很清楚了,这里不再累述。

使用场景复现的demo来验证

还是同样的demo,不过这里的定时任务逻辑可能要改动一下了,执行逻辑之前先去redis获取一下锁,只有获取锁成功了才可执行。

更改后的 demo 代码如下:


/**
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 11:58
 */
@Component
@Slf4j
public class TaskDemo {

    @Autowired
    private RedisLockUtils redisLockUtils;

    @Scheduled(cron = "0/5 * * * * *")
    public void start(){
        try {
            Boolean lock = redisLockUtils.getLock("test", 3000);
            //获取锁
            if (lock) {
                log.info("机器【1】上的 demo 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
                //延迟一秒
                Thread.sleep(1000);
            }else {
                log.error("获取锁失败了,此次不执行!");
            }
        }catch (Exception e){
            log.info("获取锁异常了!");
        }finally {
            //释放redis锁
            redisLockUtils.delLock("test");
        }
    }
}

执行结果如下:

A 服务器

AOP+自定义注解实现Redis分布式锁_第3张图片

B 服务器

在这里插入图片描述

结果:同一个时间间隔里只有一台服务器可以执行,与期望相符。

四、代码优化

现在的实现方式,必须要改动定时任务的代码逻辑,添加加锁和解锁的处理代码。这种与业务逻辑的耦合度有点高,不太美观。这里使用自定义注解 + AOP切面的方式来让锁处理 和 业务逻辑代码解耦,减少重复的代码。

关于自定义注解介绍,可以看我之前写的一篇文章《Java使用自定义注解》

1、编写自定义注解

编写一个自定义注解,有两个属性,一个是定时任务名称,用来标识这个锁,一个是锁的过期时间。


/**
 * 自定义redis锁注解.
 * 目的:把加锁解锁逻辑与业务代码解耦.
 *
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 16:50
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TaskRedisLock {

    /**
     * 定时任务名称.
     */
    String taskName();

    /**
     * 定时任务锁过期时间.
     */
    long expireTime();
}

2、编写AOP切面

编写一个AOP切面,对上面定义的自定义注解进行拦截,然后获取到里面的属性的值,最后再通过环绕通知来实现加锁和解锁的逻辑。

切面代码如下


/**
 * 定时任务锁切面,对加了自定义redis锁注解的任务进行拦截.
 *
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 16:59
 */
@Component
@Aspect
@Slf4j
public class RedisLockAspect {

	//加锁工具类
    @Autowired
    private RedisLockUtils redisLockUtils;

    /**
     * 拦截自定义的redis锁注解.
     */
    @Pointcut("@annotation(zpengblog.taskdemo.aop.custom.TaskRedisLock)")
    public void pointCut(){}

    /**
     * 环绕通知.
     */
    @Around("pointCut()")
    public Object Around(ProceedingJoinPoint pjp) throws Throwable {
        //获取方法
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        Method method = methodSignature.getMethod();
        //获取方法上的注解
        TaskRedisLock annotation = method.getAnnotation(TaskRedisLock.class);
        //获取任务名称
        String taskName = annotation.taskName();
        //获取失效时间
        long expireTime = annotation.expireTime();
        try {
            //获取锁
            Boolean lock = redisLockUtils.getLock(taskName, expireTime);
            if (lock) {
                return pjp.proceed();
            }else {
                log.error("[{} 任务] 获取redis锁失败了,此次不执行...", taskName);
            }
        }catch (Exception e){
            log.error("[{} 任务]获取锁异常了!", taskName, e);
        }finally {
        	//释放redis锁
            redisLockUtils.delLock(taskName);
        }
        return null;
    }
}

3、demo定时任务上使用该注解


/**
 * @author linzp
 * @version 1.0.0
 * CreateDate 2021/1/14 11:58
 */
@Component
@Slf4j
public class TaskDemo {

    @Autowired
    private RedisLockUtils redisLockUtils;

    /**
     * 使用自定义TaskRedisLock注解,通过aop来加锁.
     */
    @TaskRedisLock(taskName = "task_1", expireTime = 4000)
    @Scheduled(cron = "0/5 * * * * *")
    public void run(){
        log.info("task_1 定时任务启动了!>> 当前时间 [{}]", LocalDateTime.now());
        try {
            //延迟一秒
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

到此,成功把获取redis锁的逻辑和业务代码解耦,优化就完成啦。

PS:其实这个定时任务还是存在一些缺陷,目前我的场景基本满足了,当然,设计一个高可用,完美的分布式锁来说其实是一个复杂的事情。也有其他现成的方案可用,本文只是作为一个知识积累类共享,欢迎交流

https://blog.csdn.net/wang_jing_jing/article/details/106623112#comments_14608739 [参考资料]

你可能感兴趣的:(Java开发经验积累,redis,java,定时任务,分布式锁,微服务)