再有人問你如何實(shí)現(xiàn)訂單到期關(guān)閉,就把這篇文章發(fā)給他!
?在電商、支付等系統(tǒng)中,一般都是先創(chuàng)建訂單(支付單),再給用戶一定的時(shí)間進(jìn)行支付,如果沒有按時(shí)支付的話,就需要把之前的訂單(支付單)取消掉。
這種類似的場景有很多,還有比如到期自動(dòng)收貨、超時(shí)自動(dòng)退款、下單后自動(dòng)發(fā)送短信等等都是類似的業(yè)務(wù)問題。
本文就從這樣的業(yè)務(wù)問題出發(fā),探討一下都有哪些技術(shù)方案,這些方案的實(shí)現(xiàn)細(xì)節(jié),以及相關(guān)的優(yōu)缺點(diǎn)都有什么?
因?yàn)楸疚囊v的內(nèi)容比較多,涉及到11種具體方案,受篇幅限制,這篇文章主要是講方案,不會涉及到具體的代碼實(shí)現(xiàn)。 因?yàn)橹灰桨父闱宄耍a實(shí)現(xiàn)不是難事兒。
一、被動(dòng)關(guān)閉
在解決這類問題的時(shí)候,有一種比較簡單的方式,那就是通過業(yè)務(wù)上的被動(dòng)方式來進(jìn)行關(guān)單操作。
簡單點(diǎn)說,就是訂單創(chuàng)建好了之后。我們系統(tǒng)上不做主動(dòng)關(guān)單,什么時(shí)候用戶來訪問這個(gè)訂單了,再去判斷時(shí)間是不是超過了過期時(shí)間,如果過了時(shí)間那就進(jìn)行關(guān)單操作,然后再提示用戶。

這種做法是最簡單的,基本不需要開發(fā)定時(shí)關(guān)閉的功能,但是他的缺點(diǎn)也很明顯,那就是如果用戶一直不來查看這個(gè)訂單,那么就會有很多臟數(shù)據(jù)冗余在數(shù)據(jù)庫中一直無法被關(guān)單。
還有一個(gè)缺點(diǎn),那就是需要在用戶的查詢過程中進(jìn)行寫的操作,一般寫操作都會比讀操作耗時(shí)更長,而且有失敗的可能,一旦關(guān)單失敗了,就會導(dǎo)致系統(tǒng)處理起來比較復(fù)雜。
所以,這種方案只適合于自己學(xué)習(xí)的時(shí)候用,任何商業(yè)網(wǎng)站中都不建議使用這種方案來實(shí)現(xiàn)訂單關(guān)閉的功能。
二、定時(shí)任務(wù)
定時(shí)任務(wù)關(guān)閉訂單,這是很容易想到的一種方案。
具體實(shí)現(xiàn)細(xì)節(jié)就是我們通過一些調(diào)度平臺來實(shí)現(xiàn)定時(shí)執(zhí)行任務(wù),任務(wù)就是去掃描所有到期的訂單,然后執(zhí)行關(guān)單動(dòng)作。

