集群定时任务单点控制解决方案(分布式锁+续期锁解决方案)

本文探讨了如何在集群环境中确保只有一个节点执行Spring定时任务的问题。通过使用Redis分布式锁,结合自定义注解和AOP,实现任务的高并发控制。在设计中,考虑了锁超时、机器宕机等情况,确保任务的高稳定性和高性能。同时,通过设置锁的超时时间和监控机制,防止任务被重复执行。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. 业务需求描述        

        开发过程中有一个这样的需求: 集群环境中有一些Spring定时任务, 这些定时任务大概每分钟汇总生成一些日志数据, 但是我们只想让集群中其中一台服务器执行, 设计一个高性能,并且高稳定性的执行框架. (具体业务涉及到一些公司内部的商业问题, 因此这里不做过多的详细描述)

2. 设计方案思考

思考问题1:首先集群单点控制, 这显然是一个资源的竞争问题. 集群中控制资源竞争通常使用分布式锁,借助的中间件往往是redis或者是zookeeper。本次开发中我们使用的是redis。

思考问题2:既然是分布式锁,并且是高可靠性,这时候就不得不考虑一些问题:假设集群中某一台机器通过加锁抢到了任务,执行过程中这台机器突然断电宕机了,导致这个锁没能释放,那是不是其它的机器就永远不可能抢到任务了?

思考问题3:开发一个低耦合的执行框架,解耦其他开发人员的代码,首先我们想到的就是自定义注解+Spring AOP编程,即使用AspectJ

思考问题4:使用分布式锁解决方案,加锁肯定要用到时间戳,如果集群环境中多台服务器事件有细微的差别,又该如何控制?

3. 方案实现

3.1 首先Spring定时器已经存在一个注解:@Scheduled, 使用cron表达式可以实现全部机器上的定时任务,现在可考虑在此基础上新定义一个注解:@ScheduledOne 使用AOP编程切入,代码如下:

注解定义:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ScheduledOne {
    String name() default  "";//分布式锁的名字
}

AOP切面:

    @Around("@annotation(org.springframework.scheduling.annotation.Scheduled) &&" +
            "@annotation(com.xxx.scheduled.annotation.ScheduledOne)")
    public Object scheduledOneAround(ProceedingJoinPoint joinPoint) throws Throwable {
        //锁的名字要根据注解的方法名生成
        String lockName = getLockName(joinPoint);
        RedisLockBean lockBean = new RedisLockBean(lockName);
        if (redisLockService.tryLock(lockBean)) {
            // 第一层加锁:首先根据className+methodName作为key进行加锁,防止当前定时任务还没有执行完毕,下一次开始执行的时间又到来了。
            try {
                boolean acquireLock = isExec(joinPoint);
                //第二层加锁:获取到锁,检查此任务是否已被时间较快的机器执行, 默认设置超时时间为2h, 即:集群中时间最快的机器和时间最慢的机器相差时间不能超过两小时
                if (acquireLock) {
                    //设置锁成功,说明没有被执行过,开始执行,注意这里程序执行完并不释放上面的lock锁,而是设置超时时间24小时,以防其他时间慢的机器再次执行
                    Object result;
                    result = joinPoint.proceed();
                    return result;
                }
            } finally {
                redisLockService.unlock(lockBean);
            }
        }
        return null;
    }

tryLock()加锁是根据方法名+类名在redis中进行加锁,这一步可以保证当前时间只有一台机器能够抢到定时任务。回到思考问题4,假设我们的定时任务每分钟执行一次,cron表达式为:"0 * * * * * ?",那么如果有台机器事件较慢,具体例如:第一台机器2021-08-20 14:10:00秒抢到了任务执行了一次,第二台机器事件满了5秒,等第一台机器到了2021-08-20 14:10:05秒时由于没有机器和第二台去竞争,第二台机器很自然的就抢到了任务,并开始执行,这显然是不应该的。因此为了解决这个问题,再代码中isExec()方法,可以将其看成是第二次加锁,代码如下:

    //判断是否有时间快的机器已经执行过了,执行过了
    private boolean isExec(ProceedingJoinPoint joinPoint) {
        String lock = jedisConnectionFactory.jedisExecAndClose(jedis -> jedis.set(getLockKey(joinPoint), getLockValue(), "NX", "PX", timeUnit.toMillis(2)));
        return StringUtil.isNotBlank(lock) && lock.equalsIgnoreCase("OK");
    }

用于判断是否有事件快的机器已经执行过了,并且这个锁超时事件默认设置成2小时,意思是允许集群中全部机器时间相差最大2小时,这时集群中的任务还能够继续执行。上面还有一个思考问题2没有解决:即如果抢到任务的这台机器宕机了怎么办?

这里我们设计的解决方案是tryLock()加的锁默认超时时间是5秒中,并且获得锁之后将这个锁封装成一个bean, 即LockBean, 加入到本机的redis锁管理类中,这个类会维护一个线程,不断的扫描本机已获得的锁,如果超时时间即将超时(例如离过期仅剩5秒了,当然这个时间可以自定义),那么这个线程就会自动的再为这个锁续期一段时间,具体的事件封装咋LockBean中。当本机的任务正常结束就会将这个lockBean从管理类中移除。如果机器宕机的话,那么续期锁线程自然也就不工作了,redis数据库中这个锁到了一定的超时时间就会自动的释放。

通过上面的案例可以发现有几个注意事项:

1. 首先锁的默认超时时间不要设置太长;

2. @Scheduled注解中只支持cron表达式,精确到某一时间单位的写法,不能使用fixRate来控制执行频率。因为每台机器上web服务启动的事件不一致。

如果大家有什么好的其他的解决方案可以一期共同讨论!(将定时任务抽取出来为一个单独的工程然后单独部署的想法除外,因为这和web业务解耦了)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值