12306 的架构也太 "牛X" 了吧!

每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!
虽然如今大多数状况下都能订到票,可是放票瞬间即无票的场景,相信你们都深有体会。尤为是春节期间,你们不只使用12306,还会考虑“智行”和其余的抢票软件,全国上下几亿人在这段时间都在抢票。
“12306服务”承受着这个世界上任何秒杀系统都没法超越的QPS,上百万的并发再正常不过了!笔者专门研究了一下“12306”的服务端架构,学习到了其系统设计上不少亮点。
在这里和你们分享一下并模拟一个例子:如何在100万人同时抢1万张火车票时,系统提供正常、稳定的服务。

GitGub代码地址:https://github.com/GuoZhaoran/spikeSystemjavascript


1java

 大型高并发系统架构 node

高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各类容灾手段(双火机房、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不一样的负载能力和配置策略均衡到不一样的服务器上。下边是一个简单的示意图:
640?wx_fmt=jpeg

1.1 负载均衡简介

上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡:
OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称IGP)。OSPF经过路由器之间通告网络接口的状态来创建链路状态数据库,生成最短路径树,OSPF会自动计算路由接口上的Cost值,但也能够经过手工指定该接口的Cost值,手工指定的优先于自动计算的值。
OSPF计算的Cost,一样是和接口带宽成反比,带宽越高,Cost值越小。到达目标相同Cost值的路径,能够执行负载均衡,最多6条链路同时执行负载均衡。
LVS (Linux VirtualServer),它是一种集群(Cluster)技术,采用IP负载均衡技术和基于内容请求分发技术。调度器具备很好的吞吐率,将请求均衡地转移到不一样的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。
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, "\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中配置的权重占比很好的吻合在了一块儿,而且负载后的流量很是的均匀、随机。linux

具体的实现你们能够参考nginx的upsteam模块实现源码,这里推荐一篇文章:nginx

https://www.kancloud.cn/digest/understandingnginx/202607git


2github

 秒杀抢购系统选型 redis

回到咱们最初提到的问题中来:火车票秒杀系统如何在高并发状况下提供正常、稳定的服务呢?

从上面的介绍咱们知道用户秒杀流量经过层层的负载均衡,均匀到了不一样的服务器上,即便如此,集群中的单机所承受的QPS也是很是高的。如何将单机性能优化到极致呢?要解决这个问题,咱们就要想明白一件事:数据库

一般订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段,咱们系统要作的事情是要保证火车票订单不超卖、很多卖,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。apache

这三个阶段的前后顺序改怎么分配才更加合理呢?咱们来分析一下:

2.1 下单减库存

640?wx_fmt=jpeg

当用户并发请求到达服务端时,首先建立订单,而后扣除库存,等待用户支付。这种顺序是咱们通常人首先会想到的解决方案,这种状况下也能保证订单不会超卖,由于建立订单以后就会减库存,这是一个原子操做。

可是这样也会产生一些问题。

第一就是在极限并发状况下,任何一个内存操做的细节都相当影响性能,尤为像建立订单这种逻辑,通常都须要存储到磁盘数据库的,对数据库的压力是可想而知的;

第二是若是用户存在恶意下单的状况,只下单不支付这样库存就会变少,会少卖不少订单,虽然服务端能够限制IP和用户的购买订单数量,这也不算是一个好方法。

关注微信公众号:Java技术栈,在后台回复:架构,能够获取我整理的 N 篇最新架构教程,都是干货。

2.2 支付减库存

640?wx_fmt=jpeg

若是等待用户支付了订单在减库存,第一感受就是不会少卖。可是这是并发架构的大忌,由于在极限并发状况下,用户可能会建立不少订单,当库存减为零的时候不少用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操做数据库磁盘IO

2.3 预扣库存

640?wx_fmt=jpeg

从上边两种方案的考虑,咱们能够得出结论:只要建立订单,就要频繁操做数据库IO。那么有没有一种不须要直接操做数据库IO的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,而后异步生成用户订单,这样响应给用户的速度就会快不少;那么怎么保证很多卖呢?
用户拿到了订单,不支付怎么办?咱们都知道如今订单都有有效期,好比说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是如今不少网上零售企业保证商品很多卖采用的方案。订单的生成是异步的,通常都会放到MQ、kafka这样的即时消费队列中处理,订单量比较少的状况下,生成订单很是快,用户几乎不用排队。

