高併發Web服務的演變:節約系統記憶體和CPU

徐漢彬發表於2015-09-24

一、越來越多的併發連線數

現在的Web系統面對的併發連線數在近幾年呈現指數增長,高併發成為了一種常態,給Web系統帶來不小的挑戰。以最簡單粗暴的方式解決,就是增加Web系統的機器和升級硬體配置。雖然現在的硬體越來越便宜,但是一味地通過增加機器來解決併發量的增長,成本是非常高昂的。結合技術優化方案,才是更有效的解決方法。

併發連線數為什麼呈指數增長?實際上,從這幾年的使用者基數上看,這個數量並沒有出現指數增長,因此它並非主要原因。主要原因,還是web變得更復雜,互動更豐富所導致的。

1. 頁面元素增多,互動複雜

Web頁面元素越來越多,更為豐富。更多的資源元素,意味著更多的下載請求。Web系統的互動越來越複雜,互動場景和次數也大幅增加。以“www.qq.com”的首頁為例子,重新整理一次,大概會有244個請求。並且,在頁面開啟完成之後,還會有一些定時的查詢或者上報請求持續運作。

目前的Http請求,為了減少反覆的建立和銷燬連線行為,通常都建立長連線(Connection keep-alive)。一經建立,這個連線會被保持住一段時間,被後續請求複用。然而,它也帶來了另一個新的問題,連線的保持是會佔用Web系統服務端資源的,如果不充分使用這個連線,會導致資源浪費。長連線被建立後,首批資源傳輸完畢,之後幾乎沒有資料互動,一直到超時時間,才會自動釋放長連線佔據的系統資源。

除此之外,還有一些Web需求本身就需要長期保持連線的,例如Web socket。

2. 主流的本瀏覽器的連線數在增加

面對越來越豐富的Web資源,主流瀏覽器併發連線數也在增加,同一個域下,早期的瀏覽器一般只有1-2個下載連線,而目前的主流瀏覽器通常在2-6個。增加瀏覽器併發連線數目,在需要下載資源比較多的場景下,可以加快頁面的載入速度。更多的連線對瀏覽器載入頁面元素是有好處的,在某些連線遭遇“網路阻塞”的情況下,其他正常的下載連線可以繼續工作。

這樣自然無形增加了Web系統後端的壓力,更多的下載連線意味著佔據了更多的Web伺服器的資源。而在使用者訪問高峰期,自熱而然就形成了“高併發”場景。這些連線和請求,佔據了伺服器的大量CPU和記憶體等資源。尤其在資源數目超過100+的網站頁面中,使用更多的下載連線,非常有必要。

 

二、Web前端優化,降低服務端壓力

在緩解“高併發”的壓力,需要前端和後端的共同配合優化,才能達到最大效果。在使用者第一線的Web前端,可以起到減少或者減輕Http請求的效果。

1. 減少Web請求

常用的實現方法是通過Http協議頭中的expire或max-age來控制,將靜態內容放入瀏覽器的本地快取,在之後的一段時間裡,不再請求Web伺服器,直接使用本地資源。還有HTML5中的本地儲存技術(LocalStorage),也被作為一個強大的資料本地快取。

這種方案快取後,根本不傳送請求到Web伺服器,大幅降低伺服器壓力,也帶來了良好的使用者體驗。但是,這種方案,對首次訪問的使用者無效,同時,也影響部分Web資源的實時性。

2. 減輕Web請求

瀏覽器的本地快取是存在過期時間的,一旦過期,就必須重新向伺服器請求。這個時候,會有兩種情形:

(1)伺服器的資源內容沒有更新,瀏覽器請求Web資源,伺服器回覆“可以繼續使用本地快取”。(發生通訊,但是Web伺服器只需要做簡單“回覆”)

(2)伺服器的檔案或者內容已經更新,瀏覽器請求Web資源,Web伺服器通過網路傳輸新的資源內容。(發生通訊,Web伺服器需要完成複雜的傳輸工作)

這裡的協商方式是通過Http協議的Last-Modified或Etag來控制,這個時候請求伺服器,如果是內容沒有發生變更的情況,伺服器會返回304 Not Modified。這樣的話,就不需要每次請求Web伺服器都做複雜的傳輸完整資料檔案的工作,只要簡單的http應答就可以達到相同的效果。

雖然上述請求,起到“減輕”Web伺服器的壓力,但是連線仍然被建立,請求也發生了。

3. 合併頁面請求

如果是比較老一些的Web開發者,應該會更有印象,在ajax盛行之前。頁面大部分都是直接輸出的,並沒有這麼多的ajax請求,Web後端將頁面內容完全拼湊好了,再返回給前端。那個時候,頁面靜態化,是一個挺廣泛的優化方式。後來,被互動更友好的ajax漸漸替代了,一個頁面的請求也變得越來越多。

由於移動端的網路(2G/3G)比起PC寬頻差很多,並且部分手機配置比較低,面對一個超過100個請求的網頁,載入的速度會緩慢很多。於是,優化的方向又重新回到合併頁面元素,減少請求數量:

(1)合併HTML展示內容。將CSS和JS直接嵌入到HTML頁面內,不通過連線的方式引入。

(2)Ajax動態內容合併請求。對於動態內容,將10次Ajax請求合併為1次的批量資訊查詢。

(3)小圖片合併,通過CSS的偏移量技術Sprites,將很多小圖片合併為一張。這個優化方式,在PC端的Web優化中,也非常常見。

合併請求,減少了傳輸資料的次數,也就是相當於將它們從一個一個地請求,變為一次的“批量”請求。上述優化方法,到達“減輕”Web伺服器壓力的目的,減少了需要建立的連線。

 

三、 節約Web服務端的記憶體

前端的優化完成,我們就需要著眼於Web服務端本身。記憶體是Web伺服器非常重要的資源,更多的記憶體通常意味著可以同時放入更多的工作任務。就Web服務佔用記憶體而言,可以粗略劃分:

(1)用來維持連線的基本記憶體,程式初始化時,會載入一些基礎模組到記憶體。

(2)被傳輸的資料內容載入到各個緩衝區,佔據的記憶體。

(3)程式執行過程中,申請和使用的記憶體。

如果維持一個連線,能夠儘可能少佔用記憶體,那麼我們就可以維持更多的併發連線,從而讓Web伺服器支援更多的併發連線數。

Apache(httpd)是一個成熟並且古老的Web服務,而Apache的發展和演變,一直在追求做到這一點,它試圖不斷減少服務佔據的記憶體,以支援更大的併發量。以Apache的工作模式的演變為視角,我們一起來看看,它們是如何優化記憶體的問題的。

1. prefork MPM,多程式工作模式

prefork是Apache最成熟和穩定的工作模式,即使是現在,仍然被廣泛使用。主程式生成後,它先完成基礎的初始化工作,然後,通過fork預先產生一批的子程式(子程式會複製父程式的記憶體空間,不需要再做基礎的初始化工作)。然後等待服務,之所以預先生成,是為了減少頻繁建立和銷燬程式的開銷。多程式的好處,是程式之間的記憶體資料不會相互干擾,同時,某個程式異常終止也不會影響其他程式。但是,就記憶體而言,每個httpd子程式佔用了很多的記憶體,因為子程式的記憶體資料是複製父程式的。我們可以粗略認為,這裡存在大量的“重複資料”被放在記憶體中。最終,導致我們能夠生成的子程式最大數量是很有限。在面對高併發時,因為有不少Keep-alive的長連線,將這些子程式“霸佔”住,很可能導致可用子程式耗盡。因此,prefork並不太適合高併發場景。

  • 優點:成熟穩定,相容所有新老模組。同時,不需要擔心執行緒安全的問題。(例如,我們常用的mod_php,將PHP編譯為Apache的子模組,就不需要支援執行緒安全)
  • 缺點:一個服務程式佔用很多記憶體。

2. worker MPM,多程式和多執行緒的混合模式

