關於高性能高併發服務這個概念你們應該也都比較熟悉了,今天我主要是想講一下對於如何作一個高性能高併發服務架構的一些本身的思考。
本次分享主要包括三個部分:
1. 服務的瓶頸有哪些
2. 如何提高總體服務的性能及併發
3. 如何提高單機服務的性能及併發
1、服務的瓶頸有哪些
一般來講程序的定義是算法+數據結構+數據,算法簡單的理解就是一種計算方式,數據結構顧名思義是一種存儲組織數據的結構,這二者體現了程序須要用到的計算機資源涉及到CPU資源、內存資源,而數據部分除了內存資源,每每還可能涉及到硬盤資源,甚至是彼此之間傳輸數據時會消耗網絡(網卡)資源。
當咱們搞清楚程序運行起來時涉及哪些資源後,就能夠更好地分析咱們的服務中哪些多是臨界資源。所謂臨界資源就是多個進程(線程)併發訪問某個資源時,該資源同只能服務某個或者某些進程(線程)。
服務的瓶頸主要就是在這些臨界資源上,還有一些資源本來並非臨界資源,好比內存在一開始是夠的,可是由於鏈接數或者線程數不斷的增多,最終致使其成爲臨界資源,其餘的CPU、磁盤、網卡其實和內存同樣,在訪問量增大之後同樣均可能會成爲瓶頸。
因此怎麼作到高性能高併發的服務,簡單地說就是找到服務的瓶頸,在合理的範圍內儘量的消除瓶頸或者下降瓶頸帶來的影響,再通俗一點的說就是資源總量不夠就加資源,確切的說是什麼資源不夠就加什麼資源,同時儘可能下降單次訪問的資源消耗,作到在資源總量必定的狀況下有能力支撐更多的訪問。
2、如何提高總體服務的性能及併發前端
1、數據拆分java

圖1 單數據實例改爲數據庫集羣
最典型的一個臨界資源就是數據庫,數據庫在一個大訪問量的系統中每每是最薄弱的一環,由於數據庫自己的服務能力是有限的,以MySQL爲例,可能MySQL能夠支持的併發鏈接數可能也就幾千個,假設是3000個,若是一個服務對其數據庫的併發訪問若是超過了3000,有部分訪問可能在創建鏈接的時候就失敗了。
在這種狀況下,須要考慮的是如何將數據進行分片,引入多個MySQL實例,增長資源,如圖1所示。
數據庫這個臨界資源經過數據拆分的方式,由原來的一個MySQL實例變成了多個MySQL實例,這種狀況下數據庫資源的總體併發服務能力天然提高了,同時因爲服務壓力被分散,整個數據庫集羣表現出來的性能也會比單個數據庫實例高不少。
存儲類的解決思路基本是相似的,都是將數據拆分,經過引入多個存儲服務實例提高總體存儲服務的能力,無論對於SQL類的仍是NoSQL類的或文件存儲系統等均可以採用這個思路。
2
、服務拆分

圖2 服務拆分
應用程序自身的服務須要根據業務狀況進行合理的細化,讓每一個服務只負責某一類功能,這個思想實際上是和微服務思想相似。
一句話就是儘可能合理地將服務拆分,同時有一個很是重要的原則是讓拆分之後的同類服務儘可能是無狀態或弱關聯,這樣就能夠很容易進行水平擴展,若是拆分之後的同類服務的不一樣實例之間自己是有一些狀態引發彼此很是強的依賴,好比彼此要共享一些信息這些信息又會彼此影響,那這種拆分可能就未必很是的合理,須要結合業務從新進行審視。
固然生產環節上下游拆分之後不一樣的服務彼此之間的關聯又是另一種情形,由於同一個生產環節上每每是走完一個服務環節才能進入下一個服務環節,至關於有多個串行的服務,任何一個環節的服務都有可能瓶頸,因此須要拆分之後針對相應的服務進行單獨優化,這是拆分之後服務與服務之間的關係。
假設各個同類服務自己是無狀態或者弱依賴的狀況下,針對應用服務進行分析,不一樣的應用服務不太同樣,可是一般都會涉及到內存資源以及計算資源,以受內存資源限制爲例,一個應用服務能承受的鏈接數是有限的(鏈接數受限),另外若是涉及上傳下載等大量數據傳輸的狀況網絡資源很快就會成爲瓶頸(網卡打滿),這種狀況下最簡單的方式就是一樣的應用服務實例部署多份,達到水平擴展,如圖2所示。
實際在真正拆分的時候須要考慮具體的業務特色,好比像京東主站這種類型的網站,在用戶在訪問的時候除了加載基本信息之外,還有商品圖片信息、價格信息、庫存信息、購物車信息以及訂單信息發票信息等,以及下單完成之後對應的分揀配送等配套的物流服務,這些都是能夠拆成單獨的服務,拆分之後各個服務各司其職也能作更好的優化。
服務拆分這件事情,打個不是特別恰當的比方,就比如上學時都是學習,可是分了不少的科目,高考的時候要看總分,有些同窗會有偏科的現象,有些科成績好有些科成績差一點,由於分不少科目因此很容易知道本身哪科是比較強的、哪科是比較弱的,爲了保證整體分數最優,通常在弱的科目上都須要多花點精力努力提升一下分數,否則整體分數不會過高。服務拆分也是一樣的道理,拆分之後能夠很容易知道哪一個服務是總體服務的瓶頸,針對瓶頸服務再進行重點優化比等就能夠比較容易的提高總體服務的能力。
三、適當增加服務鏈路,儘可能縮短訪問鏈路,下降單次訪問的資源消耗
在大型的網站服務方案上,在各類合理拆分之後,數據拆分以及服務拆分支持擴展只是其中的一部分工做,以後還要根據需求看看是否須要引入緩存CDN之類的服務,我把這個叫作增加服務鏈路,原來直接打到數據庫的請求,如今可能變成了先打到緩存再打到數據庫,對整個服務鏈路長度來講是變長的,增加服務鏈路的原則主要是將越脆弱或者說越容易成爲瓶頸的資源(好比數據庫)放置在鏈路的越末端。
在增加完服務鏈路以後,還要儘可能的縮短訪問鏈路,好比能夠在CDN層面就返回的就儘可能不要繼續往下走了,若是能夠在緩存層面返回的就不要去訪問數據庫了,儘量地讓每次的訪問鏈路變短,能夠一步解決的事情就一步解決,能夠兩步解決的事情就不要走第三步,本質上是下降每次訪問的資源消耗,尤爲是越到鏈路的末端訪問資源的消耗會越大。
好比獲取一些產品的圖片信息能夠在訪問鏈路的最前端使用CDN,將訪問儘可能擋住,若是CDN上沒有命中,就繼續日後端訪問利用nginx等反向代理將訪問打到相應的圖片服務器上,而圖片服務器自己又能夠針對性的作一些訪問優化等。
好比像價格等信息比較敏感,若是有更改可能須要當即生效須要直接訪問最新的數據,可是若是讓訪問直接打到數據庫中,數據庫每每直接就打掛了,因此能夠考慮在數據庫以前引入redis等緩存服務,將訪問打到緩存上,價格服務系統自己保證數據庫和緩存的強一致,下降對數據庫的訪問壓力。
在極端狀況下,數據量雖然不是特別大,幾十臺緩存機器就能夠抗住,但訪問量可能會很是大,能夠將全部的數據都放在緩存中,若是緩存有異常甚至都不用去訪問數據庫直接返回訪問失敗便可。
由於在訪問量很是大的狀況下,若是緩存掛了,訪問直接打到數據庫上,可能瞬間就把數據庫打趴下了,因此在特定場景下能夠考慮將緩存和數據庫切開,服務只訪問緩存,緩存失效從新從數據庫中加載數據到緩存中再對外服務也是能夠的,因此在實踐中是能夠靈活變通的。
四、小結
如何提高總體服務的性能及併發,一句話歸納就是:
在合理範圍內儘量的拆分,拆分之後同類服務能夠經過水平擴展達到總體的高性能高併發,同時將越脆弱的資源放置在鏈路的越末端,訪問的時候儘可能將訪問連接縮短,下降每次訪問的資源消耗。
3、如何提高單機服務的性能及併發
前面說的這些狀況能夠解決大訪問量狀況下的高併發問題,可是高性能最終仍是要依賴單臺應用的性能,若是單臺應用性能在低訪問量狀況下性能已經成渣了,那部署再多機器也解決不了問題,因此接下來聊一下單臺服務自己若是支持高性能高併發。
一、多線程/線程池方式

圖3 版本一
以TCP server爲例來展開說明,最簡單的一個TCP server代碼,版本一示例如圖3所示。這種方式純粹是一個示例,由於這個server啓動之後只能接受一條鏈接,也就是隻能跟一個客戶端互動,且該鏈接斷開之後,後續就連不上了,也就是這個server只能服務一次。
這個固然是不行的,因而就有了版本二如圖4所示,版本二能夠一次接受一條鏈接,並進行一些交互處理,當這條鏈接所有處理完之後才能繼續下一條鏈接。
這個server至關因而串行的,沒有併發可言,因此在版本二的基礎上又演化出了版本三如圖5所示。

圖4 版本二

圖5 版本三
這實際上是咱們常常會接觸到的一種模型,這種模型的特色是每鏈接每線程,MySQL 5.5之前用的就是這種模型,這種模型的特色是當有大量鏈接的時候會建立大量的線程,因此每每須要限制鏈接總數,若是不作限制可能會出現建立了大量的線程,很快就會將內存等資源耗幹。
圖6 版本四

圖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的上限值反倒下降了,所以使用線程池的時候,線程數的設置須要根據實際狀況進行調整。
二、基於事件驅動的模式
多線程(線程池)的方式能夠較爲方便地進行併發編程,可是多線程的方式對cpu的有效利用率其實並非最高的,真正可以充分利用cpu的編程方式是儘可能讓cpu一直在工做,同時又儘可能避免線程的上下文切換等開銷。
圖7 epoll示例

圖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上,等待下一次可寫事件觸發之後再將結果發送出去,想象一下當有不少客戶端同時來訪問時,服務器就會出現一種狀況——一下子在處理某個客戶端的讀事件,一下子在處理另外的客戶端的寫事件,總之都是在作一個完整訪問的上下文中的一個片斷,其中任何一個片斷有等待或者卡頓都將引發整個程序的阻塞。
固然這個問題在多線程編程時也是一樣是存在的,只不過有時候你們習慣將線程設置成多個,有些線程阻塞了,但可能其餘線程並無在同一時刻阻塞,因此問題不是特別嚴重,更嚴謹的作法是在多線程編程時,將線程池的數量調整到最小進行測試,若是確實有卡頓,能夠確保程序在最快的時間內出現卡頓,從而快速確認邏輯上是否有不足或者缺陷,確認這種卡頓自己是不是正常現象。
三、語言層提供協程支持
多線程編程的方式明顯是支持了高併發,但由於整個程序線程間上下文調度可能形成cpu的利用率不是那麼高,而基於事件驅動的編程方式效果很是好的,但對編程功底要求很是高,並且在實現的時候須要花費的時間也是最多的。因此一種比較折中的方式是考慮採用提供協程支持的語言好比golang這種的。
簡單說就是語言層面抽象出了一種更輕量級的線程,通常稱爲協程,在golang裏又叫goroutine,這些底層最終也是須要用操做系統的線程去跑,在golang的runtime實現時底層用到的操做系統的線程數量相對會少一點,而上層程序裏能夠跑不少的goroutine,這些goroutine會在語言層面進行調度,看該由哪一個線程來最終執行這個goroutine。
由於goroutine之間的切換代價是遠小於操做系統線程之間的切換代價,而底層用到的操做系統數量又較少,線程間的上下文切換代價原本也會大大下降。
這類語言能比其餘語言的多線程方式提供更好的併發,由於它將操做系統的線程間切換的代價在語言層面儘量擠壓到最小,同時編程複雜度大大下降,在這類語言中上下文邏輯能夠保持連貫。由於下降了線程間上下文切換的代價,而goroutine之間的切換成本相對來講是遠遠小於線程間切換成本,因此cpu的有效計算能力相對來講也不會過低,至關於能夠比較容易的得到了一個高併發且性能還能夠的服務。
四、小結
如何提高單機服務的性能及併發,若是對性能或者高併發的要求沒有達到很是苛刻的要求,選型的時候基於事件驅動的方式能夠優先級下降一點,選擇普通的多線程編程便可(其實多數場景均可以知足了),若是想單機的併發程度更好一點,能夠考慮選擇有協程支持的語言,若是還嫌不夠,那就將邏輯理順,考慮採用基於事件驅動的模式,這個在C/C++裏直接用select/epoll/kevent等就能夠了,在java裏能夠考慮採用NIO的方式,而從這點上來講像golang這種提供協程支持的語言通常是不支持在程序層面本身實現基於事件驅動的編程方式的。
4、總結
其實並無一刀切的萬能法則,大致原則是根據實際狀況具體問題具體分析,找到服務瓶頸,資源不夠加資源,儘量下降每次訪問的資源消耗,總體服務每一個環節儘可能作到能夠水平擴展,同時儘可能提升單機的有效利用率,從而確保在扛住整個服務的同時儘可能下降資源消耗成本。
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社羣 連接:http://toutiao.com/a6329244529665310977/ 來源:頭條號(今日頭條旗下創做平臺) 著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。