新型計劃任務(wù):以接口形式實(shí)現(xiàn)的計劃任務(wù)

2018-11-21 21:19 更新

1.31.1 這里所說的計劃任務(wù)

計劃任務(wù)主要負(fù)責(zé)處理一些耗時的操作,或者非用戶觸發(fā)的作業(yè)。

有些人會稱它為后臺任務(wù),或者推送作業(yè),又或者定時任務(wù)。這時則統(tǒng)稱為:計劃任務(wù)。

例如,當(dāng)你發(fā)布一條微信朋友圈后需要通知上百個好友時;當(dāng)一條后臺的推薦資訊需要推送到每個用戶的客戶端時;當(dāng)需要將本地的靜態(tài)資源如圖片同步到CDN時。
顯然這些動則需要分鐘級別的操作,不應(yīng)該在客戶端調(diào)用接口時同步處理(但讓我驚訝的是現(xiàn)實(shí)真的有人會這么做?。?,又或者非用戶觸發(fā)而需要后臺處理(但更讓我驚訝的是竟然也有系統(tǒng)是在用戶請求時附帶進(jìn)行處理,而且還是國內(nèi)某個知名的會員中心!)。

這里不僅僅是提供實(shí)現(xiàn)計劃任務(wù)的約束和機(jī)制,更多的是引導(dǎo)大家更好地應(yīng)對此類問題。

1.31.2 計劃任務(wù)的關(guān)鍵環(huán)節(jié)

(1)觸發(fā)

首先,是何時何地由何用戶產(chǎn)生一條待執(zhí)行的計劃任務(wù),我們可以把這個場景點(diǎn)稱為一個觸發(fā)點(diǎn)。
通常的做法,我們會先紀(jì)錄下此觸發(fā)點(diǎn)的場景信息,并放入到一個隊(duì)列里面,以便等待計劃任務(wù)消費(fèi)。

(2)調(diào)度

其次,是通過何種機(jī)制進(jìn)行計劃任務(wù)的調(diào)度。
這里不僅有技術(shù)層面的問題,還有業(yè)務(wù)的問題,如每次批量處理多少,間隔多少,是否需要失敗重試等等?

(3)消費(fèi)

最后,則是具體的計劃任務(wù)執(zhí)行,以完成必要的操作,也稱為消費(fèi)。
很多傳統(tǒng)的做法,都是把這些操作和接口混在一起的,而這里,PhalApi則會以一種更為明朗的方式來實(shí)現(xiàn),從而自底而上,支持更多的調(diào)度方式和觸發(fā)機(jī)制。

1.31.3 傳統(tǒng)的計劃任務(wù)

a pic

如果以一圖而鱉之,上圖雖然簡化,但可以很好地說明傳統(tǒng)計劃任務(wù)的結(jié)構(gòu)體系。
即:很多項(xiàng)目都是使用內(nèi)嵌的方式來包含計劃任務(wù),這樣明顯會把接口服務(wù)系統(tǒng)和后臺計劃任務(wù)混在一起,增加了系統(tǒng)間的耦合性。
雖然小項(xiàng)目可以忍受或者適合這種混合,但是出于長遠(yuǎn)考慮,進(jìn)行有意識地分解還是很有好處的。

而且這種混合潛意識下又讓開發(fā)人員不加判斷就進(jìn)行調(diào)用,這會嚴(yán)重增加接口的反應(yīng)時間。
我曾目睹一個接口耗時了近36秒之久,在對這個舊系統(tǒng)的接口進(jìn)行一番排查后,原來是這個接口在發(fā)布后對上百個好友做了通知推送導(dǎo)致產(chǎn)生了上百條insert語句。

(1)傳統(tǒng)的調(diào)度方式

我們重點(diǎn)關(guān)注一下傳統(tǒng)計劃任務(wù)的調(diào)度方式,在過去,我們通常會有兩種方式:一種是啟動死循環(huán)的進(jìn)程,另一種是啟動一個crontab之類的定時任務(wù)。
當(dāng)然,上述的在接口請求時同步進(jìn)行調(diào)度也算一種方式,但不是正規(guī)的做法。

如果采用死循環(huán)的方式,我們還需要考慮代碼更新升級后,對腳本的重啟,以便載入新的代碼。如果是sh循環(huán)調(diào)用PHP腳本,則可以忽略。

1.31.4 新型的計劃任務(wù)

(1)以接口的形式提供計劃任務(wù)服務(wù)

PhalApi中最具特色的做法是,將計劃任務(wù)的執(zhí)行消費(fèi)實(shí)現(xiàn),以接口形式來提供。
這樣的好處在于,我們作為接口開發(fā)人員,可以以熟悉的方式來進(jìn)行計劃任務(wù)的開發(fā)。
但更大的得益在于,將計劃任務(wù)通過接口的形式提供后,我們會看到更為廣闊的使用場景:我們可以使用MQ隊(duì)列消費(fèi),可以同步請求也可以異步請求。

(2)系統(tǒng)架構(gòu)

我們所做的,不僅僅只是把原來混合型的代碼作簡單分解,如下:a pic

而是以一種更為正統(tǒng)的做法,為此我們添加了一些必要的節(jié)點(diǎn)來設(shè)計此構(gòu)架。新的實(shí)現(xiàn)方式下的體系結(jié)構(gòu)如下:
a pic

節(jié)點(diǎn)說明

在上圖中,應(yīng)用節(jié)點(diǎn)還是我們的接口系統(tǒng);MQ隊(duì)列則是用于存放待消費(fèi)的場景信息,同其他的MQ一樣;計劃任務(wù)則可以分為兩部分,API接口實(shí)現(xiàn)和任務(wù)調(diào)度。
計劃任務(wù)這兩部分,物理部署上可以合在一起,也可以分開,這取決于應(yīng)用系統(tǒng)是采用分布式的做法,還是單一的服務(wù)器。

執(zhí)行流程

由上圖可以看出,一個完整的計劃任務(wù)流程為:

  • 1、應(yīng)用產(chǎn)生一條新的計劃任務(wù),并存放于MQ隊(duì)列
  • 2、計劃任務(wù)定時或者不停掃描新的計劃任務(wù);若有,則進(jìn)行調(diào)度
  • 3、計劃任務(wù)API完成需要的工作,并將結(jié)果返回調(diào)度器

(3)單個添加,批量處理

這里只支持單個MQ添加,而處理則是批量的,且每批處理的數(shù)據(jù)可指定配置。

(4)MQ共享

無論是分布式還是本地一體化,MQ隊(duì)列都應(yīng)該是可以共享訪問的,以便為應(yīng)用節(jié)點(diǎn)、計劃任務(wù)調(diào)度節(jié)點(diǎn)所訪問,如下圖所示:a pic

首選redis MQ

因?yàn)镸Q作為頻繁讀寫的媒介,應(yīng)該優(yōu)先使用高效緩存來提高系統(tǒng)的吞吐率以及增加并發(fā)的能力。此外,作為臨時一次性的數(shù)據(jù),使用高效緩存也是大有好處的(但我們也需要考慮到數(shù)據(jù)丟失的情況)。
而且,為了支持 單個添加,批量處理,第三方緩存應(yīng)該很好地支持隊(duì)列的操作。
所以,redis是一個不錯的選擇。