worker模式比起prefork,是使用了多程式和多執行緒的混合模式。它也預先fork了幾個子程式(數量很少),然後每個子程式建立一些執行緒(其中包括一個監聽執行緒)。每個請求過來,會被分配到1個執行緒來服務。執行緒比起程式會更輕量,因為執行緒通常會共享父程式的記憶體空間,因此,記憶體的佔用會減少一些。在高併發的場景下,因為比起prefork更省記憶體,因此會有更多的可用執行緒。

但是,它並沒有解決Keep-alive的長連線“霸佔”執行緒的問題,只是物件變成了比較輕量的執行緒。

有些人會覺得奇怪,那麼這裡為什麼不完全使用多執行緒呢,還要引入多程式?因為還需要考慮穩定性,如果一個執行緒掛了,會導致同一個程式下其他正常的子執行緒都掛了。如果全部採用多執行緒,某個執行緒掛掉,就導致整個Apache服務“全軍覆沒”。而目前的工作模式,受影響的只是Apache的一部分服務,而不是整個服務。

執行緒共享父程式的記憶體空間,減少了記憶體的佔用,卻又引起了新的問題。就是“執行緒安全”,多個執行緒修改共享資源導致的“競爭行為”,又強迫我們所使用的模組必須支援“執行緒安全”。因此,它有一定程度上增加Web服務的不穩定性。例如,mod_php所使用的PHP擴充,也同樣需要支援“執行緒安全”,否則,不能在該模式下使用。

  • 優點:佔據更少的記憶體,高併發下表現更優秀。
  • 缺點:必須考慮執行緒安全的問題,同時鎖的引入又增加了CPU的開銷。

3. event MPM,多程式和多執行緒的混合模式,引入Epoll

這個是Apache中比較新的模式,在現在的版本(Apache 2.4.10)已經是穩定可用的模式。它和worker模式很像,最大的區別在於,它解決了keep-alive場景下,長期被佔用的執行緒的資源浪費問題。event MPM中,會有一個專門的執行緒來管理這些keep-alive型別的執行緒,當有真實請求過來的時候,將請求傳遞給服務執行緒,執行完畢後,又允許它釋放。它減少了“佔據”連線而又不使用的資源浪費,增強了高併發場景下的請求處理能力。因為減少了“閒等”的執行緒,執行緒的數量減少,同等場景下,記憶體佔用會下降一些。

event MPM在遇到某些不相容的模組時,會失效,將會回退到worker模式,一個工作執行緒處理一個請求。新版Apache官方自帶的模組,全部是支援event MPM的。注意一點,event MPM需要Linux系統(Linux 2.6+)對EPoll的支援,才能啟用。Apache的三種模式中在真實應用場景中,event MPM是最節約記憶體的。

4. 使用比較輕量的Nginx作為Web伺服器

雖然Apache的不斷優化,減少了記憶體佔用,從而增加了處理高併發的能力。但是,正如前面所說,Apache是一個古老而成熟的Web服務,同時,整合很多穩定的模組,是一個比較重的Web服務。Nginx是個比較輕量的Web服務,佔據的記憶體天然就少於Apache。而且,Nginx通過一個程式來服務於N個連線。所使用的方式,並不是Apache的增加程式/執行緒來支援更多的連線。對於Nginx來說,它少建立了大量的程式/執行緒,減少了很多記憶體的開銷。

靜態檔案的QPS效能壓測結果,Nginx效能大概3倍於Apache對靜態檔案的處理。PHP等動態檔案的QPS,Nginx的做法通常是通過FastCGI的方式和PHP-FPM通訊的方式完成,PHP作為一個與之無關的外部服務存在。而Apache通常將PHP編譯為自己的字模組(新版的Apache也支援FastCGI)。PHP動態檔案,Nginx的表現略遜於Apache。

5. sendfile節約記憶體

