商城高并发秒杀系统架构分析和设计

引言

高并发、高可用,都是面试的重点,如果你在简历中的项目与商城、秒杀相关,亦或是头部大厂、中厂,这样的问题你一定不会陌生。

之前面试某头部大厂,一面、二面均提到了关于商城中库存一致性和高并发中防止超卖、少卖、减库存,大概率三面也会问相关问题,趁这个机会,整理一下关于高并发秒杀系统的知识点。

这篇文章主要分析:如何在100万人同时抢一万张火车票时,系统提供正常、稳定的服务。

1. 高并发系统架构

高并发系统一般都会采用分布式集群部署,服务层上有层层负载均衡,并提供各种容灾手段(双活数据中心、异地多活、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。

1.1 负载均衡

图中使用了三种负载均衡,分别是OSPF负载均衡、Lvs负载均衡、Nginx负载均衡。

  • OSPF(开放式最短路径优先)是一个内部网关协议(IGP)。OSPF通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF会自动计算路由接口上的Cost值,但也可以通过手工指定(优先)该接口的Cost值。OSPF计算的Cost,同样是和接口带宽成反比,带宽越高,Cost值越小。到达目标相同Cost值的路径,可以执行负载均衡,默认为4条线路的负载均衡,最大支持6条线路的负载均衡,我们可以在OSPF路由进程中的maximuum-paths=6修改负载均衡的线路数。

  • LVS(Linux Virtual Server),是一种集群技术,已经被集成到Linux内核模块中,实现了基于IP数据请求负载均衡的调度和内容请求分发技术。调度器具有很好的吞吐率,将请求均衡的转移到不同的服务器执行,且调度器能够屏蔽掉服务器的故障,从而将一组服务器构成一个高性能、高可用的虚拟服务器。

  • Nginx是一款高性能的HTTP代理/反代理服务器,服务器开发中经常用来做负载均衡,Nginx的负载均衡主要方式包括:①轮询 ②加权轮询 ③ip hash 轮询

1.2 Nginx加权轮询

Nginx实现负载均衡通过upstream模块实现,其中加权轮询的配置是可以给相关的服务加上一个权重值,配置的时候可能根据服务器性能、负载能力设置响应的负载。

通过一个示例来实现加权轮询负载的配置,在本地监听8001-8004端口,权重配置为2、4、6、8。

#配置负载均衡
    upstream load_rule {
       server 127.0.0.1:8001 weight=2;
       server 127.0.0.1:8002 weight=4;
       server 127.0.0.1:8003 weight=6;
       server 127.0.0.1:8004 weight=8;
    }
    ...
    server {
    listen       80;
    server_name  baidu.com www.baidu.com;
    location / {
       proxy_pass http://load_rule;
    }
}

其他更细致的轮询策略等问题,可以查看理解 Nginx 源码中的这一篇:Nginx 中 upstream 机制的负载均衡

1.3 补充:压测实验

在本地/etc/hosts目录下配置了 www.baidu.com 的虚拟域名地址,接下来使用Go语言开启四个http端口监听服务,下面是监听在8001端口的Go程序,其他几个只需要修改端口即可:

package main

import (
	"net/http"
	"os"
	"strings"
)

func main() {
	http.HandleFunc("/buy/ticket", handleReq)
	http.ListenAndServe(":8001", 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"}, "8001")
	buf := []byte(content)
	fd.Write(buf)
}

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

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

统计日志中的结果,8001-8004端口分别得到了200、400、600、800的请求量,这和我在nginx中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。

2. 秒杀抢购系统机制

Question:假如有100万人同时抢一张火车票,如何提供正常、稳定的服务呢?

在已经使用了负载均衡的情况下,用户秒杀的流量通过了每一层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机承受的QPS也是非常高的。

通常来说,订票系统需要处理生成订单、减库存、用户支付这三个基本的阶段,我们系统要做的事情就是保证火车票订单不超卖、不少卖,每一张出售的火车票都是必须支付才有效,还要保证在持续的高并发环境中运行稳定。

下面我们分析一下这三个基本阶段的先后顺序:

2.1 下单减库存

当用户请求并发到达服务端的时候,首先创建订单,然后扣除库存,等待用户支付。这是一种最常见的思路,这种情况可以保证订单不超卖,因为创建订单之后减库存(利用Redis increment 原子操作)这是一个原子操作。

但是这样也会产生一些问题:

  • **第一是:**在极限并发的情况下,任何一个内存操作的细节都至关重要的影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是非常巨大的;
  • **第二是:**如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制IP和用户的购买订单数量,这也不算是一个好方法。

2.2 支付减库存

如果等待用户支付了订单再减库存,就可以避免少卖。这是并发架构的大忌,因为在极限并发的情况下,用户可能会不断点击下单键,导致下了多单,当库存减为0的时候很多用户会发现抢到的订单支付不了,这就是所谓的**“超卖”**。同时,这也不能避免并发操作数据库和磁盘IO。

2.3 预扣库存

从上面两种方案考虑,我们得出结论:只要创建订单,就要频繁操作数据库IO。

那么我们优化的点就是:有没有一种方案可以不需要直接操作数据库IO呢?

这就是预扣库存(具体实现和优化参考3.预扣库存优化):

  1. 保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;
  2. 保证不少卖,给订单加上有效期,过期不支付就加入新库存;
  3. 订单是异步生成的,一般都会放到MQ、KAFKA这样的即时消费队列中处理,订单较少的情况下,生成订单非常快,用户几乎不用排队;

2.4 三种方法对比

减库存方法 优点 缺点
下单减库存 避免超卖 造成少卖;恶意下单;对数据库压力巨大
支付减库存 避免少卖 造成超卖;订单过多,后期无法支付;数据库、磁盘IO压力巨大
预扣库存 避免超卖、少卖 还有优化空间(如何保证高并发下正确扣库存且快速响应用户请求)

3. 预扣库存优化

从上面的例子分析,预扣库存是最优的选择,既可以避免超卖、少卖,但是还有优化的空间。

优化方案需要解决的问题:

  1. 库存存在哪里?
  2. 怎样保证高并发情况下,正确的扣库存,还能快速响应用户的请求?

下面我们来一一分析:

在单机高并发的情况下,我们实现的扣库存通常是这样实现的:

为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个过程有很多的IO,对数据库的操作又是阻塞的(第一个连接占有资源没有释放,而第二个连接需要获取这个资源。如果第一个连接没有提交或者回滚,第二个连接会一直等待下去,直到第一个连接释放该资源为止)。

换句话说,由于MySQL存储数据的特点,同一数据在数据库里肯定是一行存储,因此会有大量线程来竞争InnoDB行锁,而并发度越高时等待线程会越多,TPS(每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。

这种方法根本不适合实现高并发的秒杀系统

3.1 本地扣库存

针对之前普通的扣库存方式,我们的优化方案为:本地扣库存。我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单,改进之后我们的方案是这样的:

这样就避免了对数据库频繁的IO操作,只在内存中做运算,极大的提高了单机的抗并发能力,但是百万的用户量单机是无论如何都扛不住的,虽然Nginx处理网络请求使用epoll模型,C10K问题也已经被解决。但是在Linux系统下,一切资源皆是文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应,上面我们使用了Nginx的加权均衡策略,按这样的思路,我们可以把100W用户的请求均衡到100台服务器上,这样单机承受的并发量就会少很多,然后我们给每台机器本地库存100张火车票,100台服务器的总库存还是1万张火车票,这样保证了库存订单不超卖,下面通过一张图描述我们刚刚描述的集群架构:

3.2 远程统一减库存

问题又来了!在高并发的情况下,我们不一定能够保证系统的高可用,假如这100台服务器中,有2、3台机器因为扛不住并发的流量或者其他原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖

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

我们通过一张结构图来分析远程统一减库存的架构:

我们采用Redis存储统一库存,因为Redis的性能非常高,号称单机QPS能抗10W并发。在本地减库存以后,如果本地有订单,我们再去请求Redis远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效保证订单不会超卖。当机器中有机器宕机时,因为每个机器上都有预留的buffer余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不会少卖

buffer余票设置多少合理呢,理论上buffer余票设置的越多,系统能够容忍的宕机机器就越多,但是buffer设置的太大也会对Redis造成一定的影响。虽然Redis抗并发的能力非常高,请求依然会走一次网络IO,其实:抢票过程中对Redis的请求次数是本地库存和buffer库存的总量,因为当本地库存不足时,系统直接返回“已售罄!卖光啦”的信息提示,就不会再走扣统一库存的逻辑,这在一定的程度上也避免了巨大的网络请求把Redis压垮,所以buffer值设置多少才是架构师对系统负载能力的认真考量。

4. 总结回顾

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

完整的的订单系统还有订单进度的查看,每台服务器都有一个任务:①定时从总库存同步余票和库存信息展示给用户 ②当用户没有在订单有效期内不支付,释放订单,补充到库存等等。

在文章中,简单实现了高并发抢票的核心逻辑,可以说系统设计的非常巧妙,巧妙的避开了对DB数据库IO的操作,对Redis网络IO的高并发请求,几乎所有的计算都在内存中完成,而且有效保证了不超卖、不少卖,还能一定的灾备,可以允许有部分机器宕机。

其中,有两种设计思路值得借鉴、学习:

  • 负载均衡,分而治之:

    将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致,这样整体的抗并发能力也就高了。

    12306也会采用这样的设计思路,并且还将每天10点的抢票分到9点、10点、11点,分时间抢票,降低系统的压力。

  • 合理使用并发和异步:

    能够使用异步来做的事,都交给异步来做,把功能拆解能达到意想不到的效果,这点在Nginx、node.js、Redis上体现的淋漓尽致,他们处理网络模型请求使用的epoll模型,用实践告诉了我们单线程依然可以发挥强大的威力。Go这种天生为高并发而生的语言,完美的发挥了服务器多核的优势,很多可以并发处理的任务都可以使用并发来解决,比如go处理http请求时,每个请求都会在一个goroutine中执行,合理的压榨CPU,让其发挥出应有的价值,是我们需要学习和探索的方向。

4.1 预扣预测方法对比

减库存方法 优点 缺点
下单减库存 避免超卖 造成少卖;恶意下单;对数据库压力巨大
支付减库存 避免少卖 造成超卖;订单过多,后期无法支付;数据库、磁盘IO压力巨大
普通预扣库存 避免超卖、少卖 ①事务处理②取库存判断、减库存③提交事务,IO很多,数据库阻塞,根本不适合秒杀系统
本地预扣库存 ①避免超卖、少卖②避免数据库频繁的IO操作,在内存中运算③提高了单机扛并发能力 ①高并发情况下,大量请求会导致操作系统失去响应②当一台服务器宕机就会造成少卖
远程统一减库存 ①避免超卖、少卖②避免数据库频繁的IO操作,在内存中运算③集群扛并发能力强④能容忍有机器宕机⑤高并发也不会造成超卖和少卖⑥避免了Redis网络IO的高并发请求 妙啊~~~

5. 附录:代码演示

Go语言原生为并发设计,我采用go语言给大家演示一下单机抢票的具体流程。

5.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
}

5.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_sold_nums > ticket_total_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

5.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文件方便做压测统计。

5.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:            8005

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
...

6. 参考

  1. https://github.com/GuoZhaoran/spikeSystem
  2. 极客时间-如何设计一个秒杀系统






博文的后续更新,请关注我的个人博客:星尘博客

你可能感兴趣的:(面试常考题,高并发,分布式,redis,mysql)