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

简介: “12306” 是如何支撑百万 QPS 的?(二)
  • 4. 代码演示
  • 4.1 初始化工作
  • 4.2 本地扣库存和统一扣库存
  • 4.3 响应用户信息
  • 4.4 单机服务压测
  • 5.总结回顾

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,让其发挥出应有的价值,是我们一直需要探索学习的方向。
相关文章
|
SQL 关系型数据库 Java
Mybatis-Flex框架初体验
Mybatis-Flex框架初体验
ToC和ToB有啥区别
ToC(Consumer)面向普通用户服务,ToB(business)是面向企业用户服务。对公司的营销体系和商业模式而言,定位客户群体,决定产品设计、运营管理、市场营销等系列操作。 1.1 业务形态不同
12984 2
|
存储 小程序 JavaScript
云开发(微信-小程序)笔记(五)----云函数,就这(上)
云开发(微信-小程序)笔记(五)----云函数,就这(上)
994 0
|
9月前
|
人工智能 自然语言处理 数据挖掘
2025国内有哪些呼叫中心系统值得推荐?
在数字化浪潮推动下,呼叫中心系统已成为企业客户服务的核心枢纽。通过全面智能化、多渠道融合、大数据与AI驱动的决策支持及云化与安全性等技术优势,呼叫中心系统实现了降本增效和客户体验提升。2025年,随着人工智能和云计算的深度渗透,呼叫中心将迎来新一轮升级。推荐几款高效系统:合力亿捷、中国移动、华为云、阿里云和百度语音解决方案,涵盖电商、金融、政府等多个领域,助力企业优化服务流程,提升竞争力。
714 13
|
缓存 NoSQL 应用服务中间件
【开发系列】秒杀系统的设计
【开发系列】秒杀系统的设计
|
Ubuntu 机器人 测试技术
奥比中光 Femto Bolt相机ROS配置
这篇文章介绍了奥比中光Femto Bolt相机在ROS1 Noetic和ROS2 Humble环境下的配置过程,包括自动脚本和手动配置方法,适用于Ubuntu 20.04/22.04系统和Jetson Orin平台。
757 0
奥比中光 Femto Bolt相机ROS配置
|
JSON 关系型数据库 MySQL
实时计算 Flink版产品使用问题之对于百亿数据的三张表关联,该如何操作
实时计算Flink版作为一种强大的流处理和批处理统一的计算框架,广泛应用于各种需要实时数据处理和分析的场景。实时计算Flink版通常结合SQL接口、DataStream API、以及与上下游数据源和存储系统的丰富连接器,提供了一套全面的解决方案,以应对各种实时计算需求。其低延迟、高吞吐、容错性强的特点,使其成为众多企业和组织实时数据处理首选的技术平台。以下是实时计算Flink版的一些典型使用合集。
|
前端开发 计算机视觉
|
负载均衡 NoSQL 网络协议
“12306” 是如何支撑百万 QPS 的?(一)
“12306” 是如何支撑百万 QPS 的?(一)
“12306” 是如何支撑百万 QPS 的?(一)