這個(gè)方案的優(yōu)點(diǎn)也是比較簡單,實(shí)現(xiàn)起來很容易,基于Timer、ScheduledThreadPoolExecutor、或者像xxl-job這類調(diào)度框架都能實(shí)現(xiàn),但是有以下幾個(gè)問題:
1、時(shí)間不精準(zhǔn)。 一般定時(shí)任務(wù)基于固定的頻率、按照時(shí)間定時(shí)執(zhí)行的,那么就可能會發(fā)生很多訂單已經(jīng)到了超時(shí)時(shí)間,但是定時(shí)任務(wù)的調(diào)度時(shí)間還沒到,那么就會導(dǎo)致這些訂單的實(shí)際關(guān)閉時(shí)間要比應(yīng)該關(guān)閉的時(shí)間晚一些。
2、無法處理大訂單量。 定時(shí)任務(wù)的方式是會把本來比較分散的關(guān)閉時(shí)間集中到任務(wù)調(diào)度的那一段時(shí)間,如果訂單量比較大的話,那么就可能導(dǎo)致任務(wù)執(zhí)行時(shí)間很長,整個(gè)任務(wù)的時(shí)間越長,訂單被掃描到時(shí)間可能就很晚,那么就會導(dǎo)致關(guān)閉時(shí)間更晚。
3、對數(shù)據(jù)庫造成壓力。 定時(shí)任務(wù)集中掃表,這會使得數(shù)據(jù)庫IO在短時(shí)間內(nèi)被大量占用和消耗,如果沒有做好隔離,并且業(yè)務(wù)量比較大的話,就可能會影響到線上的正常業(yè)務(wù)。
4、分庫分表問題。 訂單系統(tǒng),一旦訂單量大就可能會考慮分庫分表,在分庫分表中進(jìn)行全表掃描,這是一個(gè)極不推薦的方案。
所以,定時(shí)任務(wù)的方案,適合于對時(shí)間精確度要求不高、并且業(yè)務(wù)量不是很大的場景中。如果對時(shí)間精度要求比較高,并且業(yè)務(wù)量很大的話,這種方案不適用。
三、JDK自帶的延遲隊(duì)列
有這樣一種方案,他不需要借助任何外部的資源,直接基于應(yīng)用自身就能實(shí)現(xiàn),那就是基于JDK自帶的DelayQueue來實(shí)現(xiàn)
DelayQueue是一個(gè)無界的BlockingQueue,用于放置實(shí)現(xiàn)了Delayed接口的對象,其中的對象只能在其到期時(shí)才能從隊(duì)列中取走。
基于延遲隊(duì)列,是可以實(shí)現(xiàn)訂單的延遲關(guān)閉的,首先,在用戶創(chuàng)建訂單的時(shí)候,把訂單加入到DelayQueue中,然后,還需要一個(gè)常駐任務(wù)不斷的從隊(duì)列中取出那些到了超時(shí)時(shí)間的訂單,然后在把他們進(jìn)行關(guān)單,之后再從隊(duì)列中刪除掉。
這個(gè)方案需要有一個(gè)線程,不斷的從隊(duì)列中取出需要關(guān)單的訂單。一般在這個(gè)線程中需要加一個(gè)while(true)循環(huán),這樣才能確保任務(wù)不斷的執(zhí)行并且能夠及時(shí)的取出超時(shí)訂單。
使用DelayQueue實(shí)現(xiàn)超時(shí)關(guān)單的方案,實(shí)現(xiàn)起來簡單,必須要依賴第三方的框架和類庫,JDK原生就支持了。
當(dāng)然這個(gè)方案也不是沒有缺點(diǎn)的,首先,基于DelayQueue的話,需要把訂單放進(jìn)去,那如果訂單量太大的話,可能會導(dǎo)致OOM的問題;另外,DelayQueue是基于JVM內(nèi)存的,一旦機(jī)器重啟了,里面的數(shù)據(jù)就都沒有了。雖然我們可以配合數(shù)據(jù)庫的持久化一起使用。而且現(xiàn)在很多應(yīng)用都是集群部署的,那么集群中多個(gè)實(shí)例上的多個(gè)DelayQueue如何配合是一個(gè)很大的問題。
所以,基于JDK的DelayQueue方案只適合在單機(jī)場景、并且數(shù)據(jù)量不大的場景中使用,如果涉及到分布式場景,那還是不建議使用。
四、Netty的時(shí)間輪
還有一種方式,和上面我們提到的JDK自帶的DelayQueue類似的方式,那就是基于時(shí)間輪實(shí)現(xiàn)。
為什么要有時(shí)間輪呢?主要是因?yàn)镈elayQueue插入和刪除操作的平均時(shí)間復(fù)雜度——O(nlog(n)),雖然已經(jīng)挺好的了,但是時(shí)間輪的方案可以將插入和刪除操作的時(shí)間復(fù)雜度都降為O(1)。
時(shí)間輪可以理解為一種環(huán)形結(jié)構(gòu),像鐘表一樣被分為多個(gè) slot。每個(gè) slot 代表一個(gè)時(shí)間段,每個(gè) slot 中可以存放多個(gè)任務(wù),使用的是鏈表結(jié)構(gòu)保存該時(shí)間段到期的所有任務(wù)。時(shí)間輪通過一個(gè)時(shí)針隨著時(shí)間一個(gè)個(gè) slot 轉(zhuǎn)動(dòng),并執(zhí)行 slot 中的所有到期任務(wù)。

