找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4367

积分

0

好友

576

主题
发表于 3 小时前 | 查看: 2| 回复: 0

每逢节假日,一二线城市返乡或出游的人们总会面临一个难题:抢火车票。尽管多数时候最终能买到票,但“放票瞬间即无票”的场景,想必大家都深有体会。尤其是在春运期间,数亿人同时使用12306或各种抢票软件,其服务端承受的QPS(每秒查询率)堪称世界之最,每秒百万级的并发流量是常态。

笔者专门研究了12306的服务端架构,其系统设计中的诸多亮点值得借鉴。本文将通过一个模拟场景,探讨如何在100万人同时抢购1万张火车票时,系统仍能提供正常、稳定的服务。

1. 大型高并发系统架构

高并发系统通常采用分布式集群部署,上层设有多层负载均衡,并配备双机房、节点容错等容灾机制以确保高可用性。流量会根据各服务器的负载能力和预设策略进行均衡分发。一个简化的架构示意图如下:

大型高并发系统负载均衡架构图

1.1 负载均衡简介

上图展示了用户请求到达服务器前经历的三层负载均衡:

  • OSPF(开放式最短链路优先):一种内部网关协议(IGP)。路由器间通过通告网络接口状态来建立链路状态数据库,生成最短路径树。OSPF自动计算接口Cost值(也可手工指定,优先级更高),带宽越高,Cost值越小。到达目标的、Cost值相同的多条路径可以进行负载均衡,最多支持6条链路。
  • LVS(Linux Virtual Server):一种集群技术,采用IP负载均衡和基于内容请求分发技术。调度器吞吐率高,能将请求均衡转移至不同服务器,并自动屏蔽故障服务器,从而将一组服务器构成一个高性能、高可用的虚拟服务器。
  • Nginx:一款高性能的HTTP代理/反向代理服务器,在服务开发中常用于负载均衡。其实现方式主要有轮询、加权轮询、IP Hash轮询三种。

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端口的程序(其他端口同理,仅修改端口号):

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中配置的权重完美吻合,且流量分配均匀随机。

2. 秒杀抢购系统选型

回到核心问题:火车票秒杀系统如何在高并发下保持稳定?

通过上述负载均衡,流量被均匀分发到集群中的服务器,但单机QPS仍然极高。如何优化单机性能至极致?关键在于理解订单处理的三个阶段:生成订单、扣减库存、用户支付。系统需保证车票不超卖、不少卖,且每张售出票都需有效支付。这三个阶段的顺序应如何安排?

2.1 下单减库存

用户并发请求到达后,先创建订单,再扣除库存,最后等待支付。这是最直接的方案,能保证不超卖,因为创建订单与减库存是原子操作。但存在两个问题:一是在极限并发下,创建订单等涉及数据库磁盘IO的操作会成为巨大瓶颈;二是若用户恶意下单(只下单不支付),会导致库存被无效占用,影响正常销售。

下单减库存流程图

2.2 支付减库存

先等待用户支付成功后再减库存,直观上避免了少卖。但这是高并发架构的大忌,因为极限情况下,用户可能同时创建大量订单,当库存减为零时,很多已创建的订单将无法支付,从而导致“超卖”,同时也无法避免高并发下的数据库IO压力。

支付减库存流程图

2.3 预扣库存

结合以上两种方案的利弊,结论是:应避免创建订单时直接操作数据库IO。因此,“预扣库存”成为更优解。先扣除库存保证不超卖,然后异步生成用户订单,这样能极大加快对用户的响应速度。如何保证不少卖?通常为订单设置有效期(如5分钟),超时未支付则订单失效,库存回滚。订单生成是异步的,一般放入MQ即时消费队列(如Kafka)中处理。在订单量不大时,生成速度极快,用户体验近乎无缝。

预扣库存异步订单流程图

3. 扣库存的艺术

预扣库存方案最合理,但扣库存的细节仍有巨大优化空间:库存存于何处?如何在高并发下正确、快速地扣库存并响应用户?

在单机低并发场景下,扣库存通常是这样实现的,需要事务来保证原子性,流程涉及大量数据库IO,完全不适合高并发秒杀。

传统单机扣库存流程图

优化方向是本地扣库存。将部分库存分配到服务器本地内存中,直接在内存中完成扣减,再异步创建订单。这样就避免了频繁的数据库IO,极大提升了单机并发处理能力。

本地内存扣库存流程图

然而,单机无法承受百万级请求。通过Nginx加权轮询,将100万请求均摊到100台服务器,单机压力骤减。每台机器本地库存100张,总库存仍为1万,保证了不超卖。

集群本地库存架构图

新问题出现:如何保证高可用?若其中几台服务器宕机,其本地库存对应的票就无法售出,造成少卖。解决方案是引入远程统一扣库存,对总库存进行集中管理,作为容错方案。服务器在本地扣库存的同时,还需请求远程统一扣库存。这样可以根据机器负载,为每台机器分配额外的“buffer库存”,用于应对宕机情况。

带Buffer库存的集群统一扣库存架构图

我们选择使用Redis存储统一库存,因其性能极高。只有当本地扣库存和远程通过Redis统一扣库存都成功时,才向用户返回抢票成功。这有效保证了不超卖。当某台机器宕机,其预留的buffer余票仍可在其他机器上售出,保证了不少卖。Buffer值的大小需要架构师根据系统负载仔细权衡,它决定了系统能容忍的宕机数量,但过大的Buffer会增加对Redis的请求压力。实际上,当本地库存不足时,系统会直接返回“已售罄”,不再请求Redis,这在一定程度上避免了压垮Redis。

4. 代码演示

Go语言原生支持并发,下面用Go演示单机抢票的核心流程。

4.1 初始化工作

Go中的init函数先于main执行,用于初始化:本地库存、远程Redis库存的Hash键值、Redis连接池。此外,初始化一个容量为1的intchannel,用于实现简单的分布式锁(避免资源竞争),这体现了Go的哲学:不要通过共享内存来通信,而要通过通信来共享内存。这里使用redigo作为Redis客户端库。

...
//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
}

注意:对LocalSalesVolume的操作用锁保护。因为本地与远程扣库存需保持原子性,我们在最上层用channel控制。

统一扣库存操作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
}

服务启动前,需初始化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的压测结果摘要,单机每秒可处理超过4000个请求。在实际多核服务器上,处理上万请求毫无压力。日志显示请求流量均匀,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. 总结回顾

完整的秒杀系统极其复杂。本文仅模拟了从单机优化到高性能集群,以及保证订单不超卖、不少卖的核心策略。真实系统还包括订单状态查询、定时同步库存、订单超时释放库存等。

我们实现的高并发抢票核心逻辑巧妙地避开了对数据库的直接IO操作,并减少了对Redis的网络IO请求,几乎将所有计算置于内存中完成,同时保证了不超卖、不少卖,并能容忍部分节点故障。其中两点尤其值得借鉴:

  1. 负载均衡,分而治之:通过负载均衡分散流量,让每台服务器专注处理自身负载,将单机性能发挥到极致,从而提升整个系统的并发承受能力。
  2. 合理运用并发与异步:自Epoll模型解决C10K问题以来,异步在服务端开发中越发重要。能用异步处理的任务就使用异步,这能在功能解耦上取得意想不到的效果。在多核时代,像Go这类为高并发而生的语言,能更好地发挥硬件优势。如何合理压榨CPU性能,始终是我们需要探索的方向。

本文探讨的高并发系统设计思路,在云栈社区的诸多架构讨论与实战项目中亦有深入体现。




上一篇:AI Agent桌面智能体选型指南:OpenClaw、AutoGLM与QoderWork技术对比
下一篇:MySQL 高级实战:11个提升性能与效率的SQL进阶技巧
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-18 10:48 , Processed in 0.588439 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表