天天看點

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

大家先思考一個問題,這也是在面試過程中經常遇到的問題。

如果你們公司現在的産品能夠支援10w使用者通路,你們老闆突然和你說,融到錢了,會大量投放廣告,預計在1個月後使用者量會達到1000w,如果這個任務交給你,你應該怎麼做?

如何支撐1000w使用者其實是一個非常抽象的問題,對于技術開發來說,我們需要一個非常明确的對于執行關鍵業務上的性能名額資料,比如,高峰時段下對于事務的響應時間、并發使用者數、qps、成功率、以及基本名額要求等,這些都 必須要非常明确,隻有這樣才能夠指導整個架構的改造和優化。是以,如果大家接到這樣一個問題,首先需要去定位到問題的本質,也就是首先得知道一些可量化的資料名額。

如果有過往的相似業務交易曆史資料經驗,你需要盡量參考,處理這些收集到的原始資料(日志),進而分析出高峰時段,以及該時段下的交易行為,交易規模等,得到你想要看清楚的需求細節

另外一種情況,就是沒有相關的資料名額作為參考,這個時候就需要經驗來分析。比如可以參考一些類似行業的比較成熟的業務交易模型(比如銀行業的日常交易活動或交通行業售檢票交易活動)或者幹脆遵循“2/8”原則和“2/5/8”原則來直接下手實踐。

當使用者能夠在2秒以内得到響應時,會感覺系統的響應很快; 當使用者在2-5秒之間得到響應時,會感覺系統的響應速度還可以; 當使用者在5-8秒以内得到響應時,會感覺系統的響應速度很慢,但是還可以接受; 而當使用者在超過8秒後仍然無法得到響應時,會感覺系統糟透了,或者認為系統已經失去響應,而選擇離開這個web站點,或者發起第二次請求。

在估算響應時間、并發使用者數、tps、成功率這些關鍵名額的同時,你仍需要關心具體的業務功能次元上的需求,每個業務功能都有各自的特點,比如有些場景可以不需要同步傳回明确執行結果,有些業務場景可以接受傳回“系統忙,請等待!”這樣暴力的消息,以避免過大的處理流量所導緻的大規模癱瘓,是以,學會平衡這些名額之間的關系是必要的,大多數情況下最好為這些名額做一個優先級排序,并且盡量隻考察幾個優先級高的名額要求。(sla服務等級)

sla:service-level agreement的縮寫,意思是服務等級協定。服務的sla是服務提供者對服務消費者的正式承諾,是衡量服務能力等級的關鍵項。服務sla中定義的項必須是可測量的,有明确的測量方法。
阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

在分析上述問題之前,先給大家普及一下,系統相關的一些關鍵衡量名額。

tps(transaction per second)每秒處理的事務數。

站在宏觀角度來說,一個事務是指用戶端向服務端發起一個請求,并且等到請求傳回之後的整個過程。從用戶端發起請求開始計時,等到收到伺服器端響應結果後結束計時,在計算這個時間段内總共完成的事務個數,我們稱為tps。

站在微觀角度來說,一個資料庫的事務操作,從開始事務到事務送出完成,表示一個完整事務,這個是資料庫層面的tps。

qps(queries per second)每秒查詢數,表示伺服器端每秒能夠響應的查詢次數。這裡的查詢是指使用者送出請求到伺服器做出響應成功的次數,可以簡單認為每秒鐘的request數量。

針對單個接口而言,tps和qps是相等的。如果從宏觀層面來說,使用者打開一個頁面到頁面渲染結束代表一個tps,那這個頁面中會調用伺服器很多次,比如加載靜态資源、查詢伺服器端的渲染資料等,就會産生兩個qps,是以,一個tps中可能會包含多個qps。

qps=并發數/平均響應時間
阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

rt(response time),表示用戶端發起請求到服務端傳回的時間間隔,一般表示平均響應時間。

并發數是指系統同時能處理的請求數量。

需要注意,并發數和qps不要搞混了,qps表示每秒的請求數量,而并發數是系統同時處理的請求數量,并發數量會大于qps,因為服務端的一個連接配接需要有一個處理時長,在這個請求處理結束之前,這個連接配接一直占用。

舉個例子,如果qps=1000,表示每秒鐘用戶端會發起1000個請求到服務端,而如果一個請求的處理耗時是3s,那麼意味着總的并發=1000*3=3000,也就是服務端會同時有3000個并發。

上面說的這些名額,怎麼計算呢?舉個例子。

假設在10點到11點這一個小時内,有200w個使用者通路我們的系統,假設平均每個使用者請求的耗時是3秒,那麼計算的結果如下:

qps=2000000/60*60 = 556 (表示每秒鐘會有556個請求發送到服務端)

rt=3s(每個請求的平均響應時間是3秒)

并發數=556*3=1668

從這個計算過程中發現,随着rt的值越大,那麼并發數就越多,而并發數代表着伺服器端同時處理的連接配接請求數量,也就意味服務端占用的連接配接數越多,這些連結會消耗記憶體資源以及cpu資源等。是以rt值越大系統資源占用越大,同時也意味着服務端的請求處理耗時較長。

但實際情況是,rt值越小越好,比如在遊戲中,至少做到100ms左右的響應才能達到最好的體驗,對于電商系統來說,3s左右的時間是能接受的,那麼如何縮短rt的值呢?

繼續回到最開始的問題,假設沒有曆史資料供我們參考,我們可以使用2/8法則來進行預估。

1000w使用者,每天來通路這個網站的使用者占到20%,也就是每天有200w使用者來通路。

假設平均每個使用者過來點選50次,那麼總共的pv=1億。

一天是24小時,根據2/8法則,每天大部分使用者活躍的時間點集中在(240.2) 約等于5個小時以内,而大部分使用者指的是(1億點選 80%)約等于8000w(pv), 意味着在5個小時以内,大概會有8000w點選進來,也就是每秒大約有4500(8000w/5小時)個請求。

4500隻是一個平均數字。在這5個小時中,不可能請求是非常平均的,有可能會存在大量的使用者集中通路(比如像淘寶這樣的網站,日通路峰值的時間點集中在下午14:00、以及晚上21:00,其中21:00是一天中活躍的峰值),一般情況下通路峰值是平均通路請求的3倍到4倍左右(這個是經驗值),我們按照4倍來計算。那麼在這5個小時内有可能會出現每秒18000個請求的情況。也就是說,問題由原本的支撐1000w使用者,變成了一個具體的問題,就是伺服器端需要能夠支撐每秒18000個請求(qps=18000)

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解
阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

大概預估出了後端伺服器需要支撐的最高并發的峰值之後,就需要從整個系統架構層面進行壓力預估,然後配置合理的伺服器數量和架構。既然是這樣,那麼首先需要知道一台伺服器能夠扛做多少的并發,那這個問題怎麼去分析呢?我們的應用是部署在tomcat上,是以需要從tomcat本身的性能下手。

下面這個圖表示tomcat的工作原理,該圖的說明如下。

limitlatch是連接配接控制器,它負責控制tomcat能夠同時處理的最大連接配接數,在nio/nio2的模式中,預設是10000,如果是apr/native,預設是8192

acceptor是一個獨立的線程,在run方法中,在while循環中調用socket.accept方法中接收用戶端的連接配接請求,一旦有新的請求過來,accept會傳回一個channel對象,接着把這個channel對象交給poller去處理。

poller 的本質是一個 selector ,它同樣也實作了線程,poller 在内部維護一個 channel 數組,它在一個死循環裡不斷檢測 channel 的資料就緒狀态,一旦有 channel 可讀,就生成一個 socketprocessor 任務對象扔給 executor 去處理

socketprocessor 實作了 runnable 接口,當線程池在執行socketprocessor這個任務時,會通過http11processor去處理目前這個請求,http11processor 讀取 channel 的資料來生成 servletrequest 對象。

executor 就是線程池,負責運作 socketprocessor 任務類, socketprocessor 的 run 方法會調用 http11processor 來讀取和解析請求資料。我們知道, http11processor 是應用層協定的封裝,它會調用容器獲得響應,再把響應通過 channel 寫出。

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

從這個圖中可以得出,限制tomcat請求數量的因素四個方面。

我想可能大家遇到過類似“socket/file:can't open so many files”的異常,這個就是表示linux系統中的檔案句柄限制。

在linux中,每一個tcp連接配接會占用一個檔案描述符(fd),一旦檔案描述符超過linux系統目前的限制,就會提示這個錯誤。

我們可以通過下面這條指令來檢視一個程序可以打開的檔案數量

open files (-n) 1024 是linux作業系統對一個程序打開的檔案句柄數量的限制(也包含打開的套接字數量)

這裡隻是對使用者級别的限制,其實還有個是對系統的總限制,檢視系統總線制:

file-max是設定系統所有程序一共可以打開的檔案數量 。同時一些程式可以通過<code>setrlimit</code>調用,設定每個程序的限制。如果得到大量使用完檔案句柄的錯誤資訊,是應該增加這個值。

當出現上述異常時,我們可以通過下面的方式來進行修改(針對單個程序的打開數量限制)

<code>*</code>代表所有使用者、<code>root</code>表示root使用者。

noproc 表示最大程序數量

nofile代表最大檔案打開數量。

soft/hard,前者當達到門檻值時,制作警告,後者會報錯。

另外還要注意,要確定針對程序級别的檔案打開數量反問是小于或者等于系統的總限制,否則,我們需要修改系統的總限制。

tcp連接配接對于系統資源最大的開銷就是記憶體。

因為tcp連接配接歸根結底需要雙方接收和發送資料,那麼就需要一個讀緩沖區和寫緩沖區,這兩個buffer在linux下最小為4096位元組,可通過cat /proc/sys/net/ipv4/tcp_rmem和cat /proc/sys/net/ipv4/tcp_wmem來檢視。

是以,一個tcp連接配接最小占用記憶體為4096+4096 = 8k,那麼對于一個8g記憶體的機器,在不考慮其他限制下,最多支援的并發量為:810241024/8 約等于100萬。此數字為純理論上限數值,在實際中,由于linux kernel對一些資源的限制,加上程式的業務處理,是以,8g記憶體是很難達到100萬連接配接的,當然,我們也可以通過增加記憶體的方式增加并發量。

我們知道tomcat是java程式,運作在jvm上,是以我們還需要對jvm做優化,才能更好的提升tomcat的性能,簡單帶大家了解一下jvm,如下圖所示。

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

在jvm中,記憶體劃分為堆、程式計數器、本地方發棧、方法區(元空間)、虛拟機棧。

其中,堆記憶體是jvm記憶體中最大的一塊區域,幾乎所有的對象和數組都會被配置設定到堆記憶體中,它被所有線程共享。 堆空間被劃分為新生代和老年代,新生代進一步劃分為eden和surivor區,如下圖所示。

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

新生代和老年代的比例是1:2,也就是新生代會占1/3的堆空間,老年代會占2/3的堆空間。 另外,在新生代中,空間占比為eden:surivor0:surivor1=8:1:1 。 舉個例子來說,如果eden區記憶體大小是40m,那麼兩個survivor區分别是占5m,整個新生代就是50m,然後計算出老年代的記憶體大小是100m,也就是說堆空間的總記憶體大小是150m。

可以通過 java -xx:printflagsfinal -version檢視預設參數

initialsurvivorratio: 新生代eden/survivor空間的初始比例 newratio : old區/young區的記憶體比例

堆記憶體的具體工作原理是:

絕大部分的對象被建立之後,會儲存在eden區,當eden區滿了的時候,就會觸發ygc(young gc),大部分對象會被回收掉,如果還有活着的對象,就拷貝到survivor0,這時eden區被清空。

如果後續再次觸發ygc,活着的對象eden+survivor0中的對象拷貝到survivor1區, 這時eden和survivor0都會被清空

接着再觸發ygc,eden+survivor1中的對象會被拷貝到survivor0區,一直這麼循環,直到對象的年齡達到門檻值,則放入到老年代。(之是以這麼設計,是因為eden區的大部分對象會被回收)

survivor區裝不下的對象會直接進入到老年代

老年代滿了,會觸發full gc。

gc标記-清除算法 在執行過程中暫停其他線程??
阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

程式計數器是用來記錄各個線程執行的位元組碼位址等,當線程發生上下文切換時,需要依靠這個來記住目前執行的位置,當下次恢複執行後要沿着上一次執行的位置繼續執行。

方法區是邏輯上的概念,在hotspot虛拟機的1.8版本中,它的具體實作就是元空間。

方法區主要用來存放已經被虛拟機加載的類相關資訊,包括類元資訊、運作時常量池、字元串常量池,類資訊又包括類的版本、字段、方法、接口和父類資訊等。

方法區和堆空間類似,它是一個共享記憶體區域,是以方法區是屬于線程共享的。

java虛拟機棧是線程私有的記憶體空間,當建立一個線程時,會在虛拟機中申請一個線程棧,用來儲存方法的局部變量、操作數棧、動态連結方法等資訊。每一個方法的調用都伴随這棧幀的入棧操作,當一個方法傳回之後,就是棧幀的出棧操作。

本地方法棧和虛拟機棧類似,本地方法棧是用來管理本地方法的調用,也就是native方法。

了解了上述基本資訊之後,那麼jvm中記憶體應該如何設定呢?有哪些參數來設定?

而在jvm中,要配置的幾個核心參數無非是。

<code>-xms</code>,java堆記憶體大小

<code>-xmx</code>,java最大堆記憶體大小

<code>-xmn</code>,java堆記憶體中的新生代大小,扣除新生代剩下的就是老年代記憶體

新生代記憶體設定過小會頻繁觸發minor gc,頻繁觸發gc會影響系統的穩定性

<code>-xx:metaspacesize</code>,元空間大小, 128m

<code>-xx:maxmetaspacesize</code>,最大雲空間大小 (如果沒有指定這兩個參數,元空間會在運作時根據需要動态調整。) 256m

一個新系統的元空間,基本上沒辦法有一個測算的方法,一般設定幾百兆就夠用,因為這裡面主要存放一些類資訊。

<code>-xss</code>,線程棧記憶體大小,這個基本上不需要預估,設定512kb到1m就行,因為值越小,能夠配置設定的線程數越多。

jvm記憶體的大小,取決于機器的配置,比如一個2核4g的伺服器,能夠配置設定給jvm程序也就2g左右,因為機器本身也需要記憶體,而且機器上還運作了其他的程序也需要占記憶體。而這2g還得配置設定給棧記憶體、堆記憶體、元空間,那堆記憶體能夠得到的也就1g左右,然後堆記憶體還要分新生代、老年代。

http://tomcat.apache.org/tomcat-8.0-doc/config/http.html the maximum number of request processing threads to be created by this connector, which therefore determines the maximum number of simultaneous requests that can be handled. if not specified, this attribute is set to 200. if an executor is associated with this connector, this attribute is ignored as the connector will execute tasks using the executor rather than an internal thread pool. note that if an executor is configured any value set for this attribute will be recorded correctly but it will be reported (e.g. via jmx) as <code>-1</code> to make clear that it is not used.

accept-count: 最大等待數,當調用http請求數達到tomcat的最大線程數時,還有新的http請求到來,這時tomcat會将該請求放在等待隊列中,這個acceptcount就是指能夠接受的最大等待數,預設100。如果等待隊列也被放滿了,這個時候再來新的請求就會被tomcat拒絕(connection refused)

maxthreads:最大線程數,每一次http請求到達web服務,tomcat都會建立一個線程來處理該請求,那麼最大線程數決定了web服務容器可以同時處理多少個請求。maxthreads預設200,肯定建議增加。但是,增加線程是有成本的,更多的線程,不僅僅會帶來更多的線程上下文切換成本,而且意味着帶來更多的記憶體消耗。jvm中預設情況下在建立新線程時會配置設定大小為1m的線程棧,是以,更多的線程異味着需要更多的記憶體。線程數的經驗值為:1核2g記憶體為200,線程數經驗值200;4核8g記憶體,線程數經驗值800。

maxconnections,最大連接配接數,這個參數是指在同一時間,tomcat能夠接受的最大連接配接數。對于java的阻塞式bio,預設值是maxthreads的值;如果在bio模式使用定制的executor執行器,預設值将是執行器中maxthreads的值。對于java 新的nio模式,maxconnections 預設值是10000。對于windows上apr/native io模式,maxconnections預設值為8192

如果設定為-1,則禁用maxconnections功能,表示不限制tomcat容器的連接配接數。

maxconnections和accept-count的關系為:當連接配接數達到最大值maxconnections後,系統會繼續接收連接配接,但不會超過acceptcount的值。

前面我們分析過,nioendpoint接收到用戶端請求連接配接後,會生成一個socketprocessor任務給到線程池去處理,socketprocessor中的run方法會調用httpprocessor元件去解析應用層的協定,并生成request對象。最後調用adapter的service方法,将請求傳遞到容器中。

容器主要負責内部的處理工作,也就是目前置的連接配接器通過socket擷取到資訊之後,得到一個servlet請求,而容器就是負責處理servlet請求。

tomcat使用mapper元件将使用者請求的url定位到一個具體的serlvet,然後spring中的dispatcherservlet攔截到該servlet請求後,基于spring本身的mapper映射定位到我們具體的controller中。

到了controller之後,對于我們的業務來說,才是一個請求真正的開始,controller調用service、service調用dao,完成資料庫操作之後,講請求原路傳回給到用戶端,完成一次整體的會話。也就是說,controller中的業務邏輯處理耗時,對于整個容器的并發來說也會受到影響。

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

通過上述分析,我們假設一個tomcat節點的qps=500,如果要支撐到高峰時期的qps=18000,那麼需要40台伺服器,這四台伺服器需要通過nginx軟體負載均衡,進行請求分發,nginx的性能很好,官方給的說明是nginx處理靜态檔案的并發能夠達到5w/s。另外nginx由于不能單點,我們可以采用lvs對nginx做負載均衡,lvs(linux virtualserver),它是采用ip負載均衡技術實作負載均衡。

阿裡P8面試官:如何設計一個扛住千萬級并發的架構?1000W使用者的問題分解

通過這樣的一組架構,我們目前服務端是能夠同時承接qps=18000,但是還不夠,再回到前面我們說的兩個公式。

qps=并發量/平均響應時間

并發量=qps*平均響應時間

假設我們的rt是3s,那麼意味着伺服器端的并發數=18000*3=54000,也就是同時有54000個連接配接打到伺服器端,是以服務端需要同時支援的連接配接數為54000,這個我們在前文說過如何進行配置。如果rt越大,那麼意味着堆積的連結越多,而這些連接配接會占用記憶體資源/cpu資源等,容易造成系統崩潰的現象。同時,當連結數超過門檻值時,後續的請求無法進來,使用者會得到一個請求逾時的結果,這顯然不是我們所希望看到的,是以我們必須要縮短rt的值。

繼續閱讀