千萬級別高並發"秒殺"架構設計

  • 2019 年 11 月 7 日
  • 筆記

筆者專門研究了一下「12306」的服務端架構,學習到了其系統設計上很多亮點,在這裡和大家分享一下並模擬一個例子:如何在100萬人同時搶1萬張火車票時,系統提供正常、穩定的服務。

github源碼地址:

https://github.com/GuoZhaoran/spikeSystem

1、大型高並發系統架構

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

1.1 負載均衡簡介

上圖中描述了用戶請求到伺服器經歷了三層的負載均衡,下邊分別簡單介紹一下這三種負載均衡:

1、OSPF(開放式最短鏈路優先)是一個內部網關協議(Interior Gateway Protocol,簡稱IGP)。OSPF通過路由器之間通告網路介面的狀態來建立鏈路狀態資料庫,生成最短路徑樹,OSPF會自動計算路由介面上的Cost值,但也可以通過手工指定該介面的Cost值,手工指定的優先於自動計算的值。OSPF計算的Cost,同樣是和介面頻寬成反比,頻寬越高,Cost值越小。到達目標相同Cost值的路徑,可以執行負載均衡,最多6條鏈路同時執行負載均衡。

2、LVS (Linux VirtualServer),它是一種集群(Cluster)技術,採用IP負載均衡技術和基於內容請求分發技術。調度器具有很好的吞吐率,將請求均衡地轉移到不同的伺服器上執行,且調度器自動屏蔽掉伺服器的故障,從而將一組伺服器構成一個高性能的、高可用的虛擬伺服器。

3、Nginx想必大家都很熟悉了,是一款非常高性能的http代理/反向代理伺服器,服務開發中也經常使用它來做負載均衡。Nginx實現負載均衡的方式主要有三種:輪詢、加權輪詢、ip hash輪詢,下面我們就針對Nginx的加權輪詢做專門的配置和測試

1.2 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;   }   }

我在本地/etc/hosts目錄下配置了 www.load_balance.com的虛擬域名地址,接下來使用Go語言開啟四個http埠監聽服務,下面是監聽在3001埠的Go程式,其他幾個只需要修改埠即可:

package main    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, "rn"}, "3001")      buf := []byte(content)      fd.Write(buf)  }

我將請求的埠日誌資訊寫到了./stat.log文件當中,然後使用ab壓測工具做壓測:

ab -n 1000-c 100 http://www.load_balance.com/buy/ticket

統計日誌中的結果,3001-3004埠分別得到了100、200、300、400的請求量,這和我在nginx中配置的權重佔比很好的吻合在了一起,並且負載後的流量非常的均勻、隨機。具體的實現大家可以參考nginx的upsteam模組實現源碼,這裡推薦一篇文章:Nginx 中 upstream 機制的負載均衡

2、秒殺搶購系統選型

回到我們最初提到的問題中來:火車票秒殺系統如何在高並發情況下提供正常、穩定的服務呢?

從上面的介紹我們知道用戶秒殺流量通過層層的負載均衡,均勻到了不同的伺服器上,即使如此,集群中的單機所承受的QPS也是非常高的。如何將單機性能優化到極致呢?要解決這個問題,我們就要想明白一件事:通常訂票系統要處理生成訂單、減扣庫存、用戶支付這三個基本的階段,我們系統要做的事情是要保證火車票訂單不超賣、不少賣,每張售賣的車票都必須支付才有效,還要保證系統承受極高的並發。這三個階段的先後順序改怎麼分配才更加合理呢?我們來分析一下:

2.1 下單減庫存

當用戶並發請求到達服務端時,首先創建訂單,然後扣除庫存,等待用戶支付。這種順序是我們一般人首先會想到的解決方案,這種情況下也能保證訂單不會超賣,因為創建訂單之後就會減庫存,這是一個原子操作。但是這樣也會產生一些問題,第一就是在極限並發情況下,任何一個記憶體操作的細節都至關影響性能,尤其像創建訂單這種邏輯,一般都需要存儲到磁碟資料庫的,對資料庫的壓力是可想而知的;第二是如果用戶存在惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制IP和用戶的購買訂單數量,這也不算是一個好方法。

2.2 支付減庫存

如果等待用戶支付了訂單在減庫存,第一感覺就是不會少賣。但是這是並發架構的大忌,因為在極限並發情況下,用戶可能會創建很多訂單,當庫存減為零的時候很多用戶發現搶到的訂單支付不了了,這也就是所謂的「超賣」。也不能避免並發操作資料庫磁碟IO

2.3 預扣庫存

從上邊兩種方案的考慮,我們可以得出結論:只要創建訂單,就要頻繁操作資料庫IO。那麼有沒有一種不需要直接操作資料庫IO的方案呢,這就是預扣庫存。先扣除了庫存,保證不超賣,然後非同步生成用戶訂單,這樣響應給用戶的速度就會快很多;那麼怎麼保證不少賣呢?用戶拿到了訂單,不支付怎麼辦?我們都知道現在訂單都有有效期,比如說用戶五分鐘內不支付,訂單就失效了,訂單一旦失效,就會加入新的庫存,這也是現在很多網上零售企業保證商品不少賣採用的方案。訂單的生成是非同步的,一般都會放到MQ、kafka這樣的即時消費隊列中處理,訂單量比較少的情況下,生成訂單非常快,用戶幾乎不用排隊。

3、扣庫存的藝術

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

在單機低並發情況下,我們實現扣庫存通常是這樣的:

為了保證扣庫存和生成訂單的原子性,需要採用事務處理,然後取庫存判斷、減庫存,最後提交事務,整個流程有很多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庫存的總量,因為當本地庫存不足時,系統直接返回用戶「已售罄」的資訊提示,就不會再走統一扣庫存的邏輯,這在一定程度上也避免了巨大的網路請求量把redis壓跨,所以buffer值設置多少,需要架構師對系統的負載能力做認真的考量。

4、程式碼演示

Go語言原生為並發設計,我採用go語言給大家演示一下單機搶票的具體流程。

4.1 初始化工作

go包中的init函數先於main函數執行,在這個階段主要做一些準備性工作。我們系統需要做的準備工作有:初始化本地庫存、初始化遠程redis存儲統一庫存的hash鍵值、初始化redis連接池;另外還需要初始化一個大小為1的int類型chan,目的是實現分散式鎖的功能,也可以直接使用讀寫鎖或者使用redis等其他的方式避免資源競爭,但使用channel更加高效,這就是go語言的哲學:不要通過共享記憶體來通訊,而要通過通訊來共享記憶體。redis庫使用的是redigo,下面是程式碼實現:

...  //localSpike包結構體定義  package localSpike    type LocalSpikestruct{  LocalInStock int64  LocalSalesVolume int64  }  ...  //remoteSpike對hash結構的定義和redis連接池  package remoteSpike  //遠程訂單存儲健值  type RemoteSpikeKeysstruct{  SpikeOrderHashKeystring//redis中秒殺訂單hash結構key  TotalInventoryKeystring//hash結構中總訂單庫存key  QuantityOfOrderKeystring//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  ......  constLuaScript= `          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{  returnfalse  }  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, "rn"}, "")      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的壓測資訊

ThisisApacheBench, Version2.3<$Revision: 1826891 $>  Copyright1996AdamTwiss, ZeusTechnologyLtd, http://www.zeustech.net/  Licensed to TheApacheSoftwareFoundation, http://www.apache.org/  Benchmarking127.0.0.1(be patient)  Completed1000 requests  Completed2000 requests  Completed3000 requests  Completed4000 requests  Completed5000 requests  Completed6000 requests  Completed7000 requests  Completed8000 requests  Completed9000 requests  Completed10000 requests  Finished10000 requests  ServerSoftware:  ServerHostname:        127.0.0.1  ServerPort:            3005  DocumentPath:          /buy/ticket  DocumentLength:        29 bytes  ConcurrencyLevel:      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  ConnectionTimes(ms)                min  mean[+/-sd] median   max  Connect:        0814.76223  Processing:     21517.611232  Waiting:        11113.58225  Total:          72322.818239  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  ...

5、總結回顧

總體來說,秒殺系統是非常複雜的。我們這裡只是簡單介紹模擬了一下單機如何優化到高性能,集群如何避免單點故障,保證訂單不超賣、不少賣的一些策略,完整的訂單系統還有訂單進度的查看,每台伺服器上都有一個任務,定時的從總庫存同步余票和庫存資訊展示給用戶,還有用戶在訂單有效期內不支付,釋放訂單,補充到庫存等等。

我們實現了高並發搶票的核心邏輯,可以說系統設計的非常的巧妙,巧妙的避開了對DB資料庫IO的操作,對Redis網路IO的高並發請求,幾乎所有的計算都是在記憶體中完成的,而且有效的保證了不超賣、不少賣,還能夠容忍部分機器的宕機。我覺得其中有兩點特別值得學習總結:

1、負載均衡,分而治之。通過負載均衡,將不同的流量劃分到不同的機器上,每台機器處理好自己的請求,將自己的性能發揮到極致,這樣系統的整體也就能承受極高的並發了,就像工作的的一個團隊,每個人都將自己的價值發揮到了極致,團隊成長自然是很大的。

2、合理的使用並發和非同步。自epoll網路架構模型解決了c10k問題以來,非同步越來被服務端開發人員所接受,能夠用非同步來做的工作,就用非同步來做,在功能拆解上能達到意想不到的效果,這點在nginx、node.js、redis上都能體現,他們處理網路請求使用的epoll模型,用實踐告訴了我們單執行緒依然可以發揮強大的威力。伺服器已經進入了多核時代,go語言這種天生為並發而生的語言,完美的發揮了伺服器多核優勢,很多可以並發處理的任務都可以使用並發來解決,比如go處理http請求時每個請求都會在一個goroutine中執行,總之:怎樣合理的壓榨CPU,讓其發揮出應有的價值,是我們一直需要探索學習的方向。

作者:繪你一世傾城@獵豹移動