每秒100萬請求,“12306”的架構到底有多牛?

迎關注我的頭條號:Wooola,10年Java軟件開發及架構設計經驗,專注於Java、Go語言、微服務架構,致力於每天分享原創文章、快樂編碼和開源技術。 

前段時間,媒體公佈“12306”成為全球最大票務交易系統。

那些年熬夜刷的12306經過多年迭代,承受著這個世界上任何秒殺系統都無法超越的 QPS,上百萬的併發再正常不過。那麼,系統如何在 100 萬人同時搶 1 萬張火車票時,提供穩定的服務?

12306 搶票 極限併發

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

每秒100萬請求,“12306”的架構到底有多牛?

用戶秒殺流量通過層層的負載均衡,均勻到了不同的服務器上,即使如此,集群中的單機所承受的 QPS 也是非常高的。如何將單機性能優化到極致呢?

通常訂票系統要處理生成訂單、減扣庫存、用戶支付這三個基本的階段。

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

這三個階段的先後順序如何分配才合理?

下單減庫存

每秒100萬請求,“12306”的架構到底有多牛?

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

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

會產生一些問題:

  • 在極限併發情況下,任何一個內存操作的細節都至關影響性能,尤其像創建訂單這種邏輯,一般都需要存儲到磁盤數據庫的,對數據庫的壓力是可想而知的。
  • 如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制 IP 和用戶的購買訂單數量,這也不算是一個好方法。


支付減庫存

每秒100萬請求,“12306”的架構到底有多牛?

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

當庫存減為零的時候很多用戶發現搶到的訂單支付不了了,這也就是所謂的“超賣”。也不能避免併發操作數據庫磁盤 IO。

預扣庫存

每秒100萬請求,“12306”的架構到底有多牛?

從上邊兩種方案的考慮,我們可以得出結論:只要創建訂單,就要頻繁操作數據庫 IO。

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

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

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

扣庫存的藝術

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

在單機低併發情況下實現扣庫存通常:

每秒100萬請求,“12306”的架構到底有多牛?

為了保證扣庫存和生成訂單的原子性,需要採用事務處理,然後取庫存判斷、減庫存,最後提交事務,整個流程有很多 IO,對數據庫的操作又是阻塞的。

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

我們把一定的庫存量分配到本地機器,直接在內存中減庫存,然後按照之前的邏輯異步創建訂單。

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

每秒100萬請求,“12306”的架構到底有多牛?

這樣就避免了對數據庫頻繁的 IO 操作,只在內存中做運算,極大的提高了單機抗併發的能力。

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

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

上面我們提到了 Nginx 的加權均衡策略,不妨假設將 100W 的用戶請求量平均均衡到 100 臺服務器上,這樣單機所承受的併發量就小了很多。

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

每秒100萬請求,“12306”的架構到底有多牛?

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

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

有了遠程統一減庫存的操作,我們就可以根據機器負載情況,為每臺機器分配一些多餘的“Buffer 庫存”用來防止機器中有機器宕機的情況。

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

每秒100萬請求,“12306”的架構到底有多牛?

採用 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,代碼實現:

...
//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結構中已有訂單數量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 }

本地扣庫存和統一扣庫存

本地扣庫存邏輯非常簡單,用戶請求過來,添加銷量,然後對比銷量是否大於本地庫存,返回 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

響應用戶信息

我們開啟一個 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 := ""
//全局讀寫鎖
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 //將搶票狀態寫入到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 文件方便做壓測統計。

單機服務測壓

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

ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket

本地低配 Mac 的壓測信息:

This is ApacheBench, Version 2.3 
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 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: 127.0.0.1
Server Port: 3005
Document Path: /buy/ticket
Document Length: 29 bytes
Concurrency Level: 100
Time taken for tests: 2.339 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 1370000 bytes
HTML transferred: 290000 bytes
Requests 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] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 8 14.7 6 223
Processing: 2 15 17.6 11 232
Waiting: 1 11 13.5 8 225
Total: 7 23 22.8 18 239
Percentage 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:145
result:1,localSales:146
result:1,localSales:147
result:1,localSales:148
result:1,localSales:149
result:1,localSales:150
result:0,localSales:151
result:0,localSales:152
result:0,localSales:153
result:0,localSales:154
result:0,localSales:156
...


總 結

秒殺系統是非常複雜的,本文僅簡單介紹模擬了一下單機如何優化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略。

還涉及完整的訂單系統還有訂單進度的查看,定時的從總庫存同步餘票和庫存信息展示給用戶,以及用戶在訂單有效期內不支付,釋放訂單,補充到庫存等等。

總之,負載均衡,分而治之,每臺機器處理好自己的請求,將自己的性能發揮到極致。

合理的使用併發和異步,合理的壓榨 CPU,讓其發揮出應有的價值。

原文:《每秒100萬請求,“12306”的架構到底有多牛?》


分享到:


相關文章: