“12306” 是如何支撑百万 QPS 的?

作者阿里云代理 文章分类 分类:linux图文教程 阅读次数 已被围观 721

12306抢票,极限并发带来的思考?

每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!尽管现在大多数状况下都能订到票,可是放票瞬间即无票的场景,信任咱们都深有体会。特别是春节期间,咱们不只运用12306,还会考虑“智行”和其他的抢票软件,全国上下几亿人在这段时间都在抢票。“12306服务”接受着这个世界上任何秒杀体系都无法逾越的QPS,上百万的并发再正常不过了!笔者专门研究了一下“12306”的服务端架构,学习到了其体系规划上许多亮点,在这儿和咱们同享一下并模拟一个比如:如安在100万人一同抢1万张火车票时,体系供给正常、稳定的服务。github代码地址

1. 大型高并发体系架构

高并发的体系架构都会选用分布式集群部署,服务上层有着层层负载均衡,并供给各种容灾手段(双火机房、节点容错、服务器灾备等)确保体系的高可用,流量也会依据不同的负载才能和装备战略均衡到不同的服务器上。下边是一个简略的示意图:

image.png

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中装备的权重占比很好的吻合在了一同,并且负载后的流量十分的均匀、随机。详细的完成咱们能够参阅nginx的upsteam模块完成源码,这儿引荐一篇文章:Nginx 中 upstream 机制的负载均衡

2.秒杀抢购体系选型

回到咱们开始提到的问题中来:火车票秒杀体系如安在高并发状况下供给正常、稳定的服务呢?

从上面的介绍咱们知道用户秒杀流量经过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所接受的QPS也是十分高的。怎么将单机功能优化到极致呢?要处理这个问题,咱们就要想明白一件事:一般订票体系要处理生成订单、减扣库存、用户付出这三个基本的阶段,咱们体系要做的作业是要确保火车票订单不超卖、不少卖 ,每张售卖的车票都必须付出才有用,还要确保体系接受极高的并发。这三个阶段的先后次序改怎样分配才愈加合理呢?咱们来剖析一下:

2.1 下单减库存

当用户并发恳求到达服务端时,首先创立订单,然后扣除库存,等待用户付出。这种次序是咱们一般人首先会想到的处理计划,这种状况下也能确保订单不会超卖,由于创立订单之后就会减库存,这是一个原子操作。可是这样也会发生一些问题,榜首便是在极限并发状况下,任何一个内存操作的细节都至关影响功能,特别像创立订单这种逻辑,一般都需求存储到磁盘数据库的,对数据库的压力是可想而知的;第二是假如用户存在歹意下单的状况,只下单不付出这样库存就会变少,会少卖许多订单,尽管服务端能够约束IP和用户的购买订单数量,这也不算是一个好方法。

image.png

2.2 付出减库存

假如等待用户付出了订单在减库存,榜首感觉便是不会少卖。可是这是并发架构的大忌,由于在极限并发状况下,用户或许会创立许多订单,当库存减为零的时候许多用户发现抢到的订单付出不了了,这也便是所谓的“超卖”。也不能防止并发操作数据库磁盘IO

image.png

2.3 预扣库存

从上边两种计划的考虑,咱们能够得出结论:只需创立订单,就要频繁操作数据库IO。那么有没有一种不需求直接操作数据库IO的计划呢,这便是预扣库存。先扣除了库存,确保不超卖,然后异步生成用户订单,这样呼应给用户的速度就会快许多;那么怎样确保不少卖呢?用户拿到了订单,不付出怎样办?咱们都知道现在订单都有有用期,比如说用户五分钟内不付出,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在许多网上零售企业确保产品不少卖选用的计划。订单的生成是异步的,一般都会放到MQ、kafka这样的即时消费队列中处理,订单量比较少的状况下,生成订单十分快,用户几乎不必排队。

image.gif

3. 扣库存的艺术

