定時(shí)器(Timer)是一種在業(yè)務(wù)開(kāi)發(fā)中常用的組件,主要用在執(zhí)行延時(shí)通知任務(wù)上。本文以筆者在微信工作中的實(shí)踐作為基礎(chǔ),介紹如何使用平時(shí)部門(mén)最常用的組件快速實(shí)現(xiàn)一個(gè)業(yè)務(wù)常用的分布式定時(shí)器服務(wù)。同時(shí)介紹了過(guò)程中遇到問(wèn)題的一些解決方案,希望能夠給類(lèi)似場(chǎng)景提供一些解決思路。
定時(shí)器(Timer)是一種在指定時(shí)間開(kāi)始執(zhí)行某一任務(wù)的工具(也有周期性反復(fù)執(zhí)行某一任務(wù)的Timer,我們這里暫不討論)。它常常與延遲隊(duì)列這一概念關(guān)聯(lián)。 那么在什么場(chǎng)景下我才需要使用定時(shí)器呢?
我們先看看以下業(yè)務(wù)場(chǎng)景:
【資料圖】
當(dāng)訂單一直處于未支付狀態(tài)時(shí),如何及時(shí)的關(guān)閉訂單,并退還庫(kù)存?如何定期檢查處于退款狀態(tài)的訂單是否已經(jīng)退款成功?新創(chuàng)建店鋪,N天內(nèi)沒(méi)有上傳商品,系統(tǒng)如何知道該信息,并發(fā)送激活短信?為了解決以上問(wèn)題,最簡(jiǎn)單直接的辦法就是定時(shí)去掃表。每個(gè)業(yè)務(wù)都要維護(hù)一個(gè)自己的掃表邏輯。 當(dāng)業(yè)務(wù)越來(lái)越多時(shí),我們會(huì)發(fā)現(xiàn)掃表部分的邏輯會(huì)非常類(lèi)似。我們可以考慮將這部分邏輯從具體的業(yè)務(wù)邏輯里面抽出來(lái),變成一個(gè)公共的部分。這個(gè)時(shí)候定時(shí)器就出場(chǎng)了。
一個(gè)定時(shí)器本質(zhì)上是這樣的一個(gè)數(shù)據(jù)結(jié)構(gòu):deadline越近的任務(wù)擁有越高優(yōu)先級(jí),提供以下幾種基本操作:
Add 新增任務(wù)Delete 刪除任務(wù)Run 執(zhí)行到期的任務(wù)/到期通知對(duì)應(yīng)業(yè)務(wù)處理Update 更新到期時(shí)間 (可選)Run通常有兩種工作方式:
1.輪詢(xún)
每隔一個(gè)時(shí)間片就去查找哪些任務(wù)已經(jīng)到期;
2.睡眠/喚醒
不停地查找deadline最近的任務(wù),如到期則執(zhí)行;否則sleep直到其到期。
在sleep期間,如果有任務(wù)被Add或Delete,則deadline最近的任務(wù)有可能改變,線(xiàn)程會(huì)被喚醒并重新進(jìn)行1的邏輯。
它的設(shè)計(jì)目標(biāo)通常包含以下幾點(diǎn)要求:
支持任務(wù)提交(消息發(fā)布)、任務(wù)刪除、任務(wù)通知(消息訂閱)等基本功能。消息傳輸可靠性:消息進(jìn)入延遲隊(duì)列以后,保證至少被消費(fèi)一次(到期通知保證At-least-once ,追求Exactly-once)。數(shù)據(jù)可靠性:數(shù)據(jù)需要持久化,防止丟失。高可用性:至少得支持多實(shí)例部署。掛掉一個(gè)實(shí)例后,還有后備實(shí)例繼續(xù)提供服務(wù),可橫向擴(kuò)展。實(shí)時(shí)性:盡最大努力準(zhǔn)時(shí)交付信息,允許存在一定的時(shí)間誤差,誤差范圍可控。下面我們談?wù)劧〞r(shí)器的數(shù)據(jù)結(jié)構(gòu)。定時(shí)器通常與延遲隊(duì)列密不可分,延時(shí)隊(duì)列是什么?顧名思義它是一種帶有延遲功能的消息隊(duì)列。而延遲隊(duì)列底層通??梢圆捎靡韵聨追N數(shù)據(jù)結(jié)構(gòu)之一來(lái)實(shí)現(xiàn):
有序鏈表,這個(gè)最直觀,最好理解。堆,應(yīng)用實(shí)例如Java JDK中的DelayQueue、Go內(nèi)置的定時(shí)器等。時(shí)間輪/多級(jí)時(shí)間輪,應(yīng)用實(shí)例如Linux內(nèi)核定時(shí)器、Netty工具類(lèi)HashedWheelTimer、Kafka內(nèi)部定時(shí)器等。這里重點(diǎn)介紹一下時(shí)間輪(TimeWheel)。一個(gè)時(shí)間輪是一個(gè)環(huán)形結(jié)構(gòu),可以想象成時(shí)鐘,分為很多格子,一個(gè)格子代表一段時(shí)間(越短Timer精度越高),并用一個(gè)List保存在該格子上到期的所有任務(wù),同時(shí)一個(gè)指針隨著時(shí)間流逝一格一格轉(zhuǎn)動(dòng),并執(zhí)行對(duì)應(yīng)List中所有到期的任務(wù)。任務(wù)通過(guò)取模決定應(yīng)該放入哪個(gè)格子。示意圖如下所示:
如果任務(wù)的時(shí)間跨度很大,數(shù)量也多,傳統(tǒng)的單輪時(shí)間輪會(huì)造成任務(wù)的round很大,單個(gè)格子的任務(wù)List很長(zhǎng),并會(huì)維持很長(zhǎng)一段時(shí)間。這時(shí)可將Wheel按時(shí)間粒度分級(jí)(與水表的思想很像),示意圖如下所示:
時(shí)間輪是一種比較優(yōu)雅的實(shí)現(xiàn)方式,且如果采用多級(jí)時(shí)間輪時(shí)其效率也是比較高的。
業(yè)界對(duì)于定時(shí)器/延時(shí)隊(duì)列的工程實(shí)踐,則通常基于以下幾種方案來(lái)實(shí)現(xiàn):
基于Redis ZSet實(shí)現(xiàn)。采用某些自帶延時(shí)選項(xiàng)的隊(duì)列實(shí)現(xiàn),如RabbitMQ、Beanstalkd、騰訊TDMQ等?;赥iming-Wheel時(shí)間輪算法實(shí)現(xiàn)。其中《你真的知道怎么實(shí)現(xiàn)一個(gè)延遲隊(duì)列嗎?》一文詳細(xì)介紹了具體的實(shí)現(xiàn)方式,大家有興趣可以閱讀下。
介紹完定時(shí)器的背景知識(shí),接下來(lái)看下我們系統(tǒng)的實(shí)現(xiàn)。我們先看一下需求背景。在我們組的實(shí)際業(yè)務(wù)中,有延遲任務(wù)的需求。一種典型的應(yīng)用場(chǎng)景是:商戶(hù)發(fā)起扣費(fèi)請(qǐng)求后,立刻為用戶(hù)下發(fā)扣費(fèi)前通知,24小時(shí)后完成扣費(fèi);或者發(fā)券給用戶(hù),3天后通知用戶(hù)券過(guò)期?;谶@種需求背景,我們引出了定時(shí)器的開(kāi)發(fā)需求。
我們首先調(diào)研了公司內(nèi)外的定時(shí)器實(shí)現(xiàn),避免重復(fù)造輪子。調(diào)研了諸如例如公司外部的Quartz、有贊的延時(shí)隊(duì)列等,以及公司內(nèi)部的PCG tikker、TDMQ等,以及微信支付內(nèi)部包括營(yíng)銷(xiāo)、代扣、支付分等團(tuán)隊(duì)的一些實(shí)現(xiàn)方案。最后從可用性、可靠性、易用性、時(shí)效性以及代碼風(fēng)格、運(yùn)維代價(jià)等角度考慮,我們決定參考前人的一些優(yōu)秀的技術(shù)方案,并根據(jù)我們團(tuán)隊(duì)的技術(shù)積累和組件情況,設(shè)計(jì)和實(shí)現(xiàn)一套定時(shí)器方案。
首先要確定定時(shí)器的存儲(chǔ)數(shù)據(jù)結(jié)構(gòu)。這里借鑒了時(shí)間輪的思想,基于微信團(tuán)隊(duì)最常用的分布式存儲(chǔ)組件tablekv進(jìn)行任務(wù)的持久化存儲(chǔ)。使用到tablekv的原因是它天然支持按uin分表,分表數(shù)可以做到千萬(wàn)級(jí)別以上;其次其單表支持的記錄數(shù)非常高,讀寫(xiě)效率也很高,還可以如mysql一樣按指定的條件篩選任務(wù)。
我們的目標(biāo)是實(shí)現(xiàn)秒級(jí)時(shí)間戳精度,任務(wù)到期只需要單次通知業(yè)務(wù)方。故我們方案主要的思路是基于tablekv按任務(wù)執(zhí)行時(shí)間分表,也就是使用使用方指定的start_time(時(shí)間戳)作為分表的uin,也即是時(shí)間輪bucket。為什么不使用多輪時(shí)間輪?主要是因?yàn)槭紫萲v支持單表上億數(shù)據(jù), 其二kv分表數(shù)可以非常多,例如我們使用1000萬(wàn)個(gè)分表需要約115天的間隔才會(huì)被哈希分配到同一分表內(nèi)。故暫時(shí)不需要使用到多輪時(shí)間輪。
最終我們采用的分表數(shù)為1000w,uin=時(shí)間戳mod分表數(shù)。這里有一個(gè)注意點(diǎn),通過(guò)mod分表數(shù)進(jìn)行Key收斂, 是為了避免時(shí)間戳遞增導(dǎo)致的key無(wú)限擴(kuò)張的問(wèn)題。示例圖如下所示:
任務(wù)持久化存儲(chǔ)之后,我們采用一個(gè)Daemon程序執(zhí)行定期掃表任務(wù),將到期的任務(wù)取出,最后將請(qǐng)求中帶的業(yè)務(wù)信息(biz_data添加任務(wù)時(shí)帶來(lái),定時(shí)器透?jìng)?,不關(guān)注其具體內(nèi)容)回調(diào)通知業(yè)務(wù)方。這么一看流程還是很簡(jiǎn)單的。
這里掃描的流程類(lèi)似上面講的時(shí)間輪算法,會(huì)有一個(gè)指針(我們?cè)谶@里不妨稱(chēng)之為time_pointer)不斷向后移動(dòng),保證不會(huì)漏掉任何一個(gè)bucket的任務(wù)。這里我們采用的是commkv(可以簡(jiǎn)單理解為可以按照key-value形式讀寫(xiě)的kv,其底層仍是基于tablekv實(shí)現(xiàn))存儲(chǔ)CurrentTime,也就是當(dāng)前處理到的時(shí)間戳。每次輪詢(xún)時(shí)Daemon都會(huì)通過(guò)GetByKey接口獲取到CurrentTime,若大于當(dāng)前機(jī)器時(shí)間,則sleep一段時(shí)間。若小于等于當(dāng)前機(jī)器時(shí)間,則取出tablekv中以CurrentTime為uin的分表的TaskList進(jìn)行處理。本次輪詢(xún)結(jié)束,則CurrentTime加一,再通過(guò)SetByKey設(shè)置回commkv。這個(gè)部分的工作模式我們可以簡(jiǎn)稱(chēng)為Scheduler。
Scheduler拿到任務(wù)后只需要回調(diào)通知業(yè)務(wù)方即可。如果采用同步通知業(yè)務(wù)方的方式,由于業(yè)務(wù)方的超時(shí)情況是不可控的,則一個(gè)任務(wù)的投遞時(shí)間可能會(huì)較長(zhǎng),導(dǎo)致拖慢這個(gè)時(shí)間點(diǎn)的任務(wù)整體通知進(jìn)度。故而這里自然而然想到采用異步解耦的方式。即將任務(wù)發(fā)布至事件中心(微信內(nèi)部的高可用、高可靠的消息平臺(tái),支持事務(wù)和非事務(wù)消息。由于一個(gè)任務(wù)的投遞到事件中心的時(shí)間僅為幾十ms,理論上任務(wù)量級(jí)不大時(shí)1s內(nèi)都可以處理完。此時(shí)time_pointer會(huì)緊跟當(dāng)前時(shí)間戳。當(dāng)大量任務(wù)需要處理時(shí),需要采用多線(xiàn)程/多協(xié)程的方式并發(fā)處理,保證任務(wù)的準(zhǔn)時(shí)交付。broker訂閱事件中心的消息,接受到消息后由broker回調(diào)通知業(yè)務(wù)方,故broker也充當(dāng)了Notifier的角色。整體架構(gòu)圖如下所示:
主要模塊包括:
任務(wù)掃描Daemon:充當(dāng)Scheduler的角色。掃描所有到期任務(wù),投遞到事件中心,讓它通知broker,由broker的Notifier通知業(yè)務(wù)方。
定時(shí)器broker:集業(yè)務(wù)接入、Notifier兩者功能于一身。
任務(wù)狀態(tài)機(jī)圖如下所示,只有兩種狀態(tài)。當(dāng)任務(wù)插入kv成功時(shí)即為pending狀態(tài),當(dāng)任務(wù)成功被取出并通知業(yè)務(wù)方成功時(shí)即為finish狀態(tài)。
下面就上面的方案涉及的幾個(gè)技術(shù)細(xì)節(jié)進(jìn)行進(jìn)一步的解釋。
通過(guò)biz_type定義不同的業(yè)務(wù)類(lèi)型,不同的biz_type可以定義不同的優(yōu)先級(jí)(目前暫未支持),任務(wù)中保存biz_type信息。
業(yè)務(wù)信息(主鍵為biz_type)采用配置中心進(jìn)行配置管理。方便新業(yè)務(wù)的接入和配置變更。業(yè)務(wù)接入時(shí),需要在配置中添加諸如回調(diào)通知信息、回調(diào)重試次數(shù)限制、回調(diào)限頻等參數(shù)。業(yè)務(wù)隔離的目的在于使各個(gè)接入業(yè)務(wù)不受其他業(yè)務(wù)的影響,這一點(diǎn)由于目前我們的定時(shí)器用于支持本團(tuán)隊(duì)內(nèi)部業(yè)務(wù)的特點(diǎn),僅采取對(duì)不同的業(yè)務(wù)執(zhí)行不同業(yè)務(wù)限頻規(guī)則的策略,并未做太多優(yōu)化工作,就不詳述了。
由于1000w分表,肯定是大部分Bucket為空,時(shí)間輪的指針推進(jìn)存在低效問(wèn)題。聯(lián)想到在飯店排號(hào)時(shí),常有店員來(lái)登記現(xiàn)場(chǎng)尚存的號(hào)碼,就是因?yàn)榭梢蕴^(guò)一些號(hào)碼,加快叫號(hào)進(jìn)度。同理,為了減少這種“空推進(jìn)”,Kafka引入了DelayQueue,以bucket為單位入隊(duì),每當(dāng)有bucket到期,即queue.poll能拿到結(jié)果時(shí),才進(jìn)行時(shí)間的“推進(jìn)”,減少了線(xiàn)程空轉(zhuǎn)的開(kāi)銷(xiāo)。在這里類(lèi)似的,我們也可以做一個(gè)優(yōu)化,維護(hù)一個(gè)有序隊(duì)列,保存表不為空的時(shí)間戳。大家可以思考一下如何實(shí)現(xiàn),具體方案不再詳述。
由于定時(shí)器需要寫(xiě)kv,還需要回調(diào)通知業(yè)務(wù)方。因此需要考慮對(duì)調(diào)用下游服務(wù)做限頻,保證下游服務(wù)不會(huì)雪崩。這是一個(gè)分布式限頻的問(wèn)題。這里使用到的是微信支付的限頻組件。保證1.任務(wù)插入時(shí)不超過(guò)定時(shí)器管理員配置的頻率。 2.Notifier回調(diào)通知業(yè)務(wù)方時(shí)不超過(guò)業(yè)務(wù)方申請(qǐng)接入時(shí)配置的頻率。這里保證了1.kv和事件中心不會(huì)壓力太大。2.下游業(yè)務(wù)方不會(huì)受到超過(guò)其處理能力的請(qǐng)求量的沖擊。
出于容災(zāi)的目的,我們希望Daemon具有容災(zāi)能力。換言之若有Daemon實(shí)例異常掛起或退出,其他機(jī)器的實(shí)例進(jìn)程可以繼續(xù)執(zhí)行任務(wù)。但同時(shí)我們又希望同一時(shí)刻只需要一個(gè)實(shí)例運(yùn)行,即“分布式單實(shí)例”。所以我們完整的需求可以歸納為“分布式單實(shí)例容災(zāi)部署”。
實(shí)現(xiàn)這一目標(biāo),方式有很多種,例如:
接入“調(diào)度中心”,由調(diào)度中心來(lái)負(fù)責(zé)調(diào)度各個(gè)機(jī)器各節(jié)點(diǎn)在執(zhí)行任務(wù)前先分布式搶鎖,只有成功占用鎖資源的節(jié)點(diǎn)才能執(zhí)行任務(wù)各節(jié)點(diǎn)通過(guò)通信選出“master"來(lái)執(zhí)行邏輯,并通過(guò)心跳包持續(xù)通信,若“master”掉線(xiàn),則備機(jī)取代成為master繼續(xù)執(zhí)行主要從開(kāi)發(fā)成本,運(yùn)維支撐兩方面來(lái)考慮,選取了基于chubby分布式鎖的方案來(lái)實(shí)現(xiàn)單實(shí)例容災(zāi)部署。這也使得我們真正執(zhí)行業(yè)務(wù)邏輯的機(jī)器具有隨機(jī)性。
這是一個(gè)核心問(wèn)題,如何保證任務(wù)的通知滿(mǎn)足At-least-once的要求?
我們系統(tǒng)主要通過(guò)以下兩種方式來(lái)保證。
1.任務(wù)達(dá)到時(shí)即存入tablekv持久化存儲(chǔ),任務(wù)成功通知業(yè)務(wù)方才設(shè)置過(guò)期(保留一段時(shí)間后刪除),故而所有任務(wù)都是落地?cái)?shù)據(jù),保證事后可以對(duì)賬。
2.引入可靠事件中心。在這里使用的是事件中心的普通消息,而非事務(wù)消息。實(shí)質(zhì)是當(dāng)做一個(gè)高可用性的消息隊(duì)列。
這里引入消息隊(duì)列的意義在于:
將任務(wù)調(diào)度和任務(wù)執(zhí)行解耦(調(diào)度服務(wù)并不需要關(guān)心任務(wù)執(zhí)行結(jié)果)。異步化,保證調(diào)度服務(wù)的高效執(zhí)行,調(diào)度服務(wù)的執(zhí)行是以ms為單位。借助消息隊(duì)列實(shí)現(xiàn)任務(wù)的可靠消費(fèi)。事件中心相比普通的消息隊(duì)列還具有哪些優(yōu)點(diǎn)呢?
某些消息隊(duì)列可能丟消息(由其實(shí)現(xiàn)機(jī)制決定),而事件中心本身底層的分布式架構(gòu),使得事件中心保證極高的可用性和可靠性,基本可以忽略丟消息的情況。事件中心支持按照配置的不同事件梯度進(jìn)行多次重試(回調(diào)時(shí)間可以配置)。事件中心可以根據(jù)自定義業(yè)務(wù)ID進(jìn)行消息去重。事件中心的引入,基本保證了任務(wù)從Scheduler到Notifier的可靠性。
當(dāng)然,最為完備的方式,是增加另一個(gè)異步Daemon作為兜底策略,掃出所有超時(shí)還未交付的任務(wù)進(jìn)行投遞。這里思路較為簡(jiǎn)單,不再詳述。
若同一時(shí)間點(diǎn)有大量任務(wù)需要處理,如果采用串行發(fā)布至事件中心,則仍可能導(dǎo)致任務(wù)的回調(diào)通知不及時(shí)。這里自然而然想到采用多線(xiàn)程/多協(xié)程的方式并發(fā)處理。在本系統(tǒng)中,我們使用到了微信的BatchTask庫(kù),BatchTask是這樣一個(gè)庫(kù),它把每一個(gè)需要并發(fā)執(zhí)行的RPC任務(wù)封裝成一個(gè)函數(shù)閉包(返回值+執(zhí)行函數(shù)+參數(shù)),然后調(diào)度協(xié)程(BatchTask的底層協(xié)程為libco)去執(zhí)行這些任務(wù)。對(duì)于已有的同步函數(shù),可以很方便的通過(guò)BatchTask的Api去實(shí)現(xiàn)任務(wù)的批量執(zhí)行。Daemon將發(fā)布事件的任務(wù)提交到BatchTask創(chuàng)建的線(xiàn)程池+協(xié)程池(線(xiàn)程和協(xié)程數(shù)可以根據(jù)參數(shù)調(diào)整)中,充分利用流水線(xiàn)和并發(fā),可以將任務(wù)List處理的整體時(shí)延大大縮短,盡最大努力及時(shí)通知業(yè)務(wù)方。
從節(jié)省存儲(chǔ)資源考慮,任務(wù)通知業(yè)務(wù)成功后應(yīng)當(dāng)刪除。但刪除應(yīng)該是一個(gè)異步的過(guò)程,因?yàn)檫€需要保留一段時(shí)間方便查詢(xún)?nèi)罩镜?。這種情況,通常的實(shí)現(xiàn)方式是啟動(dòng)一個(gè)Daemon異步刪除已完成的任務(wù)。我們系統(tǒng)中,是利用了tablekv的自動(dòng)刪除機(jī)制,回調(diào)通知業(yè)務(wù)完成后,除了設(shè)置任務(wù)狀態(tài)為完成外,同時(shí)通過(guò)tablekv的update接口設(shè)置kv的過(guò)期時(shí)間為1個(gè)月,避免了異步Daemon掃表刪除任務(wù),簡(jiǎn)化了實(shí)現(xiàn)。
1.由于time_pointer的CurrentTime初始值置為首次運(yùn)行的Daemon實(shí)例的機(jī)器時(shí)間,而每次輪詢(xún)時(shí)都會(huì)對(duì)比當(dāng)前Daemon實(shí)例的機(jī)器時(shí)間與CurrentTime的差別,故機(jī)器時(shí)間出錯(cuò)可能會(huì)影響任務(wù)的正常調(diào)度。這里考慮到現(xiàn)網(wǎng)機(jī)器均有時(shí)間校正腳本在跑,這個(gè)問(wèn)題基本可以忽略。
2.本系統(tǒng)的架構(gòu)對(duì)微信事件中心構(gòu)成了強(qiáng)依賴(lài)。定時(shí)器的可用性和可靠性依賴(lài)于事件中心的可用性和可靠性。雖然目前事件中心的可用性和可靠性都非常高,但如果要考慮所有異常情況,則事件中心的短暫不可用、或者對(duì)于訂閱者消息出隊(duì)的延遲和堆積,都是需要正視的問(wèn)題。一個(gè)解決方案是使用MQ做雙鏈路的消息投遞,解決對(duì)于事件中心單點(diǎn)依賴(lài)的問(wèn)題。
這里的定時(shí)器服務(wù)目前僅用于支持境外的定時(shí)器需求,調(diào)用量級(jí)尚不大,已可滿(mǎn)足業(yè)務(wù)基本要求。如果要支撐更高的任務(wù)量級(jí),還需要做更多的思考和優(yōu)化。隨時(shí)歡迎大家和和我交流探討。
微信境外支付團(tuán)隊(duì)在不斷追求卓越的路上尋找同路人,歡迎加入我們的團(tuán)隊(duì)。
責(zé)任編輯: