探尋多機任務分配機制

lumin發表於2019-02-17

近期在後臺任務應用上遇到多機消費同一個任務佇列的場景,需要引入一定的任務分配機制解決,因為之前也遇到過類似的問題,在此整理一下幾種可能的想法,也希望和大家交流討論更合理、更高效的方案。

背景

假設我們有一個叢集,用於處理一系列不同的任務,這時候我們需要對任務進行的一定的分配,使得叢集中的每臺機器都負責一部分任務。

一般來說會有如下幾個要求:

  • 一個任務最多隻能被執行一次(換言之,只能被分配到一臺機器上)
  • 執行任務時叢集中每臺機器的負載能夠保持平衡

在這種場景下,該如何設計任務的分配方案?

為了方便後續的展開,先約束一些表達:

  • Source: 用於表示任務的來源
  • Cluster: 用於表示整個叢集
  • Task: 用於表示抽象的任務
  • Worker: 用於表示實際執行任務的具體單元(如物理機)

四者之間的關係可以用下圖表示:

undefined

思路1:簡單取模分配

最簡單,但也是非常有效的方案,在進行任務分配前需要提前確定機器數量N,為每個任務進行編號(或直接使用其id),同時為每個執行任務的機器例項進行編號(0,1,2…)。

即使用下面的公式:

Worker = TaskId % Cluster.size()
複製程式碼

如果任務沒有id標識,那麼可以通過隨機數的方式來分配任務,在任務數量足夠多的情況下,可以保證分配的均衡性,即:

Worker = random.nextInt() % Cluster.size()
複製程式碼

簡單取模分配的優點是足夠簡單,雖然負載均衡的效果比較粗糙,但可以很快達到想要的效果,在做緊急任務分機分流的時候比較有用。但從長期上看,需要維護機器數量N的實時更新和推送,並且在機器數量發生變動的時候,可能會出現叢集內部的短暫不一致,如果業務對這個比較敏感,還需要進一步優化。

undefined

思路2:分散式鎖控

為了達到“每個任務只被一臺機器執行”的目標,可以考慮使用分散式鎖機制,當有多個Worker去消費Task時,只有第一個爭搶到鎖的Worker才能夠執行該Task。

理論上講,每次搶到鎖的Worker都是隨機的,那麼也就近似的實現了負載均衡;在有成熟中介軟體依賴的前提下,實現一個分散式鎖也並不難(可以藉助快取系統的併發控制實現),並且不用考慮機器數量變化的問題。

undefined

但這個方案也有著很多的缺陷,首先爭搶鎖的過程本身就會消耗Worker的資源,另外由於無法預測究竟哪個Worker能夠爭搶到Task的鎖,所以基本不能保證整個叢集的負載均衡。

我個人認為這種方案只適合於內容非常簡單、數量比較多,同時執行頻率非常高的任務分發(類比多執行緒讀寫快取的場景)。

思路3:中心路由排程

如果要做到比較精細的負載均衡,那麼最好的方式就是根據叢集的狀態、以及任務本身的特性去量身定製一套任務分配的規則,然後通過一箇中心的路由層來實現任務的排程,即:

  • Source將任務傳送給Router
  • Router根據規則進行決策,並將Task排程到某臺Worker
  • (如果任務需要返回結果)Router將對應Worker返回的結果轉發給Source
undefined

一個簡單可行的分配規則是在排程前,計算Worker的CPU、記憶體等負載,計算一個權重,選擇壓力最小的機器去執行任務;再進一步可以根據任務本身的複雜度做更精細的拆分。

該方案最大的問題在於,自主去實現一個路由層的成本比較高,另外有出現單點問題的風險(如果路由層掛了,整個任務排程就全部癱瘓了)。

思路4:基於訊息佇列

這個是類比之前看到的,基於訊息佇列的分散式資料庫解決方案(原文),藉助一個可靠的Broker,我們可以很容易構建出一個生產者-消費者模型。

undefined

Source產出的Task將全部投入訊息佇列中,下游的Worker接收Task,並執行(消費)。這樣的好處是減少了阻塞,同時可以根據Worker的執行結果,配置重試策略(如果執行失敗,再次放回到佇列中)。但單單依賴Broker做任務分發的話,並不能解決我們開頭的兩個問題,因此還需要:

  1. 防止訊息被重複消費的機制

    因為絕大多數的訊息佇列Broker的傳輸邏輯都是“保證訊息至少被送達一次”,所以很有可能出現某個Task被多個Worker獲取到的現象,如果要確保“每個任務都只被執行一次”,那麼這時候可能需要引入一下上面提到的鎖機制來防止重複消費。

    不過如果你選擇NSQ作為Broker的話,就不用考慮這個問題。NSQ的特性保證了某個訊息在同一個channel下,一定只能被一個消費者消費。

  2. 任務分發

    構建了生產者-消費者模型後,依然不好回答“哪個Task要在哪個Worker上執行”,也就是任務分發的機制,本質上還是依賴於消費者消費動作的隨機性,如果要做更精細的調控,大致想一下有兩種方案。

    一是在放入佇列前就根據所需規則計算好對映關係,然後對Task做一下標記,最後Worker可以設定成只對含有特定標記的Task生效,或者根據Task的標記做不同Topic來分發。

    而是在取出佇列的時候再進行計算,這樣的話可能下游又需要維護一個路由層來做轉發,感覺有些得不償失。

    就大多數實際情況而言,依賴Broker本身的訊息分發機制即可。

思路5:流式背壓

參考響應式程式設計中的背壓概念。把Source端推送(Push)任務的過程改為Worker端拉取(Pull)任務,“反客為主”,來實現流速控制和負載均衡。

undefined

簡單的說,我們需要Worker(也可能是Cluster)能夠根據自身的情況來預估自己接下來能夠承接的任務量,並將其反饋給Source,然後Source生產Task並傳送給Worker(或者Cluster)。

設想一個可行的方案,將Source視為Server,Worker視為Client,那麼便形成了一種反向的C/S模式。

其中Worker端的行為是不斷重複“請求獲取Task -> 執行Task -> 請求獲取Task”這個迴圈。每當Worker評估自身處於“空閒”狀態時,就向Source端傳送請求,來獲取Task並執行。

Source端則相對比較簡單,只需要實現一個介面,每當有請求過來時,返回一個Task,並標記該Task被消費即可。

這種思路雖然可以較好的保證每臺Worker機器負載處於可控範圍,但也存在幾個問題。

首先是流速問題,因為整個任務佇列的消費速度在此模式下完全由Worker本身調控,而任務佇列的狀態(還有多少任務需要處理、哪些任務比較緊急..)對Worker是不可見的,所以有可能導致任務在Source端的堆積。

其次是任務排程的延時問題,因為Source端完全無法預知下一個Worker的請求會在什麼時候到來,所以對於任何一個被提交的Task,都無法保證其在什麼時間被執行。對於後臺任務而言這個問題倒不是很大,但對於前臺任務就非常致命了。

要解決上面兩個問題,需要在Source端引入一個合理的任務分配機制,在極端情況下可能還需要Source端能夠強制進行Task的分發。

相關文章