从上面的剖析可知,显然预扣库存的计划最合理。咱们进一步剖析扣库存的细节,这儿还有很大的优化空间,库存存在哪里?怎样确保高并发下,正确的扣库存,还能快速的呼应用户恳求?

在单机低并发状况下,咱们完成扣库存一般是这样的:

image.png

为了确保扣库存和生成订单的原子性,需求选用事务处理,然后取库存判别、减库存,最后提交事务,整个流程有许多IO,对数据库的操作又是阻塞的。这种方法底子不适合高并发的秒杀体系。

接下来咱们对单机扣库存的计划做优化:本地扣库存 。咱们把必定的库存量分配到本地机器,直接在内存中减库存,然后依照之前的逻辑异步创立订单。改进过之后的单机体系是这样的:

image.png

这样就防止了对数据库频繁的IO操作,只在内存中做运算,极大的提高了单机抗并发的才能。可是百万的用户恳求量单机是无论怎么也抗不住的,尽管nginx处理网络恳求运用epoll模型,c10k的问题在业界早已得到了处理。可是linux体系下,一切资源皆文件,网络恳求也是这样,大量的文件描绘符会使操作体系瞬间失掉呼应。上面咱们提到了nginx的加权均衡战略,咱们不妨假设将100W的用户恳求量均匀均衡到100台服务器上,这样单机所接受的并发量就小了许多。然后咱们每台机器本地库存100张火车票,100台服务器上的总库存仍是1万,这样确保了库存订单不超卖,下面是咱们描绘的集群架构:

image.png

问题接踵而至,在高并发状况下,现在咱们还无法确保体系的高可用,假如这100台服务器上有两三台机器由于扛不住并发的流量或许其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。要处理这个问题,咱们需求对总订单量做一致的管理,这便是接下来的容错计划。服务器不只需在本地减库存,别的要长途一致减库存 。有了长途一致减库存的操作,咱们就能够依据机器负载状况,为每台机器分配一些剩余的“buffer库存”用来防止机器中有机器宕机的状况。咱们结合下面架构图详细剖析一下:

image.png

咱们选用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 -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 ...

5.总结回忆

全体来说,秒杀体系是十分复杂的。咱们这儿仅仅简略介绍模拟了一下单机怎么优化到高功能,集群怎么防止单点故障,确保订单不超卖、不少卖的一些战略,完好的订单体系还有订单进展的检查,每台服务器上都有一个使命,守时的从总库存同步余票和库存信息展示给用户,还有用户在订单有用期内不付出,开释订单,补充到库存等等。

咱们完成了高并发抢票的核心逻辑,能够说体系规划的十分的奇妙,奇妙的避开了对DB数据库IO的操作,对Redis网络IO的高并发恳求,几乎一切的核算都是在内存中完成的,并且有用的确保了不超卖、不少卖,还能够忍受部分机器的宕机。我觉得其间有两点特别值得学习总结:

  • 负载均衡,分而治之。经过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的恳求,将自己的功能发挥到极致,这样体系的全体也就能接受极高的并发了,就像作业的的一个团队,每个人都将自己的价值发挥到了极致,团队生长自然是很大的。
  • 合理的运用并发和异步。自epoll网络架构模型处理了c10k问题以来,异步越来被服务端开发人员所接受,能够用异步来做的作业,就用异步来做,在功能拆解上能达到意想不到的效果,这点在nginx、node.js、redis上都能表现,他们处理网络恳求运用的epoll模型,用实践告知了咱们单线程仍然能够发挥强壮的威力。服务器现已进入了多核时代,go语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,许多能够并发处理的使命都能够运用并发来处理,比如go处理http恳求时每个恳求都会在一个goroutine中履行,总之:怎样合理的压榨CPU,让其发挥出应有的价值,是咱们一向需求探究学习的方向。


本公司销售:阿里云新/老客户,只要购买阿里云,即可享受折上折优惠!>

我有话说: