高效延時消息設計與實現的場景


 背景

在自己接觸到的業務系統中,很多地方會有定時任務的需求,比如支付的交易超時自動關閉、連接超時、支付異步通知等等。常見的做法有:

1.考慮使用JDK中的Timer定時任務來實現

2.通過封裝quartz搭建專門的調度平台來管理

目前項目中運用的是第2種。

 場景應用

看到netty中hashedwheeltimer原理,自己可以仿造一種數據結構,用來實現延時消息觸發。

首先分析項目中哪些運用場景,通過延時的過程中數據的是否需要檢測最終是否觸發來划分靜態的延時和動態的延時。

一、靜態延時:不需要在延時的過程中判斷是否觸發定時任務,只是單純地到指定時間觸發任務即可,例如:交易成功通知業務系統。

二、動態延時:在延時的過程中並不是每個任務都需要執行,是有前提條件才能觸發執行;例如:心跳檢測,連接超時等。

場景分析:支付成功異步通知

支付模塊有一筆訂單支付成功通知業務系統的定時任務,具體是支付流水交易如果支付成功了,那么由調度平台根據cron定義的時間來觸發通知的任務。

假設支付流水表的結構為:t_jnl(jnl_no, pay_status,notify_status …),定時任務每一分鍾執行一次:目前的場景可以簡化為:

1.查詢出支付成功的流水記錄:select jnl_no from t_jnl where pay_status = 1 and notify_status =0;

2.調用業務系統接口,通知支付結果;

 存在的問題:

①如果支付記錄數很大,那么去查找滿足條件的記錄會造成數據庫很大的壓力。僅僅根據2個狀態來查詢的效率是很低的。

每次查詢表數據,已經被執行過記錄,仍然會被掃描(只是不會出現在結果集中),有重復計算的嫌疑。

②如果滿足條件的支付流水足夠多的話,至少每次不能一次性讀取。需要分頁查詢,這將會是一個for循環。目前做法是定時任務觸發時一次讀取100條數據。

如果記錄數超時定時任務中設定的數量(100),那么在后面的記錄不會再本次中得到執行。

③假如一條記錄恰好在剛執行任務后0.1s滿足條件了(pay_status = 1),那么幾乎要等待下一個周期被執行,時效性不好。誤差時間有可能就是cron的設置時間t。

場景一改造:(靜態延時)

為了解決上述場景存在的問題,引入下面的設計:右側是通過一個數組進行封裝的環形隊列,類似一個時鍾。根據cron來設置環形隊列的segment,理解為一個獨立的任務單元。左側是每個任務單元的結構實現:set<Task>

 

 以當前場景為例,cron設置時間t=60s,n=60,后台啟動一個timer,這個timer每隔1s,在上述環形隊列中移動一格,有一個Current Index指針來標識正在檢測的segment。

那么改造的場景變為:

1.在支付成功后根據current Index所在位置和cron設置周期確認在環形隊列上的segment下標和cyclenum后將數據插入環形隊列中

假設 current Index = 3,想要在60s后執行,數據插入第3+60=63個節點,但是環形隊列最大長度為60,所以cyclenum=63/60=1,segment=3

2.task function是具體執行延時任務的方法

假設異步通知業務系統的方法為syncOrder(jnl_no) ,通知業務系統這筆流水支付成功了。

3.后台一致啟動一個Timer,每隔t/n時間段,current index移動一個segment,當移動到當前的segment時候,渠道set<Task>中的cyclenum,

判斷是否為0,如果cyclenum=0,立即執行task function(jnlno)(可以用單獨的線程來執行Task),並把這個Task從Set<Task>中刪除,否則cyclenum -1。等待下個周期。

 結論分析:

 (1)無需與數據庫進行交互,不用再輪詢全部訂單,效率高

 (2)時效性好,精確到秒(設置timer的移動頻率t和segment數量n可以控制精度)

 (3)但是需要考慮數據量大的時候內存吃緊的情況(可以通過t/n的頻率來減少內存中緩存的數據)。

場景二分析:支付成功但通知失敗后進行重復通知策略

在上面的"支付成功通知"場景中會去異步通知業務系統,根據業務系統響應后修改通知狀態.有時候會出現業務系統宕機或者超時的情況,遇到此種問題需要再次發起通知。

1.系統目前的解決辦法是:查詢出支付成功但通知失敗的流水記錄:select jnl_no from t_jnl where pay_status = 1 and notify_status =2;

2.再次調用業務系統接口,通知支付結果;

3.修改對應的通知狀態,如果通知成功后續不會再通知,失敗還會發起通知。

 存在的問題:

①如果“支付成功但通知失敗”記錄很少,那么去查找的時候已經通知成功的記錄仍然會被掃描,只為查詢少量數據但需要全盤掃描其實資源就被浪費了。

②假如一條記錄恰好在剛執行任務后0.1s滿足條件了,那么幾乎要等待下一個周期t=5min被執行,時效性不好。誤差時間有可能就是周期t。

場景二改造:(動態延時)

之所以是動態延遲是因為並不是每次通知的結果都需要延遲執行任務,只有通知失敗才會有后續的延時任務。

 

以當前場景為例,首先在場景一中調用定時任務中的異步通知方法,如果通知失敗后將syncOrder(jnl_no)的流水號jnl_no存入Map數據中,將對應的環形隊列的下標存入Map的值。

cron設置時間t=300s,n=60,后台啟動一個timer,這個timer每隔5s,在上述環形隊列中移動一格,有一個Current Index指針來標識正在檢測的segment。

那么改造的場景變為:

1.假設current index指向segment=3的時候,執行通知但結果失敗,先確認該流水下次在隊列上的index = current index -1 = 2 ,到下一次被執行剛好300s.

所以map.put(jnl_no,2),同時把curent index指向的節點從數據刪除。

2.隔了300s后上一步的segment會被current index讀取,執行通知任務,如果執行成功,把map中的數據刪除掉,執行失敗繼續按照上一步步驟進行。

哪些元素是通知失敗的呢?

Current Index每秒種移動一個segment,這個segment對應的Set<jnl_no>中所有jnl_no都應該被執行!如果最近500s有通知失敗的,一定被放到Current Index的前一個segment了,Current Index所在的segment對應Set中所有元素,都是通知失敗的。所以,當沒有通知失敗時,Current Index掃到的每一個segment的Set中應該都沒有元素。

結論分析:

相對項目中目前的優勢

(1)只需要1個timer即可,無需數據庫交互,全局搜索。

(2)批量通知,Current Index掃到的segment,Set中所有元素都應該被重新發起通知。

 除開上面目前項目中運用的方法,還有其他的一些辦法,來進行比較下。

“輪詢掃描法”

1)用一個Map<jnl_no, last_notify_time>來記錄每一個jnl_no最近一次通知時間last_notify_time

2)當某個用戶jnl_no通知失敗時,實時更新這個last_notify_time

3)啟動一個timer,當Map中不為空時,輪詢掃描這個Map,看每個jnl_no的last_notify_time是否超過500s,如果超過500s進行超時再次通知。

 “多timer觸發法”

1)用一個Map<jnl_no, last_notify_time>來記錄每一個jnl_no最近一次請求時間last_notify_time

2)當某個jnl_no有通知失敗,實時更新這個Map,並同時對這個jnl_no啟動一個timer,500s之后觸發

3)每個jnl_no對應的timer觸發后,看Map中,查看這個jnl_no的last_notify_time是否超過500s,如果超過則進行通知處理

方案一:只啟動一個timer,但需要輪詢,效率較低

方案二:不需要輪詢,但每個請求包要啟動一個timer,比較耗資源


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM