分布式集群下的任务调度

    本篇要探讨的一个问题就是在分布式集群下,如何避免定时任务在多个节点中被重复执行?

一. 场景示例
    在我曾经参与开发过的一个消费金融系统中,需要每隔一段时间从合同表中拉取符合条件的合同,组装成资方需要的参数,然后将合同推送给资方审核,并设置回调接口,接收审核结果,从而变更合同状态。我们的项目是基于Spring Boot + MyBatis + MySql的,集群部署,负载均衡,每个服务有两个节点,当时为了快速开发,没有专门的定时任务管理系统,刚开始想当然的直接使用Spring的@Scheduled注解开发,后面联调时发现同一个合同重复被推送了过去。而我想要达成的目标是:单点执行,故障转移,服务状态。
    每个节点都是一个独立的Server,它们的JVM是相互独立的。也就是说在内存方面我们是没办法做到节点之间的相互通信。所以需要一个第三方的媒介去完成两个节点的通信。查询了一些相关的资料后,发现说可以采用分布式锁或者基于开源的的带web界面的任务管理系统开发,个人认为就当时的需求来说过于复杂,所以,我将切入点放在数据库上,这里说明下两个节点连接的同一个数据库,是否可以通过让数据库中的操作受到约束条件产生异常来实现类似分布式锁的效果。
    另外说明一下,因为不同服务器可能存在时间差,所以对于同一个定时任务,触发点就存在时间差。

二. 实现方案

  • 只在一台服务器上部署该定时任务代码
    优点:简单易理解
    缺点: 部署麻烦,需要多套代码,且当这台服务器出问题时就没定时任务了。

  • 在定时任务代码上加上某个特定的ip限制,仅某个ip的服务器能运行该定时任务
    优点:简单易理解,部署简单,不需要多套代码。
    缺点:同上,只能规定一台服务器运行,发生故障时就没办法了。

  • 利用数据库的共享锁事务管理机制来运行定时任务
    原理:在数据库中新建一张表定时任务表,存储了上次执行定时任务的ip地址(ip),任务名称(task_name),是否正在执行(execute)。
    集群中的所有服务器都是走以下流程:
    step1:查找数据库的定时任务表;
    step2:检查是否有机器在运行定时任务。
    检查方法:update定时任务表的excute字段为1(1为执行中,0为未执行)、ip为自己的ip,如果update失败,则证明有机器在执行该定时任务,该机器的定时任务就不执行了,成功则进行step3;
    step3:执行定时任务的具体内容;
    step4:还原execute字段为0。
    以上是该方案的流程,利用了mysql的共享锁机制判断,通过是否更新成功来判断是否有机器正在执行定时任务,这种方案可以保证任务只执行一次,且只要集群中有一台服务器是好的,就会执行任务。
    缺陷:这种方式在如下场景下会有问题:假设在step3时,正在执行任务的服务器异常宕机了,execute没有被更新为0,下次重新启动时,两个节点都没有更新成功,任务就得不到执行,本质上step3和step4两个操作是原子性的。如何防止这种情况呢?如果放在事务中执行,是否奏效呢?
    如果一个事务执行到一半断电了,事务是否会回滚?
    https://bbs.csdn.net/topics/380077258

  • redis
    原理:和第三种差不多,只是通过redis的key-value来存储任务名–执行ip。执行定时任务前先查询redis是否有改任务的值,没有就自己 执行,并插入新的key-vale。有的话就查看ip是否是自己,是的话就执行,不是的话就 证明有其他机器在执行,自己就不执行啦。过期时间可以自己设置,方便有机器出故障时候可以转移机器执行任务。
    优点:利用了redis的自动过期机制实现了转移故障机器的问题,比较简单,而且redis的访问速度也很快。
    缺点:这里没有事务管理机制,访问redis的时候,一定会出现高并发的情况,所以得自己实现redis的共享锁机制。
    参考:https://www.jianshu.com/p/48c5b11b80cd

  • 分布式锁
    想要避免重复执行,无非是一个任务互斥访问的问题,声明一把全局的“锁”作为互斥量,哪个应用服务器拿到这把“锁”,就有执行任务的权利,未拿到“锁”的应用服务器不进行任何任务相关的操作。另外就是这把“锁”最好还能在下次任务执行时间点前失效。可以基于Redis或Zookeeper实现的分布式锁解决。

  • Quartz集群分布式(并发)部署方案
    Quartz本身自带集群方案, JDK自带的Timer也可以实现相同的功能, 但Timer存在的单点故障是生产环境上所不能容忍的。 在自己造个有负载均衡和支持集群(高可用、伸缩性)的调度框架又影响项目的进度, 所以不如直接使用Quartz来作为调度框架。
    一个Quartz集群中的每个节点是一个独立的Quartz应用,它又管理着其他的节点。这就意味着你必须对每个节点分别启动或停止。Quartz集群中,独立的Quartz节点并不与其他节点或是管理节点通信,而是通过相同的数据库表来感知到另一Quartz应用的。
    Quartz有很成熟的分布式并发部署定时任务的解决方案了,但是配置比较复杂,且需要新建恨的数据库表, 有兴趣的可以查找相关资料。

  • 开源的分布式任务XXL-JOB/Elastic-Job/Saturn

综上所述,我觉得第三种方案适合小型的项目去做,大的项目最好用Quartz或者开源的去做。

在实现的过程中又发现,同一台服务器上的不同版本之间也会发生cron重复执行的问题,所以不仅考虑不同服务器的问题,还得考虑不同版本之间的问题。

  • Spring Cloud Eureka DiscoveryClient
    如果项目是基于Spring Cloud的微服务架构, 则可以使用 DiscoveryClient,每次随机的选择一台服务器执行定时任务。伪代码如下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;

    @Autowired
    private DiscoveryClient discoveryClient;

    @RequestMapping(value = "/dept/discovery",method = RequestMethod.GET)
    public Object discovery() {
        List<String> list = discoveryClient.getServices();
        List<ServiceInstance>  serviceInstances = discoveryClient.getInstances("microcloudservice-provider-schedule");
        for(ServiceInstance serviceInstance:serviceInstances) {
            System.out.println(serviceInstance.getServiceId() + "\t" +
                    serviceInstance.getHost() + "\t" +
                    serviceInstance.getPort() + "\t" +
                    serviceInstance.getUri());
				// 随机返回一台服务器
        }
        return this.discoveryClient;
    }

三. 代码实现
    采用上述的第三种实现方案,使用数据库锁解决。

  1. 在启动类添加开启定时任务注解@EnableScheduling,否则定时任务无效
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  1. 建立定时任务表
create table if not exists sys_schedule_lock(
    task_id varchar(32) primary key not null comment '任务类型id',
    task_desc varchar(32) default null comment '任务类型说明',
    exec_ip varchar(32) default null comment '获取锁的机器ip',
    acquire_time datetime DEFAULT NULL comment '获取锁的时间',
    status int(11) comment '当前任务id锁状态: 0-空闲 1-占用'
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='定时任务执行数据库锁';
  1. PO和Mapper映射
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@NoArgsConstructor
@Data
@TableName("sys_schedule_lock")
public class ScheduleLock {

    @TableId(value = "task_id")
    private String taskId;
    private String taskDesc;
    private Date acquireTime;
    private String execIp;
    private int status;
}
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zzxypm.entity.ScheduleLock;

import java.util.Map;

public interface ScheduleLockMapper extends BaseMapper<ScheduleLock> {
    int lock(Map<String,Object> map);
    int unlock(Map<String,Object> map);
    int batchResetStatus();
}
  1. ScheduleLockMapper.xml


<mapper namespace="com.zzxypm.mapper.ScheduleLockMapper">

   <update id="lock" parameterType="map">
       update sys_schedule_lock
       set exec_ip = #{execIp},
           acquire_time = now(),
           status = 1
       where task_id = #{taskId}
       and status = 0;
   update>

    <update id="unlock" parameterType="map">
       update sys_schedule_lock
       set
           acquire_time = now(),
           status = 0
       where task_id = #{taskId}
       and status = 1
       and exec_ip = #{execIp};
   update>

   <update id="batchResetStatus">
           update sys_schedule_lock
           set status = 0
           where status = 1
   update>
mapper>
  1. 任务定义枚举类
package com.zzxypm.schedule;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

public enum TaskIdEnum {
    TASK_TODO("TASK_TODO", "定时推送待办任务");

    TaskIdEnum(String taskId, String desc) {
        this.taskId = taskId;
        this.desc = desc;
    }

    /**
     * 任务唯一id
     */
    private String taskId;

    /**
     * 任务类型描述
     */
    private String desc;


    public String getTaskId() {
        return taskId;
    }

    public String getDesc() {
        return desc;
    }

    public static TaskIdEnum acquire(final String taskId) {
        Optional<TaskIdEnum> serializeEnum =
                Arrays.stream(TaskIdEnum.values())
                        .filter(v -> Objects.equals(v.getTaskId(), taskId))
                        .findFirst();
        return serializeEnum.orElse(TaskIdEnum.TASK_TODO);
    }

    public static List<TaskIdEnum> getEnumList() {
        return Arrays.asList(TaskIdEnum.values());
    }
}
  1. 根据任务定义枚举初始化数据库任务表
INSERT INTO zzxypm.sys_schedule_lock (task_id, task_desc, exec_ip, acquire_time, status) VALUES ('TASK_TODO', '定时推送待办任务', '127.0.0.1', null, 0);
  1. 配置定时任务线程池
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

/**
 * @author wanghong
 * @date 2019/07/03 22:25
 * 定时任务线程池配置
 * 基于注解@Scheduled默认为单线程,开启多个任务时,任务的执行时机会受上一个任务执行时间的影响。
 **/
@Configuration
@EnableAsync
public class ScheduledPoolConfig {

    @Bean("scheduledPoolTaskExecutor")
    public ThreadPoolTaskExecutor getAsyncThreadPoolTaskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
        taskExecutor.setMaxPoolSize(200);
        taskExecutor.setQueueCapacity(500);
        taskExecutor.setKeepAliveSeconds(200);
        taskExecutor.setThreadNamePrefix("Scheduled-");
        // 线程池对拒绝任务(无线程可用)的处理策略,目前只支持AbortPolicy、CallerRunsPolicy;默认为后者
        // taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 自定义拒绝策略
        taskExecutor.setRejectedExecutionHandler((r,e) -> {
            if (!e.isShutdown()) {
                r.run();
            }
            // 记录执行失败的任务到数据库表中
            // 发送告警邮件给相关负责人
        });
        //调度器shutdown被调用时等待当前被调度的任务完成
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        //等待时长
        taskExecutor.setAwaitTerminationSeconds(60);
        taskExecutor.initialize();
        return taskExecutor;
    }
}

