基於Spring打造簡單高效通用的異步任務處理系統

背景

隨着應用系統功能的不斷新增,而某些功能的實現對實時性要求並不是那麼高,但是邏輯卻很複雜、執行比較耗時,比如涉及外部系統調用、多數據源等等;此時,我們就希望可以讓這些複雜的業務邏輯放在後臺執行,而前臺與用戶的交互可以不用等待,從而提高用戶體驗;

另外,從系統架構這個層面來說,我們也希望按照不同功能來拆分,以保持各個系統之間的低耦合,當一個系統出現問題時不會影響到其他系統,並且對於獨立的各個系統,我們可以專門進行性能優化、監控等;所以我們需要通用、高效的異步任務處理系統;

設計目標

打造輕量級、簡單、高效、通用、高擴展性、高可靠性的異步任務處理系統!

系統設計

要實現類似的異步處理系統,相信大家首先想到的就是JMS,Alibaba裏面也有基於JMS的異步處理系統,而且該系統在網店系統中應用非常廣泛;但由於目前我們阿里軟件採用了不同的技術框架,所以不能直接拿來使用;況且,該系統爲了實現異步任務系統的併發,採取了JMS與MDB結合的策略,所以系統就依賴於EJB了,這樣系統就變得笨重了,由此系統部署的應用服務器必須要支持EJB,一些輕量級的不支持EJB規範的應用服務器就沒法部署了;

考慮到如上的系統設計目標,我們的設計思路爲:任務DB持久化 + Spring封裝Job調度、線程池

  • l 任務DB持久化:是說我們需要將待處理的任務信息保存在我們可信任的DB中,若任務未到達千萬級可以和業務DB放在一起,確保當我們的任務處理服務器down了之後這些未執行成功、或未開始執行的任務不會被丟失;
  • l Spring封裝Job調度:當任務信息都持久化在DB中之後,我們需要將這些信息讀取出來執行具體的業務邏輯操作,這裏我們通過ScheduledExecutorFactoryBean來實現對任務的循環調度,比如說可採取每隔5min掃描一次待處理任務列表,若有記錄則提取出來執行;當然,若要實現更加強大的任務調度功能,可以採用Spring內部集成的Quartz這個開源調度框架;
  • l Spring封裝線程池:爲了提高任務執行效率,我們必須考慮讓任務的具體操作能夠被併發執行;爲了讓系統更加輕量級,這裏我們直接採用Spring中基於JDK線程池的默認封裝實現,通過配置調整參數;

 系統的部署圖可參考下圖:

 

 

下面我們來看以下具體的系統設計:

首先,需要新建兩張表,用來持久化我們的任務相關信息,以下表結構及其SQL都基於Oracle;表名可自取,比如Tasks/Tasks_Fail_History,兩者的字段完全一樣,字段建議包括:

字段 類型 描述 可空 默認值
TASK_ID VARCHAR2(36) @desc PK,唯一標識即可,默認是UUID NOT  
GMT_CREATE DATE @desc 創建日期 NOT  
GMT_HANDLE DATE @desc 任務待執行日期 NOT  
TASK_HANDLER VARCHAR2(32) @desc 待執行任務類型 NOT  
LOAD_BALANCE_NUM NUMBER @desc 待執行任務獲取的負載均衡值
當有多臺服務器時用於平衡各服務器壓力
NOT 0
TASK_PARAMS VARCHAR2(4000) @desc 待執行任務需要的參數 NULL  
RETRY_COUNT NUMBER @desc 重試次數,每次加1 NOT 0
RETRY_REASON VARCHAR2(512) @desc 重試原因,即上次失敗原因,便於排錯 NULL  

 

 

 

 

 

 

表Tasks主要用來保存所有待執行的任務,每條任務信息屬於一種任務類型,由TASK_HANDLER字段標識,因爲本系統核心基於Spring,所以任務類型的值建議爲:該類型任務的具體實現類在Spring容器中的bean id;

執行該任務需要的所有參數都由TASK_PARAMS字段提供,該字段內的字符串可以由應用自行組裝,只要具體任務實現類能夠解析即可;

對於字段LOAD_BALANCE_NUM,主要是用來滿足未來任務很多時,需要多臺服務器來平衡壓力時使用,相當於對每條任務分配了一個負載均衡值,不同服務器能夠處理具有不同負載均衡值的任務信息;該字段值要求在全表內儘量平均分佈,比如說全表內共500條記錄,其中1、2、......、10每個值的任務總條數都在50條左右;

每條任務被執行之後根據執行情況進行刪除或者更新操作;

表Tasks_Fail_History主要用來保存執行失敗、需要人工干預的任務記錄;記錄來源於Tasks表,當任務執行重試超過一定次數時任務記錄就會保存到失敗歷史表中;

其次,我們要明確任務生產者、消費者各自關注的一些信息:

對於任務的生產者,他需要提供的必備信息包括:任務待執行日期、任務類型、任務執行所需參數;

另外一個可選字段:LOAD_BALANCE_NUM;當任務的消費者有多臺服務器時,可以利用該字段來進行分佈式任務處理,此時可以根據一定規則對該字段設值,比如說產生一個1-10之間的隨機數;或者根據其他自行設計的規則生成一個值,只要保持該字段值是在全表內平均分佈的即可;

對於任務的消費者,大致的消費過程如下:

下面對上圖中的各個過程中具體邏輯進行一些詳細描述:

  1. 當消費者服務啓動之後,會根據配置好的調度策略(通過Spring內置的ScheduledExecutorFactoryBean實現,可以選擇兩種調度策略:其一:FixedRate,即每隔幾分鐘調度一次,而不管上次調度是否已經執行完畢;其二:FixedDelay,即在每次調度完成後都delay相同時間;)掃描Tasks表,從中取出xx條數據,比如1000,可配置;
    基本SQL語句爲:SELECT * FROM tasks WHERE gmt_handle <= SYSDATE;
    當然根據擴展策略不同,每次掃描Tasks表的查詢條件也不同,比如:
    1. 當待執行任務類型較少,任務數量也不是很多的情況下,單臺服務器已經可以搞定,所以查詢SQL爲:
      SELECT * FROM tasks WHERE gmt_handle <= SYSDATE AND ROWNUM <= ?;
    2. 當任務類型、任務數量越來越多時,單臺服務器已經不能搞定了,此時我們需要考慮對消費者服務器進行線性擴展,此時有不同的擴展策略可供選擇:
      1. 若按功能水平擴展的策略,即將不同的任務類型讓不同的消費者服務器執行;則查詢SQL條件爲:
        WHERE gmt_handle <= SYSDATE AND task_handler IN (?) AND ROWNUM <= ?;
      2. 若按壓力水平擴展的策略,即儘量保持各臺消費者服務器的壓力很平均,避免出現某些服務器很繁忙,而有些服務器卻很空閒的情況;前面的按功能水平擴展的策略就會出現服務器繁忙程度不一樣的問題;若採取這種策略,每臺消費者服務器可能會處理多種類型的任務,此時SQL查詢條件爲:
        WHERE gmt_handle <= SYSDATE AND load_balance_num IN (?) AND ROWNUM <= ?;
      3. 除了根據上面兩個獨立維度進行擴展的策略之後,還可以將兩者進行結合起來使用;可適用於我們想按照功能進行水平擴展,但是某些任務類型單臺服務器又搞不定,此時就需要對這些特殊任務類型再按照壓力進行水平擴展,此時SQL查詢條件爲:
        WHERE gmt_handle <= SYSDATE AND task_handler IN (?) AND load_balance_num IN (?) AND ROWNUM <= ?;
      4. 對於以上任務的查詢SQL中有用IN這個關鍵詞,有人可能會擔心查詢性能,其實不必擔心,因爲我們處理的任務類型、任務服務器數量都不會太多,幾百個任務類型估計最多了,而且IN語句的查詢也是會用到index的,再以ROWNUM的輔助限制條件,所以SQL的執行效率不用擔心;另外,若任務類型較少,則SQL中的IN可用=替換;
  2. 對從DB中查詢出來的每條記錄,將該條記錄的ID放進本地cache(static變量即可搞定,但要處理併發)中,根據記錄中TASK_HANDLER字段的值在Spring容器中找到對應的處理類bean實例,並扔到Spring異步線程池中執行;
  3. 具體處理類對該任務處理完成之後返回結果,然後任務系統根據返回結果對該條記錄對應的Tasks表中的記錄進行更新(增加重試次數,並根據重試策略設置下次執行時間)或者刪除(執行成功);同時將cache中的記錄ID清除、避免cache無限膨脹;
  4. 根據調度規則,當到了下次執行時間時,再次利用步驟1中的規則掃描Tasks表,循環上面的處理邏輯,差別在於,在將任務讓具體TASK_Handler處理之前會先到本地cache中查詢是否該條記錄正在被處理,若cache中已經存在該條記錄就無需處理了;這主要是爲了避免一些比較耗時的任務被重複併發執行;
  5. 對於失敗後的重試,設置重試策略,每次可delay不同的時間,可配置;比如第一次失敗後1分鐘後重試,第二次失敗後5分鐘後重試,第三次失敗後20分鐘後重試。。。失敗超過x次後將記錄移至history表中,並email報警;

