1. 程式人生 > >高效能高併發服務的瓶頸及突破思路

高效能高併發服務的瓶頸及突破思路

在各種合理拆分以後,資料拆分以及服務拆分支援擴充套件只是其中的一部分工作,之後還要根據需求看看是否需要引入快取CDN之類的服務,我把這個叫做增長服務鏈路,原來直接打到資料庫的請求,現在可能變成了先打到快取再打到資料庫,對整個服務鏈路長度來說是變長的,增長服務鏈路的原則主要是將越脆弱或者說越容易成為瓶頸的資源(比如資料庫)放置在鏈路的越末端。在增長完服務鏈路之後,還要儘量的縮短訪問鏈路,比如可以在CDN層面就返回的就儘量不要繼續往下走了,如果可以在快取層面返回的就不要去訪問資料庫了,儘可能地讓每次的訪問鏈路變短,可以一步解決的事情就一步解決,可以兩步解決的事情就不要走第三步,本質上是降低每次訪問的資源消耗,尤其是越到鏈路的末端訪問資源的消耗會越大。比如獲取一些產品的圖片資訊可以在訪問鏈路的最前端使用CDN,將訪問儘量擋住,如果CDN上沒有命中,就繼續往後端訪問利用nginx等反向代理將訪問打到相應的圖片伺服器上,而圖片伺服器本身又可以針對性的做一些訪問優化等。比如像價格等資訊比較敏感,如果有更改可能需要立即生效需要直接訪問最新的資料,但是如果讓訪問直接打到資料庫中,資料庫往往直接就打掛了,所以可以考慮在資料庫之前引入redis等快取服務,將訪問打到快取上,價格服務系統本身保證資料庫和快取的強一致,降低對資料庫的訪問壓力。在極端情況下,資料量雖然不是特別大,幾十臺快取機器就可以抗住,但訪問量可能會非常大,可以將所有的資料都放在快取中,如果快取有異常甚至都不用去訪問資料庫直接返回訪問失敗即可。因為在訪問量非常大的情況下,如果快取掛了,訪問直接打到資料庫上,可能瞬間就把資料庫打趴下了,所以在特定場景下可以考慮將快取和資料庫切開,服務只訪問快取,快取失效重新從資料庫中載入資料到快取中再對外服務也是可以的,所以在實踐中是可以靈活變通的。4、小結如何提升整體服務的效能及併發,一句話概括就是:在合理範圍內儘可能的拆分,拆分以後同類服務可以通過水平擴充套件達到整體的高效能高併發,同時將越脆弱的資源放置在鏈路的越末端,訪問的時候儘量將訪問連結縮短,降低每次訪問的資源消耗。

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

前面說的這些情況可以解決大訪問量情況下的高併發問題,但是高效能最終還是要依賴單臺應用的效能,如果單臺應用效能在低訪問量情況下效能已經成渣了,那部署再多機器也解決不了問題,所以接下來聊一下單臺服務本身如果支援高效能高併發。 1、多執行緒/執行緒池方式
圖3 版本一 以TCP server為例來展開說明,最簡單的一個TCP server程式碼,版本一示例如圖3所示。這種方式純粹是一個示例,因為這個server啟動以後只能接受一條連線,也就是隻能跟一個客戶端互動,且該連線斷開以後,後續就連不上了,也就是這個server只能服務一次。這個當然是不行的,於是就有了版本二如圖4所示,版本二可以一次接受一條連線,並進行一些互動處理,當這條連線全部處理完以後才能繼續下一條連線。這個server相當於是序列的,沒有併發可言,所以在版本二的基礎上又演化出了版本三如圖5所示。

圖4 版本二
圖5 版本三這其實是我們經常會接觸到的一種模型,這種模型的特點是每連線每執行緒,MySQL 5.5以前用的就是這種模型,這種模型的特點是當有大量連線的時候會建立大量的執行緒,所以往往需要限制連線總數,如果不做限制可能會出現建立了大量的執行緒,很快就會將記憶體等資源耗幹。 圖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一直在工作,同時又儘量避免執行緒的上下文切換等開銷。圖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&AQ1:在用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社群
連結:http://toutiao.com/a6329244529665310977/
來源:頭條號(今日頭條旗下創作平臺)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

作者:頭條號 / DBAplus社群

連結:http://toutiao.com/a6329244529665310977/
來源:頭條號(今日頭條旗下創作平臺)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。