如何實現延時觸發/定時器

問題

微信公衆平臺後臺有一個功能即定時羣發消息,如明晚的20:00羣發一條圖文消息。那麼這種延時觸發的邏輯如何實現呢?

方案一

每隔一定的時間掃描所有超時的事件

這是最容易想到的一種方案。此方案最關鍵的兩點是輪訓的頻率以及如何高效地獲取超時任務。

  • 如果可以允許一秒左右的誤差,每隔一秒輪訓一次即可。
  • 採用紅黑樹或者最小堆存儲觸發任務,按照觸發時間戳排序。如此,每次掃描能夠很快地獲取超時的任務。

此種方案的缺點在於即使頻率到達一秒,也可能會有一秒的誤差。此外,輪訓的方式在很多情況下並沒有可觸發的任務,會浪費資源。插入和刪除操作的平均時間複雜度爲O(logn)。

實踐中,一個很簡單的方案就是使用Redis的SortedSet存儲觸發任務,這樣只需要使用zrangeByScore獲取超時的任務,再使用zremrangeByScore即可刪除已經觸發的任務。不過此種方案,由於zrange和zrem是兩條命令,在多線程消費時需要控制好併發問題,否則會造成重複消費。此外,此方案缺少ACK機制,會有任務丟失的可能。

如果不關注消費者的高可用(一個隊列的消費線程掛了會有其他線程接管),那麼最簡單的實現就是使用單線程消費,通過多Redis隊列分片來提升消費速度。而如果關注消費者的高可用,可以選擇Redisson中的RDelayedQueue以及Jesque,它們通過使用Redis Lua實現了併發控制,支持多線程消費。

方案二

阻塞線程等待時間超時

此方案思路來自於Nginx中定時器的實現(和Java中的DelayQueue原理類似)。任務的存儲和上面的方案類似,採用最小堆或者紅黑樹即可。然後選擇最近要被觸發的任務的時間距離作爲阻塞調用epoll_wait的超時(也可以使用其他可以設置超時的阻塞調用)。阻塞超時後,依次獲取最小觸發時間戳的任務,超時則執行。

此種方案的最大優點在於不會有空的任務檢查週期,插入和刪除操作的平均時間複雜度和方案一一樣是O(logn)。

實踐中,給DelayQueue實現持久化機制即可。

方案三

採用環形隊列

此方案詳細可以見58沈劍的文章《1分鐘實現“延遲消息”功能》。大體的思路如下:

採用環形隊列,3600個slot,每隔1秒掃描一個slot,檢查當前slot裏面的所有任務,檢查其cycleNum是否爲0, 爲0則觸發,否則cycleNum-1。添加定時事件時,根據掃描指針的當前slot的index和事件觸發的時間,計算cycleNum和要放入的slot。

此種方案的本質是柵格化與預計算,插入和刪除操作的平均時間複雜度爲O(1)。相比起前兩種方案,大大提升了每次獲取可觸發任務的效率。但同樣存在每次查詢任務有可能做無用功的問題。此外,需要特別處理添加任務和掃描任務的臨界點的問題,否則也可能會有時間上的誤差。

Netty中的HashedWheelTimer對於此種方案做了實現。PS: 多謝@imangry提示

此外,需要提到的是Kafka使用的DelayQueue+分層時間輪的方案。這種方案使用DelayQueue避免了空輪訓的問題,同時分層的方式能夠減少每一個slot存放任務過多的問題。詳情可見:Kafka解惑之時間輪(TimingWheel)

方案四

延時消息隊列

目前,RabbitMQ、RocketMQ都支持延時消息隊列。其中,RabbitMQ的實現思路是基於TTL的,詳細可見:http://www.cnblogs.com/haoxinyue/p/6613706.html。而RocketMQ的延遲時間是預定義的,不夠靈活。

此種方案,最大的優勢是使用簡單,且支持ACK機制。但如果要取消定時任務,則需要在業務層實現。

結論

  1. 如果應用中已經有RabbitMQ或者追求任務消費的可靠性,推薦使用RabbitMQ。
  2. 簡單使用,數據量不大,對高可用、任務消費可靠性要求不高的情況下,可以選擇單線程輪詢數據庫或者Redis方案。
  3. 數據量較大、關注消費速度和高可用、對任務消費的可靠性要求不高,推薦使用Jesque/RDelayedQueue。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章