秒杀

本文介绍一种全新的秒杀架构,完全解决了秒杀架构中的核心痛点。该架构能够支持每秒超万单精准扣库存,并且保证创建订单和扣减库存的数据最终严格一致。

现有秒杀系统的问题

现有的秒杀架构,为了支持高并发,通常把库存放在Redis中,收到订单请求时,在Redis中进行库存扣减。这种的设计,导致创建订单和库存扣减不是原子操作,如果两个操作中间,遇到进程crash等问题,就会导致数据不一致。

即使库存扣减不放在Redis中,而是放在数据库,不一致问题也通常是存在的。业务系统为了模块化,减少耦合,会将库存服务与订单服务分开。只要是分开的服务,那么数据不一致的情况就是无法避免的。

进程crash等问题,虽然发生的概率不高,但即使占比百分之一,甚至千分之一,都会产生数据不一致,例如扣减的库存量和创建成功的订单不一致。

库存与订单数据不一致是必须解决的难题,常见做法是,开发人员通过订单数据,去校准库存数据,这部分的工作非常繁琐复杂,耗费大量的开发工作,而且很多时候需要人工介入,对数据进行人工校验和修复。

下面我们来看看新架构如何优雅解决这个问题

整体架构

我们明确业务场景,我们把秒杀系统的核心要点提取出来,为以下几点:

  • 用户进行秒杀,会在某个时间点发送大量的请求到后端,请求量会大大高于库存数量
  • 后端需要保证库存扣减和订单创建是最终严格一致的,即使中间发生进程crash,最终数据不会受到影响

上述的场景下,绝大部分扣减库的描述请求,都会失败,时序图如下:

flash-sales-fail

在这个架构中,使用了分布式事务框架dtm。上述的时序图中,扣减库存是在Redis中进行的,与dtm相关的注册全局事务和取消全局事务也是在Redis中处理的,全程依赖Redis,与数据库无关,因此能够支持极高的并发,从后面的测试数据中可以看到,该架构可以轻易处理每秒上万单的秒杀请求。

虽然大部分请求因为扣减库存失败而结束,但是会有一定数量的请求,扣减库存成功,这种情况的时序图如下:

flash-sales

在这个时序图中,扣减库存成功后,会进入到订单服务,进行订单相关的创建,以及后续的支付。在这个新架构中,订单服务仅需要处理有效订单,此时并发量已经大幅下降,只需要通过常规的方法,例如订单分库分表、消息队列削峰处理,就可以轻松解决问题了。

原子操作

在上述的架构中,如果在Redis中扣减库存后,在提交全局事务前,发生进程crash,就会导致两个操作没有同时完成,那么这种情况后续会怎么样?新架构如何保证数据最终严格一致?这种情况的整个的时序图如下:

flash-sales-down

一旦发生这类进程crash,导致两个操作过程中断,那么dtm服务器会轮询超时未完成的事务,如果出现已Prepare、未Submit的全局任务,那么他会调用反查接口,询问应用,库存扣减是否成功扣减。如果已扣减,则将全局事务提交,并进行后续的调用;如果未扣减,则将全局事务标记为失败,不再处理。

保证原子操作的原理,以及发生各种情况dtm的处理策略,可以参考二阶段消息,这里不做详细的描述。

核心代码

秒杀接口的核心代码如下:

  1. gid := "{a}flash-sale-" + activityID + "-" + uid
  2. msg := dtmcli.NewMsg(DtmServer, gid).
  3. Add(busi.Busi+"/createOrder", gin.H{activity_id: activityID, UID: uid})
  4. err := msg.DoAndSubmit(busi.Busi+"/QueryPreparedRedis", func(bb *BranchBarrier) error {
  5. return bb.RedisCheckAdjustAmount(rds, "{a}stock-"+stockID, -1, 86400)
  6. })
  • 行1: 一般的秒杀活动,一个用户仅能购买一次,因此按照活动id+用户id作为全局事务ID,能够保证用户最多生成一个全局事务,最多创建一个订单
  • 行2: 创建一个二阶段消息的对象,填入dtm服务器地址,以及全局事务id
  • 行3: 给二阶段消息添加一个分支事务,该事务分支为创建订单服务
  • 行4: 调用二阶段消息的DoAndSubmit,该函数第一个参数为反查的URL(见上图中的反查);第二个参数为一个回调函数,里面会包含业务逻辑。该函数会执行业务,并在成功后提交全局事务,保证执行业务和全局事务的提交是“原子的”
  • 行5: 调用RedisCheckAdjustAmount,该函数会进行库存扣减,这个函数进行库存扣减时,如果库存不够,则会返回错误;如果库存足够,则会扣减库存,并记录库存已扣减成功,这样可以保证这个操作幂等,并且保证后续的反查能够获得正确的结果

