天天看點

「後端」異步程式設計指北!

作者:架構思考
同步、異步,并發、并行、串行,這些名詞在我們的開發中會經常遇到,這裡對異步程式設計做一個詳細的歸納總結,希望可以對這方面的開發有一些幫助。之前的文章《「後端」詳細圖解并發和并行的重要差別》也可以回顧下,會對你同樣有幫助。

一、背景

業務中經常會有這樣的場景:

「後端」異步程式設計指北!

二、幾個名詞的概念

多任務的時候,才會遇到的情況,如:同步、異步,并發、并行。

2.1、理清它們的基本概念

  • 并發:多個任務在同一個時間段内同時執行,如果是單核心計算機,CPU會不斷地切換任務來完成并發操作。
  • 并行:多任務在同一個時刻同時執行,計算機需要有多核心,每個核心獨立執行一個任務,多個任務同時執行,不需要切換。
  • 同步:多任務開始執行,任務A、B、C全部執行完成後才算是結束。
  • 異步:多任務開始執行,隻需要主任務A執行完成就算結束,主任務執行的時候,可以同時執行異步任務B、C,主任務A可以不需要等待異步任務B、C的結果。

并發、并行,是邏輯結構的設計模式。同步、異步,是邏輯調用方式。串行是同步的一種實作,就是沒有并發,所有任務一個一個執行完成。并發、并行是異步的2種實作方式。

2.2、舉一個例子

「後端」異步程式設計指北!

你的朋友在廣州,但是有2輛小汽車在深圳,需要你幫忙把這2輛小汽車送到廣州去。

同步的方式,你先開一輛小汽車到廣州,然後再坐火車回深圳,再開另外一輛小汽車去廣州。這是串行的方法,2輛車需要的時間也就更長了。

異步的方式,你開一輛小汽車從深圳去廣州,同時請一個代駕把另外一輛小汽車從深圳開去廣州。這也就是并行方法,兩個人兩輛車,可以同時行駛,速度很快。

并發的方式,你一個人,先開一輛車走500米,停車跑回來,再開另外一輛車前行1000米,停車再跑回來,循環從深圳往廣州開。并發的方式,你可以把2輛車一塊送到朋友手裡,但是過程還是很辛苦的。

2.3、思考問題

你找一家汽車托運公司,把2輛車一起托運到廣州。這種方式是同步、異步,并發、并行的哪種情況呢?

三、并發/并行執行會遇到的問題

3.1、問題1:并發的任務數量控制

「後端」異步程式設計指北!

假設:某個接口的并發請求會達到1萬的qps,是以對接口的性能、響應時長都要求很高。

接口内部又有大量redis、mysql資料讀寫,程式中還有很多處理邏輯。如果接口内的所有邏輯處理、資料調用都是串行化,那麼單個請求耗時可能會超過100ms,為了性能優化,就會把資料讀取的部分與邏輯計算的部分分開來考慮和實作,能夠獨立的部分單獨剝離出來作為異步任務來執行,這樣就把串行化的耗時優化為并發執行,充分利用多核計算機的性能,減少單個接口請求的耗時。

假設的資料具體化,如:這個接口的資料全部是可以獨立擷取(支援并發),需要讀取來自不同資料結構的redis共10個,讀取不同資料表的資料共10個。那麼一次請求,資料擷取就會啟動10個redis讀取任務,10個mysql讀取任務。每秒鐘1萬接口請求,會有10萬個redis讀取任務和10萬個mysql讀取任務。這21萬的并發任務,在一秒鐘内由16/32核的後端部署單機來完成,雖然在同一時刻的任務數量不一定會是21萬(速度快的話會少于21萬,如果處理速度慢,出現請求積壓擁堵,會超過21萬)。

這時候,會遇到的瓶頸。

記憶體,如果每個任務需要500k記憶體,那麼210k*0.5M=210*0.5G=105G。

CPU,任務排程,像golang的協程可能開銷還小一些,如果是java的線程排程,作業系統會因為排程而空轉。

網絡,每次資料讀取5k,那麼200k*5k=200*5M=1G。

端口,端口号最多能配置設定出來65536個,明顯不夠用了。

資料源,redis可以支援10萬qps的請求,但是mysql就難以支援10萬qps了。

上面可能出現的瓶頸中,通過計算機資源擴容可以解決大部分問題,比如:部署50個後端執行個體,每個執行個體隻需要應對200的qps,壓力就小了很多。對于資料源,mysql可以有多個slave來支援隻讀的請求。

