實戰Spring Boot 2.0系列(六) - 單機定時任務的幾種實現

零壹技術棧發表於2018-06-26

前言

定時任務 一般會存在 中大型企業級 專案中,為了減少 伺服器資料庫 的壓力,往往會以 定時任務 的方式去完成某些業務邏輯。

本系列文章

  1. 實戰Spring Boot 2.0系列(一) - 使用Gradle構建Docker映象
  2. 實戰Spring Boot 2.0系列(二) - 全域性異常處理和測試
  3. 實戰Spring Boot 2.0系列(三) - 使用@Async進行非同步呼叫詳解
  4. 實戰Spring Boot 2.0系列(四) - 使用WebAsyncTask處理非同步任務
  5. 實戰Spring Boot 2.0系列(五) - Listener, Servlet, Filter和Interceptor
  6. 實戰Spring Boot 2.0系列(六) - 單機定時任務的幾種實現

常見的就是 金融服務系統 推送回撥,一般支付系統訂單在沒有收到成功的回撥返回內容時會 持續性的回撥,這種回撥一般都是 定時任務 來完成。

還有就是 報表的生成,我們一般會在客戶 訪問量小 時完成這個操作,也可以採用 定時任務 來完成。

實戰Spring Boot 2.0系列(六) - 單機定時任務的幾種實現

正文

定時任務的幾種方式

Timer

這是 Java 自帶的 java.util.Timer 類,這個類允許排程一個名為 java.util.TimerTask 任務。使用這種方式可以讓你的程式按照某一個 頻度 執行,但不能在 指定時間 執行。現在一般用的較少。

ScheduledExecutorService

JDK 自帶的一個類,是基於 執行緒池 設計的定時任務類,每個 排程任務 都會分配到 執行緒池 中的一個 執行緒 去執行。也就是說,任務是 併發執行,互不影響的。

Spring Task

Spring 3.0 以後自帶的 Task,支援 多執行緒 排程,可以將它看成一個 輕量級Quartz,而且使用起來比 Quartz 簡單許多,但是適用於 單節點定時任務排程

Quartz

這是一個 功能比較強大 的的排程器,可以讓你的程式在指定時間執行,也可以按照某一個頻度執行,配置起來 稍顯複雜Quartz 功能強大,可以結合 資料庫持久化,進行 分散式任務延時排程

Cron表示式簡介

Cron 表示式是一個字串,字串以 56空格 隔開,分為 67,每一個域代表一個含義,Cron 有如下兩種語法格式:

  1. Seconds Minutes Hours DayofMonth Month DayofWeek Year
  2. Seconds Minutes Hours DayofMonth Month DayofWeek

每個域對應的含義、域值範圍和特殊表示符,從左到右依次如下:

欄位 允許值 允許的特殊字元
0-59 , - * /
0-59 , - * /
小時 0-23 , - * /
日期 1-31 , - * / L W C
月份 1-12 或者 JAN-DEC , - * /
星期 1-7 或者 SUN-SAT , - * / L C #
年(可選) 留空, 1970-2099 , - * /

如上面的表示式所示:

  • ""字元: 被用來指定所有的值。如:在分鐘的欄位域裡表示"每分鐘"。

  • "-"字元: 被用來指定一個範圍。如:"10-12" 在小時域意味著 "10點、11點、12點"。

  • ","字元: 被用來指定另外的值。如:"MON,WED,FRI" 在星期域裡表示 "星期一、星期三、星期五"。

  • "?"字元: 只在日期域和星期域中使用。它被用來指定"非明確的值"。當你需要通過在這兩個域中的一個來指定一些東西的時候,它是有用的。看下面的例子你就會明白。

  • "L"字元: 指定在月或者星期中的某天(最後一天)。即 "Last" 的縮寫。但是在星期和月中 "L" 表示不同的意思,如:在月子段中 "L" 指月份的最後一天 - 1月31日,2月28日。

    • 如果在星期欄位中則簡單的表示為 "7" 或者 "SAT" 字元。
    • 如果在星期欄位中在某個 value 值得後面,則表示 "某月的最後一個星期value",如 "6L" 表示某月的最後一個星期五。
  • "W"字元: 只能用在月份欄位中,該欄位指定了離指定日期最近的那個星期日。

  • "#"字元: 只能用在星期欄位,該欄位指定了第幾個星期 value 在某月中

每一個元素都可以顯式地規定一個值(如 6),一個區間(如 9-12),一個列表(如 9,11,13)或一個萬用字元(如 *)。"月份中的日期""星期中的日期" 這兩個元素是 互斥的,因此應該通過設定一個 問號?)來表明你不想設定的那個欄位。下表顯示了一些 cron 表示式的 例子 和它們的意義:

表示式 意義
"0 0 12 * * ?" 每天中午12點觸發
"0 15 10 ? * *" 每天上午10:15觸發
"0 15 10 * * ?" 每天上午10:15觸發
"0 15 10 * * ? *" 每天上午10:15觸發
"0 15 10 * * ? 2005" 2005年的每天上午10:15觸發
"0 * 14 * * ?" 在每天下午2點到下午2:59期間的每1分鐘觸發
"0 0/5 14 * * ?" 在每天下午2點到下午2:55期間的每5分鐘觸發
"0 0/5 14,18 * * ?" 在每天下午2點到2:55期間和下午6點到6:55期間的每5分鐘觸發
"0 0-5 14 * * ?" 在每天下午2點到下午2:05期間的每1分鐘觸發
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44觸發
"0 15 10 ? * MON-FRI" 週一至週五的上午10:15觸發
"0 15 10 15 * ?" 每月15日上午10:15觸發
"0 15 10 L * ?" 每月最後一日的上午10:15觸發
"0 15 10 ? * 6L" 每月的最後一個星期五上午10:15觸發
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最後一個星期五上午10:15觸發
"0 15 10 ? * 6#3" 每月的第三個星期五上午10:15觸發
0 6 * * * 每天早上6點
0 /2 * * 每兩個小時
0 23-7/2,8 * * * 晚上11點到早上8點之間每兩個小時,早上八點
0 11 4 * 1-3 每個月的4號和每個禮拜的禮拜一到禮拜三的早上11點
0 4 1 1 * 1月1日早上4點

環境準備

配置gradle依賴

利用 Spring Initializer 建立一個 gradle 專案 spring-boot-scheduler-task-management,建立時新增相關依賴。得到的初始 build.gradle 如下:

buildscript {
    ext {
        springBootVersion = '2.0.3.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'io.ostenant.springboot.sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter')
    compile('org.springframework.boot:spring-boot-starter-web')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
複製程式碼

Spring Boot 入口類上配置 @EnableScheduling 註解開啟 Spring 自帶的定時處理功能。

@SpringBootApplication
@EnableScheduling
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
複製程式碼

配置Timer任務

這個 API 目前在專案中很少用,直接給出示例程式碼。具體的介紹可以檢視 APITimer 的內部只有 一個執行緒,如果有 多個任務 的話就會 順序執行,這樣任務的 延遲時間迴圈時間 就會出現問題。

TimerService.java

public class TimerService {
    private static final Logger LOGGER = LoggerFactory.getLogger(TimerService.class);
    private AtomicLong counter = new AtomicLong();

    public void schedule() {
        TimerTask timerTask = new TimerTask() {
            @Override
            public void run() {
                long count = counter.incrementAndGet();
                LOGGER.info("Schedule timerTask {} times", count);
            }
        };
        Timer timer = new Timer();
        timer.schedule(timerTask, 1000L, 10 * 1000L;
    }
}
複製程式碼

上面的程式碼定義了一個 TimerTask,在 TimerTask 中累加 執行次數,並通過 slf4j 進行列印 (自帶執行時間)。然後通過 Timer 排程工具類排程 TimerTask 任務,設定 初始化延遲時間1s定時執行間隔10s,測試程式碼如下:

public static void main(String[] args) {
    TimerService timerService = new TimerService();
    timerService.schedule();
}
複製程式碼

觀察測試結果,能夠發現 TimerTask 配置的任務每隔 10s 被執行了一次,執行執行緒預設都是 Timer-0 這個執行緒。

17:48:18.731 [Timer-0] INFO io.ostenant.springboot.sample.timer.TimerService - Schedule timerTask 1 times
17:48:28.730 [Timer-0] INFO io.ostenant.springboot.sample.timer.TimerService - Schedule timerTask 2 times
17:48:38.736 [Timer-0] INFO io.ostenant.springboot.sample.timer.TimerService - Schedule timerTask 3 times
17:48:48.738 [Timer-0] INFO io.ostenant.springboot.sample.timer.TimerService - Schedule timerTask 4 times
17:48:58.743 [Timer-0] INFO io.ostenant.springboot.sample.timer.TimerService - Schedule timerTask 5 times
複製程式碼

配置ScheduledExecutorService任務

ScheduledExecutorService延時執行 的執行緒池,對於 多執行緒 環境下的 定時任務,推薦用 ScheduledExecutorService 代替 Timer 定時器。

建立一個執行緒數量為 4任務執行緒池,同一時刻並向它提交 4 個定時任務,用於測試延時任務的 併發處理。執行 ScheduledExecutorServicescheduleWithFixedDelay() 方法,設定任務執行緒池的 初始任務延遲時間2 秒,並在上一次 執行完畢時間點 之後 10 秒再執行下一次任務。

public void scheduleWithFixedDelay() {
    ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(4);
    for (int i = 0; i < 4; i++) {
        scheduledExecutor.scheduleWithFixedDelay(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(10 * 1000L);
            } catch (InterruptedException e) {
                LOGGER.error("Interrupted exception", e);
            }
            long count = counter.incrementAndGet();
            LOGGER.info("Schedule executor {} times with fixed delay", count);
        }, 2000L, 10 * 1000L, TimeUnit.MILLISECONDS);
    }
    LOGGER.info("Start to schedule");
}
複製程式碼

測試結果如下,我們可以發現每隔 20 秒的時間間隔,就會有 4 個定時任務同時執行。因為在任務執行緒池初始化時,我們同時向執行緒池提交了 4 個任務,這 四個任務 會完全利用執行緒池中的 4 個執行緒進行任務執行。

20 秒是怎麼來的?首先每個任務的 時間間隔 設定為 10 秒。其次因為採用的是 withFixedDelay 策略,即當前任務執行的 結束時間,作為下次延時任務的 開始計時節點,並且每個任務在執行過程中睡眠了 10 秒的時間,累計起來就是 20 秒的時間。

19:42:02.444 [main] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Start to schedule
19:42:14.449 [pool-1-thread-1] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 3 times with fixed delay
19:42:14.449 [pool-1-thread-2] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 1 times with fixed delay
19:42:14.449 [pool-1-thread-3] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 2 times with fixed delay
19:42:14.449 [pool-1-thread-4] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 4 times with fixed delay
19:42:34.458 [pool-1-thread-4] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 7 times with fixed delay
19:42:34.458 [pool-1-thread-3] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 5 times with fixed delay
19:42:34.458 [pool-1-thread-2] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 8 times with fixed delay
19:42:34.458 [pool-1-thread-1] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 6 times with fixed delay
複製程式碼

建立一個執行緒數量為 4任務執行緒池,同一時刻並向它提交 4 個定時任務,用於測試延時任務的 併發處理。每個任務分別執行 ScheduledExecutorServicescheduleAtFixedRate() 方法,設定任務執行緒池的 初始任務延遲時間2 秒,並在上一次 開始執行時間點 之後 10 秒再執行下一次任務。

public void scheduleAtFixedRate() {
    ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(4);
    for (int i = 0; i < 4; i++) {
        scheduledExecutor.scheduleAtFixedRate(() -> {
            long count = counter.incrementAndGet();
            LOGGER.info("Schedule executor {} times at fixed rate", count);
        }, 2000L, 10 * 1000L, TimeUnit.MILLISECONDS);
    }
    LOGGER.info("Start to schedule");
}
複製程式碼

測試結果如下,我們可以發現每隔 10 秒的時間間隔,就會有 4 個定時任務同時執行,因為在任務執行緒池初始化時,我們同時向執行緒池提交了 4 個任務,這 四個任務 會完全利用執行緒池中的 4 個執行緒進行任務執行。

19:31:46.837 [main] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Start to schedule
19:31:48.840 [pool-1-thread-1] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 1 times at fixed rate
19:31:48.840 [pool-1-thread-3] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 3 times at fixed rate
19:31:48.840 [pool-1-thread-2] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 2 times at fixed rate
19:31:48.840 [pool-1-thread-4] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 4 times at fixed rate
19:31:58.839 [pool-1-thread-2] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 6 times at fixed rate
19:31:58.840 [pool-1-thread-4] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 8 times at fixed rate
19:31:58.839 [pool-1-thread-3] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 7 times at fixed rate
19:31:58.839 [pool-1-thread-1] INFO io.ostenant.springboot.sample.executor.ScheduledExecutorsService - Schedule executor 5 times at fixed rate
複製程式碼

配置Spring Task任務

Spring 提供了 @Scheduled 註解來實現 定時任務@Scheduled 引數可以接受 兩種 定時的設定,一種是我們常用的 格林時間表示式 cron = "*/10 * * * * *",另一種是 fixedRate = 10 * 1000L,兩種都表示每隔 10 秒執行一次目標任務。

引數說明:

  • @Scheduled(fixedRate = 10 * 1000L):上一次 開始執行時間點 之後 10 秒再執行。
@Scheduled(fixedRate = 10 * 1000L)
public void scheduleAtFixedRate() throws Exception {
    long count = counter.incrementAndGet();
    LOGGER.info("Schedule executor {} times at fixed rate", count);
}
複製程式碼
  • @Scheduled(fixedDelay = 10 * 1000L):上一次 執行完畢時間點 之後 10 秒再執行。
@Scheduled(fixedDelay = 10 * 1000L)
public void scheduleWithFixedDelay() throws Exception {
    try {
        TimeUnit.MILLISECONDS.sleep(10 * 1000L);
    } catch (InterruptedException e) {
        LOGGER.error("Interrupted exception", e);
    }
    long count = counter.incrementAndGet();
    LOGGER.info("Schedule executor {} times with fixed delay", count);
}
複製程式碼
  • @Scheduled(initialDelay = 2000L, fixedRate = 10 * 1000L):第一次延遲 2 秒後執行,之後按 fixedRate 的規則每 10 秒執行一次。
@Scheduled(initialDelay = 2000L, fixedDelay = 10 * 1000L)
public void scheduleWithinitialDelayAndFixedDelay() throws Exception {
    try {
        TimeUnit.MILLISECONDS.sleep(10 * 1000L);
    } catch (InterruptedException e) {
        LOGGER.error("Interrupted exception", e);
    }
    long count = counter.incrementAndGet();
    LOGGER.info("Schedule executor {} times with fixed delay", count);
}
複製程式碼
  • @Scheduled(cron = "0/10 * * * * *"):根據 cron 表示式定義,每隔 10 秒執行一次。
@Scheduled(cron = "0/10 * * * * *")
public void scheduleWithCronExpression() throws Exception {
    long count = counter.incrementAndGet();
    LOGGER.info("Schedule executor {} times with ", count);
}
複製程式碼

完整的程式碼如下:

SpringTaskService.java

@Component
public class SpringTaskService {
    private static final Logger LOGGER = LoggerFactory.getLogger(SpringTaskService.class);
    private AtomicLong counter = new AtomicLong();

    @Scheduled(fixedDelay = 10 * 1000L)
    public void scheduleWithFixedDelay() throws Exception {
        try {
            TimeUnit.MILLISECONDS.sleep(10 * 1000L);
        } catch (InterruptedException e) {
            LOGGER.error("Interrupted exception", e);
        }
        long count = counter.incrementAndGet();
        LOGGER.info("Schedule executor {} times with fixed delay", count);
    }

    @Scheduled(initialDelay = 2000L, fixedDelay = 10 * 1000L)
    public void scheduleWithinitialDelayAndFixedDelay() throws Exception {
        try {
            TimeUnit.MILLISECONDS.sleep(10 * 1000L);
        } catch (InterruptedException e) {
            LOGGER.error("Interrupted exception", e);
        }
        long count = counter.incrementAndGet();
        LOGGER.info("Schedule executor {} times with fixed delay", count);
    }

    @Scheduled(fixedRate = 10 * 1000L)
    public void scheduleAtFixedRate() throws Exception {
        long count = counter.incrementAndGet();
        LOGGER.info("Schedule executor {} times at fixed rate", count);
    }

    @Scheduled(cron = "0/10 * * * * *")
    public void scheduleWithCronExpression() throws Exception {
        long count = counter.incrementAndGet();
        LOGGER.info("Schedule executor {} times with ", count);
    }
}
複製程式碼

檢視日誌,任務每 20 秒的時間間隔執行一次。每次定時任務在上次 執行完畢時間點 之後 10 秒再執行,在任務中設定 睡眠時間10 秒。這裡只驗證了 @Scheduled(initialDelay = 2000L, fixedDelay = 10 * 1000L)。

2018-06-25 18:00:53.051  INFO 5190 --- [pool-1-thread-1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 1 times with fixed delay
2018-06-25 18:01:13.056  INFO 5190 --- [pool-1-thread-1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 2 times with fixed delay
2018-06-25 18:01:33.061  INFO 5190 --- [pool-1-thread-1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 3 times with fixed delay
2018-06-25 18:01:53.071  INFO 5190 --- [pool-1-thread-1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 4 times with fixed delay
2018-06-25 18:02:13.079  INFO 5190 --- [pool-1-thread-1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 5 times with fixed delay
複製程式碼

配置任務執行緒池

上述配置都是基於 單執行緒 的任務排程,如何引入 多執行緒 提高 延時任務併發處理 能力?

Spring Boot 提供了一個 SchedulingConfigurer 配置介面。我們通過 ScheduleConfig 配置檔案實現 ScheduleConfiguration 介面,並重寫 configureTasks() 方法,向 ScheduledTaskRegistrar 註冊一個 ThreadPoolTaskScheduler 任務執行緒物件即可。

@Configuration
public class ScheduleConfiguration implements SchedulingConfigurer {
    private static final Logger LOGGER = LoggerFactory.getLogger(ScheduleConfiguration.class);

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setTaskScheduler(taskScheduler());
    }

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(4);
        taskScheduler.setWaitForTasksToCompleteOnShutdown(true);
        taskScheduler.setThreadNamePrefix("schedule");
        taskScheduler.setRemoveOnCancelPolicy(true);
        taskScheduler.setErrorHandler(t -> LOGGER.error("Error occurs", t));
        return taskScheduler;
    }
}
複製程式碼

啟動 Spring Boot 引用,上面 SpringTaskService 配置的 4 個定時任務會同時生效。

2018-06-20 20:37:50.746  INFO 8142 --- [      schedule1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 1 times at fixed rate
2018-06-20 20:38:00.001  INFO 8142 --- [      schedule3] i.o.s.sample.spring.SpringTaskService    : Schedule executor 2 times with 
2018-06-20 20:38:00.751  INFO 8142 --- [      schedule1] i.o.s.sample.spring.SpringTaskService    : Schedule executor 3 times at fixed rate
2018-06-20 20:38:02.748  INFO 8142 --- [      schedule2] i.o.s.sample.spring.SpringTaskService    : Schedule executor 4 times with fixed delay
2018-06-20 20:38:10.005  INFO 8142 --- [      schedule4] i.o.s.sample.spring.SpringTaskService    : Schedule executor 5 times with 
2018-06-20 20:38:10.747  INFO 8142 --- [      schedule3] i.o.s.sample.spring.SpringTaskService    : Schedule executor 6 times at fixed rate
2018-06-20 20:38:20.002  INFO 8142 --- [      schedule2] i.o.s.sample.spring.SpringTaskService    : Schedule executor 7 times with 
2018-06-20 20:38:20.747  INFO 8142 --- [      schedule4] i.o.s.sample.spring.SpringTaskService    : Schedule executor 8 times at fixed rate
複製程式碼

觀察日誌,執行緒名字首schedule,可以發現 Spring Task@Scheduled 註解配置的 4 個任務,分發給我們配置的 ThreadPoolTaskScheduler 中的 4 個執行緒併發執行。

小結

本文介紹了基於單節點的定時任務排程及實現,包括 JDK 原生的 TimerScheduledExecutorService,以及 Spring 3.0 以後自帶的基於註解的 Spring Task 任務排程方式。除此之外,重點闡述了基於 固定延時固定頻率cron 表示式 的不同之處,並對 ScheduledExecutorServiceSpring Scheduler執行緒池併發處理 進行了測試。


歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章