从一个程序员的角度告诉你:“12306”有多牛逼?

📅 发布时间:2026/7/11 22:52:52 👁️ 浏览次数:
从一个程序员的角度告诉你:“12306”有多牛逼?
每到节假日期间一二线城市返乡、外出游玩的人们几乎都面临着一个问题抢火车票抢票极限并发带来的思考虽然现在大多数情况下都能订到票但是放票瞬间即无票的场景相信大家都深有体会。尤其是春节期间大家不仅使用 12306还会考虑“智行”和其他的抢票软件全国上下几亿人在这段时间都在抢票。“12306 服务”承受着这个世界上任何秒杀系统都无法超越的 QPS上百万的并发再正常不过了笔者专门研究了一下“12306”的服务端架构学习到了其系统设计上很多亮点在这里和大家分享一下并模拟一个例子如何在 100 万人同时抢 1 万张火车票时系统提供正常、稳定的服务。Github代码地址https://github.com/GuoZhaoran/spikeSystem大型高并发系统架构高并发的系统架构都会采用分布式集群部署服务上层有着层层负载均衡并提供各种容灾手段双火机房、节点容错、服务器灾备等保证系统的高可用流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。下边是一个简单的示意图负载均衡简介上图中描述了用户请求到服务器经历了三层的负载均衡下边分别简单介绍一下这三种负载均衡。①OSPF开放式最短链路优先是一个内部网关协议Interior Gateway Protocol简称 IGPOSPF 通过路由器之间通告网络接口的状态来建立链路状态数据库生成最短路径树OSPF 会自动计算路由接口上的 Cost 值但也可以通过手工指定该接口的 Cost 值手工指定的优先于自动计算的值。OSPF 计算的 Cost同样是和接口带宽成反比带宽越高Cost 值越小。到达目标相同 Cost 值的路径可以执行负载均衡最多 6 条链路同时执行负载均衡。②LVS Linux Virtual Server它是一种集群Cluster技术采用 IP 负载均衡技术和基于内容请求分发技术。调度器具有很好的吞吐率将请求均衡地转移到不同的服务器上执行且调度器自动屏蔽掉服务器的故障从而将一组服务器构成一个高性能的、高可用的虚拟服务器。③Nginx想必大家都很熟悉了是一款非常高性能的 HTTP 代理/反向代理服务器服务开发中也经常使用它来做负载均衡。Nginx 实现负载均衡的方式主要有三种轮询加权轮询IP Hash 轮询下面我们就针对 Nginx 的加权轮询做专门的配置和测试。Nginx 加权轮询的演示Nginx 实现负载均衡通过 Upstream 模块实现其中加权轮询的配置是可以给相关的服务加上一个权重值配置的时候可能根据服务器的性能、负载能力设置相应的负载。下面是一个加权轮询负载的配置我将在本地的监听 3001-3004 端口分别配置 1234 的权重#配置负载均衡 upstream load_rule { server 127.0.0.1:3001 weight1; server 127.0.0.1:3002 weight2; server 127.0.0.1:3003 weight3; server 127.0.0.1:3004 weight4; } ... 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对数据库阻塞。这种方式根本不适合高并发秒杀系统。接下来对单机做优化本地扣库存。我们把一定量分配到机器直接内存中减然后按照之前逻辑异步创建订单。改进过之后系统这样就避免频繁操作数据库只内存运算极大提高抗并发能力。但是百万请求量单机无论如何也抗不住虽然Nginx处理网络使用Epoll模型c10k问题业界早已解决。但是Linux下一切资源皆文件网络请求也大量描述符会使操作系统瞬间失去响应。上面提到加权均衡策略不妨假设将100W平均到100台服务器上单机所承受量小很多。然后每台本地100张火车票总还是1万保证订单不超卖下面描述集群架构问题接踵而至高并发现在无法保证高可用假如两三台机器因为扛不住流量或者其他原因宕机那么这些上订单就卖不出去造成少卖。要解决这个问题需要对总量做统一管理这就是接下来容错方案。服务器不仅本地减另外要远程统一减有了远程操作就可以根据负载情况为每台分配一些多余“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 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(chanint, 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 return0 //远端统一扣库存 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 { returnfalse } 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 的高并发请求几乎所有的计算都是在内存中完成的而且有效的保证了不超卖、不少卖还能够容忍部分机器的宕机。我觉得其中有两点特别值得学习总结①负载均衡分而治之通过负载均衡将不同的流量划分到不同的机器上每台机器处理好自己的请求将自己的性能发挥到极致。这样系统的整体也就能承受极高的并发了就像工作的一个团队每个人都将自己的价值发挥到了极致团队成长自然是很大的。②合理的使用并发和异步自 Epoll 网络架构模型解决了 c10k 问题以来异步越来越被服务端开发人员所接受能够用异步来做的工作就用异步来做在功能拆解上能达到意想不到的效果。这点在 Nginx、Node.JS、Redis 上都能体现他们处理网络请求使用的 Epoll 模型用实践告诉了我们单线程依然可以发挥强大的威力。服务器已经进入了多核时代Go 语言这种天生为并发而生的语言完美的发挥了服务器多核优势很多可以并发处理的任务都可以使用并发来解决比如 Go 处理 HTTP 请求时每个请求都会在一个 Goroutine 中执行。总之怎样合理的压榨 CPU让其发挥出应有的价值是我们一直需要探索学习的方向。