=高性能緩存架構
在某些複雜的業務場景下,單純依靠存儲系統的性能提升不夠的,典型的場景有:
- 需要經過複雜運算后得出的數據,存儲系統無能為力
例如,一個論壇需要在首頁展示當前有多少用戶同時在線,如果使用MySQL來存儲當前用戶狀態,則每次獲取這個總數都要「count(*)」大量數據,這樣的操作無論怎麼優化MySQL,性能都不會太高。如果要即時展示用戶同時在線數,則MySQL性能無法支撐。
- 讀多寫少的數據,存儲系統有心無力
絕大部分在線業務都是讀多寫少。例如,微博、淘寶、微信這類互聯網業務,讀業務佔了整體業務量的90%以上。以微博為例:一個明星發一條微博,可能幾千萬人來瀏覽。如果使用MySQL來存儲微博,用戶寫微博只有一條insert語句,但每個用戶瀏覽時都要select一次,即使有索引,幾千萬條select語句對MySQL資料庫的壓力也會非常大。
緩存就是為了彌補存儲系統在這些複雜業務場景下的不足,其基本原理是將可能重複使用的數據放到記憶體中,一次生成、多次使用,避免每次使用都去訪問存儲系統。
緩存能夠帶來性能的大幅提升,以Memcache為例,單台Memcache 伺服器簡單的key-value查詢能夠達到TPS 50000以上,其基本的架構是:
緩存雖然能夠大大減輕存儲系統的壓力,但同時也給架構引入了更多複雜性。架構設計時如果沒有針對緩存的複雜性進行處理,某些場景下甚至會導致整個系統崩潰。下面,我來逐一分析緩存的架構設計要點。
緩存穿透
緩存穿透 是指緩存沒有發揮作用,業務系統雖然去緩存查詢數據,但緩存中沒有數據,業務系統需要再次去存儲系統查詢數據。通常情況下有兩種情況:
1.存儲數據不存在
第一種情況是被訪問的數據確實不存在。一般情況下,如果存儲系統中沒有某個數據,則不會在緩存中存儲相應的數據,這樣就導致用戶查詢的時候,在緩存中找不到對應的數據,每次都要去存儲系統中再查詢一遍,然後返回數據不存在。緩存在這個場景中並沒有起到分擔存儲系統訪問壓力的作用。
通常情況下,業務上讀取不存在的數據的請求量並不會太大,但如果出現一些異常情況,例如被黑客攻擊,故意大量訪問某些讀取不存在數據的業務,有可能會將存儲系統拖垮。
這種情況的解決辦法比較簡單,如果查詢存儲系統的數據沒有找到,則直接設置一個默認值(可以是空值,也可以是具體的值)存到緩存中,這樣第二次讀取緩存時就會獲取到默認值,而不會繼續訪問存儲系統。
2.緩存數據生成耗費大量時間或者資源
第二種情況是存儲系統中存在數據,但生成緩存數據需要耗費較長時間或者耗費大量資源。如果剛好在業務訪問的時候緩存失效了,那麼也會出現緩存沒有發揮作用,訪問壓力全部集中在存儲系統上的情況。
典型的就是電商的商品分頁,假設我們在某個電商平台上選擇「手機」這個類別查看,由於數據巨大,不能把所有數據都緩存起來,只能按照分頁來進行緩存,由於難以預測用戶到底會訪問哪些分頁,因此業務上最簡單的就是每次點擊分頁的時候按分頁計算和生成緩存。通常情況下這樣實現是基本滿足要求的,但是如果被競爭對手用爬蟲來遍歷的時候,系統性能就可能出現問題。
具體的場景有:
- 分頁緩存的有效期設置為1天,因為設置太長時間的話,緩存不能反應真實的數據。
- 通常情況下,用戶不會從第1頁到最後1頁全部看完,一般用戶訪問集中在前10頁,因此第10頁以後的緩存過期失效的可能性很大。
- 競爭對手每周來爬取數據,爬蟲會將所有分類的所有數據全部遍歷,從第1頁到最後1頁全部都會讀取,此時很多分頁緩存可能都失效了。
- 由於很多分頁都沒有緩存數據,從資料庫中生成緩存數據又非常耗費性能(order by limit操作),因此爬蟲會將整個資料庫全部拖慢。
這種情況並沒有太好的解決方案,因為爬蟲會遍歷所有的數據,而且什麼時候來爬取也是不確定的,可能是每天都來,也可能是每周,也可能是一個月來一次,我們也不可能為了應對爬蟲而將所有數據永久緩存。通常的應對方案要麼就是識別爬蟲然後禁止訪問,但這可能會影響SEO和推廣;要麼就是做好監控,發現問題后及時處理,因為爬蟲不是攻擊,不會進行暴力破壞,對系統的影響是逐步的,監控發現問題後有時間進行處理。
緩存雪崩
緩存雪崩 是指當緩存失效(過期)后引起系統性能急劇下降的情況。當緩存過期被清除后,業務系統需要重新生成緩存,因此需要再次訪問存儲系統,再次進行運算,這個處理步驟耗時幾十毫秒甚至上百毫秒。而對於一個高併發的業務系統來說,幾百毫秒內可能會接到幾百上千個請求。由於舊的緩存已經被清除,新的緩存還未生成,並且處理這些請求的線程都不知道另外有一個線程正在生成緩存,因此所有的請求都會去重新生成緩存,都會去訪問存儲系統,從而對存儲系統造成巨大的性能壓力。這些壓力又會拖慢整個系統,嚴重的會造成資料庫宕機,從而形成一系列連鎖反應,造成整個系統崩潰。
緩存雪崩的常見解決方法有兩種: 更新鎖機制 和 後台更新機制。
1.更新鎖
對緩存更新操作進行加鎖保護,保證只有一個線程能夠進行緩存更新,未能獲取更新鎖的線程要麼等待鎖釋放后重新讀取緩存,要麼就返回空值或者默認值。
對於採用分散式集群的業務系統,由於存在幾十上百台 伺服器,即使單台 伺服器只有一個線程更新緩存,但幾十上百台 伺服器一起算下來也會有幾十上百個線程同時來更新緩存,同樣存在雪崩的問題。因此分散式集群的業務系統要實現更新鎖機制,需要用到分散式鎖,如ZooKeeper。
2.後台更新
由後台線程來更新緩存,而不是由業務線程來更新緩存,緩存本身的有效期設置為永久,後台線程定時更新緩存。
後台定時機制需要考慮一種特殊的場景,當緩存系統記憶體不夠時,會「踢掉」一些緩存數據,從緩存被「踢掉」到下一次定時更新緩存的這段時間內,業務線程讀取緩存返回空值,而業務線程本身又不會去更新緩存,因此業務上看到的現象就是數據丟了。解決的方式有兩種:
- 後台線程除了定時更新緩存,還要頻繁地去讀取緩存(例如,1秒或者100毫秒讀取一次),如果發現緩存被「踢了」就立刻更新緩存,這種方式實現簡單,但讀取時間間隔不能設置太長,因為如果緩存被踢了,緩存讀取間隔時間又太長,這段時間內業務訪問都拿不到真正的數據而是一個空的緩存值,用戶體驗一般。
- 業務線程發現緩存失效后,通過消息隊列發送一條消息通知後台線程更新緩存。可能會出現多個業務線程都發送了緩存更新消息,但其實對後台線程沒有影響,後台線程收到消息后更新緩存前可以判斷緩存是否存在,存在就不執行更新操作。這種方式實現依賴消息隊列,複雜度會高一些,但緩存更新更及時,用戶體驗更好。
後台更新既適應單機多線程的場景,也適合分散式集群的場景,相比更新鎖機制要簡單一些。
後台更新機制還適合業務剛上線的時候進行緩存預熱。緩存預熱指系統上線后,將相關的緩存數據直接載入到緩存系統,而不是等待用戶訪問才來觸發緩存載入。
緩存熱點
雖然緩存系統本身的性能比較高,但對於一些特別熱點的數據,如果大部分甚至所有的業務請求都命中同一份緩存數據,則這份數據所在的緩存 伺服器的壓力也很大。例如,某明星微博發布「我們」來宣告戀愛了,短時間內上千萬的用戶都會來圍觀。
緩存熱點的解決方案就是複製多份緩存副本,將請求分散到多個緩存 伺服器上,減輕緩存熱點導致的單台緩存 伺服器壓力。以微博為例,對於粉絲數超過100萬的明星,每條微博都可以生成100份緩存,緩存的數據是一樣的,通過在緩存的key裡面加上編號進行區分,每次讀緩存時都隨機讀取其中某份緩存。
緩存副本設計有一個細節需要注意,就是不同的緩存副本不要設置統一的過期時間,否則就會出現所有緩存副本同時生成同時失效的情況,從而引發緩存雪崩效應。正確的做法是設定一個過期時間範圍,不同的緩存副本的過期時間是指定範圍內的隨機值。
由於緩存的各種訪問策略和存儲的訪問策略是相關的,因此上面的各種緩存設計方案通常情況下都是整合在存儲訪問方案中,可以採用「 程式程式碼實現」的中間層方式,也可以採用獨立的中間件來實現。
單 伺服器下的高性能模型
高性能是每個 程式員的追求,無論我們是做一個系統還是寫一行程式碼,都希望能夠達到高性能的效果,而高性能又是最複雜的一環,磁碟、操作系統、CPU、記憶體、緩存、網路、編程語言、架構等,每個都有可能影響系統達到高性能,一行不恰當的debug日誌,就可能將 伺服器的性能從TPS 30000降低到8000;一個tcp\_nodelay參數,就可能將響應時間從2毫秒延長到40毫秒。因此,要做到高性能計算是一件很複雜很有挑戰的事情,軟體系統開發過程中的不同階段都關係著高性能最終是否能夠實現。
站在架構師的角度,當然需要特別關注高性能架構的設計。高性能架構設計主要集中在兩方面:
- 盡量提升單 伺服器的性能,將單 伺服器的性能發揮到極致。
- 如果單 伺服器無法支撐性能,設計 伺服器集群方案。
除了以上兩點,最終系統能否實現高性能,還和具體的實現及編碼相關。但架構設計是高性能的基礎,如果架構設計沒有做到高性能,則後面的具體實現和編碼能提升的空間是有限的。形象地說,架構設計決定了系統性能的上限,實現細節決定了系統性能的下限。
單 伺服器高性能的關鍵之一就是 伺服器採取的併發模型,併發模型有如下兩個關鍵設計點:
- 伺服器如何管理連接。
- 伺服器如何處理請求。
以上兩個設計點最終都和操作系統的I/O模型及進程模型相關。
- I/O模型:阻塞、非阻塞、同步、非同步。
- 進程模型:單進程、多進程、多線程。
在下面詳細介紹併發模型時會用到上面這些基礎的知識點,所以我建議你先檢測一下對這些基礎知識的掌握情況,更多內容你可以參考《UNIX網路編程》三卷本。
PPC
PPC是Process Per Connection的縮寫,其含義是指每次有新的連接就新建一個進程去專門處理這個連接的請求,這是傳統的UNIX網路 伺服器所採用的模型。基本的流程圖是:
- 父進程接受連接(圖中accept)。
- 父進程「fork」子進程(圖中fork)。
- 子進程處理連接的讀寫請求(圖中子進程read、業務處理、write)。
- 子進程關閉連接(圖中子進程中的close)。
注意,圖中有一個小細節,父進程「fork」子進程后,直接調用了close,看起來好像是關閉了連接,其實只是將連接的文件描述符引用計數減一,真正的關閉連接是等子進程也調用close后,連接對應的文件描述符引用計數變為0后,操作系統才會真正關閉連接,更多細節請參考《UNIX網路編程:卷一》。
PPC模式實現簡單,比較適合 伺服器的連接數沒那麼多的情況,例如資料庫 伺服器。對於普通的業務 伺服器,在互聯網興起之前,由於 伺服器的訪問量和併發量並沒有那麼大,這種模式其實運作得也挺好,世界上第一個web 伺服器CERN httpd就採用了這種模式(具體你可以參考 https://en.wikipedia.org/wiki/CERN\_httpd)。互聯網興起后, 伺服器的併發和訪問量從幾十劇增到成千上萬,這種模式的弊端就凸顯出來了,主要體現在這幾個方面:
- fork代價高:站在操作系統的角度,創建一個進程的代價是很高的,需要分配很多內核資源,需要將記憶體映像從父進程複製到子進程。即使現在的操作系統在複製記憶體映像時用到了Copy on Write(寫時複製)技術,總體來說創建進程的代價還是很大的。
- 父子進程通信複雜:父進程「fork」子進程時,文件描述符可以通過記憶體映像複製從父進程傳到子進程,但「fork」完成後,父子進程通信就比較麻煩了,需要採用IPC(Interprocess Communication)之類的進程通信方案。例如,子進程需要在close之前告訴父進程自己處理了多少個請求以支撐父進程進行全局的統計,那麼子進程和父進程必須採用IPC方案來傳遞資訊。
- 支持的併發連接數量有限:如果每個連接存活時間比較長,而且新的連接又源源不斷的進來,則進程數量會越來越多,操作系統進程調度和切換的頻率也越來越高,系統的壓力也會越來越大。因此,一般情況下,PPC方案能處理的併發連接數量最大也就幾百。
prefork
PPC模式中,當連接進來時才fork新進程來處理連接請求,由於fork進程代價高,用戶訪問時可能感覺比較慢,prefork模式的出現就是為了解決這個問題。
顧名思義,prefork就是提前創建進程(pre-fork)。系統在啟動的時候就預先創建好進程,然後才開始接受用戶的請求,當有新的連接進來的時候,就可以省去fork進程的操作,讓用戶訪問更快、體驗更好。prefork的基本示意圖是:
prefork的實現關鍵就是多個子進程都accept同一個socket,當有新的連接進入時,操作系統保證只有一個進程能最後accept成功。但這裡也存在一個小小的問題:「驚群」現象,就是指雖然只有一個子進程能accept成功,但所有阻塞在accept上的子進程都會被喚醒,這樣就導致了不必要的進程調度和上下文切換了。幸運的是,操作系統可以解決這個問題,例如Linux 2.6版本后內核已經解決了accept驚群問題。
prefork模式和PPC一樣,還是存在父子進程通信複雜、支持的併發連接數量有限的問題,因此目前實際應用也不多。Apache 伺服器提供了MPM prefork模式,推薦在需要可靠性或者與舊軟體兼容的 網站時採用這種模式,默認情況下最大支持256個併發連接。
TPC
TPC是Thread Per Connection的縮寫,其含義是指每次有新的連接就新建一個線程去專門處理這個連接的請求。與進程相比,線程更輕量級,創建線程的消耗比進程要少得多;同時多線程是共享進程記憶體空間的,線程通信相比進程通信更簡單。因此,TPC實際上是解決或者弱化了PPC fork代價高的問題和父子進程通信複雜的問題。
TPC的基本流程是:
- 父進程接受連接(圖中accept)。
- 父進程創建子線程(圖中pthread)。
- 子線程處理連接的讀寫請求(圖中子線程read、業務處理、write)。
- 子線程關閉連接(圖中子線程中的close)。
注意,和PPC相比,主進程不用「close」連接了。原因是在於子線程是共享主進程的進程空間的,連接的文件描述符並沒有被複制,因此只需要一次close即可。
TPC雖然解決了fork代價高和進程通信複雜的問題,但是也引入了新的問題,具體表現在:
- 創建線程雖然比創建進程代價低,但並不是沒有代價,高併發時(例如每秒上萬連接)還是有性能問題。
- 無須進程間通信,但是線程間的互斥和共享又引入了複雜度,可能一不小心就導致了死鎖問題。
- 多線程會出現互相影響的情況,某個線程出現異常時,可能導致整個進程退出(例如記憶體越界)。
除了引入了新的問題,TPC還是存在CPU線程調度和切換代價的問題。因此,TPC方案本質上和PPC方案基本類似,在併發幾百連接的場景下,反而更多地是採用PPC的方案,因為PPC方案不會有死鎖的風險,也不會多進程互相影響,穩定性更高。
prethread
TPC模式中,當連接進來時才創建新的線程來處理連接請求,雖然創建線程比創建進程要更加輕量級,但還是有一定的代價,而prethread模式就是為了解決這個問題。
和prefork類似,prethread模式會預先創建線程,然後才開始接受用戶的請求,當有新的連接進來的時候,就可以省去創建線程的操作,讓用戶感覺更快、體驗更好。
由於多線程之間數據共享和通信比較方便,因此實際上prethread的實現方式相比prefork要靈活一些,常見的實現方式有下面幾種:
- 主進程accept,然後將連接交給某個線程處理。
- 子線程都嘗試去accept,最終只有一個線程accept成功,方案的基本示意圖如下:
Apache 伺服器的MPM worker模式本質上就是一種prethread方案,但稍微做了改進。Apache 伺服器會首先創建多個進程,每個進程裡面再創建多個線程,這樣做主要是為了考慮穩定性,即:即使某個子進程裡面的某個線程異常導致整個子進程退出,還會有其他子進程繼續提供服務,不會導致整個 伺服器全部掛掉。
prethread理論上可以比prefork支持更多的併發連接,Apache 伺服器MPM worker模式默認支持16 × 25 = 400 個併發處理線程。
Reactor
PPC模式最主要的問題就是每個連接都要創建進程(為了描述簡潔,這裡只以PPC和進程為例,實際上換成TPC和線程,原理是一樣的),連接結束後進程就銷毀了,這樣做其實是很大的浪費。為了解決這個問題,一個自然而然的想法就是資源復用,即不再單獨為每個連接創建進程,而是創建一個進程池,將連接分配給進程,一個進程可以處理多個連接的業務。
引入資源池的處理方式后,會引出一個新的問題:進程如何才能高效地處理多個連接的業務?當一個連接一個進程時,進程可以採用「read -> 業務處理 -> write」的處理流程,如果當前連接沒有數據可以讀,則進程就阻塞在read操作上。這種阻塞的方式在一個連接一個進程的場景下沒有問題,但如果一個進程處理多個連接,進程阻塞在某個連接的read操作上,此時即使其他連接有數據可讀,進程也無法去處理,很顯然這樣是無法做到高性能的。
解決這個問題的最簡單的方式是將read操作改為非阻塞,然後進程不斷地輪詢多個連接。這種方式能夠解決阻塞的問題,但解決的方式並不優雅。首先,輪詢是要消耗CPU的;其次,如果一個進程處理幾千上萬的連接,則輪詢的效率是很低的。
為了能夠更好地解決上述問題,很容易可以想到,只有當連接上有數據的時候進程才去處理,這就是I/O多路復用技術的來源。
I/O多路復用技術歸納起來有兩個關鍵實現點:
- 當多條連接共用一個阻塞對象后,進程只需要在一個阻塞對象上等待,而無須再輪詢所有連接,常見的實現方式有select、epoll、kqueue等。
- 當某條連接有新的數據可以處理時,操作系統會通知進程,進程從阻塞狀態返回,開始進行業務處理。
I/O多路復用結合線程池,完美地解決了PPC和TPC的問題,而且「大神們」給它取了一個很牛的名字:Reactor,中文是「反應堆」。聯想到「核反應堆」,聽起來就很嚇人,實際上這裡的「反應」不是聚變、裂變反應的意思,而是「 事件反應」的意思,可以通俗地理解為「 來了一個事件我就有相應的反應」,這裡的「我」就是Reactor,具體的反應就是我們寫的程式碼,Reactor會根據事件類型來調用相應的程式碼進行處理。Reactor模式也叫Dispatcher模式(在很多開放原始碼的系統裡面會看到這個名稱的類,其實就是實現Reactor模式的),更加貼近模式本身的含義,即I/O多路復用統一監聽事件,收到事件後分配(Dispatch)給某個進程。
Reactor模式的核心組成部分包括Reactor和處理資源池(進程池或線程池),其中Reactor負責監聽和分配事件,處理資源池負責處理事件。初看Reactor的實現是比較簡單的,但實際上結合不同的業務場景,Reactor模式的具體實現方案靈活多變,主要體現在:
- Reactor的數量可以變化:可以是一個Reactor,也可以是多個Reactor。
- 資源池的數量可以變化:以進程為例,可以是單個進程,也可以是多個進程(線程類似)。
將上面兩個因素排列組合一下,理論上可以有4種選擇,但由於「多Reactor單進程」實現方案相比「單Reactor單進程」方案,既複雜又沒有性能優勢,因此「多Reactor單進程」方案僅僅是一個理論上的方案,實際沒有應用。
最終Reactor模式有這三種典型的實現方案:
- 單Reactor單進程/線程。
- 單Reactor多線程。
- 多Reactor多進程/線程。
以上方案具體選擇進程還是線程,更多地是和編程語言及平台相關。例如,Java語言一般使用線程(例如,Netty),C語言使用進程和線程都可以。例如,Nginx使用進程,Memcache使用線程。
1.單Reactor單進程/線程
單Reactor單進程/線程的方案示意圖如下(以進程為例):
注意,select、accept、read、send是標準的網路編程API,dispatch和「業務處理」是需要完成的操作,其他方案示意圖類似。
詳細說明一下這個方案:
- Reactor對象通過select監控連接事件,收到事件后通過dispatch進行分發。
- 如果是連接建立的事件,則由Acceptor處理,Acceptor通過accept接受連接,並創建一個Handler來處理連接後續的各種事件。
- 如果不是連接建立事件,則Reactor會調用連接對應的Handler(第2步中創建的Handler)來進行響應。
- Handler會完成read->業務處理->send的完整業務流程。
單Reactor單進程的模式優點就是很簡單,沒有進程間通信,沒有進程競爭,全部都在同一個進程內完成。但其缺點也是非常明顯,具體表現有:
- 只有一個進程,無法發揮多核CPU的性能;只能採取部署多個系統來利用多核CPU,但這樣會帶來運維複雜度,本來只要維護一個系統,用這種方式需要在一台機器上維護多套系統。
- Handler在處理某個連接上的業務時,整個進程無法處理其他連接的事件,很容易導致性能瓶頸。
因此,單Reactor單進程的方案在實踐中應用場景不多, 只適用於業務處理非常快速的場景,目前比較著名的開放原始碼軟體中使用單Reactor單進程的是Redis。
需要注意的是,C語言編寫系統的一般使用單Reactor單進程,因為沒有必要在進程中再創建線程;而Java語言編寫的一般使用單Reactor單線程,因為Java虛擬機是一個進程,虛擬機中有很多線程,業務線程只是其中的一個線程而已。
2.單Reactor多線程
為了克服單Reactor單進程/線程方案的缺點,引入多進程/多線程是顯而易見的,這就產生了第2個方案:單Reactor多線程。
單Reactor多線程方案示意圖是:
我來介紹一下這個方案:
- 主線程中,Reactor對象通過select監控連接事件,收到事件后通過dispatch進行分發。
- 如果是連接建立的事件,則由Acceptor處理,Acceptor通過accept接受連接,並創建一個Handler來處理連接後續的各種事件。
- 如果不是連接建立事件,則Reactor會調用連接對應的Handler(第2步中創建的Handler)來進行響應。
- Handler只負責響應事件,不進行業務處理;Handler通過read讀取到數據后,會發給Processor進行業務處理。
- Processor會在獨立的子線程中完成真正的業務處理,然後將響應結果發給主進程的Handler處理;Handler收到響應后通過send將響應結果返回給client。
單Reator多線程方案能夠充分利用多核多CPU的處理能力,但同時也存在下面的問題:
- 多線程數據共享和訪問比較複雜。例如,子線程完成業務處理后,要把結果傳遞給主線程的Reactor進行發送,這裡涉及共享數據的互斥和保護機制。以Java的NIO為例,Selector是線程安全的,但是通過Selector.selectKeys()返回的鍵的集合是非線程安全的,對selected keys的處理必須單線程處理或者採取同步措施進行保護。
- Reactor承擔所有事件的監聽和響應,只在主線程中運行,瞬間高併發時會成為性能瓶頸。
你可能會發現,我只列出了「單Reactor多線程」方案,沒有列出「單Reactor多進程」方案,這是什麼原因呢?主要原因在於如果採用多進程,子進程完成業務處理后,將結果返回給父進程,並通知父進程發送給哪個client,這是很麻煩的事情。因為父進程只是通過Reactor監聽各個連接上的事件然後進行分配,子進程與父進程通信時並不是一個連接。如果要將父進程和子進程之間的通信模擬為一個連接,並加入Reactor進行監聽,則是比較複雜的。而採用多線程時,因為多線程是共享數據的,因此線程間通信是非常方便的。雖然要額外考慮線程間共享數據時的同步問題,但這個複雜度比進程間通信的複雜度要低很多。
3.多Reactor多進程/線程
為了解決單Reactor多線程的問題,最直觀的方法就是將單Reactor改為多Reactor,這就產生了第3個方案:多Reactor多進程/線程。
多Reactor多進程/線程方案示意圖是(以進程為例):
方案詳細說明如下:
- 父進程中mainReactor對象通過select監控連接建立事件,收到事件后通過Acceptor接收,將新的連接分配給某個子進程。
- 子進程的subReactor將mainReactor分配的連接加入連接隊列進行監聽,並創建一個Handler用於處理連接的各種事件。
- 當有新的事件發生時,subReactor會調用連接對應的Handler(即第2步中創建的Handler)來進行響應。
- Handler完成read→業務處理→send的完整業務流程。
多Reactor多進程/線程的方案看起來比單Reactor多線程要複雜,但實際實現時反而更加簡單,主要原因是:
- 父進程和子進程的職責非常明確,父進程只負責接收新連接,子進程負責完成後續的業務處理。
- 父進程和子進程的交互很簡單,父進程只需要把新連接傳給子進程,子進程無須返回數據。
- 子進程之間是互相獨立的,無須同步共享之類的處理(這裡僅限於網路模型相關的select、read、send等無須同步共享,「業務處理」還是有可能需要同步共享的)。
目前著名的開放原始碼系統Nginx採用的是多Reactor多進程,採用多Reactor多線程的實現有Memcache和Netty。
我多說一句,Nginx採用的是多Reactor多進程的模式,但方案與標準的多Reactor多進程有差異。具體差異表現為主進程中僅僅創建了監聽埠,並沒有創建mainReactor來「accept」連接,而是由子進程的Reactor來「accept」連接,通過鎖來控制一次只有一個子進程進行「accept」,子進程「accept」新連接后就放到自己的Reactor進行處理,不會再分配給其他子進程,更多細節請查閱相關資料或閱讀Nginx源碼。
Proactor
Reactor是非阻塞同步網路模型,因為真正的read和send操作都需要用戶進程同步操作。這裡的「同步」指用戶進程在執行read和send這類I/O操作的時候是同步的,如果把I/O操作改為非同步就能夠進一步提升性能,這就是非同步網路模型Proactor。
Proactor中文翻譯為「前攝器」比較難理解,與其類似的單詞是proactive,含義為「主動的」,因此我們照貓畫虎翻譯為「主動器」反而更好理解。Reactor可以理解為「來了事件我通知你,你來處理」,而Proactor可以理解為「 來了事件我來處理,處理完了我通知你」。這裡的「我」就是操作系統內核,「事件」就是有新連接、有數據可讀、有數據可寫的這些I/O事件,「你」就是我們的 程式程式碼。
Proactor模型示意圖是:
詳細介紹一下Proactor方案:
- Proactor Initiator負責創建Proactor和Handler,並將Proactor和Handler都通過Asynchronous Operation Processor註冊到內核。
- Asynchronous Operation Processor負責處理註冊請求,並完成I/O操作。
- Asynchronous Operation Processor完成I/O操作后通知Proactor。
- Proactor根據不同的事件類型回調不同的Handler進行業務處理。
- Handler完成業務處理,Handler也可以註冊新的Handler到內核進程。
理論上Proactor比Reactor效率要高一些,非同步I/O能夠充分利用DMA特性,讓I/O操作與計算重疊,但要實現真正的非同步I/O,操作系統需要做大量的工作。目前Windows下通過IOCP實現了真正的非同步I/O,而在Linux系統下的AIO並不完善,因此在Linux下實現高併發網路編程時都是以Reactor模式為主。所以即使Boost.Asio號稱實現了Proactor模型,其實它在Windows下採用IOCP,而在Linux下是用Reactor模式(採用epoll)模擬出來的非同步模型。
高性能集群架構
單 伺服器無論如何優化,無論採用多好的硬體,總會有一個性能天花板,當單 伺服器的性能無法滿足業務需求時,就需要設計高性能集群來提升系統整體的處理性能。
高性能集群的本質很簡單,通過增加更多的 伺服器來提升系統整體的計算能力。由於計算本身存在一個特點:同樣的輸入數據和邏輯,無論在哪台 伺服器上執行,都應該得到相同的輸出。因此高性能集群設計的複雜度主要體現在任務分配這部分,需要設計合理的任務分配策略,將計算任務分配到多台 伺服器上執行。
高性能集群的複雜性主要體現在需要增加一個任務分配器,以及為任務選擇一個合適的任務分配演算法。對於任務分配器,現在更流行的通用叫法是「負載均衡器」。但這個名稱有一定的誤導性,會讓人潛意識里認為任務分配的目的是要保持各個計算單元的負載達到均衡狀態。而實際上任務分配並不只是考慮計算單元的負載均衡,不同的任務分配演算法目標是不一樣的,有的基於負載考慮,有的基於性能(吞吐量、響應時間)考慮,有的基於業務考慮。考慮到「負載均衡」已經成為了事實上的標準術語,這裡我也用「負載均衡」來代替「任務分配」,但請你時刻記住, 負載均衡不只是為了計算單元的負載達到均衡狀態。
負載均衡分類
常見的負載均衡系統包括3種:DNS負載均衡、硬體負載均衡和軟體負載均衡。
DNS負載均衡
DNS是最簡單也是最常見的負載均衡方式,一般用來實現地理級別的均衡。例如,北方的用戶訪問北京的機房,南方的用戶訪問深圳的機房。DNS負載均衡的本質是DNS解析同一個域名可以返回不同的IP地址。例如,同樣是www.baidu.com,北方用戶解析后獲取的地址是61.135.165.224(這是北京機房的IP),南方用戶解析后獲取的地址是14.215.177.38(這是深圳機房的IP)。
下面是DNS負載均衡的簡單示意圖:
DNS負載均衡實現簡單、成本低,但也存在粒度太粗、負載均衡演算法少等缺點。仔細分析一下優缺點,其優點有:
- 簡單、成本低:負載均衡工作交給DNS 伺服器處理,無須自己開發或者維護負載均衡設備。
- 就近訪問,提升訪問速度:DNS解析時可以根據請求來源IP,解析成距離用戶最近的 伺服器地址,可以加快訪問速度,改善性能。
缺點有:
- 更新不及時:DNS緩存的時間比較長,修改DNS配置后,由於緩存的原因,還是有很多用戶會繼續訪問修改前的IP,這樣的訪問會失敗,達不到負載均衡的目的,並且也影響用戶正常使用業務。
- 擴展性差:DNS負載均衡的控制權在域名商那裡,無法根據業務特點針對其做更多的定製化功能和擴展特性。
- 分配策略比較簡單:DNS負載均衡支持的演算法少;不能區分 伺服器的差異(不能根據系統與服務的狀態來判斷負載);也無法感知後端 伺服器的狀態。
針對DNS負載均衡的一些缺點,對於時延和故障敏感的業務,有一些公司自己實現了HTTP-DNS的功能,即使用HTTP協議實現一個私有的DNS系統。這樣的方案和通用的DNS優缺點正好相反。
硬體負載均衡
硬體負載均衡是通過單獨的硬體設備來實現負載均衡功能,這類設備和路由器、交換機類似,可以理解為一個用於負載均衡的基礎網路設備。目前業界典型的硬體負載均衡設備有兩款:F5和A10。這類設備性能強勁、功能強大,但價格都不便宜,一般只有「土豪」公司才會考慮使用此類設備。普通業務量級的公司一是負擔不起,二是業務量沒那麼大,用這些設備也是浪費。
硬體負載均衡的優點是:
- 功能強大:全面支持各層級的負載均衡,支持全面的負載均衡演算法,支持全局負載均衡。
- 性能強大:對比一下,軟體負載均衡支持到10萬級併發已經很厲害了,硬體負載均衡可以支持100萬以上的併發。
- 穩定性高:商用硬體負載均衡,經過了良好的嚴格測試,經過大規模使用,穩定性高。
- 支持安全防護:硬體均衡設備除具備負載均衡功能外,還具備防火牆、防DDoS攻擊等安全功能。
硬體負載均衡的缺點是:
- 價格昂貴:最普通的一台F5就是一台「馬6」,好一點的就是「Q7」了。
- 擴展能力差:硬體設備,可以根據業務進行配置,但無法進行擴展和定製。
軟體負載均衡
軟體負載均衡通過負載均衡軟體來實現負載均衡功能,常見的有Nginx和LVS,其中Nginx是軟體的7層負載均衡,LVS是Linux內核的4層負載均衡。4層和7層的區別就在於 協議 和 靈活性,Nginx支持HTTP、E-mail協議;而LVS是4層負載均衡,和協議無關,幾乎所有應用都可以做,例如,聊天、資料庫等。
軟體和硬體的最主要區別就在於性能,硬體負載均衡性能遠遠高於軟體負載均衡性能。Nginx的性能是萬級,一般的Linux 伺服器上裝一個Nginx大概能到5萬/秒;LVS的性能是十萬級,據說可達到80萬/秒;而F5性能是百萬級,從200萬/秒到800萬/秒都有(數據來源網路,僅供參考,如需採用請根據實際業務場景進行性能測試)。當然,軟體負載均衡的最大優勢是便宜,一台普通的Linux 伺服器批發價大概就是1萬元左右,相比F5的價格,那就是自行車和寶馬的區別了。
除了使用開放原始碼的系統進行負載均衡,如果業務比較特殊,也可能基於開放原始碼系統進行定製(例如,Nginx外掛),甚至進行自研。
下面是Nginx的負載均衡架構示意圖:
軟體負載均衡的優點:
- 簡單:無論是部署還是維護都比較簡單。
- 便宜:只要買個Linux 伺服器,裝上軟體即可。
- 靈活:4層和7層負載均衡可以根據業務進行選擇;也可以根據業務進行比較方便的擴展,例如,可以通過Nginx的外掛來實現業務的定製化功能。
其實下面的缺點都是和硬體負載均衡相比的,並不是說軟體負載均衡沒法用。
- 性能一般:一個Nginx大約能支撐5萬併發。
- 功能沒有硬體負載均衡那麼強大。
- 一般不具備防火牆和防DDoS攻擊等安全功能。
負載均衡典型架構
前面我們介紹了3種常見的負載均衡機制:DNS負載均衡、硬體負載均衡、軟體負載均衡,每種方式都有一些優缺點,但並不意味著在實際應用中只能基於它們的優缺點進行非此即彼的選擇,反而是基於它們的優缺點進行組合使用。具體來說,組合的 基本原則 為:DNS負載均衡用於實現地理級別的負載均衡;硬體負載均衡用於實現集群級別的負載均衡;軟體負載均衡用於實現機器級別的負載均衡。
我以一個假想的實例來說明一下這種組合方式,如下圖所示。
整個系統的負載均衡分為三層。
- 地理級別負載均衡:www.xxx.com部署在北京、廣州、上海三個機房,當用戶訪問時,DNS會根據用戶的地理位置來決定返回哪個機房的IP,圖中返回了廣州機房的IP地址,這樣用戶就訪問到廣州機房了。
- 集群級別負載均衡:廣州機房的負載均衡用的是F5設備,F5收到用戶請求后,進行集群級別的負載均衡,將用戶請求發給3個本地集群中的一個,我們假設F5將用戶請求發給了「廣州集群2」。
- 機器級別的負載均衡:廣州集群2的負載均衡用的是Nginx,Nginx收到用戶請求后,將用戶請求發送給集群裡面的某台 伺服器, 伺服器處理用戶的業務請求並返回業務響應。
需要注意的是,上圖只是一個示例,一般在大型業務場景下才會這樣用,如果業務量沒這麼大,則沒有必要嚴格照搬這套架構。例如,一個大學的論壇,完全可以不需要DNS負載均衡,也不需要F5設備,只需要用Nginx作為一個簡單的負載均衡就足夠了。
接下來我介紹一下負載均衡演算法以及它們的優缺點:
輪詢
負載均衡系統收到請求后,按照順序輪流分配到 伺服器上。
輪詢是最簡單的一個策略,無須關注 伺服器本身的狀態,例如:
- 某個 伺服器當前因為觸發了 程式bug進入了死循環導致CPU負載很高,負載均衡系統是不感知的,還是會繼續將請求源源不斷地發送給它。
- 集群中有新的機器是32核的,老的機器是16核的,負載均衡系統也是不關注的,新老機器分配的任務數是一樣的。
需要注意的是負載均衡系統無須關注「 伺服器本身狀態」,這裡的關鍵詞是「本身」。也就是說, 只要 伺服器在運行,運行狀態是不關注的。但如果 伺服器直接宕機了,或者 伺服器和負載均衡系統斷連了,這時負載均衡系統是能夠感知的,也需要做出相應的處理。例如,將 伺服器從可分配 伺服器列表中刪除,否則就會出現 伺服器都宕機了,任務還不斷地分配給它,這明顯是不合理的。
總而言之,「簡單」是輪詢演算法的優點,也是它的缺點。
加權輪詢
負載均衡系統根據 伺服器權重進行任務分配,這裡的權重一般是根據硬體配置進行靜態配置的,採用動態的方式計算會更加契合業務,但複雜度也會更高。
加權輪詢是輪詢的一種特殊形式,其主要目的就是為了 解決不同 伺服器處理能力有差異的問題。例如,集群中有新的機器是32核的,老的機器是16核的,那麼理論上我們可以假設新機器的處理能力是老機器的2倍,負載均衡系統就可以按照2:1的比例分配更多的任務給新機器,從而充分利用新機器的性能。
加權輪詢解決了輪詢演算法中無法根據 伺服器的配置差異進行任務分配的問題,但同樣存在無法根據 伺服器的狀態差異進行任務分配的問題。
負載最低優先
負載均衡系統將任務分配給當前負載最低的 伺服器,這裡的負載根據不同的任務類型和業務場景,可以用不同的指標來衡量。例如:
- LVS這種4層網路負載均衡設備,可以以「連接數」來判斷 伺服器的狀態, 伺服器連接數越大,表明 伺服器壓力越大。
- Nginx這種7層網路負載系統,可以以「HTTP請求數」來判斷 伺服器狀態(Nginx內置的負載均衡演算法不支持這種方式,需要進行擴展)。
- 如果我們自己開發負載均衡系統,可以根據業務特點來選擇指標衡量系統壓力。如果是CPU密集型,可以以「CPU負載」來衡量系統壓力;如果是I/O密集型,可以以「I/O負載」來衡量系統壓力。
負載最低優先的演算法解決了輪詢演算法中無法感知 伺服器狀態的問題,由此帶來的代價是複雜度要增加很多。例如:
- 最少連接數優先的演算法要求負載均衡系統統計每個 伺服器當前建立的連接,其應用場景僅限於負載均衡接收的任何連接請求都會轉發給 伺服器進行處理,否則如果負載均衡系統和 伺服器之間是固定的連接池方式,就不適合採取這種演算法。例如,LVS可以採取這種演算法進行負載均衡,而一個通過連接池的方式連接MySQL集群的負載均衡系統就不適合採取這種演算法進行負載均衡。
- CPU負載最低優先的演算法要求負載均衡系統以某種方式收集每個 伺服器的CPU負載,而且要確定是以1分鐘的負載為標準,還是以15分鐘的負載為標準,不存在1分鐘肯定比15分鐘要好或者差。不同業務最優的時間間隔是不一樣的,時間間隔太短容易造成頻繁波動,時間間隔太長又可能造成峰值來臨時響應緩慢。
負載最低優先演算法基本上能夠比較完美地解決輪詢演算法的缺點,因為採用這種演算法后,負載均衡系統需要感知 伺服器當前的運行狀態。當然,其代價是複雜度大幅上升。通俗來講,輪詢可能是5行程式碼就能實現的演算法,而負載最低優先演算法可能要1000行才能實現,甚至需要負載均衡系統和 伺服器都要開發程式碼。負載最低優先演算法如果本身沒有設計好,或者不適合業務的運行特點,演算法本身就可能成為性能的瓶頸,或者引發很多莫名其妙的問題。所以負載最低優先演算法雖然效果看起來很美好,但實際上真正應用的場景反而沒有輪詢(包括加權輪詢)那麼多。
性能最優類
負載最低優先類演算法是站在 伺服器的角度來進行分配的,而性能最優優先類演算法則是站在客戶端的角度來進行分配的,優先將任務分配給處理速度最快的 伺服器,通過這種方式達到最快響應客戶端的目的。
和負載最低優先類演算法類似,性能最優優先類演算法本質上也是感知了 伺服器的狀態,只是通過響應時間這個外部標準來衡量 伺服器狀態而已。因此性能最優優先類演算法存在的問題和負載最低優先類演算法類似,複雜度都很高,主要體現在:
- 負載均衡系統需要收集和分析每個 伺服器每個任務的響應時間,在大量任務處理的場景下,這種收集和統計本身也會消耗較多的性能。
- 為了減少這種統計上的消耗,可以採取採樣的方式來統計,即不統計所有任務的響應時間,而是抽樣統計部分任務的響應時間來估算整體任務的響應時間。採樣統計雖然能夠減少性能消耗,但使得複雜度進一步上升,因為要確定合適的 採樣率,採樣率太低會導致結果不準確,採樣率太高會導致性能消耗較大,找到合適的採樣率也是一件複雜的事情。
- 無論是全部統計還是採樣統計,都需要選擇合適的 周期:是10秒內性能最優,還是1分鐘內性能最優,還是5分鐘內性能最優……沒有放之四海而皆準的周期,需要根據實際業務進行判斷和選擇,這也是一件比較複雜的事情,甚至出現系統上線后需要不斷地調優才能達到最優設計。
Hash類
負載均衡系統根據任務中的某些關鍵資訊進行Hash運算,將相同Hash值的請求分配到同一台 伺服器上,這樣做的目的主要是為了滿足特定的業務需求。例如:
- 源地址Hash
將來源於同一個源IP地址的任務分配給同一個 伺服器進行處理,適合於存在事務、會話的業務。例如,當我們通過瀏覽器登錄網上銀行時,會生成一個會話資訊,這個會話是臨時的,關閉瀏覽器后就失效。網上銀行後台無須持久化會話資訊,只需要在某台 伺服器上臨時保存這個會話就可以了,但需要保證用戶在會話存在期間,每次都能訪問到同一個 伺服器,這種業務場景就可以用源地址Hash來實現。
- ID Hash
將某個ID標識的業務分配到同一個 伺服器中進行處理,這裡的ID一般是臨時性數據的ID(如session id)。例如,上述的網上銀行登錄的例子,用session id hash同樣可以實現同一個會話期間,用戶每次都是訪問到同一台 伺服器的目的。
總結
本文講述了高性能架構設計中緩存設計需要注意的幾個關鍵點,常見的緩存問題,並圍繞單台 伺服器的高性能模式,介紹了PPC,TPC的模式,之後介紹了適用於互聯網海量用戶的Reactor, Proactor模式,這兩種模式是支持高併發的架構模型,最後我們談了集群下的負載均衡以及典型架構。