天天看點

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

雖然現在大多數情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。

尤其是春節期間,大家不僅使用 12306,還會考慮“智行”和其他的搶票軟體,全國上下幾億人在這段時間都在搶票。

“12306 服務”承受着這個世界上任何秒殺系統都無法超越的 qps,上百萬的并發再正常不過了!

筆者專門研究了一下“12306”的服務端架構,學習到了其系統設計上很多亮點,在這裡和大家分享一下并模拟一個例子:如何在 100 萬人同時搶 1 萬張火車票時,系統提供正常、穩定的服務。

github代碼位址:

大型高并發系統架構

高并發的系統架構都會采用分布式叢集部署,服務上層有着層層負載均衡,并提供各種容災手段(雙打火機房、節點容錯、伺服器災備等)保證系統的高可用,流量也會根據不同的負載能力和配置政策均衡到不同的伺服器上。

下邊是一個簡單的示意圖:

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

負載均衡簡介

上圖中描述了使用者請求到伺服器經曆了三層的負載均衡,下邊分别簡單介紹一下這三種負載均衡。

①ospf(開放式最短鍊路優先)是一個内部網關協定(interior gateway protocol,簡稱 igp)

ospf 通過路由器之間通告網絡接口的狀态來建立鍊路狀态資料庫,生成最短路徑樹,ospf 會自動計算路由接口上的 cost 值,但也可以通過手工指定該接口的 cost 值,手工指定的優先于自動計算的值。

ospf 計算的 cost,同樣是和接口帶寬成反比,帶寬越高,cost 值越小。到達目标相同 cost 值的路徑,可以執行負載均衡,最多 6 條鍊路同時執行負載均衡。

②lvs (linux virtual server)

它是一種叢集(cluster)技術,采用 ip 負載均衡技術和基于内容請求分發技術。

排程器具有很好的吞吐率,将請求均衡地轉移到不同的伺服器上執行,且排程器自動屏蔽掉伺服器的故障,進而将一組伺服器構成一個高性能的、高可用的虛拟伺服器。

③nginx

想必大家都很熟悉了,是一款非常高性能的 http 代理/反向代理伺服器,服務開發中也經常使用它來做負載均衡。

nginx 實作負載均衡的方式主要有三種:

輪詢

權重輪詢

ip hash 輪詢

下面我們就針對 nginx 的權重輪詢做專門的配置和測試。

nginx 權重輪詢的示範

nginx 實作負載均衡通過 upstream 子產品實作,其中權重輪詢的配置是可以給相關的服務加上一個權重值,配置的時候可能根據伺服器的性能、負載能力設定相應的負載。

下面是一個權重輪詢負載的配置,我将在本地的監聽 3001-3004 端口,分别配置 1,2,3,4 的權重:

我在本地 /etc/hosts 目錄下配置了 www.load_balance.com 的虛拟域名位址。

接下來使用 go 語言開啟四個 http 端口監聽服務,下面是監聽在 3001 端口的 go 程式,其他幾個隻需要修改端口即可:

我将請求的端口日志資訊寫到了 ./stat.log 檔案當中,然後使用 ab 壓測工具做壓測:

統計日志中的結果,3001-3004 端口分别得到了 100、200、300、400 的請求量。

這和我在 nginx 中配置的權重占比很好的吻合在了一起,并且負載後的流量非常的均勻、随機。

具體的實作大家可以參考 nginx 的 upsteam 子產品實作源碼

秒殺搶購系統選型

回到我們最初提到的問題中來:火車票秒殺系統如何在高并發情況下提供正常、穩定的服務呢?

從上面的介紹我們知道使用者秒殺流量通過層層的負載均衡,均勻到了不同的伺服器上,即使如此,叢集中的單機所承受的 qps 也是非常高的。如何将單機性能優化到極緻呢?

要解決這個問題,我們就要想明白一件事:通常訂票系統要處理生成訂單、減扣庫存、使用者支付這三個基本的階段。

我們系統要做的事情是要保證火車票訂單不超賣、不少賣,每張售賣的車票都必須支付才有效,還要保證系統承受極高的并發。

這三個階段的先後順序該怎麼配置設定才更加合理呢?我們來分析一下:

下單減庫存

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

當使用者并發請求到達服務端時,首先建立訂單,然後扣除庫存,等待使用者支付。

這種順序是我們一般人首先會想到的解決方案,這種情況下也能保證訂單不會超賣,因為建立訂單之後就會減庫存,這是一個原子操作。

但是這樣也會産生一些問題:

在極限并發情況下,任何一個記憶體操作的細節都至關影響性能,尤其像建立訂單這種邏輯,一般都需要存儲到磁盤資料庫的,對資料庫的壓力是可想而知的。

如果使用者存在惡意下單的情況,隻下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制 ip 和使用者的購買訂單數量,這也不算是一個好方法。

支付減庫存

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

如果等待使用者支付了訂單在減庫存,第一感覺就是不會少賣。但是這是并發架構的大忌,因為在極限并發情況下,使用者可能會建立很多訂單。

當庫存減為零的時候很多使用者發現搶到的訂單支付不了了,這也就是所謂的“超賣”。也不能避免并發操作資料庫磁盤 io。

預扣庫存

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

從上邊兩種方案的考慮,我們可以得出結論:隻要建立訂單,就要頻繁操作資料庫 io。

那麼有沒有一種不需要直接操作資料庫 io 的方案呢,這就是預扣庫存。先扣除了庫存,保證不超賣,然後異步生成使用者訂單,這樣響應給使用者的速度就會快很多;那麼怎麼保證不少賣呢?使用者拿到了訂單,不支付怎麼辦?

我們都知道現在訂單都有有效期,比如說使用者五分鐘内不支付,訂單就失效了,訂單一旦失效,就會加入新的庫存,這也是現在很多網上零售企業保證商品不少賣采用的方案。

訂單的生成是異步的,一般都會放到 mq、kafka 這樣的即時消費隊列中處理,訂單量比較少的情況下,生成訂單非常快,使用者幾乎不用排隊。

扣庫存的藝術

從上面的分析可知,顯然預扣庫存的方案最合理。我們進一步分析扣庫存的細節,這裡還有很大的優化空間,庫存存在哪裡?怎樣保證高并發下,正确的扣庫存,還能快速的響應使用者請求?

在單機低并發情況下,我們實作扣庫存通常是這樣的:

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

為了保證扣庫存和生成訂單的原子性,需要采用事務處理,然後取庫存判斷、減庫存,最後送出事務,整個流程有很多 io,對資料庫的操作又是阻塞的。

這種方式根本不适合高并發的秒殺系統。接下來我們對單機扣庫存的方案做優化:本地扣庫存。

我們把一定的庫存量配置設定到本地機器,直接在記憶體中減庫存,然後按照之前的邏輯異步建立訂單。

改進過之後的單機系統是這樣的:

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

這樣就避免了對資料庫頻繁的 io 操作,隻在記憶體中做運算,極大的提高了單機抗并發的能力。

但是百萬的使用者請求量單機是無論如何也抗不住的,雖然 nginx 處理網絡請求使用 epoll 模型,c10k 的問題在業界早已得到了解決。

但是 linux 系統下,一切資源皆檔案,網絡請求也是這樣,大量的檔案描述符會使作業系統瞬間失去響應。

上面我們提到了 nginx 的權重均衡政策,我們不妨假設将 100w 的使用者請求量平均均衡到 100 台伺服器上,這樣單機所承受的并發量就小了很多。

然後我們每台機器本地庫存 100 張火車票,100 台伺服器上的總庫存還是 1 萬,這樣保證了庫存訂單不超賣,下面是我們描述的叢集架構:

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

問題接踵而至,在高并發情況下,現在我們還無法保證系統的高可用,假如這 100 台伺服器上有兩三台機器因為扛不住并發的流量或者其他的原因當機了。那麼這些伺服器上的訂單就賣不出去了,這就造成了訂單的少賣。

要解決這個問題,我們需要對總訂單量做統一的管理,這就是接下來的容錯方案。伺服器不僅要在本地減庫存,另外要遠端統一減庫存。

有了遠端統一減庫存的操作,我們就可以根據機器負載情況,為每台機器配置設定一些多餘的“buffer 庫存”用來防止機器中有機器當機的情況。

我們結合下面架構圖具體分析一下:

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

我們采用 redis 存儲統一庫存,因為 redis 的性能非常高,号稱單機 qps 能抗 10w 的并發。

在本地減庫存以後,如果本地有訂單,我們再去請求 redis 遠端減庫存,本地減庫存和遠端減庫存都成功了,才傳回給使用者搶票成功的提示,這樣也能有效的保證訂單不會超賣。

當機器中有機器當機時,因為每個機器上有預留的 buffer 餘票,是以當機機器上的餘票依然能夠在其他機器上得到彌補,保證了不少賣。

buffer 餘票設定多少合适呢,理論上 buffer 設定的越多,系統容忍當機的機器數量就越多,但是 buffer 設定的太大也會對 redis 造成一定的影響。

雖然 redis 記憶體資料庫抗并發能力非常高,請求依然會走一次網絡 io,其實搶票過程中對 redis 的請求次數是本地庫存和 buffer 庫存的總量。

因為當本地庫存不足時,系統直接傳回使用者“已售罄”的資訊提示,就不會再走統一扣庫存的邏輯。

這在一定程度上也避免了巨大的網絡請求量把 redis 壓跨,是以 buffer 值設定多少,需要架構師對系統的負載能力做認真的考量。

