背景
隨著應用系統功能的不斷新增,而某些功能的實現對即時性要求並不是那麼高,但是邏輯卻很複雜、執行比較耗時,比如涉及外部系統調用、多資料來源等等;此時,我們就希望可以讓這些複雜的商務邏輯放在後台執行,而前台與使用者的互動可以不用等待,從而提高使用者體驗;
另外,從系統架構這個層面來說,我們也希望按照不同功能來拆分,以保持各個系統之間的低耦合,當一個系統出現問題時不會影響到其他系統,並且對於獨立的各個系統,我們可以專門進行效能最佳化、監控等;所以我們需要通用、高效的非同步任務處理系統;
設計目標
打造輕量級、簡單、高效、通用、高擴充性、高可靠性的非同步任務處理系統!
系統設計
要實作類別似的非同步處理系統,相信大家首先想到的就是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之間的隨機數;或者根據其他自行設計的規則產生一個值,只要保持該欄位值是在全表內平均分布的即可;
對於任務的消費者,大致的消費過程如下:
下面對中的各個過程中具體邏輯進行一些詳細描述:
- 當消費者服務啟動之後,會根據配置好的調度策略(通過Spring內建的ScheduledExecutorFactoryBean實現,可以選擇兩種調度策略:其一:FixedRate,即每隔幾分鐘調度一次,而不管上次調度是否已經執行完畢;其二:FixedDelay,即在每次調度完成後都delay相同時間;)掃描Tasks表,從中取出xx條資料,比如1000,可配置;
基本SQL語句為:SELECT * FROM tasks WHERE gmt_handle <= SYSDATE;
當然根據擴充策略不同,每次掃描Tasks表的查詢條件也不同,比如:
- 當待執行任務類型較少,任務數量也不是很多的情況下,單台伺服器已經可以搞定,所以查詢SQL為:
SELECT * FROM tasks WHERE gmt_handle <= SYSDATE AND ROWNUM <= ?;
- 當任務類型、任務數量越來越多時,單台伺服器已經不能搞定了,此時我們需要考慮對消費者伺服器進行線性擴充,此時有不同的擴充策略可供選擇:
- 若按功能水平擴充的策略,即將不同的任務類型讓不同的消費者伺服器執行;則查詢SQL條件為:
WHERE gmt_handle <= SYSDATE AND task_handler IN (?) AND ROWNUM <= ?;
- 若按壓力水平擴充的策略,即盡量保持各台消費者伺服器的壓力很平均,避免出現某些伺服器很繁忙,而有些伺服器卻很閒置情況;前面的按功能水平擴充的策略就會出現伺服器繁忙程度不一樣的問題;若採取這種策略,每台消費者伺服器可能會處理多種類型的任務,此時SQL查詢條件為:
WHERE gmt_handle <= SYSDATE AND load_balance_num IN (?) AND ROWNUM <= ?;
- 除了根據上面兩個獨立維度進行擴充的策略之後,還可以將兩者進行結合起來使用;可適用於我們想按照功能進行水平擴充,但是某些任務類型單台伺服器又搞不定,此時就需要對這些特殊任務類型再按照壓力進行水平擴充,此時SQL查詢條件為:
WHERE gmt_handle <= SYSDATE AND task_handler IN (?) AND load_balance_num IN (?) AND ROWNUM <= ?;
- 對於以上任務的查詢SQL中有用IN這個關鍵詞,有人可能會擔心查詢效能,其實不必擔心,因為我們處理的任務類型、任務伺服器數量都不會太多,幾百個任務類型估計最多了,而且IN語句的查詢也是會用到index的,再以ROWNUM的輔助限制條件,所以SQL的執行效率不用擔心;另外,若任務類型較少,則SQL中的IN可用=替換;
- 對從DB中查詢出來的每條記錄,將該條記錄的ID放進本地cache(static變數即可搞定,但要處理並發)中,根據記錄中TASK_HANDLER欄位的值在Spring容器中找到對應的處理類bean執行個體,並扔到Spring非同步線程池中執行;
- 具體處理類對該任務處理完成之後返回結果,然後任務系統根據返回結果對該條記錄對應的Tasks表中的記錄進行更新(增加重試次數,並根據重試原則設定下次執行時間)或者刪除(執行成功);同時將cache中的記錄ID清除、避免cache無限膨脹;
- 根據調度規則,當到了下次執行時間時,再次利用步驟1中的規則掃描Tasks表,迴圈上面的處理邏輯,差別在於,在將任務讓具體TASK_Handler處理之前會先到本地cache中查詢是否該條記錄正在被處理,若cache中已經存在該條記錄就無需處理了;這主要是為了避免一些比較耗時的任務被重複並發執行;
- 對於失敗後的重試,設定重試策略,每次可delay不同的時間,可配置;比如第一次失敗後1分鐘後重試,第二次失敗後5分鐘後重試,第三次失敗後20分鐘後重試。。。失敗超過x次後將記錄移至history表中,並email警示;
詳細設計
針對以上的系統設計,我們可以規划出大致的類圖,可以參考如下實現:
其中類圖中涉及到的幾個核心class的用途說明可以參考如下的Spring配置資訊:
<!-- 任務從此處開始載入 --><br /><bean id="notifySpringScheduledExecutorFactoryBean" class="org.springframework.scheduling.concurrent.ScheduledExecutorFactoryBean"><br /><property name="scheduledExecutorTasks"><br /><list><br /><ref bean="notifySpringScheduledExecutorTask" /><br /></list><br /></property><br /></bean><br /><!-- 待加入Spring Schedual進行調度的task列表 --><br /><bean id="notifySpringScheduledExecutorTask" class="org.springframework.scheduling.concurrent.ScheduledExecutorTask"><br /><property name="runnable" ref="notifyScheduledMainExecutor" /><br /><!-- 初次執行任務delay時間,單位為ms,預設值為0,代表首次載入任務時立即執行;比如1min --><br /><property name="delay" value="60000" /><br /><!-- 間隔時間,單位為ms,預設值為0,代表任務只執行一次;比如2min --><br /><property name="period" value="120000" /><br /><!-- 是否採用fixedRate方式進行任務調度,預設為false,即採用fixedDelay方式 --><br /><!-- fixedRate:定時間隔執行,不管上次任務是否已執行完畢;fixedDelay:每次任務執行完畢之後delay固定的時間 --><br /><property name="fixedRate" value="true" /><br /></bean><br /><!-- 任務調度主線程 --><br /><bean id="notifyScheduledMainExecutor" class="com.alisoft.aep.notify.schedual.NotifyScheduledMainExecutor"><br /><!-- 針對Notify服務端的Service,用於更新Notify重試資訊等 --><br /><property name="notifyServerService" ref="notifyServerService" /><br /><!-- notify.notifyId緩衝策略實作類別,可自行擴充 --><br /><property name="notifyIdCacheStrategy" ref="defaultNotifyIdCacheStrategy" /><br /><!-- notify.load_balance_num欄位值產生、以及調度時where條件中取值的策略實作類別,可自行擴充 --><br /><!-- 當有多台notify伺服器時才有用,用於平衡各台server間的壓力;一般不用配置 --><br /><!-- <property name="loadBalanceNumStrategy" ref="alternateLoadBalanceNumStrategy" /> --><br /><!-- notify.handler欄位值在調度時where條件中取值的策略實作類別,可自行擴充 --><br /><!-- 當有多台notify伺服器時才有用,用於表明某台server可執行哪些handler;一般不用配置 --><br /><!-- <property name="notifyHandlerStrategy" ref="defaultNotifyHandlerStrategy" /> --><br /><!-- 當有多台notify伺服器時才有用,用於設定某台server調度時每次讀取的Notify最大數,用於覆蓋maxNum;一般不用配置 --><br /><!-- <property name="notifyMaxNumPerJobStrategy" ref="defaultNotifyMaxNumPerJobStrategy" /> --><br /><!-- 用於並發的線程池 --><br /><property name="notifyTaskExecutor" ref="notifyTaskExecutor" /><br /><!-- 每次調度讀取的Notify最大記錄數,預設為1000 --><br /><property name="maxNum" value="1000" /><br /><property name="notifyDao" ref="notifyDao" /><br /></bean></p><p><!-- 非同步線程池 --><br /><bean id="notifyTaskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor"><br /><!-- 核心線程數,預設為1 --><br /><property name="corePoolSize" value="10" /><br /><!-- 最大線程數,預設為Integer.MAX_VALUE --><br /><property name="maxPoolSize" value="50" /><br /><!-- 隊列最大長度,一般需要設定值>=notifyScheduledMainExecutor.maxNum;預設為Integer.MAX_VALUE --><br /><property name="queueCapacity" value="1000" /><br /><!-- 線程池維護線程所允許的空閑時間,預設為60s --><br /><property name="keepAliveSeconds" value="300" /><br /><!-- 線程池對拒絕任務(無線程可用)的處理策略,目前只支援AbortPolicy、CallerRunsPolicy;預設為後者 --><br /><property name="rejectedExecutionHandler"><br /><!-- AbortPolicy:直接拋出java.util.concurrent.RejectedExecutionException異常 --><br /><!-- CallerRunsPolicy:主線程直接執行該任務,執行完之後嘗試添加下一個任務到線程池中,可以有效降低向線程池內新增工作的速度 --><br /><!-- DiscardOldestPolicy:拋棄舊的任務、暫不支援;會導致被丟棄的任務無法再次被執行 --><br /><!-- DiscardPolicy:拋棄當前任務、暫不支援;會導致被丟棄的任務無法再次被執行 --><br /><bean class="java.util.concurrent.ThreadPoolExecutor$CallerRunsPolicy" /><br /></property><br /></bean><br /><bean id="notifyServerService" class="com.alisoft.aep.notify.service.impl.NotifyServerServiceImpl"><br /><!-- 針對任務執行失敗後Notify如何重試的策略實作類別,可自行擴充 --><br /><property name="notifyRetryStrategy" ref="defaultNotifyRetryStrategy" /><br /><!-- 針對任務執行失敗後異常處理策略實作類別,可自行擴充 --><br /><!-- 預設不對異常進行補救,具體handler實作類別中若返回NULL或拋出異常,則均按異常處理,直接將Notify記錄遷移到曆史表中,不進行重試; --><br /><!-- <property name="notifyHandlerExceptionStrategy" ref="defaultNotifyHandlerExceptionStrategy" /> --><br /><!-- 描述見notifyScheduledMainExecutor --><br /><property name="notifyIdCacheStrategy" ref="defaultNotifyIdCacheStrategy" /><br /><!-- 事務模板,需保證能夠找到對應的bean --><br /><property name="transactionTemplate" ref="transactionTemplate" /><br /><property name="notifyDao" ref="notifyDao" /><br /></bean><br />
是否達成設計目標?
- 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的穩定性相比,在可靠性方面不是一個層級的;