基于Netty的HashedWheelTimer可以幫助我們快速的實(shí)現(xiàn)一個(gè)時(shí)間輪,這種方式和DelayQueue類似,缺點(diǎn)都是基于內(nèi)存、集群擴(kuò)展麻煩、內(nèi)存有限制等等。
但是他相比DelayQueue的話,效率更高一些,任務(wù)觸發(fā)的延遲更低。代碼實(shí)現(xiàn)上面也更加精簡。
所以,基于Netty的時(shí)間輪方案比基于JDK的DelayQueue效率更高,實(shí)現(xiàn)起來更簡單,但是同樣的,只適合在單機(jī)場景、并且數(shù)據(jù)量不大的場景中使用,如果涉及到分布式場景,那還是不建議使用。
五、Kafka的時(shí)間輪
既然基于Netty的時(shí)間輪存在一些問題,那么有沒有其他的時(shí)間輪的實(shí)現(xiàn)呢?
還真有的,那就是Kafka的時(shí)間輪,Kafka內(nèi)部有很多延時(shí)性的操作,如延時(shí)生產(chǎn),延時(shí)拉取,延時(shí)數(shù)據(jù)刪除等,這些延時(shí)功能由內(nèi)部的延時(shí)操作管理器來做專門的處理,其底層是采用時(shí)間輪實(shí)現(xiàn)的。
而且,為了解決有一些時(shí)間跨度大的延時(shí)任務(wù),Kafka 還引入了層級時(shí)間輪,能更好控制時(shí)間粒度,可以應(yīng)對更加復(fù)雜的定時(shí)任務(wù)處理場景;
Kafka 中的時(shí)間輪的實(shí)現(xiàn)是 TimingWheel 類,位于 kafka.utils.timer 包中。基于Kafka的時(shí)間輪同樣可以得到O(1)時(shí)間復(fù)雜度,性能上還是不錯(cuò)的。
基于Kafka的時(shí)間輪的實(shí)現(xiàn)方式,在實(shí)現(xiàn)方式上有點(diǎn)復(fù)雜,需要依賴kafka,但是他的穩(wěn)定性和性能都要更高一些,而且適合用在分布式場景中。
六、RocketMQ延遲消息
相比于Kafka來說,RocketMQ中有一個(gè)強(qiáng)大的功能,那就是支持延遲消息。
延遲消息,當(dāng)消息寫入到Broker后,不會立刻被消費(fèi)者消費(fèi),需要等待指定的時(shí)長后才可被消費(fèi)處理的消息,稱為延時(shí)消息。
有了延遲消息,我們就可以在訂單創(chuàng)建好之后,發(fā)送一個(gè)延遲消息,比如20分鐘取消訂單,那就發(fā)一個(gè)延遲20分鐘的延遲消息,然后在20分鐘之后,消息就會被消費(fèi)者消費(fèi),消費(fèi)者在接收到消息之后,去關(guān)單就行了。
但是,RocketMQ的延遲消息并不是支持任意時(shí)長的延遲的,它只支持:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h這幾個(gè)時(shí)長。(商業(yè)版支持任意時(shí)長)
可以看到,有了RocketMQ延遲消息之后,我們處理上就簡單很多,只需要發(fā)消息,和接收消息就行了,系統(tǒng)之間完全解耦了。但是因?yàn)檠舆t消息的時(shí)長受到了限制,所以并不是很靈活。
如果我們的業(yè)務(wù)上,關(guān)單時(shí)長剛好和RocketMQ延遲消息支持的時(shí)長匹配的話,那么是可以基于RocketMQ延遲消息來實(shí)現(xiàn)的。否則,這種方式并不是最佳的。
七、RabbitMQ死信隊(duì)列
延遲消息不僅在RocketMQ中支持,其實(shí)在RabbitMQ中也是可以實(shí)現(xiàn)的,只不過其底層是基于死信隊(duì)列實(shí)現(xiàn)的。
當(dāng)RabbitMQ中的一條正常的消息,因?yàn)檫^了存活時(shí)間(TTL過期)、隊(duì)列長度超限、被消費(fèi)者拒絕等原因無法被消費(fèi)時(shí),就會變成Dead Message,即死信。
當(dāng)一個(gè)消息變成死信之后,他就能被重新發(fā)送到死信隊(duì)列中(其實(shí)是交換機(jī)-exchange)。
那么基于這樣的機(jī)制,就可以實(shí)現(xiàn)延遲消息了。那就是我們給一個(gè)消息設(shè)定TTL,然但是并不消費(fèi)這個(gè)消息,等他過期,過期后就會進(jìn)入到死信隊(duì)列,然后我們再監(jiān)聽死信隊(duì)列的消息消費(fèi)就行了。
而且,RabbitMQ中的這個(gè)TTL是可以設(shè)置任意時(shí)長的,這就解決了RocketMQ的不靈活的問題。
但是,死信隊(duì)列的實(shí)現(xiàn)方式存在一個(gè)問題,那就是可能造成隊(duì)頭阻塞,因?yàn)殛?duì)列是先進(jìn)先出的,而且每次只會判斷隊(duì)頭的消息是否過期,那么,如果隊(duì)頭的消息時(shí)間很長,一直都不過期,那么就會阻塞整個(gè)隊(duì)列,這時(shí)候即使排在他后面的消息過期了,那么也會被一直阻塞。
基于RabbitMQ的死信隊(duì)列,可以實(shí)現(xiàn)延遲消息,非常靈活的實(shí)現(xiàn)定時(shí)關(guān)單,并且借助RabbitMQ的集群擴(kuò)展性,可以實(shí)現(xiàn)高可用,以及處理大并發(fā)量。他的缺點(diǎn)第一是可能存在消息阻塞的問題,還有就是方案比較復(fù)雜,不僅要依賴RabbitMQ,而且還需要聲明很多隊(duì)列(exchange)出來,增加系統(tǒng)的復(fù)雜度
八、RabbitMQ插件
其實(shí),基于RabbitMQ的話,可以不用死信隊(duì)列也能實(shí)現(xiàn)延遲消息,那就是基于rabbitmq_delayed_message_exchange插件,這種方案能夠解決通過死信隊(duì)列實(shí)現(xiàn)延遲消息出現(xiàn)的消息阻塞問題。但是該插件從RabbitMQ的3.6.12開始支持的,所以對版本有要求。

這個(gè)插件是官方出的,可以放心使用,安裝并啟用這個(gè)插件之后,就可以創(chuàng)建x-delayed-message類型的隊(duì)列了。
前面我們提到的基于私信隊(duì)列的方式,是消息先會投遞到一個(gè)正常隊(duì)列,在TTL過期后進(jìn)入死信隊(duì)列。但是基于插件的這種方式,消息并不會立即進(jìn)入隊(duì)列,而是先把他們保存在一個(gè)基于Erlang開發(fā)的Mnesia數(shù)據(jù)庫中,然后通過一個(gè)定時(shí)器去查詢需要被投遞的消息,再把他們投遞到x-delayed-message隊(duì)列中。
基于RabbitMQ插件的方式可以實(shí)現(xiàn)延遲消息,并且不存在消息阻塞的問題,但是因?yàn)槭腔诓寮模@個(gè)插件支持的最大延長時(shí)間是(2^32)-1 毫秒,大約49天,超過這個(gè)時(shí)間就會被立即消費(fèi)。但是他基于RabbitMQ實(shí)現(xiàn),所以在可用性、性能方便都很不錯(cuò)
九、Redis過期監(jiān)聽
很多用過Redis的人都知道,Redis有一個(gè)過期監(jiān)聽的功能,
在 redis.conf 中,加入一條配置notify-keyspace-events Ex開啟過期監(jiān)聽,然后再代碼中實(shí)現(xiàn)一個(gè)KeyExpirationEventMessageListener,就可以監(jiān)聽key的過期消息了。
這樣就可以在接收到過期消息的時(shí)候,進(jìn)行訂單的關(guān)單操作。
這個(gè)方案不建議大家使用,是因?yàn)镽edis官網(wǎng)上明確的說過,Redis并不保證Key在過期的時(shí)候就能被立即刪除,更不保證這個(gè)消息能被立即發(fā)出。所以,消息延遲是必然存在的,隨著數(shù)據(jù)量越大延遲越長,延遲個(gè)幾分鐘都是常事兒。?
而且,在Redis 5.0之前,這個(gè)消息是通過PUB/SUB模式發(fā)出的,他不會做持久化,至于你有沒有接到,有沒有消費(fèi)成功,他不管。也就是說,如果發(fā)消息的時(shí)候,你的客戶端掛了,之后再恢復(fù)的話,這個(gè)消息你就徹底丟失了。(在Redis 5.0之后,因?yàn)橐肓薙tream,是可以用來做延遲消息隊(duì)列的。)
十、Redis的zset
雖然基于Redis過期監(jiān)聽的方案并不完美,但是并不是Redis實(shí)現(xiàn)關(guān)單功能就不完美了,還有其他的方案。
我們可以借助Redis中的有序集合——zset來實(shí)現(xiàn)這個(gè)功能。
zset是一個(gè)有序集合,每一個(gè)元素(member)都關(guān)聯(lián)了一個(gè) score,可以通過 score 排序來取集合中的值。
我們將訂單超時(shí)時(shí)間的時(shí)間戳(下單時(shí)間+超時(shí)時(shí)長)與訂單號分別設(shè)置為 score 和 member。這樣redis會對zset按照score延時(shí)時(shí)間進(jìn)行排序。然后我們再開啟redis掃描任務(wù),獲取”當(dāng)前時(shí)間 > score”的延時(shí)任務(wù),掃描到之后取出訂單號,然后查詢到訂單進(jìn)行關(guān)單操作即可。
使用redis zset來實(shí)現(xiàn)訂單關(guān)閉的功能的優(yōu)點(diǎn)是可以借助redis的持久化、高可用機(jī)制。避免數(shù)據(jù)丟失。但是這個(gè)方案也有缺點(diǎn),那就是在高并發(fā)場景中,有可能有多個(gè)消費(fèi)者同時(shí)獲取到同一個(gè)訂單號,一般采用加分布式鎖解決,但是這樣做也會降低吞吐型。?
但是,在大多數(shù)業(yè)務(wù)場景下,如果冪等性做得好的,多個(gè)消費(fèi)者取到同一個(gè)訂單號也無妨。
十一、Redisson
上面這種方案看上去還不錯(cuò),但是需要我們自己基于zset這種數(shù)據(jù)結(jié)構(gòu)編寫代碼,那么有沒有什么更加友好的方式?
有的,那就是基于Redisson。
Redisson是一個(gè)在Redis的基礎(chǔ)上實(shí)現(xiàn)的框架,它不僅提供了一系列的分布式的Java常用對象,還提供了許多分布式服務(wù)。