Apache、Nginx等不少Web服務,都帶有sendfile支援的。sendfile可以減少資料到“使用者態記憶體空間”(使用者緩衝區)的拷貝,進而減少記憶體的佔用。當然,很多同學第一個反應當然是問Why?為了儘可能清楚講述這個原理,我們就先回Linux核心態和使用者態的儲存空間的互動。

一般情況下,使用者態(也就是我們的程式所在的記憶體空間)是不會直接讀寫或者操作各種裝置(磁碟、網路、終端等),中間通常用核心作為“中間人”,來完成對裝置的操作或者讀寫。

以最簡單的磁碟讀寫例子,從磁碟中讀取A檔案,寫入到B檔案。A檔案資料是從磁碟開始,然後載入到“核心緩衝區”,然後再拷貝到“使用者緩衝區”,我們才可以對資料進行處理。寫入的時候,也同理,從“使用者態緩衝區”載入到“核心緩衝區”,最後寫入到磁碟B檔案。

這樣寫檔案很累吧,於是有人覺得這裡可以跳過“使用者緩衝區”的拷貝。其實,這就是MMP(Memory-Mapping,記憶體對映)的實現,建立一個磁碟空間和記憶體的直接對映,資料不再複製到“使用者態緩衝區”,而是返回一個指向記憶體空間的指標。於是,我們之前的讀寫檔案例子,就會變成,A檔案資料從磁碟載入到“核心緩衝區”,然後從“核心緩衝區”複製到B檔案的“核心緩衝區”,B檔案再從”核心緩衝區“寫回到磁碟中。這個過程,減少了一次記憶體拷貝,同時也少記憶體佔用。

好了,回到sendfile的話題上來,簡單的說,sendfile的做法和MMP類似,就是減少資料從”核心態緩衝區“到”使用者態緩衝區“的記憶體拷貝。

預設的磁碟檔案讀取,到傳輸給socket,流程(不使用sendfile)是:

使用sendfile之後:

這種方式,不僅節省了記憶體,而且還有CPU的開銷。

 

四、節約Web伺服器的CPU

對Web伺服器而言,CPU是另一個非常核心的系統資源。雖然一般情況下,我們認為業務程式的執行消耗了我們主要CPU。但是,就Web服務程式而言,多執行緒/多程式的上下文切換,也是比較消耗CPU資源的。一個程式/執行緒通常不能長期佔有CPU,當發生阻塞或者時間片用完,就無法繼續佔用CPU,這個時候,就會發生上下文切換,CPU時間片從老程式/執行緒切換到新的。除此之外,在併發連線數目很高的場景下,對這些使用者建立的連線(socket檔案描述符)狀態的輪詢和檢測,也是比較消耗CPU的。

而Apache和Nginx的發展和演變,也在努力減少CPU開銷。

1. Select/Poll(Apache早期版本的I/O多路複用)

通常,Web服務都要維護很多個和使用者通訊的socket檔案描述符,I/O多路複用,其實就是為了方便對這些檔案描述符的管理和檢測。Apache早期版本,是使用select的模式,簡單的說,就是將這些我們關注的socket檔案描述符交給核心,讓核心告訴我們,那些描述符可操作。Poll與select原理基本相同,因此放在一起,它們之間的區別,就不贅敘了哈。

select/poll返回的是一個我們之前提交的檔案描述符集合(核心將其中可讀、可寫或者異常狀態的socket檔案描述符的標識位修改了),我們需要通過輪詢檢查才能獲得我們可以操作的檔案描述符。在這個過程中,不斷重複執行。在實際應用場景中,大部分被我們監控的socket檔案描述符,都是”空閒的“,也就是說,不能操作。我們對整個集合輪詢,就是為了找了少部分我們可以操作的socket檔案描述符。於是,當我們監控的socket檔案描述符越多(使用者併發連線數越來越多),這個輪詢工作,也就越來越沉重,進而導致增大了CPU的開銷。

如果我們監控的socket檔案描述符,幾乎都是”活躍的“,反而使用這種模式更合適一點。

2. Epoll(新版的Apache的event MPM,Nginx等支援)

