分佈式集羣下的任務調度

    本篇要探討的一個問題就是在分佈式集羣下,如何避免定時任務在多個節點中被重複執行?

一. 場景示例
    在我曾經參與開發過的一個消費金融系統中,需要每隔一段時間從合同表中拉取符合條件的合同,組裝成資方需要的參數,然後將合同推送給資方審覈,並設置回調接口,接收審覈結果,從而變更合同狀態。我們的項目是基於Spring Boot + MyBatis + MySql的,集羣部署,負載均衡,每個服務有兩個節點,當時爲了快速開發,沒有專門的定時任務管理系統,剛開始想當然的直接使用Spring的@Scheduled註解開發,後面聯調時發現同一個合同重複被推送了過去。而我想要達成的目標是:單點執行,故障轉移,服務狀態。
    每個節點都是一個獨立的Server,它們的JVM是相互獨立的。也就是說在內存方面我們是沒辦法做到節點之間的相互通信。所以需要一個第三方的媒介去完成兩個節點的通信。查詢了一些相關的資料後,發現說可以採用分佈式鎖或者基於開源的的帶web界面的任務管理系統開發,個人認爲就當時的需求來說過於複雜,所以,我將切入點放在數據庫上,這裏說明下兩個節點連接的同一個數據庫,是否可以通過讓數據庫中的操作受到約束條件產生異常來實現類似分佈式鎖的效果。
    另外說明一下,因爲不同服務器可能存在時間差,所以對於同一個定時任務,觸發點就存在時間差。

二. 實現方案

  1. 只在一臺服務器上部署該定時任務代碼
    優點:簡單易理解
    缺點: 部署麻煩,需要多套代碼,且當這臺服務器出問題時就沒定時任務了。

  2. 在定時任務代碼上加上某個特定的ip限制,僅某個ip的服務器能運行該定時任務
    優點:簡單易理解,部署簡單,不需要多套代碼。
    缺點:同上,只能規定一臺服務器運行,發生故障時就沒辦法了。

  3. 利用數據庫的共享鎖事務管理機制來運行定時任務
    原理:在數據庫中新建一張表定時任務表,存儲了上次執行定時任務的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重複執行的問題,所以不僅考慮不同服務器的問題,還得考慮不同版本之間的問題。

三. 代碼實現
    採用上述的第三種實現方案,使用數據庫鎖解決。

  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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<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();
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章