参数说明:

  • corePoolSize 线程池维护线程的最少数量

  • keepAliveSeconds 线程池维护线程所允许的空闲时间

  • maxPoolSize 线程池维护线程的最大数量

  • queueCapacity 线程池所使用的缓冲队列

当一个任务通过execute(Runnable)方法欲添加到线程池时:

  • 如果此时线程池中的数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。

  • 如果此时线程池中的数量等于 corePoolSize,但是缓冲队列workQueue未满,那么任务被放入缓冲队列。

  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。

  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量等于maximumPoolSize,那么通过
    handler所指定的策略来处理此任务。也就是:处理任务的优先级为:核心线程corePoolSize、任务队列workQueue、最大线程
    maximumPoolSize,如果三者都满了,使用handler处理被拒绝的任务。

  • 当线程池中的线程数量大于
    corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数。

  1. 编写具体的定时任务类
import com.zzxypm.mapper.ScheduleLockMapper;
import com.zzxypm.schedule.TaskIdEnum;
import com.zzxypm.util.IPV4Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.Map;

/**
 * Created by wanghong
 * Date 2019-06-18 15:06
 * Description:
 * 待办推送任务
 */
@Component
public class TodoTask {

    private static final Logger log = LoggerFactory.getLogger(TodoTask.class);

    @Autowired
    private ScheduleLockMapper lockMapper;

    private static TaskIdEnum taskIdEnum = TaskIdEnum.TASK_TODO;

    // 每隔5秒执行一次
    private static final String CRON = "*/5 * * * * ?";

    @Transactional
    @Async("scheduledPoolTaskExecutor")
    @Scheduled(cron = CRON)
    protected void pushTodo() {
        String taskId = taskIdEnum.getTaskId();
        String ip = IPV4Util.getLocalIpv4Address();
        Map<String, Object> map = new HashMap<>();
        map.put("taskId", taskId);
        map.put("execIp", ip);
        int lock = lockMapper.lock(map);

        if (lock == 1) {
            // 模拟执行具体的任务
            log.info("定时推送待办任务执行开始,taskID:[{}],执行服务器IP:[{}]", taskId, ip);
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.info("定时推送待办任务执行结束,taskID:[{}],执行服务器IP:[{}]", taskId, ip);
            lockMapper.unlock(map);
        }
    }
}
  1. 服务重启时重置状态
import com.zzxypm.entity.ScheduleLock;
import com.zzxypm.mapper.ScheduleLockMapper;
import com.zzxypm.schedule.TaskIdEnum;
import com.zzxypm.util.DateUtil;
import com.zzxypm.util.IPV4Util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

/**
 * Created by John on 2019/1/25.
 * SpringBoot启动时执行任务CommandLineRunner
 */
@Component
public class StartupRunner implements CommandLineRunner {

    @Autowired
    private ScheduleLockMapper lockMapper;

    @Override
    public void run(String... strings) {
        batchResetTaskStatus();
    }

    /**
     * 项目启动时重置所有定时任务的状态为初始状态,
     *  防止因为服务器因为异常宕机等情况致使状态没有释放,
     *  从而导致重启时定时任务得不到执行
     */
    private void batchResetTaskStatus(){
        lockMapper.batchResetStatus();
    }
}

你可能感兴趣的:(Spring-Boot)