Epoll是Linux2.6開始正式支援的I/O多路複用,我們可以理解為它是對select/poll的改進。首先,我們同樣將我們關注的socket檔案描述符集合告訴給核心,同時,給它們註冊”回撥函式“,如果某個socket檔案準備好了,就通過回撥函式通知我們。於是,我們就不需要專門去輪詢整個全量的socket檔案描述符集合,直接可以得到已經可操作的socket檔案描述符。那麼,那些大部分”空閒“的描述符,我們就不遍歷了。即使我們監控的socket檔案描述越來越多,我們輪詢的也只是”活躍可操作“的socket檔案描述符。

其實,有一種極端點的場景,就是我們全部檔案描述符幾乎都是”活躍“的,這樣反而導致了大量回撥函式的執行,又增加了CPU的開銷。但是,就Web服務的真實場景,絕大部分時候,都是連線集合中都存在很多”空閒“連線。

3. 執行緒/程式的建立銷燬和上下文切換

通常,Apache某一個時間內,是一個程式/執行緒服務於一個連線。於是,Apache就有很多的程式/執行緒,服務於很多的連線。Web服務在高峰期,會建立很多的程式/執行緒,也就帶來很多的上下文切換開銷。而Nginx,它通常只有1個master主程式和幾個worker子程式,然後,1個worker程式服務很多個連線,進而節省了CPU的上下文切換開銷。

兩種模式雖然不同,但實際上不能直接出分好壞,綜合來說,各有各自的優勢,就不妄議了哈。

4. 多執行緒下的鎖對CPU的開銷

Apache中的worker和event模式,都有采用多執行緒。多執行緒因為共享父程式的記憶體空間,在訪問共享資料的時候,就會產生競爭,也就是執行緒安全問題。因此通常會引入鎖(Linux下比較常用的執行緒相關的鎖有互斥量metux,讀寫鎖rwlock等),成功獲取鎖的執行緒可以繼續執行,獲取失敗的通常選擇阻塞等待。引入鎖的機制,程式的複雜度往往增加不少,同時還有執行緒“死鎖”或者“餓死”的風險(多程式在訪問程式間共享資源的時候,也有同樣的問題)。

死鎖現象(兩個執行緒彼此鎖住對方想要獲取的資源,相互阻塞等待,永遠無法達不到滿足條件):

餓死現象(某個執行緒,一直獲取不到它想要鎖資源,永遠無法執行下一步):

為了避免這些鎖導致的問題,就不得不加大程式的複雜度,解決方案一般有:

(1)對資源的加鎖,根據約定好的順序,大家都先對共享資源X加鎖,加鎖成功之後才能加鎖共享資源Y。

(2)如果執行緒佔有資源X,卻加鎖資源Y失敗,則放棄加鎖,同時也釋放掉之前佔有的資源X。

在使用PHP的時候,在Apache的worker和event模式下,也必須相容執行緒安全。通常,新版本的PHP官方庫是沒有執行緒安全方面的問題,需要關注的是第三方擴充套件。PHP實現執行緒安全,不是通過鎖的方式實現的。而是為每個執行緒獨立申請一份全域性變數的副本,相當於執行緒的私人記憶體空間,但是這樣做相對消耗多一些記憶體。不過,這樣的好處,是不需要引入複雜的鎖機制實現,也避免了鎖機制對CPU的開銷。

這裡順便提到一下,經常和Nginx搭配工作的PHP-FPM(FastCGI)使用的是多程式,因此不會有執行緒安全的問題。

 

五、 小結

可能有些同學看完之後,會得出結論,Nginx+PHP-FPM的工作方式,似乎是最節省系統資源的Web系統工作方式。某種程度上說,的確是可以這麼說的,但是Web系統的搭建,需要從實際業務應用的角度出發,具體問題需要具體分析,尋求最合適的技術方案。

Web服務的不斷演變和發展,努力地追求用盡可能少的系統資源,來支撐更多的使用者請求,這是一條波瀾壯闊的前進之路。這些技術方案,匯聚了很多值得學習和借鑑的解決問題的思路。

相關文章