12.秒杀系统


秒杀系统的挑战:巨大的瞬时流量、热点数据问题、刷子流量

  1. CDN
  2. 将Nginx 的职责放大,前置用来做Web 网关,承担部分业务逻辑校验,并且可能增加黑白名单、限流和流控的功能
    • 刷子:无效:正常请求 = 6:1:3

秒杀业务流程梳理

1.运营人员在秒杀系统的运营后台,根据指定商品,创建秒杀活动,指定活动的开始时间、结束时间、活动库存等。
2.活动开始之前,由秒杀系统运营后台开启秒杀,会同时往商城系统的RedisCluster 集群写入首页秒杀活动信息和往秒杀系统的Redis主从集群写诸如秒杀商品库存等信息。
3.用户进入到秒杀商详页准备秒杀。
4.商详页可以看到立即抢购的按钮,这里我们可以通过增加一些逻辑判断来限制按钮是否可以点击,比如是否设置了抢购用户等级限制,是否还有活动库存,是否设置了预约等等。如果都没限制,用户可以点击抢购按钮,进入到秒杀结算页。
5.在结算页,用户可更改购买数量,切换地址、支付方式等,这里的结算元素也需要按实际业务来定,更复杂的场景还可以支持积分、优惠券、红包、配送时效等,并且这些都会影响最终价格的计算。
6.确认无误后,用户提交订单,在这里后端服务可以调用风控、限购等接口,来完善校验,都通过之后,完成库存的扣减和订单的生成。
7.订单完成后,根据用户选择的支付方式跳转到对应的页面,比如在线支付就跳转到收银台,货到付款的话,就跳到下单成功提示页。

秒杀的隔离

因为秒杀流量是突发式的,而且流量规模很难提前准确预估,如果混合在一起,势必会对普通商品的交易造成比较大的冲击。需要单独搭建秒杀系统,它天然为流量而生。

业务隔离

大部分的电商平台,会有一个专门的提报系统(提报系统的建设不是秒杀的核心部分,我们系统没有实现),商家或者业务可以根据自己的运营计划在提报系统里进行活动提报,提供参与秒杀的商品编号、活动起止时间、库存量、限购规则、风控规则以及参与活动群体的地域分布、预计人数、会员级别等基本信息。电商平台的提报过程和这些基本信息,对于大厂是比较重要的,有了这些信息作为输入,技术部门就能预估出大致的流量、并发数等,并结合系统当前能支撑的容量情况,评估是否需要扩容,是否需要降级或者调整限流策略等,因此业务隔离重要性也很高。

系统隔离

比较常见的实践是对会被流量冲击比较大的核心系统进行物理隔离,而相对链路末端的一些系统,经过前面的削峰之后,流量比较可控了,这些系统就可以不做物理隔离。
用户的秒杀一定是首先进入商品详情页(很多电商的秒杀系统还会在商详页进行倒计时等待,时间到了点击秒杀按钮进行抢购)。因此第一个需要关注的系统就是商品详情页,我们需要申请独立的秒杀详情页域名,独立的 Nginx 负载均衡器,以及独立的详情页后端服务
如有可能,还需要对域名进行隔离,可以申请一个独立的域名,专门用来承接秒杀流量,流量从专有域名进来之后,分配到专有的负载均衡器,再路由到专门的微服务分组,这样就做到了应用服务层面从入口到微服务的流量隔离。
一般来说,秒杀中流量冲击比较大的核心系统就是秒杀详情页、秒杀结算页、秒杀下单库存扣减是需要我们重点关注的对象,而相对链路末端的一些系统,经过前面的削峰之后,流量比较可控了,如收银台、支付系统,物理隔离的意义就不大,反而会增加成本。

数据隔离

数据层的专有部署,需要结合秒杀的场景来设计部署拓扑结构,比如 Redis缓存,一般的场景一主一从就够了,但是在秒杀场景,需要一主多从来扛读热点数据

Nginx(OpenResty)

Nginx 有5 大优点,即模块化、事件驱动、异步、非阻塞、多进程单线程。

OpenResty 是一个基于 Nginx 与Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。快速构造出足以胜任 10K 乃至 1000K 以上单机并发连接的高性能Web 应用系统。Lua的线程模型是单线程多协程的模式,而Nginx 刚好是单进程单线程

原理

Nginx 服务器启动后,产生一个 Master 进程(Master Process),Master 进程执行一系列工作后产生一个或者多个 Worker 进程(Worker Processes)。其中,**Master 进程用于接收来自外界的信号,并向各 Worker 进程发送信号,同时监控Worker 进程的工作状态。**当 Worker 进程退出后(异常情况下),Master 进程也会自动重新启动新的 Worker 进程。Worker 进程则是外部请求真正的处理者。
多个 Worker 进程之间是对等的,他们同等竞争来自客户端的请求,各进程互相之间是独立的。一个请求,只可能在一个 Worker 进程中处理,一个 Worker 进程不可能处理其它进程的请求。Worker 进程的个数是可以设置的,一般我们会设置与机器CPU 核数一致。同时,Nginx 为了更好的利用多核特性,具有 CPU 绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来cache 的失效(CPU affinity)。所有的进程的都是单线程(即只有一个主线程)的,进程之间通信主要是通过共享内存机制实现的。

OpenResty 本质上是将LuaJIT 的虚拟机嵌入到Nginx 的管理进程和工作进程中,同一个进程内的所有协程都会共享这个虚拟机,并在虚拟机中执行Lua 代码。在性能上,OpenResty 接近或超过 Nginx 的C 模块,而且开发效率更高。
Nginx 将HTTP 请求的处理过程划分为多个阶段。这样可以使一个HTTP 请求的处理过程由很多模块参与处理,每个模块只专注于一个独立而简单的功能处理,可以使性能更好、更稳定,同时拥有更好的扩展性。

  1. ngx_http_post_read_phase:接收到完整的http 头部后处理的阶段,它位于uri 重写之前。
  2. ngx_http_server_rewrite_phase:uri 与location 匹配前,修改uri 的阶段,用于重定向。
  3. ngx_http_find_config_phase:根据uri 寻找匹配的location 块配置项阶段,该阶段使用重写之后的uri 来查找对应的location,值得注意的是该阶段可能会被执行多次,因为也可能有location 级别的重写指令。
  4. ngx_http_rewrite_phase:上一阶段找到location 块后再修改uri,location 级别的uri 重写阶段,该阶段执行location 基本的重写指令,也可能会被执行多次。
  5. ngx_http_post_rewrite_phase:防止重写url 后导致的死循环,location 级别重写的后一阶段,用来检查上阶段是否有uri 重写,并根据结果跳转到合适的阶段。
  6. ngx_http_preaccess_phase:下一阶段之前的准备,访问权限控制的前一阶段,该阶段在权限控制阶段之前,一般也用于访问控制,比如限制访问频率,链接数等。
  7. ngx_http_access_phase:让http 模块判断是否允许这个请求进入nginx 服务器,访问权限控制阶段,比如基于ip 黑白名单的权限控制,基于用户名密码的权限控制等。标准模块 ngx_access、第三方模块 ngx_auth_request 以及第三方模块ngx_lua 的 access_by_lua 指令就运行在这个阶段。
  8. ngx_http_post_access_phase:访问权限控制的后一阶段,该阶段根据权限控制阶段的执行结果进行相应处理。
  9. ngx_http_try_files_phase:为访问静态文件资源而设置,try_files 指令的处理阶段,如果没有配置try_files 指令,则该阶段被跳过。
  10. ngx_http_content_phase:处理http 请求内容的阶段,大部分http 模块介入这个阶段,内容生成阶段,该阶段产生响应,并发送到客户端。Nginx 的 content 阶段是所有请求处理阶段中最为重要的一个,因为运行在这个阶段的配置指令一般都肩负着生成“内容”(content)并输出HTTP 响应的使命。
  11. ngx_http_log_phase:log 阶段处理,比如记录访问量/统计平均响应时间。log_by_lua处理完请求后的日志记录阶段,该阶段记录访问日志。

以上11个阶段中,http 无法介入的阶段有4 个:3)ngx_http_find_config_phase、5)ngx_http_post_rewrite_phase、8)ngx_http_post_access_phase、9)ngx_http_try_files_phase。

OpenResty 在HTTP 处理阶段基础上分别在Rewrite/Access 阶段、Content 阶段、Log 阶段注册了自己的handler,加上系统初始阶段master 的两个阶段,共11 个阶段为Lua 脚本提供处理介入的能力。

  1. init_by_lua*:Master 进程加载Nginx 配置文件时运行,一般用来注册全局变量或者预加载Lua 模块。
  2. init_worker_by_lua*:每个worker 进程启动时执行,通常用于定时拉取配置/数据或者进行后端服务的健康检查。
  3. set_by_lua*:变量初始化。
  4. rewrite_by_lua*:可以实现复杂的转发、重定向逻辑。
  5. access_by_lua*:IP 准入、接口权限等情况集中处理。
  6. content_by_lua*:内容处理器,接收请求处理并输出响应。
  7. header_filter_by_lua*:响应头部或者cookie 处理。
  8. body_filter_by_lua*:对响应数据进行过滤,如截断或者替换。
  9. log_by_lua*:会话完成后,本地异步完成日志记录

秒杀系统中的OpenResty

所以我们秒杀的OpenResty 就不仅仅承担着反向代理和负载均衡的职能。还承担着网关、静态模板化网页访问、静态资源访问、流量管控、防刷等一系列职能。这些都需要我们使用Lua 脚本来完成,配置一下对Lua 脚本的支持

商详页的静态化

首先当然是生成静态html,我们使用freemarker 来处理,将通用的产品模板文件product.ftl 变为确定的商品Html
命名规则为seckill_+秒杀活动id + “_” + 秒杀产品ID,如 seckill_1_3.html,并保存在本地磁盘。
然后将产生的商品Html 上传到Nginx 服务器,用 jsch 组件
利用sftp 进行上传,注意,因为秒杀的Nginx服务器可能有多台,所以要循环服务器列表依次上传。

OpenResty中的处理

还需要把访问链接和实际的Html 页面进行对应,而且在把页面作为结果返回给用户之前,我们可能还有一些其他的事情要做,这就需要在Nginx 中再将已经生成的seckill_1_3.html 作为模板文件再处理一次。于是我们引入第三方的模板文件处理Lua 脚本,并放置到Nginx 服务器Lua 库目录下
当用户访问秒杀的商详页时,还需要告诉Nginx 返回给用户的网页内容需要由product.lua 进行处理

商详页的库存获取

用户访问秒杀的商详页,我们用静态网页展示给了用户,但是有些数据还是需要动态获取的,比如秒杀商品的库存。

  1. 直接让Nginx 访问Redis 来获得商品的当前库存
  2. 让Redis 的从库和Nginx在部署在同一台服务器
    1. 如果本地Redis 宕机的情况,需要我们回源到微服务中查询主Redis 或者数据库
  3. 还可以直接使用Unix Domain Socket 来避免真实的网络通讯实现下占用网络连接、并且需要经过网络协议栈,需要打包拆包、计算校验和、维护序号和应答等TCPIP 协议固有要求,进一步提高访问效率。

秒杀前期流量管控

通过对秒杀流量的隔离,我们已经能够把巨大瞬时流量的影响范围控制在隔离的秒杀环境里了。接下来,我们开始考虑隔离环境的高可用问题,通俗点说,普通商品交易流程保住了,现在就要看怎么把秒杀系统搞稳定,来应对流量冲击,让秒杀系统也不出问题。方法很多,有流量控制、削峰、限流、缓存热点处理、扩容、熔断等一系列措施。

先来看流量控制。在库存有限的情况下,过多的用户参与实际上对电商平台的价值是边际递减的。举个例子,1 万的荣耀手机,100 万用户进来秒杀和1000万用户进来秒杀,对电商平台而言,所带来的经济效益、社会影响不会有10 倍的差距。相反,用户越多,一方面消耗机器资源越多;另一方面,越多的人抢不到商品,电商平台的客诉和舆情压力也就越大。当然如果为了满足用户,让所有用户都能参与,秒杀系统也可以通过堆机器扩容来实现,但是成本太高,ROI 不划算,所以我们需要,也可以提前对流量进行管控。

一般来说,很多电商平台,特别是头部电商很多时候会用“预约+秒杀”作为主流营销玩法。预约期内,开放用户预约,获取秒杀抢购资格,秒杀期内,具备抢购资格的用户真正开始秒杀。在预约期内,关键是锁定用户,这也是做前期流量管控的核心。

预约系统设计

先从角色看,参与的有运营方,提供商品,进行预约活动的计划安排;终端用户,进行预约和秒杀行为;以及支撑预约活动的交易链路系统。

  1. 需要一个预约管理后台,进行活动的设置和关闭;
  2. 需要一个预约系统向预约过的用户发短信或消息提醒;
  3. 需要一个面向终端的预约核心微服务,提供给用户预约和取消预约能力;
  4. 商详在展示时获取预约信息的能力,比如当前商品是否预约,当前预约人数等等;
  5. 秒杀下单时检查用户预约资格的能力。

所以在数据库层面,对预约来讲,核心就是两个维度:预约活动和用户预约关系。所以需要两张表,一张是预约活动信息表,记录预约活动本身的信息,比如预约活动的开始结束时间,预约活动对应的秒杀活动信息,预约的商品信息等等;另一张是用户预约关系表,比如用户的ID,预约的活动ID,预约的商品等等。

预约系统优化

传统的预约模式,预约期是固定的时间段,用户在这个阶段内都可以预约;但在秒杀场景下,为了能够准确把控流量,控制预约人数上限,我们需要拓展预约期的定义,除了时间维度外,还要加入预约人数上限的维度,一旦达到上限,预约期就即时结束。
这实际上是给预约活动添加了一个自动熔断的功能,一旦活动太火爆,到达上限后系统自动关闭预约入口,提前进入等待秒杀状态。这样就可以准确把控人数,从而为秒杀期护航。但是当用户都知道必须有预约才能参加秒杀时,用户就会在预约期抢占预约资格,那么此时的预约系统也具备一定程度秒杀系统的特点了。不过预约人数的把控不需要那么精确,只需要即时熔断即可,比如准备预约人数为100 万,实际105 万或者110 万都没有什么问题。对于头部电商平台,每次预约人数都可以达到千万量级的,因此为了更好的性能,往往还需要对数据库分库分表,主要是用户预约关系表。另外,对于预约历史数据,也需要有个定时任务进行结转归档,以减轻数据库的压力。但是仅仅分库分表还是不够的,对高并发系统来说,要扛住大流量,肯定不能让流量击穿到数据库,所以需要设计缓存来抵挡。首先是预约活动信息表,这是个很明显的读热点,所有的预约商品展示的时候都需要这份数据,很自然我们可以将数据在 Redis 缓存里存储,如果 Redis 缓存也扛不住,可以使用Redis 一主多从来扛,也可以使用服务的本地缓存。对于用户预约关系表,是跟着用户走的,没有读热点问题,只要用户登录或者合适的时机将该用户的本次预约关系加载到Redis 缓存即可,在预约商品展示时从Redis 读取然后告诉用户是否已经预约。用户进行预约的时候怎么办呢?虽然用户预约关系表可以做分库分表,本身又是个纯粹的insert 操作,MySQL 执行相对来说速度较快,但是要考虑某些热门商品会短时间挤入大量的用户,这个时候可以考虑使用消息中间件异步写入,做好消息的防重防丢失,同时前端提醒用户“预约排队中”。
另外,一般预约系统在业务设计上,需要在商详页展示当前预约人数给用户看,以营造商品火爆的气氛。我们自然就想到了可以在Redis 里记录一个预约人数的记录。商详页展示氛围的时候,会从Redis 里获取到这个记录进行提示,而用户点击“立即预约”按钮进行预约时,会往这个key 进行累加操作。这个设计在预约流量没那么聚集时没什么问题,因为一般 Redis 单片也能扛个七八万的OPS。而当预约期每秒中十几万,甚至几十万预约呢?显然这个Redis key 就是典型的写热 key 问题了。考虑到这个预约人数并不需要非常精确,这个热key 问题的解决我们可以考虑在本地缓存中累加,然后批量的方式写入Redis,比如累加了1000 个人后一次性在Redis 中incr 1000,这样就把对Redis 的写压力降低了1000 倍。通过预约来控制流量属于事前管控

秒杀的事中流量管控

流量削峰

我们已经知道了秒杀有隔离和事前流量控制,其目的是降低流量的相互耦合和量级,减少对系统的冲击。秒杀系统事中流量管控——削峰和限流让系统更加稳健。
真实场景下的秒杀流量一般几秒内爬升到峰值,然后很快往平常值回归。我们现在需要做的就是通过削峰和限流,把这超大的瞬时流量平稳地承接下来,落到秒杀系统里。
削峰填谷概念一开始出现在电力行业,是调整用电负荷的一种措施,在互联网分布式高可用架构的演进过程中,也经常会采用类似的削峰填谷手段来构建稳定的系统。
削峰的方法有很多,可以分为无损和有损削峰。本质上,限流是一种有损技术削峰;而引入验证码、问答题以及异步化消息队列可以归为无损削峰,不过我们习惯上会把限流和削峰分开来说,所以我们这里也分开阐述。

我们已经知道秒杀的业务特点是库存少,最终能够抢到商品的人数取决于库存数量,而参与秒杀的人越多,并发数就越高,随之无效请求也就越多。在秒杀开始的时刻,会出现巨大的瞬时流量,这个流量对资源的消耗也是巨大且瞬时的。
我们支撑秒杀系统的硬件资源是一定是有限的,它的处理能力也是恒定的,当有秒杀活动的时候,很容易繁忙导致请求处理不过来,而没有活动的时候,机器又是低负载运转。但是为了保证用户的秒杀体验,一般情况下我们的处理资源只能按照忙的时候来预估,这会导致资源的一个浪费。因此我们需要设计一些规则,延缓并发请求,甚至过滤掉无效的请求,让真正可以下单的请求越少越好。总结来说,削峰的本质,一是让服务端处理变得更加平稳,二是节省服务器的机器成本。

验证码和问答题

在秒杀交易流程中,引入验证码和问答题,有两个目的:一是快速拦截掉部分刷子流量,防止机器作弊,起到防刷的作用;二是平滑秒杀的毛刺请求,延缓并发,对流量进行削峰。 让用户在秒杀前输入验证码或者做问答题,不同用户的手速有快有慢,这就起到了让1s 的瞬时流量平均到30s 甚至1 分钟的平滑流量中,这样就不需要堆积过多的机器应对1s 的瞬时流量了。

从session 共享角度来说,验证码应该放入Redis
还需注意的是验证码放入主Redis 后,如果选择从Nginx 直接读取从Redis 的方式,需要注意Redis 主从同步的延迟问题,解决方案可以在Lua 脚本中引入以下两者之一:1. 休眠后重试”os.execute("sleep " … n)”;读从Redis 未果,则读主Redis

当然,验证码本身也可以独立为一个微服务,因为当生成验证码本身成为性能瓶颈,可以验证码服务集群化或者预生成批量验证码并缓存,但是缓存的内容除了验证码的文字结果外,验证图片也要缓存。很多大厂就有独立的验证码服务集群,这个时候直接调用即可。

消息队列

如果服务A 的流量非常高(假设10 万QPS),远远大于服务B 所能支持的能力(假设1 万QPS),那么服务B 的CPU 很快就会升高,TP99 也随之变高,最终服务B 被服务A 的流量冲垮。这个时候,消息队列就派上用场了,我们把一步调用的直接紧耦合方式,通过消息队列改造成两步异步调用,让超过服务B 范围的流量,暂存在消息队列里,由B 根据自己的服务能力来决定处理快慢,这就是通过消息队列进行调用解耦的常见手段。

而在秒杀系统拉取消息队列进行处理的时候,也有个小技巧,那就是当前面的请求已经把库存消耗光之后,在缓存里设置占位符,让后续的请求快速失败,从而最快地进行响应。

限流

主流的做法是从上游开始,对流量进行逐级限流,分层过滤,优质的有效的流量最终才能参与下单。

通过一系列的逐级限流,分层过滤,比如风控和防刷筛选刷子流量,通过限购和预约校验过滤无效流量,通过限流丢弃多余流量,最终秒杀系统给到下游的流量就是非常优质且少量的了。限流常用的算法有令牌桶和漏桶

Nginx 限流

Nginx 本身也提供了非常强大的限流功能,比如有两个专门的限流模块HttpLimitzone 和HttpLimitReqest,HttpLimitzone 用来限制一个客户端的并发连接数,HttpLimitReqest 通过漏桶算法来限制用户的连接频率,我们用HttpLimitReqest来说明如何限流。

http { 
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; 
    server {
        location /search/ {
            limit_req zone=one burst=2 nodelay; 
        }
    }
}
  • limit_req_zone 是指令名称,也就是关键字,只能在http 块中使用;
  • $binary_remote_addr 是Nginx 内置绑定变量,比如$remote_port 是客户端端口号;
  • zone=one:10m zone 是关键字,one 是自定义的规则名称,后续代码中可以指定使用哪个规则;10m 是指声明多大内存来支撑限流的功能,从理论上说一个1MB 的区域可以包含大约16000 个会话状态;
  • rate=1r/s rate 是关键字,可以指定限流的阈值,r/s 意为每秒允许通过的请求数,我们这里就限定了每秒1 请求。

再看两个实际例子:

  • limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s;

    • 表示同一ip 不同请求地址,进入名为one 的zone,限制速率为5 请求/秒。
  • limit_req_zone $binary_remote_addr $uri zone=two:10m rate=1r/s;

    • 同一ip 同一请求地址,进入名为two 的zone,限制速率为1 请求/秒。
  • limit_req 是指令名称,可在http,server,location 块中使用,这个指令主要用于设置共享的内存zone 和最大的突发请求大小;

  • zone=one 使用名为one 的zone,这个zone 之前使用limit_req_zone 声明过;

  • burst=2 burst 用于指定最大突发请求数,超过这个数目的请求会被延时;

  • nodelay 设置了nodelay,在突发请求数大于burst 时,会立刻丢弃掉这部分请求,一般情况下会给客户端返回503 状态。

在秒杀的场景下,一般会把 rate 和 burst 设置的很低,可以都为 1,即要求1 个IP 1 秒内只能访问1 次
这种设置一般对公司用户不太友好,公司内用户,他们的出口 IP 就那么几个,很容易就触发了限流,所以大家在参与头部电商的秒杀活动时,最好切换到自己的手机网络,避免被“误杀”。

应用/服务层限流

  • 线程池限流: 配置最大连接数,以请求处理队列长度以及拒绝策略等参数来达到限流的目的。当处理队列满,而且最大线程都在处理时,多余的请求就会被拒绝策略丢弃,也就是被限流了。
  • API 限流: 我们希望根据QPS多少来进行限流,这时就不能用线程池策略了但是可以用Google 提供的 RateLimiter 开源包,自己手写一个基于令牌桶的限流注解和实现,在业务API 代码里使用。
    • 现在大家用的Sentinel 流量治理组件会比较多,可以从从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助保障微服务的稳定性
  • 自定义限流
    • 在用户进入订单结算页面时,前端页面会先调用生成订单号的服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号
    • 如果每个用户的请求都去申请一个订单号,在秒杀高并发的情况下是无法应对的
    • 用一个线程安全的ConcurrentLinkedQueue 预先存放一批订单ID,这样的话订单的ID 无需去远程获取了。ConcurrentLinkedQueue 中订单号的刷新则是通过定时任务刷新。
    • 目前设定是100 毫秒刷新一次,1 秒钟最多从生成订单号的服务获得2000个订单ID,以常数的形式的写死在代码中的,这两个值其实可以写入配置中心进行热部署,方便秒杀根据实际情况来调整。而从生成订单号的服务获得批量订单ID 数,则是通过公式计算出来的,按照缺省值ConcurrentLinkedQueue 每100 毫秒最多有200 个订单ID,这其实就起了一个限流的作用,因为在从ConcurrentLinkedQueue 获得订单ID 的时候,如果没有获取到,会直接返回中断用户的请求处理,返回一个处理失败。

分层过滤

仔细考察秒杀的流量特征,比如某个秒杀商品1000 个,秒杀时间为5 分钟,现在有10 万人来抢,2 分钟内商品抢购完毕,那么后面3 分钟其实商品已经无库存了。但是对后面3 分钟的人发出的请求对于我们系统来说,其实是无效的请求,是没有必要把请求链路全部完成一遍的,这对资源其实是很大的浪费,所以我们可以在请求链路上层层过滤,把这部分无效请求提前筛选掉。

  • Nginx 中,启用了本地缓存
  • 与之相配合的,商详页中会根据这个返回值提示用户“秒杀商品已无库存,秒杀结束”,并关闭秒杀按钮。
  • 在服务层,不管是秒杀确认单处理服务还是秒杀订单处理服务都会对库存进行检查
    • 作用就是在实际下单和扣减库存前中断用户的请求链路的执行,起到一个层层过滤的作用

限购

对于像秒杀这种大流量、高并发的业务场景,更不适合直接将全部流量打到库存服务,所以这个时候就需要有个系统能够承接大流量,并且只放和商品库存相匹配的请求量到库存服务,而限购就能够承担这样的角色。限购之于库存,就像秒杀之于下单,前者都是后者的过滤网和保护伞。
限购的主要功能就是做商品的限制性购买。因为参加秒杀活动的商品都是爆品、稀缺品,所以为了让更多的用户参与进来,并让有限的投放量惠及到更多的人,所以往往会对商品的售卖做限制,一般限制的维度主要包括两方面。
商品维度限制:最基本的限制就是商品活动库存的限制,即每次参加秒杀活动的商品投放量。如果再细分,还可以支持针对不同地区做投放的场景,比如我只想在北京、上海、广州、深圳这些一线城市投放,那么就只有收货地址是这些城市的用户才能参与抢购,而且各地区库存量是隔离的,互不影响。
个人维度限制:就是以个人维度来做限制,这里不单单指同一用户ID,还会从同一手机号、同一收货地址、同一设备IP 等维度来做限制。比如限制同一手机号每天只能下1 单,每单只能购买1 件,并且一个月内只能购买2 件等。个人维度的限购,体现了秒杀的公平性。
有了这些功能支持之后,再做一个热门秒杀活动时,首先会在限购系统中配置活动库存以及各种个人维度的限购策略;然后在用户提单时,走下限购系统,通过限购的请求,再去做真实库存的扣减,这个时候可以减少到库存服务的量。

库存扣减

用户成功购买一个商品,对应的库存就要完成相应的扣减。而库存的扣减主要涉及到两个核心操作,**一个是查询商品库存,另一个是在活动库存充足的情况下,做对应数量的扣减。**两个操作拆分开来,都是非常简单的操作,但是在高并发场景下,不好的事情就发生了。

库存超卖的问题主要是由两个原因引起的,一个是查询和扣减不是原子操作,另一个是并发引起的请求无序

数据库方案

  • 行锁机制
    1. 查询和扣减放在一个事务中,在查询库存的时候使用for update,事务结束行锁释放。
    2. 通过SQL 语句,比如where 语句的条件,保证库存不会被减到 0 以下
  • 乐观锁
    1. 每次查询库存的时候,除了库存值还有一个版本号,每次扣减库存时带上这个版本号进行扣减
    2. 扣减失败,则需要重新查询,重新扣减。但会加重数据库的负担
  • 数据库特性
    1. 直接设置数据库的字段数据为无符号整数
    2. 这样减后库存字段值小于零时会直接执行SQL 语句来报错

数据库方案简单安全,但是其性能比较差,无法适用于我们秒杀业务场景,在请求量比较小的业务场景下,是可以考虑的

分布式锁方案

不管通过哪种方式实现的分布式锁,都是有弊端的。以Redis 的实现来说,仅仅在设置锁的有效期问题上,就让人头大。如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这与我们要实现高性能的秒杀系统是相悖的。所以通过分布式锁的方式可以实现,但不建议使用

高并发的扣减

当秒杀活动开启,流量洪峰来临时,交易系统压力陡增,具体表现一般会包括CPU 升高,IО 等待变长,请求响应时间TP99 指标变差,整个系统变得越来越不稳定。为了力保核心交易流程,我们需要对非核心的一些服务进行降级,减轻系统负担,这种降级一般是有损的,属于“弃卒保帅”。而秒杀的核心问题,是要解决单个商品的高并发读和高并发写的问题,这是典型的热点数据问题,我们需要有相应的机制,避免热点数据打垮系统。

降级

降级其实和削峰一样,降级解决的也是有限的机器资源和超大的流量需求之间的矛盾。如果你的资源够多,或者你的流量不够大,就不需要对系统进行降级了;只有当资源和流量的矛盾突出时,我们才需要考虑系统的降级。降级一般是有损的,那么必然要有所牺牲,几种常见的降级:

  • 写服务降级:牺牲数据一致性获取更高的性能;
  • 读服务降级:故障场景下紧急降级快速止损。

写服务降级

在多数据源(MySQL 和Redis)的场景下,数据一致性一般是很难保证的。除非引入分布式事务,但分布式事务也会带来一些缺点,比如实现复杂、性能问题、可靠性问题等。因此一般在涉及金融资产类对一致性要求高的场景时,我们才会考虑分布式事务。

在流量不高的时候,我们的写请求可以直接先落入MySQL 数据库,再通过监听数据库的Binlog 变化,把数据更新进Redis 缓存,这种设计,缓存和数据库是最终一致的。通过缓存,我们可以扛更高流量的读操作,但是写操作仍然受制于数据库的磁盘IOPS,一般考虑一个数据库也就能支持3000~5000 TPS 的写操作。

当流量激增的时候,我们就需要对以上的写路径进行降级,由同步写数据库降级成同步写缓存、异步写数据库,利用Redis 强大的OPS 来扛流量,一般单个Redis 分片可达8~10 万的OPS,Redis 集群的OPS 就更高了。

写请求首先直接写入Redis 缓存,写入成功之后,发出写操作MQ(这一步可以放入另一个线程中操作),就可以返回客户端了。其他应用消费MQ,通过MQ 异步化写数据库。

商城库存扣减的实现

这里根本没检查库存是否足够,是会导致超卖的。要知道,秒杀是一种促销活动,为了吸引更多的人气,更多的流量,是“赔本赚吆喝”,宁可少买,不可超卖! 少买还可以再做一次“返场”活的,超卖肯定是不行的。
要保证不超卖,查询和扣减需要是原子操作,正好Redis 本身就是单线程的,天生就可以支持操作的顺序性,如果我们能在一次Redis 的执行中,同时包含查询和扣减两个命令就行。而且Redis 可以执行Lua 脚本的,并且可以保证脚本中的所有逻辑会在一次执行中按顺序完成。

我们通过Redis 的高并发写能力,提升了系统性能,带来的牺牲就是缓存数据和数据库数据的一致性问题。为了追求高性能,牺牲一致性在大厂的设计中比较常见,对于异步造成的数据丢失等一致性问题,一般来说还会有定时任务一直在比对,以便最快发现问题,进行修复。

读服务降级

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

简化系统功能

简化系统功能就是指干掉一些不必要的流程,舍弃非核心功能,秒杀系统要求尽量简单,交互越少,数据越小,链路越短,离用户越近,响应就越快,因此非核心的功能在秒杀场景下都是可以降级的。

去除了普通商品详情页的很多信息,以加快商详页的显示,节约系统资源。不过,实际运用中,这种非核心功能的有损降级,要视具体的SKU 而定,一般为了降低影响范围,我们只对流量非常高的SKU 进行降级。比如,如果是手机秒杀,一般是不需要降级的,但是像口罩这样的爆品,就需要针对SKU 维度进行非核心功能的降级了。降级开关的怎么设计呢,其实比较简单,核心思路就是通过配置中心,对降级开关进行变更,然后推送到各个微服务实例上。

热点数据

一般高并发的常规解决思路是:如果是数据库,可以通过分库分表来应对,如果是Redis,可以增加Redis 集群的分片来解决,而应用层一般是无状态的设计。所以从数据库、Redis '缓存到应用服务,都是可以通过增加机器来水平扩展服务能力,解决高并发的问题。

这样就能应对秒杀的挑战了吗?其实还不够,秒杀的核心问题是要解决单个商品的高并发读和高并发写问题,也就是要处理好热点数据问题。所谓热点数据,是从单个数据被访问的频次角度去看的。单位时间(1s)内,一个数据非常频繁的被访问,就可以称之为热点数据,反之可以归为一般数据或冷数据。那么单位时间内究竟多高的频次才能称为热点数据呢?实际上并没有一个明确的定义,可以根据你自己的系统吞吐能力而定。**热点商品在进行秒杀时,只有这个SKU 是热点,所以再怎么进行分库分表,或者增加Redis 集群的分片数,热点商品SKU 落在的那个分片的能力实际并没有提升,总会触达上限,把 Redis 打挂,最后可能引发缓存击穿、系统雪崩。**那我们应该怎么解决这个棘手的热点问题呢?

读热点

  1. 增加热点数据的副本数;
    • 就是增加Redis 从的副本数,然后业务层(Tomcat 集群)轮询查询不同的副本,提高同一数据的QPS。一般情况下,单个Redis 从,可提供8~10 万的查询,所以如果我们增加12 个副本,就可以提供百万QPS 的热点查询。这个方法能解决热点问题,但成本比较高,如果你的集群分片数比较多,那分片数*副本数就是一笔不小的开销。
  2. 让热点数据离用户越近越好。
    • 我们把热点数据再上移,在服务内部做热点数据的本地缓存,也就是让业务层的每个实例里都有份数据副本,读请求数据的时候,无需去Redis 获取,直接从本地缓存里取。这时候,数据的副本数和服务一样多,另外请求链路减少了一层,而且也减少了对Redis 单片QPS 上限的依赖,具有更高的可靠性和更高的性能。这种方式热点数据的副本数随实例的增加而增加,非常容易扩展,扛高流量。但是本地缓存的数据延迟,业务要能够接受。
  3. 直接短路返回
    • 某个商品秒杀的时候,这个SKU 是不支持使用优惠券的,那么优惠券系统在处理的时候,可以根据商品SKU 编码,直接返回空的券列表,这样基本上不怎么耗资源,效率非常高。当然了,这种方式和具体商品的活动方式有关,不具有通用性,但是在几百万的流量面前,简单有效

写热点

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

写热点还有一个场景就是库存的扣减,有一种思路,可以通过把一个热 key拆解成多个key 的方式,避免热点问题。这种设计针对MySQL 和Redis 缓存都是适用的,但是涉及到对库存进行再细分,以及子库存挪动,非常复杂,而且边界问题比较多,容易出现库存不准的问题,需要谨慎小心的使用这种方法。

另一个思路就是**对单SKU 的库存直接在Redis 单分片上进行扣减,实际上,扣减库存在秒杀链路的末端,通过我们之前的削峰和限流的各种手段,真正到库存的流量是有限的,单片的Redis OPS 能承受得了。然后,我们可以针对单SKU的库存扣减进行单独限流,保证库存单片Redis 的压力。**这样双管齐下,单SKU的库存Redis 扣减压力就是可控的了。

防刷

秒杀的抢购原则无外乎两种,要么是绝对公平的,即先到的请求先处理,暂时处理不了的,会把你放入到一个等待队列,然后慢慢处理。要么是非公平的,暂时处理不完的请求会立即拒绝,让你回到开始的地方,和大家一起再比谁先到,如此往复,直至商品售完。

因此黑产的方法也很简单,就是想法设法比别人快,发出的请求比别人多,就像在一个赛道上,给自己制造很多的分身,不仅保证自己比别人快,同时还要把别人挤出赛道,确保自己能够到达终点。黑产对秒杀业务的威胁是巨大的,它不仅破坏了公平的抢购环境,而且给秒杀系统带来了庞大的性能开销,所以我们不能放任黑产流量对系统的肆意冲击,我们必须对抗它。既然黑产流量的特点是比正常流量快且频率高,那么我们也就可以从这两个方面来着手思考对策。

只针对第一个快的特点,其实在活动开始后,进来的流量我们都无法将其定义为非法流量,这个只能借助像风控这种多维度校验,才能将其识别出来,除非它跳步骤。而第二个高频率的特点,同时也是对秒杀系统造成危害最大的一种,我们还是有很多种手段来应对的。专门针对高频率以及跳步奏的非法手段常见的防刷方案有哪些呢?

  • Nginx 有条件限流,是非常简单且直接的一种方式,这种方式可以有效解决黑产流量对单个接口的高频请求,但要想防止刷子不经过前置流程直接提单,还需要引入一个流程编排的Token 机制。
  • Token 机制,Token 一般都是用来做鉴权的。放到秒杀的业务场景就是,**对于有先后顺序的接口调用,我们要求进入下个接口之前,要在上个接口获得令牌,**不然就认定为非法请求。同时这种方式也可以防止多端操作对数据的篡改,如果我们在Nginx 层做Token 的生成与校验,可以做到对业务流程主数据的无侵入。比如可以通过header_filter_by_lua_block,在返回的header 里增加流程Token。Token 可以做MD5,加入商品编号、活动开始时间、自定义加密key 等。
  • 黑名单机制,黑名单机制分为本地黑名单和集群黑名单两种。该机制顾名思义,就是通过黑名单的方式来拦截非法请求的,但我们的核心问题是黑名单从哪里来呢?
    • 从外部导入,可以是风控,也可以是别的渠道
    • 自力更生,自己生成自己用
      • 比如前面介绍了Nginx 有条件限流会过滤掉超过阈值的流量,但不能完全拦截,所以索性就不限流,直接全部放进来。然后我们自己实现一套“逮捕机制”,即利用Lua 的共享缓存功能,去统计1 秒内这个用户或者IP 的请求频率,如果达到了我们设定的阈值,我们就认定其为黑产,然后将其放入到本地缓存黑名单。黑名单可以被所有接口共享,这样用户一旦被认定为黑产,其针对所有接口的请求,都将直接被全部拦截,实现刷子流量的0 通过。
      • 本地黑名单机制的优点就是简单、高效。但也正因为基于单机,如果黑产将请求频率控制在1*Nginx 机器数以内,按请求理想散落的情况下,那么就不会被抓到,所以真要想通过频率来严格限制刷子请求,是可以借助Redis 来实现集群黑名单的。实现思路和单机的基本一致,就是使用的内存由本地变为了Redis,当然这也必然会影响接口的响应性能。

风控

风控在秒杀业务流程中非常重要,但风控的建立却是非常困难的。成熟的风控体系需要建立在大量的数据之上,并且要通过复杂的实际业务场景考验,不断地做智能修正,才能逐步提高风险识别的准确率。风控的建设过程,其实就是一个不断完善用户画像的过程,而用户画像是建立风控的基础。一个用户画像的基础要素包括手机号、设备号、身份、IP、地址等,一些延展的信息还包括信贷记录、购物记录、履信记录、工作信息、社保信息等等。这些数据的收集,仅仅依靠单平台是无法做到的,这也是为什么风控的建立需要多平台、广业务、深覆盖,因为只有这样,才能够尽可能多地拿到用户数据。有了这些数据,所谓的风控,其实就是针对某个用户,在不同的业务场景下,检查用户画像中的某些数据,是否触碰了红线,或者是某几项综合数据,是否触碰了红线。而有了完善的用户画像,黑产用户风控中的判定自然就越准。

容灾

机房容灾其实不仅仅是秒杀系统需要思考的,重要的软件系统,不管是互联网应用,还是传统应用,比如银行系统等,都需要考虑机房容灾的问题。不同的场景,容灾的设计也不尽相同

容灾,一般是指搭建多套(两套或以上)相同的系统,当其中一个系统出现故障时,其他系统能快速进行接管,从而持续提供7*24 不间断业务。

在讨论容灾的时候,经常会听到“同城双活”“异地多活”等术语,它们都是不同的容灾方案,不同的方案,其技术要求、建设成本、运维成本都不一样。在多活架构下,对两套系统之间通信线路质量、时延要求很高,业内主流IT 厂家比较认可的是单向时延2ms 以内,超过这个时延,对“多活”的跨机房请求和数据同步的性能影响就会比较大。

