ホームページ  >  記事  >  バックエンド開発  >  「12306」の建築はどれほど素晴らしいのでしょうか?

「12306」の建築はどれほど素晴らしいのでしょうか?

angryTom
angryTom転載
2019-11-05 18:15:572980ブラウズ

休日になるたびに、帰省したり、一級都市や二級都市に遊びに出かけたりする人々は、ほぼ必ずと言っていいほど、1 つの問題に直面します。それは、電車の切符を手に入れることです。

12306 チケットの取得、極端な同時実行によってもたらされる思考

チケットは現在ほとんどの場合予約できますが、チケットが届いた時点ではチケットはありません。リリース 誰もがシーンについて深く理解していると思います。
特に春節期間中は、12306 を使用するだけでなく、「Zhixing」やその他のチケット取得ソフトウェアも考慮され、この期間中、全国で何億人もの人々がチケットを取得しています。
「12306 サービス」は、世界中のどのインスタント キル システムも超えることのできない QPS を備えています。数百万の同時実行はごく普通のことです。
著者は、「12306」のサーバー アーキテクチャを具体的に研究し、そのシステム設計の多くのハイライトを学びました。ここで、例を共有してシミュレーションします: 100 万人が同時に 10,000 枚の電車の切符を手に入れる方法システムは通常の安定したサービスを提供します。

Github コード アドレス:

https://github.com/GuoZhaoran/spikeSystem

大規模な高-同時実行システム アーキテクチャ

高度な同時実行システム アーキテクチャは、分散クラスタ展開を採用し、サービスの上位層は層ごとの負荷分散を備え、さまざまな災害復旧手段 (デュアルファイア コンピュータ ルーム、ノード障害) を提供します。システムの高可用性を確保するために、さまざまな負荷能力と構成戦略に基づいて、トラフィックはさまざまなサーバーに分散されます。
次は簡単な図です:

「12306」の建築はどれほど素晴らしいのでしょうか?

負荷分散の概要

上の図は、ユーザーがサーバーに要求する 3 層の負荷分散を説明しています。次に、これら 3 種類の負荷分散について簡単に説明します。

①OSPF (Open Shortest Link First) は、Interior Gateway Protocol (略して IGP) です。

OSPF は、ルーター間のネットワーク インターフェイスのステータスを通知します。リンク状態データベースを確立します。最短パス ツリーを生成すると、OSPF はルーティング インターフェイスのコスト値を自動的に計算しますが、インターフェイスのコスト値を手動で指定することもできます。手動で指定した値は、自動的に計算された値よりも優先されます。

OSPF によって計算されるコストもインターフェイス帯域幅に反比例し、帯域幅が大きいほどコスト値は小さくなります。ターゲットまでのコスト値が同じパスで負荷分散を行うことができ、最大6リンクまで同時に負荷分散を行うことができます。

②LVS (Linux Virtual Server)

IP負荷分散技術とコンテンツベースのリクエスト分散技術を用いたクラスタ(Cluster)技術です。
スケジューラは非常に優れたスループット レートを備えており、実行のためにリクエストをさまざまなサーバーに均等に転送し、サーバーの障害を自動的に保護して、サーバーのグループを高性能で可用性の高い仮想サーバーに形成します。

③Nginx

皆さんもご存知かと思いますが、非常に高性能なHTTPプロキシ/リバースプロキシサーバーで、サービス開発でもよく使われています。負荷分散。
Nginx がロード バランシングを実現するには、主に 3 つの方法があります: ポーリング 加重ポーリング IP ハッシュ ポーリング

以下では、Nginx の加重ポーリングの特別な構成とテストを行います。

Nginx 加重ポーリングのデモンストレーション

Nginx は、アップストリーム モジュールを介して負荷分散を実装します。加重ポーリングの構成により、関連サービスに加重値を追加できます。対応する負荷構成時にサーバーのパフォーマンスと負荷容量に応じて設定できます。

次は加重ポーリング負荷設定です。ポート 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;
    }
}

I am local / www.load_balance.com の仮想ドメイン名アドレスは、etc/hosts ディレクトリに設定されます。

次に、Go 言語を使用して 4 つの 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)
}

I will requestポート ログ情報が ./stat.log ファイルに書き込まれ、ストレス テストに AB ストレス テスト ツールが使用されます: ab -n 1000 -

c 100 http://www.load_balance.com/buy/ticket

。統計ログの結果によると、ポート 3001 ~ 3004 は 100、リクエスト量は 200、300、400 を取得しました。

これは、Nginx で設定した重み比と一致しており、負荷後のトラフィックは非常に均一でランダムです。

具体的な実装については、Nginx のアップストリーム モジュール実装ソース コードを参照してください。推奨記事「Nginx におけるアップストリーム メカニズムの負荷分散」: https://www.kancloud.cn/digest/ Understandingnginx/ 202607

フラッシュ セール システムの選択

先ほどの最初の質問に戻ります。鉄道チケットのフラッシュ セール システムは、高負荷環境下で通常の安定したパフォーマンスをどのように提供するのでしょうか。同時実行条件はどうですか? サービスはどうですか?

上記の説明から、ユーザーのフラッシュ セール トラフィックは、負荷分散レイヤーを通じてさまざまなサーバーに均等に分散されていることがわかりますが、それでも、クラスター内の 1 台のマシンが耐える QPS も非常に高いです。スタンドアロンのパフォーマンスを極限まで最適化するにはどうすればよいでしょうか?

この問題を解決するには、1 つのことを理解する必要があります。通常、予約システムは注文の生成、在庫削減、ユーザーの支払いという 3 つの基本段階を処理する必要があります。

私たちのシステムが行う必要があるのは、鉄道チケットの注文が売れすぎたり売られすぎたりしないようにすることです。販売された各チケットは有効であるために支払われる必要があります。また、システムが非常に高い同時実行に耐えられることを確認する必要があります。

これら 3 つのステージの順序をより合理的に割り当てるにはどうすればよいでしょうか?分析してみましょう:

在庫を減らすために注文を出します

「12306」の建築はどれほど素晴らしいのでしょうか?
ユーザーの同時リクエストがサーバーで、まず注文を作成し、次に在庫を差し引いてユーザーの支払いを待ちます。

この注文は、ほとんどの人が最初に考える解決策です。この場合、注文の作成後に在庫が削減されるため、注文が売れすぎないようにすることもできます。アトミック操作。

しかし、これはいくつかの問題も引き起こします:

極端な同時実行性の場合、メモリ操作の詳細、特に注文の作成などのパフォーマンスに大きな影響を与えます。一般にロジックはディスクデータベースに保存する必要があり、データベースへの負担が考えられます。

ユーザーが悪意を持って注文し、支払いなしで注文した場合、サーバーは IP とユーザーの注文数を制限できますが、在庫が減り、多くの注文の販売が減少します。 、これはカウントされません。良い方法です。

お金を払って在庫を減らす

ユーザーが注文の代金を払って在庫を減らすのを待っていると、最初に感じるのは、もう在庫は減らないということです。販売。しかし、これは並行アーキテクチャのタブーです。極端な同時並行性では、ユーザーが多数の注文を作成する可能性があるためです。

「12306」の建築はどれほど素晴らしいのでしょうか?

在庫がゼロになると、多くのユーザーは、受け取った注文の代金を支払うことができないことに気づきます。これがいわゆる「売られすぎ」です。同時データベース ディスク IO 操作も避けることはできません。
在庫保留

上記の 2 つのソリューションを考慮すると、注文が作成される限り、データベース IO を頻繁に操作する必要があると結論付けることができます。

それでは、データベース IO の直接操作を必要としないソリューションはありますか? これは保留在庫です。まず、売れすぎないように在庫が差し引かれ、次にユーザーの注文が非同期で生成されるため、ユーザーへの応答が大幅に速くなります。では、大量の販売を確保するにはどうすればよいでしょうか?注文を受け取った後に支払いを行わなかった場合、ユーザーはどうすればよいでしょうか?

注文には有効期間があることは誰もが知っています。たとえば、ユーザーが 5 分以内に支払いをしない場合、注文は期限切れになります。注文の期限が切れると、新しい在庫が追加されます。これが、多くのユーザーが注文する理由です。オンライン小売会社は現在、商品が正規品ではないことを保証しており、採用されているソリューションを販売することは少なくなりました。

注文は非同期に生成され、通常は MQ や Kafka などのインスタント消費キューで処理されます。注文量が比較的少ない場合、注文は非常に迅速に生成されるため、ユーザーはほとんどキューに入る必要がありません。

在庫を保留する技術

上記の分析から、在庫を保留する計画が最も合理的であることは明らかです。在庫控除の詳細をさらに分析してみましょう。最適化の余地はまだたくさんあります。在庫はどこにありますか?同時実行性が高く、ユーザーのリクエストに迅速に対応できる環境で、正しい在庫控除を確実に行うにはどうすればよいでしょうか?

単一マシンでの同時実行性が低い場合、通常は次のような在庫控除を実装します。

「12306」の建築はどれほど素晴らしいのでしょうか?


在庫控除と注文生成 アトミシティでは、トランザクション処理、在庫判定、在庫削減、トランザクション送信が必要となり、そのプロセス全体で大量の IO が発生し、データベースの動作がブロックされます。

この方法は、同時実行性の高いフラッシュ販売システムにはまったく適していません。次に、単一マシンの在庫控除計画、つまりローカル在庫控除を最適化します。

一定量の在庫をローカル マシンに割り当て、メモリ内の在庫を直接減らしてから、前のロジックに従って非同期で注文を作成します。

改良されたスタンドアロン システムは次のようになります:

「12306」の建築はどれほど素晴らしいのでしょうか?

これにより、データベースでの頻繁な IO 操作が回避され、メモリ内でのみ操作が実行されるため、非常に便利です。単一マシンの同時実行に対する耐性が大幅に向上します。

ただし、1 台のマシンでは何百万ものユーザー リクエストに耐えることはできません。Nginx はネットワーク リクエストの処理に Epoll モデルを使用していますが、c10k の問題は業界で長い間解決されてきました。

しかし、Linux システムではすべてのリソースはファイルであり、ネットワーク リクエストにも同じことが当てはまります。ファイル記述子が多数あると、オペレーティング システムが即座に応答しなくなることがあります。
Nginx の重み付けバランシング戦略については上で説明しましたが、100 万のユーザー リクエストが平均して 100 台のサーバーに分散されると仮定したほうがよいため、1 台のマシンが耐えられる同時実行の量ははるかに少なくなります。

次に、100 枚の電車の切符を各マシンにローカルに保存しますが、100 台のサーバー上の総在庫は依然として 10,000 です。これにより、在庫注文が売れすぎないことが保証されます。以下に説明するクラスター アーキテクチャを示します: # ##################################

问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这 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 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事は微信で複製されています。侵害がある場合は、admin@php.cn までご連絡ください。