如下,是redis簡單的隊(duì)列操作:

$redis = new Redis();
$redis->connect('127.0.0.1', 6300);

$redis->lpush('test_key', 'www');
$redis->lpush('test_key', 'phalapi');
$redis->lpush('test_key', 'net');

echo $redis->lpop('test_key'), "\n";
echo $redis->lpop('test_key'), "\n";
echo $redis->lpop('test_key'), "\n";

數(shù)據(jù)庫MQ

如果考慮到redis擴(kuò)展不好安裝,或者應(yīng)用喜歡使用數(shù)據(jù)庫來存放MQ,也是可以的。只需要用SQL的一些基本的操作語句便可做到FIFO。

文件MQ

文件MQ也是一種方式,但很少使用。

(5)更豐富的調(diào)度方式

接口同步調(diào)度

雖然也是同步調(diào)度,但是我們將計劃任務(wù)隔離后,便于日后發(fā)現(xiàn)此同步的計劃任務(wù)影響到接口的響應(yīng)時間時,可以及時輕松地切換到后臺異步處理的方式。

回歸傳統(tǒng)的調(diào)度

我們也可以沿用傳統(tǒng)的做法,即使用死循環(huán)的腳本調(diào)度,或者crontab類的定時任務(wù)。

MQ隊(duì)列消費(fèi)

