1. 秒杀定义

一个秒杀系统,应该具备商品能够预热加载到内存,保证商品不出先超卖的情况,对于超卖的情况损失是不可估计的,最后就是秒杀的逻辑尽可能的简单,最好就是一个扣减商品库存的功能,将复杂的逻辑(如:秒杀成功后创建商品订单)异步来操作,以此来提高系统的吞吐量。

1.1. 秒杀特点

谈谈秒杀 - 图1

1.2. 秒杀流程

谈谈秒杀 - 图2

秒杀基本流程.jpg

2. 如何解决

2.1. 秒杀隔离

我们指定普通商品和秒杀商品本质上下单流程是一样的,区别在于流量的不同,秒杀商品具有瞬时流量高的特点,普通商品的流量比较平缓。在一个电商平台可以说几十亿商品中,参与秒杀的商品是非常少的。如果把普通商品和秒杀商品混合在一起,势必对普通商品的访问造成很大冲击,造成不可预估的后果。所以我们势必要将秒杀商品和普通商品在访问上进行隔离,做到秒杀商品的异常不影响到普通商品的访问。
那么我们的隔离可以从哪些方面来做呢?
谈谈秒杀 - 图4

2.1.1. 业务隔离

商家将秒杀商品通过“提报系统”进行上报,提供秒杀商品、活动起止时间,秒杀库存,限购规则,参与活动群体分布、预计人数等信息。那么我们就可以通过提报系统提前收集到秒杀信息,预估大致流量、并发数等,并结合系统当前的支撑容量情况来评估是否需要扩容、降级、限流等策略。

2.1.2. 系统隔离

无论是普通商品还是秒杀商品在购买过程的链路中会涉及到很多系统,如果把这些系统完全复制单独部署一份来支持秒杀,从技术上是可行的,但是从经济效率来讲成本会很高,因为秒杀活动时流量大用到的机器资源会很多,活动结束后资源就处于空闲造成资源浪费。
所以我们可以把整个链路中更接近用户的系统进行单独部署隔离,那些离用户远的系统共享,可以节省很多资源。

2.1.3. 数据隔离

在对系统隔离后,我们应该也要对数据进行隔离,不能因为瞬间大流量击垮我们的数据层,如Redis缓存等。在秒杀场景中,一般会采用一主多从来扛热点数据。

2.1.4. 隔离总结

我们先捋一下隔离的整个过程
谈谈秒杀 - 图5

问题:
当对系统和数据隔离后,如何让秒杀商品和普通商品都各自准确的打到自己的链路上去呢?我们可以对商品打标,为秒杀商品申请独立的域名,商品打上秒杀标。这样在请求到后端服务时可以根据商品的标和域名来路由到对应的服务上,解决服务链路调用乱串问题。

2.2. 流量管控

通过“隔离”机制我们已经把巨大流量的请求隔离在秒杀环境中了,也就是说出现巨大流量时也不会影响到普通商品的业务。那么我们怎么保证秒杀环境中的高可用是我们需要考虑的问题。
方法有很多,通过流量控制、削峰、限流、缓存热点处理、扩容、熔断等措施。

2.2.1. 预约机制

image.png
预约期内,开放用户预约,获取秒杀抢购资格。秒杀期内具备抢购资格的用户真正开始秒杀。在预约期内,关键是锁定用户,这也是我们能够用来做流量管控的核心。

2.2.2. 流量削峰

秒杀的特点是商品数量少,购买用户多。这种供求关系的不平衡势必带来系统的流量暴增,这个流量对资源的消耗也是巨大的。
我们应该考虑在有限的硬件资源下,考虑怎么抵挡瞬间的流量暴增。我们可以在靠近用户最近的系统过滤掉无效请求,让真正可以下单的请求越少越好。本质上的目的就是如何让服务端处理变得平缓,节省服务器的资源成本。

2.2.2.1. 验证码

验证码的机制其实就是利用了每个用户在输入验证码的时间长短不同,让请求到后端的请求变得平缓。
举个例子:如果没有验证码机制,用户在同一秒点击“秒杀”按钮全部打到后端,如果我们引入验证码机制,每个用户在输入验证码的时间长短是不一样的,所以打到后端的请求不会是在同一秒,这样对于后端系统来说流量变得平缓

2.2.2.2. 消息队列

通过消息队列对业务解耦,改成异步调用,服务器根据自己的处理能力消费消息,让处理变得平缓。
举个例子:用户点击“抢购”按钮后,所有的请求先到消息队列,然后在异步的处理秒杀,页面上轮询的获取秒杀结果。

2.2.3. 限流

流量削峰在一定程度上解决了后端系统不同时接收大流量的请求,使得系统处理变得平滑。但这种方式降低了用户体验。为了解决用户体验的问题,我们还可以引入限流策略。
限流是系统自我保护的最佳手段,在牛逼的系统都有一个承载的上线,如果流量突破了这个上线,那么系统就会引起诸如宕机、雪崩等不可预估的问题带来灾难性的后果。

在实际秒杀系统中,从秒杀到支付完成,会经过很多系统的链路调用,整个链路中有很多系统在支撑这秒杀业务,比如:商品详情、风控、登录、限购、购物车、订单、支付等。
对于秒杀时的瞬间流量,如果不进行过滤筛选,直接把流量打到下游各系统,对整个秒杀链路各系统的挑战都是非常大的,也会造成很大的资源浪费。所以主流的做法是从靠近用户最近的地方开始,逐级限流,分层过滤。减少下游系统的压力。

限流的方式无外乎在网管层限流。

2.2.4. 降级

降级其实就是一种“弃卒保帅”的做法,在有限资源情况下确保秒杀核心系统的高可用,对一些非核心系统进行降级,减轻服务器负担。
降级服务是有损的,那么必然就会牺牲一些非核心系统,一般常见的降级方法:

  1. 写服务降级,牺牲数据一致性获取更高性能
  2. 读服务降级,故障场景下紧急降级快速止损
  3. 简化系统,舍去一些不必要的流程,舍弃非核心功能

2.4.4.1. 写服务降级

在多数据源的场景下,数据一致性一般是很难保证的(如:MySql和Redis数据一致性),除非引入分布式事务,但分布式事务会带来的缺点是实现复杂、性能问题,可靠性问题等,这些一般只在金融类的系统中才会使用强一致性分布式事务。
那么我们可以在流量不高的时候,数据先落入MySql数据库,然后监听BinLog日志把数据写入Redis缓存中,这种设计可以让MySQL和Redis的数据最终保证一致性,当流量上来的时候可以通过Redis扛更高的流量的读操作,但是写操作还是受限于MySQL数据库,我们一般建议一个数据库最大能支持3000~5000TPS的写操作。
image.png

通过上面的分析,我们直到在流量上来的时候,MySQL不足以扛住支撑大流量的写操作。那么我们可以对写MySQL进行降级,由先写入MySQL改成先写入缓存,然后异步写入数据库。让Redis来扛高流量的写操作,一般一个Redis分片可以抗住8~10万的OPS。
image.png

2.4.4.2. 读服务降级

在做高可用系统设计时,我们都会有个共识,就是微服务自身所依赖的外部中间件服务或者其他 RPC 服务,随时都可能发生故障,因此我们需要建设多级缓存,以便故障时能及时降级止损。
我们给 Redis 缓存之外,又增加了 ES 缓存。当然了,你可以建立多个缓存副本,比如主 Redis 缓存外,再建立副 Redis 缓存,或者再增加 ES 缓存,这些都可以的,不过相应会增加你的资源成本和代码编写的复杂度。
image.png
假设当秒杀的 Redis 缓存出现故障时,我们就可以通过降级开关,快速将读请求降级到 ES 上。或者当 Redis 和 ES 同时出现故障时(现实中很少出现同时故障的场景),我们还是可以通过降级开关将流量切换到数据库上,让数据库暂时承压来完成读请求服务。由此可见,在做高可用系统设计时,降级路径是多么的重要,它会是你关键时候的保命开关,让你在突发故障时有路可退。

2.4.4.3. 简化系统功能

我们以秒杀商品为例,在商品的详情页面,除了商品的基本信息外,还有很多附加的信息,比如你是否收藏过该商品、商品的收藏总数量、商品的排行榜、评价和推荐等楼层。同样,对于秒杀结算页,还会有礼品卡、优惠券等虚拟支付路径。
如果是普通商品,这些附加信息当然是越多越好,一方面体现了系统的完整性,另一方面也可以多渠道引流促进转化。但是在秒杀场景下,这些信息是否有必要就需要视情况而定了,秒杀系统要求尽量简单,交互越少,数据越小,链路越短,离用户越近,响应就越快,因此非核心的功能在秒杀场景下都是可以降级的,如下图红框所示。
image.png

2.3.5. 热点数据

所谓热点数据是指单个数据被访问的频次角度去看,在单位时间内(一般指1S),一个数据非常频繁的被访问,就可以被认为是热点数据。

问题:为什么热点数据不能水平扩展服务来解决呢?
我们的数据库都采用分库分表、Redis采用集群部署,通过水平扩展确实可以提高数据库和Redis的处理能力,但是我们的问题是一个数据(指一件商品)的热点问题,无论怎么水平扩展,这个数据只会落在一个分片上。

解决热点数据问题我们可以从两个方面来考虑,就是读热点和写热点。

2.3.5.1. 热点读数据

热点读数据解决思路可以增加热点数据的副本,让热点数据的副本多份,在大流量时把流量分散到各个副本上。
1112.png
方案一:
增加 Redis 从的副本数,然后业务层(Tomcat 集群)轮询查询不同的副本,提高同一数据的 QPS。一般情况下,单个 Redis 从,可提供 8~10 万的查询,所以如果我们增加 12 个副本,就可以提供百万 QPS 的热点查询。
image.png

方案二:
把热点数据再上移,在 Tomcat 集群做热点数据的本地缓存,也就是让业务层的每个实例里都有份数据副本,读请求数据的时候,无需去 Redis 获取,直接从本地缓存里取。这时候,数据的副本数和 Tomcat 实例一样多,另外请求链路减少了一层,而且也减少了对 Redis 单片 QPS 上限的依赖,具有更高的可靠性和更高的性能。
image.png
这种方式热点数据的副本数随实例的增加而增加,非常容易扩展,扛高流量。不过你要思考一个问题,本地缓存的数据延迟业务是否能够接受?如果能接受,本地缓存的时候可以设置几分钟?如果对延迟要求比较高,可以设置 1s,这样对 Redis 而言,OPS 的压力直接降低到实例数 / 每秒,就不需要那么多副本了。本地缓存的实现比较简单,可以用 HashMap、Ehcache,或者 Google 提供的 Guava 组件。

2.3.5.2. 热点写数据

以秒杀预约功能为例,在用户点击“立即预约”的时候,会往“预约人数”这个 Redis key 上进行 ++ 操作,当几百万人同时预约的时候,这个 key 就是热点写操作了。这个预约总人数有个特点,只是在前端给用户展示用,除此之外,没有其他用途,因此在高并发的场景下,这个人数可以不用那么及时和精确。知道了问题所在,解决方案就在眼前了,我们的思路就是先在 JVM 内存里 ++,延迟提交到 Redis,这样就可以把 Redis 的 OPS 降低几十倍。
123.png

2.3. 恶意请求

2.3.1. Token 机制

Token的在秒杀场景中的作用是防止恶意请求跳过一些步骤,直接访问靠后面的应用。对于有先后顺序的接口调用,我们要求进入下个接口之前,要在上个接口获得令牌,不然就认定为非法请求。同时这种方式也可以防止多端操作对数据的篡改。
1234.png
我们为了更真实地获取用户 ID,在登陆时,模拟将 user_id 放到 cookie,这样之后的每次请求,我们就直接从 cookie 中获取 user_id 即可。同时根据我们的设计,有几个主要参数是每次接口请求都必须要校验的,那就是用户 ID、产品编号,还有新的 Token,所以我们就定义了一个统一的解析方法,其中用户 ID 从 cookie 中解析。

2.3.2. 黑名单机制

黑名单机制分为本地黑名单和集群黑名单两种,我们会重点介绍本地黑名单。该机制顾名思义,就是通过黑名单的方式来拦截非法请求的,但我们的核心问题是黑名单从哪里来呢?
总体来说,有两个来源:一个是从外部导入,可以是风控,也可以是别的渠道。而另一个就是自力更生,自己生成自己用。黑名单就怎么捕获我们这里就不开展方案。
123456.png

2.3.3. 风控

想要更全面地对抗黑产,我们还需要引入另一个重要的机制,那就是风控。风控在秒杀业务流程中非常重要,但风控的建立却是非常困难的。成熟的风控体系需要建立在大量的数据之上,并且要通过复杂的实际业务场景考验,不断地做智能修正,才能逐步提高风险识别的准确率。像腾讯的风控,其依赖于庞大的微信、手 Q 生态体系的客户数据,日均调用量达 2000 亿次;京东的风控体系,涵盖零售、数科、物流、健康等线上线下多业务场景,跨多个领域且闭环;还有就是阿里的风控,相比京东,不仅有零售、数科、物流等,还有大文娱之类,场景更丰富。那么为什么场景越丰富,相对来说风控的准确率越高呢?这是因为风控的建设过程,其实就是一个不断完善用户画像的过程,而用户画像是建立风控的基础。一个用户画像的基础要素包括手机号、设备号、身份、IP、地址等,一些延展的信息还包括信贷记录、购物记录、履信记录、工作信息、社保信息等等。这些数据的收集,仅仅依靠单平台是无法做到的,这也是为什么风控的建立需要多平台、广业务、深覆盖,因为只有这样,才能够尽可能多地拿到用户数据。有了这些数据,所谓的风控,其实就是针对某个用户,在不同的业务场景下,检查用户画像中的某些数据,是否触碰了红线,或者是某几项综合数据,是否触碰了红线。而有了完善的用户画像,那些黑产用户,在风控的照妖镜下,自然也就无处遁形了。

2.4. 限购

限购的主要功能就是做商品的限制性购买。因为参加秒杀活动的商品都是爆品、稀缺品,所以为了让更多的用户参与进来,并让有限的投放量惠及到更多的人,所以往往会对商品的售卖做限制,一般限制的维度主要包括两方面。

2.4.1. 商品维度限制

最基本的限制就是商品活动库存的限制,即每次参加秒杀活动的商品投放量。如果再细分,还可以支持针对不同地区做投放的场景,比如我只想在北京、上海、广州、深圳这些一线城市投放,那么就只有收货地址是这些城市的用户才能参与抢购,而且各地区库存量是隔离的,互不影响。

2.4.2. 个人维度限制

个人维度来做限制,这里不单单指同一用户 ID,还会从同一手机号、同一收货地址、同一设备 IP 等维度来做限制。比如限制同一手机号每天只能下 1 单,每单只能购买 1 件,并且一个月内只能购买 2 件等。个人维度的限购,体现了秒杀的公平性。
有了这些功能支持之后,再做一个热门秒杀活动时,首先会在限购系统中配置活动库存以及各种个人维度的限购策略。然后在用户秒杀时,走下限购系统,通过限购的请求,再去做真实库存的扣减,这个时候到库存系统的流量已经是非常小了。
123456.png

2.5. 超卖问题

用户成功购买一个商品,对应的库存就要完成相应的扣减。而库存的扣减主要涉及到两个核心操作,一个是查询商品库存,另一个是在活动库存充足的情况下,做对应数量的扣减。两个操作拆分开来,都是非常简单的操作,但是在高并发场景下,就会出现库存原子性问题。
举例:现在活动商品有 2 件库存,此时有两个并发请求过来,其中请求 A 要抢购 1 件,请求 B 要抢购 2 件,然后大家都去调用活动查询接口,发现库存都够,紧接着就都去调用对应的库存扣减接口,这个时候,两个都会扣减成功,但库存却变成了-1,也就是出现了商品超卖。
引起超卖的原因是因为查询和扣减库存不是原子操作,另外一个是并发引起的请求无序。
image.png
那么我们要解决商品的超卖问题就需要解决库存查询和扣减的原子性问题,并发引起的无序问题。
image.png
秒杀商品的库存我们都是放在Redis中的,所以解决这个问题我们只需要保证Redis中库存扣减的原子性问题。可以通过Lua脚本或者Redis的信号量来解决。怎么解决会在【秒杀】项目实战中给出解决方案,我们这里只需要先了解理论知识就好。

2.6. 资源静态化

将展示给用户的页面静态化,如:商品名称、图片等。减少对服务器端的请求。
通过CND(内容分发网络)就近访问活动页面,用户一般分布在全国各地,如果应用只部署在一个机房,那离机房远的地方在网络传输上会不同,所以我们通过CND将用户的请求转发到最近的机房节点上来保证用户的网络请求的流畅程度

3. 总结

秒杀的主要挑战在于:

  1. 高并发产生的巨大瞬时流量。秒杀活动的特点,就是将用户全部集中到同一个时刻,然后一起开抢某个热门商品,而热门商品的库存往往又非常少,因此聚集效应产生了巨大的瞬时流量。
  2. 高并发无法避开的热点数据问题。秒杀活动大家抢购的都是同一个商品,所以这个商品直接就被推到了热点的位置。
  3. 来自黑产的刷子流量。刷子高频次的请求,会挤占正常用户的抢购通道,也获得了更高的秒杀成功率。这不仅破坏了公平的抢购环境,也给系统服务带来了巨大的额外负担。

从技术层面介绍了 HTTP 服务的请求链路路径。我们讨论了将秒杀系统提供的业务功能,按不同阶段、不同响应,合理地拆分到不同的链路层级来实现,以符合我们校验前置、分层过滤、缩短链路的设计原则,并能够从容应对秒杀系统所面临的瞬时大流量、热点数据、黄牛刷子等各种挑战。