七月在線深度學(xué)習(xí)集訓(xùn)營第三期2022一片孤城萬仞山
如何關(guān)閉到期訂單?
七月在線深度學(xué)習(xí)集訓(xùn)營第三期2022
download:https://www.zxit666.com/5616/
在電子商務(wù)、支付等系統(tǒng)中,一般先創(chuàng)建一個訂單(付款單),然后給用戶一定的時間進(jìn)行支付。如果沒有按時付款,之前的訂單(付款單)需要取消。類似的場景還有很多,到期自動收貨,超時自動退款,下單后自動發(fā)短信等等,都是類似的業(yè)務(wù)問題。
從這樣的業(yè)務(wù)問題出發(fā),討論有哪些技術(shù)解決方案,這些解決方案的實(shí)現(xiàn)細(xì)節(jié),以及相關(guān)的優(yōu)缺點(diǎn)。
由于本文要講的內(nèi)容很多,涉及11個具體方案,由于篇幅所限,本文主要講方案,不會涉及具體的代碼實(shí)現(xiàn)。只要方案明確,代碼實(shí)現(xiàn)并不難。
1.被動關(guān)閉
解決這類問題,有一個比較簡單的辦法,就是通過業(yè)務(wù)的被動方式來關(guān)閉賬單。
簡單來說就是訂單創(chuàng)建之后。我們不會主動關(guān)閉我們系統(tǒng)上的訂單。當(dāng)用戶訪問這個訂單時,我們會判斷時間是否超過了過期時間。如果有,我們將關(guān)閉訂單,然后提示用戶。
這種方法最簡單,基本不需要開發(fā)定時關(guān)閉的功能。但是它的缺點(diǎn)也很明顯,就是如果用戶不一直檢查這個訂單,數(shù)據(jù)庫里會有很多臟數(shù)據(jù)冗余無法關(guān)閉。
還有一個缺點(diǎn),就是需要在用戶查詢過程中進(jìn)行寫操作。通常,寫操作比讀操作花費(fèi)的時間長,并且可能會失敗。一旦平倉單失敗,系統(tǒng)處理起來會更加復(fù)雜。
所以這個方案只適合自學(xué),不建議在任何商業(yè)網(wǎng)站實(shí)現(xiàn)訂單關(guān)閉的功能。
二、計時任務(wù)
很容易想到定期平倉的方案。
具體的實(shí)現(xiàn)細(xì)節(jié)是我們通過一些調(diào)度平臺來實(shí)現(xiàn)預(yù)定的任務(wù)。任務(wù)是掃描所有過期訂單,然后執(zhí)行關(guān)閉操作。
這種方案的優(yōu)點(diǎn)是簡單易行。它可以基于Timer、ScheduledThreadPoolExecutor或類似xxl-job的調(diào)度框架來實(shí)現(xiàn),但它有以下問題:
1.時間不準(zhǔn)確。一般預(yù)定任務(wù)都是按照固定的頻率和時間執(zhí)行的,所以很多訂單可能已經(jīng)到了超時時間,但是預(yù)定任務(wù)的預(yù)定時間還沒有到,就會導(dǎo)致這些訂單的實(shí)際關(guān)閉時間比應(yīng)該的時間要晚。
2.不能處理大訂單。任務(wù)調(diào)度的方式是將原本分散的關(guān)門時間集中到任務(wù)調(diào)度的時間段。如果訂單量大,可能會導(dǎo)致任務(wù)執(zhí)行時間長。整個任務(wù)花費(fèi)的時間越長,訂單掃描的時間就越晚,導(dǎo)致關(guān)門時間也越晚。
3.對數(shù)據(jù)庫的壓力。當(dāng)任務(wù)集中掃描表時,數(shù)據(jù)庫IO會在短時間內(nèi)被占用和消耗。如果隔離做得不好,業(yè)務(wù)量比較大,可能會影響正常的網(wǎng)上業(yè)務(wù)。
4.子數(shù)據(jù)庫和子表的問題。訂單系統(tǒng),一旦訂單量大,可能會考慮分倉分表,分倉分表掃描全表,這是非常推薦的方案。
因此,計劃任務(wù)的方案適用于對時間精度要求不高、業(yè)務(wù)量不大的場景。如果時間精度要求高,業(yè)務(wù)量大,此方案不適用。
第三,JDK自己的延遲隊(duì)列
有這樣一個方案,可以直接基于應(yīng)用本身實(shí)現(xiàn),不需要任何外部資源,也就是基于JDK自己的DelayQueue實(shí)現(xiàn)。
DelayQueue是一個無界的BlockingQueue,用于放置實(shí)現(xiàn)延遲接口的對象,對象只有在過期時才能從隊(duì)列中取出。
基于延遲隊(duì)列,可以延遲訂單的關(guān)閉。首先,當(dāng)用戶創(chuàng)建訂單時,訂單被添加到延遲隊(duì)列中。然后,需要一個常駐任務(wù)不斷地從隊(duì)列中取出那些已經(jīng)超過時間限制的訂單,然后關(guān)閉它們并從隊(duì)列中刪除它們。
這個方案需要有一個線程,不斷從隊(duì)列中取出需要關(guān)閉的訂單。一般需要在這個線程中增加一個while(true)循環(huán),以保證任務(wù)的連續(xù)執(zhí)行,及時取出加班單。
使用DelayQueue實(shí)現(xiàn)超時關(guān)閉的方案實(shí)現(xiàn)簡單,不需要依賴第三方的框架和類庫。JDK天生支持它。
當(dāng)然,這個方案也不是沒有缺點(diǎn)。首先,基于DelayQueue,你需要把訂單放進(jìn)去,訂單量太大可能會導(dǎo)致OOM問題;另外,DelayQueue是基于JVM的內(nèi)存,一旦重啟機(jī)器,里面的數(shù)據(jù)就全沒了。雖然我們可以將它與數(shù)據(jù)庫的持久性結(jié)合使用。而現(xiàn)在很多應(yīng)用都部署在集群中,如何在一個集群中的多個實(shí)例上與多個DelayQueue協(xié)同工作是一個很大的問題。
因此,基于JDK的延遲隊(duì)列方案只適用于單機(jī)場景和數(shù)據(jù)量較小的場景。如果涉及分布式場景,還是不推薦。
第四,內(nèi)蒂的時間輪
還有另一種方式,類似于上面提到的JDK附帶的延遲隊(duì)列,基于時間輪實(shí)現(xiàn)。
為什么會有時間輪?主要原因是DelayQueue-O (nlog (n))中插入和刪除操作的平均時間復(fù)雜度相當(dāng)不錯,但是時間輪方案可以將插入和刪除操作的時間復(fù)雜度降低到O(1)。
時間輪可以理解為環(huán)形結(jié)構(gòu),像時鐘一樣分成多個槽。每個槽代表一個時間段,每個槽可以存儲多個任務(wù),并且使用鏈表結(jié)構(gòu)來存儲在該時間段到期的所有任務(wù)。時間輪通過時針隨著時間一個一個槽的旋轉(zhuǎn),執(zhí)行槽內(nèi)所有到期的任務(wù)。
基于Netty的HashedWheelTimer可以幫助我們快速實(shí)現(xiàn)一個時間輪,類似于DelayQueue。它的缺點(diǎn)是基于內(nèi)存,集群擴(kuò)展麻煩,內(nèi)存有限等等。
但與DelayQueue相比,它的效率更高,任務(wù)觸發(fā)的延遲更低。代碼實(shí)現(xiàn)也更加精簡。
因此,基于Netty的時間輪方案比基于JDK的DelayQueue方案更高效,更容易實(shí)現(xiàn),但同樣,它只能用于數(shù)據(jù)量較小的單機(jī)場景。如果涉及分布式場景,還是不推薦。
動詞 (verb的縮寫)卡夫卡的時間之輪
既然基于Netty的時間輪存在一些問題,那么還有其他的時間輪實(shí)現(xiàn)嗎?
有,就是卡夫卡的時間之輪??ǚ蚩ㄖ杏泻芏嘌舆t操作,比如延遲生產(chǎn)、延遲拉取、延遲數(shù)據(jù)刪除等。這些延遲功能由內(nèi)部的延遲操作管理器專門處理,其底層由時間輪實(shí)現(xiàn)。
而且為了解決一些時間跨度較大的延遲任務(wù),Kafka還引入了分層時間輪,可以更好地控制時間粒度,應(yīng)對更復(fù)雜的定時任務(wù)處理場景;
Kafka中時間輪的實(shí)現(xiàn)是TimingWheel類,它位于kafka.utils.timer包中。基于卡夫卡的時間輪也能得到O(1)時間復(fù)雜度,性能不錯。
基于kafka的時間輪實(shí)現(xiàn)有點(diǎn)復(fù)雜,需要依賴Kafka,但是穩(wěn)定性和性能更高,適合分布式場景。
六。RocketMQ延遲消息
與Kafka相比,RocketMQ有一個強(qiáng)大的功能,就是支持延遲消息。
延遲消息在寫入Broker時,不會立即被消費(fèi)者消費(fèi),只能在等待指定的一段時間后才能被消費(fèi)和處理,這就是所謂的延遲消息。
有了延遲消息,我們可以在訂單創(chuàng)建后發(fā)送延遲消息,比如20分鐘后取消訂單,再發(fā)送延遲20分鐘的延遲消息,然后20分鐘后,消息就被消費(fèi)者消費(fèi)了。收到消息后,消費(fèi)者可以關(guān)閉訂單。
但是RocketMQ的延遲消息不支持任何長度的延遲,只支持1s 50s 30s 1m 2m 4m 6m 8m 9m 10m 30m 1h 2h的長度。(商業(yè)版支持任意時長)
正如您所看到的,有了RocketMQ延遲消息,我們處理它就容易多了。我們只需要發(fā)送和接收消息,系統(tǒng)是完全解耦的。但是,由于延遲時間有限,它不是很靈活。
在我們的業(yè)務(wù)中,如果關(guān)閉時間剛好匹配RocketMQ延遲消息支持的時間,那么可以基于RocketMQ延遲消息實(shí)現(xiàn)。否則,這種方法不是最佳的。(不過在RocketMQ 5.0中新增了基于時間輪的定時消息,可以解決這個問題!)
七。RabbitMQ死信隊(duì)列
消息延遲不僅在RocketMQ中支持,在RabbitMQ中也有實(shí)現(xiàn),但其底層是基于死信隊(duì)列的。
當(dāng)RabbitMQ中的一條正常消息由于壽命到期(TTL過期)、隊(duì)列長度超過限制、被消費(fèi)者拒絕而無法消費(fèi)時,就會變成死消息,也就是一封死信。
當(dāng)消息成為死信時,它可以被重新發(fā)送到死信隊(duì)列(實(shí)際上是交換機(jī))。
然后基于這個機(jī)制,可以實(shí)現(xiàn)延遲消息。也就是說,我們?yōu)橐粭l消息設(shè)置TTL,但不消費(fèi)它。當(dāng)它過期時,它將進(jìn)入死信隊(duì)列,然后我們可以監(jiān)控死信隊(duì)列的消息消耗。
而且RabbitMQ中的TTL可以設(shè)置為任意時長,解決了RocketMQ不靈活的問題。
但是死信隊(duì)列的實(shí)現(xiàn)有一個問題,就是可能會造成隊(duì)列頭被阻塞,因?yàn)殛?duì)列是先進(jìn)先出的,每次只會判斷隊(duì)列頭的消息是否過期。然后,如果隊(duì)列頭的消息持續(xù)時間長,就會阻塞整個隊(duì)列,即使他后面的消息逾期了,也會一直被阻塞。
基于RabbitMQ的死信隊(duì)列可以實(shí)現(xiàn)消息的延遲和關(guān)閉賬單的靈活定時,并借助RabbitMQ的集群可擴(kuò)展性,實(shí)現(xiàn)高可用性和處理大并發(fā)。他的缺點(diǎn)是可能存在消息阻塞,方案復(fù)雜,不僅依賴RabbitMQ,還需要聲明很多隊(duì)列(交換),增加了系統(tǒng)的復(fù)雜度。
八。RabbitMQ插件
其實(shí)基于RabbitMQ,不需要死信隊(duì)列就可以實(shí)現(xiàn)延遲消息,也就是基于rabbit MQ _ delayed _ message _ exchange插件,這個方案可以解決延遲消息通過死信隊(duì)列造成的消息阻塞問題。但是從RabbitMQ的3.6.12開始就支持插件了,所以對版本有要求。
這個插件是官方的,可以放心使用。安裝并啟用該插件后,您可以創(chuàng)建一個x延遲消息隊(duì)列。
前面提到的基于私有消息隊(duì)列的方法是,消息會先投遞到一個正常隊(duì)列,TTL過期后再進(jìn)入死消息隊(duì)列。但是基于插件,消息并不會立即進(jìn)入隊(duì)列,而是保存在Erlang開發(fā)的一個Mnesia數(shù)據(jù)庫中,然后通過定時器查詢需要傳遞的消息,再傳遞到x-delayed-message隊(duì)列中。
基于RabbitMQ插件的方式可以延遲消息,不存在消息阻塞的問題。但是因?yàn)槭腔诓寮模赃@個插件支持的最大延長時間是(2 ^ 32)-1 ms,大概49天左右,之后就會立刻消耗掉。但它是基于RabbitMQ的,所以在易用性和便捷性上表現(xiàn)非常好。
九、Redis過期監(jiān)控
很多用過Redis的人都知道Redis有一個過期的監(jiān)控功能。
在redis.conf中添加一個notify-keyspace-events Ex的配置來啟動過期監(jiān)控,然后在代碼中實(shí)現(xiàn)一個keyexpirationeventmessagelistener,就可以監(jiān)控key的過期消息了。
這樣,當(dāng)收到過期消息時,可以關(guān)閉訂單。
這個方案不建議大家使用,因?yàn)镽edis官網(wǎng)明確說明,Redis不保證密鑰過期后立即刪除,更不保證這個消息可以立即發(fā)送。因此,消息延遲是不可避免的。隨著數(shù)據(jù)量的增加,延遲變長,延遲幾分鐘是常有的事。
而且在Redis 5.0之前,這個消息是通過PUB/SUB模式發(fā)送的,他不會持久化。至于你有沒有收到,他不在乎是不是消費(fèi)成功了。也就是說,如果你的客戶端在發(fā)送消息的時候掛機(jī),然后恢復(fù),你就完全失去了消息。(Redis 5.0以后,因?yàn)镾tream的引入,可以作為延遲消息隊(duì)列。)
X.雷迪斯的zset
雖然基于Redis過期監(jiān)控的方案并不完善,但并不是Redis實(shí)現(xiàn)通關(guān)功能不完善。還有其他方案。
我們可以使用Redis-ZSet中的有序集來實(shí)現(xiàn)這個功能。
Zset是一個有序集合,每個元素(成員)都與一個分?jǐn)?shù)相關(guān)聯(lián),集合中的值可以按分?jǐn)?shù)排序。
我們將訂單超時的時間戳(下單時間+超時時長)和訂單號分別設(shè)置為score和member。這樣redis會根據(jù)分?jǐn)?shù)的延遲時間對zset進(jìn)行排序。然后我們打開redis掃描任務(wù),得到“當(dāng)前時間>比分”的延遲任務(wù),掃描后取出訂單號,然后查詢訂單關(guān)閉訂單。
使用redisSet關(guān)閉訂單的好處是可以利用Redis的持久性和高可用性機(jī)制。避免數(shù)據(jù)丟失。不過這種方案也有一個缺點(diǎn),就是在高并發(fā)場景下,有可能多個消費(fèi)者同時獲得同一個訂單號,一般通過添加分布式鎖來解決,但這樣也會降低吞吐量。
但在大多數(shù)業(yè)務(wù)場景中,如果冪等做得好,多個消費(fèi)者得到同一個訂單號也沒關(guān)系。
XI。Redis+Redis
上面的方案看起來不錯,但是我們需要基于數(shù)據(jù)結(jié)構(gòu)zset編寫自己的代碼。有沒有更友好的方式?
是的,那是基于雷迪森。
Redisson是一個基于Redis的框架,它不僅提供了一系列分布式Java公共對象,還提供了許多分布式服務(wù)。
分布式延遲隊(duì)列RDelayedQueue是在Redission中定義的,它是基于我們前面介紹的zset結(jié)構(gòu)的延遲隊(duì)列。它允許元素以指定的延遲持續(xù)時間被放置在目標(biāo)隊(duì)列中。
實(shí)際上是在zset的基礎(chǔ)上增加了一個基于內(nèi)存的延遲隊(duì)列。當(dāng)我們要向延遲隊(duì)列中添加一個數(shù)據(jù)時,redission會將data+timeout放入zset,并啟動一個延遲任務(wù)。當(dāng)任務(wù)到期后,我們會去zset取出數(shù)據(jù),返回給客戶端。
這是總的思路。感興趣的可以看看RDelayedQueue的具體實(shí)現(xiàn)。
基于Redisson的實(shí)現(xiàn)方法可以解決zset方案中的并發(fā)和重復(fù)問題,實(shí)現(xiàn)方法相對簡單,具有較高的穩(wěn)定性和性能。
摘要
我們介紹了11種實(shí)現(xiàn)訂單及時關(guān)閉的方案,其中不同的方案各有優(yōu)缺點(diǎn),也適用于不同的場景。讓我們試著總結(jié)一下:
關(guān)于實(shí)現(xiàn)的復(fù)雜性(包括所用框架的依賴和部署):
Redission > RabbitMQ插件> RabbitMQ死信隊(duì)列> RocketMQ延遲消息≈Redis的Zset > Redis過期監(jiān)控≈ kafka時間輪>計劃任務(wù)> Netty的時間輪> JDK自己的DelayQueue >被動關(guān)機(jī)
方案的完整性:
紅刊≈ RabbitMQ插件> kafka時間輪> zset≈rocket MQ Redis的延遲消息≈ Rabbit MQ死信隊(duì)列> Redis過期監(jiān)控>定時任務(wù)> Netty的時間輪> JDK自己的DelayQueue >被動關(guān)機(jī)
不同的場景也適用于不同的方案:
自己玩:被動關(guān)機(jī)
單一應(yīng)用程序,小業(yè)務(wù)量:Netty的時間輪,JDK自己的延遲隊(duì)列,預(yù)定任務(wù)。
分布式應(yīng)用,業(yè)務(wù)量小:Redis過期監(jiān)控,RabbitMQ死信隊(duì)列,Redis zset,調(diào)度任務(wù)。
大業(yè)務(wù)量高并發(fā)的分布式應(yīng)用:Redission、RabbitMQ插件、kafka時間輪、RocketMQ延時消息
總體來說,考慮到第三方框架的成本、完備性、復(fù)雜性、普及性,個人比較建議優(yōu)先考慮redision+Redis、RabbitMQ插件、Redis的zset、RocketMQ延遲消息等方案。