既然我們以接口服務(wù)的形式提供計劃任務(wù)的操作,那么可以把同一接口的調(diào)度放置到同一隊(duì)列中進(jìn)行維護(hù)和消費(fèi)。

接口異步調(diào)度

當(dāng)計劃任務(wù)以接口服務(wù)提供后,我們可以使用另一種免MQ的做法,即使用接口的異步調(diào)度。如下:
a pic

這樣既可以避免死循環(huán)帶來的性能負(fù)載問題,也可以避免定時任務(wù)帶來的延時問題,可以說異步調(diào)度是一種折中完美的做法。
但這也可能是一種不負(fù)責(zé)任或者不安全的做法,因?yàn)槲覀儫o法跟進(jìn)異步計劃任務(wù)的結(jié)果。

本地調(diào)度和遠(yuǎn)程調(diào)度

本地調(diào)度是指在執(zhí)行過程中構(gòu)建模擬接口的調(diào)用而無須經(jīng)過網(wǎng)絡(luò)請求,遠(yuǎn)程調(diào)度則是通過遠(yuǎn)程接口請求來實(shí)現(xiàn)。
如果把本地調(diào)度和遠(yuǎn)程調(diào)度,跟同步/異步組合起來,我們可以得到以下三種有意義的組合:

  • 本地同步調(diào)度
  • 遠(yuǎn)程同步調(diào)度
  • 遠(yuǎn)程異步調(diào)度

(6)計劃任務(wù)的劃分

service即類型

明顯地,接口服務(wù)名稱service即可作為計劃任務(wù)劃分的依據(jù)。

不同的service作為不同的隊(duì)列,不同類型的計劃任務(wù);而相同的service則作為相同的隊(duì)列相同的計劃任務(wù)。

接口參數(shù)即參數(shù)

接口參數(shù)即可計劃任務(wù)執(zhí)行時所需要的上下文信息。

1.31.5 PhalApi中計劃任務(wù)的核心設(shè)計解讀

(1)橋接模式 - 數(shù)據(jù)與行為獨(dú)立變化

為了給計劃任務(wù)一個執(zhí)行的環(huán)境,我們提供了 計劃任務(wù)調(diào)度器 ,即:Task_Runner。
每個計劃任務(wù)需要調(diào)度的接口是不一樣的,即不同的接口服務(wù)決定不同的行為;每個行為需要的數(shù)據(jù)也不一樣,即不同的接口參數(shù)決定不同的數(shù)據(jù)。

自然而言的,Task_Runner按照橋接模式,其充當(dāng)?shù)慕巧缦拢?br />a pic

然后,我們就可以這樣各自實(shí)現(xiàn):a pic

(2)適配器模式 - 對象適配器和類適配器

在對MQ進(jìn)行實(shí)現(xiàn)時,我們提供的Redis MQ隊(duì)列、文件MQ隊(duì)列和DB MQ隊(duì)列,都使用了適配器模式,以重用框架已有的功能。
其中,Redis MQ隊(duì)列和文件MQ隊(duì)列是屬于對象適配器,DB MQ隊(duì)列是類適配器。對于對象適配器,我們也提供了外部注入,以便客戶端在使用時可以輕松定制擴(kuò)展,當(dāng)然也可以使用默認(rèn)的緩存。

如下:a pic

這樣以后,我們可以這樣根據(jù)創(chuàng)建不同的MQ隊(duì)列:

//Redis MQ隊(duì)列
$mq = Task_MQ_Redis();
//或
$mq = Task_MQ_Redis(new PhalApi_Cache_Redis(array('host' => '127.0.0.1', 'port' => 6379)));

//文件MQ隊(duì)列
$mq = new Task_MQ_File();
//或
$mq = new Task_MQ_File(new PhalApi_Cache_File(array('path' => '/tmp/cache')));

//DB MQ隊(duì)列
$mq = new Task_MQ_DB();

//Array MQ隊(duì)列
$mq = new Task_MQ_Array();

(3)模板方法 - 本地和遠(yuǎn)程兩種調(diào)度策略

