过年回家的火车票,你们买到了吗?
我知道你们很多人都没有买到,我能感受到你们内心的绝望。
前几天12306崩了,很多人在痛骂12306,还有很多人在我公众号的后台问我为什么12306总是动不动就崩溃,和大姨妈一样不给力。
明明只是一个简单的卖票软件,怎么搞成这个鬼样子,人家QQ微信几亿人同时在线聊天,激情互动,还有各种小视频。
另一边双十一几亿人同时购物疯狂败家剁手都没有问题,为什么12306一出手,就是炸穿裤衩的用户体验?
让硬核的半佛老师来给你们科普一下。
12306到底面临多大的业务压力和挑战。
你们这么多人一拥而上,他们当然受不了,谁受得了呢。
虽然本篇文章会有大量极为硬核的技术术语,但是我会说的尽量简单,大家一定要认真听,多记笔记,过年在饭桌上吹牛的时候,这都是王炸,不用谢我了。
不是说你看了这篇文章就能买到票,实际上买票是一个玄学。
只是说,能死的明白点。
很多人拿12306和双十一来比较,认为双十一这么多订单都能撑住,12306就撑不住,显然是因为技术水平不到位。
这一开始就走了弯路了朋友,12306的业务模式和双十一是有本质不同的。
这种不同,就导致了12306的难度要比双十一大的多的多的多多多~
如果说双十一的难度是人间模式,那么12306差不多相当于是地狱十八层,还要再挖个坑的难度。
第一,双十一的流量再大,也只不过是纯线上业务,什么叫纯线上业务?所有用户都是在网页或者APP下单,整个数据其实是闭环的。
这就导致双十一其实只是一个纯粹的线上流量问题,解决起来相对纯粹,就像一个单纯的小朋友一样好欺负。
而12306不是,12306不是只有一个APP和网站的朋友,所有人在线下售票厅以及线下机器里产生的交易,也会影响整个12306的数据系统。
实际上现实生活中非常多的买火车票返乡的人,例如辛苦的农民和工人朋友,很多都是不会线上操作的,他们只会线下彻夜排队,非常辛苦,所以12306也必须照顾他们的感受,不能断掉线下业务。
这就导致了12306本身是一个线下与线上同享数据的复杂业务,复杂度要高出双十一一个数量级的。
和纯粹简单的双十一相比,12306就像一个饱经社会摧残的老油条,你永远不知道他们会什么时候会出现什么幺蛾子。
这就像一个纯洁男孩第一次和他的男朋友约会,怕他不来,又怕他乱来。
会有人问,既然挑战这么大,12306这么不容易,那么为什么不去像国外先进技术取经?为什么不去加大投入服务器?为什么还在找借口?
为什么12306不引入国外的先进技术呢?
答案其实很简单,国外也罩不住啊。
早在2012年,12306就有公开招标,预算不设限,只要能解决问题,世界顶级机构都来竞标了,但是最后基本都放弃了。
因为当时的技术环境没有人能解决这个问题。
国外很多技术的确先进,但是没有一个国家或者公司,历史上接受过14亿人的数百亿级别流量的挑战,你能说出来的世界顶级公司,没有一家能承受这么强的即时交易流量。
他们有的流量更大,但就和电商业务一样,是可以异步操作,不需要身份唯一性,没有这么复杂的路线存量计算的,你们也知道国外的高铁和地铁是什么垃圾水平,我们遇到的问题他们从来没有遇到过。
这是很现实的一件事情。
全中国14亿人的出行需求面前,大家都是一样菜。
说到这里,我想到了我们行业里的一个笑话,有个脸书的早期工程师回国加入阿里巴巴,离开前,他说要去拯救阿里巴巴的数据系统,结果回来之后才发现,他在脸书遇到的数据挑战,和阿里巴巴比起来,简直是幼儿园水平。
在数据挑战上,我们遇到的数据挑战绝对是世界最强梯队的,很多时候没有之一。
那么为什么不加服务器呢?技术不够,硬件来凑。
加服务器面临的核心问题有3个。
第一个,加服务器只是增加了储存能力,并不能解决数据库的问题,这就和一个女人生孩子要10个月,不代表你找10个女人就能在1个月内生孩子。
第二个,如何驱动这些服务器?当年阿里云领先世界的技术,就是突破了同时驱动5000台服务器,成为世界三大云之一。
要知道,阿里云面对的只是双十一,而12306的挑战要更加恐怖,需要同时驱动的服务器数量更多,这也是有技术挑战在的。
另外,阿里云也确实参与了12306的建设。
第三个,成本问题。
12306往往全年都表现良好,只有重大节假日才会偶尔出现崩溃,你为了应付一年中为数不多的重大节假日,采购了这么多高折旧率的服务器,平时根本用不上,这是一种浪费钱的行为。
中国铁路本身就是巨额亏损,国家持续补贴的,这种情况下,为了短时间的需求,投入海量的成本,这笔账不用多说吧?
你看看隔壁微博,宁可每次被流量击溃也不肯长时间维系大量服务器,微博看财报每年都是盈利的,金额都是按照亿来结算的,人家都是这个态度,你知道12306有多不容易了吧。
而且这可都是纳税人的钱。
到最后,买票问题的本质,还是供需关系。
全国这么多人,在这么短的时间内要完成这么多的出行,远远超过了铁路本身的运载能力,在这种供小于求的情况下,怎么调配资源,都没有办法解决供需问题。
东西就这么多,大家都想要,能怎么办呢?
加钱,继续扩建?
要知道很多线路只有春节才爆满,平时都空车亏损,为了满足小部分人短时间的出行,大量浪费资金投入到已经富余的路线中,并不划算的。
有这个钱,应该去投入到更多的地方。
12306这种基础设施,天然就是挨骂的,做的好,大家不会夸,做的有一点点不好,会被骂到死,这是基础设施的悲哀,所有人都有不合理的期待。
何况,12306在只花了这么少预算的情况下,做到现在这个程度,已经是超神操作了。
不考虑资金成本和技术成本张口就骂,是一种不太理智的行为。
怎么不去说人家印度火车卖挂票呢?
本文来自微信公众号“仙人JUMP”(ID:xrtiaotiao),作者:半佛仙人
“12306”的服务端架构
作者: 绘你一世倾城来源:https://juejin.im/post/5d84e21f6fb9a06ac8248149
春节期间,大家不仅使用12306,还会考虑“智行”和其他的抢票软件,全国上下几亿人在这段时间都在抢票。“12306服务”承受着这个世界上任何秒杀系统都无法超越的QPS,上百万的并发再正常不过了!笔者专门研究了一下“12306”的服务端架构,学习到了其系统设计上很多亮点,在这里和大家分享一下并模拟一个例子:如何在100万人同时抢1万张火车票时,系统提供正常、稳定的服务。https://github.com/GuoZhaoran/spikeSystem
1. 大型高并发系统架构
高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾手段(双火机房、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。下边是一个简单的示意图:
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;
- }
- }
- 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)
- }
- ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket
2.秒杀抢购系统选型
回到我们最初提到的问题中来:火车票秒杀系统如何在高并发情况下提供正常、稳定的服务呢?
从上面的介绍我们知道用户秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所承受的QPS也是非常高的。如何将单机性能优化到极致呢?要解决这个问题,我们就要想明白一件事:通常订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段,我们系统要做的事情是要保证火车票订单不超卖、不少卖,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。这三个阶段的先后顺序改怎么分配才更加合理呢?我们来分析一下:
2.1 下单减库存
当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。但是这样也会产生一些问题,第一就是在极限并发情况下,任何一个内存操作的细节都至关影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是可想而知的;第二是如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制IP和用户的购买订单数量,这也不算是一个好方法。
2.2 支付减库存
如果等待用户支付了订单在减库存,第一感觉就是不会少卖。但是这是并发架构的大忌,因为在极限并发情况下,用户可能会创建很多订单,当库存减为零的时候很多用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘IO
2.3 预扣库存
从上边两种方案的考虑,我们可以得出结论:只要创建订单,就要频繁操作数据库IO。那么有没有一种不需要直接操作数据库IO的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。订单的生成是异步的,一般都会放到MQ、kafka这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。
3. 扣库存的艺术
从上面的分析可知,显然预扣库存的方案最合理。我们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?
在单机低并发情况下,我们实现扣库存通常是这样的:
为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多IO,对数据库的操作又是阻塞的。这种方式根本不适合高并发的秒杀系统。
接下来我们对单机扣库存的方案做优化:本地扣库存。我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。改进过之后的单机系统是这样的:
这样就避免了对数据库频繁的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值设置多少,需要架构师对系统的负载能力做认真的考量。
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
- }
本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回bool值:
- package localSpike
- //本地扣库存,返回bool值
- func (spike *LocalSpike) LocalDeductionStock() bool{
- spike.LocalSalesVolume = spike.LocalSalesVolume + 1
- return spike.LocalSalesVolume < spike.LocalInStock
- }
- 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
我们开启一个http服务,监听在一个端口上:
- package main
- ...
- func main() {
- http.HandleFunc("/buy/ticket", handleReq)
- http.ListenAndServe(":3005", nil)
- }
- 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)
- }
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)
而且查看日志发现整个服务过程中,请求都很正常,流量均匀,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的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。我觉得其中有两点特别值得学习总结:
- 负载均衡,分而治之。通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致,这样系统的整体也就能承受极高的并发了,就像工作的的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。
- 合理的使用并发和异步。自epoll网络架构模型解决了c10k问题以来,异步越来被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果,这点在nginx、node.js、redis上都能体现,他们处理网络请求使用的epoll模型,用实践告诉了我们单线程依然可以发挥强大的威力。服务器已经进入了多核时代,go语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如go处理http请求时每个请求都会在一个goroutine中执行,总之:怎样合理的压榨CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。
作者:绘你一世倾城
链接:https://juejin.im/post/5d84e21f6fb9a06ac8248149
来源:掘金
虽然有一句“这就像一个纯洁男孩第一次和他的男朋友约会,怕他不来,又怕他乱来。”不知所以然……