領導:誰再用 Redis 實現過期訂單關閉,立馬滾蛋!
由於 Redis 具有過期監聽的功能,於是就有人拿它來實現過期訂單關閉,但是這個方案並不完美。今天來聊聊 11 種實現訂單定時關閉的方案,總有一種適合你!
在電商、支付等系統中,一般都是先創建訂單(支付單),再給用戶一定的時間進行支付,如果沒有按時支付的話,就需要把之前的訂單(支付單)取消掉。
這種類似的場景有很多,還有比如到期自動收貨、超時自動退款、下單後自動發送短信等等都是類似的業務問題。
本文就從這樣的業務問題出發,探討一下都有哪些技術方案,這些方案的實現細節,以及相關的優缺點都有什麼?
因爲本文要講的內容比較多,涉及到 11 種具體方案,受篇幅限制,這篇文章主要是講方案,不會涉及到具體的代碼實現。 因爲只要方案搞清楚了,代碼實現不是難事兒。
一、被動關閉
在解決這類問題的時候,有一種比較簡單的方式,那就是通過業務上的被動方式來進行關單操作。
簡單點說,就是訂單創建好了之後。我們系統上不做主動關單,什麼時候用戶來訪問這個訂單了,再去判斷時間是不是超過了過期時間,如果過了時間那就進行關單操作,然後再提示用戶。
這種做法是最簡單的,基本不需要開發定時關閉的功能,但是他的缺點也很明顯,那就是如果用戶一直不來查看這個訂單,那麼就會有很多髒數據冗餘在數據庫中一直無法被關單。
還有一個缺點,那就是需要在用戶的查詢過程中進行寫的操作,一般寫操作都會比讀操作耗時更長,而且有失敗的可能,一旦關單失敗了,就會導致系統處理起來比較複雜。
所以,這種方案只適合於自己學習的時候用,任何商業網站中都不建議使用這種方案來實現訂單關閉的功能。
二、定時任務
定時任務關閉訂單,這是很容易想到的一種方案。
具體實現細節就是我們通過一些調度平臺來實現定時執行任務,任務就是去掃描所有到期的訂單,然後執行關單動作。
這個方案的優點也是比較簡單,實現起來很容易,基於 Timer、ScheduledThreadPoolExecutor、或者像 xxl-job 這類調度框架都能實現,但是有以下幾個問題:
1、時間不精準。 一般定時任務基於固定的頻率、按照時間定時執行的,那麼就可能會發生很多訂單已經到了超時時間,但是定時任務的調度時間還沒到,那麼就會導致這些訂單的實際關閉時間要比應該關閉的時間晚一些。
2、無法處理大訂單量。 定時任務的方式是會把本來比較分散的關閉時間集中到任務調度的那一段時間,如果訂單量比較大的話,那麼就可能導致任務執行時間很長,整個任務的時間越長,訂單被掃描到時間可能就很晚,那麼就會導致關閉時間更晚。
3、對數據庫造成壓力。 定時任務集中掃表,這會使得數據庫 IO 在短時間內被大量佔用和消耗,如果沒有做好隔離,並且業務量比較大的話,就可能會影響到線上的正常業務。
4、分庫分表問題。 訂單系統,一旦訂單量大就可能會考慮分庫分表,在分庫分表中進行全表掃描,這是一個極不推薦的方案。
所以,定時任務的方案,適合於對時間精確度要求不高、並且業務量不是很大的場景中。如果對時間精度要求比較高,並且業務量很大的話,這種方案不適用。
三、JDK 自帶的延遲隊列
有這樣一種方案,他不需要藉助任何外部的資源,直接基於應用自身就能實現,那就是基於 JDK 自帶的 DelayQueue 來實現
DelayQueue 是一個無界的 BlockingQueue,用於放置實現了 Delayed 接口的對象,其中的對象只能在其到期時才能從隊列中取走。
基於延遲隊列,是可以實現訂單的延遲關閉的,首先,在用戶創建訂單的時候,把訂單加入到 DelayQueue 中,然後,還需要一個常駐任務不斷的從隊列中取出那些到了超時時間的訂單,然後在把他們進行關單,之後再從隊列中刪除掉。
這個方案需要有一個線程,不斷的從隊列中取出需要關單的訂單。一般在這個線程中需要加一個 while(true) 循環,這樣才能確保任務不斷的執行並且能夠及時的取出超時訂單。
使用 DelayQueue 實現超時關單的方案,實現起來簡單,不須要依賴第三方的框架和類庫,JDK 原生就支持了。
當然這個方案也不是沒有缺點的,首先,基於 DelayQueue 的話,需要把訂單放進去,那如果訂單量太大的話,可能會導致 OOM 的問題;另外,DelayQueue 是基於 JVM 內存的,一旦機器重啓了,裏面的數據就都沒有了。雖然我們可以配合數據庫的持久化一起使用。而且現在很多應用都是集羣部署的,那麼集羣中多個實例上的多個 DelayQueue 如何配合是一個很大的問題。
所以,基於 JDK 的 DelayQueue 方案只適合在單機場景、並且數據量不大的場景中使用,如果涉及到分佈式場景,那還是不建議使用。
四、Netty 的時間輪
還有一種方式,和上面我們提到的 JDK 自帶的 DelayQueue 類似的方式,那就是基於時間輪實現。
爲什麼要有時間輪呢?主要是因爲 DelayQueue 插入和刪除操作的平均時間複雜度——O(nlog(n)),雖然已經挺好的了,但是時間輪的方案可以將插入和刪除操作的時間複雜度都降爲 O(1)。
時間輪可以理解爲一種環形結構,像鐘錶一樣被分爲多個 slot。每個 slot 代表一個時間段,每個 slot 中可以存放多個任務,使用的是鏈表結構保存該時間段到期的所有任務。時間輪通過一個時針隨着時間一個個 slot 轉動,並執行 slot 中的所有到期任務。
基於 Netty 的 HashedWheelTimer 可以幫助我們快速的實現一個時間輪,這種方式和 DelayQueue 類似,缺點都是基於內存、集羣擴展麻煩、內存有限制等等。
但是他相比 DelayQueue 的話,效率更高一些,任務觸發的延遲更低。代碼實現上面也更加精簡。
所以,基於 Netty 的時間輪方案比基於 JDK 的 DelayQueue 效率更高,實現起來更簡單,但是同樣的,只適合在單機場景、並且數據量不大的場景中使用,如果涉及到分佈式場景,那還是不建議使用。
五、Kafka 的時間輪
既然基於 Netty 的時間輪存在一些問題,那麼有沒有其他的時間輪的實現呢?
還真有的,那就是 Kafka 的時間輪,Kafka 內部有很多延時性的操作,如延時生產,延時拉取,延時數據刪除等,這些延時功能由內部的延時操作管理器來做專門的處理,其底層是採用時間輪實現的。
#而且,爲了解決有一些時間跨度大的延時任務,Kafka 還引入了層級時間輪,能更好控制時間粒度,可以應對更加複雜的定時任務處理場景;
Kafka 中的時間輪的實現是 TimingWheel 類,位於 kafka.utils.timer 包中。基於 Kafka 的時間輪同樣可以得到 O(1) 時間複雜度,性能上還是不錯的。
基於 Kafka 的時間輪的實現方式,在實現方式上有點複雜,需要依賴 kafka,但是他的穩定性和性能都要更高一些,而且適合用在分佈式場景中。
六、RocketMQ 延遲消息
相比於 Kafka 來說,RocketMQ 中有一個強大的功能,那就是支持延遲消息。
延遲消息,當消息寫入到 Broker 後,不會立刻被消費者消費,需要等待指定的時長後纔可被消費處理的消息,稱爲延時消息。
有了延遲消息,我們就可以在訂單創建好之後,發送一個延遲消息,比如 20 分鐘取消訂單,那就發一個延遲 20 分鐘的延遲消息,然後在 20 分鐘之後,消息就會被消費者消費,消費者在接收到消息之後,去關單就行了。
但是,RocketMQ 的延遲消息並不是支持任意時長的延遲的,它只支持:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 這幾個時長。(商業版支持任意時長)
可以看到,有了 RocketMQ 延遲消息之後,我們處理上就簡單很多,只需要發消息,和接收消息就行了,系統之間完全解耦了。但是因爲延遲消息的時長受到了限制,所以並不是很靈活。
如果我們的業務上,關單時長剛好和 RocketMQ 延遲消息支持的時長匹配的話,那麼是可以基於 RocketMQ 延遲消息來實現的。否則,這種方式並不是最佳的。
七、RabbitMQ 死信隊列
延遲消息不僅在 RocketMQ 中支持,其實在 RabbitMQ 中也是可以實現的,只不過其底層是基於死信隊列實現的。
當 RabbitMQ 中的一條正常的消息,因爲過了存活時間(TTL 過期)、隊列長度超限、被消費者拒絕等原因無法被消費時,就會變成 Dead Message,即死信。
當一個消息變成死信之後,他就能被重新發送到死信隊列中(其實是交換機 - exchange)。
那麼基於這樣的機制,就可以實現延遲消息了。那就是我們給一個消息設定 TTL,然但是並不消費這個消息,等他過期,過期後就會進入到死信隊列,然後我們再監聽死信隊列的消息消費就行了。
而且,RabbitMQ 中的這個 TTL 是可以設置任意時長的,這就解決了 RocketMQ 的不靈活的問題。
但是,死信隊列的實現方式存在一個問題,那就是可能造成隊頭阻塞,因爲隊列是先進先出的,而且每次只會判斷隊頭的消息是否過期,那麼,如果隊頭的消息時間很長,一直都不過期,那麼就會阻塞整個隊列,這時候即使排在他後面的消息過期了,那麼也會被一直阻塞。
基於 RabbitMQ 的死信隊列,可以實現延遲消息,非常靈活的實現定時關單,並且藉助 RabbitMQ 的集羣擴展性,可以實現高可用,以及處理大併發量。他的缺點第一是可能存在消息阻塞的問題,還有就是方案比較複雜,不僅要依賴 RabbitMQ,而且還需要聲明很多隊列 (exchange) 出來,增加系統的複雜度
八、RabbitMQ 插件
其實,基於 RabbitMQ 的話,可以不用死信隊列也能實現延遲消息,那就是基於 rabbitmq_delayed_message_exchange 插件,這種方案能夠解決通過死信隊列實現延遲消息出現的消息阻塞問題。但是該插件從 RabbitMQ 的 3.6.12 開始支持的,所以對版本有要求。
這個插件是官方出的,可以放心使用,安裝並啓用這個插件之後,就可以創建 x-delayed-message 類型的隊列了。
前面我們提到的基於私信隊列的方式,是消息先會投遞到一個正常隊列,在 TTL 過期後進入死信隊列。但是基於插件的這種方式,消息並不會立即進入隊列,而是先把他們保存在一個基於 Erlang 開發的 Mnesia 數據庫中,然後通過一個定時器去查詢需要被投遞的消息,再把他們投遞到 x-delayed-message 隊列中。
基於 RabbitMQ 插件的方式可以實現延遲消息,並且不存在消息阻塞的問題,但是因爲是基於插件的,而這個插件支持的最大延長時間是 (2^32)-1 毫秒,大約 49 天,超過這個時間就會被立即消費。但是他基於 RabbitMQ 實現,所以在可用性、性能方便都很不錯
九、Redis 過期監聽
很多用過 Redis 的人都知道,Redis 有一個過期監聽的功能,
在 redis.conf 中,加入一條配置 notify-keyspace-events Ex 開啓過期監聽,然後再代碼中實現一個 KeyExpirationEventMessageListener,就可以監聽 key 的過期消息了。
這樣就可以在接收到過期消息的時候,進行訂單的關單操作。
這個方案不建議大家使用,是因爲 Redis 官網上明確的說過,Redis 並不保證 Key 在過期的時候就能被立即刪除,更不保證這個消息能被立即發出。所以,消息延遲是必然存在的,隨着數據量越大延遲越長,延遲個幾分鐘都是常事兒。
而且,在 Redis 5.0 之前,這個消息是通過 PUB/SUB 模式發出的,他不會做持久化,至於你有沒有接到,有沒有消費成功,他不管。也就是說,如果發消息的時候,你的客戶端掛了,之後再恢復的話,這個消息你就徹底丟失了。(在 Redis 5.0 之後,因爲引入了 Stream,是可以用來做延遲消息隊列的。)
十、Redis 的 zset
雖然基於 Redis 過期監聽的方案並不完美,但是並不是 Redis 實現關單功能就不完美了,還有其他的方案。
我們可以藉助 Redis 中的有序集合——zset 來實現這個功能。
zset 是一個有序集合,每一個元素 (member) 都關聯了一個 score,可以通過 score 排序來取集合中的值。
我們將訂單超時時間的時間戳(下單時間 + 超時時長)與訂單號分別設置爲 score 和 member。這樣 redis 會對 zset 按照 score 延時時間進行排序。然後我們再開啓 redis 掃描任務,獲取” 當前時間 > score” 的延時任務,掃描到之後取出訂單號,然後查詢到訂單進行關單操作即可。
使用 redis zset 來實現訂單關閉的功能的優點是可以藉助 redis 的持久化、高可用機制。避免數據丟失。但是這個方案也有缺點,那就是在高併發場景中,有可能有多個消費者同時獲取到同一個訂單號,一般採用加分佈式鎖解決,但是這樣做也會降低吞吐型。
但是,在大多數業務場景下,如果冪等性做得好的,多個消費者取到同一個訂單號也無妨。
十一、Redisson
上面這種方案看上去還不錯,但是需要我們自己基於 zset 這種數據結構編寫代碼,那麼有沒有什麼更加友好的方式?
有的,那就是基於 Redisson。
Redisson 是一個在 Redis 的基礎上實現的框架,它不僅提供了一系列的分佈式的 Java 常用對象,還提供了許多分佈式服務。
Redission 中定義了分佈式延遲隊列 RDelayedQueue,這是一種基於我們前面介紹過的 zset 結構實現的延時隊列,它允許以指定的延遲時長將元素放到目標隊列中。
其實就是在 zset 的基礎上增加了一個基於內存的延遲隊列。當我們要添加一個數據到延遲隊列的時候,redission 會把數據 + 超時時間放到 zset 中,並且起一個延時任務,當任務到期的時候,再去 zset 中把數據取出來,返回給客戶端使用。
大致思路就是這樣的,感興趣的大家可以看一看 RDelayedQueue 的具體實現。
基於 Redisson 的實現方式,是可以解決基於 zset 方案中的併發重複問題的,而且還能實現方式也比較簡單,穩定性、性能都比較高。
總結
我們介紹了 11 種實現訂單定時關閉的方案,其中不同的方案各自都有優缺點,也各自適用於不同的場景中。那我們嘗試着總結一下:
實現的複雜度上(包含用到的框架的依賴及部署):
Redission > RabbitMQ 插件 > RabbitMQ 死信隊列 > RocketMQ 延遲消息 ≈ Redis 的 zset > Redis 過期監聽 ≈ kafka 時間輪 > 定時任務 > Netty 的時間輪 > JDK 自帶的 DelayQueue > 被動關閉
方案的完整性:
Redission ≈ RabbitMQ 插件 > kafka 時間輪 > Redis 的 zset ≈ RocketMQ 延遲消息 ≈ RabbitMQ 死信隊列 > Redis 過期監聽 > 定時任務 > Netty 的時間輪 > JDK 自帶的 DelayQueue > 被動關閉
不同的場景中也適合不同的方案:
-
自己玩玩:被動關閉
-
單體應用,業務量不大:Netty 的時間輪、JDK 自帶的 DelayQueue、定時任務
-
分佈式應用,業務量不大:Redis 過期監聽、RabbitMQ 死信隊列、Redis 的 zset、定時任務
-
分佈式應用,業務量大、併發高:Redission、RabbitMQ 插件、kafka 時間輪、RocketMQ 延遲消息
總體考慮的話,考慮到成本,方案完整性、以及方案的複雜度,還有用到的第三方框架的流行度來說,個人比較建議優先考慮 Redission+Redis、RabbitMQ 插件、Redis 的 zset、RocketMQ 延遲消息等方案。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/rWAXIYl91C2aFjdCvYmntw