幾種簡單實用的分布式定時任務!
單點定時任務
JDK 原生
自從 JDK1.5 之后,提供了 ScheduledExecutorService 代替 TimerTask 來執(zhí)行定時任務,提供了不錯的可靠性。
public class SomeScheduledExecutorService {
public static void main(String[] args) {
// 創(chuàng)建任務隊列,共 10 個線程
ScheduledExecutorService scheduledExecutorService =
Executors.newScheduledThreadPool(10);
// 執(zhí)行任務: 1秒 后開始執(zhí)行,每 30秒 執(zhí)行一次
scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println("執(zhí)行任務:" + new Date());
}, 10, 30, TimeUnit.SECONDS);
}
}
Spring Task
Spring Framework 自帶定時任務,提供了 cron 表達式來實現(xiàn)豐富定時任務配置。新手推薦使用 https://cron.qqe2.com/ 這個網(wǎng)站來匹配你的 cron 表達式。
@Configuration
@EnableScheduling
public class SomeJob {
private static final Logger LOGGER = LoggerFactory.getLogger(SomeJob.class);
/**
* 每分鐘執(zhí)行一次(例:18:01:00,18:02:00)
* 秒 分鐘 小時 日 月 星期 年
*/
@Scheduled(cron = "0 0/1 * * * ? *")
public void someTask() {
//...
}
}
單點的定時服務在目前微服務的大環(huán)境下,應用場景越來越局限,所以嘗鮮一下分布式定時任務吧。
基于 Redis 實現(xiàn)
相較于之前兩種方式,這種基于 Redis 的實現(xiàn)可以通過多點來增加定時任務,多點消費。但是要做好防范重復消費的準備。
通過 ZSet 的方式:將定時任務存放到 ZSet 集合中,并且將過期時間存儲到 ZSet 的 Score 字段中,然后通過一個循環(huán)來判斷當前時間內是否有需要執(zhí)行的定時任務,如果有則進行執(zhí)行。
具體實現(xiàn)代碼如下:
/**
* Description: 基于Redis的ZSet的定時任務 .<br>
*
* @author mxy
* @Date 2020/8/25 11:54
*/
@Configuration
@EnableScheduling
public class RedisJob {
public static final String JOB_KEY = "redis.job.task";
private static final Logger LOGGER = LoggerFactory.getLogger(RedisJob.class);
@Autowired private StringRedisTemplate stringRedisTemplate;
/**
* 添加任務.
*
* @param task
*/
public void addTask(String task, Instant instant) {
stringRedisTemplate.opsForZSet().add(JOB_KEY, task, instant.getEpochSecond());
}
/**
* 定時任務隊列消費
* 每分鐘消費一次(可以縮短間隔到1s)
*/
@Scheduled(cron = "0 0/1 * * * ? *")
public void doDelayQueue() {
long nowSecond = Instant.now().getEpochSecond();
// 查詢當前時間的所有任務
Set<String> strings = stringRedisTemplate.opsForZSet().range(JOB_KEY, 0, nowSecond);
for (String task : strings) {
// 開始消費 task
LOGGER.info("執(zhí)行任務:{}", task);
}
// 刪除已經(jīng)執(zhí)行的任務
stringRedisTemplate.opsForZSet().remove(JOB_KEY, 0, nowSecond);
}
}
適用場景如下:
- 訂單下單之后 15 分鐘后,用戶如果沒有付錢,系統(tǒng)需要自動取消訂單
- 紅包 24 小時未被查收,需要延遲執(zhí)退還業(yè)務
- 某個活動指定在某個時間內生效&失效
優(yōu)勢是:
- 省去了 MySQL 的查詢操作,而使用性能更高的 Redis 做為代替
- 不會因為停機等原因,遺漏要執(zhí)行的任務
鍵空間通知的方式:我們可以通過 Redis 的鍵空間通知來實現(xiàn)定時任務,它的實現(xiàn)思路是給所有的定時任務設置一個過期時間,等到了過期之后,我們通過訂閱過期消息就能感知到定時任務需要被執(zhí)行了,此時我們執(zhí)行定時任務即可。
默認情況下 Redis 是不開啟鍵空間通知的,需要我們通過 config set notify-keyspace-events Ex 的命令手動開啟。
開啟之后定時任務的代碼如下:
自定義監(jiān)聽器:
/**
* 自定義監(jiān)聽器.
*/
public class KeyExpiredListener extends KeyExpirationEventMessageListener {
public KeyExpiredListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
// channel
String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
// 過期的key
String key = new String(message.getBody(), StandardCharsets.UTF_8);
// todo 你的處理
}
}
設置該監(jiān)聽器:
/** * Description: 通過訂閱Redis的過期通知來實現(xiàn)定時任務 .
/**
* Description: 通過訂閱Redis的過期通知來實現(xiàn)定時任務 .<br>
*
* @author mxy
* @Date 2020/8/25 12:07
*/
@Configuration
public class RedisExJob {
@Autowired private RedisConnectionFactory redisConnectionFactory;
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory);
return redisMessageListenerContainer;
}
@Bean
public KeyExpiredListener keyExpiredListener() {
return new KeyExpiredListener(this.redisMessageListenerContainer());
}
}
Spring 會監(jiān)聽符合以下格式的 Redis 消息:
private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");
基于 Redis 的定時任務能夠適用的場景也比較有限,但實現(xiàn)上相對簡單,但對于功能冪等有很大要求。從使用場景上來說,更應該叫做延時任務。
場景舉例:
訂單下單之后 15 分鐘后,用戶如果沒有付錢,系統(tǒng)需要自動取消訂單
紅包 24 小時未被查收,需要延遲執(zhí)退還業(yè)務
優(yōu)劣勢是:
- 被動觸發(fā),對于服務的資源消耗更小
- Redis 的 Pub/Sub 不可靠,沒有 ACK 機制等,但是一般情況可以容忍
- 鍵空間通知功能會耗費一些 CPU
分布式定時任務
引入分布式定時任務組件 or 中間件:將定時任務作為單獨的服務,遏制了重復消費,獨立的服務也有利于擴展和維護。
quartz
依賴于 MySQL,使用相對簡單,可多節(jié)點部署,通過競爭數(shù)據(jù)庫鎖來保證只有一個節(jié)點執(zhí)行任務。沒有圖形化管理頁面,使用相對麻煩。
elastic-job-lite
依賴于 Zookeeper,通過 Zookeeper 的注冊與發(fā)現(xiàn),可以動態(tài)的添加服務器。
- 多種作業(yè)模式
- 失效轉移
- 運行狀態(tài)收集
- 多線程處理數(shù)據(jù)
- 冪等性
- 容錯處理
- 支持spring命名空間
- 有圖形化管理頁面
LTS
依賴于 Zookeeper,集群部署,可以動態(tài)的添加服務器。可以手動增加定時任務,啟動和暫停任務。
- 業(yè)務日志記錄器
- SPI 擴展支持
- 故障轉移
- 節(jié)點監(jiān)控
- 多樣化任務執(zhí)行結果支持
- FailStore 容錯
- 動態(tài)擴容
- 對 spring 相對友好
- 有監(jiān)控和管理圖形化界面
xxl-job
國產,依賴于 MySQL,基于競爭數(shù)據(jù)庫鎖保證只有一個節(jié)點執(zhí)行任務,支持水平擴容??梢允謩釉黾佣〞r任務,啟動和暫停任務。
- 彈性擴容
- 分片廣播
- 故障轉移
- Rolling實時日志
- GLUE(支持在線編輯代碼,免發(fā)布)
- 任務進度監(jiān)控
- 任務依賴
- 數(shù)據(jù)加密
- 郵件報警
- 運行報表
- 優(yōu)雅停機
- 國際化(中文友好)
總結
微服務下,推薦使用 xxl-job 這一類組件服務將定時任務合理有效的管理起來。而單點的定時任務有其局限性,適用于規(guī)模較小、對未來擴展要求不高的服務。
相對而言,基于 Spring Task 的定時任務最簡單快捷,而 xxl-job 的難度主要體現(xiàn)在集成和調試上。
無論是什么樣的定時任務,你都需要確保:
任務不會因為集群部署而被多次執(zhí)行
任務發(fā)生異常得到有效的處理
任務的處理過慢導致大量積壓
任務應該在預期的時間點執(zhí)行
中間件可以將服務解耦,但增加了復雜度。