Redission中定義了分布式延遲隊(duì)列RDelayedQueue,這是一種基于我們前面介紹過的zset結(jié)構(gòu)實(shí)現(xiàn)的延時(shí)隊(duì)列,它允許以指定的延遲時(shí)長將元素放到目標(biāo)隊(duì)列中。
其實(shí)就是在zset的基礎(chǔ)上增加了一個(gè)基于內(nèi)存的延遲隊(duì)列。當(dāng)我們要添加一個(gè)數(shù)據(jù)到延遲隊(duì)列的時(shí)候,redission會把數(shù)據(jù)+超時(shí)時(shí)間放到zset中,并且起一個(gè)延時(shí)任務(wù),當(dāng)任務(wù)到期的時(shí)候,再去zset中把數(shù)據(jù)取出來,返回給客戶端使用。
大致思路就是這樣的,感興趣的大家可以看一看RDelayedQueue的具體實(shí)現(xiàn)。
基于Redisson的實(shí)現(xiàn)方式,是可以解決基于zset方案中的并發(fā)重復(fù)問題的,而且還能實(shí)現(xiàn)方式也比較簡單,穩(wěn)定性、性能都比較高。
總結(jié)
我們介紹了11種實(shí)現(xiàn)訂單定時(shí)關(guān)閉的方案,其中不同的方案各自都有優(yōu)缺點(diǎn),也各自適用于不同的場景中。那我們嘗試著總結(jié)一下:
實(shí)現(xiàn)的復(fù)雜度上(包含用到的框架的依賴及部署):?
Redission > RabbitMQ插件 > RabbitMQ死信隊(duì)列 > RocketMQ延遲消息 ≈ Redis的zset > Redis過期監(jiān)聽 ≈ kafka時(shí)間輪 > 定時(shí)任務(wù) > Netty的時(shí)間輪 > JDK自帶的DelayQueue > 被動(dòng)關(guān)閉
方案的完整性:?
Redission ≈ RabbitMQ插件 > kafka時(shí)間輪 > Redis的zset ≈ RocketMQ延遲消息 ≈ RabbitMQ死信隊(duì)列 > Redis過期監(jiān)聽 > 定時(shí)任務(wù) > Netty的時(shí)間輪 > JDK自帶的DelayQueue > 被動(dòng)關(guān)閉
不同的場景中也適合不同的方案:?
- 自己玩玩:被動(dòng)關(guān)閉
- 單體應(yīng)用,業(yè)務(wù)量不大:Netty的時(shí)間輪、JDK自帶的DelayQueue、定時(shí)任務(wù)
- 分布式應(yīng)用,業(yè)務(wù)量不大:Redis過期監(jiān)聽、RabbitMQ死信隊(duì)列、Redis的zset、定時(shí)任務(wù)
- 分布式應(yīng)用,業(yè)務(wù)量大、并發(fā)高:Redission、RabbitMQ插件、kafka時(shí)間輪、RocketMQ延遲消息
總體考慮的話,考慮到成本,方案完整性、以及方案的復(fù)雜度,還有用到的第三方框架的流行度來說,個(gè)人比較建議優(yōu)先考慮Redission+Redis、RabbitMQ插件、Redis的zset、RocketMQ延遲消息等方案。?

