反查的核心代码如下:

  1. app.GET(BusiAPI+"/QueryPreparedRedis", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
  2. return MustBarrierFromGin(c).RedisQueryPrepared(rds)
  3. }))

开发人员编写反查的逻辑很简单,对于Redis里面的数据,只需要复制粘贴这上面的代码就行。反查的详细原理参考二阶段消息,二阶段消息的文档里介绍的是数据库中如何做,而这里则是用Redis来完成类似的反查逻辑,就不详细说明了。

性能

从上面的介绍中,可以看到,对于大部分扣减库存失败的请求,只需要进行三个Redis操作,1. 注册全局事务;2. 扣减库存;3. 修改全局事务为已失败。这个三个操作都是lua脚本实现。一个普通的redis,每秒大约能够支持6w个lua脚本操作,照此分析,我们的新架构,理论上每秒能够支持2w个秒杀请求。我做的性能测试报告显示,当dtm与扣库存共享一个redis时,每秒可以轻松完成1.2w个秒杀订单,达到理论极限值的60%,详情可以参考后面的性能测试报告

更进一步分析,扣减库存与全局事务可以使用不同的Redis,那么

  • 扣减库存:若由单独一个Redis来支持,那么扣库存的理论上限值为6w/s,预估的实际值为6*0.6=3.6w/s,如果更进一步,采用 Redis6 的多线程IO,可以获得更高的性能,大约达到6 * 2.5 * 0.6=9w/s。
  • 全局事务操作:而这里面的dtm只需要部署多组,或者未来使用集群版,就可以提供远超9w/s的支持。
  • 所以采用新架构的情况下,预计可以达到9w/s的秒杀请求流量

上述的分析还仅仅限于普通云厂商虚拟机上的自己安装Redis,假如通过简单的硬件升级,或者使用云厂商提供的Redis,那么Redis能提供更强劲的性能,上述的9w/s还能够再提高一个台阶。

参考一下阿里双十一的峰值订单:58.3万笔/秒,那么上述预估的9w/s,几乎足以应对所有的秒杀活动

代码示例

完整的可运行的代码示例,可以参考dtm-cases/flash

秒杀性能测试详情

测试的环境,两台阿里云主机,类型为:ecs.hfc5.3xlarge 12核 CPU 3.1GHz/3.4GHz PPS 130万

  • 一台机器运行Redis
  • 另一台机器运行测试程序

测试过程:

准备Redis

选择虚拟机 A 安装 Redis

  1. apt-get install -y redis
  2. # 修改 /etc/redis/redis.conf
  3. # bind 127.0.0.1 => 0.0.0.0
  4. systemctl redis restart

准备dtm

选择虚拟机 B 安装 dtm

  1. apt update
  2. apt install -y git
  3. wget https://golang.org/dl/go1.17.1.linux-amd64.tar.gz
  4. rm -rf /usr/local/go && tar -C /usr/local -xzf go1.17.1.linux-amd64.tar.gz && cp -f /usr/local/go/bin/go /usr/local/bin/go
  5. git clone https://github.com/dtm-labs/dtm.git && cd dtm && git checkout v1.11.0 && cd bench && make
  6. # 修改 dtm/bench/test-flash-sales.sh
  7. # export BUSI_REDIS=localhost:6379 => 虚拟机A 的私网ip

运行测试

  1. sh test-flash-sales.sh

获取结果

我的结果显示,每秒大约能够完成1.2w个秒杀请求:

  1. Requests per second: 11970.21 [#/sec] (mean)

小结

我们提出了一个全新的秒杀架构,可以保证创建订单和扣减库存的原子性,并且预估可以快速支撑9w/s的秒杀请求流量。帮助大家更好更快的解决秒杀的业务需求。