Elastic-Job的執行原理及優化實踐

OPPO數智技術發表於2021-11-04

1. Quartz

Quartz是由OpenSymphony提供的強大的開源任務排程框架,用來執行定時任務。比如每天凌晨三點鐘需要從資料庫匯出資料,這時候就需要一個任務排程框架,幫我們自動去執行這些程式。那Quartz是怎樣實現的呢?
1)首先我們需要定義一個執行業務邏輯的介面,即Job,我們的類繼承這個介面來實現業務邏輯,比如凌晨三點讀取資料庫並且匯出資料。

2)有了Job之後需要按時執行這個Job,這就需要一個觸發器Trigger,觸發器Trigger就是按照我們的要求在每天凌晨三點執行我們定義的Job。

3)有了任務Job和觸發器Trigger後,就需要把它們結合起來,讓觸發器Trigger在規定的時間呼叫Job,這時需要一個Schedule來實現這個功能。
所以,Quartz主要有三個部分組成:
排程器:Scheduler
任務:JobDetail
觸發器:Trigger,包括SimpleTrigger和CronTrigger
建立一個Quartz任務的流程如下:

//定義一個作業類,實現使用者的業務邏輯
public class HelloJob implements Job {
     ......
     實現業務邏輯
}
//根據作業類得到JobDetail
JobDetail jobDetail = JobBuilder.newJob(HelloJob.class)
//定義一個觸發器,按照規定的時間排程作業
Trigger trigger = TriggerBuilder.newTrigger("每隔1分鐘執行一次")
//根據作業類和觸發器建立排程器
Scheduler scheduler = scheduler.scheduleJob(jobDetail,trigger);
//啟動排程器,開始執行任務
scheduler .start()

2. Elastic-Job的基本原理

2.1 分片

Elastic-Job為了提高任務的併發能力,引入了分片的概念,即將一個任務劃分成多個分片,然後由多個執行的機器分別領取這些分片來執行。比如一個資料庫中有1億條資料,需要將這些資料讀取出來並計算,然後再寫入到資料庫中。就可以將這1億條資料劃分成10個分片,每一個分片讀取其中的1千萬條資料,然後計算後寫入資料庫。這10個分片編號為0,1,2...9,如果有三臺機器執行,A機器分到分片(0,1,2,9),B機器分到分片(3,4,5),C機器分到分片(6,7,8) 。

2.2 作業排程與執行

Elastic-Job是去中心化的任務排程框架,當多個節點執行時,會先選擇一個主節點,當到達執行時間後,每個例項開始執行任務,主節點負責分片的劃分,其它節點等待劃分完成,主節點將劃分後的結果存放到zookeeper中,然後每個節點再從zookeeper中獲取劃分好的分片項,將分片資訊作為引數,傳入到本地的任務函式中,從而執行任務。

2.3 作業的型別

elastic-job支援三種型別的作業任務處理!
Simple 型別作業:Simple 型別用於一般任務的處理,只需實現SimpleJob介面。該介面僅提供單一方法用於覆蓋,此方法將定時執行,與Quartz原生介面相似。
Dataflow 型別作業:Dataflow 型別用於處理資料流,需實現DataflowJob介面。該介面提供2個方法可供覆蓋,分別用於抓取(fetchData)和處理(processData)資料。
Script型別作業:Script 型別作業意為指令碼型別作業,支援 shell,python,perl等所有型別指令碼。只需通過控制檯或程式碼配置 scriptCommandLine 即可,無需編碼。執行指令碼路徑可包含引數,引數傳遞完畢後,作業框架會自動追加最後一個引數為作業執行時資訊。

3. Elastic-Job的執行原理

3.1 Elastic-Job的啟動流程

下面以一個SimpleJob型別的任務來說明elastic-job的啟動流程

public class MyElasticJob implements SimpleJob {
    public void execute(ShardingContext context) {
         //實現業務邏輯
          ......
    }
   
     // 對zookeeper進行設定,作為分散式任務的註冊中心
    private static CoordinatorRegistryCenter createRegistryCenter() {
        CoordinatorRegistryCenter regCenter = new ZookeeperRegistryCenter(new ZookeeperConfiguration("xxxx"));
        regCenter.init();
        return regCenter;
    }

    //設定任務的執行頻率、執行的類
    private static LiteJobConfiguration createJobConfiguration() {
        JobCoreConfiguration simpleCoreConfig = JobCoreConfiguration.newBuilder("demoSimpleJob", "0/15 * * * * ?", 10).build();
        // 定義SIMPLE型別配置
        SimpleJobConfiguration simpleJobConfig = new SimpleJobConfiguration(simpleCoreConfig, MyElasticJob.class.getCanonicalName());
        // 定義Lite作業根配置
        LiteJobConfiguration simpleJobRootConfig = LiteJobConfiguration.newBuilder(simpleJobConfig).build();
        return simpleJobRootConfig;
    }
   //主函式
 public static void main(String[] args) {
        new JobScheduler(createRegistryCenter(), createJobConfiguration()).init();
    }
}

建立一個Elastic-Job的任務並執行,步驟如下:
1)需要先設定zookeeper的基本資訊,Elastic-Job使用zookeeper來進行分散式管理,如選主、後設資料儲存與讀取、分散式監聽機制等。
2)建立一個執行任務的Job類,以Simple 型別作業為例,建立一個繼承SimpleJob的類,在這個類中實現execute函式。
3)設定作業的基本資訊,在JobCoreConfiguration 中設定作業的名稱(jobName),作業執行的時間表示式(cron),總的分片數(shardingTotalCount);然後在SimpleJobConfiguration 中設定執行作業的Job類,最後定義Lite作業根配置。
4)建立JobScheduler(作業排程器)例項,然後JobScheduler的init()方法中執行作業的初始化,這樣作業就開始執行了。
Elastic-Job的作業排程在JobScheduler中完成,下面詳細介紹JobScheduler方法。JobScheduler的定義如下:

public class JobScheduler {
    
    public static final String ELASTIC_JOB_DATA_MAP_KEY = "elasticJob";
    
    private static final String JOB_FACADE_DATA_MAP_KEY = "jobFacade";
     
    //作業配置
    private final LiteJobConfiguration liteJobConfig;
    
   //註冊中心 
   private final CoordinatorRegistryCenter regCenter;
    
    //排程器門面
    private final SchedulerFacade schedulerFacade;
    
    //作業門面
    private final JobFacade jobFacade;
 
     private JobScheduler(final CoordinatorRegistryCenter regCenter, final LiteJobConfiguration liteJobConfig, final JobEventBus jobEventBus, final ElasticJobListener... elasticJobListeners) {
        JobRegistry.getInstance().addJobInstance(liteJobConfig.getJobName(), new JobInstance());
 
        this.liteJobConfig = liteJobConfig;
 
        this.regCenter = regCenter;
 
        List<ElasticJobListener> elasticJobListenerList = Arrays.asList(elasticJobListeners);
 
        setGuaranteeServiceForElasticJobListeners(regCenter, elasticJobListenerList);
 
        schedulerFacade = new SchedulerFacade(regCenter, liteJobConfig.getJobName(), elasticJobListenerList);
 
        jobFacade = new LiteJobFacade(regCenter, liteJobConfig.getJobName(), Arrays.asList(elasticJobListeners), jobEventBus);
    }

如上,在JobScheduler的構造方法中,設定好作業配置資訊liteJobConfig、註冊中心regCenter、一系列監聽器elasticJobListenerList ,排程器門面,作業門面。
在建立好JobScheduler例項後,就進行作業的初始化操作,如下:

/**
     * 初始化作業.
     */
    public void init() {
        JobRegistry.getInstance().setCurrentShardingTotalCount(liteJobConfig.getJobName(), liteJobConfig.getTypeConfig().getCoreConfig().getShardingTotalCount());
        JobScheduleController jobScheduleController = new JobScheduleController(createScheduler(), createJobDetail(liteJobConfig.getTypeConfig().getJobClass()), liteJobConfig.getJobName());
        JobRegistry.getInstance().registerJob(liteJobConfig.getJobName(), jobScheduleController, regCenter);
        schedulerFacade.registerStartUpInfo(liteJobConfig);
        jobScheduleController.scheduleJob(liteJobConfig.getTypeConfig().getCoreConfig().getCron());
    }

如上,
1)JobRegistry是作業登錄檔,以單例的形式儲存作業的後設資料,在JobRegistry中設定好分片總數等資訊。
2)jobScheduleController是作業排程控制器,在jobScheduleController中可以執行:排程作業、重新排程作業、暫停作業、恢復作業、立刻恢復作業。所以作業的開始、暫停、恢復都是在jobScheduleController中執行的。
3)在作業登錄檔JobRegistry中設定作業名稱、作業排程器、註冊中心。
4)執行排程器門面schedulerFacade的registerStartUpInfo方法,在這個方法中註冊作業啟動資訊,程式碼如下:

/**
     * 註冊作業啟動資訊.
     * 
     * @param liteJobConfig 作業配置
     */
    public void registerStartUpInfo(final LiteJobConfiguration liteJobConfig) {
        regCenter.addCacheData("/" + liteJobConfig.getJobName());
        // 開啟所有監聽器
        listenerManager.startAllListeners();
        // 選舉主節點
        leaderService.electLeader();
        //持久化job的配置資訊
        configService.persist(liteJobConfig);
        LiteJobConfiguration liteJobConfigFromZk = configService.load(false);
        // 持久化作業伺服器上線資訊
       serverService.persistOnline(!liteJobConfigFromZk.isDisabled());
        // 持久化作業執行例項上線相關資訊,將服務例項註冊到zk
        instanceService.persistOnline();
        // 設定 需要重新分片的標記
        shardingService.setReshardingFlag();
        // 初始化 作業監聽服務
        monitorService.listen();
        // 初始化 調解作業不一致狀態服務
        if (!reconcileService.isRunning()) {
            reconcileService.startAsync();
        }
    }

如上,
1)開啟所有的監聽器,利用zookeeper的watch機制來監聽系統中各種後設資料的變化,從而執行相應的操作
2)選舉主節點,利用zookeeper的分散式鎖來選擇一個主節點,主節點主要進行分片的劃分。
3)持久化各種後設資料到zookeeper,如作業的配置資訊,每個服務例項的資訊等
4)設定需要分片的標誌,在第一次執行任務或者系統中服務例項增減時都需要重新分片。
在作業啟動資訊註冊好以後,就呼叫jobScheduleController的scheduleJob方法,進行作業的排程,這樣作業就開始執行了。scheduleJob方法的程式碼如下:

/**
     * 排程作業.
     * 
     * @param cron CRON表示式
     */
    public void scheduleJob(final String cron) {
        try {
            if (!scheduler.checkExists(jobDetail.getKey())) {
                scheduler.scheduleJob(jobDetail, createTrigger(cron));
            }
            scheduler.start();
        } catch (final SchedulerException ex) {
            throw new JobSystemException(ex);
        }
    }

通過前面Quartz的講解可知,scheduler通過將jobDetail和觸發器Trigger結合,再呼叫scheduler.start(),這樣就開始了作業呼叫。
通過上面的程式碼分析可知。作業的啟動流程如下:

3.2 Elastic-Job的執行流程

通過前面Quartz的講解可知,任務的執行實際是執行JobDetail中定義的業務邏輯,我們只需要看jobDetail裡面的內容,就能知道作業執行的過程

private JobDetail createJobDetail(final String jobClass) {
    JobDetail result = JobBuilder.newJob(LiteJob.class).withIdentity(liteJobConfig.getJobName()).build();
    //忽略其它程式碼
}

通過上面的程式碼可知,執行的任務就是LiteJob這個類的內容

public final class LiteJob implements Job {
    
