Spring SchedulingConfigurer 實現動態定時任務


點擊上方藍字,關注我們



一、前言


大家在日常工作中,一定使用過 Spring 的 @Scheduled 註解吧,通過該註解可以非常方便的幫助我們實現任務的定時執行。

但是該註解是不支持運行時動態修改執行間隔的,不知道你在業務中有沒有這些需求和痛點:

在服務運行時能夠動態修改定時任務的執行頻率和執行開關,而無需重啓服務和修改代碼能夠基於配置,在不同環境/機器上,實現定時任務執行頻率的差異化

這些都可以通過 Spring 的 SchedulingConfigurer 註解來實現。

這個註解其實大家並不陌生,如果有使用過 @Scheduled 的話,因爲 @Scheduled 默認是單線程執行的,因此如果存在多個任務同時觸發,可能觸發阻塞。使用 SchedulingConfigurer 可以配置用於執行 @Scheduled 的線程池,來避免這個問題。

@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
//設定一個長度10的定時任務線程池
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));
}
}

但其實這個接口,還可以實現動態定時任務的功能,下面來演示如何實現。


二、功能實現


後續定義的類開頭的 DS 是 Dynamic Schedule 的縮寫。

使用到的依賴,除了 Spring 外,還包括:

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
<version>1.18.18</version>
</dependency>

2.1 @EnableScheduling

首先需要開啓 @EnableScheduling 註解,直接在啓動類添加即可:

@EnableScheduling
@SpringBootApplication
public class DSApplication {
public static void main(String[] args) {
SpringApplication.run(DSApplication.class, args);
}
}

2.2 IDSTaskInfo

定義一個任務信息的接口,後續所有用於動態調整的任務信息對象,都需要實現該接口。

id:該任務信息的唯一 ID,用於唯一標識一個任務•cron:該任務執行的 cron 表達式。•isValid:任務開關•isChange:用於標識任務參數是否發生了改變

public interface IDSTaskInfo {
/**
* 任務 ID
*/
long getId();

/**
* 任務執行 cron 表達式
*/
String getCron();

/**
* 任務是否有效
*/
boolean isValid();

/**
* 判斷任務是否發生變化
*/
boolean isChange(IDSTaskInfo oldTaskInfo);
}

2.3 DSContainer

顧名思義,是存放 IDSTaskInfo 的容器。

具有以下成員變量:

scheduleMap:用於暫存 IDSTaskInfo 和實際任務 ScheduledTask 的映射關係。其中:

task_id:作爲主鍵,確保一個 IDSTaskInfo 只會被註冊進一次T:暫存當初註冊時的 IDSTaskInfo,用於跟最新的 IDSTaskInfo 比較參數是否發生變化ScheduledTask:暫存當初註冊時生成的任務,如果需要取消任務的話,需要拿到該對象Semaphore:確保每個任務實際執行時只有一個線程執行,不會產生併發問題

taskRegistrar:Spring 的任務註冊管理器,用於註冊任務到 Spring 容器中


具有以下成員方法:

void checkTask(final T taskInfo, final TriggerTask triggerTask):檢查 IDSTaskInfo,判斷是否需要註冊/取消任務。具體的邏輯包括:

•如果任務已經註冊:

如果任務無效:則取消任務如果任務有效:

•如果任務配置發生了變化:則取消任務並重新註冊任務


•如果任務沒有註冊:

•如果任務有效:則註冊任務



Semaphore getSemaphore():獲取信號量屬性。


import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.config.TriggerTask;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;

@Slf4j
public class DSContainer<T extends IDSTaskInfo> {
/**
* IDSTaskInfo和真實任務的關聯關係
*
* <task_id, <Task, <Scheduled, Semaphore>>>
*/
private final Map<Long, Pair<T, Pair<ScheduledTask, Semaphore>>> scheduleMap = new ConcurrentHashMap<>();

private final ScheduledTaskRegistrar taskRegistrar;

public DSContainer(ScheduledTaskRegistrar scheduledTaskRegistrar) {
this.taskRegistrar = scheduledTaskRegistrar;
}

/**
* 註冊任務
* @param taskInfo 任務信息
* @param triggerTask 任務的觸發規則
*/
public void checkTask(final T taskInfo, final TriggerTask triggerTask) {
final long taskId = taskInfo.getId();

if (scheduleMap.containsKey(taskId)) {
if (taskInfo.isValid()) {
final T oldTaskInfo = scheduleMap.get(taskId).getLeft();

if(oldTaskInfo.isChange(taskInfo)) {
log.info("DSContainer will register again because task config change, taskId: {}", taskId);
cancelTask(taskId);
registerTask(taskInfo, triggerTask);
}
} else {
log.info("DSContainer will cancelTask because task not valid, taskId: {}", taskId);
cancelTask(taskId);
}
} else {
if (taskInfo.isValid()) {
log.info("DSContainer will registerTask, taskId: {}", taskId);
registerTask(taskInfo, triggerTask);
}
}
}

/**
* 獲取 Semaphore,確保任務不會被多個線程同時執行
*/
public Semaphore getSemaphore(final long taskId) {
return this.scheduleMap.get(taskId).getRight().getRight();
}

private void registerTask(final T taskInfo, final TriggerTask triggerTask) {
final ScheduledTask latestTask = taskRegistrar.scheduleTriggerTask(triggerTask);
this.scheduleMap.put(taskInfo.getId(), Pair.of(taskInfo, Pair.of(latestTask, new Semaphore(1))));
}

private void cancelTask(final long taskId) {
final Pair<T, Pair<ScheduledTask, Semaphore>> pair = this.scheduleMap.remove(taskId);
if (pair != null) {
pair.getRight().getLeft().cancel();
}
}
}

