微服務的概念可以說給程式設計開啟了一個新世界,帶來了眾多的優點,但是也將一些以往容易處理的問題變得複雜,例如:快取、事務、定時任務等。快取可以用中介軟體例如redis、memcached等,事務有諸多分散式事務框架解決,定時任務也有分散式的解決方案,例如quartz、elastic job等,今天我要講的是就是定時任務。
既然已經有成熟的分散式定時任務框架,我要講的東西並不是用另一種設計去實現相同的功能,而是從不同的角度去解決分散式定時任務的問題。
問題來源
這個問題來起源於一個小功能,我們有一個傳送簡訊的微服務,需要獲取簡訊的狀態報告,狀態報告對於簡訊傳送不是同步的,簡訊提交到服務商,服務商要提交運營商傳送之後才能生成狀態報告,因此有一定的延遲,需要非同步獲取,並且服務商提供的介面有頻率限制,因此需要做一個定時任務,且需要單點執行,那麼問題來了,因為這一個功能我就需要引入一個定時任務框架嗎,總感覺有點大材小用的意思。
之前我們的定時任務處理既有用過quartz,也用過elastic job,但是隻為這樣一個小功能就引入一個框架,再加上配置又得好半天,想想都不划算。
例如要用quartz,要建立一堆資料庫表,但表裡面只儲存了一個任務資訊。
用elastic job吧,還要使用zookeeper,即便用lite版,也需要一堆配置,遠比我寫業務的時間要長。
我只想簡簡單單的寫邏輯!!!
解決方案
談分散式解決方案大致總離不開中介軟體,聯想到上次解決websocket的分散式方案(參見Spring Cloud 微服務架構下的 WebSocket 解決方案)使用到的Spring Cloud Stream,大概有了思路:
- 我需要一個任務分發中心,專門負責觸發定時任務
- 其他服務如果需要觸發定時任務,接收特定的觸發訊息
- 任務執行完成向任務分發中心推送任務完成的確認訊息
- 為任務執行端提供一個公共的spring boot starter晚上2,3的步驟,實際需要編碼的幾乎就剩下業務邏輯本身了
詳細設計
根據上一步的方案,需要確認一些細節,以及一些特殊的情況,例如定時任務可能是由微服務叢集中單個例項執行,也可能存在集體執行(例如更新記憶體中的快取),還可能存在分割槽執行。
客戶端(需要定時任務的為服務端)需要建立以下訊息佇列:
- 叢集接收的佇列,每個微服務例項建立一個,每個微服務例項都會收到相同訊息
- 單獨接收的佇列,每個應用叢集建立一個,確保訊息只被一個例項消費
- 按分割槽接收的佇列,每個分割槽建立一個,確保只被分割槽內一個例項消費
客戶端與服務端需要通過唯一的任務id來確認需要執行的定時任務
服務端(任務分發微服務)需要根據情況將訊息推送到不同的佇列,不能直接使用Spring Cloud Stream,需要使用rabbitmq
服務端本身也是分散式的,因此需要一個定時任務框架用於任務觸發,我這裡選擇了quartz
程式碼實現
Spring Cloud Stream的基本知識我不再複述了,Spring Cloud 微服務架構下的 WebSocket 解決方案中有講解。
定時任務分發服務
定義定時任務
data class ScheduleTask(
/** 任務的id,全域性唯一,與客戶端的taskId完全匹配 */
var taskId: String = "",
/** 定時任務的cron 表示式 */
var cron: String = "",
/** 關聯應用 */
var appId: Int = 0,
/** 任務描述 */
var description: String = "",
/** 接收任務的分割槽 */
var zone: String? = null,
/** 排程方式,廣播到叢集或單例執行,預設單例 */
var dispatchMode: DispatchMode = DispatchMode.Singleton,
/** 是否啟用 */
var enabled: Boolean = true,
/** 任務的資料庫記錄 id,自增 */
var id: Int = -1)
複製程式碼
任務排程
使用quartz進行任務排程
private fun scheduleJob(task: ScheduleTask) {
val job = JobBuilder.newJob(TaskEmitterJob::class.java)
.withIdentity(task.taskId, task.appId.toString())
.withDescription(task.description)
.storeDurably()
.requestRecovery()
.usingJobData("id", task.id)
.usingJobData("taskId", task.taskId)
.build()
val trigger = TriggerBuilder.newTrigger()
.withIdentity(task.taskId, task.appId.toString())
.withSchedule(CronScheduleBuilder.cronSchedule(task.cron))
.forJob(job)
.build()
scheduler.addJob(job, true, true)
if (scheduler.checkExists(trigger.key)) {
scheduler.rescheduleJob(trigger.key, trigger)
} else {
scheduler.scheduleJob(trigger)
}
}
複製程式碼
ScheduleTask是持久化的,插入的時候同時向quartz插入任務,更新的時候也要向quartz更新,刪除的時候同時刪除
quartz的任務觸發
class TaskEmitterJob : Job {
companion object {
private val log = LogFactory.getLog(TaskEmitterJob::class.java)
}
override fun execute(context: JobExecutionContext) {
try {
val taskId = context.jobDetail.jobDataMap["taskId"] as String
log.info("任務分發:$taskId")
val service = ScheduleCenterApplication.context.getBean(ScheduleTaskService::class.java)
service.launch(taskId)
} catch (e: Exception) {
log.error("任務失敗$[taskId]", e)
}
}
}
複製程式碼
rabbitmq的傳送邏輯
/**
* 釋出定時任務事件
*/
fun launch(task: ScheduleTask) {
val exchange = when (task.dispatchMode) {
Cluster -> "aegisScheduleCluster"
Singleton -> "aegisScheduleSingleton"
}
val routingKey = when (task.dispatchMode) {
Cluster -> exchange
Singleton -> "$exchange.${task.appName}"
}
val executeTaskInfo = ScheduleTaskInfo(task.taskId, task.appName!!)
amqpTemplate.convertAndSend(exchange, routingKey,
executeTaskInfo)
taskExecuteRecordDAO.save(
TaskExecuteRecord(executeTaskInfo.uid, task.id, Date())
)
}
複製程式碼
客戶端spring boot starter的實現
定義定時任務介面,只要在專案中實現該介面並將實現宣告為bean,即可完成定時任務的定義
@FunctionalInterface
interface ScheduledJob {
/**
* 執行定時任務
*/
fun execute(properties: Map<String, Any>)
/**
* 獲取定時任務id
* @return 定時任務id,對應任務分發中心ScheduleTask的taskId
*/
fun getId(): String
}
複製程式碼
接收任務
/**
* 接收單例任務
*/
@StreamListener(SINGLETON_INPUT)
fun acceptGroupTask(taskInfo: ScheduleTaskInfo) {
if (taskInfo.app == application) {
val receivedTime = Date()
val job = jobsProvider.ifAvailable?.firstOrNull {
it.getId() == taskInfo.id
}
job?.execute(taskInfo.properties ?: mapOf())
singletonOutput.send(GenericMessage(
ConfirmInfo(taskInfo.id, taskInfo.uid, job != null, receivedTime, Date())
))
}
}
複製程式碼
叢集全體執行任務與單例任務的區別只在stream的配置,一個需要宣告binding的group,一個不需要,這屬於Spring Cloud Stream的知識範疇,可以自己看官方文件或檢視我前面提到的文件,如果有不懂的可以私聊我。
stream的事件流宣告
/**
* 定時任務資訊的事件流介面
* @author 吳昊
* @since 0.1.0
*/
interface AegisScheduleClient {
companion object {
const val CLUSTER_INPUT = "aegisScheduleClusterInput"
const val SINGLETON_INPUT = "aegisScheduleSingletonInput"
const val CONFIRM_OUTPUT = "aegisScheduleGroupOutput"
}
/**
*
* @return
*/
@Input(CLUSTER_INPUT)
fun scheduleInput(): SubscribableChannel
/**
*
* @return
*/
@Input(SINGLETON_INPUT)
fun singletonScheduleInput(): SubscribableChannel
/**
*
* @return
*/
@Output(CONFIRM_OUTPUT)
fun confirmOutput(): MessageChannel
}
複製程式碼
最後再加上服務端確認訊息的接收程式碼:
@StreamListener(CONFIRM_INPUT)
fun acceptGroupTask(confirmInfo: ConfirmInfo) {
LOG.info("接收到確認訊息:$confirmInfo")
scheduleTaskService.confirm(confirmInfo)
}
複製程式碼
主要的程式碼已經全部放上來了,整體思路也很簡單,後面仍有很多需要優化的地方,例如訊息推送失敗,或者確認訊息未送達等等,於整體設計並沒有多大的影響了。
這樣在微服務端如果需要新增定時任務,只需要
- 引入starter
- 實現ScheduledJob介面
- 在任務排程中心新增任務
至於在任務中心新增任務,主題程式碼有了,實現個簡單管理介面很容易對不對,也就幾個欄位的輸入。
最後附上管理介面的截圖:
任務列表
任務詳情
我的其他文章: