什么是秒杀

百度百科对秒杀这个词的解释有多个,第一种是:

在某些领域以压倒性的优势超越其他人,或者是在极短时间(比如一秒钟)内解决对手,该种语言通常使用在网络游戏中。

还有一种解释语义用在网购场景中,通常是指:

网络商家一个非常优惠,极具吸引力的价格发布一款商品,并限定在一段非常短的时间内开放给消费者购买。由于价格十分实惠,往往会吸引很多消费者争相购买,商品会在很短时间内被一抢而空,有时甚至在一秒钟之内商品就被抢完了。因此将这种电商的限时低价抢购活动形象的称为秒杀。

当然,我们今天的主题肯定是第二个了。下面就先来看看网购秒杀的场景有哪些特点。

秒杀场景的特点

生活中最常见的秒杀场景有双十一的电商促销活动,节假日12306的抢票场景等。这些场景的特点就是:

  • 瞬间系统的并发请求特别高;
  • 商品数量有限,往往供不应求。

因此我们在针对秒杀场景设计系统时就要充分考虑以下问题:

  • 系统怎么扛住高并发的请求;
  • 怎么防止商品超卖; —不考虑秒杀的话,就是一个普通的下单流程,乐观锁扣减库存
  • 恶意软件刷单秒杀; —针对IP过滤限流?
  • 秒杀的接口需要到指定时间才放开,到指定时间失效; — 后台判定
  • 订单长时间没有支付,应该及时释放该商品,补充到库存中。 —死信队列
  • 秒杀业务并发量大,是否会对其业务造成影响; —单独部署秒杀系统?
  • 恶意DDos攻击; —高防IP

从系统架构的角度优化

在秒杀进行的瞬间,会有大量的请求涌进系统。如果系统不具备高并发能力的话,那么系统会立马进入瘫痪状态。这对于大公司是不能接受的,因为不仅会影响业务的正常开展,还会给用户留下这个公司技术能力差的映像。所以让秒杀系统具备高并发能力是我们在设计系统时首先需要考虑的问题。

让系统具备高并发的能力是一个很大的话题,这边限于篇幅,不会深入展开每个细节点。

负载均衡提高系统水平扩展能力

高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾手段 (双活机房、节点容错、服务器灾备等)来保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。下边是一个简单的负载均衡示意图:

640?wx fmt=png&tp=webp&wxfrom=5&wx lazy=1&wx co=1 - 怎么设计一个秒杀系统

上面的三层负载均衡的架构,能大大提升了系统的水平扩展能力。可以根据秒杀的并发量来灵活地调整机器的数量。如果预估请求量比较大的话,可以同步往上加机器。

LVS和Nginx都是和负载均衡相关的技术,一个是四层负载,一个是七层负载。LVS的吞吐量比Nginx要高很多,可以达到几十万,Nginx的吞吐量也相对较高,可以达到几万的量级。关于两者更深入的知识,大家可以自己学习。

接口限流减少不必要的流量

秒杀的商品库存只有100件,但是一秒钟内可能会有10000个,甚至更多的用户来抢购这100件商品。很显然其中的大多数是抢不到的,那么就很有必要做一下限制——一定时间内不要让这么多用户进来抢,比如说一秒内我只放行5000个用户进来秒杀。这就是限流措施,在秒杀系统中引进限流措施可以大大较少系统资源的浪费,减少无意义的争抢。

限流可以分为前端限流后端限流。

前端限流的措施有:

  • 首先第一步就是通过前端限流,用户在秒杀按钮点击以后发起请求,那么在接下来的5秒是无法点击(通过设置按钮为disable)。这一小举措开发起来成本很小,但是很有效。

后端限流:

  • 每个用户一定时间内只能秒杀一次:具体多少秒需要根据实际业务和秒杀的人数而定,一般限定为10秒。具体的做法就是通过redis的键过期策略,首先对每个请求都从String value = redis.get(userId);如果获取到这个value为空或者为null,表示它是有效的请求,然后放行这个请求。如果不为空表示它是重复性请求,直接丢掉这个请求。如果有效,采用redis.setexpire(userId,value,10).value可以是任意值,一般放业务属性比较好,这个是设置以userId为key,10秒的过期时间(10秒后,key对应的值自动为null)。
  • 限流算法限流:到这步已经将很多无效的争抢流量限制住了,但是极限并发下还是会有很多请求进来。这时我们就可以使用限流算法来进一步限制流量,比如1秒内最多放行1000个请求。

比较成熟的限流算法有:

  • 令牌桶算法
  • 漏桶算法

当然,已经有很多的限流算法实现了。比如Guava、Nignx和Spring等,都可以让我们实现限流措施。大家可以自己去查询使用。(后面会写专门的文章来介绍限流的实现)

从秒杀流程的角度优化

从上面的介绍我们知道用户秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,但即使如此,集群中的单机所承受的 QPS 也是非常高的,因此我们还需要尽可能地优化单机的性能。

其实如果我们抛开秒杀场景的大并发特点的话,秒杀就是一个普通的电商下单流程。一个普通的电商下单流程包括以下几步:

  • 查询库存,库存不足的话就不能购买;
  • 生成订单并扣减库存(生成订单和库存扣减的顺序有讲究,下面会讨论);
  • 用户支付;
  • 长时间没支付的订单处理成失效,并释放库存。

为了将整个流程说清楚,这边简单建两个表:商品表和订单表,用简单的代码描述下大体的过程。

-- 商品表
CREATE TABLE `stock` 
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '商品名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY(`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
-- 订单表
CREATE TABLE `stock_order`
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '库存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY(`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;

库存扣减的优化

1. 乐观锁更新库存

在高并发系统中存在一个普遍的问题就是多个请求并发修改一条记录。在秒杀系统中也存在这种情况,就是在扣减库存的时候。一个请求首先去查商品现在的库存,发现库存还充足,准备去扣减相应的库存并生成订单。但是这个时候其他请求可能已经在这个请求之前扣减了库存,导致库存已经不足了。这个时候如果再继续去扣减的话就会导致“超卖”现象。

下面是有问题的代码逻辑

1775037 20200903222319110 1495115683 - 怎么设计一个秒杀系统

上面出问题的地方就是更新扣减库存时没有检查当前的库存是否被人更新过了。解决的办法也非常简单,就是在扣减更新库存时加一个乐观锁更新。乐观锁我们通常一个时间戳版本号实现,所以将更新库存的sql改成如下,就可以解决超卖的问题。

update stock set sales = sales + 1, version = version + 1 where id = # and version = version

其实这步还有优化的空间:将查询库存和扣减库存合成一步,可以用这样的做法:

update stock set sales = sales + 1, version = version + 1 
where id ={id} and 
      version = #{version} and 
      count - sale > 0;

这样的话,就可以保证库存不会超卖并且一次更新库存。当update语句返回更新的数据条数是1的时候就认为还有库存。

2. 将库存信息放入Redis缓存

在上面的下单流程中,我们可以发现每次都要去数据库去查下库存信息。这在大并发情况下对数据库的压力时相当大的。所以我们可以将库存信息放入Redis分布式缓存,减少数据库的压力。更新库存时同时将缓存中的库存数据也更新。(这边有个问题,当并发级别真的非常高时,Redis会不会成为查询的瓶颈?)

3. 释放长时间没有支付的订单

上面的流程中,一旦订单创建成功就会占据一个库存。如果这个订单一直没支付的话就会导致其他想买的用户不能买到商品。所以我们要想办法释放这种长期没有支付的订单,将库存释放到总库存中去。

方案一:写一个定时job, 每分钟扫描一下数据库的订单表,如果订单超过了15分钟,那么订单状态改为失效,并且商品表数量要加1,因为刚刚删除的订单释放了一个商品。但是这样会给数据库造成很大的压力,而且如果长时间都没有过期的订单,而job依然会每分钟跑一次,浪费资源。(问题:删除订单时如果用户同时支付了怎么办?加乐观锁?还有什么需要注意的?)

方案二:使用延迟队列处理,创建订单的时候同步向延迟队列中发送相关的订单信息。然后消费者在指定的延迟时间后取出订单ID,去查询订单是否已经支付,如果没有支付则设置成失效。

这边只是简单介绍下方案,后面会写详细的文章进行分析。

4. 为什么要先扣库存再创建订单

我们上面设计的流程是:扣减库存 –> 创建订单 –> 支付。有没小伙伴想过为什么这个流程会比较好。能不能是创建订单–>扣减库存–>支付;或者是创建订单–>支付–>扣减库存呢?

先说创建订单–>扣减库存–>支付这个顺序,这种流程存在的一个比较大的问题就是:一个用户会创建很多订单,但是他只需要买一个,所以会占据其他用户的购买名额。

再说创建订单–>支付–>扣减库存这种顺序,这个流程存在的问题就是:用户创建了订单并支付成功了,但是因为存在高并发的情况,其他用户可能在这个用户支付的过程中已经提前支付买走了最后的商品,这就会导致“超卖”的现象——钱付了,货没了,尴尬。

异步创建订单的优化

如果还要继续提升系统性能的话,可以考虑将最后一步的创建订单从同步转为异步。通过引入消息队列,将订单的信息发到消息队列,消费者负责消费信息并创建订单。因为异步了,所以最终需要采取回调或者是其他提醒的方式提醒用户购买完成。当然也可以轮询订单的创建情况,主动完成支付。

秒杀页面静态化

用户在秒杀开始前,一般会通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成负载压力。

重新设计秒杀商品页面,不使用网站原来的商品详细页面,将商品的描述、参数、成交记录、图像、评价等全部写入到一个静态页面,用户请求不需要通过访问后端服务器。

具体的方法可以使用freemarker模板技术,建立网页模板,填充数据,然后渲染网页。

秒杀静态页面CDN部署

秒杀的瞬间,传递商品静态页面需要的贷款可能会超过平时服务器的贷款,所以可以考虑将 商品静态页面部署到CDN来节省贷款。

秒杀系统优化思路总结

  • 尽量将请求拦截在上游。
  • 还可以根据 UID 进行限流。
  • 最大程度的减少请求落到 DB。
  • 多利用缓存。
  • 同步操作异步化。
  • fail fast,尽早失败,保护应用。

其实不止秒杀系统,个人觉得系统优化都可以参考这几个维度。这些方面都进行优化过了,可以再考虑其他方面的优化。

秒杀系统的开源代码

Spring-Boot相关的开源秒杀框架

参考

怎么设计一个秒杀系统

怎么设计一个秒杀系统插图