因此,涉及跨城市的多活,当城市距离较大时,比如上海和北京,那么这种物理上的时延很难克服。为了保证数据库的一致性,就需要付出很高的时间成本,往返几个来回时延叠加,RT 就受不了了。所以异地多活单元化的设计其实非常复杂,成本高昂,即便是大厂也不一定能搭建好异地多活。“同城双活”相对就简单一些,同城双活是在同城或相近区域内建立两个机房。同城双机房距离比较近,通信线路质量较好,比较容易实现数据的同步复制,保证高度的数据完整性和数据零丢失。同城两个机房各承担一部分流量,一般入口流量完全随机,内部RPC 调用尽量通过就近路由闭环在同机房,相当于两个机房镜像部署了两个独立集群,同城双活因为物理距离短,机房间的时延是有保证的。数据仍然是单点写到主机房数据库,然后实时同步到另外一个机房,读流量则完全可以做到机房内闭环。双机房间的物理专线也必须是高可用的设计,至少需要两根以上进行互备,这样在专线故障时才有机会绕行避免不可用,这些在大厂里一般是运维团队在保障,我们稍微了解实现原理就可以。

总结梳理

秒杀隔离: 业务隔离(提报系统)、系统隔离、数据隔离(一主多从)

页面静态化(Nginx)

  • 商详页静态化(freemarker)

  • 商详页库存获取

    • 直接让Nginx(OpenResty - lua)访问Redis

    • Redis从库和Nginx在部署在同一台服务器

      • Unix Domain Socket

      • 本地Redis宕机时, 回源到微服务中查询主Redis或者数据库

流量管控

  • 前期(预约系统

    • 固定时段、人数上限(即时熔断)

    • 预约关系表:分库分表&历史归档&MQ异步写入

    • 预约商品表:Redis一主多从&本地缓存

    • 页面预约人数:本地缓存累加,批量写入Redis

  • 事中(服务平稳处理&节省机器成本)

    • 验证码和问答题(可以独立为一个微服务)

      • 从session 共享角度来说,验证码应该放入Redis

      • Nginx Lua读时:**读从Redis未果则读主Redis;**或休眠后再读

    • 消息队列

      • 技巧:库存为0后在缓存里设置占位符,让后续请求快速失败
  • 限流(逐级限流,分层过滤 - 令牌桶和漏桶)

    • Nginx 限流(HttpLimitzone 和 HttpLimitReqest

      • 1 个IP 1 秒内只能访问 1 次
    • 应用/服务层限流

      • 线程池限流(最大连接数)

      • API 限流(QPS - RateLimiter,Sentinel)

    • 自定义限流

      • 本地预先存放一批订单ID,通过定时任务刷新
    • 分层过滤(秒杀5分钟,前2分钟抢完了,则后3分钟为无效请求

      • Nginx本地缓存库存信息(商详页直接返回秒杀结束)

      • 服务层:秒杀确认单和秒杀订单都会进行库存检查

        • 在实际下单和扣减库存前中断用户的请求链路
  • 限购(用户提单时,通过限购请求,再去做真实库存扣减)

    • 商品维度限制:不同地区的库存不同

    • 个人维度限制:手机号、收货地、设备IP

库存扣减(先查询库存,再库存扣减)

  • 超卖:查询和扣减不是原子操作;并发引起的请求无序

  • 数据库方案(**简单安全,性能较差,**适合请求量较小的场景)

    • 行锁

      • 查询库存的时候使用for update

      • where 条件,保证库存不会被减到 0 以下

    • 乐观锁

      • 查询库存时除了库存值还有版本号

      • 每次扣减库存时带上这个版本号进行扣减

      • 扣减失败,则需要重新查询,重新扣减(加重数据库的负担)

    • 将库存字段设为无符号整数(值小于零时直接报错)

  • 分布式锁方案(可以实现,但不建议)

    • 锁的有效期问题:时间太长太短都对业务有不利影响
  • 高并发扣减方案(单个商品的高并发读和高并发写问题

    • 写服务降级(牺牲数据一致性获取更高的性能

      • 同步写缓存、MQ异步写数据库(定时任务比对)

      • 宁可少买,不可超卖,少卖可以做返场(Lua脚本)

    • 读服务降级(故障场景下紧急降级快速止损

      • 建设多级缓存(Redis, MongoDB, ES, 多副本)

      • 缓存全部故障时,切换到 DB 暂抗

    • 简化系统功能(舍弃非核心功能

      • 交互少,数据小,链路短,离用户近

      • 只对流量非常高的SKU进行降级

热点数据(单商品SKU落在的那个分片上性能总会达到上限)

  • 读热点

    • 增加Redis从副本数

    • 在服务内部做热点数据的本地缓存(但本地缓存有数据延迟

    • 不支持该活动的SKU直接短路返回(不通用,和具体商品有关)

  • 写热点

    • 本地累加,延迟批量提交到Redis

    • 一个热 key拆解成多个key(复杂

    • 对单SKU的库存直接在Redis单分片上进行扣减,进行单独限流

      • 扣减库存在秒杀链路的末端,流量有限

防刷

  • Nginx限流

  • Token机制:对于有先后顺序的接口调用,我们要求进入下个接口之前,要在上个接口获得令牌

  • 黑名单(本地黑名单和集群黑名单)

    • 从外部导入,可以是风控,也可以是别的渠道

    • 自己生成

      • 根据Nginx的流量进行分析,利用Lua共享缓存,**统计1秒内这个用户或者IP的请求频率,**放入本地缓存黑名单,但是如果黑产将请求频率控制在1*Nginx机器数以内,就不会被抓到

      • 借助Redis实现集群黑名单(影响接口响应性能

风控(不断完善用户画像)

  • 需要大量数据和复杂的实际业务场景

容灾(同城双活,异地多活)

  • 单向时延2ms以内(物理专线至少两根以上)

    • 异地多活成本太高,设计复杂

文章作者: 钱不寒
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 钱不寒 !
  目录