2.4 AbstractDSHandler

下面定義實際的動態線程池處理方法,這裏採用抽象類實現,將共用邏輯封裝起來,方便擴展。

具有以下抽象方法:

ExecutorService getWorkerExecutor():提供用於真正執行任務時的線程池。•List<T> listTaskInfo():獲取所有的任務信息。•void doProcess(T taskInfo):實現實際執行任務的業務邏輯。

具有以下公共方法:

void configureTasks(ScheduledTaskRegistrar taskRegistrar):創建 DSContainer 對象,並創建一個單線程的任務定時執行,調用 scheduleTask() 方法處理實際邏輯。•void scheduleTask():首先加載所有任務信息,然後基於 cron 表達式生成 TriggerTask 對象,調用 checkTask() 方法確認是否需要註冊/取消任務。當達到執行時間時,調用 execute() 方法,執行任務邏輯。•void execute(final T taskInfo):獲取信號量,成功後執行任務邏輯。

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.config.TriggerTask;
import org.springframework.scheduling.support.CronTrigger;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

@Slf4j
public abstract class AbstractDSHandler<T extends IDSTaskInfo> implements SchedulingConfigurer {

private DSContainer<T> dsContainer;

private final String CLASS_NAME = getClass().getSimpleName();

/**
* 獲取用於執行任務的線程池
*/
protected abstract ExecutorService getWorkerExecutor();

/**
* 獲取所有的任務信息
*/
protected abstract List<T> listTaskInfo();

/**
* 做具體的任務邏輯
*/
protected abstract void doProcess(T taskInfo);

@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
dsContainer = new DSContainer<>(taskRegistrar);
// 每隔 100ms 調度一次,用於讀取所有任務
taskRegistrar.addFixedDelayTask(this::scheduleTask, 1000);
}

/**
* 調度任務,加載所有任務並註冊
*/
private void scheduleTask() {
CollectionUtils.emptyIfNull(listTaskInfo()).forEach(taskInfo ->
dsContainer.checkTask(taskInfo, new TriggerTask(() ->
this.execute(taskInfo), triggerContext -> new CronTrigger(taskInfo.getCron()).nextExecutionTime(triggerContext)
))
);
}

private void execute(final T taskInfo) {
final long taskId = taskInfo.getId();

try {
Semaphore semaphore = dsContainer.getSemaphore(taskId);
if (Objects.isNull(semaphore)) {
log.error("{} semaphore is null, taskId: {}", CLASS_NAME, taskId);
return;
}
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
try {
getWorkerExecutor().execute(() -> doProcess(taskInfo));
} finally {
semaphore.release();
}
} else {
log.warn("{} too many executor, taskId: {}", CLASS_NAME, taskId);
}
} catch (InterruptedException e) {
log.warn("{} interruptedException error, taskId: {}", CLASS_NAME, taskId);
} catch (Exception e) {
log.error("{} execute error, taskId: {}", CLASS_NAME, taskId, e);
}
}
}


三、快速測試


至此就完成了動態任務的框架搭建,下面讓我們來快速測試下。爲了儘量減少其他技術帶來的複雜度,本次測試不涉及數據庫和真實的定時任務,完全採用模擬實現。

3.1 模擬定時任務

爲了模擬一個定時任務,我定義了一個 foo() 方法,其中只輸出一句話。後續我將通過定時調用該方法,來模擬定時任務。

import lombok.extern.slf4j.Slf4j;

import java.time.LocalTime;

@Slf4j
public class SchedulerTest {
public void foo() {
log.info("{} Execute com.github.jitwxs.sample.ds.test.SchedulerTest#foo", LocalTime.now());
}
}

3.2 實現 IDSTaskInfo

首先定義 IDSTaskInfo,我這裏想通過反射來實現調用 foo() 方法,因此 reference 表示的是要調用方法的全路徑。另外我實現了 isChange() 方法,只要 cron、isValid、reference 發生了變動,就認爲該任務的配置發生了改變。

import com.github.jitwxs.sample.ds.config.IDSTaskInfo;
import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class SchedulerTestTaskInfo implements IDSTaskInfo {
private long id;

private String cron;

private boolean isValid;

private String reference;

@Override
public boolean isChange(IDSTaskInfo oldTaskInfo) {
if(oldTaskInfo instanceof SchedulerTestTaskInfo) {
final SchedulerTestTaskInfo obj = (SchedulerTestTaskInfo) oldTaskInfo;
return !this.cron.equals(obj.cron) || this.isValid != obj.isValid || !this.reference.equals(obj.getReference());
} else {
throw new IllegalArgumentException("Not Support SchedulerTestTaskInfo type");
}
}
}

3.3 實現 AbstractDSHandler

有幾個需要關注的:

(1)getWorkerExecutor() 我隨便寫了個 2,其實 SchedulerTestTaskInfo 對象只有一個(即調用 foo() 方法的定時任務)。

(2)listTaskInfo() 返回值我使用了 volatile 變量,便於我修改它,模擬任務信息數據的改變。

(3)doProcess() 方法中,讀取到 reference 後,使用反射進行調用,模擬定時任務的執行。

(4)額外實現了 ApplicationListener 接口,當服務啓動後,每隔一段時間修改下任務信息,模擬業務中調整配置。

服務啓動後,foo() 定時任務將每 10s 執行一次。10s 後,將 foo() 定時任務執行週期從每 10s 執行調整爲 1s 執行。10s 後,關閉 foo() 定時任務執行。10s 後,開啓 foo() 定時任務執行。

import com.github.jitwxs.sample.ds.config.AbstractDSHandler;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;

@Component
public class SchedulerTestDSHandler extends AbstractDSHandler<SchedulerTestTaskInfo> implements ApplicationListener {
public volatile List<SchedulerTestTaskInfo> taskInfoList = Collections.singletonList(
SchedulerTestTaskInfo.builder()
.id(1)
.cron("0/10 * * * * ? ")
.isValid(true)
.reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
.build()
);

@Override
protected ExecutorService getWorkerExecutor() {
return Executors.newFixedThreadPool(2);
}

@Override
protected List<SchedulerTestTaskInfo> listTaskInfo() {
return taskInfoList;
}

@Override
protected void doProcess(SchedulerTestTaskInfo taskInfo) {
final String reference = taskInfo.getReference();
final String[] split = reference.split("#");
if(split.length != 2) {
return;
}

try {
final Class<?> clazz = Class.forName(split[0]);
final Method method = clazz.getMethod(split[1]);
method.invoke(clazz.newInstance());
} catch (Exception e) {
e.printStackTrace();
}
}

@Override
public void onApplicationEvent(ApplicationEvent applicationEvent) {
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));

// setting 1 seconds execute
taskInfoList = Collections.singletonList(
SchedulerTestTaskInfo.builder()
.id(1)
.cron("0/1 * * * * ? ")
.isValid(true)
.reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
.build()
);

LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));

// setting not valid
taskInfoList = Collections.singletonList(
SchedulerTestTaskInfo.builder()
.id(1)
.cron("0/1 * * * * ? ")
.isValid(false)
.reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
.build()
);

LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10));

// setting valid
taskInfoList = Collections.singletonList(
SchedulerTestTaskInfo.builder()
.id(1)
.cron("0/1 * * * * ? ")
.isValid(true)
.reference("com.github.jitwxs.sample.ds.test.SchedulerTest#foo")
.build()
);
}, 12, 86400, TimeUnit.SECONDS);
}
}

3.4 運行程序

整個應用包結構如下:

運行程序後,在控制檯可以觀測到如下輸出:


四、後記


以上完成了動態定時任務的介紹,你能夠根據本篇文章,實現以下需求嗎:

本文基於 cron 表達式實現了頻率控制,你能改用 fixedDelay 或 fixedRate 實現嗎?基於數據庫/配置文件/配置中心,實現對服務中定時任務的動態頻率調整和任務的啓停。開發一個數據表歷史數據清理功能,能夠動態配置要清理的表、清理的規則、清理的週期。開發一個數據表異常數據告警功能,能夠動態配置要掃描的表、告警的規則、掃描的週期。

往期推薦



大數據批處理框架Spring Batch全面解析

軟件架構設計說明書該怎麼寫?

基於Redis位圖實現用戶簽到功能

後端程序員也可以用Grafana做出漂亮可視化界面!

面試官:請你談談ConcurrentHashMap

歡迎分享轉發,有幫助的話點個“在看”


本文分享自微信公衆號 - 俠夢的開發筆記(xmdevnote)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章