12306 的架构到底有多⽜逼?

“12306 服务”承受着这个世界上任何秒杀系统都⽆法超越的 QPS,上百万的并发再正常不过了!

通过“12306”的服务端架构,其系统设计上很多亮点,在这⾥和⼤家分享⼀下并模拟⼀个例⼦:如何在 100 万⼈同时抢 1 万张⽕⻋票时,系统提供正常、稳定的服务。

⼤型⾼并发系统架构

⾼并发的系统架构都会采⽤分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾⼿段(双⽕机房、节点容错、服务器灾备等)保证系统的⾼可⽤,流量也会根据不同的负载能⼒和配置策略均衡到不同的服务器上。

下边是⼀个简单的⽰意图:

负载均衡简介

上图中描述了⽤⼾请求到服务器经历了三层的负载均衡,下边分别简单介绍⼀下这三种负载均衡。

1. OSPF(开放式最短链路优先)是⼀个内部⽹关协议(议 Interior Gateway Protocol,简, 称 IGP)OSPF 通过路由器之间通告⽹络接⼝的状态来建⽴链路状态数据库,⽣成最短路径树,OSPF 会⾃动计算路由接⼝上的Cost 值,但也可以通过⼿⼯指定该接⼝的 Cost 值,⼿⼯指定的优先于⾃动计算的值。

OSPF 计算的 Cost,同样是和接⼝带宽成反⽐,带宽越⾼,Cost 值越⼩。到达⽬标相同 Cost 值的路径,可以执⾏负载均衡,最多 6 条链路同时执⾏负载均衡。

2. LVS (Linux Virtual Server)

它是⼀种集群(Cluster)技术,采⽤ IP 负载均衡技术和基于内容请求分发技术。

调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执⾏,且调度器⾃动屏蔽掉服务器的故障,从⽽将⼀组服务器构成⼀个⾼性能的、⾼可⽤的虚拟服务器。

3. Nginx

想必⼤家都很熟悉了,是⼀款⾮常⾼性能的 HTTP 代理/反向代理服务器,服务开发中也经常使⽤它来做负载均衡。这⾥有⼀份Nginx不错的⽂章:Nginx 从⼊⻔到实战

Nginx 实现负载均衡的⽅式主要有三种:

轮询

加权轮询

IP Hash 轮询

下⾯我们就针对 Nginx 的加权轮询做专⻔的配置和测试。

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 中配置的权重占⽐很好的吻合在了⼀起,并且负载后的流量⾮常的均匀、随机。

具体的实现⼤家可以参考 Nginx 的 Upsteam 模块实现源码,这⾥推荐⼀篇⽂章《Nginx 中 Upstream 机制的负载均衡》:

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

秒杀抢购系统选型

回到我们最初提到的问题中来:⽕⻋票秒杀系统如何在⾼并发情况下提供正常、稳定的服务呢?

从上⾯的介绍我们知道⽤⼾秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所承受的QPS 也是⾮常⾼的。如何将单机性能优化到极致呢?

要解决这个问题,们就要想明⽩⼀件事:通常订票系统要处理⽣成订单、减扣库存、⽤⼾⽀付这三个基本的阶段。

我们系统要做的事情是要保证⽕⻋票订单不超卖、不少卖,每张售卖的⻋票都必须⽀付才有效,还要保证系统承受极⾼的并发。

这三个阶段的先后顺序该怎么分配才更加合理呢?我们来分析⼀下:

下单减库存

 

当⽤⼾并发请求到达服务端时,⾸先创建订单,然后扣除库存,等待⽤⼾⽀付。

这种顺序是我们⼀般⼈⾸先会想到的解决⽅案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是⼀个原⼦操作。

但是这样也会产⽣⼀些问题:

在极限并发情况下,任何⼀个内存操作的细节都⾄关影响性能,尤其像创建订单这种逻辑,⼀般都需要存储到磁盘数据库的,对数据库的压⼒是可想⽽知的。

如果⽤⼾存在恶意下单的情况,只下单不⽀付这样库存就会变少,会少卖很多订单,虽然服务端可以限制 IP 和⽤⼾的购买订单数量,这也不算是⼀个好⽅法。

⽀付减库存

如果等待⽤⼾⽀付了订单在减库存,第⼀感觉就是不会少卖。但是这是并发架构的⼤忌,因为在极限并发情况下,⽤⼾可能会创建很多订单。

