- 基于OpenResty Nginx + lua 写业务逻辑
- 预约 + 抢购
- 验证码预削峰
- nginx限流、线程池限流、API 限流
- nginx + lua 黑名单机制
- 如何防止超卖 lua(EVALSHA预加载lua脚本)
- 基于硬件的优化(网卡中断优化)
- 基于Java的优化
- 业务思考
- 在高并发下在高并发下,对于读热点数据最好放到本地缓存,这样可以快速的返回
- 避免单点,将服务无状态化,可以将参数通过Nacos配置中心来推送(比如服务A里面不能存储状态相关的数据(比如static 修饰的数据存储) )
前言
OpenArt项目和商城类的项目很像,本质上就是数字藏品售卖。和普通商品不同的是,当用户稍微具有一点规模时,他的售卖场景,将全部是秒杀场景,所以秒杀也是本项目最为核心的功能。
因为数字藏品目前十分火热,很多新台子一开出来,便有成千上万用户注册使用,甚至有平台注册接口都能崩了。因此用户数量拥有极大的不确定性,在设计时应该将用户量往稍微多一些考虑。
秒杀系统面临的挑战
巨大的瞬时流量
秒杀活动的特点,就是将用户全部集中到同一个时刻,然后一起开抢某个热门商品,而热门商品的库存往往又非常少,所以持续的时间也比较短,快的话可能一两秒内就结束了。而数字藏品由于近期火热,抢到发售通常可以为用户带来收益,因此所有的商品都属于热门商品。
热点数据问题
高并发下一个无法避开的问题,就是热点数据问题。特别是对于秒杀活动,大家抢购的都是同一个商品,所以这个商品直接就被推到了热点的位置,不管你是用的数据库,还是分布式缓存,都无法支持几十万、上百万对同一个 key 的读写,以 Redis 的写为例,最高仅可支持几万的 TPS。
刷子流量
由于数字藏品的火热,非常多的平台都会被非法流量盯上,因此,面对刷子流量也是一个巨大的挑战。
一般我们提供的秒杀对外服务,都是 HTTP 的服务。不管是用 H5 实现的页面,还是通过安卓或是 iOS 实现的原生页面,特别是 H5,都可以直接通过浏览器或是抓包工具拿到请求数据,这样刷子便可以自己通过程序实现接口的直接调用,并可以设置请求的频率。
这样高频次的请求,会挤占正常用户的抢购通道,同时,刷子也获得了更高的秒杀成功率。这不仅破坏了公平的抢购环境,也给系统服务带来了巨大的额外负担。
其实总结来说,瞬时的大流量就是最大的挑战,当业务系统流量成几何增长时,有些业务接口加机器便可以支持。但考虑到成本与收益,在有限的资源下,如何通过合理的系统设计来达到预期的业务目标,就显得格外重要了。
秒杀HTTP请求链路分析
DNS:负责域名解析,会将你的域名请求指定一个实际的 IP 来处理(事先配置好处理请求的 IP,DNS 按顺序指定),并且一般客户端浏览器会缓存这个 IP 一段时间,当下次再请求时就直接用这个 IP 来建立连接,当然如果指定的 IP 挂了,DNS 并不会自动剔除,下次依然会使用它。
Nginx:也就是上面的被 DNS 指定来处理请求的 IP,一般都会被用来当做反向代理和负载均衡器使用,因为它具有良好的吞吐性能,所以一般也可以用来做静态资源服务器。当 Nginx 接收到客户端请求后,根据负载均衡算法(默认是轮询)将请求分发给下游的 Web 服务。
Web 服务:这个就是我们都比较熟知的领域了,一般我们写业务接口的地方就是这了,还有我们的 H5 页面,也都可以放到这里,这里是我们做业务聚合的地方,提供页面需要的数据以及元素。
RPC 服务:一般提供支撑业务的基础服务,服务功能相对单一,可灵活、快速部署,复用性高。RPC 服务一般都是内部服务,仅供内部服务间调用,不对外开放,安全性高。
从哪里进行优化
我们一个个来看下,对于DNS层一般会做一些和网络相关的防攻击措施,可以拦截一些攻击请求,但是成本有些高,所以暂不考虑。
对于Nginx层,Nginx 不仅可以作为反向代理和负载均衡器,也可以做大流量的 Web 服务器,同时也是一款非常优秀的静态资源服务器。所以我们可以在这层做文章,在经过了解后,我也发现可以在这层写一些业务逻辑,做一些前置校验,过滤大量请求,因此列入考虑。
接下来就到了 Web 服务了。我们在这里做业务的聚合,提供结算页页面渲染所需要的数据以及下单数据透传,同时也负责流量的筛选与控制,保证下游系统的安全。
最后就是 RPC 服务。它提供基础服务,一般经过上面 3 层的严格把关,到这里的请求,量已经小很多了,我们写业务逻辑,在技术上也有更多的发挥空间。
基于OpenResty 的 Nginx + lua
对于OpenResty的加简单介绍和使用,可以看我的另一篇文章—— 初探OpenResty
OpenResty 是一个基于 Nginx 与 Lua 的高性能 Web 平台,它使我们具备在 Nginx 上使用 Lua 语言来开发业务逻辑的能力,并充分利用 Nginx 的非阻塞 IO 模型,来帮助我们非常方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。
为什么要用 Lua 语言来做 Nginx 开发呢?这就要说到 Lua 语言的特点了,Lua 的线程模型是单线程多协程的模式,而 Nginx 刚好是单进程单线程,天生的完美搭档。
搭建OpenResty 首先需要下载,这里吐槽下Windows下有很多奇奇怪怪的问题,还需要装Perl等环境,最好使用Docker安装。
职责划分
对于此层,在我的规划中,主要用于以下功能:
- 流量筛选:根据黑白名单、登录态和参数有效性等来筛选流量。
- 流量分发:通过设置的负载均衡算法进行流量分发,也可以自定义算法,比如根据 IP 做 hash,或者根据用户 ID 做 hash 等。
- 简单业务以及校验:提供活动数据、活动有效性校验、库存数量校验和其他业务相关的简单校验等。
- 限流:根据 IP 或者自定义关键入参做限流。
- 异常提示页面:主要是进结算页失败的提示页,可能是被限流,被业务校验拦截或者是后端服务异常等。
秒杀的流量管控
对于流量管控,其实我看了很多台子,有些台子简单粗暴,通过抽签的方式来抽取藏品,这种方式可以使流量平滑均匀分配,但是这种方式只有一个目前这一块非常大的一个台子在用,而且抽签算法也较为复杂,因此我们不考虑这种方式。
而另一种考虑的方式就是预约抢购,只有预约的用户能够参与抢购,这样也可以过滤非常多的流量,这种方式也有非常多的平台在使用,不过基本都是头部互联网公司建设的平台。因为他们自带的公信力和影响力,所以可以采用这种方式,对于我们新开发的平台,则不太适合,因此前期暂不考虑使用。
综上,我们需要从流量削峰和限流考虑了。
秒杀的削峰和限流和防刷
削峰的方法有很多,可以通过业务手段来削峰,比如秒杀流程中设置验证码或者问答题环节;也可以通过技术手段削峰,比如采用消息队列异步化用户请求,或者采用限流漏斗对流量进行层层过滤。削峰又分为无损和有损削峰。本质上,限流是一种有损技术削峰;而引入验证码、问答题以及异步化消息队列可以归为无损削峰。
削峰
削峰的一般手段就是验证码、问答题、消息队列、分层过滤和限流。
秒杀交易流程中,引入验证码和问答题,有两个目的:一是快速拦截掉部分刷子流量,防止机器作弊,起到防刷的作用;二是平滑秒杀的毛刺请求,延缓并发,对流量进行削峰。
校验的逻辑比较简单,从前端的 HTTP 请求里,取得 skuId、user、timestamp 和 newCode,首先验证 timestamp 是否已经过期,然后根据用户输入的验证码内容 newCode ,并和 Redis 里的 newCode 进行比对,比对一致表示验证码校验通过。然后我们需要删掉 Redis 的内容,避免被重复验证,这样的话一个验证码就只会被验证一次了。
限流
限流是系统自我保护的最直接手段,再厉害的系统,总有所能承载的能力上限,一旦流量突破这个上限,就会引起实例宕机,进而发生系统雪崩,带来灾难性后果。
限流常用的算法有令牌桶和漏桶,有关这两个算法的专业介绍,可以参考:https://hansliu.com/posts/20。
限流也有网关层和Web层的限流。
网关层限流
这里的 Nginx 限流,主要是依赖 Nginx 自带的限流功能,针对请求的来源 IP 或者自定义的一个关键参数来做限流,比如用户 ID。其配置限流规则的语法为:
1 | limit_req_zone <变量名> zone=<限流规则名称>:<内存大小> rate=<速率阈值>r/s; |
- 以上 limit_req_zone 是关键字,< 变量名 > 是指定根据什么来限流;
- zone 是关键字,< 限流规则名称 > 是定义规则名称,后续代码中可以指定使用哪个规则;
- < 内存大小 > 是指声明多大内存来支撑限流的功能;
- rate 是关键字,可以指定限流的阈值,单位 r/s 意为每秒允许通过的请求,这个算法是使用令牌漏桶的思想来实现的。
其实在一些云存储服务中,也特别容易被盗刷流量,因此,在我们七牛云也利用nginx和防盗链reference做了简单的防刷。
Web层限流
线程池限流
我们可以通过自定义线程池,配置最大连接数,以请求处理队列长度以及拒绝策略等参数来达到限流的目的。当处理队列满,而且最大线程都在处理时,多余的请求就会被拒绝策略丢弃,也就是被限流了。
API 限流
上面说的的线程池限流可以看做是一种并发数限流,对于并发数限流来说,实际上服务提供的 QPS 能力是和后端处理的响应时长有关系的,在并发数恒定的情况下,TP99 越低,QPS 就越高。然而大部分情况是,我们希望根据 QPS 多少来进行限流,这时就不能用线程池策略了。不过,我们可以用 Google 提供的 RateLimiter 开源包,自己手写一个基于令牌桶的限流注解和实现,在业务 API 代码里使用。
具体来说,TP99表示在特定时间段内,有99%的观察值低于或等于这个数值。换句话说,TP99是一个经验法则,它告诉我们如何评估系统的稳定性和响应时间。
例如,当我们测试一个网络应用程序的性能时,我们可能会记录每个请求的响应时间,并计算出TP99。如果TP99为3秒,则说明99%的请求在3秒内完成处理,而只有1%的请求需要更长的时间。因此,TP99可以帮助我们了解系统的瓶颈和性能瓶颈所在,并为我们提供优化系统的线索。
防刷
目前可以采用的防刷手段就是利用Token,简单说,就是在进入下个接口之前,要在上个接口获得Token,不然就认定为非法请求。同时这种方式也可以防止多端操作对数据的篡改,如果我们在 Nginx 层做 Token 的生成与校验,可以做到对业务流程主数据的无侵入。
我们在返回的 header 里增加流程 Token。这里 st 的生成只是简单地将用户 ID+ 步骤编号做了 MD5,后期需要更严格一些,需要加入商品编号、活动开始时间、自定义加密 key 等。
1 | location /activity/query{ |
流程如下图所示:
热点数据处理
秒杀的核心问题是要解决单个商品的高并发读和高并发写问题,也就是要处理好热点数据问题。对于这个问题,需要分为两类:读热点问题和写热点问题。
读热点
对于读热点有两种解决思路:
- 增加热点数据的副本数;
- 让热点数据离用户越近越好。
第一个解决方案,就是增加 Redis 从的副本数,然后业务层(Tomcat 集群)轮询查询不同的副本,提高同一数据的 QPS。一般情况下,单个 Redis 从,可提供 8~10 万的查询,所以如果我们增加 12 个副本,就可以提供百万 QPS 的热点查询。当然,本项目是暂时不需要考虑那么大的并发的,而这么多副本,成本必然也高。
第二个解决方案,是把热点数据再上移,在 Tomcat 集群做热点数据的本地缓存,也就是让业务层的每个实例里都有份数据副本,读请求数据的时候,无需去 Redis 获取,直接从本地缓存里取。这时候,数据的副本数和 Tomcat 实例一样多,另外请求链路减少了一层,而且也减少了对 Redis 单片 QPS 上限的依赖,具有更高的可靠性和更高的性能。本地缓存的实现比较简单,可以用 HashMap、Ehcache,或者 Google 提供的 Guava 组件。
但是这种方式也有问题,本地缓存数据同步延迟肯定是更高的,需要考虑这种延迟在不同的业务场景下是否能够接受。
容灾
容灾其实也非常重要,如果服务器出现问题,用户数据丢失将十分危险,而目前我们能想到的相对经济省力的手段就是定时备份了。
其次就是避免单点,将服务无状态化,可以将参数通过Nacos配置中心来推送(比如服务A里面不能存储状态相关的数据(比如static 修饰的数据存储) )
防止超卖
对于超卖,也无需过多介绍。举个简单的例子,现在活动藏品有 2 件库存,此时有两个并发请求过来,其中请求 A 要抢购 1 件,请求 B 要抢购 2 件,然后大家都去调用活动查询接口,发现库存都够,紧接着就都去调用对应的库存扣减接口,这个时候,两个都会扣减成功,但库存却变成了 -1,也就是超卖了。
超卖的问题主要是由两个原因引起的,一个是查询和扣减不是原子操作,另一个是并发引起的请求无序。
所以要解决这个问题,我们就得做到库存扣减的原子性和有序性。
怎么解决
首先我想到利用数据库的行锁机制。这种方式的优点是简单安全,但是其性能比较差,无法适用于本项目的业务场景。
既然数据库不行,那能使用分布式锁吗?通过 Redis 或者 ZooKeeper 来实现一个分布式锁,以藏品维度来加锁,在获取到锁的线程中,按顺序去执行商品库存的查询和扣减,这样就同时实现了顺序性和原子性。这个思路是可以的,只是不管通过哪种方式实现的分布式锁,都是有弊端的。
以 Redis 的实现来说,仅仅在设置锁的有效期问题上,就让人头大。如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这与我们要实现高性能的秒杀系统是相悖的。所以通过分布式锁的方式可以实现,但我们不使用。
这时候,我们再考虑Redis执行Lua脚本去实现,Redis本身就是单线程的,天生就可以支持操作的顺序性。如果能在一次 Redis 的执行中,同时包含查询和扣减两个命令,就可以做到保证操作执行的原子性。
当然这里的原子性说法可能不是很准确,因为 Lua 脚本并不会自动帮你完成回滚操作,所以如果我们的脚本逻辑中包含两步写操作,需要自己去做回滚。但是我们库存扣减的逻辑针对 Redis 的命令就两种,一个读一个写,并且写命令在最后,这样就不存在需要回滚的问题了。
Redis 执行 Lua 脚本
Redis 执行 Lua 脚本的命令有两个,一个是 EVAL,另一个是 EVALSHA。
原生 EVAL 方法的使用语法如下:
1 | EVAL script numkeys key [key ...] arg [arg ...] |
其中 EVAL 是命令,script 是我们 Lua 脚本的字符串形式,numkeys 是我们要传入的参数数量,key 是我们的入参,可以传入多个,arg 是额外的入参。但这种方式需要每次都传入 Lua 脚本字符串,不仅浪费网络开销,同时 Redis 需要每次重新编译 Lua 脚本,对于我们追求性能极限的系统来说,不是很完美。
所以这里就要说到另一个命令 EVALSHA 了,原生语法如下:
1 | EVALSHA sha1 numkeys key [key ...] arg [arg ...] |
可以看到其语法与 EVAL 类似,不同的是这里传入的不是脚本字符串,而是一个加密串 sha1。这个 sha1 是从哪来的呢?它是通过另一个命令 SCRIPT LOAD 返回的,该命令是预加载脚本用的,语法为:
1 | SCRIPT LOAD script |
这样的话,我们通过预加载命令,将 Lua 脚本先存储在 Redis 中,并返回一个 sha1,下次要执行对应脚本时,只需要传入 sha1 即可执行对应的脚本。这完美地解决了 EVAL 命令存在的弊端,所以我们这里也是基于 EVALSHA 方式来实现的。
实现
在Web服务中,我们可以定义一个RedisTools,这里列出和此例相关的部分代码如下:
1 | @Component |
硬件的优化
对于硬件层面的优化,我们也准备从以下几个方面进行优化。
CPU 模式的优化
所谓 CPU 模式的调整,就是调整 CPU 的工作频率,使其呈现出不同的性能表现,以满足特定的业务使用场景。
因为考虑到业务的特殊性,基本是秒杀场景,所以可以让CPU 一直处于超频状态,当然这种状态也是比较耗电的,但是为了更好地开展活动,还是需要打开的。
网卡中断优化
中断其实就是硬件和CPU交换的基本方式的,硬件是通过中断信号,陷入内核态,去让CPU执行相关指令。
而秒杀的瞬时流量非常高,带来的问题就是一下子会有非常多的网络请求进来。网卡在收到网络信号后,会通知 CPU 来处理,这时如果我们没有调整过相关配置,那么很有可能处理网卡中断的 CPU 都集中在一个核上。
如果这个时候该 CPU 也在承担处理应用进程的任务,那么就有可能出现单核 CPU 飙升的问题,同时网络数据的处理也会受到影响,导致大量 TCP 重传现象的发生。所以这个时候,我们要做的就是合理分配多核 CPU 资源,专门拿出一个核来处理网卡中断。
注意这里的区别:调整前,是某一个核既要执行其他相关指令操作,也要执行网卡中断操作;而调整后,某一个核只执行网卡中断操作。
对于如何执行这一操作,可以参考我的另一篇文章 —— linux将网卡的-IRQ-与一个特定的-CPU-核进行绑定。
JVM层面的优化
常见的垃圾回收器有以下几种:
- Parallel GC
Parallel GC 是一种基于标记-清除算法的并行垃圾回收器,它使用多个线程来加速垃圾回收过程。Parallel GC 通过标记所有活动对象,然后清除未被标记的对象来实现垃圾回收。Parallel GC 的主要优点是速度快,能够处理大量的垃圾数据。但是,它也有一些缺点,例如会导致长时间的停顿时间,并且会浪费一定的内存空间。
- G1
G1 是一种全新的垃圾回收器,它使用分代算法和复制算法来管理内存空间。G1 可以将整个堆空间分为多个区域,并按需进行回收。G1 的主要优点是可预测性好,并且可以避免长时间的停顿时间。同时,G1 还使用了一些优化技术来提高回收效率和减少内存浪费。但是,G1 的启动时间较长,并且可能会导致一定的系统开销。
- CMS
CMS 是针对低延迟垃圾回收而设计的一种垃圾回收器,它使用标记-清除算法和并发性算法来管理内存空间。CMS 的主要优点是能够实现低延迟垃圾回收,并且减少长时间的停顿时间。但是,CMS 也有一些缺点,例如需要大量的 CPU 资源,并且可能会导致碎片化问题。
对于垃圾回收器的选择,是需要分业务场景的。
如果我们提供的服务对响应时间敏感,并且堆内存能够给到 8G 以上的,那可以选择 G1;堆内存较小或 JDK 版本较低的,可以选择 CMS。相反如果对响应时间不敏感,追求一定的吞吐量的,则建议选择 ParallelGC,同时这也是 JDK8 的默认垃圾回收器。
综合来看,秒杀的业务场景更适合选择 G1 来做垃圾回收器。
业务思考
在看过很多书,特别是《实现领域驱动设计》后,我非常认同一句话——技术为业务服务,脱离业务的技术,将毫无意义。
其实在本项目中,也有很多值得去思考的业务问题,这里简单总结一下:
- 在抢购的过程中,如果用户是因为已经买到了上限被限购拦住了,那我们可以直接文案提示“您的购买数量已经达到上限”,这个文案对用户比较直观也能接受,因为规则就是这样定的。如果用户被识别为流量刷子,被拦截是不能下单的,那我们不能直接提示“您是风险用户,不能购买”。这样的文案会招来用户的投诉,即使他是刷子。因此,这种刷子用户,我们的文案可以直接提示“很抱歉没有抢到,下次再来”。
- 系统在运行过程中,也会出现抖动、接口超时以及流量过大被限流的情况,那我们要怎样友好地提示用户呢?在普通售卖场景,我们这样提示“系统开了小差,请你稍后重试”,“当前参与人数多,系统繁忙,请你稍后重试”,一般也不会有太大问题。但是在我们的场景,就可能招来大量投诉,毕竟这是有利可图的东西。所以我们直接返回“很抱歉没有抢到,下次再接再厉”,可能会觉得这样修改文案,对用户不公平,实际上你仔细想想,如果用户被限流,本身就表示他的手速还是慢了,提示“没有抢到”也合理。