你们一直吐槽的12306,你知道他们的的架构有多牛X吗
每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!
作者:绘你一世倾城
来源:juejin.im/post/5d84e21f6fb9a06ac8248149
“12306服务”承受着这个世界上任何秒杀系统都无法超越的QPS,上百万的并发再正常不过了!笔者专门研究了一下“12306”的服务端架构,学习到了其系统设计上很多亮点。
在这里和大家分享一下并模拟一个例子:如何在100万人同时抢1万张火车票时,系统提供正常、稳定的服务。
GitGub代码地址:https://github.com/GuoZhaoran/spikeSystem
1.1 负载均衡简介
上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡:
OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称IGP)。OSPF通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF会自动计算路由接口上的Cost值,但也可以通过手工指定该接口的Cost值,手工指定的优先于自动计算的值。
OSPF计算的Cost,同样是和接口带宽成反比,带宽越高,Cost值越小。到达目标相同Cost值的路径,可以执行负载均衡,最多6条链路同时执行负载均衡。
LVS (Linux VirtualServer),它是一种集群(Cluster)技术,采用IP负载均衡技术和基于内容请求分发技术。调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。
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 -n 1000 -c 100 http://www.load_balance.com/buy/ticket
统计日志中的结果,3001-3004端口分别得到了100、200、300、400的请求量,这和我在nginx中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。
https://www.kancloud.cn/digest/understandingnginx/202607
二 秒杀抢购系统选型
回到我们最初提到的问题中来:火车票秒杀系统如何在高并发情况下提供正常、稳定的服务呢?
通常订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段,我们系统要做的事情是要保证火车票订单不超卖、不少卖,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。
这三个阶段的先后顺序改怎么分配才更加合理呢?我们来分析一下:
2.1 下单减库存
当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。
2.2 支付减库存
2.3 预扣库存
三 扣库存的艺术
从上面的分析可知,显然预扣库存的方案最合理。我们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?
这样就避免了对数据库频繁的IO操作,只在内存中做运算,极大的提高了单机抗并发的能力。但是百万的用户请求量单机是无论如何也抗不住的,虽然nginx处理网络请求使用epoll模型,c10k的问题在业界早已得到了解决。
但是linux系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。
上面我们提到了nginx的加权均衡策略,我们不妨假设将100W的用户请求量平均均衡到100台服务器上,这样单机所承受的并发量就小了很多。然后我们每台机器本地库存100张火车票,100台服务器上的总库存还是1万,这样保证了库存订单不超卖,下面是我们描述的集群架构:
要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“buffer库存”用来防止机器中有机器宕机的情况。
我们结合下面架构图具体分析一下:
当机器中有机器宕机时,因为每个机器上有预留的buffer余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。
buffer余票设置多少合适呢,理论上buffer设置的越多,系统容忍宕机的机器数量就越多,但是buffer设置的太大也会对redis造成一定的影响。
四 代码演示
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 本地扣库存和统一扣库存
package localSpike
//本地扣库存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
spike.LocalSalesVolume = spike.LocalSalesVolume + 1
return spike.LocalSalesVolume < spike.LocalInStock
}
统一扣库存操作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
}
hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0
4.3 响应用户信息
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
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
...
五 总结回顾
正文结束
1.不认命,从10年流水线工人,到谷歌上班的程序媛,一位湖南妹子的励志故事
4.“37岁,985毕业,年薪50万,被裁掉只用了10分钟”
5.37岁程序员被裁,120天没找到工作,无奈去小公司,结果懵了...
一个人学习、工作很迷茫?
点击「阅读原文」加入我们的小圈子!