代碼示範

go 語言原生為并發設計,我采用 go 語言給大家示範一下單機搶票的具體流程。

初始化工作

go 包中的 init 函數先于 main 函數執行,在這個階段主要做一些準備性工作。

我們系統需要做的準備工作有:初始化本地庫存、初始化遠端 redis 存儲統一庫存的 hash 鍵值、初始化 redis 連接配接池。

另外還需要初始化一個大小為 1 的 int 類型 chan,目的是實作分布式鎖的功能。

也可以直接使用讀寫鎖或者使用 redis 等其他的方式避免資源競争,但使用 channel 更加高效,這就是 go 語言的哲學:不要通過共享記憶體來通信,而要通過通信來共享記憶體。

redis 庫使用的是 redigo,下面是代碼實作:

本地扣庫存和統一扣庫存

本地扣庫存邏輯非常簡單,使用者請求過來,添加銷量,然後對比銷量是否大于本地庫存,傳回 bool 值:

注意這裡對共享資料 localsalesvolume 的操作是要使用鎖來實作的,但是因為本地扣庫存和統一扣庫存是一個原子性操作,是以在最上層使用 channel 來實作,這塊後邊會講。

統一扣庫存操作 redis,因為 redis 是單線程的,而我們要實作從中取資料,寫資料并計算一些列步驟,我們要配合 lua 腳本打包指令,保證操作的原子性:

我們使用 hash 結構存儲總庫存和總銷量的資訊,使用者請求過來時,判斷總銷量是否大于庫存,然後傳回相關的 bool 值。

在啟動服務之前,我們需要初始化 redis 的初始庫存資訊:

響應使用者資訊

我們開啟一個 http 服務,監聽在一個端口上:

上面我們做完了所有的初始化工作,接下來 handlereq 的邏輯非常清晰,判斷是否搶票成功,傳回給使用者資訊就可以了。

前邊提到我們扣庫存時要考慮競态條件,我們這裡是使用 channel 避免并發的讀寫,保證了請求的高效順序執行。我們将接口的傳回資訊寫入到了 ./stat.log 檔案友善做壓測統計。

單機服務壓測

開啟服務,我們使用 ab 壓測工具進行測試:

下面是我本地低配 mac 的壓測資訊:

根據名額顯示,我單機每秒就能處理 4000+ 的請求,正常伺服器都是多核配置,處理 1w+ 的請求根本沒有問題。

而且檢視日志發現整個服務過程中,請求都很正常,流量均勻,redis 也很正常:

總結回顧

總體來說,秒殺系統是非常複雜的。我們這裡隻是簡單介紹模拟了一下單機如何優化到高性能,叢集如何避免單點故障,保證訂單不超賣、不少賣的一些政策,完整的訂單系統還有訂單進度的檢視,每台伺服器上都有一個任務,定時的從總庫存同步餘票和庫存資訊展示給使用者,還有使用者在訂單有效期内不支付,釋放訂單,補充到庫存等等。

我們實作了高并發搶票的核心邏輯,可以說系統設計的非常的巧妙,巧妙的避開了對 db 資料庫 io 的操作。

對 redis 網絡 io 的高并發請求,幾乎所有的計算都是在記憶體中完成的,而且有效的保證了不超賣、不少賣,還能夠容忍部分機器的當機。

我覺得其中有兩點特别值得學習總結:

①負載均衡,分而治之

通過負載均衡,将不同的流量劃分到不同的機器上,每台機器處理好自己的請求,将自己的性能發揮到極緻。

這樣系統的整體也就能承受極高的并發了,就像工作的一個團隊,每個人都将自己的價值發揮到了極緻,團隊成長自然是很大的。

②合理的使用并發和異步

自 epoll 網絡架構模型解決了 c10k 問題以來,異步越來越被服務端開發人員所接受,能夠用異步來做的工作,就用異步來做,在功能拆解上能達到意想不到的效果。

這點在 nginx、node.js、redis 上都能展現,他們處理網絡請求使用的 epoll 模型,用實踐告訴了我們單線程依然可以發揮強大的威力。

伺服器已經進入了多核時代,go 語言這種天生為并發而生的語言,完美的發揮了伺服器多核優勢,很多可以并發處理的任務都可以使用并發來解決,比如 go 處理 http 請求時每個請求都會在一個 goroutine 中執行。

總之,怎樣合理的壓榨 cpu,讓其發揮出應有的價值,是我們一直需要探索學習的方向。

背景回複【電子書籍】免費擷取電子書籍

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!

背景回複【面試】免費擷取面試題目

你知道12306 是如何支撐百萬 QPS 的?寫得太好了!
你知道12306 是如何支撐百萬 QPS 的?寫得太好了!