作者|画你的一生
节假日,一二线城市返乡和外出游玩的人,几乎都面临一个问题:抢火车票!
虽然现在大部分情况下都能订到票,但是放票瞬间无票的场景相信大家都深有体会。
尤其是春运期间,人们不仅使用12306,还会考虑“智行”等抢票软件。全国上亿人都在这段时间抢票。
“12306服务”承载着世界上任何秒杀系统都无法超越的QPS,百万级并发很正常!
笔者专门研究了“12306”的服务器架构,了解到其系统设计中的诸多亮点。这里跟大家分享一下,模拟一个例子:100万人同时抢1万张火车票,如何提供正常稳定的服务。
代码地址:https://github.com/GuoZhaoran/spikeSystem
大规模高并发系统架构
高并发的系统架构将采用分布式集群部署,服务上层有逐层负载均衡,多种容灾手段(双消防机房、节点容错、服务器容灾备份等。)来保证系统的高可用性,流量会根据不同的负载能力和配置策略均衡到不同的服务器。
这是一个简单的示意图:
1.负载平衡简介
上图中描述了用户请求服务器经过三层负载均衡,下面分别简单介绍这三种负载均衡。
①OSPF(开放最短链路优先)是一种内部网关协议(IGP)。
OSPF建立链路状态数据库,并通过通知路由器之间网络接口的状态来生成最短路径树。OSPF会自动计算路由接口的开销值,但它也可以手动指定接口的开销值,该值优先于自动计算的值。
OSPF计算的开销也与接口带宽成反比。带宽越高,成本值越小。对于开销值相同的路径,可以进行负载均衡,最多6条链路同时进行负载均衡。
②LVS (Linux虚拟服务器)
它是一种集群技术,采用IP负载均衡技术和基于内容的请求分发技术。
调度器具有良好的吞吐率,将请求均衡地转移到不同的服务器上执行,并自动屏蔽服务器的故障,从而将一组服务器组成一个高性能、高可用的虚拟服务器。
③Nginx
大家一定很熟悉。它是一个非常高性能的HTTP代理/反向代理服务器,通常用于服务开发中的负载平衡。
Nginx实现负载平衡有三种主要方式:
投票
加权轮询
IP哈希轮询
下面我们将对Nginx的加权轮询进行特殊的配置和测试。
2.Nginx加权轮询演示
Nginx通过上游模块实现负载均衡,其中加权轮询的配置可以给相关服务增加一个权重值,可以根据服务器的性能和负载能力设置相应的负载。
以下是加权轮询负载的配置。我将监控本地端口3001-3004,并分别配置权重1、2、3和4:
#配置负载平衡
上游加载_规则{
服务器127.0.0.1:3001权重= 1;
服务器127.0.0.1:3002权重= 2;
服务器127.0.0.1:3003权重= 3;
服务器127.0.0.1:3004权重= 4;
}
…
服务器{
听80;
server _ name load _ balance . com www . load _ balance . com;
位置/ {
proxy _ pass http://load _ rule;
}
}
我在local /etc/hosts目录中配置了www.load_balance.com的虚拟域地址。
接下来,使用Go语言启动四个HTTP端口监控服务。下面是监听端口3001的Go程序,其他程序只需要修改端口:
主包装
导入(
\”网络/http \”
\”操作系统\”
\”字符串\”
)
功能主要{
http。HandleFunc(\”/buy/ticket \”,handleReq)
http。ListenAndServe(\”:3001 \”,无)
}
//处理请求函数,根据请求将响应结果信息写入日志。
func handleReq(w http。ResponseWriter。请求){
failedMsg := \”端口中的句柄:\”
writeLog(failedMsg,\”。/stat.log \”)
}
//写入日志
func writeLog(消息字符串,日志路径字符串){
fd,_ := os。OpenFile(logPath,os。O_RDWR|os。O_CREATE|os。o _ APPEND 0644)
推迟fd。关闭
内容:=字符串。Join([]string{msg,\” \\r\\n\”},\” 3001))
buf :=字节(内容)
fd。写(缓冲区)
}
我将请求的端口日志信息写入。/stat.log文件,然后使用AB压力测量工具进行压力测量:
a b-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.
摘穗系统的选择
回到我们一开始提到的问题:火车票秒杀系统如何在高并发下提供正常稳定的服务?
从上面的介绍中我们知道,用户尖峰流量是通过层层负载均衡,平均分配到不同的服务器上的。即便如此,集群中单个机器所承担的QPS还是非常高的。如何将单机性能优化到极致?
要解决这个问题,我们需要明白一件事:通常情况下,预订系统要处理三个基本阶段:订单生成、库存扣除和用户支付。
我们的系统需要做的是保证火车票订单不超售、不超售,每卖出一张票都要付费才有效,保证系统能够承受极高的并发。
如何更合理地分配这三个阶段的顺序?我们来分析一下:
1.下订单以减少库存
当用户的并发请求到达服务器时,首先创建订单,然后扣除库存,等待用户付款。
这个顺序是大多数人会想到的第一个解决方案。在这种情况下,也能保证订单不会超卖,因为订单创建后库存会减少,这是原子操作。
但这也会产生一些问题:
在极端并发的情况下,任何内存操作的细节都会影响性能,尤其是创建订单等逻辑,一般需要存储在磁盘数据库中,对数据库的压力可想而知。
如果用户恶意下单,只下单不付款会减少库存,卖出很多单,虽然服务器可以限制IP和用户的购买订单数量,但这不是一个好方法。
2.为库存减少买单
如果等用户付款下单,减少库存,第一感觉是不会少卖。但是,这是并发架构的禁忌,因为在极端并发的情况下,用户可能会创建很多订单。
当库存降至零时,很多用户发现抢来的订单无法支付,这就是所谓的“超卖”。也无法避免数据库磁盘IO的并发操作。
3.预扣库存
从以上两种方案的考虑,我们可以得出结论,只要创建一个订单,就必须频繁操作数据库IO。
那么有没有不需要直接操作数据库IO的方案呢?这是扣留库存。先扣除库存保证不超售,再异步生成用户订单,这样对用户的响应会快很多;那么如何保证大量的销量呢?用户拿到订单不付款怎么办?
我们都知道现在命令是有效的。例如,如果用户在五分钟内没有付款,订单将会无效。一旦订单失效,就会增加新的库存。这也是很多网络零售企业采取的方案,以保证很多商品卖出去。
订单的生成是异步的,通常放在MQ、Kafka等实时消费队列中。订单数量少的时候,订单生成非常快,用户几乎不需要排队。
保留存货的艺术
从以上分析可以看出,预提存货的方案是最合理的。我们进一步分析库存抵扣的细节,还有很多优化空。库存在哪里?如何保证高并发,正确的库存扣除,快速响应用户请求?
在单机低并发的情况下,我们通常如下实现库存扣除:
为了保证库存推演和订单生成的原子性,需要采用事务处理,然后采取库存判断,减少库存,最后提交事务。整个过程IO多,数据库的操作被阻塞。
这种方法根本不适合高并发尖峰系统。接下来我们优化单机库存扣除的方案:本地库存扣除。
我们给本地机器分配一定的库存,直接减少内存中的库存,然后按照之前的逻辑异步创建订单。
改进后的单机系统是这样的:
这样避免了对数据库的频繁IO操作,操作只在内存中进行,大大提高了单机抗并发的能力。
然而,数以百万计的用户请求无论如何都不能由单台计算机来抵抗。虽然Nginx使用Epoll模型处理网络请求,但是c10k的问题在业界早已解决。
但是在Linux系统下,所有的资源都是文件,网络请求也是。大量的文件描述符会让操作系统瞬间失去响应。
我们上面提到了Nginx的加权平衡策略。我们不妨假设100W的用户请求平均均衡到100台服务器,这样单机承担的并发就小很多。
然后我们在每台机器上本地库存100张火车票,100台服务器上总库存还是10000张,这样就保证了库存订单不会超售。下面是我们描述的集群架构:
问题接踵而至。在高并发的情况下,我们现在无法保证系统的高可用性。如果这100台服务器上的两到三台机器因为无法处理并发流量或其他原因而停机。然后这些服务器上的订单卖不出去,导致订单少了。
要解决这个问题,需要统一管理订单总量,这是下一个容错方案。服务器不仅要减少本地库存,还要减少远程库存。
通过远程统一减少库存操作,我们可以根据机器负载为每台机器分配一些额外的“缓冲库存”,以防止机器停机。
下面我们用下面的架构图来详细分析一下:
我们用Redis存储统一库存,因为Redis的性能很高,号称单个QPS可以抗10W并发。
本地降库存后,如果本地有订单,我们会要求Redis远程降库存。只有本地减库存和远程减库存成功,我们才会给用户返回抢票成功的提示,这也能有效保证订单不会超售。
当机器出现机器停机时,由于每台机器上都有预留的缓冲票,停机机器上剩余的票仍然可以在其他机器上补上,保证了大量的销量。
缓冲余票设置多少合适?理论上,缓冲区设置越多,系统可以容忍停机的机器就越多,但是缓冲区设置过大也会对Redis产生一定的影响。
虽然Redis内存数据库的抗并发能力很高,但是请求还是会占用一个网络IO。实际上抢票过程中对Redis的请求次数是本地库存和缓冲库存的总和。
因为当本地库存不足时,系统直接返回用户的“售完”消息提示,所以不会遵循统一的库存抵扣逻辑。
这在一定程度上也避免了碾压Redis的巨大网络请求,所以缓冲值设置多少需要架构师认真考虑系统的负载能力。
代码演示
Go语言最初是为并发而设计的。我用Go语言给大家演示一下单机抢票的具体流程。
1、初始化工作
Go包中的Init函数在Main函数之前执行,这个阶段主要做一些准备工作。
我们的系统需要做的准备工作包括:初始化本地清单,初始化远程Redis存储统一清单的Hash键值,初始化Redis连接池。
另外,还需要初始化一个大小为1的Int类型Chan,才能实现分布式锁的功能。
也可以直接使用读写锁或者使用Redis来避免资源竞争,但是使用Channel效率更高。这就是Go语言的哲学:通过交流共享内存,而不是共享内存。
Redis库使用Redigo,以下是代码实现:
…
//localSpike包结构定义
包localSpike
类型LocalSpike结构{
LocalInStock int64
LocalSalesVolume int64
}
…
//remoteSpike对哈希结构和redis连接池的定义
包remoteSpike
//远程订单存储键值
类型RemoteSpikeKeys结构{
SpikeOrderHashKey字符串//redis spike顺序哈希结构键
TotalInventoryKey字符串//哈希结构中的总订单库存键
QuantityForderKeystring//哈希结构中的现有订单数量键。
}
//初始化redis连接池
func NewPool *redis。池{
返回& amp雷迪斯。池{
麦克斯:10000,
MaxActive: 12000,//最大连接数
拨号:func(redis。连接,错误){
c,err := redis。拨号(“tcp”,“:6379”)
如果err!=零{
恐慌(呃。错误)
}
返回c,错误
},
}
}
…
功能初始化{
localSpike = localSpike2LocalSpike{
LocalInStock: 150,
本地销售数量:0,
}
remoteSpike = remoteSpike2。RemoteSpikeKeys{
SpikeOrderHashKey:\” ticket _ hash _ key \”,
TotalInventoryKey:\” ticket _ total _ nums \”,
QuantityOfOrderKey:\” ticket _ sold _ nums \”,
}
redisPool = remoteSpike2。新池
done = make(chan int,1)
完成& lt- 1
}
2.本地库存扣除和统一库存扣除
本地库存抵扣的逻辑很简单。用户请求过来加销量,然后比较销量是否大于本地库存,返回Bool值:
包localSpike
//本地扣除库存,返回bool值。
func(spike * local spike)localdutationstockbool {
斯派克。LocalSalesVolume =尖峰。本地销售额+ 1
回击道钉。LocalSalesVolume & lt斯派克。LocalInStock
}
注意,这里共享数据LocalSalesVolume的操作是通过使用锁来实现的,但是因为本地库存扣款和统一库存扣款是原子操作,所以在顶层使用了Channel,后面会讲到。
库存操作Redis的统一推演,因为Redis是单线程的,我们想实现从中取数据,写数据,计算一些列的步骤。我们应该配合Lua脚本来封装命令,以保证操作的原子性:
包remoteSpike
……
const LuaScript =
本地ticket_key = KEYS[1]
本地ticket_total_key = ARGV[1]
本地ticket_sold_key = ARGV[2]
local ticket _ total _ nums = to number(redis . call(\’ HGET \’,ticket_key,ticket_total_key))
local ticket _ sold _ nums = to number(redis . call(\’ HGET \’,ticket_key,ticket_sold_key))
-检查是否有剩余票,增加订单数量,并返回结果值。
if(ticket _ total _ nums & gt;= ticket_sold_nums)然后
return redis.call(\’HINCRBY \’,ticket_key,ticket_sold_key,1)
结束
返回0
`
//远程统一库存扣除
func(RemoteSpikeKeys * RemoteSpikeKeys)remoteductionstock(conn redis。连接)布尔{
lua := redis。NewScript(1,LuaScript)
结果,err := redis。Int(lua。做(连接,远程SpikeKeys。SpikeOrderHashKey,RemoteSpikeKeys。TotalInventoryKey,RemoteSpikeKeys。QuantityOfOrderKey))
如果err!=零{
返回false
}
返回结果!= 0
}
我们使用哈希结构来存储总库存和总销量的信息。当用户请求时,我们可以判断总销量是否大于库存,然后返回相关的Bool值。
在启动服务之前,我们需要初始化Redis的初始库存信息:
hmset ticket _ hash _ key \” ticket _ total _ nums \” 10000 \” ticket _ sold _ nums \” 0
3。响应用户信息
我们启动一个HTTP服务并监听一个端口:
主包装
…
功能主要{
http。HandleFunc(\”/buy/ticket \”,handleReq)
http。ListenAndServe(\”:3005 \”,无)
}
我们已经完成了上面所有的初始化工作,然后handleReq的逻辑就很清晰了。只需判断抢票是否成功,并将信息返回给用户即可。
主包装
//处理请求函数,根据请求将响应结果信息写入日志。
func handleReq(w http。ResponseWriter。请求){
redisConn:= re spool。得到
LogMsg := \” \”
& lt-完成了
//全局读写锁
如果localSpike。LocalDeductionStock & amp& amp远程尖刺。remotedutationstock(redisConn){
Util。RespJson(w,1,“抢票成功”,零)
LogMsg = LogMsg +\”结果:1,localSales:\” + strconv。FormatInt(localSpike。LocalSalesVolume,10)
}否则{
Util。RespJson(w,-1,“售完”,零)
LogMsg = LogMsg +\”结果:0,localSales:\” + strconv。FormatInt(localSpike。LocalSalesVolume,10)
}
完成& lt- 1
//将抢票状态写入日志。
写日志(日志消息。/stat.log \”)
}
func writeLog(消息字符串,日志路径字符串){
fd,_ := os。OpenFile(logPath,os。O_RDWR|os。O_CREATE|os。o _ APPEND 0644)
推迟fd。关闭
内容:=字符串。Join([]string{msg,\” \\r\\n\”},\”\”)
buf :=字节(内容)
fd。写(缓冲区)
}
如前所述,我们在扣除库存时要考虑竞争条件。我们在这里使用Channel来避免并发读写,保证了请求的高效有序执行。我们已经将接口的返回信息写到。压力测量统计的/stat.log文件。
4、单一服务压力测量
启动服务后,我们使用AB压力测量工具进行测试:
a b-n 10000-c 100 http://127 . 0 . 0 . 1:3005/买/票
以下是我本地低配Mac的测压信息:
这是ApacheBench,版本2.3 & lt$ revision:1826891 = \” \” & gt;
版权所有1996 http://www.zeustech.net/宙斯科技有限公司Adam Twiss
许可给http://www.apache.org/的Apache软件基金会
基准测试127.0.0.1(耐心点)
完成了1000个请求
完成2000项请求
完成了3000个请求
完成了4000个请求
完成了5000个请求
完成了6000个请求
完成了7000个请求
完成了8000个请求
完成了9000个请求
完成了10000个请求
完成了10000个请求
服务器软件:
服务器主机名:127.0.0.1
服务器端口:3005
文档路径:/购买/票证
文件长度:29字节
并发级别:100
测试时间:2.339秒
完成请求:10000
失败的请求:0
总传输量:1370000字节
传输的HTML:290000字节
每秒请求数:4275.96[#/秒](平均值)
每次请求的时间:23.387毫秒(平均值)
每个请求的时间:0.234[毫秒](所有并发请求的平均值)
传输速率:接收到572.08[千字节/秒]
连接时间(毫秒)
最小平均值[+/-sd]中值最大值
连接:0 8 14.7 6 223
加工:2 15 17.6 11 232
等待:1 11 13.5 8 225
总数:7 23 22.8 18 239
在特定时间内处理的请求的百分比(毫秒)
50% 18
66% 24
75% 26
80% 28
90% 33
95% 39
98% 45
99% 54
100% 239(最长的请求)
根据指标,我单机每秒可以处理4000+请求,正常服务器都是多核,处理1W+请求没有问题。
并且查看日志,发现整个服务过程中,请求正常,流量均匀,Redis也正常:
//stat.log
…
结果:1,本地销售:145
结果:1,本地销售:146
结果:1,本地销售:147
结果:1,本地销售:148
结果:1,本地销售:149
结果:1,本地销售:150
结果:0,本地销售额:151
结果:0,本地销售额:152
结果:0,本地销售额:153
结果:0,本地销售额:154
结果:0,本地销售额:156
…
总结和回顾
总的来说,扣球系统非常复杂。这里只简单介绍和模拟一些关于如何优化单机到高性能,如何避免集群单点故障,如何保证订单不超卖,不多卖的策略。
完整的订单系统还可以查看订单进度。每台服务器都有一个任务,定时将总库存中的余票和库存信息同步给用户,用户不会在订单有效期内付款、下单和补货到库存。
我们实现了高并发抢票的核心逻辑,可以说系统设计的非常巧妙,巧妙地避开了DB数据库IO的操作。
对于Redis网络IO的高并发请求,几乎所有的计算都在内存中完成,有效保证了不超售,卖得多,可以容忍部分机器的宕机。
我觉得其中有两点特别值得学习和总结:
①负载均衡,分而治之。
通过负载均衡,不同的流量被分配到不同的机器上,每台机器都很好地处理自己的请求,以最大限度地提高性能。
这样整个系统可以承受极高的并发,就像一个工作团队,每个人都把自己的价值发挥到极致,团队成长自然很大。
②并发和异步的合理使用。
自从Epoll网络架构模型解决了c10k问题以来,异步已经越来越被服务器开发人员所接受。用异步能做的就用异步做,在函数反汇编中能达到意想不到的效果。
这一点可以在Nginx,Node中体现出来。JS和Redis。他们用来处理网络请求的Epoll模型告诉我们,单线程在实践中仍然可以发挥强大的作用。
服务器已经进入多核时代。Go这种为并发而生的语言,完美地利用了多核服务器的优势。很多可以并发处理的任务都可以通过并发来解决。例如,当Go处理HTTP请求时,每个请求都将在一个Goroutine中执行。
总之,如何合理挤压CPU,使其发挥应有的价值,是我们一直需要探索和学习的方向。
原文:https://juejin.im/post/5d84e21f6fb9a06ac8248149
代码地址:https://github.com/GuoZhaoran/spikeSystem
声明:本文经作者授权转载。如需转载,请联系原作者。
【结束】
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。