3

 扣库存的艺术

从上面的分析可知,显然预扣库存的方案最合理。咱们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?

在单机低并发状况下,咱们实现扣库存一般是这样的:

640?wx_fmt=jpeg

为了保证扣库存和生成订单的原子性,须要采用事务处理,而后取库存判断、减库存,最后提交事务,整个流程有不少IO,对数据库的操做又是阻塞的。这种方式根本不适合高并发的秒杀系统。

接下来咱们对单机扣库存的方案作优化:本地扣库存。咱们把必定的库存量分配到本地机器,直接在内存中减库存,而后按照以前的逻辑异步建立订单。改进过以后的单机系统是这样的:

640?wx_fmt=jpeg

这样就避免了对数据库频繁的IO操做,只在内存中作运算,极大的提升了单机抗并发的能力。可是百万的用户请求量单机是不管如何也抗不住的,虽然nginx处理网络请求使用epoll模型,c10k的问题在业界早已获得了解决。
可是linux系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操做系统瞬间失去响应。
上面咱们提到了nginx的加权均衡策略,咱们不妨假设将100W的用户请求量平均均衡到100台服务器上,这样单机所承受的并发量就小了不少。而后咱们每台机器本地库存100张火车票,100台服务器上的总库存仍是1万,这样保证了库存订单不超卖,下面是咱们描述的集群架构:

640?wx_fmt=jpeg

问题接踵而至,在高并发状况下,如今咱们还没法保证系统的高可用,假如这100台服务器上有两三台机器由于扛不住并发的流量或者其余的缘由宕机了。那么这些服务器上的订单就卖不出去了,这就形成了订单的少卖。
要解决这个问题,咱们须要对总订单量作统一的管理,这就是接下来的容错方案。服务器不只要在本地减库存,另外要远程统一减库存。有了远程统一减库存的操做,咱们就能够根据机器负载状况,为每台机器分配一些多余的“buffer库存”用来防止机器中有机器宕机的状况。
咱们结合下面架构图具体分析一下:

640?wx_fmt=jpeg

当机器中有机器宕机时,由于每一个机器上有预留的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 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
}

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
......
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
}
咱们使用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, "\r\n"}, "")
    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的压测信息:
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
...

5

 总结回顾

整体来讲,秒杀系统是很是复杂的。咱们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、很多卖的一些策略,完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展现给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。

咱们实现了高并发抢票的核心逻辑,能够说系统设计的很是的巧妙,巧妙的避开了对DB数据库IO的操做,对Redis网络IO的高并发请求,几乎全部的计算都是在内存中完成的,并且有效的保证了不超卖、很多卖,还可以容忍部分机器的宕机。我以为其中有两点特别值得学习总结:
负载均衡,分而治之。经过负载均衡,将不一样的流量划分到不一样的机器上,每台机器处理好本身的请求,将本身的性能发挥到极致,这样系统的总体也就能承受极高的并发了,就像工做的的一个团队,每一个人都将本身的价值发挥到了极致,团队成长天然是很大的。
合理的使用并发和异步。自epoll网络架构模型解决了c10k问题以来,异步越来被服务端开发人员所接受,可以用异步来作的工做,就用异步来作,在功能拆解上能达到意想不到的效果,这点在nginx、node.js、redis上都能体现,他们处理网络请求使用的epoll模型,用实践告诉了咱们单线程依然能够发挥强大的威力。
服务器已经进入了多核时代,go语言这种天生为并发而生的语言,完美的发挥了服务器多核优点,不少能够并发处理的任务均可以使用并发来解决,好比go处理http请求时每一个请求都会在一个goroutine中执行,总之:怎样合理的压榨CPU,让其发挥出应有的价值,是咱们一直须要探索学习的方向。

做者:绘你一世倾城

来源:juejin.im/post/5d84e21f6fb9a06ac8248149

- END -
推荐阅读:

关注 Java技术栈 公众号在后台回复: Java ,可获取一份栈长整理的最新 Java 技术干货。

640

点击「阅读原文」和栈长学更多~