京東資深架構師:高性能高并發服務的瓶頸及突破思路
關于高性能高并發服務這個概念大家應該也都比較熟悉了,今天為大家帶來如何做一個高性能高并發服務架構的實踐和思考。
本次分享主要包括三個部分:
- 服務的瓶頸有哪些
- 如何提升整體服務的性能及并發
- 如何提升單機服務的性能及并發
服務的瓶頸有哪些
通常來說程序的定義是算法+數據結構+數據,算法簡單的理解就是一種計算方式,數據結構顧名思義是一種存儲組織數據的結構。
這兩者體現了程序需要用到的計算機資源,涉及到 CPU 資源、內存資源,而數據部分除了內存資源,往往還可能涉及到硬盤資源,甚至是彼此之間傳輸數據時會消耗網絡(網卡)資源。
當我們搞清楚程序運行起來時涉及哪些資源后,就可以更好地分析我們的服務中哪些可能是臨界資源。
所謂臨界資源就是多個進程(線程)并發訪問某個資源時,該資源同時只能服務某個或者某些進程(線程)。
服務的瓶頸主要就是在這些臨界資源上,還有一些資源原本并不是臨界資源。
比如內存在一開始是夠的,但是因為連接數或者線程數不斷地增多,最終導致其成為臨界資源,其他的 CPU、磁盤、網卡其實和內存一樣,在訪問量增大以后一樣都可能會成為瓶頸。
所以怎么做到高性能高并發的服務,簡單地說就是找到服務的瓶頸,在合理的范圍內盡可能的消除瓶頸或者降低瓶頸帶來的影響。
再通俗一點的說就是資源總量不夠就加資源,什么資源不夠就加什么資源,同時盡量降低單次訪問的資源消耗,做到在資源總量一定的情況下有能力支撐更多的訪問。
如何提升整體服務的性能及并發
數據拆分
最典型的一個臨界資源就是數據庫,數據庫在一個大訪問量的系統中往往是最薄弱的一環,因為數據庫本身的服務能力是有限的。
以 MySQL 為例,MySQL 可以支持的并發連接數可能也就幾千個,假設是 3000 個,一個服務對其數據庫的并發訪問如果超過了 3000 個,有部分訪問可能在建立連接的時候就失敗了。
在這種情況下,需要考慮的是如何將數據進行分片,引入多個 MySQL 實例,增加資源,如圖 1 所示。
圖 1:單數據實例改成數據庫集群
數據庫這個臨界資源通過數據拆分的方式,由原來的一個 MySQL 實例變成了多個 MySQL 實例。
這種情況下數據庫資源的整體并發服務能力自然提升了,同時由于服務壓力被分散,整個數據庫集群表現出來的性能也會比單個數據庫實例高很多。
存儲類的解決思路基本是類似的,都是將數據拆分,通過引入多個存儲服務實例提升整體存儲服務的能力,不管對于 SQL 類的還是 NoSQL 類的或文件存儲系統等都可以采用這個思路。
服務拆分
應用程序自身的服務需要根據業務情況進行合理的細化,讓每個服務只負責某一類功能,這個思想和微服務思想類似。
一句話就是盡量合理地將服務拆分,同時有一個非常重要的原則是讓拆分以后的同類服務盡量是無狀態或弱關聯,這樣就可以很容易進行水平擴展。
如果拆分以后的同類服務的不同實例之間本身是有一些狀態引起彼此非常強的依賴,比如彼此要共享一些信息這些信息又會彼此影響,那這種拆分可能就未必非常的合理,需要結合業務重新進行審視。
當然生產環節上下游拆分以后不同的服務彼此之間的關聯又是另外一種情形,因為同一個生產環節上往往是走完一個服務環節才能進入下一個服務環節。
相當于有多個串行的服務,任何一個環節的服務都有可能出現瓶頸,所以需要拆分以后針對相應的服務進行單獨優化,這是拆分以后服務與服務之間的關系。
假設各個同類服務本身是無狀態或者弱依賴的情況下,針對應用服務進行分析,不同的應用服務不太一樣,但是通常都會涉及到內存資源以及計算資源。
以受內存資源限制為例,一個應用服務能承受的連接數是有限的(連接數受限),另外如果涉及上傳下載等大量數據傳輸的情況,網絡資源很快就會成為瓶頸(網卡打滿)。
這種情況下最簡單的方式就是同樣的應用服務實例部署多份,達到水平擴展,如圖 2 所示。
圖 2:服務拆分
實際在真正拆分的時候需要考慮具體的業務特點,比如像京東主站這種類型的網站,用戶在訪問的時候除了加載基本信息以外,還有商品圖片信息、價格信息、庫存信息、購物車信息以及訂單信息、發票信息等。
以及下單完成以后對應的分揀配送等配套的物流服務,這些都可以拆成單獨的服務,拆分以后各個服務各司其職也能做更好的優化。
服務拆分這件事情,打個不是特別恰當的比方,就好比上學時都是學習,但是分了很多的科目,高考的時候要看總分,有些同學會有偏科的現象,有些科成績好有些科成績差一點。
因為分很多科目所以很容易知道自己哪科是比較強的、哪科是比較弱的,為了保證總體分數最優,一般在弱的科目上都需要多花點精力努力提高一下分數,不然總體分數不會太高。
服務拆分也是同樣的道理,拆分以后可以很容易知道哪個服務是整體服務的瓶頸,針對瓶頸服務再進行重點優化就可以比較容易的提升整體服務的能力。
增長服務鏈路
在大型的網站服務方案上,在各種合理拆分以后,數據拆分以及服務拆分支持擴展只是其中的一部分工作,之后還要根據需求看看是否需要引入緩存 CDN 之類的服務。
我把這個叫做增長服務鏈路,原來直接打到數據庫的請求,現在可能變成了先打到緩存再打到數據庫,對整個服務鏈路長度來說是變長的。
增長服務鏈路的原則主要是將越脆弱或者說越容易成為瓶頸的資源(比如數據庫)放置在鏈路的越末端。
在增長完服務鏈路之后,還要盡量的縮短訪問鏈路,比如可以在 CDN 層面就返回的就盡量不要繼續往下走了。
如果可以在緩存層面返回的就不要去訪問數據庫了,盡可能地讓每次的訪問鏈路變短。
可以一步解決的事情就一步解決,可以兩步解決的事情就不要走第三步,本質上是降低每次訪問的資源消耗,尤其是越到鏈路的末端訪問資源的消耗會越大。
比如獲取一些產品的圖片信息可以在訪問鏈路的最前端使用 CDN,將訪問盡量擋住。
如果 CDN 上沒有命中,就繼續往后端訪問,利用 Nginx 等反向代理將訪問打到相應的圖片服務器上,而圖片服務器本身又可以針對性的做一些訪問優化等。
比如像價格等信息比較敏感,如果有更改可能需要立即生效,需要直接訪問最新的數據,但是如果讓訪問直接打到數據庫中,數據庫往往直接就打掛了。
所以可以考慮在數據庫之前引入 Redis 等緩存服務,將訪問打到緩存上,價格服務系統本身保證數據庫和緩存的強一致,降低對數據庫的訪問壓力。
在極端情況下,數據量雖然不是特別大,幾十臺緩存機器就可以抗住,但訪問量可能會非常大,可以將所有的數據都放在緩存中,如果緩存有異常甚至都不用去訪問數據庫直接返回訪問失敗即可。
因為在訪問量非常大的情況下,如果緩存掛了,訪問直接打到數據庫上,可能瞬間就把數據庫打趴下了。
所以在特定場景下可以考慮將緩存和數據庫切開,服務只訪問緩存,緩存失效重新從數據庫中加載數據到緩存中再對外服務也是可以的,所以在實踐中是可以靈活變通的。
小結
如何提升整體服務的性能及并發,一句話概括就是:在合理范圍內盡可能的拆分,拆分以后同類服務可以通過水平擴展達到整體的高性能高并發。
同時將越脆弱的資源放置在鏈路的越末端,訪問的時候盡量將訪問鏈接縮短,降低每次訪問的資源消耗。
如何提升單機服務的性能及并發
前面說的這些情況可以解決大訪問量情況下的高并發問題,但是高性能最終還是要依賴單臺應用的性能。
如果單臺應用性能在低訪問量情況下性能已經成渣了,那部署再多機器也解決不了問題,所以接下來聊一下單臺服務本身如果支持高性能高并發。
多線程/線程池方式
以 TCP server 為例來展開說明,最簡單的一個 TCP server 代碼,版本一示例如圖 3 所示。
圖 3:版本一
這種方式純粹是一個示例,因為這個 server 啟動以后只能接受一條連接,也就是只能跟一個客戶端互動,且該連接斷開以后,后續就連不上了,也就是這個 server 只能服務一次。
這個當然是不行的,于是就有了版本二,如圖 4 所示,版本二可以一次接受一條連接,并進行一些交互處理,當這條連接全部處理完以后才能繼續下一條連接。
圖 4:版本二
這個 server 相當于是串行的,沒有并發可言,所以在版本二的基礎上又演化出了版本三,如圖 5 所示。
圖 5:版本三
這其實是我們經常會接觸到的一種模型,這種模型的特點是每連接每線程,MySQL 5.5 以前用的就是這種模型,這種模型的特點是當有大量連接的時候會創建大量的線程。
所以往往需要限制連接總數,如果不做限制可能會出現創建了大量的線程,很快就會將內存等資源耗干。
另一個是當出現了大量的線程的時候,操作系統會有大量的 CPU 資源花費在線程間的上下文切換上,導致真正給業務提供服務的 CPU 資源比例反倒很小。
同時,考慮到大多數時候即使有很多連接也并不代表所有的連接在同一個時刻都是活躍的,所以版本三又演化出了版本四,如圖 6 所示。
圖 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 的上限值反倒降低了,因此使用線程池的時候,線程數的設置需要根據實際情況進行調整。
基于事件驅動的模式
多線程(線程池)的方式可以較為方便地進行并發編程,但是多線程的方式對 CPU 的有效利用率并不是最高的,真正能夠充分利用 CPU 的編程方式是盡量讓 CPU 一直在工作,同時又盡量避免線程的上下文切換等開銷。
基于事件驅動的模式(也稱 I/O 多路復用)在充分利用 CPU 有效計算能力這件事件上是非常出色的。
比較典型的有 select/poll/epoll/kevent(這些機制本身之間的優劣今天先不展開說明,后續以 epoll 為例說明)。
這種模式的特點是將要監聽的 socket fd 注冊在 epoll 上,等這個描述符可讀事件或者可寫事件就緒了,那么就會觸發相應的讀操作或者寫操作。
可以簡單地理解為需要 CPU 干活的時候就會告知 CPU 需要做什么事情,實際使用時示例,如圖 7 所示。
圖 7:epoll 示例
這個事情拿一個經典的例子來說明:假如在餐廳就餐,餐廳里有很多顧客(訪問),每連接每線程的方式相當于每個客戶一個服務員(線程相當于一個服務員)。
服務的過程中一個服務員一直為一個客戶服務,那就會出現這個服務員除了真正提供服務以外有很大一段時間可能是空閑的,且隨著客戶數越多服務員數量也會越多,可餐廳的容量是有限的。
因為要同時容納相同數量的服務員和顧客,所以餐廳服務顧客的數量將變成理論容量的 50%。
那這件事件對于老板(老板相當于開發人員,希望可以充分利用 CPU 的計算能力,也就是在 CPU 計算能力<成本>一定的情況下希望盡量的多做一些事情)來說代價就會很大。
線程池的方式是雇傭固定數量的服務員,服務的時候一個服務員服務好幾個客戶,可以理解為一個服務員在客戶 A 面前站 1 分鐘,看看 A 客戶是否需要服務。
如果不需要就到 B 客戶那邊站 1 分鐘,看看 B 客戶是否需要服務,以此類推。這種情況會比之前每個客戶一個服務員的情況節省一些成本,但是還是會出現一些成本上的浪費。
還有一種模式也就是 epoll 的方式,相當于服務員就在總臺等著,客戶有需要的時候就會在桌上的呼叫器上按一下按鈕表示自己需要服務,服務員每次看一下總臺顯示的信息。
比如一共有 100 個客戶,一次可能有 10 個客戶呼叫,這個服務員就會過去為這 10 個客戶服務(假設服務每個客戶的時候不會出現停頓且可以在較短的時間內處理完)。
等這個服務員為這 10 個客戶服務員完以后再重新回到總臺查看哪些客戶需要服務,依此類推。在這種情況下,可能只需要一個服務員,而餐廳剩余的空間可以全部給客戶使用。
Nginx 服務器性能非常好,也能支撐非常多的連接,其網絡模型使用的就是 epoll 的方式,且在實現的時候采用了多個子進程的方式。
相當于同時有多個 epoll 在工作,充分利用了 CPU 多核的特性,所以并發及性能都會比單個 epoll 的方式會有更大的提升。
另外 Redis 緩存服務器大家應該也非常熟悉,用的也是 epoll 的方式,性能也是非常好。
通過這些現成的經典開源項目,大家就可以直觀地理解基于事件驅動這一方式在實際生產環境中的性能是非常高的,性能提升以后并發效果一般都會隨之提升。
但是這種方式在實現的時候是非常考驗編程功底以及邏輯嚴謹性,換句話編程友好性是非常差的。
因為一個完整的上下文邏輯會被切成很多片段,比如“客戶端發送一個命令-服務器端接收命令進行操作-然后返回結果”這個過程。
這個過程至少會包括一個可讀事件、一個可寫事件。可讀事件,簡單地理解就是指這條命令已經發送到服務器端的 tcp 緩存區了,服務器去讀取命令(假設一次讀取完,如果一次讀取的命令不完整,可能會觸發多次讀事件)。
服務器再根據命令進行操作獲取到結果,同時注冊一個可寫事件到 epoll 上,等待下一次可寫事件觸發以后再將結果發送出去。
想象一下當有很多客戶端同時來訪問時,服務器就會出現一種情況——一會兒在處理某個客戶端的讀事件,一會兒在處理另外的客戶端的寫事件。
總之都是在做一個完整訪問的上下文中的一個片段,其中任何一個片段有等待或者卡頓都將引起整個程序的阻塞。
當然這個問題在多線程編程時也是同樣是存在的,只不過有時候大家習慣將線程設置成多個,有些線程阻塞了,但可能其他線程并沒有在同一時刻阻塞。
所以問題不是特別嚴重,更嚴謹的做法是在多線程編程時,將線程池的數量調整到最小進行測試。
如果確實有卡頓,可以確保程序在最快的時間內出現卡頓,從而快速確認邏輯上是否有不足或者缺陷,確認這種卡頓本身是否是正常現象。
語言層提供協程支持
多線程編程的方式明顯是支持了高并發,但因為整個程序線程間上下文調度可能造成 CPU 的利用率不是那么高,而基于事件驅動的編程方式效果是非常好的。
但對編程功底要求非常高,而且在實現的時候需要花費的時間也是最多的,所以一種比較折中的方式是考慮采用提供協程支持的語言比如 golang 這種的。
簡單說就是語言層面抽象出了一種更輕量級的線程,一般稱為協程,在 golang 里又叫 goroutine。
這些底層最終也是需要用操作系統的線程去跑,在 golang 的 runtime 實現時底層用到的操作系統的線程數量相對會少一點。
而上層程序里可以跑很多的 goroutine,這些 goroutine 會在語言層面進行調度,看該由哪個線程來最終執行這個 goroutine。
因為 goroutine 之間的切換代價是遠小于操作系統線程之間的切換代價,而底層用到的操作系統數量又較少,線程間的上下文切換代價也會大大降低。
這類語言能比其他語言的多線程方式提供更好的并發,因為它將操作系統的線程間切換的代價在語言層面盡可能擠壓到最小,同時編程復雜度大大降低,在這類語言中上下文邏輯可以保持連貫。
因為降低了線程間上下文切換的代價,而 goroutine 之間的切換成本相對來說是遠遠小于線程間切換成本。
所以 CPU 的有效計算能力相對來說也不會太低,可以比較容易的獲得了一個高并發且性能還可以的服務。
小結
如何提升單機服務的性能及并發,如果對性能或者高并發的要求沒有達到非常苛刻的要求,選型的時候基于事件驅動的方式可以優先級降低一點,選擇普通的多線程編程即可(其實多數場景都可以滿足了)。
如果想單機的并發程度更好一點,可以考慮選擇有協程支持的語言,如果還嫌不夠,那就將邏輯理順,考慮采用基于事件驅動的模式,這個在 C/C++ 里直接用 select/epoll/kevent 等就可以了。
在 Java 里可以考慮采用 NIO 的方式,而從這點上來說像 golang 這種提供協程支持的語言一般是不支持在程序層面自己實現基于事件驅動的編程方式的。
總結
其實并沒有一刀切的萬能法則,大體原則是根據實際情況具體問題具體分析,找到服務瓶頸,資源不夠加資源,盡可能降低每次訪問的資源消耗,整體服務每個環節盡量做到可以水平擴展。
同時盡量提高單機的有效利用率,從而確保在扛住整個服務的同時盡量降低資源消耗成本。
張成遠,京東資深架構師,《MariaDB原理與實現》作者,開源項目 Speedy 作者,分布式數據庫相關研究方向碩士。負責京東分布式數據庫系統的架構與研發,擅長大規模分布式系統架構。
來自:http://developer.51cto.com/art/201802/565948.htm