但是,如果接口的并發量更大呢?或者某個/某些資料源讀取出現異常,需要重試,或者出現擁堵,接口響應變慢,任務數量也就會出現暴增,後端服務的各方面瓶頸又會随之出現。

是以,我們需要特别注意和關心後端開啟的異步任務數量,要做好異常情況的防範,及時中斷掉擁堵/逾時的任務,避免任務暴增導緻整個服務不可用。

3.2、思考問題

你要如何應對這類并發任務暴增的情況呢?如何提前預防?如何及時幹預呢?

3.3、問題2:共享資料的讀寫順序和依賴關系

共享資料的并發讀寫,是并發程式設計中的老大難問題,如:讀寫髒資料,舊資料覆寫新資料等等。

而資料的依賴關系,也就決定了任務的執行先後順序。

為了避免共享資料的競争讀寫,為了保證任務的先後關系,就需要用到鎖、隊列等手段,這時候,并發的過程又被部分的拉平為串行化執行。

3.4、舉個例子

「後端」異步程式設計指北!

NBA季後賽,去現場看球,要搶購球票,體育館最多容納1萬人(1萬張球票)。

體育館不同距離、不同位置的票,價格和優惠都不相同。有單人位、有雙人位,也有3、4人位。你約着朋友共10個人去看球,要買票,要選位置。這時候搶票就會很尴尬,因為位置連着的可能會被别人搶走,同時買的票越多,與人沖突的機率就越大,會導緻搶票特别困難。

同時,這個系統的開發也很頭大,搶購(秒殺)的并發非常大,預計在開始的一秒鐘會超過10萬人同時進來,再加上刷票的機器人,接口請求量可能瞬間達到100萬的QPS。

較簡單的實作方式,所有的請求都異步執行,訂單全部進入消息隊列,下單馬上響應進行中,請等待。然後,後端程式再從消息隊列中串行化處理每一個訂單,把出現沖突的訂單直接報錯,這樣,估計1秒鐘可以處理1000個訂單,10秒鐘可以處理1萬個訂單。考慮訂單的沖突問題,1萬張球票的9000張可能在30秒内賣出去,此時隻處理了3萬個訂單,第一秒鐘進來的100萬訂單已經在消息隊列中堆積,又有30秒鐘的新訂單進來,需要很久才可以把剩下的1000張球票賣出去啊。同理,下單的使用者需要等待太久才知道自己的訂單結果,這個過程輪詢的請求也會很多很多。

換一種方案,不使用隊列串行化處理訂單,直接并發的處理每一個訂單。那麼處理流程中的資料都需要梳理清楚。

  • 針對每一個使用者的請求加鎖,避免同一個使用者的重入;
  • 每一個/組座位預生成一個key:0,預設0說明沒有下單;
  • 預估平均每一個訂單包含2個/組座位,需要更新2個座位key;
  • 下單的時候給座位key執行INCR key數字遞增操作,隻有傳回1的訂單才是成功,其他都是失敗;
  • 如果同一個訂單中的座位key有沖突的情況下,需要復原成功key(INCR key=1)重置(SET key 0);
  • 訂單成功/失敗,處理完成後,去掉使用者的請求鎖;
  • 訂單資料入庫到mysql(消息隊列,避免mysql成為瓶頸);

綜上,需要用到1個鎖(2次操作),平均2個座位key(每個座位号1-2次操作),這裡隻有2個座位key可以并發更新。為了讓redis不成為資料讀寫的瓶頸(超過100w的QPS寫操作),不能使用單執行個體模式,而要使用redis叢集,使用由10-20個redis執行個體組成的叢集,來支援這麼高的redis資料讀寫。

算上redis資料讀寫、參數、異常、邏輯處理,一個請求大概耗時10ms左右,單核至少可以支援100并發,由于這裡有大量IO處理,後端服務可以支援的并發可以更高些,預計單核200并發,16核就可以支援3200并發。總共需要支援100萬并發,預計需要312台後端伺服器。

這種方案比隊列的方案需要的伺服器資源更多,但是使用者的等待時間很短,體驗就好很多。

3.5、思考問題

實際情況會是怎樣呢?會有10萬人同時搶票嗎?會有100萬的超高并發嗎?訂票系統真的會準備300多台伺服器來應對搶票嗎?

四、狀态處理:忽略結果

「後端」異步程式設計指北!

4.1、使用場景和案例

使用場景,主流程之外的異步任務,可能重要程度不高,或者處理的複雜度太高,有時候會忽略異步任務的處理結果。

案例1:異步的資料上報、資料存儲/計算/統計/分析。

案例2:模闆化建立服務,有很多個任務,有前後關聯任務,也有互相獨立任務,有些執行速度很慢,有些任務失敗後也可以手動重試來修複。

忽略結果的情況,就會遇到下面的問題。

4.2、問題1:資料一緻性

看下案例1的情況。

異步的日志上報,是否成功發送到服務端呢?

異步的名額資料上報,是否正确彙總統計和發送到服務端呢?

異步的任務,資料發送到消息隊列,是否被後端應用程式消費呢?

服務端是否正常存儲和處理完成呢?

如果因為網絡原因,因為并發量太大導緻服務負載問題,因為程式bug的原因,導緻資料沒能正确上報和處理,這時候的資料不一緻、丢失的問題,就會難以及時排查和事後補發。

如果在本地完整記錄一份資料,以備資料審查,又要考慮高并發高性能的瓶頸,畢竟本地日志讀寫性能受到磁盤速度的影響,性能會很差。

4.3、問題2:功能可靠性

看下案例2的情況。

建立服務的過程中,有建立代碼倉庫、開啟日志采集和自定義鏡像中心,CI/CD等耗時很長的任務。這裡開啟日志采集和自定義鏡像中心如果出現異常,對整個服務的運作沒有影響,而且開發者發現問題後也可以自己手動操作下,再次開啟日志采集和自定義鏡像功能。是以在模闆化進行中,這些異步處理任務就沒有關注任務的狀态。

那麼問題就很明顯,模闆化建立服務的過程中,是不能保證全部功能都正常執行完成的,會有部分功能可能有異常,而且也沒有提示和後續指引。

當然模闆化建立服務的程式,也可以把全部任務的狀态都檢查結果,隻是會增加一些處理的複雜度和難度。

4.4、思考問題

實際開發中,有遇到類似上面的兩個案例嗎?你會如何處理呢?所有的異步任務,都會檢查狀态結果嗎?為什麼呢?

五、狀态處理:結果傳回

5.1、使用場景和案例

大部分的異步任務對于狀态結果還是很關注的,比如:後續的處理邏輯或者任務依賴某個異步任務,或者異步任務非常重要,需要把結果傳回給請求方。

案例1:模闆化建立服務的過程中,需要異步建立服務的git代碼倉庫,還要給倉庫添加成員、webhook、初始化代碼等。整個過程全部串行化作為一個任務的話,耗時會比較長。可以把建立服務的git代碼倉庫作為一個異步任務,然後得到成功的結果後再異步的發起添加成員、加webhook、初始化代碼等任務。同時,這裡的CI/CD有配置相關,有執行相關,整個過程也很長,CD部署成功之後才可以開啟日志采集等配置,是以也需要關注CD部署的結果。

案例2:各種webhook、callback接口和方法,就是基于回調的方式,如:golang中的channel通知,工蜂中的代碼push等webhook,監控告警中的callback等。

案例3:釋出訂閱模式,如引入消息隊列服務,主程式把資料發送給消息隊列,異步任務訂閱相應的主題然後處理。處理完成後也可以把結果再發送給消息隊列,或者把結果發送給主調程式的接口,或者等待主調程式來查詢結果,當然也可能是上面的忽略結果的情況。

從上可以總結出來,對于異步任務的狀态處理,需要關注結果的話,有兩種主要的方法,分别是:輪詢查詢和等待回調。

5.2、方法1:輪詢查詢

「後端」異步程式設計指北!

上面的案例1中,模闆化建立服務的過程很慢,是以整個功能都是異步的,使用者大概要等待10s左右才知道最後的結果。是以,使用者在建立服務之後,浏覽器會不斷輪詢服務端接口,看看建立服務的結果,各個步驟的處理結果,服務配置是否都成功完成了。

類似的功能實作應該有很多,比如:服務建構、部署、建立鏡像倉庫、搶購買票等,把任務執行和任務結果通過異步的方式強制分離開,使用者可以等待,但是不用停留在目前任務中持續等待,而是可以去做别的事情,随時回來關注下這個任務的處理結果就好了。大部分執行時間很長的任務都會放到異步線程中執行,使用者關注結果的話,就可以通過查詢的方式來擷取結果,程式自動來傳回結果的話,就可以用到輪詢查詢了。

  • 局限性1:頻率和實時性

