秒杀系统

概述

秒杀系统是指大量用户同时抢购数量有限的商品,瞬时造成服务端的尖峰压力, 服务端需要保证高并发处理,同时又要保证高可用性、商品不会超发、快速扩容缩容、系统容错、降级熔断等,融合了各种服务端技术。

我们以电商的购买流程举例,电商购买分为放入购物车、下单、支付三个步骤。购物车服务是用来管理用户将要购买的商品,单独一个服务。下单服务是服务端创建订单并减库存的服务,如果库存减为0,则不能再卖出商品,减库存需要做到幂等和防超发。支付系统是对订单进行最终的支付, 如果过期没有支付需要再返回库存。总体上来说订单ID在整个流程中是唯一的,试用订单ID来保证幂等性。如果以微信红包系统来举例的话,经历微信包红包、发红包、抢红包、拆红包的过程。抢红包类似电商的减库存,用户抢到红包但是不知道多少钱,抢到红包的人是说有资格拿到钱(拆红包时有可能失败)。拆红包类似最终支付到账的流程,后台随机给用户分配抢到的金额并写入到数据库中

秒杀系统中,一个重要的概念的幂等性,因为分布式系统中存在各种重试(事务补偿),在各个层面都要做的幂等性,才能有效的防止重复购买和数据一致性。

下面我们按照客户端、服务端、缓存、数据库来说明下各个阶段需要做的事情。

客户端

客户端是请求的发起方,一般来说客户端有一个H5页面或者客户端页面。在秒杀时刻,会有大量用户同时访问服务端,用户也需要首先下载H5页面或者一些静态信息。

静态信息存储
  • 在CDN中存储静态H5页面,快速返回给客户端。
  • 客户端活动开始前访问服务端时候,缓存页面到本地。
防止重复购买
  • 用户只能单点登录,防止多地登录,重复购买。
  • 客户端访问服务端创建订单后,缓存订单号到本地。后续访问服务端时候带上订单号,防止重复下单。
  • 用户抢购过后将抢购按钮置灰,防止用户重复抢购。
  • 添加验证码、游戏、问答等,防止用户快速的重复刷新。增加用户下单的时间,降低服务端性能压力。

服务端

nginx层的日志通过flume写入到Hadoop + Hbase中,风控平台,供以后分析用或者对账用。

限制购买数量

服务端需要记录用户目前已经购买了多少次,防止用户超过购买次数。用户下单后创建订单,如果减库存成功则记录在订单管理系统中,这里可以用来统计用户下单成功的次数。为了提升高并发的能力,考虑以下措施。另外这一步在一些特殊情况下(主备不同步,机器挂掉)可能会存在漏掉一些非法用户,需要后面下单写入数据库的时候也进行判断。

客户端缓存

客户端缓存用户目前在本机上已经购买了多少数量的商品,如果已经超过限定,则置按钮为灰,不能再抢购。而且可以限定用户抢购的频率,每抢购一次停留5s再抢购。但是限制不了用户绕过客户端直接发起http请求到服务端。

Redis缓存

客户端下单时,服务端首先在Redis中存储用户+商品-》购买次数的kv对,如果这期间有同时有该用户的其他请求,发现Redis中已经存在该记录,则暂时返回失败,等待重试。同时一旦有请求进来,对redis进行异步操作加载数据库中用户的订单记录计算用户目前真正的购买次数(前面的抢购中有可能有失败的),异步操作可以根据redis中数据的时间超过5秒更新一次。

  • 一个用户只能购买一个:redis中缓存用户+商品的key-》上次加载时间,操作redis时候使用原子加操作
  • 一个用户限定购买多个:redis中缓存用户+商品-》购买次数,上次加载时间,操作redis时候使用原子加操作。
本地缓存

在某些极端情况下,Redis的性能可能不足以支撑抢购性能。一个优化的策略是说,将用户目前的购买次数存在本地,同样同样适用异步更新的策略。

  • 本地机器只存储非常热门的多次购买的用户次数
  • 用户通过一种hash方式使得相同用户的请求到同一台机器。(待考虑,因为商品ID可能也希望到同一台机器上处理)。但是如果这一台机器挂掉,本地存储的用户购买数量就会丢失,换到新机器后需要去redis中查询存储在本地再判断是否减库存。
数据一致性
  • Redis采用集群格式,一主多副,防止主机挂掉。
  • 如果Redis挂掉,采用熔断方式。并且降级,回复大部分的请求失败,少部分请求走数据库逻辑。
  • 如果跨过这一层,需要后续减库存时候判断是否该用户已经购买了足够多的商品,判断下单是否失败。
  • Redis扩容缩容交由Redis集群保证。

限制购买频率

除了限制用户的购买数量,另一方面可以从用户的购买频率上进行限制。首先客户端和服务端都要限制一个用户一次最多购买多少件商品,一个配置就可以搞定。然后从服务端采用异步的方式计算热点用户或者热点IP的购买频率,计算出黑名单用户,并上传到服务端,对黑名单用户直接返回失败。

  • 限制商品购买频率。如果该抢购商品在一段时间内抢购频率太快,则随机给一些用户返回失败。
  • 限制用户购买频率:防止单个用户购买频率太快,防止是非法用户。

缓存库存

用户下单的时候需要判断库里是否还有库存,如果没有库存就直接返回给用户失败,防止超卖的发生。但是在并发量高的场景,会同时有请求操作数据库,如果不加锁的话会有数据不一致的情况,加锁的话会对系统性能有影响。所以,一种办法是在数据库层增加缓存层,将大部分超过库存的请求挡到外面。

随机返回失败

当大批量请求进来时候,可能已经超过本机的承载能力,则随机将一些请求置为抢购失败,减轻服务端的压力。这就是降级和熔断的功能。

redis缓存

将商品库存缓存在redis中,一旦过来订单,则对redis做原子减操作,如果库存已经减为0,则直接返回失败。否则,将本次的订单写入到消息队列中,后续异步更新到数据库。如果订单写入消息队列失败,同样返回失败。如果写入成功,返回正在处理,等待数据库更新成功后,才真正的返回成功。
通过redis的请求并不一定最后一定处理成功,可能会有一些重复订单(机器挂掉后出现)、或者缓存和数据库库存的不一致导致的漏掉,所以最后的结果要以数据库为准。

防止重复提交

首先服务端需要判断订单是否之前处理过。所以每当用户下单时候,需要把该订单和其当前状态写入到数据库或者Redis中,key是订单号。用来判断是否是重复订单,如果是重复订单,则返回当前订单的状态(正在处理中,抢购成功,抢购失败)。类似抢火车票时候,一直处于排队状态。

本地预分配

使用redis缓存需要经过一次网络调用,如果可以做到将库存缓存到本地,则会大大提升后端性能。所以,可以考虑首先将抢购商品的库存预分配到每台机器上(每天机器上有部分库存),需要保证同一个用户的请求会被分配到同一台机器。这样,当用户请求进来时,直接在本地给用户分配商品,清减库存(这里需要尽量减少锁的操作,保证性能,比如可以考虑分组、数组等减少锁的粒度)。如果失败,则直接返回失败。如果成功,则写入到消息队列,返回抢购成功同时更新订单状态(这里和Redis的不一样,redis方案需要最终写入到数据库中才返回成功)。如果写消息队列失败,则仍然返回失败。但是该用户的这份库存需要释放出来。

防止重复提交

redis方案中使用redis存储订单状态,那么是否可以在本地缓存订单状态呢?保证同一个订单的请求会进入同一台机器,使用订单ID的后两位来路由到某台机器上,同时在更新本地状态后,异步写入到redis中订单状态。考虑机器宕机的情况下,订单请求切换到另外一台机器怎么办,这时候需要去redis中查询一下,判断该订单是否之前处理过。偶尔一些重复请求(一些非法用户或者更换了机器的)可能会进入后面的逻辑,由数据库层面拦截。

机器宕机
  • 如果出现机器宕机导致没来得及更新Redis中订单的状态,用户再来请求时候的直接返回订单目前的状态。直到后续数据库完成操作后,更新redis中订单状态。
  • 机器宕机导致来不及发送到消息队列中,客户端访问超时设置状态为系统正在处理。用户再来请求时候的直接返回订单目前的状态(正在处理)。后续判定该数据过期或者确认失败后,设置为失败状态。
机器扩容

另一个问题是在运行过程中发现压力太大,需要新增一台机器。这时候库存分配策略是怎么样的?如果直接使用hash规则会发生改变,导致大批用户迁移机器,影响性能。一个好的hash策略是参考微信红包的策略,将订单ID的最后两位分配到每台机器上。比如现在有10台机器,目前订单ID的后两位生成时候都是00-09。如果增加了一台机器,生成00-10结尾的订单ID。这样由订单ID生成规则来做机器的路由。

库存分配

机器扩容的另外一个问题是新机器需要新的库存。考虑将整体库存分成多个20w的小库存放到数据库中。每台机器每隔一段时间来拿空闲的库存,并设置该库存的owner是该机器IP。新机器扩容时候,同样来这里拿。如果一台机器挂掉,那么属于它的小库存将不再更新,等到一段时间确定处理完所有的消息队列中的该机器请求后,则设置为无owner,允许其他机器获取。

数据库

数据库中保存最终的商品库存,需要保证数据安全性和数据一致性。从消息队列中顺序读取消息并减库存,因为是顺序操作不涉及到超卖的问题。同时更新redis中订单的状态。为了保证幂等性,需要在同一个事务中同步写入数据库中该订单消耗了多少库存(流水),防止后续同一个订单又来减库存。数据库使用raft强一致性协议保持主从同步。其他的方法:

  • 使用悲观锁
  • 使用消息队列
  • 使用乐观锁,version来实现

你可能感兴趣的:(秒杀系统)