本系列轉載自 【架構師之路】公眾號 By 58沈劍。
沈老師擅長用簡單的文字把常見原理講的很透徹,推薦。
一、緣起
很多時候,業務有定時任務或者定時超時的需求,當任務量很大時,可能需要維護大量的timer,或者進行低效的掃描。
例如:58到家APP實時消息通道系統,對每個用戶會維護一個APP到服務器的TCP連接,用來實時收發消息,對這個TCP連接,有這樣一個需求:“如果連續30s沒有請求包(例如登錄,消息,keepalive包),服務端就要將這個用戶的狀態置為離線”。
其中,單機TCP同時在線量約在10w級別,keepalive請求包大概30s一次,吞吐量約在3000qps。
一般來說怎么實現這類需求呢?
- “輪詢掃描法”
1)用一個Map來記錄每一個uid最近一次請求時間last_packet_time
2)當某個用戶uid有請求包來到,實時更新這個Map
3)啟動一個timer,當Map中不為空時,輪詢掃描這個Map,看每個uid的last_packet_time是否超過30s,如果超過則進行超時處理
- “多timer觸發法”
1)用一個Map來記錄每一個uid最近一次請求時間last_packet_time
2)當某個用戶uid有請求包來到,實時更新這個Map,并同時對這個uid請求包啟動一個timer,30s之后觸發
3)每個uid請求包對應的timer觸發后,看Map中,查看這個uid的last_packet_time是否超過30s,如果超過則進行超時處理
方案一:只啟動一個timer,但需要輪詢,效率較低
方案二:不需要輪詢,但每個請求包要啟動一個timer,比較耗資源, 特別在同時在線量很大時,很容易CPU100%,如何高效維護和觸發大量的定時/超時任務,是本文要討論的問題。
二、環形隊列法
廢話不多說,三個重要的數據結構:
1)假設需要30s超時,就創建一個index從0到30的環形隊列(本質是個數組)
2)環上每一個slot是一個Set,任務集合
3)同時還有一個Map,記錄uid落在環上的哪個slot里
同時:
1)啟動一個timer,每隔1s,在上述環形隊列中移動一格,0->1->2->3…->29->30->0…
2)有一個Current Index指針來標識剛檢測過的slot
當有某用戶uid有請求包到達時:
1)從Map結構中,查找出這個uid存儲在哪一個slot里
2)從這個slot的Set結構中,刪除這個uid
3)將uid重新加入到新的slot中,具體是哪一個slot呢 => Current Index指針所指向的上一個slot,因為這個slot會被timer在30s之后掃描到
(4)更新Map,這個uid對應slot的index值
哪些元素會被超時掉呢?
Current Index每秒種移動一個slot,這個slot對應的Set中所有uid都應該被集體超時!如果最近30s有請求包來到,一定被放到Current Index的前一個slot了,Current Index所在的slot對應Set中所有元素, 都是最近30s沒有請求包來到的。
所以,當沒有超時時,Current Index掃到的每一個slot的Set中應該都沒有元素。
優勢:
(1)只需要1個timer
(2)timer每1s只需要一次觸發,消耗CPU很低
(3)批量超時,Current Index掃到的slot,Set中所有元素都應該被超時掉
三、總結
這個環形隊列法是一個通用的方法,Set和Map中可以是任何task,本文的uid是一個最簡單的舉例。
HashedWheelTimer([https://segmentfault.com/a/1190000010987765](https://segmentfault.com/a/1190000010987765))也是類似的原理,有興趣的同學可以百度一下這個數據結構,Netty中的一個工具類,希望大家有收獲,幫忙轉發一下哈。
補:
Netty中增加了要給round的概念,這樣可以實現任意時間之后開始。
以上圖為例,假設一個格子是1秒,則整個wheel能表示的時間段為8s,假如當前指針指向2,此時需要調度一個3s后執行的任務,顯然應該加入到(2+3=5)的方格中,指針再走3次就可以執行了;如果任務要在10s后執行,應該等指針走完一個round零2格再執行,因此應放入4,同時將round(1)保存到任務中。檢查到期任務時應當只執行round為0的。每次執行之后,格子上其他任務的round應減1。
效率
- 添加任務:O(1)
- 刪除/取消任務:O(1)
- 過期/執行任務:最差情況為O(n)->也就是當HashMap里面的元素全部hash沖突,退化為一條鏈表的情況。平均O(1)