每逢节假日,一二线城市返乡或出游的人们总会面临一个难题:抢火车票。尽管多数时候最终能买到票,但“放票瞬间即无票”的场景,想必大家都深有体会。尤其是在春运期间,数亿人同时使用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库存”,用于应对宕机情况。

我们选择使用Redis存储统一库存,因其性能极高。只有当本地扣库存和远程通过Redis统一扣库存都成功时,才向用户返回抢票成功。这有效保证了不超卖。当某台机器宕机,其预留的buffer余票仍可在其他机器上售出,保证了不少卖。Buffer值的大小需要架构师根据系统负载仔细权衡,它决定了系统能容忍的宕机数量,但过大的Buffer会增加对Redis的请求压力。实际上,当本地库存不足时,系统会直接返回“已售罄”,不再请求Redis,这在一定程度上避免了压垮Redis。
4. 代码演示
Go语言原生支持并发,下面用Go演示单机抢票的核心流程。
4.1 初始化工作
Go中的init函数先于main执行,用于初始化:本地库存、远程Redis库存的Hash键值、Redis连接池。此外,初始化一个容量为1的int型channel,用于实现简单的分布式锁(避免资源竞争),这体现了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请求,几乎将所有计算置于内存中完成,同时保证了不超卖、不少卖,并能容忍部分节点故障。其中两点尤其值得借鉴:
- 负载均衡,分而治之:通过负载均衡分散流量,让每台服务器专注处理自身负载,将单机性能发挥到极致,从而提升整个系统的并发承受能力。
- 合理运用并发与异步:自Epoll模型解决C10K问题以来,异步在服务端开发中越发重要。能用异步处理的任务就使用异步,这能在功能解耦上取得意想不到的效果。在多核时代,像Go这类为高并发而生的语言,能更好地发挥硬件优势。如何合理压榨CPU性能,始终是我们需要探索的方向。
本文探讨的高并发系统设计思路,在云栈社区的诸多架构讨论与实战项目中亦有深入体现。