閱讀715 返回首頁    go 阿裏雲 go 技術社區[雲棲]


係統架構-性能篇章1(應用係統性能1)

 

在前麵的文章中,說了很多JVM和數據庫方麵的東西,我所描述的內容大多偏重於技術本身,和實際的業務係統結合的比較少,本文開始進入實際的係統設計中應當注意的方方麵麵(文章偏重於訪問量高,但是每次訪問量並不是很大的係統),而偏重點在於性能和效率本身,由於這個知識涉及的基礎和麵很廣,所以建議是先看下以前寫的內容或自己有一定的基礎來才開始接觸比較好,另外本文也不能詮釋性能的關鍵,從一個應用係統前端到後端涉及的部分非常多,本文也隻會說明其中一部分,後續的部分我們再繼續說;下麵我們想一下一個web應用絕大部分請求的整個過程:client發出請求->server開始響應並創建請求對象及反饋對象->如果沒有用戶對象就創建session信息->調用業務代碼->業務代碼分層組織數據->調用數據(從某個遠程或數據庫或文件等)->開始組織輸出數據->反饋數據開始通過模板引擎進行渲染->渲染完成未靜態文件向客戶端進行輸出->待客戶端接收完成結束掉請求對象(這種請求針對短連接,長連接有所區別)。

就從前端說起吧,說下一下幾個內容:

1、線程數量

2、內容輸出

3、線程上下文切換

4、內存

 

1.首先說下線程數量,線程數量很多人認為在配置服務器的線程數量時認為越多越好,各大網站上很多人也給出了自己的測試數據,也有人說了每個CPU配置多少線程為合適(比如有人說過每個CPU給25個線程較為合適),但是沒有一個明確的為什麼,其實這個要和CPU本身的運行效率和上來說明,並非一概而論的,也需要考慮每個請求所持有的CPU開銷大小以及其處於非Running狀態的時間來說明,線程配置得過多,其實往往會形成CPU的征用調度問題,要比較恰當將CPU用滿才是性能的最佳狀態(說到線程就不得不說下CPU,因為線程就是消耗CPU的,其本身持有的內存片段非常小,前麵文章已經說明了它的內存使用情況,所以我們主要是討論它與CPU之間的關係)。

首先內存到CPU的延遲在幾十納秒,雖然CPU內部的三級緩存比這個更加小,但是幾乎對於我們所能識別的時間來講可以被忽略;另外內存與CPU之間的帶寬也是以最少幾百M每秒的速度通信,所以對於內存與CPU交互數據的時間開銷對於常規的高並發小請求的應用客戶忽略掉,我們隻計算本身的計算延遲開銷以及非計算的等待開銷,這些都一般會用毫秒來計算,相互之間是用10e6的級別來衡量,所以前者可以忽略,我們可以認為處於running的時間就是CPU實際執行的時間,因為這種短暫的時間也很難監控出來到底用了多久。

那麼首先可以將線程的運行狀態劃分為兩大類,就是:運行與等待,我們不考慮被釋放的情況,因為線程池一般不會釋放線程,至於等待有很多種,我們都認為它是等待就可以了;為什麼是這兩種呢,這兩種正好對應了CPU是否在被使用,running狀態的線程就在持有CPU的占用,等待的就處於沒有使用CPU。

再明確一個概念,一個常規的web請求,後台對應一個線程對它的請求進行處理,同一個線程在同一個時間片上隻能請求一個CPU為他進行處理,也就是說我們可以認為它不論請求過多少次CPU、不論請求了多少個CPU,隻要這些CPU的型號是一樣的,我們就可以認為它是請求的一個CPU(注意這裏的CPU不包含多個core的情況,因為多個core的CPU隻能說明這個CPU的處理速度可以接近於多個CPU的速度,而真正對線程的請求來講,它認為這是一個CPU,在主板上也是一個插槽,所以計算CPU的時候不考慮多核心)。

最後明確線程在什麼情況下會發生等待,比如讀取數據庫時,數據庫尚未反饋內容之前,該線程是不會占用CPU的,隻會處理等待;類似的是向客戶端輸出、線程為了去持有鎖的等待一係列的情況。

此時一個線程過來如果一個線程毫無等待(這種情況不存在,隻是一種假設),不論它處理多久,處理時間長度多長,此時如果隻有一個CPU,那麼這個應用服務器隻需要一個1個線程就足以支撐,因為線程沒有等待,那麼CPU就沒有停止運行,1個線程處理完這個請求後,接著就處理下一個請求,CPU一直是滿的,也幾乎沒有太大的征用,此時1個線程就是最佳的,如果是多個同型號的CPU,那麼就是CPU數量的線程是最佳的;不過這個例子比較極端,在很多類似的情況下,大家喜歡用CPU+1或CPU-1來完成對類似情況的線程設置,為了保證一些特殊情況的發生。

那麼考慮下實際的情況,如果有等待,這個等待不是鎖等待的(因為鎖等待有瓶頸,瓶頸在於CPU的個數對於他們無效),應該如何考慮呢?我們此時來考慮下這個等待的時間長度應該如何去考慮,假如等待的時間長度為100ms,而運行的時間長度為10ms,那麼在等待的這100ms中,就可以有另外10個線程進來,對CPU進行占用,也就是說對於單個CPU來說,11個線程就可以占滿整個CPU的使用,如果是多個CPU當然在理論上可以乘以CPU的個數,這裏再次強調,這裏的CPU個數是物理的,而不算多核,多核在這裏的意義比如以前一個CPU處理一個線程需要30ms,現在采用4個core,隻需要處理10ms了,在這裏體現了速度,所以計算是不要用它來計算。

那麼對於鎖等待呢?這個有點麻煩了,因為這個和模塊有關係,這裏也隻能說明某個有鎖等待的模塊要達到最佳狀態的訪問效率可以配置的線程數,首先要明確鎖等待已經沒有CPU個數的概念,不論多少個CPU,隻要運行到這段代碼,他們就是一個CPU,不然鎖就沒有存在的意義了;另外,假如訪問是非常密集的,那麼當某個線程持有鎖並訪問的時候,其他沒有得到的運行到這個位置都會處於等待,我們將一個模塊的所有有鎖等待的時間集中在一起,隻有當前一個線程將具有鎖的這段代碼運行完成後,下一個線程才可以繼續運行,所以它其他地方都沒有瓶頸,或者說其他地方理論配置的線程數都會很高,唯獨遇到這個地方就會很慢,假如一個線程從運行代碼時長為20ms,等待事件為100ms,鎖等待為20ms,此時假如該線程沒有受到任何等待就是140ms即可運行完成,而當多個線程同時並發到這裏的時候,後續每個線程將會等待20*N的時間長度,當有7個線程的時候,恰好排滿運行的隊列,也就是當又7個線程訪問這個模塊的時候,理論上剛好達到每個線程順序執行而且成流水線狀態,但是這裏不能乘以CPU的個數了,為什麼,你懂的。

 

2.內容輸出,其實內容輸出有很多種方法,在java方麵,你可以自己編寫OutputStream或者PrintWriter去輸出,也可以用渲染模板去渲染輸出,渲染的模板也有很多,最常見的就是JSP模板來渲染,也有velocity等各種各樣的渲染模板,當然對於頁麵來講隻能用渲染模板去做,不過異步請求你可以選擇,在選擇時要對應選擇才能將效果做得比較好。

說到這裏不得不說下velocity這個東西,也就是經常看到的vm的文件,這種文件和JSP一樣都是渲染模板的方法,隻是語法格式有所區別,velocity是新出來的東西,很多人認為新的東西肯定很好,其實velocity是渲染效率很低的,在內容較小的輸出上對性能進行壓力測試,其單位時間內所能承受的訪問量,比JSP渲染模板要低好幾倍,不過對較大的數據輸出和JSP差不多,也就是頁麵輸出使用velocity無所謂的,而且效果比JSP要好,但是類似ajax交互中的小數據輸出建議不要使用vm模板引擎,使用JSP模板引擎甚至於直接輸出是最佳的方式。

說到這裏JSP模板引擎在輸出時是會被預先編譯為java的class文件,VM是解釋執行的,所以小文件兩者性能差距很大,當遇到大數據輸出時,其實大部分時間在輸出文件的過程中,解釋時間幾乎就可以被忽略掉了。

那麼JSP輸出小文件是不是最快的呢?未必,JSP的輸出其實是將JSP頁麵的內容組成字符串,最終使用PrintWriter流取完成,中間跳轉交互其實還是蠻多的,而且有部分容器在組裝字符串的時候竟然用+,這個讓我很是鬱悶啊,所以很多時候小數據的輸出,我還是喜歡自己寫,經過測試得到的結果是使用OutputStream的性能將會比PrintWriter高一些,(至於高多少,大家可以自己用工具或寫代碼測試下就知道了,這裏可能單個處理速度幾乎看不出區別,要並發訪問看下平均每秒能處理的請求數就會有區別了),字符集方麵,在獲取要輸出內容的時候,指定byte的字符集,如:String.getByte(“字符集”),一般這類輸出也不會有表頭,隻需要和接收方或者叫瀏覽器一致就可以了(有些接收方可能是請求方);其實OutputStream比PrintWriter快速的原因很簡單,在底層運行和傳輸的過程中,始終采用二進製流來完成,即使是字符也需要轉換成byte格式,在轉換前,它需要去尋找很多的字符集關係,最終定位到應該如何去轉換,內部代碼看過一下就明白,內部的方法調用非常多,一層套一層,相應占用的CPU開銷也會升高。

總結起來說,如果你有vm模板引擎,在頁麵請求時建議使用vm模板引擎來做,因為代碼要規範一些,而且也很好用;另外如果在簡單的ajax請求,返回數據較小的情況下,建議使用OutputStream直接輸出,這個輸出可以放在你的BaseAction的中,對實現類中是透明的,實現類隻需要將處理的反饋結果數據放在一個地方,由父類完成統一的輸出即可,此處將Ajax類的調用可以獨立一個父親類出來,這樣繼承後就不用關心細節了。

輸出中文件和大數據將是一個問題,對於文件來說,尤其是大文件,在前麵文章已經說明,輸出時壓縮隻能節省服務器輸出時和客戶端的流量,從而提高下載速度,但是絕對不會提高服務器端的性能,因為服務器端是通過消耗CPU去做動作,而且壓縮的這個過程是需要時間的,這種隻會降低速度,而絕對不會提高;那麼大文件的方法就是一種是將大文件提前壓縮好存放,如果實在太大,需要考慮采用斷點傳送,並將文件分解。

對大數據來講,和文件類似,不過數據可能對我們要好處理一點,需要控製訪問頻率甚至於直接在超過訪問頻率下拒絕訪問請求,每次請求的量也需要控製,如果對特殊大的數據量,建議采用異步方式輸出到文件並壓縮後,再由客戶端下載,這樣不論是客戶端還是服務器端都是有好處的。

 

3、線程上下文切換,對於線程的上下文切換,在一般的係統中基本遇不到,不過一些特殊應用會遇到,比如剛才的異步導出的功能,請求的線程隻是將事情提交上去,但是不是由它去下載,而是由其他線程再去處理這個問題,處理完成後再回寫某個狀態即可;在javaNIO中是非常的多,NIO是一種高性能服務器的解決方案,在有限的線程資源情況下,對極高並發的小請求,並存在很多推拉數據的情況下是很有效的,最大的要求就是服務器要有較好的連接支撐能力,NIO細節不用多說,理解上就是異步IO,把事情交給異步的一個線程去做,但是它也未必馬上做,它做完再反饋,這段時間交給你的這個線程不是等待而是去做其他的事情,充分利用線程的資源,處理完反饋結果的線程也未必是開始請求的線程,幾個來來回回是有很多的開銷的,總體其實效率上未必有單個請求好,但是對服務器的性能發揮是非常有效的。

線程之間的開銷大小也要看具體應用情況以及配置情況決定,此時將任務和線程沒有做一個一對一的綁定,而是放一堆事情在隊列中,處理線程也有很多,誰有時間處理誰就處理它,每個線程都做自己這一類的事情,甚至於將一些內容交給遠程去做,交互後就不管了,結果反饋的時候,這邊再由一個線程去處理結果請求即可。

在整個過程中會涉及到一次或多次的線程切換,這個過程中的開銷在某些時候也是不小的,關鍵還是要看應用場景,不能一概而論。

4、內存,最後還是內存,其實這裏我就不想多說了,因為前麵幾篇文章說得太多了,不論是理論上還是實現上,以及經驗上都說了非常多,不過可以說明的一點就是內存的問題絕大部分來源於代碼,而代碼有很大一部分可能性來源於工程的程序員編寫或者框架,第三方包的內存問題相對較少,一般被開源出來的包內存溢出的可能性不大,但是不排除有寫得比較爛的代碼;二方包呢,一般指代公司內部人員封裝的包,如果在經過很多項目的驗證可以比較放心使用,要絕對放心的話還是需要看看源碼才行,至於JVM本身的BUG一般不要找到這個上麵來,雖然也有這種可能性,不過這種問題除了升級JVM外也沒有太多的辦法,修改它的源碼的可能性不大,除非你真的太厲害了(這裏在內存上一般是指C或C++語言的源碼,java部分的基礎類包這些代碼如果真的有問題,還是比較容易修改的,但還是不建議自己刻意去修改,除非你能肯定有你更好的解決方案而且是穩定有效的);在編寫代碼的時候將那些可以提前做的事情做了(比如這個事情以後會反複做,重複做,而且都是一樣的,那麼可以提前做一次,以後就不用做了),那些邏輯是可以省掉的,最後是如果你的應用很特殊是不是更好的解決方案和算法來完成。

 

總結下,從今天提到的係統設計的角度來說,影響QPS的最關鍵的東西就是模板渲染,它會占據請求的很大一部分時間,而且這個東西可以做非常大的改進,比如:壓縮空白字符、重複對象的簡化和模板化、大數據和重複信息的CSS化、盡量將輸出轉化為網絡可以直接接受的內容;而其次就是如何配置線程,配置得太少,CPU的開銷一直處於一種比較閑的狀態,而配置得太多,CPU的征用情況比較嚴重,沒有建議值,隻要最適合應用場景的值,不過你的代碼如果沒有太多的同步,線程最少應該設置為CPU的格式+1或-1個;上下文切換對常規應用一般不要使用,對特殊的應用要注意中間的切換開銷應該如何降低;文件輸出上講提前做的壓縮提前做掉,注意控製訪問頻率和單次輸出量;最後內存上多多注意代碼,配置上隻需要控製好常規的幾個參數,其餘的在沒有特殊情況不要修改默認的配置。

 

擴展,那麼關於一個係統的架構中是不是就這麼一點就完了呢,當然不是,這應該說說出了一個常見的OLTP係統的一些常見的性能指標,但是還有很內容,比如:緩存、宕機類異常處理、session切換、IO、數據庫、分布式、集群等都是這方麵的關鍵內容,尤其是IO也是當今係統中性能瓶頸的最主要原因之一;在後續的文章中會逐步說明一些相關的解決方案。

最後更新:2017-04-02 06:51:52

  上一篇:go android IO流 寫入 讀出
  下一篇:go Java 序列化的高級認識