“12306”的架构到底有多6?
生活随笔
收集整理的這篇文章主要介紹了
“12306”的架构到底有多6?
小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
戳藍字“CSDN云計算”關注我們哦!
責編 | 阿禿
每到節假日期間,一二線城市返鄉、外出游玩的人們幾乎都面臨著一個問題:搶火車票!
尤其是春節期間,大家不僅使用 12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票。
“12306 服務”承受著這個世界上任何秒殺系統都無法超越的 QPS,上百萬的并發再正常不過了!
筆者專門研究了一下“12306”的服務端架構,學習到了其系統設計上很多亮點,在這里和大家分享一下并模擬一個例子:如何在 100 萬人同時搶 1 萬張火車票時,系統提供正常、穩定的服務。
Github代碼地址:https://github.com/GuoZhaoran/spikeSystem
下邊是一個簡單的示意圖:
上圖中描述了用戶請求到服務器經歷了三層的負載均衡,下邊分別簡單介紹一下這三種負載均衡。
①OSPF(開放式最短鏈路優先)是一個內部網關協議(Interior Gateway Protocol,簡稱?IGP)
OSPF 通過路由器之間通告網絡接口的狀態來建立鏈路狀態數據庫,生成最短路徑樹,OSPF 會自動計算路由接口上的 Cost 值,但也可以通過手工指定該接口的 Cost 值,手工指定的優先于自動計算的值。
OSPF 計算的 Cost,同樣是和接口帶寬成反比,帶寬越高,Cost 值越小。到達目標相同 Cost 值的路徑,可以執行負載均衡,最多 6 條鏈路同時執行負載均衡。
它是一種集群(Cluster)技術,采用 IP 負載均衡技術和基于內容請求分發技術。
調度器具有很好的吞吐率,將請求均衡地轉移到不同的服務器上執行,且調度器自動屏蔽掉服務器的故障,從而將一組服務器構成一個高性能的、高可用的虛擬服務器。
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服務器,服務開發中也經常使用它來做負載均衡。
Nginx 實現負載均衡的方式主要有三種:
下面我們就針對 Nginx 的加權輪詢做專門的配置和測試。
Nginx 實現負載均衡通過 Upstream 模塊實現,其中加權輪詢的配置是可以給相關的服務加上一個權重值,配置的時候可能根據服務器的性能、負載能力設置相應的負載。
下面是一個加權輪詢負載的配置,我將在本地的監聽 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;
????}
}
import?(
????"net/http"
????"os"
????"strings"
)
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3001",?nil)
}
//處理請求函數,根據請求將響應結果信息寫入日志
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
這和我在 Nginx 中配置的權重占比很好的吻合在了一起,并且負載后的流量非常的均勻、隨機。
具體的實現大家可以參考 Nginx 的 Upsteam 模塊實現源碼,這里推薦一篇文章《Nginx 中 Upstream 機制的負載均衡》:https://www.kancloud.cn/digest/understandingnginx/202607
回到我們最初提到的問題中來:火車票秒殺系統如何在高并發情況下提供正常、穩定的服務呢?
從上面的介紹我們知道用戶秒殺流量通過層層的負載均衡,均勻到了不同的服務器上,即使如此,集群中的單機所承受的 QPS 也是非常高的。如何將單機性能優化到極致呢?
要解決這個問題,我們就要想明白一件事:通常訂票系統要處理生成訂單、減扣庫存、用戶支付這三個基本的階段。
當用戶并發請求到達服務端時,首先創建訂單,然后扣除庫存,等待用戶支付。
這種順序是我們一般人首先會想到的解決方案,這種情況下也能保證訂單不會超賣,因為創建訂單之后就會減庫存,這是一個原子操作。
但是這樣也會產生一些問題:
如果等待用戶支付了訂單在減庫存,第一感覺就是不會少賣。但是這是并發架構的大忌,因為在極限并發情況下,用戶可能會創建很多訂單。
從上邊兩種方案的考慮,我們可以得出結論:只要創建訂單,就要頻繁操作數據庫 IO。
那么有沒有一種不需要直接操作數據庫 IO 的方案呢,這就是預扣庫存。先扣除了庫存,保證不超賣,然后異步生成用戶訂單,這樣響應給用戶的速度就會快很多;那么怎么保證不少賣呢?用戶拿到了訂單,不支付怎么辦?
我們都知道現在訂單都有有效期,比如說用戶五分鐘內不支付,訂單就失效了,訂單一旦失效,就會加入新的庫存,這也是現在很多網上零售企業保證商品不少賣采用的方案。
訂單的生成是異步的,一般都會放到 MQ、Kafka 這樣的即時消費隊列中處理,訂單量比較少的情況下,生成訂單非常快,用戶幾乎不用排隊。
從上面的分析可知,顯然預扣庫存的方案最合理。我們進一步分析扣庫存的細節,這里還有很大的優化空間,庫存存在哪里?怎樣保證高并發下,正確的扣庫存,還能快速的響應用戶請求?
在單機低并發情況下,我們實現扣庫存通常是這樣的:
為了保證扣庫存和生成訂單的原子性,需要采用事務處理,然后取庫存判斷、減庫存,最后提交事務,整個流程有很多 IO,對數據庫的操作又是阻塞的。
這樣就避免了對數據庫頻繁的 IO 操作,只在內存中做運算,極大的提高了單機抗并發的能力。
但是百萬的用戶請求量單機是無論如何也抗不住的,雖然 Nginx 處理網絡請求使用 Epoll 模型,c10k 的問題在業界早已得到了解決。
但是 Linux 系統下,一切資源皆文件,網絡請求也是這樣,大量的文件描述符會使操作系統瞬間失去響應。
上面我們提到了 Nginx 的加權均衡策略,我們不妨假設將 100W 的用戶請求量平均均衡到 100 臺服務器上,這樣單機所承受的并發量就小了很多。
然后我們每臺機器本地庫存 100 張火車票,100?臺服務器上的總庫存還是?1 萬,這樣保證了庫存訂單不超賣,下面是我們描述的集群架構:
問題接踵而至,在高并發情況下,現在我們還無法保證系統的高可用,假如這 100?臺服務器上有兩三臺機器因為扛不住并發的流量或者其他的原因宕機了。那么這些服務器上的訂單就賣不出去了,這就造成了訂單的少賣。
要解決這個問題,我們需要對總訂單量做統一的管理,這就是接下來的容錯方案。服務器不僅要在本地減庫存,另外要遠程統一減庫存。
有了遠程統一減庫存的操作,我們就可以根據機器負載情況,為每臺機器分配一些多余的“Buffer 庫存”用來防止機器中有機器宕機的情況。
我們結合下面架構圖具體分析一下:
我們采用 Redis 存儲統一庫存,因為 Redis 的性能非常高,號稱單機 QPS 能抗 10W 的并發。
在本地減庫存以后,如果本地有訂單,我們再去請求 Redis 遠程減庫存,本地減庫存和遠程減庫存都成功了,才返回給用戶搶票成功的提示,這樣也能有效的保證訂單不會超賣。
當機器中有機器宕機時,因為每個機器上有預留的 Buffer 余票,所以宕機機器上的余票依然能夠在其他機器上得到彌補,保證了不少賣。
Buffer 余票設置多少合適呢,理論上 Buffer 設置的越多,系統容忍宕機的機器數量就越多,但是 Buffer 設置的太大也會對 Redis 造成一定的影響。
雖然 Redis 內存數據庫抗并發能力非常高,請求依然會走一次網絡 IO,其實搶票過程中對 Redis 的請求次數是本地庫存和 Buffer 庫存的總量。
Go 語言原生為并發設計,我采用 Go 語言給大家演示一下單機搶票的具體流程。
Go 包中的 Init 函數先于 Main 函數執行,在這個階段主要做一些準備性工作。
我們系統需要做的準備工作有:初始化本地庫存、初始化遠程 Redis 存儲統一庫存的 Hash 鍵值、初始化 Redis 連接池。
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?<-?1
}
本地扣庫存邏輯非常簡單,用戶請求過來,添加銷量,然后對比銷量是否大于本地庫存,返回 Bool 值:package?localSpike
//本地扣庫存,返回bool值
func?(spike?*LocalSpike)?LocalDeductionStock()?bool{
????spike.LocalSalesVolume?=?spike.LocalSalesVolume?+?1
????return?spike.LocalSalesVolume?<?spike.LocalInStock
}
統一扣庫存操作 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
}
我們開啟一個 HTTP 服務,監聽在一個端口上:package?main
...
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3005",?nil)
}
//處理請求函數,根據請求將響應結果信息寫入日志
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)
}
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)
而且查看日志發現整個服務過程中,請求都很正常,流量均勻,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
...
總體來說,秒殺系統是非常復雜的。我們這里只是簡單介紹模擬了一下單機如何優化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略
完整的訂單系統還有訂單進度的查看,每臺服務器上都有一個任務,定時的從總庫存同步余票和庫存信息展示給用戶,還有用戶在訂單有效期內不支付,釋放訂單,補充到庫存等等。
我們實現了高并發搶票的核心邏輯,可以說系統設計的非常的巧妙,巧妙的避開了對 DB 數據庫 IO 的操作。
對 Redis 網絡 IO 的高并發請求,幾乎所有的計算都是在內存中完成的,而且有效的保證了不超賣、不少賣,還能夠容忍部分機器的宕機。
我覺得其中有兩點特別值得學習總結:
①負載均衡,分而治之
自 Epoll 網絡架構模型解決了 c10k 問題以來,異步越來越被服務端開發人員所接受,能夠用異步來做的工作,就用異步來做,在功能拆解上能達到意想不到的效果。
服務器已經進入了多核時代,Go 語言這種天生為并發而生的語言,完美的發揮了服務器多核優勢,很多可以并發處理的任務都可以使用并發來解決,比如 Go 處理 HTTP 請求時每個請求都會在一個 Goroutine 中執行。
福利掃描添加小編微信,備注“姓名+公司職位”,入駐【CSDN博客】,加入【云計算學習交流群】,和志同道合的朋友們共同打卡學習!
推薦閱讀:
作者 |? ?繪你一世傾城
責編 | 阿禿
每到節假日期間,一二線城市返鄉、外出游玩的人們幾乎都面臨著一個問題:搶火車票!
12306 搶票,極限并發帶來的思考
尤其是春節期間,大家不僅使用 12306,還會考慮“智行”和其他的搶票軟件,全國上下幾億人在這段時間都在搶票。
“12306 服務”承受著這個世界上任何秒殺系統都無法超越的 QPS,上百萬的并發再正常不過了!
筆者專門研究了一下“12306”的服務端架構,學習到了其系統設計上很多亮點,在這里和大家分享一下并模擬一個例子:如何在 100 萬人同時搶 1 萬張火車票時,系統提供正常、穩定的服務。
Github代碼地址:https://github.com/GuoZhaoran/spikeSystem
大型高并發系統架構
高并發的系統架構都會采用分布式集群部署,服務上層有著層層負載均衡,并提供各種容災手段(雙火機房、節點容錯、服務器災備等)保證系統的高可用,流量也會根據不同的負載能力和配置策略均衡到不同的服務器上。下邊是一個簡單的示意圖:
負載均衡簡介
上圖中描述了用戶請求到服務器經歷了三層的負載均衡,下邊分別簡單介紹一下這三種負載均衡。
①OSPF(開放式最短鏈路優先)是一個內部網關協議(Interior Gateway Protocol,簡稱?IGP)
OSPF 通過路由器之間通告網絡接口的狀態來建立鏈路狀態數據庫,生成最短路徑樹,OSPF 會自動計算路由接口上的 Cost 值,但也可以通過手工指定該接口的 Cost 值,手工指定的優先于自動計算的值。
OSPF 計算的 Cost,同樣是和接口帶寬成反比,帶寬越高,Cost 值越小。到達目標相同 Cost 值的路徑,可以執行負載均衡,最多 6 條鏈路同時執行負載均衡。
它是一種集群(Cluster)技術,采用 IP 負載均衡技術和基于內容請求分發技術。
調度器具有很好的吞吐率,將請求均衡地轉移到不同的服務器上執行,且調度器自動屏蔽掉服務器的故障,從而將一組服務器構成一個高性能的、高可用的虛擬服務器。
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服務器,服務開發中也經常使用它來做負載均衡。
Nginx 實現負載均衡的方式主要有三種:
- 輪詢
- 加權輪詢
- IP?Hash?輪詢
下面我們就針對 Nginx 的加權輪詢做專門的配置和測試。
Nginx 加權輪詢的演示
Nginx 實現負載均衡通過 Upstream 模塊實現,其中加權輪詢的配置是可以給相關的服務加上一個權重值,配置的時候可能根據服務器的性能、負載能力設置相應的負載。
下面是一個加權輪詢負載的配置,我將在本地的監聽 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;
????}
}
import?(
????"net/http"
????"os"
????"strings"
)
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3001",?nil)
}
//處理請求函數,根據請求將響應結果信息寫入日志
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
這和我在 Nginx 中配置的權重占比很好的吻合在了一起,并且負載后的流量非常的均勻、隨機。
具體的實現大家可以參考 Nginx 的 Upsteam 模塊實現源碼,這里推薦一篇文章《Nginx 中 Upstream 機制的負載均衡》:https://www.kancloud.cn/digest/understandingnginx/202607
秒殺搶購系統選型
回到我們最初提到的問題中來:火車票秒殺系統如何在高并發情況下提供正常、穩定的服務呢?
從上面的介紹我們知道用戶秒殺流量通過層層的負載均衡,均勻到了不同的服務器上,即使如此,集群中的單機所承受的 QPS 也是非常高的。如何將單機性能優化到極致呢?
要解決這個問題,我們就要想明白一件事:通常訂票系統要處理生成訂單、減扣庫存、用戶支付這三個基本的階段。
下單減庫存
當用戶并發請求到達服務端時,首先創建訂單,然后扣除庫存,等待用戶支付。
這種順序是我們一般人首先會想到的解決方案,這種情況下也能保證訂單不會超賣,因為創建訂單之后就會減庫存,這是一個原子操作。
但是這樣也會產生一些問題:
- 在極限并發情況下,任何一個內存操作的細節都至關影響性能,尤其像創建訂單這種邏輯,一般都需要存儲到磁盤數據庫的,對數據庫的壓力是可想而知的。
- 如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制 IP 和用戶的購買訂單數量,這也不算是一個好方法。
支付減庫存
如果等待用戶支付了訂單在減庫存,第一感覺就是不會少賣。但是這是并發架構的大忌,因為在極限并發情況下,用戶可能會創建很多訂單。
預扣庫存
從上邊兩種方案的考慮,我們可以得出結論:只要創建訂單,就要頻繁操作數據庫 IO。
那么有沒有一種不需要直接操作數據庫 IO 的方案呢,這就是預扣庫存。先扣除了庫存,保證不超賣,然后異步生成用戶訂單,這樣響應給用戶的速度就會快很多;那么怎么保證不少賣呢?用戶拿到了訂單,不支付怎么辦?
我們都知道現在訂單都有有效期,比如說用戶五分鐘內不支付,訂單就失效了,訂單一旦失效,就會加入新的庫存,這也是現在很多網上零售企業保證商品不少賣采用的方案。
訂單的生成是異步的,一般都會放到 MQ、Kafka 這樣的即時消費隊列中處理,訂單量比較少的情況下,生成訂單非常快,用戶幾乎不用排隊。
扣庫存的藝術
從上面的分析可知,顯然預扣庫存的方案最合理。我們進一步分析扣庫存的細節,這里還有很大的優化空間,庫存存在哪里?怎樣保證高并發下,正確的扣庫存,還能快速的響應用戶請求?
在單機低并發情況下,我們實現扣庫存通常是這樣的:
為了保證扣庫存和生成訂單的原子性,需要采用事務處理,然后取庫存判斷、減庫存,最后提交事務,整個流程有很多 IO,對數據庫的操作又是阻塞的。
這樣就避免了對數據庫頻繁的 IO 操作,只在內存中做運算,極大的提高了單機抗并發的能力。
但是百萬的用戶請求量單機是無論如何也抗不住的,雖然 Nginx 處理網絡請求使用 Epoll 模型,c10k 的問題在業界早已得到了解決。
但是 Linux 系統下,一切資源皆文件,網絡請求也是這樣,大量的文件描述符會使操作系統瞬間失去響應。
上面我們提到了 Nginx 的加權均衡策略,我們不妨假設將 100W 的用戶請求量平均均衡到 100 臺服務器上,這樣單機所承受的并發量就小了很多。
然后我們每臺機器本地庫存 100 張火車票,100?臺服務器上的總庫存還是?1 萬,這樣保證了庫存訂單不超賣,下面是我們描述的集群架構:
問題接踵而至,在高并發情況下,現在我們還無法保證系統的高可用,假如這 100?臺服務器上有兩三臺機器因為扛不住并發的流量或者其他的原因宕機了。那么這些服務器上的訂單就賣不出去了,這就造成了訂單的少賣。
要解決這個問題,我們需要對總訂單量做統一的管理,這就是接下來的容錯方案。服務器不僅要在本地減庫存,另外要遠程統一減庫存。
有了遠程統一減庫存的操作,我們就可以根據機器負載情況,為每臺機器分配一些多余的“Buffer 庫存”用來防止機器中有機器宕機的情況。
我們結合下面架構圖具體分析一下:
我們采用 Redis 存儲統一庫存,因為 Redis 的性能非常高,號稱單機 QPS 能抗 10W 的并發。
在本地減庫存以后,如果本地有訂單,我們再去請求 Redis 遠程減庫存,本地減庫存和遠程減庫存都成功了,才返回給用戶搶票成功的提示,這樣也能有效的保證訂單不會超賣。
當機器中有機器宕機時,因為每個機器上有預留的 Buffer 余票,所以宕機機器上的余票依然能夠在其他機器上得到彌補,保證了不少賣。
Buffer 余票設置多少合適呢,理論上 Buffer 設置的越多,系統容忍宕機的機器數量就越多,但是 Buffer 設置的太大也會對 Redis 造成一定的影響。
雖然 Redis 內存數據庫抗并發能力非常高,請求依然會走一次網絡 IO,其實搶票過程中對 Redis 的請求次數是本地庫存和 Buffer 庫存的總量。
代碼演示
Go 語言原生為并發設計,我采用 Go 語言給大家演示一下單機搶票的具體流程。
初始化工作
Go 包中的 Init 函數先于 Main 函數執行,在這個階段主要做一些準備性工作。
我們系統需要做的準備工作有:初始化本地庫存、初始化遠程 Redis 存儲統一庫存的 Hash 鍵值、初始化 Redis 連接池。
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?<-?1
}
本地扣庫存和統一扣庫存
本地扣庫存邏輯非常簡單,用戶請求過來,添加銷量,然后對比銷量是否大于本地庫存,返回 Bool 值:package?localSpike
//本地扣庫存,返回bool值
func?(spike?*LocalSpike)?LocalDeductionStock()?bool{
????spike.LocalSalesVolume?=?spike.LocalSalesVolume?+?1
????return?spike.LocalSalesVolume?<?spike.LocalInStock
}
統一扣庫存操作 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
}
響應用戶信息
我們開啟一個 HTTP 服務,監聽在一個端口上:package?main
...
func?main()?{
????http.HandleFunc("/buy/ticket",?handleReq)
????http.ListenAndServe(":3005",?nil)
}
//處理請求函數,根據請求將響應結果信息寫入日志
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)
}
單機服務壓測
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)
而且查看日志發現整個服務過程中,請求都很正常,流量均勻,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
...
總結回顧
總體來說,秒殺系統是非常復雜的。我們這里只是簡單介紹模擬了一下單機如何優化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略
完整的訂單系統還有訂單進度的查看,每臺服務器上都有一個任務,定時的從總庫存同步余票和庫存信息展示給用戶,還有用戶在訂單有效期內不支付,釋放訂單,補充到庫存等等。
我們實現了高并發搶票的核心邏輯,可以說系統設計的非常的巧妙,巧妙的避開了對 DB 數據庫 IO 的操作。
對 Redis 網絡 IO 的高并發請求,幾乎所有的計算都是在內存中完成的,而且有效的保證了不超賣、不少賣,還能夠容忍部分機器的宕機。
我覺得其中有兩點特別值得學習總結:
①負載均衡,分而治之
自 Epoll 網絡架構模型解決了 c10k 問題以來,異步越來越被服務端開發人員所接受,能夠用異步來做的工作,就用異步來做,在功能拆解上能達到意想不到的效果。
服務器已經進入了多核時代,Go 語言這種天生為并發而生的語言,完美的發揮了服務器多核優勢,很多可以并發處理的任務都可以使用并發來解決,比如 Go 處理 HTTP 請求時每個請求都會在一個 Goroutine 中執行。
福利掃描添加小編微信,備注“姓名+公司職位”,入駐【CSDN博客】,加入【云計算學習交流群】,和志同道合的朋友們共同打卡學習!
推薦閱讀:
總結
以上是生活随笔為你收集整理的“12306”的架构到底有多6?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 不看就亏系列!这里有完整的 Hadoop
- 下一篇: 在线教育如何应对流量洪峰?阿里云专家:上