Go 如何調用一個只支持 batch_call 的服務?
我們先來說下標題是什麼意思。
爲了更好的理解我說的是啥,我們來舉個例子。
假設你現在在做一個類似 B 站的系統,裏面放了各種視頻。
用戶每天在裏頭上傳各種視頻。
按理說每個視頻都要去審查一下有沒有搞顏色,但總不能人眼挨個看吧。
畢竟唐老哥表示這玩意看多了,看太陽都是綠色的,所以會有專門訓練過的算法服務去做檢測。
但也不能上來就整個視頻每一幀都拿去做審查吧,所以會在每個視頻里根據時長和視頻類型隨機抽出好幾張圖片去做審查,比如視頻標籤是美女的,算法愛看,那多抽幾張。標籤是編程的,狗都不看,就少抽幾張。
將這些抽出來的圖片,送去審查。
爲了實現這個功能,我們會以視頻爲維度去做審覈,而每個視頻裏都會有 N 張數量不定的圖片,下游服務是個使用 GPU 去檢測圖片的算法服務。
現在問題來了,下游服務的算法開發告訴你,這些個下游服務,它不支持很高的併發,但請求傳參裏給你加了個數組,你可以批量(batch)傳入一個比較大的圖片數組,通過這個方式可以提升點圖片處理量。
於是,我們的場景就變成。
上游服務的入參是一個視頻和它的 N 張圖片,出參是這個視頻是否審覈通過。
下游服務的入參是 N 張圖片的,出參是這個視頻是否審覈通過。
batch_call 上下游
**現在我們想要用上游服務接入下游服務。**該怎麼辦?
看上去挺好辦的,一把梭不就完事了嗎?
當一個視頻進來,就拿着視頻的十多張
圖片作爲一個 batch 去進行調用。
有幾個視頻進來,就開幾個這樣的併發。
這麼做的結果就是,當併發大一點時,你會發現性能很差,並且性能非常不穩定,比如像下面的監控圖一樣一會 3qps,一會 15qps。處理的圖片也只支持 20qps 左右。
狗看了都得搖頭。
圖 1 - 直接調用時 qps 很低
這可如何是好?
爲什麼下游需要 batch call
本着先問是不是,再問爲什麼的精神,我們先看看爲啥下游的要求會如此別緻。
爲什麼同樣都是處理多張圖片,下游不搞成支持併發而要搞成批量調用(batch call)?
這個設定有點奇怪?
其實不奇怪,在算法服務中甚至很常見,舉個例子你就明白了。
同樣是處理多張圖片,爲了簡單,我就假設是三張吧。如果是用單個 cpu 去處理的話。那不管是併發還是 batch 進來,由於 cpu 內部的計算單元有限,所以你可以簡單理解爲,這三張圖片,就是串行去計算的。
cpu 處理圖片時的流程
我計算第一張圖片是否能審覈通過,跟第二張圖片是否能審覈通過,這兩者沒有邏輯關聯,因此按道理兩張圖片是可以並行計算。
奈何我 CPU 計算單元有限啊,做不到啊。
但是。
如果我打破計算單元有限的這個條件,給 CPU 加入超多計算單元,並且弱化一些對於計算沒啥用處的組件,比如 cache 和控制單元。那我們就有足夠的算力可以讓這些圖片的計算並行起來了。
並行處理圖片
是的,把 CPU 這麼一整,它其實就變成了 GPU。
GPU 和 CPU 的區別
上面的講解只是爲了方便理解,實際上,gpu 會以更細的粒度去做併發計算,比如可以細到圖片裏的像素級別。
這也是爲什麼如果我們跑一些 3d 遊戲的時候,需要用到顯卡,因爲它可以快速的並行計算畫面裏每個地方的光影,遠近效果啥的,然後渲染出畫面。
回到爲什麼要搞成 batch call 的問題中。
其實一次算法服務調用中,在數據真正進入 GPU 前,其實也使用了 CPU 做一些前置處理。
因此,我們可以簡單的將一次調用的時間理解成做了下面這些事情。
GPU 處理圖片時的流程
服務由 CPU 邏輯和 GPU 處理邏輯組成,調用進入服務後,會有一些前置邏輯,它需要 CPU 來完成,然後才使用 GPU 去進行並行計算,將結果返回後又有一些後置的 CPU 處理邏輯。中間的 GPU 部分,管是計算 1 張圖,還是計算 100 張圖,只要算力支持,那它們都是並行計算的,耗時都差不多。
如果把這多張圖片拆開,併發去調用這個算法服務,那就有 N 組這樣的 CPU+GPU 的消耗,而中間的並行計算,其實沒有利用到位。
並且還會多了前置和後置的 CPU 邏輯部分,算法服務一般都是 python 服務,主流的一些 web 框架幾乎都是以多進程,而不是多線程的方式去處理外部請求,這就有可能導致額外的進程間切換消耗。
當併發的請求多了,請求處理不過來,後邊來的請求就需要等前邊的處理完才能被處理,後面的請求耗時看起來就會變得特別大。這也是上面圖 1 裏,接口延時(latency)像過山車那樣往上漲的原因。
還是上面的圖 1 的截圖,一張圖用兩次哈哈
按理說減少併發,增大每次調用時的圖片數量,就可以解決這個問題。
這就是推薦 batch call 的原因。
但問題又來了。
每次調用,上游服務輸入的是一個視頻以及它的幾張圖片,調用下游時,batch 的數量按道理就只能是這幾張圖片的數量,怎麼才能增大 batch 的數量呢?
這裏的調用,就需要分爲同步調用和異步調用了。
同步調用和異步調用的區別
同步調用,意思是上游發起請求後,阻塞等待,下游處理邏輯後返回結果給上游。常見的形式就像我們平時做的 http 調用一樣。
同步調用
異步調用,意思是上游發起請求後立馬返回,下游收到消息後慢慢處理,處理完之後再通過某個形式通知上游。常見的形式是使用消息隊列,也就是 mq。將消息發給 mq 後,下游消費 mq 消息,觸發處理邏輯,然後再把處理結果發到 mq,上游消費 mq 的結果。
異步調用
異步調用的形式接入
異步調用的實現方式
回到我們文章開頭提到的例子,當上遊服務收到一個請求(一個視頻和它對應的圖片),這時候上游服務作爲生產者將這個數據寫入到 mq 中,請求返回。然後新造一個 C 服務,負責批量消費 mq 裏的消息。這時候服務 C 就可以根據下游服務的性能控制自己的消費速度,比如一次性消費 10 條數據(視頻),每個數據下面掛了 10 個圖片,那我一次 batch 的圖片數量就是 10*10=100 張,原來的 10 次請求就變爲了 1 次請求。這對下游就相當的友好了。
下游返回結果後,服務 C 將結果寫入到 mq 的另外一個 topic 下,由上游去做消費,這樣就結束了整個調用流程。
當然上面的方案,如果你把 mq 換成數據庫,一樣是 ok 的,這時候服務 C 就可以不斷的定時輪詢數據庫表,看下哪些請求沒處理,把沒處理的請求批量撈出來再 batch call 下游。不管是 mq 還是數據庫,它們的作用無非就是作爲中轉,暫存數據,讓服務 C 根據下游的消費能力,去消費這些數據。
這樣不管後續要加入多少個新服務,它們都可以在原來的基礎上做擴展,如果是 mq,加 topic,如果是數據庫,則加數據表,每個新服務都可以根據自己的消費能力去調整消費速度。
mq 串聯多個不同性能的服務
其實對於這種上下游服務處理性能不一致的場景,最適合用的就是異步調用。而且涉及到的服務性能差距越大,服務個數越多,這個方案的優勢就越明顯。
同步調用的方式接入
雖然異步調用在這種場景下的優勢很明顯,但也有個缺點,就是它需要最上游的調用方能接受用異步的方式去消費結果。其實涉及到算法的服務調用鏈,都是比較耗時的,用異步接口非常合理。但合理歸合理,有些最上游他不一定聽你的,就是不能接受異步調用。
這就需要採用同步調用的方案,但怎麼才能把同步接口改造得更適合這種調用場景,這也是這篇文章的重點。
限流
如果直接將請求打到下游算法服務,下游根本喫不消,因此首先需要做的就是給在上游調用下游的地方,加入一個速率限制(rate limit)。
這樣的組件一般也不需要你自己寫,幾乎任何一個語言裏都會有現成的。
比如 golang 裏可以用golang.org/x/time/rate
庫,它其實是用令牌桶算法實現的限流器。如果不知道令牌桶是啥也沒關係,不影響理解。
限流器邏輯
當然,這個限制的是當前這個服務調用下游的 qps,也就是所謂的單節點限流。如果是多個服務的話,網上也有不少現成的分佈式限流框架。但是,還是那句話,夠用就好。
限流只能保證下游算法服務不被壓垮,並不能提升單次調用 batch 的圖片數量,有沒有什麼辦法可以解決這個問題呢?
參考 Nagle 算法的做法
我們熟悉的 TCP 協議裏,有個算法叫 Nagle 算法,設計它的目的,就是爲了避免一次傳過少數據,提高數據包的有效數據負載。
當我們想要發送一些數據包時,數據包會被放入到一個緩衝區中,不立刻發送,那什麼時候會發送呢?
數據包會在以下兩個情況被髮送:
-
緩衝區的數據包長度達到某個長度(MSS)時。
-
或者等待超時(一般爲
200ms
)。在超時之前,來的那麼多個數據包,就是湊不齊 MSS 長度,現在超時了,不等了,立即發送。
這個思路就非常值得我們參考。我們完全可以自己在代碼層實現一波,實現也非常簡單。
-
我們定義一個帶鎖的全局隊列(鏈表)。
-
當上遊服務輸入一個視頻和它對應的 N 張圖片時,就加鎖將這 N 張圖片數據和一個用來存放返回結果的結構體放入到全局隊列中。然後死循環讀這個結構體,直到它有結果。就有點像阻塞等待了。
-
同時在服務啓動時就起一個線程 A 專門用於收集這個全局隊列的圖片數據。線程 A 負責發起調用下游服務的請求,但只有在下面兩個情況下會發起請求
-
當收集的圖片數量達到 xx 張的時候
-
距離上次發起請求過了 xx 毫秒(超時)
-
調用下游結束後,再根據一開始傳入的數據,將調用結果拆開來,送回到剛剛提到的用於存放結果的結構體中。
-
第 2 步裏的死循環因爲存放返回結果的結構體,有值了,就可以跳出死循環,繼續執行後面的邏輯。
batch_call 同步調用改造
這就像公交車站一樣,公交車站不可能每來一個顧客就發一輛公交車,當然是希望車裏顧客越多越好。上游每來一個請求,就把請求裏的圖片,也就是乘客,塞到公交車裏,公交車要麼到點發車(向下遊服務發起請求),要麼車滿了,也沒必要等了,直接發車。這樣就保證了每次發車的時候公交車裏的顧客數量足夠多,發車的次數儘量少。
大體思路就跟上面一樣,如果是用 go 來實現的話,就會更加簡單。
比如第 1 步裏的加鎖全局隊列可以改成有緩衝長度的 channel。第 2 步裏的 " 用來存放結果的結構體 ",也可以改成另一個無緩衝 channel。執行 res := <-ch, 就可以做到阻塞等待的效果。
而核心的仿 Nagle 的代碼也大概長下面這樣。當然不看也沒關係,反正你已經知道思路了。
func CallAPI() error {
size := 100
// 這個數組用於收集視頻裏的圖片,每個 IVideoInfo 下都有N張圖片
videoInfos := make([]IVideoInfo, 0, size)
// 設置一個200ms定時器
tick := time.NewTicker(200 * time.Microsecond)
defer tick.Stop()
// 死循環
for {
select {
// 由於定時器,每200ms,都會執行到這一行
case <-tick.C:
if len(videoInfos) > 0 {
// 200ms超時,去請求下游
limitStartFunc(videoInfos, true)
// 請求結束後把之前收集的數據清空,重新開始收集。
videoInfos = make([]IVideoInfo, 0, size)
}
// AddChan就是所謂的全局隊列
case videoInfo, ok := <-AddChan:
if !ok {
// 通道關閉時,如果還有數據沒有去發起請求,就請求一波下游服務
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
return nil
} else {
videoInfos = append(videoInfos, videoInfo)
if videoInfos 內的圖片滿足xx數量 {
limitStartFunc(videoInfos, false)
videoInfos = make([]IVideoInfo, 0, size)
// 重置定時器
tick.Reset(200 * time.Microsecond)
}
}
}
}
return nil
}
通過這一操作,上游每來一個請求,都會將視頻裏的圖片收集起來,堆到一定張數的時候再統一請求,大大提升了每次 batch call 的圖片數量,同時也減少了調用下游服務的次數。真 · 一舉兩得。
優化的效果也比較明顯,上游服務支持的 qps 從原來不穩定的 3q~15q 變成穩定的 90q。下游的接口耗時也變得穩定多了,從原來的過山車似的飆到 15s 變成穩定的 500ms 左右。處理的圖片的速度也從原來 20qps 提升到 350qps。
到這裏就已經大大超過業務需求的預期(40qps)了,夠用就好,多一個 qps 都是浪費。
可以了,下班吧。
image-20220528215806920
image-20220529171810510
總結
-
爲了充分利用 GPU 並行計算的能力,不少算法服務會希望上游通過加大 batch 的同時減少併發的方式進行接口調用。
-
對於上下游性能差距明顯的服務,建議配合 mq 採用異步調用的方式將服務串聯起來。
-
如果非得使用同步調用的方式進行調用,建議模仿 Nagle 算法的形式,攢一批數據再發起請求,這樣既可以增大 batch,同時減少併發,真 · 一舉兩得,親測有效。
最後
講了那麼多可以提升性能的方式,現在需求來了,如果你資源充足,但時間不充足,那還是直接同步調用一把梭吧。
性能不夠?下游加機器,gpu 卡,買!
然後下個季度再提起一個技術優化,性能提升 xx%,cpu,gpu 減少 xx%。
有沒有聞到?
這是 KPI 的味道。
又是一個小細節,學到了的兄弟們,評論區裏打個【學到了】。
最近原創更文的閱讀量穩步下跌,思前想後,夜裏輾轉反側。
小白 debug 答應我,關注之後,好好學技術,別隻是收藏我的表情包。。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/3PHLC7_ThViWx_gEKAQF2Q