首頁  >  文章  >  後端開發  >  「12306」的架構到底有多屌?

「12306」的架構到底有多屌?

angryTom
angryTom轉載
2019-11-05 18:15:572903瀏覽

每到假日期間,一二線城市返鄉、外出遊玩的人們幾乎都面臨一個問題:搶火車票!

12306 搶票,極限並發帶來的思考

雖然現在大多數情況下都能訂到票,但是放票瞬間即無票的場景,相信大家都深有體會。
尤其是春節期間,大家不僅使用 12306,還會考慮「智行」和其他的搶票軟體,全國上下幾億人在這段時間都在搶票。
「12306 服務」承受著這個世界上任何秒殺系統都無法超越的 QPS,上百萬的並發再正常不過了!
筆者專門研究了一下「12306」的服務端架構,學習到了其係統設計上很多亮點,在這裡和大家分享一下並模擬一個例子:如何在100 萬人同時搶1 萬張火車票時,系統提供正常、穩定的服務。

Github程式碼位址:

https://github.com/GuoZhaoran/spikeSystem

## 大型高並發系統架構

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

下邊是一個簡單的示意圖:

「12306」的架構到底有多屌?

負載平衡簡

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

①OSPF(開放式最短連結優先)是內部閘道協定(Interior Gateway Protocol,簡稱 IGP)

OSPF 透過路由器之間通告網路介面的狀態來建立連結狀態資料庫,產生最短路徑樹,OSPF 會自動計算路由介面上的Cost 值,但也可以透過手動指定該介面的Cost 值,手動指定的優先於自動計算的值。

OSPF 計算的 Cost,同樣是和介面頻寬成反比,頻寬越高,Cost 值越小。到達目標相同 Cost 值的路徑,可以執行負載平衡,最多 6 條連結同時執行負載平衡。

②LVS (Linux Virtual Server)

它是一種叢集(Cluster)技術,採用 IP 負載平衡技術和基於內容請求分發技術。

調度器具有很好的吞吐率,將請求均衡地轉移到不同的伺服器上執行,且調度器自動屏蔽掉伺服器的故障,從而將一組伺服器構成一個高效能的、高可用的虛擬伺服器。

③Nginx

想必大家都很熟悉了,是一款非常高效能的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;
    }
}

我在本地 / 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, "\r\n"}, "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 機制的負載平衡》:https://www.kancloud.cn/digest/understandingnginx/202607


秒殺搶購系統選型

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

從上面的介紹我們知道用戶秒殺流量通過層層的負載平衡,均勻到了不同的伺服器上,即使如此,叢集中的單機所承受的 QPS 也是非常高的。如何將單機效能優化到極致呢?

要解決這個問題,我們就要想明白一件事:通常訂票系統要處理產生訂單、減扣庫存、用戶支付這三個基本的階段。

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

這三個階段的先後順序該怎麼分配才更合理呢?讓我們來分析一下:

下單減庫存

「12306」的架構到底有多屌?
#當使用者並發請求到達服務端時,首先建立訂單,然後扣除庫存,等待用戶付款。

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

但是這樣也會產生一些問題:

在極限並發情況下,任何一個記憶體操作的細節都至關影響效能,尤其像是建立訂單這種邏輯,一般都需要儲存到磁碟資料庫的,對資料庫的壓力是可想而知的。

如果用戶有惡意下單的情況,只下單不支付這樣庫存就會變少,會少賣很多訂單,雖然服務端可以限制IP 和用戶的購買訂單數量,這也不算是一個好方法。

支付減庫存

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

「12306」的架構到底有多屌?

當庫存減為零的時候很多用戶發現搶到的訂單支付不了了,這也就是所謂的「超賣」。也不能避免並發操作資料庫磁碟 IO。
預扣庫存

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

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

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

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

扣庫存的藝術

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

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

「12306」的架構到底有多屌?


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

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

我們把一定的庫存量分配到本地機器,直接在記憶體中減去庫存,然後按照先前的邏輯非同步建立訂單。

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

「12306」的架構到底有多屌?

#這樣就避免了對資料庫頻繁的IO 操作,只在記憶體中做運算,極大的提高了單機抗併發的能力。

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

但是 Linux 系統下,一切資源皆文件,網路請求也是這樣,大量的文件描述符會使作業系統瞬間失去回應。
上面我們提到了 Nginx 的加權均衡策略,我們不妨假設將 100W 的使用者請求量平均均衡到 100 台伺服器上,這樣單機所承受的並發量就小了很多。

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

「12306」的架構到底有多屌?

问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这 100 台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。

要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。
有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“Buffer 库存”用来防止机器中有机器宕机的情况。

我们结合下面架构图具体分析一下:

「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 <- 1
}

本地扣库存和统一扣库存

本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回 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(&#39;HGET&#39;, ticket_key, ticket_total_key))
        local ticket_sold_nums = tonumber(redis.call(&#39;HGET&#39;, ticket_key, ticket_sold_key))
        -- 查看是否还有余票,增加订单数量,返回结果值
       if(ticket_total_nums >= ticket_sold_nums) then
            return redis.call(&#39;HINCRBY&#39;, 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 := ""
    <-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)
}

前边提到我们扣库存时要考虑竞态条件,我们这里是使用 Channel 避免并发的读写,保证了请求的高效顺序执行。我们将接口的返回信息写入到了 ./stat.log 文件方便做压测统计。

单机服务压测

开启服务,我们使用 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)

根据指标显示,我单机每秒就能处理 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
...

总结回顾

总体来说,秒杀系统是非常复杂的。我们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、不少卖的一些策略

完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展示给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。
我们实现了高并发抢票的核心逻辑,可以说系统设计的非常的巧妙,巧妙的避开了对 DB 数据库 IO 的操作。
对 Redis 网络 IO 的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。

我觉得其中有两点特别值得学习总结:
①负载均衡,分而治之

通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致。

这样系统的整体也就能承受极高的并发了,就像工作的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。

②合理的使用并发和异步

自 Epoll 网络架构模型解决了 c10k 问题以来,异步越来越被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果。

这点在 Nginx、Node.JS、Redis 上都能体现,他们处理网络请求使用的 Epoll 模型,用实践告诉了我们单线程依然可以发挥强大的威力。
服务器已经进入了多核时代,Go 语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如 Go 处理 HTTP 请求时每个请求都会在一个 Goroutine 中执行。

总之,怎样合理的压榨 CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。

以上是「12306」的架構到底有多屌?的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:微信。如有侵權,請聯絡admin@php.cn刪除