关于nginx:千万级-高并发-秒杀-架构设计含源码

44次阅读

共计 10120 个字符,预计需要花费 26 分钟才能阅读完成。

公众号:MarkerHub,网站:https://markerhub.com

小 Hub 领读:


  • 作者:绘你一世倾城
  • https://juejin.im/post/5d84e2…
  • github 源码地址:
  • https://github.com/GuoZhaoran…

每到节假日期间, 一二线城市返乡、外出玩耍的人们简直都面临着一个问题:抢火车票! 尽管当初大多数状况下都能订到票, 然而放票霎时即无票的场景,置信大家都深有体会。尤其是春节期间,大家不仅应用 12306,还会思考“智行”和其余的抢票软件, 全国上下几亿人在这段时间都在抢票。“12306 服务”接受着这个世界上任何秒杀零碎都无奈超过的 QPS, 上百万的并发再失常不过了!笔者专门钻研了一下“12306”的服务端架构, 学习到了其零碎设计上很多亮点,在这里和大家分享一下并模仿一个例子:如何在 100 万人同时抢 1 万张火车票时,零碎提供失常、稳固的服务。

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

我将申请的端口日志信息写到了./stat.log 文件当中,而后应用 ab 压测工具做压测:

ab-n1000-c100http://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 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-n10000-c100http://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 的高并发申请,简直所有的计算都是在内存中实现的,而且无效的保障了不超卖、不少卖,还可能容忍局部机器的宕机。我感觉其中有两点特地值得学习总结:

1、负载平衡,分而治之。通过负载平衡,将不同的流量划分到不同的机器上,每台机器解决好本人的申请,将本人的性能施展到极致,这样零碎的整体也就能接受极高的并发了,就像工作的的一个团队,每个人都将本人的价值施展到了极致,团队成长天然是很大的。

2、正当的应用并发和异步。自 epoll 网络架构模型解决了 c10k 问题以来,异步越来被服务端开发人员所承受,可能用异步来做的工作,就用异步来做,在性能拆解上能达到意想不到的成果,这点在 nginx、node.js、redis 上都能体现,他们解决网络申请应用的 epoll 模型,用实际通知了咱们单线程仍然能够施展弱小的威力。服务器曾经进入了多核时代,go 语言这种天生为并发而生的语言,完满的施展了服务器多核优势,很多能够并发解决的工作都能够应用并发来解决,比方 go 解决 http 申请时每个申请都会在一个 goroutine 中执行,总之: 怎么正当的压迫 CPU, 让其施展出应有的价值,是咱们始终须要摸索学习的方向。

举荐浏览

太赞了,这个 Java 网站,什么我的项目都有!https://markerhub.com

这个 B 站的 UP 主,讲的 java 真不错!

太赞了!最新版 Java 编程思维能够在线看了!

正文完
 0