詳細設計

針對以上的系統設計,我們可以規劃出大致的類圖,可以參考如下實現:

 

其中類圖中涉及到的幾個核心class的用途說明可以參考如下的Spring配置信息:

  

是否達成設計目標?

  • l 輕量:核心實現完全基於Spring、Dao層完全可以自行決定採取何種框架;可以部署於任何Web容器中;這也是相對於JMS系統最大的改進;
  • l 簡單:對於任務的生產者,只需要向Tasks表中insert記錄即可,無需引入任何其他通訊協議;
    對於任務的消費者而言,因爲系統只依賴於Spring,所以要想將該系統與目前已有系統進行集成將會非常簡單:引入jar包,將Ibatis、Spring配置文件加入到自己系統的加載列表中即可;
    另外,任務的調度策略設置基於Spring Schedual,配置文件相對於Quartz來說更少;
  • l 高效:若採取FixedRate調度方式,系統的處理能力可以被準確計算;比如每1min提取1000條數據,那麼1天單臺服務器的處理能力爲144w;當然需要考慮每個任務的具體耗時,因爲1min內系統不一定能將1000條數據處理完畢;
    若採取FixedDelay調度方式,系統的處理能力就完全基於任務的具體執行耗時了,因爲當該種調度方式設置每次調度完成之後delay 1s,其實就相當於系統一直在處理任務,這樣就可以最大化的保持系統的利用率;
    可能有人會懷疑多臺消費者服務器都對TASKS表進行查詢會不會有性能問題?其實經過我們的系統運行經驗,該問題是不存在的,因爲該表的記錄當執行成功之後就會被刪除的,所以該表的數據量不會太大,除非消費者服務大面積down掉,但這是極少數情況,當出現這種情況時,當消費者服務再次啓動時系統會有一定壓力,但也不會太大,因爲每次查詢待執行任務時是取前XX條的,況且可以建立index來進行輔助;
  • l 通用:該系統只實現最核心的異步處理功能,而與具體業務邏輯沒有任何關係,系統根據TASK_HANDLER去加載具體的業務邏輯實現;具體的Handler實現只需實現對應接口,並在Spring中添加bean配置即可;
  • l 擴展:根據TASKS表中的TASK_HANDLER/LOAD_BALANCE_NUM中任意一個字段、或者兩者組合的方式可以實現分佈式線性擴展,他們分別對應於兩種不同的分佈式線性擴展策略;而這對於客戶端而言是完全透明的,任務生產者插入時只需配置不同策略而已;而且可以通過合理使用這兩種策略達到新增任務類型時已經在運行的消費者服務無需重新發布;
  • l 可靠:由於待執行任務信息是在我們自行維護的可靠DB中保存,所以當我們的消費者服務down了也不會讓未處理的任務信息丟失,相比於基於JMS Server的一些內存數據庫定時持久化方案,與業務DB的穩定性相比,在可靠性方面不是一個級別的;

 

發佈了135 篇原創文章 · 獲贊 69 · 訪問量 97萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章