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

負載均衡簡介
上圖中描述了用戶請求到服務器經(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)移到不同的服務器上執(zhí)行,且調(diào)度器自動屏蔽掉服務器的故障,從而將一組服務器構(gòu)成一個高性能的、高可用的虛擬服務器。
③Nginx
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服務器,服務開發(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)聽服務,下面是監(jiān)聽在 3001 端口的 Go 程序,其他幾個只需要修改端口即可:
package?main
import?(
????"net/http"
????"os"
????"strings"
)
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3001",?nil)
}
//處理請求函數(shù),根據(jù)請求將響應結(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ù)庫的壓力是可想而知的。
如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制 IP 和用戶的購買訂單數(shù)量,這也不算是一個好方法。
支付減庫存

預扣庫存

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


然后我們每臺機器本地庫存 100 張火車票,100?臺服務器上的總庫存還是?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?}
統(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
響應用戶信息
我們開啟一個 HTTP 服務,監(jiān)聽在一個端口上:
package?main
...
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3005",?nil)
}
上面我們做完了所有的初始化工作,接下來 handleReq 的邏輯非常清晰,判斷是否搶票成功,返回給用戶信息就可以了。
package?main
//處理請求函數(shù),根據(jù)請求將響應結(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)
}
單機服務壓測
開啟服務,我們使用 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)整個服務過程中,請求都很正常,流量均勻,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)是非常復雜的。我們這里只是簡單介紹模擬了一下單機如何優(yōu)化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略
完整的訂單系統(tǒng)還有訂單進度的查看,每臺服務器上都有一個任務,定時的從總庫存同步余票和庫存信息展示給用戶,還有用戶在訂單有效期內(nèi)不支付,釋放訂單,補充到庫存等等。
來源:juejin.im/post/5d84e21f6fb9a06ac8248149
版權(quán)申明:內(nèi)容來源網(wǎng)絡(luò),版權(quán)歸原創(chuàng)者所有。除非無法確認,我們都會標明作者及出處,如有侵權(quán)煩請告知,我們會立即刪除并表示歉意。謝謝!

