當前位置: 妍妍網 > 碼農

11種方案,實作訂單到期關閉

2024-03-30碼農

架構師(JiaGouX)

我們都是架構師!
架構未來,你來不來?


在電商 、支付等系統中,一般都是先建立訂單(支付單),再給使用者一定的時間進行支付,如果沒有按時支付的話,就需要把之前的訂單(支付單)取消掉。

這種類似的場景有很多,還有比如到期自動收貨、超時自動退款、下單後自動發送簡訊等等都是類似的業務問題。

本文就從這樣的業務問題出發,探討一下都有哪些技術方案,這些方案的實作細節,以及相關的優缺點都有什麽?

因為本文要講的內容比較多,涉及到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延遲訊息等方案。

    如喜歡本文,請點選右上角,把文章分享到朋友圈
    如有想了解學習的技術點,請留言給若飛安排分享

    因公眾號更改推播規則,請點「在看」並加「星標」 第一時間獲取精彩技術分享

    ·END·

    相關閱讀:

    作者:Hollis

    來源:Hollis

    版權申明:內容來源網路,僅供學習研究,版權歸原創者所有。如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!

    架構師

    我們都是架構師!

    關註 架構師(JiaGouX),添加「星標」

    獲取每天技術幹貨,一起成為牛逼架構師

    技術群請 加若飛: 1321113940 進架構師群

    投稿、合作、版權等信箱: [email protected]