当库存减为零的时候很多⽤⼾发现抢到的订单⽀付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘 IO。

预扣库存

 

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

那么有没有⼀种不需要直接操作数据库 IO 的⽅案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步⽣成⽤⼾订单,这样响应给⽤⼾的速度就会快很多;那么怎么保证不少卖呢?⽤⼾拿到了订单,不⽀付怎么办?

我们都知道现在订单都有有效期,⽐如说⽤⼾五分钟内不⽀付,订单就失效了,订单⼀旦失效,就会加⼊新的库存,这也是现在很多⽹上零售企业保证商品不少卖采⽤的⽅案。

订单的⽣成是异步的,⼀般都会放到 MQ、Kafka 这样的即时消费队列中处理,订单量⽐较少的情况下,⽣成订单⾮常快,⽤⼾⼏乎不⽤排队。

扣库存的艺术

从上⾯的分析可知,显然预扣库存的⽅案最合理。我们进⼀步分析扣库存的细节,这⾥还有很⼤的优化空间,库存存在哪⾥?

怎样保证⾼并发下,正确的扣库存,还能快速的响应⽤⼾请求?

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

为了保证扣库存和⽣成订单的原⼦性,需要采⽤事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多 IO,对数据库的操作⼜是阻塞的。

这种⽅式根本不适合⾼并发的秒杀系统。接下来我们对单机扣库存的⽅案做优化:本地扣库存。

我们把⼀定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。

Tips:欢迎⼤家关注微信公众号:Java后端,来获取更多推送。

改进过之后的单机系统是这样的:

这样就避免了对数据库频繁的 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 值设置多少,需要架构师对系统的负载能⼒做认真的考量。

代码演⽰

Go 语⾔原⽣为并发设计,我采⽤ Go 语⾔给⼤家演⽰⼀下单机抢票的具体流程。

初始化⼯作

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 p 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 r 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 c int i , 1) 
done <- 1
 }

本地扣库存和统⼀扣库

本地扣库存逻辑⾮常简单,⽤⼾请求过来,添加销量,然后对⽐销量是否⼤于本地库存,返回 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

响应⽤⼾信息

我们开启⼀个 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 ⽂件⽅便做压测统计。

单机服务压测

开启服务,我们使⽤ 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

...

总结回顾

总体来说,秒杀系统是⾮常复杂的。我们这⾥只是简单介绍模拟了⼀下单机如何优化到⾼性能,集群如何避免单点故障,保证订单不超卖、不少卖的⼀些策略

完整的订单系统还有订单进度的查看,每台服务器上都有⼀个任务,定时的从总库存同步余票和库存信息展⽰给⽤⼾,还有⽤⼾在订单有效期内不⽀付,释放订单,补充到库存等等。

我们实现了⾼并发抢票的核⼼逻辑,可以说系统设计的⾮常的巧妙,巧妙的避开了对 DB 数据库 IO 的操作。

对 Redis ⽹络 IO 的⾼并发请求,⼏乎所有的计算都是在内存中完成的,⽽且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。

我觉得其中有两点特别值得学习总结:

1. 负载均衡 分⽽治之

通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好⾃⼰的请求,将⾃⼰的性能发挥到极致。

这样系统的整体也就能承受极⾼的并发了,就像⼯作的⼀个团队,每个⼈都将⾃⼰的价值发挥到了极致,团队成⻓⾃然是很⼤的。

2. 合理的使⽤并发和异步

⾃ Epoll ⽹络架构模型解决了 c10k 问题以来,异步越来越被服务端开发⼈员所接受,能够⽤异步来做的⼯作,就⽤异步来做,在功能拆解上能达到意想不到的效果。

这点在 Nginx、Node.JS、Redis 上都能体现,他们处理⽹络请求使⽤的 Epoll 模型,⽤实践告诉了我们单线程依然可以发挥强⼤的威⼒。

 

服务器已经进⼊了多核时代,Go 语⾔这种天⽣为并发⽽⽣的语⾔,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使⽤并发来解决,⽐如 Go 处理 HTTP 请求时每个请求都会在⼀个 Goroutine 中执⾏。

总之,怎样合理的压榨 CPU,让其发挥出应有的价值,是我们⼀直需要探索学习的⽅向。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小坏蛋至尊宝

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值