每到節假日期間,一二線城市返鄉、外出遊玩的人們幾乎都面臨著一個問題:搶火車票!雖然現在大多數情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。尤其是春節期間,大家不僅使用12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票。“12306服務”承受著這個世界上任何秒殺系統都無法超越的QPS,上百萬的並發再正常不過了!筆者專門研究了一下“12306”的服務端架構,學習到了其系統設計上很多亮點,在這裏和大家分享一下並模擬一個例子:如何在100萬人同時搶1萬張火車票時,系統提供正常、穩定的服務。
github源碼地址:
https://github.com/GuoZhaoran/spikeSystem
1、大型高並發系統架構
高並發的系統架構都會采用分布式集群部署,服務上層有著層層負載均衡,並提供各種容災手段(雙火機房、節點容錯、服務器災備等)保證系統的高可用,流量也會根據不同的負載能力和配置策略均衡到不同的服務器上。下邊是一個簡單的示意圖:
2.2 支付減庫存
如果等待用戶支付了訂單在減庫存,第一感覺就是不會少賣。但是這是並發架構的大忌,因爲在極限並發情況下,用戶可能會創建很多訂單,當庫存減爲零的時候很多用戶發現搶到的訂單支付不了了,這也就是所謂的“超賣”。也不能避免並發操作數據庫磁盤IO
3、扣庫存的藝術
從上面的分析可知,顯然預扣庫存的方案最合理。我們進一步分析扣庫存的細節,這裏還有很大的優化空間,庫存存在哪裏?怎樣保證高並發下,正確的扣庫存,還能快速的響應用戶請求?
在單機低並發情況下,我們實現扣庫存通常是這樣的:
這樣就避免了對數據庫頻繁的IO操作,只在內存中做運算,極大的提高了單機抗並發的能力。但是百萬的用戶請求量單機是無論如何也抗不住的,雖然nginx處理網絡請求使用epoll模型,c10k的問題在業界早已得到了解決。但是linux系統下,一切資源皆文件,網絡請求也是這樣,大量的文件描述符會使操作系統瞬間失去響應。上面我們提到了nginx的加權均衡策略,我們不妨假設將100W的用戶請求量平均均衡到100台服務器上,這樣單機所承受的並發量就小了很多。然後我們每台機器本地庫存100張火車票,100台服務器上的總庫存還是1萬,這樣保證了庫存訂單不超賣,下面是我們描述的集群架構:
我們采用Redis存儲統一庫存,因爲Redis的性能非常高,號稱單機QPS能抗10W的並發。在本地減庫存以後,如果本地有訂單,我們再去請求redis遠程減庫存,本地減庫存和遠程減庫存都成功了,才返回給用戶搶票成功的提示,這樣也能有效的保證訂單不會超賣。當機器中有機器宕機時,因爲每個機器上有預留的buffer余票,所以宕機機器上的余票依然能夠在其他機器上得到彌補,保證了不少賣。buffer余票設置多少合適呢,理論上buffer設置的越多,系統容忍宕機的機器數量就越多,但是buffer設置的太大也會對redis造成一定的影響。雖然redis內存數據庫抗並發能力非常高,請求依然會走一次網絡IO,其實搶票過程中對redis的請求次數是本地庫存和buffer庫存的總量,因爲當本地庫存不足時,系統直接返回用戶“已售罄”的信息提示,就不會再走統一扣庫存的邏輯,這在一定程度上也避免了巨大的網絡請求量把redis壓跨,所以buffer值設置多少,需要架構師對系統的負載能力做認真的考量。
4、代碼演示
Go語言原生爲並發設計,我采用go語言給大家演示一下單機搶票的具體流程。
4.1 初始化工作
go包中的init函數先于main函數執行,在這個階段主要做一些准備性工作。我們系統需要做的准備工作有:初始化本地庫存、初始化遠程redis存儲統一庫存的hash鍵值、初始化redis連接池;另外還需要初始化一個大小爲1的int類型chan,目的是實現分布式鎖的功能,也可以直接使用讀寫鎖或者使用redis等其他的方式避免資源競爭,但使用channel更加高效,這就是go語言的哲學:不要通過共享內存來通信,而要通過通信來共享內存。redis庫使用的是redigo,下面是代碼實現:
…//localSpike包結構體定義package localSpiketype 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結構中已有訂單數量key}//初始化redis連接池func NewPool() *redis.Pool { return &redis.Pool{ MaxIdle: 10000, MaxActive: 12000, // max number of connections Dial: func() (redis.Conn, error) { c, err := redis.Dial(“tcp”, “:6379”) if err != nil { panic(err.Error()) } return c, err }, }}…func init() { localSpike = localSpike2.LocalSpike{ LocalInStock: 150, LocalSalesVolume: 0, } remoteSpike = remoteSpike2.RemoteSpikeKeys{ SpikeOrderHashKey: “ticket_hash_key”, TotalInventoryKey: “ticket_total_nums”, QuantityOfOrderKey: “ticket_sold_nums”, } redisPool = remoteSpike2.NewPool() done = make(chan int, 1) done <- 1}
4.2 本地扣庫存和統一扣庫存
本地扣庫存邏輯非常簡單,用戶請求過來,添加銷量,然後對比銷量是否大于本地庫存,返回bool值:
package localSpike//本地扣庫存,返回bool值func (spike *LocalSpike) LocalDeductionStock() bool{ spike.LocalSalesVolume = spike.LocalSalesVolume + 1 return spike.LocalSalesVolume < spike.LocalInStock}
注意這裏對共享數據LocalSalesVolume的操作是要使用鎖來實現的,但是因爲本地扣庫存和統一扣庫存是一個原子性操作,所以在最上層使用channel來實現,這塊後邊會講。統一扣庫存操作redis,因爲redis是單線程的,而我們要實現從中取數據,寫數據並計算一些列步驟,我們要配合lua腳本打包命令,保證操作的原子性:
package remoteSpike……const LuaScript = ` local ticket_key = KEYS[1] local ticket_total_key = ARGV[1] local ticket_sold_key = ARGV[2] local ticket_total_nums = tonumber(redis.call(‘HGET’, ticket_key, ticket_total_key)) local ticket_sold_nums = tonumber(redis.call(‘HGET’, ticket_key, ticket_sold_key)) — 查看是否還有余票,增加訂單數量,返回結果值 if(ticket_total_nums >= ticket_sold_nums) then return redis.call(‘HINCRBY’, ticket_key, ticket_sold_key, 1) end return 0`//遠端統一扣庫存func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool { lua := redis.NewScript(1, LuaScript) result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey)) if err != nil { return false } return result != 0}
我們使用hash結構存儲總庫存和總銷量的信息,用戶請求過來時,判斷總銷量是否大于庫存,然後返回相關的bool值。在啓動服務之前,我們需要初始化redis的初始庫存信息:
hmset ticket_hash_key “ticket_total_nums” 10000 “ticket_sold_nums” 0
4.3 響應用戶信息
我們開啓一個http服務,監聽在一個端口上:
package main…func main() { http.HandleFunc(“/buy/ticket”, handleReq) http.ListenAndServe(“:3005”, nil)}
上面我們做完了所有的初始化工作,接下來handleReq的邏輯非常清晰,判斷是否搶票成功,返回給用戶信息就可以了。
package main//處理請求函數,根據請求將響應結果信息寫入日志func handleReq(w http.ResponseWriter, r *http.Request) { redisConn := redisPool.Get() LogMsg := “” <-done //全局讀寫鎖 if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) { util.RespJson(w, 1, “搶票成功”, nil) LogMsg = LogMsg + “result:1,localSales:” + strconv.FormatInt(localSpike.LocalSalesVolume, 10) } else { util.RespJson(w, -1, “已售罄”, nil) LogMsg = LogMsg + “result:0,localSales:” + strconv.FormatInt(localSpike.LocalSalesVolume, 10) } done <- 1 //將搶票狀態寫入到log中 writeLog(LogMsg, “./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”}, “”) buf := []byte(content) fd.Write(buf)}
前邊提到我們扣庫存時要考慮競態條件,我們這裏是使用channel避免並發的讀寫,保證了請求的高效順序執行。我們將接口的返回信息寫入到了./stat.log文件方便做壓測統計。
4.4 單機服務壓測
開啓服務,我們使用ab壓測工具進行測試:
ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket
下面是我本地低配mac的壓測信息
This is ApacheBench, Version 2.3 <$Revision: 1826891 $>Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient)Completed 1000 requestsCompleted 2000 requestsCompleted 3000 requestsCompleted 4000 requestsCompleted 5000 requestsCompleted 6000 requestsCompleted 7000 requestsCompleted 8000 requestsCompleted 9000 requestsCompleted 10000 requestsFinished 10000 requestsServer Software:Server Hostname: 127.0.0.1Server Port: 3005Document Path: /buy/ticketDocument Length: 29 bytesConcurrency Level: 100Time taken for tests: 2.339 secondsComplete requests: 10000Failed requests: 0Total transferred: 1370000 bytesHTML transferred: 290000 bytesRequests per second: 4275.96 [#/sec] (mean)Time per request: 23.387 [ms] (mean)Time per request: 0.234 [ms] (mean, across all concurrent requests)Transfer rate: 572.08 [Kbytes/sec] receivedConnection Times (ms) min mean[+/-sd] median maxConnect: 0 8 14.7 6 223Processing: 2 15 17.6 11 232Waiting: 1 11 13.5 8 225Total: 7 23 22.8 18 239Percentage of the requests served within a certain time (ms) 50% 18 66% 24 75% 26 80% 28 90% 33 95% 39 98% 45 99% 54 100% 239 (longest request)
根據指標顯示,我單機每秒就能處理4000+的請求,正常服務器都是多核配置,處理1W+的請求根本沒有問題。而且查看日志發現整個服務過程中,請求都很正常,流量均勻,redis也很正常:
//stat.log…result:1,localSales:145result:1,localSales:146result:1,localSales:147result:1,localSales:148result:1,localSales:149result:1,localSales:150result:0,localSales:151result:0,localSales:152result:0,localSales:153result:0,localSales:154result:0,localSales:156…
5、總結回顧
總體來說,秒殺系統是非常複雜的。我們這裏只是簡單介紹模擬了一下單機如何優化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略,完整的訂單系統還有訂單進度的查看,每台服務器上都有一個任務,定時的從總庫存同步余票和庫存信息展示給用戶,還有用戶在訂單有效期內不支付,釋放訂單,補充到庫存等等。
我們實現了高並發搶票的核心邏輯,可以說系統設計的非常的巧妙,巧妙的避開了對DB數據庫IO的操作,對Redis網絡IO的高並發請求,幾乎所有的計算都是在內存中完成的,而且有效的保證了不超賣、不少賣,還能夠容忍部分機器的宕機。我覺得其中有兩點特別值得學習總結:
1、負載均衡,分而治之。通過負載均衡,將不同的流量劃分到不同的機器上,每台機器處理好自己的請求,將自己的性能發揮到極致,這樣系統的整體也就能承受極高的並發了,就像工作的的一個團隊,每個人都將自己的價值發揮到了極致,團隊成長自然是很大的。
2、合理的使用並發和異步。自epoll網絡架構模型解決了c10k問題以來,異步越來被服務端開發人員所接受,能夠用異步來做的工作,就用異步來做,在功能拆解上能達到意想不到的效果,這點在nginx、node.js、redis上都能體現,他們處理網絡請求使用的epoll模型,用實踐告訴了我們單線程依然可以發揮強大的威力。服務器已經進入了多核時代,go語言這種天生爲並發而生的語言,完美的發揮了服務器多核優勢,很多可以並發處理的任務都可以使用並發來解決,比如go處理http請求時每個請求都會在一個goroutine中執行,總之:怎樣合理的壓榨CPU,讓其發揮出應有的價值,是我們一直需要探索學習的方向。