微服務架構下的輕量級定時任務解決方案

吳昊87發表於2018-12-27

微服務的概念可以說給程式設計開啟了一個新世界,帶來了眾多的優點,但是也將一些以往容易處理的問題變得複雜,例如:快取、事務、定時任務等。快取可以用中介軟體例如redis、memcached等,事務有諸多分散式事務框架解決,定時任務也有分散式的解決方案,例如quartz、elastic job等,今天我要講的是就是定時任務。

既然已經有成熟的分散式定時任務框架,我要講的東西並不是用另一種設計去實現相同的功能,而是從不同的角度去解決分散式定時任務的問題。

問題來源

這個問題來起源於一個小功能,我們有一個傳送簡訊的微服務,需要獲取簡訊的狀態報告,狀態報告對於簡訊傳送不是同步的,簡訊提交到服務商,服務商要提交運營商傳送之後才能生成狀態報告,因此有一定的延遲,需要非同步獲取,並且服務商提供的介面有頻率限制,因此需要做一個定時任務,且需要單點執行,那麼問題來了,因為這一個功能我就需要引入一個定時任務框架嗎,總感覺有點大材小用的意思。

之前我們的定時任務處理既有用過quartz,也用過elastic job,但是隻為這樣一個小功能就引入一個框架,再加上配置又得好半天,想想都不划算。

例如要用quartz,要建立一堆資料庫表,但表裡面只儲存了一個任務資訊。

用elastic job吧,還要使用zookeeper,即便用lite版,也需要一堆配置,遠比我寫業務的時間要長。

我只想簡簡單單的寫邏輯!!!

解決方案

談分散式解決方案大致總離不開中介軟體,聯想到上次解決websocket的分散式方案(參見Spring Cloud 微服務架構下的 WebSocket 解決方案)使用到的Spring Cloud Stream,大概有了思路:

  1. 我需要一個任務分發中心,專門負責觸發定時任務
  2. 其他服務如果需要觸發定時任務,接收特定的觸發訊息
  3. 任務執行完成向任務分發中心推送任務完成的確認訊息
  4. 為任務執行端提供一個公共的spring boot starter晚上2,3的步驟,實際需要編碼的幾乎就剩下業務邏輯本身了

詳細設計

根據上一步的方案,需要確認一些細節,以及一些特殊的情況,例如定時任務可能是由微服務叢集中單個例項執行,也可能存在集體執行(例如更新記憶體中的快取),還可能存在分割槽執行。

客戶端(需要定時任務的為服務端)需要建立以下訊息佇列:

  1. 叢集接收的佇列,每個微服務例項建立一個,每個微服務例項都會收到相同訊息
  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)
  }
複製程式碼

主要的程式碼已經全部放上來了,整體思路也很簡單,後面仍有很多需要優化的地方,例如訊息推送失敗,或者確認訊息未送達等等,於整體設計並沒有多大的影響了。

這樣在微服務端如果需要新增定時任務,只需要

  1. 引入starter
  2. 實現ScheduledJob介面
  3. 在任務排程中心新增任務

至於在任務中心新增任務,主題程式碼有了,實現個簡單管理介面很容易對不對,也就幾個欄位的輸入。

最後附上管理介面的截圖:

任務列表

微服務架構下的輕量級定時任務解決方案

任務詳情

微服務架構下的輕量級定時任務解決方案

我的其他文章:

Spring Cloud 微服務架構下的 WebSocket 解決方案

Mybatis去xml化:我再也不想寫xml了

Spring Security OAuth2 快取使用jackson序列化的處理

相關文章