    @Setter
    private ElasticJob elasticJob;
    
    @Setter
    private JobFacade jobFacade;
    
    @Override
    public void execute(final JobExecutionContext context) throws JobExecutionException {
        JobExecutorFactory.getJobExecutor(elasticJob, jobFacade).execute();
    }
}

LiteJob 通過 JobExecutorFactory 獲得到作業執行器( AbstractElasticJobExecutor ),並進行執行:

public final class JobExecutorFactory {
    
    /**
     * 獲取作業執行器.
     *
     * @param elasticJob 分散式彈性作業
     * @param jobFacade 作業內部服務門面服務
     * @return 作業執行器
     */
    @SuppressWarnings("unchecked")
    public static AbstractElasticJobExecutor getJobExecutor(final ElasticJob elasticJob, final JobFacade jobFacade) {
        // ScriptJob
        if (null == elasticJob) {
            return new ScriptJobExecutor(jobFacade);
        }
        // SimpleJob
        if (elasticJob instanceof SimpleJob) {
            return new SimpleJobExecutor((SimpleJob) elasticJob, jobFacade);
        }
        // DataflowJob
        if (elasticJob instanceof DataflowJob) {
            return new DataflowJobExecutor((DataflowJob) elasticJob, jobFacade);
        }
        throw new JobConfigurationException("Cannot support job type '%s'", elasticJob.getClass().getCanonicalName());
    }
}

可見,作業執行器工廠JobExecutorFactory ,根據不同的作業型別,返回對應的作業執行器,然後執行對應作業執行器的execute()函式。下面看一下execute函式

// AbstractElasticJobExecutor.java
public final void execute() {
   // 檢查作業執行環境
   try {
       jobFacade.checkJobExecutionEnvironment();
   } catch (final JobExecutionEnvironmentException cause) {
       jobExceptionHandler.handleException(jobName, cause);
   }
   // 獲取當前作業伺服器的分片上下文
   ShardingContexts shardingContexts = jobFacade.getShardingContexts();
   // 釋出作業狀態追蹤事件(State.TASK_STAGING)
   if (shardingContexts.isAllowSendJobEvent()) {
       jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_STAGING, String.format("Job '%s' execute begin.", jobName));
   }
   // 跳過存在執行中的被錯過作業
   if (jobFacade.misfireIfRunning(shardingContexts.getShardingItemParameters().keySet())) {
       // 釋出作業狀態追蹤事件(State.TASK_FINISHED)
       if (shardingContexts.isAllowSendJobEvent()) {
           jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_FINISHED, String.format(
                   "Previous job '%s' - shardingItems '%s' is still running, misfired job will start after previous job completed.", jobName, 
                   shardingContexts.getShardingItemParameters().keySet()));
       }
       return;
   }
   // 執行作業執行前的方法
   try {
       jobFacade.beforeJobExecuted(shardingContexts);
       //CHECKSTYLE:OFF
   } catch (final Throwable cause) {
       //CHECKSTYLE:ON
       jobExceptionHandler.handleException(jobName, cause);
   }
   // 執行普通觸發的作業
   execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER);
   // 執行被跳過觸發的作業
   while (jobFacade.isExecuteMisfired(shardingContexts.getShardingItemParameters().keySet())) {
       jobFacade.clearMisfire(shardingContexts.getShardingItemParameters().keySet());
       execute(shardingContexts, JobExecutionEvent.ExecutionSource.MISFIRE);
   }
   // 執行作業失效轉移
   jobFacade.failoverIfNecessary();
   // 執行作業執行後的方法
   try {
       jobFacade.afterJobExecuted(shardingContexts);
       //CHECKSTYLE:OFF
   } catch (final Throwable cause) {
       //CHECKSTYLE:ON
       jobExceptionHandler.handleException(jobName, cause);
   }
}

execute函式的主要流程:

  1. 檢查作業執行環境
  2. 獲取當前作業伺服器的分片上下文。即通過函式jobFacade.getShardingContexts()獲取當前的分片資訊,由主節點根據相應的分片策略來進行分片項的劃分,劃分好之後將劃分結果存入到zookeeper中,其它節點再從zookeeper中獲取劃分結果。
  3. 釋出作業狀態追蹤事件
  4. 跳過正在執行中的被錯過執行的作業
  5. 執行作業執行前的方法
  6. 執行普通觸發的作業
    最後,會呼叫MyElasticJob中的execute方法,從而達到執行使用者業務邏輯的目的。
    整個Elastic-Job的執行流程如下:

4. Elastic-Job的優化實踐

4.1 空轉問題

Elastic-Job的作業按照是否有實現類可以分為兩種:有實現類的作業和沒有實現類的作業。如Simple型別和DataFlow型別的作業需要使用者自己定義實現類,繼承SimpleJob或者DataFlowJob類;另一種是不需要實現類的作業,如Script型別作業和Http型別作業,對應這種不需要實現類的作業,使用者只需要在配置平臺填寫好相應的配置,我們後臺再定時的從配置平臺拉取最新註冊的任務,然後就可以執行使用者最新註冊的script或者Http型別的作業。
在生產環境中,執行作業的叢集的機器數量很多,但是使用者註冊的每個作業的分片卻很少(大部分只有1個分片),根據前面的分析可知,對應只有一個分片的任務,叢集中的所有機器都會參與執行,但是由於只有得到那個分片的機器才會真正執行,其餘的都會因為沒有分片而空轉,這無疑是對計算資源的浪費。

4.2 解決方案

為了解決分片數量少、執行伺服器多而出現的空轉問題,我們這邊的解決方案是使用者在配置平臺註冊任務時,指定好對應的執行伺服器,執行伺服器的數量M=分片數+1(多出來的機器作為冗餘備份)。如使用者的作業分片為2, 後臺根據每天機器當前的負載排序,選擇3臺負載最輕的機器作為執行伺服器。這樣當這些機器定時從配置平臺拉取任務時,如果發現自己不屬於這個任務的執行伺服器,就不執行這個作業,只有屬於當前任務的執行伺服器才執行。這樣既保證了可靠性,又避免了過多機器的空轉,提高了效率。

5. OPPO海量作業排程方案

Elastic-Job通過zookeeper來實現彈性分散式的功能,這在任務量很小的時候可以滿足使用者需求,但是也有以下缺點:

  1. Elastic-Job的彈性分散式功能強依賴zookeeper,zookeeper容易成為效能瓶頸。
  2. 任務劃分的分片數可能小於執行任務的例項數,導致一些機器空轉。

基於Elastic-Job的上述缺點,OPPO中介軟體團隊在處理海量任務排程時,採用了集中式的排程方案,使用者的作業不需要通過Quartz來定時觸發,而是通過接收伺服器的訊息來觸發本地任務。使用者先在註冊平臺註冊任務,伺服器定時從註冊平臺的資料庫中掃描最近一個週期(30秒)內需要執行的任務,再根據任務的實際執行時間生成延時訊息並寫入具有延時功能的訊息佇列,使用者再從訊息佇列中拉取資料並觸發作業的執行。這種集中式的排程方式由中心伺服器來觸發訊息執行,既克服了zookeeper的效能瓶頸,又避免了任務伺服器的空轉,能夠滿足海量任務的執行要求。

總結

Elastic-Job使用quartz來進行作業的排程,同時引入zookeeper來實現分散式管理的功能,在高可用方案的基礎上增加了彈性擴容和資料分片的思路,以便於更大限度的利用分散式伺服器的資源從而實現了分散式任務排程的功能。同時由於分片的思路,也會導致沒有得到分片的伺服器處於空轉的狀態,這在實際的生產中可以設法規避。

作者簡介
Xinchun OPPO高階後端工程師
目前負責分散式作業排程的研發,關注訊息佇列、redis資料庫、ElasticSearch等中介軟體技術。

獲取更多精彩內容,掃碼關注[OPPO數智技術]公眾號

相關文章