“12306”的架構(gòu)到底有多牛逼?

每到節(jié)假日期間,一二線城市返鄉(xiāng)、外出游玩的人們幾乎都面臨著一個問題:搶火車票!
12306 搶票,極限并發(fā)帶來的思考
雖然現(xiàn)在大多數(shù)情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。
尤其是春節(jié)期間,大家不僅使用 12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票。
“12306 服務(wù)”承受著這個世界上任何秒殺系統(tǒng)都無法超越的 QPS,上百萬的并發(fā)再正常不過了!
筆者專門研究了一下“12306”的服務(wù)端架構(gòu),學習到了其系統(tǒng)設(shè)計上很多亮點,在這里和大家分享一下并模擬一個例子:如何在 100 萬人同時搶 1 萬張火車票時,系統(tǒng)提供正常、穩(wěn)定的服務(wù)。
https://github.com/GuoZhaoran/spikeSystem
大型高并發(fā)系統(tǒng)架構(gòu)
高并發(fā)的系統(tǒng)架構(gòu)都會采用分布式集群部署,服務(wù)上層有著層層負載均衡,并提供各種容災(zāi)手段(雙火機房、節(jié)點容錯、服務(wù)器災(zāi)備等)保證系統(tǒng)的高可用,流量也會根據(jù)不同的負載能力和配置策略均衡到不同的服務(wù)器上。

負載均衡簡介
上圖中描述了用戶請求到服務(wù)器經(jīng)歷了三層的負載均衡,下邊分別簡單介紹一下這三種負載均衡。
①OSPF(開放式最短鏈路優(yōu)先)是一個內(nèi)部網(wǎng)關(guān)協(xié)議(Interior Gateway Protocol,簡稱 IGP)
OSPF 通過路由器之間通告網(wǎng)絡(luò)接口的狀態(tài)來建立鏈路狀態(tài)數(shù)據(jù)庫,生成最短路徑樹,OSPF 會自動計算路由接口上的 Cost 值,但也可以通過手工指定該接口的 Cost 值,手工指定的優(yōu)先于自動計算的值。
OSPF 計算的 Cost,同樣是和接口帶寬成反比,帶寬越高,Cost 值越小。到達目標相同 Cost 值的路徑,可以執(zhí)行負載均衡,最多 6 條鏈路同時執(zhí)行負載均衡。
②LVS (Linux Virtual Server)
它是一種集群(Cluster)技術(shù),采用 IP 負載均衡技術(shù)和基于內(nèi)容請求分發(fā)技術(shù)。
調(diào)度器具有很好的吞吐率,將請求均衡地轉(zhuǎn)移到不同的服務(wù)器上執(zhí)行,且調(diào)度器自動屏蔽掉服務(wù)器的故障,從而將一組服務(wù)器構(gòu)成一個高性能的、高可用的虛擬服務(wù)器。
③Nginx
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服務(wù)器,服務(wù)開發(fā)中也經(jīng)常使用它來做負載均衡。
Nginx 實現(xiàn)負載均衡的方式主要有三種:
輪詢
加權(quán)輪詢
IP Hash 輪詢
下面我們就針對 Nginx 的加權(quán)輪詢做專門的配置和測試。
Nginx 加權(quán)輪詢的演示
下面是一個加權(quán)輪詢負載的配置,我將在本地的監(jiān)聽 3001-3004 端口,分別配置 1,2,3,4 的權(quán)重:
#配置負載均衡
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;
}
}
接下來使用 Go 語言開啟四個 HTTP 端口監(jiān)聽服務(wù),下面是監(jiān)聽在 3001 端口的 Go 程序,其他幾個只需要修改端口即可:
package main
import (
"net/http"
"os"
"strings"
)
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3001", nil)
}
//處理請求函數(shù),根據(jù)請求將響應(yīng)結(jié)果信息寫入日志
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
具體的實現(xiàn)大家可以參考 Nginx 的 Upsteam 模塊實現(xiàn)源碼,這里推薦一篇文章《Nginx 中 Upstream 機制的負載均衡》:
https://www.kancloud.cn/digest/understandingnginx/202607
秒殺搶購系統(tǒng)選型
下單減庫存
在極限并發(fā)情況下,任何一個內(nèi)存操作的細節(jié)都至關(guān)影響性能,尤其像創(chuàng)建訂單這種邏輯,一般都需要存儲到磁盤數(shù)據(jù)庫的,對數(shù)據(jù)庫的壓力是可想而知的。
如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務(wù)端可以限制 IP 和用戶的購買訂單數(shù)量,這也不算是一個好方法。
支付減庫存