輪詢的方式延時可能會比較高,因為跟定時器的間隔時間有關系。

  • 局限性2:增加請求壓力

因為輪詢,要不斷地請求服務端,是以對後端的請求壓力也會比較大。

5.3、方法2:通知回調

「後端」異步程式設計指北!

等待回調幾乎是實時的,處理有結果傳回就馬上通過回調通知到主程式/使用者,那麼效率和體驗上就會好很多。

但是這裡也有一個前提要求,回調的時候,主程式必須還在運作,否則回調也就沒有了主體,也就無效了。是以要求主程式需要持續等待異步任務的回調,不能過早的退出。

一般程式中使用異步任務,需要得到任務狀态的結果,使用等待回調的情況更多一些。

  • 特别注意1:等待逾時

等待的時間,一般不能是無限長,這樣容易造成某些異常情況下的任務爆炸,記憶體洩露。是以需要對異步任務設定一個等待逾時,過期後就要中斷任務了,也就不能通過回調來得到結果了,直接認為是任務異常了。

  • 特别注意2:異常情況

當主程式在等待異步任務的回調時,如果異步任務自身有異常,無法成功執行,也無法完成回調的操作,那麼主程式也就無法得到想要的結果,也不知道任務狀态的結果是成功還是失敗,這時候也就會遇到上面等待逾時的情況了。

  • 特别注意3:回調地獄

使用nodejs異步程式設計的時候,所有的io操作都是異步回調,于是就很容易陷入N層的回調,代碼就會變得異常醜陋和難以維護。于是就出現了很多的異步程式設計架構/模式,像:Promise,Generator,async/await等。這裡不做過多講解。

5.4、思考問題

實際工作中,還有哪些地方需要處理異步任務的狀态結果傳回呢?除了輪詢和回調,還有其他的方法嗎?

六、異常處理

同步的程式,處理異常情況,在java中隻需要一個try catch就可以捕獲到全部的異常。

6.1、重點1:分别做異常處理

異步的程式,try catch隻能捕獲到目前主程式的異常,主程式中的異步線程是無法被捕獲的。這時候,就需要針對異步線程中的異步任務也要單獨進行 try catch捕獲異常。

在golang中,開啟協程,還是需要在異步任務的defer方法中,加入一個recover(),以避免沒有處理的異常導緻整個程序的panic。

6.2、重點2:異常結果的記錄,查詢或者回調

當我們把異步任務中的異常情況都處理好了,不會導緻異步線程把整個程序整奔潰了,那麼還有問題,怎麼把異常的結果傳回給主程序。這就涉及到上面的狀态處理了。

如果可以忽略結果,那麼隻需要寫一下錯誤日志就好了。

如果需要處理狀态,那就要記錄下異常資訊或者通知回調給到主程序。

6.3、思考問題

實際工作中,你會對所有的可能異常情況都做相應的處理嗎?異常結果,都是怎麼處理的呢?

七、典型場景和思考

前面已經講到一些案例,總結下來的典型場景有如下幾種:

  • 訂閱釋出模式,消息隊列;
  • 慢請求,耗時長的任務;
  • 高并發、高性能要求時的多任務處理;
  • 不确定執行的時間點,觸發器;
  • 人腦(單核)不擅長異步思考,電腦(多核)卻更适合。

程式設計的時候,是人腦适配電腦,還是電腦服務人腦?

在大部分的程式設計中,大家都隻需要考慮同步的方式來寫代碼邏輯。少部分時候,就要考慮使用異步的方式。而且,有很多的開發架構、類庫已經把異步處理封裝,可以簡化異步任務的開發和調試工作。

是以,對于開發者來說,預設還是同步方式思考和開發,當不得不使用異步的時候,才會考慮異步的方式。畢竟讓人腦适配電腦,這個過程還是有些困難的。

文章來源:王毅_騰訊應用開發工程師_https://mp.weixin.qq.com/s?__biz=MzI2NDU4OTExOQ==&mid=2247538296&idx=1&sn=97eacded099b48cc2f117a590c939a57&chksm=eaa84c28dddfc53e644d86a54577a3bdbda3b0eadfe14a202a9d24604c2782b5b9b61b2c8941&scene=21#wechat_redirect

繼續閱讀