12306搶票:極限并發(fā)帶來的思考
時間:2021-10-18 16:15:58
手機看文章
掃描二維碼
隨時隨地手機看文章
[導讀]每到節(jié)假日期間,一二線城市返鄉(xiāng)、外出游玩的人們幾乎都面臨著一個問題:搶火車票!雖然現(xiàn)在大多數(shù)情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。尤其是春節(jié)期間,大家不僅使用12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票?!?2306服務...
每到節(jié)假日期間,一二線城市返鄉(xiāng)、外出游玩的人們幾乎都面臨著一個問題:搶火車票!雖然現(xiàn)在大多數(shù)情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。尤其是春節(jié)期間,大家不僅使用12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票。“12306服務”承受著這個世界上任何秒殺系統(tǒng)都無法超越的QPS,上百萬的并發(fā)再正常不過了!筆者專門研究了一下“12306”的服務端架構,學習到了其系統(tǒng)設計上很多亮點,在這里和大家分享一下并模擬一個例子:如何在100萬人同時搶1萬張火車票時,系統(tǒng)提供正常、穩(wěn)定的服務。
大型高并發(fā)系統(tǒng)架構
高并發(fā)的系統(tǒng)架構都會采用分布式集群部署,服務上層有著層層負載均衡,并提供各種容災手段(雙火機房、節(jié)點容錯、服務器災備等)保證系統(tǒng)的高可用,流量也會根據(jù)不同的負載能力和配置策略均衡到不同的服務器上。下邊是一個簡單的示意圖:
負載均衡簡介
上圖中描述了用戶請求到服務器經(jīng)歷了三層的負載均衡,下邊分別簡單介紹一下這三種負載均衡:
- OSPF(開放式最短鏈路優(yōu)先)是一個內(nèi)部網(wǎng)關協(xié)議(Interior Gateway Protocol,簡稱IGP)。OSPF通過路由器之間通告網(wǎng)絡接口的狀態(tài)來建立鏈路狀態(tài)數(shù)據(jù)庫,生成最短路徑樹,OSPF會自動計算路由接口上的Cost值,但也可以通過手工指定該接口的Cost值,手工指定的優(yōu)先于自動計算的值。OSPF計算的Cost,同樣是和接口帶寬成反比,帶寬越高,Cost值越小。到達目標相同Cost值的路徑,可以執(zhí)行負載均衡,最多6條鏈路同時執(zhí)行負載均衡。
- LVS(Linux VirtualServer),它是一種集群(Cluster)技術,采用IP負載均衡技術和基于內(nèi)容請求分發(fā)技術。調(diào)度器具有很好的吞吐率,將請求均衡地轉移到不同的服務器上執(zhí)行,且調(diào)度器自動屏蔽掉服務器的故障,從而將一組服務器構成一個高性能的、高可用的虛擬服務器。
- Nginx想必大家都很熟悉了,是一款非常高性能的http代理/反向代理服務器,服務開發(fā)中也經(jīng)常使用它來做負載均衡。Nginx實現(xiàn)負載均衡的方式主要有三種:輪詢、加權輪詢、ip hash輪詢,下面我們就針對Nginx的加權輪詢做專門的配置和測試。
Nginx加權輪詢的演示
Nginx實現(xiàn)負載均衡通過upstream模塊實現(xiàn),其中加權輪詢的配置是可以給相關的服務加上一個權重值,配置的時候可能根據(jù)服務器的性能、負載能力設置相應的負載。下面是一個加權輪詢負載的配置,我將在本地的監(jiān)聽3001-3004端口,分別配置1,2,3,4的權重:#配置負載均衡
????upstream?load_rule?{
???????server?127.0.0.1:3001?weight=1;
???????server?127.0.0.1:3002?weight=2;
???????server?127.0.0.1:3003?weight=3;
???????server?127.0.0.1:3004?weight=4;
????}
????...
????server?{
????listen???????80;
????server_name??load_balance.com?www.load_balance.com;
????location?/?{
???????proxy_pass?http://load_rule;
????}
}我在本地/etc/hosts目錄下配置了www.load_balance.com的虛擬域名地址,接下來使用Go語言開啟四個http端口監(jiān)聽服務,下面是監(jiān)聽在3001端口的Go程序,其他幾個只需要修改端口即可:package?main
import?(
?"net/http"
?"os"
?"strings"
)
func?main()?{
?http.HandleFunc("/buy/ticket",?handleReq)
?http.ListenAndServe(":3001",?nil)
}
//處理請求函數(shù),根據(jù)請求將響應結果信息寫入日志
func?handleReq(w?http.ResponseWriter,?r?*http.Request)?{
?failedMsg?:=??"handle?in?port:"
?writeLog(failedMsg,?"./stat.log")
}
//寫入日志
func?writeLog(msg?string,?logPath?string)?{
?fd,?_?:=?os.OpenFile(logPath,?os.O_RDWR|os.O_CREATE|os.O_APPEND,?0644)
?defer?fd.Close()
?content?:=?strings.Join([]string{msg,?"\r\n"},?"3001")
?buf?:=?[]byte(content)
?fd.Write(buf)
}我將請求的端口日志信息寫到了./stat.log文件當中,然后使用ab壓測工具做壓測:ab?-n?1000?-c?100?http://www.load_balance.com/buy/ticket統(tǒng)計日志中的結果,3001-3004端口分別得到了100、200、300、400的請求量,這和我在Nginx中配置的權重占比很好的吻合在了一起,并且負載后的流量非常的均勻、隨機。具體的實現(xiàn)大家可以參考nginx的upsteam模塊實現(xiàn)源碼,這里推薦一篇文章:https://www.kancloud.cn/digest/understandingnginx/202607
秒殺搶購系統(tǒng)選型
回到我們最初提到的問題中來:火車票秒殺系統(tǒng)如何在高并發(fā)情況下提供正常、穩(wěn)定的服務呢?
從上面的介紹我們知道用戶秒殺流量通過層層的負載均衡,均勻到了不同的服務器上,即使如此,集群中的單機所承受的QPS也是非常高的。如何將單機性能優(yōu)化到極致呢?要解決這個問題,我們就要想明白一件事:通常訂票系統(tǒng)要處理生成訂單、減扣庫存、用戶支付這三個基本的階段,我們系統(tǒng)要做的事情是要保證火車票訂單不超賣、不少賣,每張售賣的車票都必須支付才有效,還要保證系統(tǒng)承受極高的并發(fā)。這三個階段的先后順序改怎么分配才更加合理呢?我們來分析一下:
下單減庫存
當用戶并發(fā)請求到達服務端時,首先創(chuàng)建訂單,然后扣除庫存,等待用戶支付。這種順序是我們一般人首先會想到的解決方案,這種情況下也能保證訂單不會超賣,因為創(chuàng)建訂單之后就會減庫存,這是一個原子操作。但是這樣也會產(chǎn)生一些問題,第一就是在極限并發(fā)情況下,任何一個內(nèi)存操作的細節(jié)都至關影響性能,尤其像創(chuàng)建訂單這種邏輯,一般都需要存儲到磁盤數(shù)據(jù)庫的,對數(shù)據(jù)庫的壓力是可想而知的;第二是如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制IP和用戶的購買訂單數(shù)量,這也不算是一個好方法。
支付減庫存
如果等待用戶支付了訂單在減庫存,第一感覺就是不會少賣。但是這是并發(fā)架構的大忌,因為在極限并發(fā)情況下,用戶可能會創(chuàng)建很多訂單,當庫存減為零的時候很多用戶發(fā)現(xiàn)搶到的訂單支付不了了,這也就是所謂的“超賣”。也不能避免并發(fā)操作數(shù)據(jù)庫磁盤IO。
預扣庫存
從上邊兩種方案的考慮,我們可以得出結論:只要創(chuàng)建訂單,就要頻繁操作數(shù)據(jù)庫IO。那么有沒有一種不需要直接操作數(shù)據(jù)庫IO的方案呢,這就是預扣庫存。先扣除了庫存,保證不超賣,然后異步生成用戶訂單,這樣響應給用戶的速度就會快很多;那么怎么保證不少賣呢?用戶拿到了訂單,不支付怎么辦?我們都知道現(xiàn)在訂單都有有效期,比如說用戶五分鐘內(nèi)不支付,訂單就失效了,訂單一旦失效,就會加入新的庫存,這也是現(xiàn)在很多網(wǎng)上零售企業(yè)保證商品不少賣采用的方案。訂單的生成是異步的,一般都會放到MQ、Kafka這樣的即時消費隊列中處理,訂單量比較少的情況下,生成訂單非??欤脩魩缀醪挥门抨?。
扣庫存的藝術
從上面的分析可知,顯然預扣庫存的方案最合理。我們進一步分析扣庫存的細節(jié),這里還有很大的優(yōu)化空間,庫存存在哪里?怎樣保證高并發(fā)下,正確的扣庫存,還能快速的響應用戶請求?
在單機低并發(fā)情況下,我們實現(xiàn)扣庫存通常是這樣的:
為了保證扣庫存和生成訂單的原子性,需要采用事務處理,然后取庫存判斷、減庫存,最后提交事務,整個流程有很多IO,對數(shù)據(jù)庫的操作又是阻塞的。這種方式根本不適合高并發(fā)的秒殺系統(tǒng)。
接下來我們對單機扣庫存的方案做優(yōu)化:本地扣庫存。我們把一定的庫存量分配到本地機器,直接在內(nèi)存中減庫存,然后按照之前的邏輯異步創(chuàng)建訂單。改進過之后的單機系統(tǒng)是這樣的:
這樣就避免了對數(shù)據(jù)庫頻繁的IO操作,只在內(nèi)存中做運算,極大的提高了單機抗并發(fā)的能力。但是百萬的用戶請求量單機是無論如何也抗不住的,雖然Nginx處理網(wǎng)絡請求使用epoll模型,c10k的問題在業(yè)界早已得到了解決。但是Linux系統(tǒng)下,一切資源皆文件,網(wǎng)絡請求也是這樣,大量的文件描述符會使操作系統(tǒng)瞬間失去響應。上面我們提到了Nginx的加權均衡策略,我們不妨假設將100W的用戶請求量平均均衡到100臺服務器上,這樣單機所承受的并發(fā)量就小了很多。然后我們每臺機器本地庫存100張火車票,100臺服務器上的總庫存還是1萬,這樣保證了庫存訂單不超賣,下面是我們描述的集群架構:
問題接踵而至,在高并發(fā)情況下,現(xiàn)在我們還無法保證系統(tǒng)的高可用,假如這100臺服務器上有兩三臺機器因為扛不住并發(fā)的流量或者其他的原因宕機了。那么這些服務器上的訂單就賣不出去了,這就造成了訂單的少賣。要解決這個問題,我們需要對總訂單量做統(tǒng)一的管理,這就是接下來的容錯方案。服務器不僅要在本地減庫存,另外要遠程統(tǒng)一減庫存。有了遠程統(tǒng)一減庫存的操作,我們就可以根據(jù)機器負載情況,為每臺機器分配一些多余的“buffer庫存”用來防止機器中有機器宕機的情況。我們結合下面架構圖具體分析一下:
我們采用Redis存儲統(tǒng)一庫存,因為Redis的性能非常高,號稱單機QPS能抗10W的并發(fā)。在本地減庫存以后,如果本地有訂單,我們再去請求Redis遠程減庫存,本地減庫存和遠程減庫存都成功了,才返回給用戶搶票成功的提示,這樣也能有效的保證訂單不會超賣。當機器中有機器宕機時,因為每個機器上有預留的buffer余票,所以宕機機器上的余票依然能夠在其他機器上得到彌補,保證了不少賣。buffer余票設置多少合適呢,理論上buffer設置的越多,系統(tǒng)容忍宕機的機器數(shù)量就越多,但是buffer設置的太大也會對redis造成一定的影響。雖然Redis內(nèi)存數(shù)據(jù)庫抗并發(fā)能力非常高,請求依然會走一次網(wǎng)絡IO,其實搶票過程中對redis的請求次數(shù)是本地庫存和buffer庫存的總量,因為當本地庫存不足時,系統(tǒng)直接返回用戶“已售罄”的信息提示,就不會再走統(tǒng)一扣庫存的邏輯,這在一定程度上也避免了巨大的網(wǎng)絡請求量把Redis壓跨,所以buffer值設置多少,需要架構師對系統(tǒng)的負載能力做認真的考量。
代碼演示
Go語言原生為并發(fā)設計,我采用Go語言給大家演示一下單機搶票的具體流程。
初始化工作
Go包中的init函數(shù)先于main函數(shù)執(zhí)行,在這個階段主要做一些準備性工作。我們系統(tǒng)需要做的準備工作有:初始化本地庫存、初始化遠程Redis存儲統(tǒng)一庫存的hash鍵值、初始化Redis連接池;另外還需要初始化一個大小為1的int類型chan,目的是實現(xiàn)分布式鎖的功能,也可以直接使用讀寫鎖或者使用Redis等其他的方式避免資源競爭,但使用channel更加高效,這就是Go語言的哲學:不要通過共享內(nèi)存來通信,而要通過通信來共享內(nèi)存。Redis庫使用的是redigo,下面是代碼實現(xiàn):...
//localSpike包結構體定義
package?localSpike
type?LocalSpike?struct?{
?LocalInStock?????int64
?LocalSalesVolume?int64
}
...
//remoteSpike對hash結構的定義和Redis連接池
package?remoteSpike
//遠程訂單存儲健值
type?RemoteSpikeKeys?struct?{
?SpikeOrderHashKey?string?//Redis中秒殺訂單hash結構key
?TotalInventoryKey?string?//hash結構中總訂單庫存key
?QuantityOfOrderKey?string?//hash結構中已有訂單數(shù)量key
}
//初始化Redis連接池
func?NewPool()?*redis.Pool?{
?return?