預(yù)扣庫存

扣庫存的藝術(shù)
在單機低并發(fā)情況下,我們實現(xiàn)扣庫存通常是這樣的:


然后我們每臺機器本地庫存 100 張火車票,100 臺服務(wù)器上的總庫存還是 1 萬,這樣保證了庫存訂單不超賣,下面是我們描述的集群架構(gòu):
我們結(jié)合下面架構(gòu)圖具體分析一下:

代碼演示
初始化工作
Redis 庫使用的是 Redigo,下面是代碼實現(xiàn):
...
//localSpike包結(jié)構(gòu)體定義
package localSpike
type LocalSpike struct {
LocalInStock int64
LocalSalesVolume int64
}
...
//remoteSpike對hash結(jié)構(gòu)的定義和redis連接池
package remoteSpike
//遠程訂單存儲健值
type RemoteSpikeKeys struct {
SpikeOrderHashKey string //redis中秒殺訂單hash結(jié)構(gòu)key
TotalInventoryKey string //hash結(jié)構(gòu)中總訂單庫存key
QuantityOfOrderKey string //hash結(jié)構(gòu)中已有訂單數(shù)量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
}
本地扣庫存和統(tǒng)一扣庫存
本地扣庫存邏輯非常簡單,用戶請求過來,添加銷量,然后對比銷量是否大于本地庫存,返回 Bool 值:
package localSpike
//本地扣庫存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
spike.LocalSalesVolume = spike.LocalSalesVolume + 1
return spike.LocalSalesVolume < spike.LocalInStock
}
統(tǒng)一扣庫存操作 Redis,因為 Redis 是單線程的,而我們要實現(xiàn)從中取數(shù)據(jù),寫數(shù)據(jù)并計算一些列步驟,我們要配合 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))
-- 查看是否還有余票,增加訂單數(shù)量,返回結(jié)果值
if(ticket_total_nums >= ticket_sold_nums) then
return redis.call('HINCRBY', ticket_key, ticket_sold_key, 1)
end
return 0
`
//遠端統(tǒng)一扣庫存
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
}
hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0
響應(yīng)用戶信息
我們開啟一個 HTTP 服務(wù),監(jiān)聽在一個端口上:
package main
...
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3005", nil)
}
上面我們做完了所有的初始化工作,接下來 handleReq 的邏輯非常清晰,判斷是否搶票成功,返回給用戶信息就可以了。
package main
//處理請求函數(shù),根據(jù)請求將響應(yīng)結(jié)果信息寫入日志
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
//將搶票狀態(tài)寫入到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)
}
單機服務(wù)壓測
開啟服務(wù),我們使用 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 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)
而且查看日志發(fā)現(xiàn)整個服務(wù)過程中,請求都很正常,流量均勻,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
...
總結(jié)回顧
總體來說,秒殺系統(tǒng)是非常復(fù)雜的。我們這里只是簡單介紹模擬了一下單機如何優(yōu)化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略
完整的訂單系統(tǒng)還有訂單進度的查看,每臺服務(wù)器上都有一個任務(wù),定時的從總庫存同步余票和庫存信息展示給用戶,還有用戶在訂單有效期內(nèi)不支付,釋放訂單,補充到庫存等等。
-End-
最近有一些小伙伴,讓我?guī)兔φ乙恍?nbsp;面試題 資料,于是我翻遍了收藏的 5T 資料后,匯總整理出來,可以說是程序員面試必備!所有資料都整理到網(wǎng)盤了,歡迎下載!

面試題】即可獲取
