跳至主要內容

Redis-应用篇-⑤秒杀场景

holic-x...大约 43 分钟RedisRedis

Redis-应用篇-⑤秒杀场景

学习核心

  • 秒杀场景介绍

学习资料

秒杀场景核心

1.秒杀场景介绍

秒杀场景核心特性

秒杀通常指因为某种活动瞬时产生巨大流量的场景,例如双11、618等电商促销活动。秒杀场景的业务特点是限时限量,其系统特征主要有两个:

  • 特征1:瞬时并发访问量非常高

    • 一般数据库每秒只能支撑千级别的并发请求,而 Redis 的并发处理能力(每秒处理请求数)能达到万级别,甚至更高。所以,当有大量并发请求涌入秒杀系统时,可使用 Redis 先拦截大部分请求,避免大量请求直接发送给数据库,把数据库压垮
  • 特征2:读多写少

    • 秒杀活动中只有少部分用户能成功下单,所以,商品库存查询操作(读操作)要远多于库存扣减和下单操作(写操作)

    • 在秒杀场景下,用户需要先查验商品是否还有库存(也就是根据商品 ID 查询该商品的库存还有多少),只有库存有余量时,秒杀系统才能进行库存扣减和下单操作。

      库存查验操作是典型的键值对查询,而 Redis 对键值对查询的高效支持,正好和这个操作的要求相匹配

秒杀场景常见问题

针对秒杀活动需要注意几个问题:

  • 服务需能抗住海量请求:秒杀活动一开始,瞬间会有海量流量涌入,热门的商品甚至会有几百万人来抢。大规模的流量砸下来,服务如果扛不住挂了就会导致用户体验极差
  • 不能超卖:大部分秒杀场景本身就是赔本赚吆喝,如果因为超卖导致超预算亏损,会导致严重的业务问题
  • 避免少卖:针对少卖场景,虽然不必超卖严重,但也需尽量避免这种情况
  • 保证触达用户而非黄牛:黄牛可能是开脚本,一次发很多请求过来,抢到之后再转卖。但做活动,希望的就是回馈客户、吸引用户,而不是去让黄牛赚外快。黄牛操作不仅仅是侵害了正常用户的权益,且由于黄牛善于使用脚本,很容易造成大量的恶意请求,让本就不富裕的服务器资源,雪上加霜。

2.秒杀场景问题处理

高并发(抗住海量请求)

​ 秒杀活动的主要思路是:削峰、限流、异步、补偿

异步

​ 异步这一步可以通过消息队列来实现,将抢和购进行解耦,还可以很方便地限频,不至于让MySQL过度承压。抢的话使用Redis来做处理,因为Redis处理简单的扣减清求是非常快的,而直接到MySQL是比较力不从心。Redis可是单机支撑每秒几万的写入,并且可以做成集群,提高扩展能力的。

​ 可以先将库存名额预加载到Redis,然后在Redis中进行扣减,扣减成功的再通过消息队列,传递到MySQL做真正的订单生成。

​ 但此处需注意一个问题,如果Redis中扣减成功,但是消息未同步到消息队列时Redis宕机,则需采取相应的方案进行补偿

​ 而对于Redis,如果请求量超过6W每秒,就要考虑使用多个Redis来分流。预计有100W请求量,可以临时调度20个Redis实例来支持,一个5W/s,留点Buffer。可以直接接个Nginx负载均衡,这种模式倒是不需要使用Redis Cluster的做法

拒绝超卖

​ 抢购场景的核心有两个步骤:

  • 步骤1:判断库存名额是否充足
  • 步骤2:减少库存名额,扣减成功则认为抢占成功

​ 此处需注意一个问题,抢购的操作是并发操作,可能出现在步骤1的时候还有库存,但实际调用的时候库存已经不足了,就会导致超卖现象

​ 但是Redis本身是没有提供这种场景原子化的操作,但可以借助Lua脚本来支持Redis的原子性,在Lua脚本中调用Redis的多个命令,这些命令整体上都会作为原子操作来执行。基于这套机制,可以分析以下访问Redis扣减库存时各种异常情况和处理:

  • 正常业务错误,比如库存用完,这种情况符合预期,直接返回给用户即可
  • 访问Redis错误,这种情况返回给用户,让其重试即可
  • 访问Redis超时,这种情况下,其实可能库存已经扣减成功,此时不用再重试,免得产生更多的无效扣减,虽然多了一次扣减,但是总数是不变的,只会少卖不会多卖。

避免少卖

​ 少卖的情况是:库存减少了,但是用户订单没有生成。少卖场景产生的原因在于:

  • 减少库存操作超时,但实际是成功的,因为超时并不会进入订单流程
  • 在Redis操作成功,但是向Kafka发送消息失败,白白消耗Redis中的库存

​ 针对上述场景,只需要保证Redis库存+Kafka消费的最终一致性。参考方案分析如下:

  • 方案1:最简单的方式,在投递Kafka失败的情况下,增加渐进式重试
  • 方案2:更安全一点,在第一种的基础上,将这条消息记录在磁盘上,"慢慢重试"
  • 方案3:写磁盘之前就可能失败,可以考虑走WAL路线,但是这样做下去说不定就做成MySQL的undolog、redolog这种WAL技术了,会相当复杂,没有必要

​ 针对少卖场景,一般可以考虑选择方案2,少卖场景是异常情况的小概率时间,如果真的除了问题可以考虑人工介入的方式。

触达用户

方案1:限购

​ 通常来说,为了打击黄牛,最常见的方式是限购,一个用户最多只能抢到N份,这样可以大大保障正常用户的权益。

​ 为了性能,可以将限制逻辑加入到Redis中,原始Lua脚本的实现步骤是:查库存、扣减库存,优化后的Lua脚本中可设计如下步骤:

  • 步骤1:查询库存
  • 步骤2:查询用户已购买个数
  • 步骤3:扣减库存
  • 步骤4:记录用户购买数

​ 且如果使用Redis集群,则Redis的数据分片需要根据用户来分key,便于查询用户相关数据(例如借助Hash Tag)

方案2:异常IP限制 + 人机校验,尽量保证公平竞争

​ 有了限购,可以保证货品不会被黄牛占据太多,但黄牛大多是通过代码来抢购,点击速度比人点击快得多,这样就导致了竞争不公平。作为追求极致的coder,如果希望还能更进一步,做到竞争公平,可以参考下述思路:

​ 某个用户请求接口次数过于频繁,一般说明是用脚本在跑,可以只针对该用户做限制。针对IP做限制也是常见做的做法,但这样容易误杀,主要考虑到使用同一个网络的用户,可能都是一个出口IP。限制IP,会导致正常用户也受到影响。更好用的方案是加上一个验证码验证。验证码符合91原则,90%的时间,都用在验证码输入上,所以使用脚本点击的影响会降到很低。但这种方式的缺点在于降低了用户的体验感。

3.Redis 在秒杀场景中的作用

​ 一般情况下可以将秒杀活动分为三个阶段,分别为秒杀活动前、秒杀活动开始、秒杀活动后,每个阶段的请求处理需求并不相同,Redis 所发挥的作用也不一样

阶段1:秒杀活动前

​ 在这个阶段,用户会不断刷新商品详情页,这会导致详情页的瞬时请求量剧增

​ 这个阶段的应对方案,一般是尽量把商品详情页的页面元素静态化,然后使用 CDN 或是浏览器把这些静态化的元素缓存起来。这样一来,秒杀前的大量请求可以直接由 CDN 或是浏览器缓存服务,不会到达服务器端了,以此减轻了服务器端的压力

​ 在这个阶段,有 CDN 和浏览器缓存服务请求就足够了,还不需要使用 Redis

阶段2:秒杀活动开始

​ 此时,大量用户点击商品详情页上的秒杀按钮,会产生大量的并发请求查询库存。一旦某个请求查询到有库存,紧接着系统就会进行库存扣减。然后,系统会生成实际订单,并进行后续处理,例如订单支付和物流服务。如果请求查不到库存,就会返回。用户通常会继续点击秒杀按钮,继续查询库存。

​ 简单来说,这个阶段的操作就是三个:库存查验、库存扣减和订单处理。因为每个秒杀请求都会查询库存,而请求只有查到有库存余量后,后续的库存扣减和订单处理才会被执行。所以,这个阶段中最大的并发压力都在库存查验操作上。

​ 为了支撑大量高并发的库存查验请求,需要在这个环节使用 Redis 保存库存量,请求可以直接从 Redis 中读取库存并进行查验。

库存扣减和订单处理是否都可以交给后端的数据库来执行呢?

​ 针对订单处理,可以在数据库中执行,但库存扣减操作,不能交给后端数据库处理。

​ 在数据库中处理订单的原因:订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。而且,订单处理时的请求压力已经不大了,数据库可以支撑这些订单处理请求。

​ 库存扣减操作不能在数据库执行的原因:一旦请求查到有库存,就意味着发送该请求的用户获得了商品的购买资格,用户就会下单了。同时,商品的库存余量也需要减少一个。如果把库存扣减的操作放到数据库执行,会带来两个问题。

  • 额外的开销。Redis 中保存了库存量,而库存量的最新值又是数据库在维护,所以数据库更新后,还需要和 Redis 进行同步,这个过程增加了额外的操作逻辑,也带来了额外的开销
  • 下单量超过实际库存量,出现超售。由于数据库的处理速度较慢,不能及时更新库存余量,这就会导致大量库存查验的请求读取到旧的库存值,并进行下单。此时,就会出现下单数量大于实际的库存量,导致出现超售,这就不符合业务层的要求了。

​ 所以,需要直接在 Redis 中进行库存扣减。具体的操作是,当库存查验完成后,一旦库存有余量,就立即在 Redis 中扣减库存。而且,为了避免请求查询到旧的库存值,库存查验和库存扣减这两个操作需要保证原子性。

阶段3:秒杀活动后

​ 在这个阶段,可能还会有部分用户刷新商品详情页,尝试等待有其他用户退单。而已经成功下单的用户会刷新订单详情,跟踪订单的进展。不过,这个阶段中的用户请求量已经下降很多了,服务器端一般都能支撑。

Redis对秒杀场景的支持

​ 结合上述场景分析,将秒杀场景分成秒杀前、秒杀中和秒杀后三个阶段:

  • 秒杀开始前后,高并发压力没有那么大,不需要使用 Redis
  • 在秒杀进行中,需要查验和扣减商品库存,库存查验面临大量的高并发请求,而库存扣减又需要和库存查验一起执行,以保证原子性
  • 秒杀活动后,这个阶段的用户请求已经下降很多,服务器端一般都能支撑
image-20240728210425341

​ 而秒杀场景对 Redis 操作的根本要求有两个:

​ **支持高并发。**这个很简单,Redis 本身高速处理请求的特性就可以支持高并发。而且,如果有多个秒杀商品,我们也可以使用切片集群,用不同的实例保存不同商品的库存,这样就避免,使用单个实例导致所有的秒杀请求都集中在一个实例上的问题了。不过,需要注意的是,当使用切片集群时,我们要先用 CRC 算法计算不同秒杀商品 key 对应的 Slot,然后,我们在分配 Slot 和实例对应关系时,才能把不同秒杀商品对应的 Slot 分配到不同实例上保存。

保证库存查验和库存扣减原子性执行。针对这条要求,我们就可以使用 Redis 的原子操作或是分布式锁这两个功能特性来支撑了。

(1)基于原子操作来支撑秒杀场景

​ 在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量。这种数据模型正好是一个 key(商品 ID)对应了两个属性(总库存量和已秒杀量),可以使用一个 Hash 类型的键值对来保存库存的这两个信息,如下所示:

# itemID 是商品的编号,total 是总库存量,ordered 是已秒杀量
key: itemID
value: {total: N, ordered: M}

​ 因为库存查验和库存扣减这两个操作要保证一起执行,选择使用 Lua 脚本原子性地执行这两个操作。参考 Lua 脚本伪代码实现这两个操作

#获取商品库存信息        
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])  
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存     
if ordered + k <= total then
    #更新已秒杀的库存量
    redis.call("HINCRBY",KEYS[1],"ordered",k)                              return k;  
end           
return 0

​ 基于上述Lua脚本,在 Redis 客户端使用 EVAL 命令来执行这个脚本。客户端会根据脚本的返回值,来确定秒杀是成功还是失败了。如果返回值是 k,就是成功了;如果是 0,就是失败。

​ 此处基于Lua 脚本来实现库存查验和库存扣减。此外,要想保证库存查验和扣减这两个操作的原子性,可以使用分布式锁来保证多个客户端能互斥执行这两个操作

(2)基于分布式锁来支撑秒杀场景

使用分布式锁来支撑秒杀场景:先让客户端向 Redis 申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。这样一来,大量的秒杀请求就会在争夺分布式锁时被过滤掉。而且,库存查验和扣减也不用使用原子操作了,因为多个并发客户端只有一个客户端能够拿到锁,已经保证了客户端并发访问的互斥性。

​ 参考下述伪代码,它显式使用分布式锁来执行库存查验和扣减的过程。

//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
   //库存查验和扣减
   availStock = DECR(key, k)
   //库存已经扣减完了,释放锁,返回秒杀失败
   if (availStock < 0) {
      releaseLock(key, val)
      return error
   }
   //库存扣减成功,释放锁
   else{
     releaseLock(key, val)
     //订单处理
   }
}
//没有拿到锁,直接返回
else
   return

​ 在使用分布式锁时,客户端需要先向 Redis 请求锁,只有请求到了锁,才能进行库存查验等操作,这样一来,客户端在争抢分布式锁时,大部分秒杀请求本身就会因为抢不到锁而被拦截。因此可以使用切片集群中的不同实例来分别保存分布式锁和商品库存信息。使用这种保存方式后,秒杀请求会首先访问保存分布式锁的实例。如果客户端没有拿到锁,这些客户端就不会查询商品库存,这就可以减轻保存库存信息的实例的压力。

秒杀场景总结向

​ 秒杀场景有 2 个负载特征,分别是瞬时高并发请求和读多写少。Redis 良好的高并发处理能力,以及高效的键值对读写特性,正好可以满足秒杀场景的需求。

​ 在秒杀场景中,可以通过前端 CDN 和浏览器缓存拦截大量秒杀前的请求。在实际秒杀活动进行时,库存查验和库存扣减是承受巨大并发请求压力的两个操作,同时,这两个操作的执行需要保证原子性。Redis 的原子操作、分布式锁这两个功能特性可以有效地来支撑秒杀场景的需求。

​ 对于秒杀场景来说,只用 Redis 是不够的。秒杀系统是一个系统性工程,Redis 实现了对库存查验和扣减这个环节的支撑,除此之外,还有 4 个环节需要处理好

  • 前端静态页面的设计:秒杀页面上能静态化处理的页面元素,我们都要尽量静态化,这样可以充分利用 CDN 或浏览器缓存服务秒杀开始前的请求
  • 请求拦截和流控:在秒杀系统的接入层,对恶意请求进行拦截,避免对系统的恶意攻击,例如使用黑名单禁止恶意 IP 进行访问。如果 Redis 实例的访问压力过大,为了避免实例崩溃,也需要在接入层进行限流,控制进入秒杀系统的请求数量
  • 库存信息过期时间处理:Redis 中保存的库存信息其实是数据库的缓存,为了避免缓存击穿问题,不要给库存信息设置过期时间
  • 数据库订单异常处理:如果数据库没能成功处理订单,可以增加订单重试功能,保证订单最终能被成功处理。

​ 秒杀活动带来的请求流量巨大,需要把秒杀商品的库存信息用单独的实例保存,而不要和日常业务系统的数据保存在同一个实例上,这样可以避免干扰业务系统的正常运行。

秒杀场景实战

秒杀专栏

  • 沟通对齐
  • 架构设计
  • 要点分析
  • 陈述总结
  • 实战案例

1.沟通对齐

需求对齐

需求分析

​ 首先要了解秒杀活动的整体诉求:在什么时间、秒杀什么商品、多少台、限制条件等。例如2023.06.18.0点天猫秒杀100台苹果电脑(原价23000、秒杀价19000,每人限购一台)

秒杀流程设计

image-20240729085445324

请求量对齐

​ 针对不同的请求量其方案设计是完全不一样的。在设计前需要达到共识,请求量要确认两个,

  • 整体的流量有多大(预计业务到来时候的压力)

  • 后端服务要能支撑多大的请求量(后端要正常服务多少请求量)

    针对上述请求量分析,设定如果超过的就限流掉。下述针对请求量的分析都是指后端服务要能支撑多大的请求量

(1)5K以内

​ 如果预计是5000以内的请求量,可以直接单机MySQL抗(上线前需要实际测试)

​ 如果项目本身并没有那么大的请求量,但是在项目中却使用到了Redis,可以解释说团队中的其他服务也使用到了Redis,有现成的Redis架构可接入,接入和维护成本极低。

​ 此处的**“单机”概念主要指的是单个节点提供服务**,而此处的从节点是作为备份容灾。

(2)1W以内

​ 如果预计是1W以内的请求量,可以考虑引入2台MySQL

​ 此处的分流器规则限定可以结合业务场景设计,例如最简单的就是根据用户ID进行Hash取模分流。

(3)10W以内

​ 基于1W以内的方案考虑,如果分流均衡,那么此处每个节点可以服务记录5K库存。假设一个MySQL节点抗5000,那么理想情况下10W可以用20个MySQL?基于成本考虑,如果是扩展几个MySQL的话咬咬牙成本还是可以接受的,但是如果数量太多则需要考虑相应的成本优化问题。

​ 因此此处考虑引入Redis来做库存处理,一台Redis的性能可以顶10台MySQL。

MySQL VS Redis 成本比较

​ 可以参考腾讯云的机器价格比较,如果是购买云服务MySQL4核8G一个月大概要1000元左右。

​ 一个4核8G实际能支撑的请求量一般在5000以内(虽然腾讯云测试写到了2w,但不排除厂商测试都是拿好的硬件+最简单场景来堆服务性能的)

​ 假设10w每秒,全用MySQL堆,就需要20台机器,一个月开销19200

​ 假设100w每秒,全用MySQL堆,就需要200台机器,一个月开销192000

​ 如果使用Redis,8GB内存机器(默认2核),608元。

​ 假设10w每秒,全用Redis抗,就需要1台机器,一个月开销608

​ 假设100w每秒,全用Redis堆,就需要10台机器,一个月开销6080,相比之下MySQL的成本是Redis的31.5倍

(4)10W以上

​ 如果是10W以上的请求量,可以引入Redis集群,将库存分散到Redis不同分片,不同的用户走不同的分片,将流量分散、分开抢占名额

​ 基于Redis集群方案,这种方案设计涉及到对库存进行细分、子库存挪动等问题,非常复杂、且边界问题比较多,容易出现少卖或者超卖的问题,如果确保这种方案的可靠性?=》这种方案确实过于复杂且容易出问题,但是针对10W以上的请求量来说,确实需要通过多节点Redis去抗。如果不希望引入多节点Redis方式,可以考虑通过消息队列在面前进行削峰

精准度对齐

(1)是否允许小概率超卖

什么情况下会出现超卖?

​ 超卖:可以简单理解为查询的时候库存管够,但是真正扣减库存的时候却发现不够了。常见的超卖场景有:

  • 场景1:Redis重启导致已执行命令丢失,进而导致库存变多
    • 如果在高并发下用Redis来管理库存,如果发生Redis重启则可能丢失掉已经执行的命令,库存变多
  • 场景2:主从切换导致部分命令丢失,进而导致库存变多
    • 如果在高并发下用Redis来管理库存,Redis如果是主从模式,假设发生切换,也可能丢失一部分命令,库存变多
  • 场景3:高并发场景下查询库存和扣减库存是非原子性操作,导致超卖
    • 如果查询库存和扣减库存不是原子性的,在高并发下可能超卖

是否允许小概率超卖?

  • 如果不允许,那么需要一致性更强的MySQL来保护:

    • Redis挂了再恢复可能会丢数据,主从切换也可能会丢数据

    • MySQL拥有ACID特性,挂了重启也不会丢数据,主从切换可能丢,但如果配置强同步设置则挂了也不会超卖,总的来说都可靠很多

  • 如果允许小概率超卖,库存走Redis即可,毕竟发生崩溃刚好丢数据属于小概率事件

    • 设定Redis抢到就是成功,根据抢占结果创建支付单,业务实现简单化,Redis名额和MySQL库存的一致性问题就不存在了
(2)是否允许小概率少卖

什么情况下会出现少卖?

  • 库存扣减成功,但是订单生成失败
  • 订单生成成功,但是用户付款失败,相应回退逻辑可能失败

是否允许小概率少卖?

  • 如果不允许小概率少卖,则需要构建相应的补偿机制(例如通过定时任务监控库存、订单的数值)
  • 如果允许小概率少卖,则不需要用定时任务去检测两者(库存和订单)的数值

难点分析

​ 秒杀活动的难点可以总结为三个方向的内容:高并发(抗住海量请求)、高精准(超卖、少卖)、黄牛打击(触达用户),和前面秒杀场景概念核心介绍的方向类似,此处继续扩展相应的应对方案:

  • 高并发:针对高并发场景,设计可靠的库存逻辑,例如减少流量、异步化削峰等方向
  • 高精准:针对超卖、少卖问题采取可靠有效的方案
  • 打击黄牛:采用限购、限IP、验证码等方式限制黄牛流,确保真正触达客户
(1)高并发

概念核心

​ 问题分析:秒杀活动面临着瞬时产生的巨大流量,需要考虑如何设计系统来抗住瞬时的海量请求

​ 应对方案:减少流量、异步化削峰

减少流量
  • 风控:设定购买门槛,限定购买资格,引入风控系统(例如信分系统等)
  • 预约机制:采用提前预约机制
  • 验证码答题:拉平请求(原通过直接点击1S内瞬时请求非常大,现引入验证码机制将原来的操作时间拉到1S-10S之间,相当于扩大请求的时间区间)
  • 限流:超过的流量就直接拒绝(由于不确定具体的流量大小,结合实际业务经验引入限流机制,限流保命)

​ 针对风控、预约机制这类减少流量的思路,它可以通过第三方服务来实现,并不属于秒杀活动的关注核心(更倾向业务限定的服务策略),但可以通过减少流量来提供对秒杀场景的支持。

异步化削峰

​ 首先需要了解哪些步骤流程是需要进行异步化,异步化是解决秒杀问题的一个重要思路,但针对不同的业务,异步化范围不同,用户体验也不同。

全流程异步化

​ 最彻底的异步化操作是全流程异步化,即在对请求做完参数检验、频率限制之后,将后续的一整个流程进行异步化处理,然后最终返回处理结果。但这样会产生一个很常见的用户体验问题:秒杀后菊花转了很久,最后弹了个框提示:奖品已抢光。很多产品不太可接受这样的用户体验,所以如果有其他的办法,尽量选择别的。

部分流程异步化

​ 将用户抢购流程进行拆解,然后将部分可异步执行的流程丢到异步操作中,优先返回一个信息提示给到用户,然后异步执行相关流程,以优化用户体验。

​ 一般的做法是通过Redis来预扣库存,这里为了避免混淆,此处把Redis中的库存称为名额。抢到名额的,大在绝大部分情况下都能发券成功时,再让用户等待一会是可以接受的。所以在实时流程中,需要确认用户是否抢到名额,大概涉及以下业务逻辑:

【1】校验请求(检查请求参数的正确性),避免通过修改请求包的方法获取不对自己定向投放的高价值奖品,或重复秒杀

【2】确认并扣减名额

【3】记录扣减名额信息

【4】将扣减名额成功的信息直接发给库存服务或者先丢入异步队列

​ 原实现机制为Redis检查库存、DB扣减库存,现引入缓存机制进行预扣减,相当于在缓存和DB之间存在一个异步的空间,避免大量扣减操作直达DB(类似引入白名单机制,只有抢占到名额的用户才能下订单)

(2)高精准(超卖、少卖问题)

​ 针对超卖、少卖问题还是要看业务的接收程度,对于上架而言一般来说某些情况下可以一定程度少卖,但是一定不能超卖(赔本生意)

​ 针对少卖可以通过补偿机制进行补偿(例如通过一个定时任务来处理,定时check额度和订单数,保证指定数目的库存都正确卖出)

​ 而针对超卖,如果超卖发货会导致超预算亏损,如果超卖不发货则会导致商家声誉受损,两头都是硬伤,是非常严重的业务问题。因此必须要限制超卖,严格控制库存(一般需要超强一致性的组件,例如MySQL,拒绝超卖)

(3)黄牛打击

​ 做活动的目的就是让利于真实的用户通过活动热度以打开市场,肯定不想商品都落入黄牛手上,要是都给黄牛抢走了,效果自然会打折扣。打击黄牛的思路:

  • 限购:限制单个用户能买的数量,比如很多秒杀活动就是一人能秒1件,这样增加黄牛作恶的成本
  • 限IP流(容易误杀):部分黄牛是用机器人来刷的,这时候可以针对IP做限制,当然IP是可以构造的,这主要还是增加作恶成本
  • 验证码(增强人机交互校验,但会降低体验感):秒杀之前需要验证码答题,这种模式非常有效,也很难饶过,但是缺点在于体验感会差很多,很多产品不会用这种方式

2.架构设计

核心设计思路

  • 【1】最外层流量的控制:削峰还是限频?
    • 削峰:请求一进来就丢消息队列,堆消息队列机器抗下海量请求,但是产品体验较差,可能出现大量用户抢了之后最终等待响应提示失败
    • 限频:todo
  • 【2】预扣库存:针对库存扣减,MySQL扛不住、Redis不够可靠,因此引入预扣库存机制。Redis中存放的视作名额,在Redis中执行预扣库存操作,而真正的库存扣减操作在MySQL中执行
  • 【3】库存扣减:MySQL中存放的是真正的库存,需通过MySQL保证库存扣减的可靠性
  • 【4】生成订单、完成支付:当MySQL扣减库存成功之后,创建相应的订单,前端跳转到支付页面,等待用户支付成功则更新相应状态。如果订单支付失败(放弃支付、网络异常等情况)则回退名额(避免少卖),一般这个过程限制5-10分钟

秒杀架构设计

(1)服务设计

​ 针对秒杀架构可设计如下服务:接入层服务、商品信息服务、秒杀信息服务、预扣库存服务、库存服务、订单服务、支付服务

(2)存储设计

​ 基于上述服务设计,关注预扣库存服务和库存服务,Redis实现预扣、而MySQL实现真正的库存扣减。

​ 即Redis拿到的是入场名额(可以快速获取结果,一般情况下不会出现什么异常,且大多数能拿到名额的人可以正常拿到库存),再由MySQL来确保库存的最终一致性。如果出现并发、主从切换等导致的异常,此时之前拿到名额的用户不一定可以拿到库存。

​ 除却Redis、MySQL,引入消息队列来进行消息的异步传递。如果结合正常场景分析,预扣库存之后真正执行的量被限定到商品数量范围内,这个数据量可能不会太多,为什么还要引入消息队列进行强行异步操作?实际上虽然将真正达到MySQL的量降到很小,但是如果结合实际情况考虑,此时商品数量超过1W,则秒杀瞬间也可能会将MySQL的流量打崩(MySQL无法直接承载1s内1W的流量)

数据表设计

  • 库存信息(t_seckill_stock):记录库存信息
字段类型说明
idbigint(20)主键自增ID
goods_idvarchar(256)商品ID
stockint(11)库存(秒杀活动库存数目)
  • 秒杀信息(t_seckill_record):记录用户一次秒杀操作的信息(保存秒杀操作的核心信息)
字段类型说明
idbigint(20)主键自增ID
sec_numvarchar(256)秒杀编号
order_numvarchar(256)订单编号
user_idvarchar(256)用户ID
goods_idvarchar(256)商品ID
amountint(10)抢购数量(本次秒杀抢购商品的数量)
statustinyint(4)秒杀单据的状态,
// 预购抢到名额,待生成订单
SK_STATUS_BEFORE_ORDER SecKillStatusEnum=1
// 订单生成之后,待付款
SK_STATUS_BEFORE_PAY SecKillstatusEnum=2
// 终态,已付款
SK_STATUS_PAYED SecKillstatusEnum=3
// 终态,超时未付款
SK_STATUS_OOT SecKillStatusEnum=4
// 终态,主动取消
SK_STATUS_CANCEL SecKillStatusEnum=5

​ 下述针对Key的组成说明,前缀为固定字符,而针对goodsID、userID则为非固定字符

  • keyUserGoodsSecNum(SK:UserGoodsSecNum:goodsID:userID):对应Value为secNum。secNum是秒杀单号,秒杀单号在发起秒杀请求的返回包里可以得到。所以这个SK:UserGoodsSecNum:goodsID:userID的意义是某个用户在某个商品,秒杀单号是多少,如果不是为空,则表示在秒杀流程中,禁止用户重复发起秒杀流程
  • keyStock(SK:Stock:goodslD):对应Value为名额数量(比如SK:Stock:1表示1号产品目前的名额,Value如果是10则表示剩余10件名额)
  • keyLimit( SK:Limit:goodslD):对应value为全局限额,即单个用户对该商品能做多秒杀多少件

构建流程思路分析:

  • 【1】查看是否还有库存,也就是查看【keyStock】,检查名额还剩多少
  • 【2】扣减库存,可以用DECR 【keyStock】命令来做
  • 【3】以secNum为Key记录本次秒杀信息,secNum提前用uuid生成即可

​ 注意此处有个并发问题,如果很多请求同时到来,都发现库存>0,比如库存等于1,如果此时同时去扣减,就会给库存整成负数,并不符合实际场景,因此这里需要引入LUA来保证原子性,进而避免超卖异常。

业务整体架构

image-20240729141042131

核心流程分析如下:

  • 【步骤1】:获取基础信息(商品信息、秒杀信息)
  • 【步骤2】:发起抢购
    • 2.1 扣减Redis名额(预扣减库存),记录抢购信息(记录秒杀信息、当前秒杀状态)
    • 2.2 丢进Kafka,异步执行操作(等待库存扣减、生成订单)
  • 【步骤3】:库存服务消费Kafka消息
    • 3.1 真正扣减MySQL库存
    • 3.2 创建相应的订单
    • 3.3 更新秒杀状态信息
  • 【步骤4】:等待Redis中秒杀状态更新完成,前端跳转到支付页面完成,进一步完成支付操作(更新支付状态)
    • 更新支付状态
    • 定时任务定时检查支付状态,如果出现支付状态异常,则采取相应的处理机制(重试、回退)

理解为什么要这样设计秒杀流程?

​ 传统的秒杀流程设计中可能会将查询库存、扣减库存、订单生成、订单支付这三个步骤视作一个完整的秒杀过程,那么基于高并发场景就可能会出现下单的时候是成功的、但是等订单支付完成之后被告知抢购失败。这种场景在实际的业务场景中也会经常遇到,例如在抢票的过程中,点击抢票、等待生成订单、支付订单,当所有的操作完成之后却被告知抢票失败,所有操作的基础都在拼网速、拼手速,这会给用户带来非常不好的体验(但不乏有些商家需要这种机制,目的在于减少客户犹豫时间以更好地促进交易)。且部分系统由于接入的是第三方的支付,如果支付成功但最终结果是抢购失败的话还需要关注款项的退回机制等相关操作。

​ 为了优化上述的业务体验,也避免出现高并发场景中的超卖问题,此处引入预扣库存机制进行流程优化,将原有的秒杀流程优化为:查询库存、预扣库存、异步操作(真正扣减库存、生成订单服务、同步秒杀状态信息),当上述操作流程执行完成则视为秒杀成功(对应上述流程的前3个步骤),而针对后续的订单支付则将其视为秒杀后的一个节点(进入这个节点状态的用户是已经成功抢占到了名额,只需完成支付操作即可完成整个抢购流程)。此处预扣库存机制的引入有如下特点:

  • 避免超卖:通过预扣库存锁定库存资源,进而避免超卖现象
  • 优化体验:通过预扣库存锁定抢购名额,将订单支付剥离出来,后续通过定时任务异步检查订单状态以执行相应的操作

3.要点分析(细节拆解)

​ 此处结合秒杀流程中的各个要点进行剖析和解读,拆解各个要点的含义和实现

CDN

​ 针对CDN的引入,可以将静态资源放到CDN上,加速页面加载速度,优化秒杀体验

限流/削峰

​ 限流:如果每秒过来的请求超过指定频率则拒绝。限流可以作为一个单独的系统设计,一般是放在网关来做,可以理解为引入通用的限流组件,一般放在接入服务中。例如请求流程可以为:发起请求=>网关(限流组件)/ 接入服务(限流组件)=>预扣库存服务(Redis)=》.....等

打击黄牛

​ 如果说某个用户请求接口测试过于频繁,一般说明可能是黄牛在用脚本跑接口,可以针对用户做限制

​ 打击黄牛主要是为了避免程序被恶意抢购,确保服务真正触达客户。针对黄牛可以采取如下思路:

  • 限购:引入风控对用户身份进行校验,限制单个用户只能购买指定数量的商品
  • 限IP:针对IP做限制是常见的做法,但容易出现误杀(如果使用同一个网络的用户,如果限制IP的话可能导致正常用户也会受到影响),因此针对IP限流的阈值不能太低,更多是作为兜底策略
  • 验证码:引入验证码策略,避免脚本攻击,但也降低了用户体验感(实际上天猫、京东都很少用验证码的方式,但12306用过)
(1)限购

限购逻辑

​ 所谓限购主要是针对两个方面进行校验:

  • 商品限额(商品信息可以缓存在Redis中)
  • 用户已参与的商品秒杀个数

​ Redis通过比较【本次秒杀用户欲购买商品个数 + 已参与的商品秒杀个数】与【商品限额】,进而进行限制。根据业务不同的限购场景进行控制,有些业务是限购一次、有些业务是限购个数,针对不同的业务要匹配相应的限购策略。假设此处是设定限购数量,则限购相关判断的伪代码如下

// 判断这个用户购买是否已经超过限额
Local limit = redis.call('get', keyLimit)
Local userSecKilledNum = redis.call('get', keyUserSeckilledNum)
if limit and userSecKilledNum and tonumber(userSecKilledNum) + tonumber(num)> tonumber(
  retAry[1]=-2
	return retAry
end

限购风险

​ 由于限购是使用Redis来实现,但实际上它并不完全可靠。和预扣库存概念类似,此处限购也要注意数据丢失风险导致限购异常(可以理解为Redis所有的库存相关操作情况,最终都需要走到MySQL进行兜底,而通过预扣机制则是为了在Redis先过滤一层,避免所有的操作直接落到MySQL而将其打崩)

​ MySQL的限购兜底策略:用户库存真正扣减之前,需先检查限购(引入限购表配置限购信息),当库存真正扣减成功之后,需更新用户购买记录

  • 查询用户当前限购情况
    • 如果没有限购额度则直接拒绝
    • 如果在限购额度范围内,则扣减额度并更新用户购买记录

前端拿到名额的状态查询(秒杀状态查询)

​ 针对前端怎么查询秒杀状态 =》在Redis中来单独记录。在调用秒杀接口之后,会得到一个secNum(秒杀单号),通过这个单号即可查询对应的数据,具体参考如下结构的JSON化数据:

type PreSeckillRecord struct {
  SecNum string // key
  UserID int64
  GoodsID int64
  OrderNum string
  Price float64
  Status int
	CreateTime time.Time
  ModifyTime time.Time
}

Redis预扣库存(扣减名额)

​ 通过Redis扣减名额,整体分析构建参考如下:

  • 使用Redis高性能管理秒杀名额
  • 基于Redis + Lua 脚本确保原子性操作
  • 增加限额和秒杀状态记录逻辑

关键Key定义

key1:用户id,表明是哪个用户

key2:商品id,表明是哪件商品

key3:希望抢购多少个名额

key4:秒杀单号,作为这个秒杀行为的唯一标识

key5:秒杀记录,以秒杀单号为Key对应的秒杀详细信息(JSON数据)

var secKillLua =
-- key1:用户id,key2:商品id key3:抢购多少个 key4:秒杀单号,keys5:秒杀记录
-- keyLimit是 SK:Limit:goodsID
local keyLimit ="SK:Limit:".. KEYS[2]
-- keyUserGoodsSecNum 是SK:UserGoodsSecNum:goodsID:userID
local keyUserGoodsSecNum ="sK:UserGoodsSecNum:".. KEYS[2]1.1.. KEYS[1]
-- keyUserSecKilledNum 是SK:UserSecKilledNum:userID:goodsID
local keyUserSecKilledNum ="SK:UserSecKilledNum:"KEYS[1] ..H.KEYS[2]

判断用户是否在秒杀中

​ 判断是否秒杀中,即查询keyUserGoodsSecNum这个Key,如果有记录目前的secNum,则在秒杀中,并且将秒杀单号返回

--1.判断这个用户是不是已经在秒杀中,是的话返回secNum
local alreadySecNum = redis.call('get', keyUserGoodsSecNum)
local retAry = {0,""}if alreadySecNum and string.len(alreadySecNum)~=0 then
	retAry[1]=-1
	retAry[2]= alreadySecNum
	return retAry
end

判断用户是否超过限额

​ 判断用户在此商品是否超过限额,即查询keyUserSecKilledNum这个Key,如果用户剩余额度不足,则拒绝

--2.判断这个用户是不是已经超过限额了
Local limit = redis.call('get', keyLimit)
Local userSecKilledNum = redis.call('get', keyUserSeckilledNum)
if limit and userSecKilledNum and tonumber(userSecKilledNum) + tonumber(num)> tonumber(
  	retAry[1]=-2
	return retAry
end

判断活动名额是否足够

​ 通过stockKey查看商品的名额数目,将这个数目和希望秒杀的数目进行对比,如果不够则拒绝

--3.判断查询活动名额
local stockKey ="SK:Stock:"KEYS[2]
local stock = redis.call('get', stockkey)
if not stock or tonumber(stock)< tonumber(KEYS[3])then
	retAry[1]=-3
	return retAry
end

进行扣减

  • 扣减操作(数据写入):

    • 减少KEYS[3]对应数量的商品名额数目
    • 增加该用户针对此商品的已秒名额数目
    • 关联当前用户在这个商品下的秒杀单号
    • 记录秒杀单号对应的秒杀记录信息
    -- 4.活动库存充足,进行扣减操作
    redis.call('decrby',stockKey, KEYS[3])
    redis.call('incrby', keyUserSecKilledNum, KEYS[3])
    redis.call('set', keyUserGoodsSecNum,KEYS[4])
    redis.call('set',KEYS[4]KEYS[5])return retAry
    

Redis预扣库存后如何传递给库存服务

​ Redis预扣库存之后,需要调用库存服务来完成真正的库存扣减操作,常见的策略是直接调用库存服务,最终达到库存服务的MySQL。

​ 但其实是应该分情况来看:一般商品秒杀总数都很少,几百,甚至几千。在大部分流量都没抢到名额的情况下,就只有商品数量的请求打到MySQL,一般问题不大。但是如果商品数量是2w的话,此时一瞬间秒完打到MySQL,MySQL就扛不住了。

​ 因此针对请求量小于5k的情况可以直接调用库存服务,MySQL还可以抗的住。超出5K的请求量则考虑扔入消息队列进行异步消费(此处消息队列起一个削峰的作用)

MySQL真正扣减库存、生成订单

​ MySQL在此步的操作涉及到两部分的数据:库存信息、操作记录数据,而订单数据则由订单服务进行生成

  • 库存信息:商品id、库存总数、库存数量stock
  • 操作记录数据:唯一id(用户id+商品id)、订单状态(等待支付、订单关闭、支付成功)、用户id、商品id、抢购数量

消费消息队列的数据:

【1】扣减数据库存储

【2】产生真正的订单(由订单系统生成),订单有状态

【3】在数据库t_seckill_record表产生一条订单记录数据,记录有状态,可以用来支持幂等(结合退回逻辑理解,这个记录起到状态保护作用)

【4】通知预扣系统的Redis状态变了,更新下状态,如果发现有数据异常,此时也做修复,前端发现Redis状态变了可以跳转到付费页面

【5】付费之后状态会改变,如果超时未付费,额度退回

MySQL 不会超卖的原理分析

# 语句1 更新库存
update t_scekill_stock set stock = stock -1 where stock >=1 and product_id = xxx;
-- 如果SQL语句执行影响行数为0则说明库存已经扣完,不会再执行第二条语句,并通知Redis商品已抢完

# 语句2 插入操作记录数据
insert t_scekill_stock(order_id,user_id,goods_id,status) values(....) -- 其中status为代付款

退回逻辑

​ 针对退回的逻辑是分布操作的,主要拆分为两个方向,分别为超时库存退回MySQL、名额加载回Redis

  • 超时库存退回MySQL:例如用户抢占名额成功,但是由于超时未完成订单流程,导致购买失败,需要将这部分操作退回MySQL
  • 名额加载回Redis:MySQL库存还存在,但是Redis名额为0,可能出现了少卖现象,需要将MySQL的可用名额加载到Redis

image-20240729163534817

(1)超时库存退回MySQL

​ 通过定时任务进行扫描,判断用户订单处理状态,如果订单超时未支付则将额度加载回MySQL,通过乐观锁 + 事务实现幂等。由于此时支付超时可能已经过去了很长时间,Redis中的名额也被消耗完了,可以主动触发”名额加载回Redis“的逻辑,让此处退回的名额回到Redis。

​ 或者可以简化操作:在更新MySQL额度之后,直接加载Redis(失败了也有”名额加载回Redis“逻辑兜底)

​ MySQL额度退回操作参考:

# 语句1 支付超时,关闭订单(通过where status 条件乐观锁保护)
update t_scekill_stock set status = '超时关闭' where order_id = xxx and status = '待支付';
-- 如果SQL语句执行影响行数为0则说明库存已经扣完,不会再执行第二条语句,并通知Redis商品已抢完

# 语句2 如果语句1执行后影响行数不为0,则可继续执行下述操作完成库存退还
update t_scekill_stock set stock = stock + 2 where product_id = xxx; -- 此处stock退回的数量要结合实际业务设置(此处案例为退回2个)
(2)名额退回Redis

​ ”名额加载回Redis“:判断MySQL库存还在,但Redis名额为0,出现了少卖现象,需要将MySQL的名额加载回Redis。为了避免过程中还有请求在处理中,可以增加逻辑:上述状态维持超过X分钟(例如5分钟),分钟级的时间足够消息处理,确认少卖之后则将MySQL的库存加载到Redis

4.实战案例

​ todo:微信秒杀实例open in new window

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3