1. 程式人生 > >京東資深架構師:高效能高併發服務的瓶頸及突破思路

京東資深架構師:高效能高併發服務的瓶頸及突破思路

本文根據DBAplus社群第74期線上分享整理而成

張成遠

                                                                                                                  張成遠
                                                                                                            京東資深架構師
  • 《MariaDB原理與實現》作者,開源專案Speedy作者,分散式資料庫相關研究方向碩士。
  • 負責京東分散式資料庫系統的架構與研發,擅長大規模分散式系統架構。

大家好,關於高效能高併發服務這個概念大家應該也都比較熟悉了,今天我主要是想講一下對於如何做一個高效能高併發服務架構的一些自己的思考。

本次分享主要包括三個部分:

1. 服務的瓶頸有哪些

2. 如何提升整體服務的效能及併發

3. 如何提升單機服務的效能及併發

一、服務的瓶頸有哪些

通常來說程式的定義是演算法+資料結構+資料,演算法簡單的理解就是一種計算方式,資料結構顧名思義是一種儲存組織資料的結構,這兩者體現了程式需要用到的計算機資源涉及到CPU資源、記憶體資源,而資料部分除了記憶體資源,往往還可能涉及到硬碟資源,甚至是彼此之間傳輸資料時會消耗網路(網絡卡)資源。

當我們搞清楚程式執行起來時涉及哪些資源後,就可以更好地分析我們的服務中哪些可能是臨界資源。所謂臨界資源就是多個程序(執行緒)併發訪問某個資源時,該資源同只能服務某個或者某些程序(執行緒)。

服務的瓶頸主要就是在這些臨界資源上,還有一些資源原本並不是臨界資源,比如記憶體在一開始是夠的,但是因為連線數或者執行緒數不斷的增多,最終導致其成為臨界資源,其他的CPU、磁碟、網絡卡其實和記憶體一樣,在訪問量增大以後一樣都可能會成為瓶頸。

所以怎麼做到高效能高併發的服務,簡單地說就是找到服務的瓶頸,在合理的範圍內儘可能的消除瓶頸或者降低瓶頸帶來的影響,再通俗一點的說就是資源總量不夠就加資源確切的說是什麼資源不夠就加什麼資源

,同時儘量降低單次訪問的資源消耗,做到在資源總量一定的情況下有能力支撐更多的訪問。

二、如何提升整體服務的效能及併發

1、資料拆分

資料拆分

圖1 單資料例項改成資料庫叢集

最典型的一個臨界資源就是資料庫,資料庫在一個大訪問量的系統中往往是最薄弱的一環,因為資料庫本身的服務能力是有限的,以MySQL為例,可能MySQL可以支援的併發連線數可能也就幾千個,假設是3000個,如果一個服務對其資料庫的併發訪問如果超過了3000,有部分訪問可能在建立連線的時候就失敗了。

在這種情況下,需要考慮的是如何將資料進行分片,引入多個MySQL例項,增加資源,如圖1所示。

資料庫這個臨界資源通過資料拆分的方式,由原來的一個MySQL例項變成了多個MySQL例項,這種情況下資料庫資源的整體併發服務能力自然提升了,同時由於服務壓力被分散,整個資料庫叢集表現出來的效能也會比單個數據庫例項高很多。

儲存類的解決思路基本是類似的,都是將資料拆分,通過引入多個儲存服務例項提升整體儲存服務的能力,不管對於SQL類的還是NoSQL類的或檔案儲存系統等都可以採用這個思路。

2、服務拆分

服務拆分

圖2 服務拆分

應用程式自身的服務需要根據業務情況進行合理的細化,讓每個服務只負責某一類功能,這個思想其實是和微服務思想類似。

一句話就是儘量合理地將服務拆分,同時有一個非常重要的原則是讓拆分以後的同類服務儘量是無狀態或弱關聯,這樣就可以很容易進行水平擴充套件,如果拆分以後的同類服務的不同例項之間本身是有一些狀態引起彼此非常強的依賴,比如彼此要共享一些資訊這些資訊又會彼此影響,那這種拆分可能就未必非常的合理,需要結合業務重新進行審視。

當然生產環節上下游拆分以後不同的服務彼此之間的關聯又是另外一種情形,因為同一個生產環節上往往是走完一個服務環節才能進入下一個服務環節,相當於有多個序列的服務,任何一個環節的服務都有可能瓶頸,所以需要拆分以後針對相應的服務進行單獨優化,這是拆分以後服務與服務之間的關係。

假設各個同類服務本身是無狀態或者弱依賴的情況下,針對應用服務進行分析,不同的應用服務不太一樣,但是通常都會涉及到記憶體資源以及計算資源,以受記憶體資源限制為例,一個應用服務能承受的連線數是有限的(連線數受限),另外如果涉及上傳下載等大量資料傳輸的情況網路資源很快就會成為瓶頸(網絡卡打滿),這種情況下最簡單的方式就是同樣的應用服務例項部署多份,達到水平擴充套件,如圖2所示。

實際在真正拆分的時候需要考慮具體的業務特點,比如像京東主站這種型別的網站,在使用者在訪問的時候除了載入基本資訊以外,還有商品圖片資訊、價格資訊、庫存資訊、購物車資訊以及訂單資訊發票資訊等,以及下單完成以後對應的分揀配送等配套的物流服務,這些都是可以拆成單獨的服務,拆分以後各個服務各司其職也能做更好的優化。

服務拆分這件事情,打個不是特別恰當的比方,就好比上學時都是學習,但是分了很多的科目,高考的時候要看總分,有些同學會有偏科的現象,有些科成績好有些科成績差一點,因為分很多科目所以很容易知道自己哪科是比較強的、哪科是比較弱的,為了保證總體分數最優,一般在弱的科目上都需要多花點精力努力提高一下分數,不然總體分數不會太高。服務拆分也是同樣的道理,拆分以後可以很容易知道哪個服務是整體服務的瓶頸,針對瓶頸服務再進行重點優化比等就可以比較容易的提升整體服務的能力。

3、適當增長服務鏈路,儘量縮短訪問鏈路,降低單次訪問的資源消耗

在大型的網站服務方案上,在各種合理拆分以後,資料拆分以及服務拆分支援擴充套件只是其中的一部分工作,之後還要根據需求看看是否需要引入快取CDN之類的服務,我把這個叫做增長服務鏈路,原來直接打到資料庫的請求,現在可能變成了先打到快取再打到資料庫,對整個服務鏈路長度來說是變長的,增長服務鏈路的原則主要是將越脆弱或者說越容易成為瓶頸的資源(比如資料庫)放置在鏈路的越末端。

在增長完服務鏈路之後,還要儘量的縮短訪問鏈路,比如可以在CDN層面就返回的就儘量不要繼續往下走了,如果可以在快取層面返回的就不要去訪問資料庫了,儘可能地讓每次的訪問鏈路變短,可以一步解決的事情就一步解決,可以兩步解決的事情就不要走第三步,本質上是降低每次訪問的資源消耗,尤其是越到鏈路的末端訪問資源的消耗會越大。

比如獲取一些產品的圖片資訊可以在訪問鏈路的最前端使用CDN,將訪問儘量擋住,如果CDN上沒有命中,就繼續往後端訪問利用nginx等反向代理將訪問打到相應的圖片伺服器上,而圖片伺服器本身又可以針對性的做一些訪問優化等。

比如像價格等資訊比較敏感,如果有更改可能需要立即生效需要直接訪問最新的資料,但是如果讓訪問直接打到資料庫中,資料庫往往直接就打掛了,所以可以考慮在資料庫之前引入redis等快取服務,將訪問打到快取上,價格服務系統本身保證資料庫和快取的強一致,降低對資料庫的訪問壓力。

在極端情況下,資料量雖然不是特別大,幾十臺快取機器就可以抗住,但訪問量可能會非常大,可以將所有的資料都放在快取中,如果快取有異常甚至都不用去訪問資料庫直接返回訪問失敗即可。

因為在訪問量非常大的情況下,如果快取掛了,訪問直接打到資料庫上,可能瞬間就把資料庫打趴下了,所以在特定場景下可以考慮將快取和資料庫切開,服務只訪問快取,快取失效重新從資料庫中載入資料到快取中再對外服務也是可以的,所以在實踐中是可以靈活變通的。

4、小結

如何提升整體服務的效能及併發,一句話概括就是:

在合理範圍內儘可能的拆分,拆分以後同類服務可以通過水平擴充套件達到整體的高效能高併發,同時將越脆弱的資源放置在鏈路的越末端,訪問的時候儘量將訪問連結縮短,降低每次訪問的資源消耗。

三、如何提升單機服務的效能及併發

前面說的這些情況可以解決大訪問量情況下的高併發問題,但是高效能最終還是要依賴單臺應用的效能,如果單臺應用效能在低訪問量情況下效能已經成渣了,那部署再多機器也解決不了問題,所以接下來聊一下單臺服務本身如果支援高效能高併發。

1、多執行緒/執行緒池方式

服務拆分

圖3 版本一

以TCP server為例來展開說明,最簡單的一個TCP server程式碼,版本一示例如圖3所示。這種方式純粹是一個示例,因為這個server啟動以後只能接受一條連線,也就是隻能跟一個客戶端互動,且該連線斷開以後,後續就連不上了,也就是這個server只能服務一次。

這個當然是不行的,於是就有了版本二如圖4所示,版本二可以一次接受一條連線,並進行一些互動處理,當這條連線全部處理完以後才能繼續下一條連線。

這個server相當於是序列的,沒有併發可言,所以在版本二的基礎上又演化出了版本三如圖5所示。

TCP server

 圖4 版本二

MySQL

圖5 版本三

這其實是我們經常會接觸到的一種模型,這種模型的特點是每連線每執行緒,MySQL 5.5以前用的就是這種模型,這種模型的特點是當有大量連線的時候會建立大量的執行緒,所以往往需要限制連線總數,如果不做限制可能會出現建立了大量的執行緒,很快就會將記憶體等資源耗幹。

cpu

圖6 版本四

另一個是當出現了大量的執行緒的時候,作業系統會有大量的cpu資源花費線上程間的上下文切換上,導致真正給業務提供服務的cpu資源比例反倒很小。同時,考慮到大多數時候即使有很多連線也並不代表所有的連線在同一個時刻都是活躍的,所以版本三又演化出了版本四,如圖6所示,版本四的時候是很多的連線共享一個執行緒池,這些執行緒池裡的執行緒數是固定的,這樣就可以做到執行緒池裡的一個執行緒同時服務多條連線了,MySQL 5.6之後採用的就是這種方式。

在絕大多數的開發中,執行緒池技術就已經足夠了,但是執行緒池在充分榨乾cpu計算資源或者說提供有效計算資源方面並不是最完美的,以一核的計算資源為例,執行緒池裡假設有x個執行緒,這x個執行緒會被作業系統依據具體排程策略進行排程,但是執行緒上下文切換本身是會消耗一定的cpu資源的,假設這部分消耗代價是w, 而實際有效服務的能力是c,那麼理論上來說w+c 就是總的cpu實際提供的計算資源,同時假設一核cpu理論上提供計算資源假設為t,這個是固定的。

所以就會出現一種情況,當執行緒池中執行緒數量較少的時候併發度較低,w雖然小了,但是c也是比較小的,也就是w+c < t甚至是遠遠小於t,如果執行緒數很多,又會出現上下文切換代價太大,即w變大了。雖然c也隨之提升了一些,但因為t是固定的,所以c的上限值一定是小於t-w的,而且隨著w越大,c的上限值反倒降低了,因此使用執行緒池的時候,執行緒數的設定需要根據實際情況進行調整。

2、基於事件驅動的模式

多執行緒(執行緒池)的方式可以較為方便地進行併發程式設計,但是多執行緒的方式對cpu的有效利用率其實並不是最高的,真正能夠充分利用cpu的程式設計方式是儘量讓cpu一直在工作,同時又儘量避免執行緒的上下文切換等開銷。

NIO

圖7 epoll示例

基於事件驅動的模式(也稱I/O多路複用)在充分利用cpu有效計算能力這件事件上是非常出色的。比較典型的有select/poll/epoll/kevent(這些機制本身之間的優劣今天先不展開說明,後續以epoll為例說明),這種模式的特點是將要監聽的socket fd註冊在epoll上,等這個描述符可讀事件或者可寫事件就緒了,那麼就會觸發相應的讀操作或者寫操作,可以簡單地理解為需要cpu幹活的時候就會告知cpu需要做什麼事情,實際使用時示例如圖7所示。