在完成底層的實(shí)現(xiàn)后,我們可以再來關(guān)注如何調(diào)度的問題,目前可以有本地調(diào)度和遠(yuǎn)程調(diào)度兩種方式。

  • 本地調(diào)度:是指本地模擬接口的請求,以實(shí)現(xiàn)接口的調(diào)度
  • 遠(yuǎn)程調(diào)度:是指通過計劃任務(wù)充當(dāng)接口客戶端,通過請求遠(yuǎn)程服務(wù)器的接口以完成接口的調(diào)度

為此,我們的設(shè)計演進(jìn)成了這樣:a pic

上圖多了兩個調(diào)度器的實(shí)現(xiàn)類,并且遠(yuǎn)程調(diào)度器會將遠(yuǎn)程的接口請求功能委托給連接器來完成。

(4)設(shè)計審視

好了!讓我們再回頭審視這樣的設(shè)計。

首先,我們在高層,也就是規(guī)約層得到了很好的約定。
不必過多地深入理解計劃任務(wù)內(nèi)部的實(shí)現(xiàn)細(xì)節(jié),我們也可以輕松得到這樣的概念流程:
計劃任務(wù)調(diào)度器(Task_Runner)從MQ隊(duì)列(Task_MQ)中不斷取出計劃任務(wù)接口服務(wù)(PhalApi_Api)進(jìn)行消費(fèi)。

再下一層,則是具體的實(shí)現(xiàn),即我們所說的實(shí)現(xiàn)層。
客戶可以根據(jù)自己的需要進(jìn)行選取使用,他們也可以擴(kuò)展他們需要的MQ。重要的是,他們需要自己實(shí)現(xiàn)計劃任務(wù)的接口服務(wù)。

根據(jù)愛因斯坦說的,要保持簡單,但不要過于簡單。
所以,為了更好地理解計劃任務(wù)的運(yùn)行過程,我們提供了簡單的時序圖:
a pic

上圖主要體現(xiàn)了兩個操作流程:加入MQ和MQ消費(fèi)。
其中,注意這兩個流程是共享同一個MQ的,否則不能共享數(shù)據(jù)。同時調(diào)度是會進(jìn)行循環(huán)式的調(diào)度,并且窮極之。

(5)沒有引入工廠方法的原因

我們在考慮是否需要提供工廠方法來創(chuàng)建計劃任務(wù)調(diào)度器,或者M(jìn)Q。
但我們發(fā)現(xiàn),設(shè)計是如此明了,不必要再引入工廠方法來增加使用的復(fù)雜性,因?yàn)榇嬖诮M合的情況。而且,對于后期客戶端進(jìn)行擴(kuò)展也不利。

當(dāng)我們需要啟動一個計劃任務(wù)時,可以這樣寫:

$mq = new Task_MQ_Redis();
$runner = new Task_Runner_Local($mq);

$runner->go('MyTask.DoSth');

上面簡單的組合可以有:4種MQ * 2種調(diào)度 = 8種組合。

所以,我們最后決定不使用工廠方法,而是把這種自由組合的權(quán)利交給客戶端。

(6)失敗重試與并發(fā)問題

除了對計劃任務(wù)使用什么模式進(jìn)行探討外,我們還需要關(guān)注計劃任務(wù)其他運(yùn)行時的問題。

一個考慮的是失敗重試,這一點(diǎn)會發(fā)生在遠(yuǎn)程調(diào)度中,因?yàn)榻涌谡埱罂赡軙瑫r。這時我們采用的是失敗輪循重試。
即,把失敗的任務(wù)放到MQ的最后,等待下一批次的嘗試。連接器在進(jìn)行請求時,也會進(jìn)行一定次數(shù)的超時重試。這里主要是為了預(yù)防接口服務(wù)器崩潰后的計劃任務(wù)丟失。

另一個則是并發(fā)的問題。這里并沒有過多地進(jìn)行加鎖策略。
而是把這種需要的實(shí)現(xiàn)移交給了客戶端。因?yàn)榧渔i會使得計劃任務(wù)更為復(fù)雜,而且有時不一定需要使用,如一個計劃任務(wù)只有一個進(jìn)程時,也就是單個死循環(huán)的腳本進(jìn)程的情況。

(7)客戶端的使用

最后,客戶端的使用就很簡單了:

$mq = new Task_MQ_Redis();
$taskLite = new Task_Lite();

$taskLite->add('MyTask.DoSth', array('id' => 888));

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號