springboot 分布式定时任务调度 zookeeper + AOP实现

Scheduled 定时任务的问题

定时任务,是很多业务系统都需要用到的东西,在Springboot中,我们通常用@Scheduled注解去定义一个单体应用定时任务。然而在微服务的场景下还使用这个东西,定时任务就会重复执行了。就比如我向下面这样定义了一个定时任务(每分钟的0,5,10…秒都会执行),然后修改端口启动两次应用,不用猜,自然是两个进程都重复执行了。

	@Scheduled(cron = "*/5 * * * * ?")
    public void MySchedule2(){
        try{
            logger.info("[DistributedScheduled service port:{}] 执行定时任务 ....",port);
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

在某些业务场景下定时任务重复执行,是会有问题的。比如说一个信用卡系统,每天早上八点扫描自己的欠钱的用户来发催还款的短信。大家可以想想,如果你是一个信用卡用户,它每次催缴短信都是三连发,是不是烦的一批(至少我是这样)。当然,我们可以给业务表加一些字段来标志它是否推送过短信解决这个问题,但是这么做,定时任务的逻辑侵入业务了,增加了业务表的复杂性,这种事情当然是不想干的。

有没有什么好办法呢?

我选择的方案是zookeeper给定时任务枷锁,来保证同一时刻只有一个服务会触发定时任务。每个服务在执行定时任务之前都要去判断自己能不能执行,具体而言,就是用定时任务名去zookeeper加锁,加锁成功才能执行。时序图如下:
分布式定时调度时序图

实现代码

下面部分主要是代码,首先是启动类,注意要加上EnableScheduling 开启定时任务,否则不会执行:

@SpringBootApplication
@EnableScheduling   // 2.开启定时任务
public class ApplicationBoot {
    public static void main(String[] args) {
        new SpringApplication(ApplicationBoot.class).run(args);
    }
}

下面是定义了一个包含定时任务信息的注解,主要可以拿来获取定时任务名。

import org.springframework.core.annotation.AliasFor;
import org.springframework.scheduling.annotation.Scheduled;
import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedScheduleInfo {

    @AliasFor("name")
    String value() default "";

    @AliasFor("value")
    String name() default "";
}

接下来是最主要的部分了,切面处理。我用 @annotation 的方式来处理所有标注了 DistributedScheduleInfo的方法。在这部分的代码逻辑也很简单,一上来,首先使用反射获取方法的注解上的定时任务名,然后去zookeeper 创建一个节点。创建成功,则意味着加锁成功,创建节点失败,则跳过不执行本次定时任务。如下:


import org.I0Itec.zkclient.ZkClient;
import org.apache.zookeeper.KeeperException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;

@Aspect
@Component
public class DistributeAspect {

    private static final Logger logger = LoggerFactory.getLogger(DistributeAspect.class);

    @Autowired
    ZkClient zkClient;

    @Pointcut("@annotation(fc.learn.schedule.DistributedScheduleInfo)")
    public void scheduleAspect(){}

    @Around(value = "scheduleAspect()")
    public Object arround(ProceedingJoinPoint pjp) {
        String scheduleName = getScheduleName(pjp);
        try {
            zkClient.createEphemeral("/schedule-distributed/"+scheduleName);
            Object o = pjp.proceed();
            return o;
        } catch (Exception e) {
            logger.error("未抢占到定时任务");
        } finally {
            zkClient.delete("/schedule-distributed/"+scheduleName);
            return null;
        }
    }

    private String getScheduleName(ProceedingJoinPoint pjp){
        try {
            Class targetCls = pjp.getTarget().getClass();
            MethodSignature ms=(MethodSignature)pjp.getSignature();
            Method targetMethod=
                    targetCls.getDeclaredMethod(
                            ms.getName(),
                            ms.getParameterTypes());
            DistributedScheduleInfo distributedScheduleInfo = targetMethod.getAnnotation(DistributedScheduleInfo.class);
            return distributedScheduleInfo.name();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
            return "";
        }
    }
}

接下来还有zookeeper client的初始化:

import org.I0Itec.zkclient.ZkClient;
import org.I0Itec.zkclient.ZkConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ZokiConfiguration {

    @Bean
    public ZkConnection zkConnection(ZokiConfig zokiConfig) {
        ZkConnection zkConnection = new ZkConnection("127.0.0.1:2181",30000);
        return zkConnection;
    }

    @Bean
    public ZkClient zkClient(ZkConnection zkConnection){
        return new ZkClient(zkConnection);
    }
}

最后是项目依赖:

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.1.RELEASE</version>
    </parent>
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.101tec/zkclient -->
        <dependency>
            <groupId>com.101tec</groupId>
            <artifactId>zkclient</artifactId>
            <version>0.9</version>
        </dependency>

    </dependencies>

我们运行一下,结果如下,解决了springboot 定时任务重复调度的问题。
日志8082端口
日志8081端口

这个实现还存在的问题-有待实现~~
1.假如争抢到锁的定时任务执行失败了,如何让其他节点进行重试

抢锁失败的服务,可以让它监听当前的任务节点,执行失败的服务会修改zookeeper节点的信息,这样其他监听着的服务就可以接棒去重试执行。

2.这里我并没有控制加锁的时间,如果两台机器的时钟差别大过业务执行时间,那么可能还是会有重复执行的情况出现

比如说我执行一个十点整零五秒的定时任务,而且很快就执行完了,执行完还不到一秒。另外一台服务器的时钟比我晚了三秒钟,那么过了三秒钟之后,它调度到十点整零五秒这个任务(这是我已经执行完成并且释放了锁),那么这时就会重复执行了。
我们以 cron="*/5 * * * * ?" 为例来说明解决办法。

这个的解决办法是可以通过cron表达式计算出本次执行任务的时间来加锁,并且给锁加一个较长的超时。比如说执行定时任务时,计算出是十点整零五秒的任务,那我去zookeeper 加锁的时候节点名就叫"发短信10_00_05"。这样的话每个定时任务节点所知道的信息就是我要调度哪个时刻的什么任务。就不会重复了。

业界的解决方案

关于定时任务,微博知乎有大量的信息,quartz、xxl-job等。具体不再赘述。
关于xxl-job,我一开始很疑惑为什么是个这么神奇的名字,类似于我们最开始学编程的时候使用 x,y,a,b来定义变量一样。@@ 后来了解了一下,是大众点评员工徐雪里发布的,恍然大悟原来是作者的名字拼音缩写。
谢谢大家阅读,如有错漏,欢迎指正。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值