背景
在業務數據沒達到一定量又不想引入分佈式事務框架增加複雜性,基於Job框架實現的補償方案不失爲一種簡單優雅的方案。
微服務環境下雖然使用了retry框架,對一些冪等的接口一次失敗多次嘗試,但有些場景比如下單後無庫存,要保證庫存在一定時間內一定能扣成功,也只能使用Job框架以一定的頻率發起補償。
業界常用的分佈式Job框架有 Saturn與 xxl-job
Job併發
像Saturn跟xxl-job都有控制檯頁面把Job配置爲單機執行,單機發生故障進行故障轉移。
數據量少的場景只需要單機執行,不需要考慮併發。
但單機執行場景下仍然要注意併發問題。
一般來說Job框架會保證下一個Job等待上一個Job執行完畢纔開始。
如果業務邏輯使用了線程異步執行,如下
void executeJob() {
new Thread(()->{
//耗時的業務處理
}).start();
}
由於是在線程裏面異步執行,Job就當做上一個Job已經處理完畢了,毫不猶豫地就開啓下一輪輪詢,就有可能發生兩個線程併發處理同一段數據。
爲了避免耗時業務處理長時間阻塞Job,啓用線程異步處理也是必要的,同時要控制兩個Job的輪詢間隔,避免當前Job輪詢啓動了,上一個Job異步線程裏面還有未完成業務處理邏輯。
Job查詢必須有結束條件
使用Job自然是要查詢那些需要補償的任務。
通常使用分頁查詢,把待補償的業務數據查詢出來處理,直到處理完畢結束循環。
查詢分頁避免了一次性查詢出過量數據造成OutOfMemoryError。
//第一種場景:分頁查詢不知道數據總數
public void executeJob() {
int beginPage = 0;
int pageSize = 500;
while (true) {
//根據beginPage進行分頁查詢
List<Object> orders = getOrders(beginPage, pageSize);
//處理業務數據 orders
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
}
}
//第二種場景:分頁查詢知道數據總數
public void executeJob() {
int beginPage = 0;
int pageSize = 10;
int total = 0;
int allTotal = 0;
while (total<allTotal) {
//根據beginPage進行分頁查詢
PageInfo<Object> orders = getOrders(beginPage, pageSize);
//處理業務數據 orders
allTotal = orders.getTotal();
total = total + orders.size();
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
}
}
while要有一種業務無關的結束條件
雖然分頁查詢有結束條件,但查詢邏輯是會隨着需求更新的,當查詢條件不再滿足結束條件就會陷入死循環。
由於Job是純後臺服務,測試人員一時也不易發現Job邏輯出了問題。
例如 getOrders(beginPage, pageSize) 隨着分頁插件的升級或Sql的更新每次總是查詢出相同的500條數據。
500條相同的數據不多也不少,也不易發現查詢結果出錯,如下判斷條件永遠不滿足,第一種場景:分頁查詢不知道數據總數就沒有結束條件。
if (orders.size() < pageSize) {
break;
}
來看第二種場景:分頁查詢知道數據總數 ,多了一個結束條件 total<allTotal ,那多了一個結束條件就靠譜嗎?
雖然把每次查詢出的總數累加 了total = total + orders.size() ,但分頁插件或Sql通過 select count(*) from table where object = xxxx 統計出的 allTotal 也可能在變化,每次都變大,正所謂你長我也長。可能半天過去了 total<allTotal 條件仍然沒有滿足,while也陷入死循環。
由於根據sql查詢條件去結束while有一定的不確定性,通過限制最大循環是一種簡單有效的方法。
例如根據業務量,while循環不應該超過1千次,數據總量不應該超過50萬。
那麼保留根據分頁查詢結果判斷結束條件的同時,應該增加最大循環次數、最大業務量控制,避免由於查詢失效造成的死循環。
public void executeJob() {
int beginPage = 0;
while (true) {
//分頁查詢
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
if(beginPage>1000){
break;
}
}
}
public void executeJob() {
int beginPage = 0;
while (total<500000) {
//分頁查詢
total = total + orders.size();
if (orders.size() < pageSize) {
break;
}
beginPage = beginPage + 1;
if(beginPage>1000){
break;
}
}
}
Job也應該像微服務一樣優雅關閉
在微服務發佈過程中,通常把舊服務從Eureka等註冊中心以及負載平衡裏面摘掉,不再接受新的請求,舊服務把已有請求處理完畢下線。由於Job是純後臺服務,有可能被忽略掉。例如舊服務已經不接受新的Http請求,但後臺的Job輪詢還沒處理完畢,Job裏面同時開啓了數據庫事務,這時候強殺進程會造成數據回滾。
Job也應該同理,像微服務一樣,通過Http接口向分佈式Job註冊中心發送優雅下線的命令,待當前微服務已經沒有未完成的Job任務再停機。