這個事情拿一個經典的例子來說明。就是在餐廳就餐,餐廳裡有很多顧客(訪問),每連線每執行緒的方式相當於每個客戶一個服務員(執行緒相當於一個服務員),服務的過程中一個服務員一直為一個客戶服務,那就會出現這個服務員除了真正提供服務以外有很大一段時間可能是空閒的,且隨著客戶數越多服務員數量也會越多,可餐廳的容量是有限的,因為要同時容納相同數量的服務員和顧客,所以餐廳服務顧客的數量將變成理論容量的50%。那這件事件對於老闆(老闆相當於開發人員,希望可以充分利用cpu的計算能力,也就是在cpu計算能力<成本>一定的情況下希望儘量的多做一些事情)來說代價就會很大。

執行緒池的方式是僱傭固定數量的服務員,服務的時候一個服務員服務好幾個客戶,可以理解為一個服務員在客戶A面前站1分鐘,看看A客戶是否需要服務,如果不需要就到B客戶那邊站1分鐘,看看B客戶是否需要服務,以此類推。這種情況會比之前每個客戶一個服務員的情況節省一些成本,但是還是會出現一些成本上的浪費。

還有一種模式也就是epoll的方式,相當於服務員就在總檯等著,客戶有需要的時候就會在桌上的呼叫器上按一下按鈕表示自己需要服務,服務員每次看一下總檯顯示的資訊,比如一共有100個客戶,一次可能有10個客戶呼叫,這個服務員就會過去為這10個客戶服務(假設服務每個客戶的時候不會出現停頓且可以在較短的時間內處理完),等這個服務員為這10個客戶服務員完以後再重新回到總檯檢視哪些客戶需要服務,依此類推。在這種情況下,可能只需要一個服務員,而餐廳剩餘的空間可以全部給客戶使用。

nginx伺服器效能非常好,也能支撐非常多的連線,其網路模型使用的就是epoll的方式,且在實現的時候採用了多個子程序的方式,相當於同時有多個epoll在工作,充分利用了cpu多核的特性,所以併發及效能都會比單個epoll的方式會有更大的提升。

另外Redis快取伺服器大家應該也非常熟悉,用的也是epoll的方式,效能也是非常好,通過這些現成的經典開源專案,大家就可以直觀地理解基於事件驅動這一方式在實際生產環境中的效能是非常高的,效能提升以後併發效果一般都會隨之提升。

但是這種方式在實現的時候是非常考驗程式設計功底以及邏輯嚴謹性,換句話程式設計友好性是非常差的。因為一個完整的上下文邏輯會被切成很多片段,比如“客戶端傳送一個命令-伺服器端接收命令進行操作-然後返回結果”這個過程,至少會包括一個可讀事件、一個可寫事件,可讀事件簡單地理解就是指這條命令已經發送到伺服器端的tcp快取區了,伺服器去讀取命令(假設一次讀取完,如果一次讀取的命令不完整,可能會觸發多次讀事件),伺服器再根據命令進行操作獲取到結果,同時註冊一個可寫事件到epoll上,等待下一次可寫事件觸發以後再將結果傳送出去,想象一下當有很多客戶端同時來訪問時,伺服器就會出現一種情況——一會兒在處理某個客戶端的讀事件,一會兒在處理另外的客戶端的寫事件,總之都是在做一個完整訪問的上下文中的一個片段,其中任何一個片段有等待或者卡頓都將引起整個程式的阻塞。

當然這個問題在多執行緒程式設計時也是同樣是存在的,只不過有時候大家習慣將執行緒設定成多個,有些執行緒阻塞了,但可能其他執行緒並沒有在同一時刻阻塞,所以問題不是特別嚴重,更嚴謹的做法是在多執行緒程式設計時,將執行緒池的數量調整到最小進行測試,如果確實有卡頓,可以確保程式在最快的時間內出現卡頓,從而快速確認邏輯上是否有不足或者缺陷,確認這種卡頓本身是否是正常現象。

3、語言層提供協程支援

多執行緒程式設計的方式明顯是支援了高併發,但因為整個程式執行緒間上下文排程可能造成cpu的利用率不是那麼高,而基於事件驅動的程式設計方式效果非常好的,但對程式設計功底要求非常高,而且在實現的時候需要花費的時間也是最多的。所以一種比較折中的方式是考慮採用提供協程支援的語言比如golang這種的。

簡單說就是語言層面抽象出了一種更輕量級的執行緒,一般稱為協程,在golang裡又叫goroutine,這些底層最終也是需要用作業系統的執行緒去跑,在golang的runtime實現時底層用到的作業系統的執行緒數量相對會少一點,而上層程式裡可以跑很多的goroutine,這些goroutine會在語言層面進行排程,看該由哪個執行緒來最終執行這個goroutine。

因為goroutine之間的切換代價是遠小於作業系統執行緒之間的切換代價,而底層用到的作業系統數量又較少,執行緒間的上下文切換代價本來也會大大降低。

這類語言能比其他語言的多執行緒方式提供更好的併發,因為它將作業系統的執行緒間切換的代價在語言層面儘可能擠壓到最小,同時程式設計複雜度大大降低,在這類語言中上下文邏輯可以保持連貫。因為降低了執行緒間上下文切換的代價,而goroutine之間的切換成本相對來說是遠遠小於執行緒間切換成本,所以cpu的有效計算能力相對來說也不會太低,相當於可以比較容易的獲得了一個高併發且效能還可以的服務。

4、小結

如何提升單機服務的效能及併發,如果對效能或者高併發的要求沒有達到非常苛刻的要求,選型的時候基於事件驅動的方式可以優先順序降低一點,選擇普通的多執行緒程式設計即可(其實多數場景都可以滿足了),如果想單機的併發程度更好一點,可以考慮選擇有協程支援的語言,如果還嫌不夠,那就將邏輯理順,考慮採用基於事件驅動的模式,這個在C/C++裡直接用select/epoll/kevent等就可以了,在java裡可以考慮採用NIO的方式,而從這點上來說像golang這種提供協程支援的語言一般是不支援在程式層面自己實現基於事件驅動的程式設計方式的。

四、總結

其實並沒有一刀切的萬能法則,大體原則是根據實際情況具體問題具體分析,找到服務瓶頸,資源不夠加資源,儘可能降低每次訪問的資源消耗,整體服務每個環節儘量做到可以水平擴充套件,同時儘量提高單機的有效利用率,從而確保在扛住整個服務的同時儘量降低資源消耗成本。

Q&A

Q1:在用NIO多執行緒下,涉及到執行緒間的資料,怎麼互動比較好呢?

A1:在NIO的情況下,一般是避免使用多執行緒,其實NIO本質上和C/C++裡使用epoll效果是類似的,所以像nginx/redis裡並不存在多執行緒的情況(內部實現的時候一些特殊情況除外)。

但是如果確實是有NIO觸發以後需要將連線丟給執行緒池去處理的情況,比如涉及到耗時操作,同時確實涉及到臨界資源,那隻能建議不要讓NIO所在的執行緒去訪問這個臨界資源,否則整個NIO卡住整個服務就卡住了。儘量避免NIO所線上程出現有鎖等待等任何可能阻塞的情況。

Q2:請問老師MySQL也是採用epoll機制嗎?

A2:MySQL連線池版參考mariadb的實現其實也有用到epoll這種機制,但是跟我們通常理解基於事件驅動的方式不太一樣,我們一般會將其歸類為每連線每執行緒/執行緒池的方式,相當於將連線最後還是要分配丟給某個執行緒去處理,而且這個訪問操作本身可能是比較耗時的,會在較長一段時間內一直佔用這個執行緒,併發主要是靠多個執行緒之間的排程達到併發效果。

Q3:Redis、MySQL資料強一致性方案能稍微講講嗎?

A3:這個還得看具體業務場景,理論上沒有特別完美能保證嚴格一致的,但是在實際情況下可以靈活處理。比如我之前提到的,像商品價格,如果訪問量足夠大,大到快取失效打到資料庫時直接可以將資料庫打趴下,那也可以特殊情況特殊對待,直接讓訪問打到快取為止。快取掛了,訪問直接失敗,直到重新將資料載入進去。

還有一些情況是頻繁的寫操作,但寫的內容未必那麼重要的,可以接受丟失,但是寫操作非常頻繁,那麼可以將寫先寫到快取直接返回成功,後續再慢慢將資料同步到資料庫。

來源:DBAplus社群