行非常之事,须非常手段
软件是工程
在日常的互联网应用中,各种抢位的业务需求是比较常见的。最典型的就是微信抢红包,淘宝商品优惠券,12306抢火车票...等等。这个需求也被比较形象的称为"秒杀"。
在本文中,笔者想要基于一个开发者的立场,从概念和技术的角度探讨一下这个问题。而且笔者认为,这其中最重要的应该是构想并且实践,我们将会从比较基础的层面上,设计一个可以实现的流程和架构,并使用实际的技术和代码对相关的设计和想法进行验证。
基本概念
笔者认为,在一切开始之前,需要大家先能够客观的理解什么叫做"抢位"。
举一个现实中的例子,典型的就是商品的"秒杀"。商户设置一个秒杀活动,预告几个秒杀的商品和秒杀开始的时间。活动开始后,用户访问商品页面,抢先预定这些商品,然后完成付款购买流程。由于秒杀商品的数量,和想要抢购商品的客户数量可能会差异很大,比如10万个用户,要抢购5个商品。可能在系统开放的那个时间点,所有的用户都在尽力发起抢购的操作,在技术上就是响应的程序网络请求。而电商平台在理论上应当使用"先来后到"的原则,通过这个请求的次序来确定识别抢购成功的用户,并进行后续的业务处理。
这里面我们可以看到这一业务的一些特点和要点:
- 抢位的数量是有限并且确定的,但预定请求可能远远超过这个数量
- 确定抢位成功的基本原则是"先来后到"
- 在某个具体的时间点后的操作才算有效
- 实际上,除了开启之后排队的那几个请求之外,开启前,和之后的超过有效数量之后的请求,都是无效的
- 抢位的实际过程可能比较短,但短时间内的请求数量可能很大,对系统处理性能的要求很高
所以,在技术上而言,"抢位"这一操作的本质,就是对所有的业务请求,进行排序,然后取前面相应数量的请求作为结果,排序的规则就是先来后到。从用户角度来看,好像就是所谓的发起(点击)时间,但实际上为了处理方便和一致性,一般可以使用服务接口收到请求的哪个"时间"。
这个规则非常简单,但在工程上却有实际的问题。主要就是,如果在那个时间点有大量的请求进行处理的话,即便是正常有效的预定请求,可能还来不及等待处理完成,服务系统就会被大量的请求阻塞并出错,无法完成正常的响应,或者恢复时间非常长,看起来就是卡死了。所以,在工程上,抢位应用处理的核心,就是在保证服务系统不崩溃的基础之上,来完成请求的排序,和对有效请求的业务处理。
下面,我们来结合一个实际的接口设计示例,来阐述在信息技术工程上如何进行实现和优化。
基本设定
为了在实际系统中解决这个问题,我们这里先做一些信息技术方面的基本设定,后续的实现和优化,都在这个范围和框架中实行。
- 抢位业务过程使用简单的请求-响应模型
- 抢位业务使用标准HTTP协议,通过接口的形式提供服务,这样可以很好的兼容各种客户端和前后端分类模式
- 抢位优先级的主要依据是服务端收到抢位请求的时间
- 示例测试系统基于Nodejs/JavaScript开发,这只是笔者个人的选择和喜好,逻辑上可以使用任意支持HTTP服务开发的支撑系统和语言
- 基本的性能指标,是需要在10秒内,可以正常完成并处理1000000万次网络请求
- 无论何种负载和情况,系统本身都不能崩溃
有过一些开发经验的读者应该能够了解到,我们一般开发的正常的业务Web应用系统,在设计和实现良好的情况下,一般能够做到2000qps,响应时间小于100ms,就是一个不错的成绩了。实际上,如果没有很大的模式和架构改变,这些业务的优化空间并不是很大,下面我们就以普通的秒杀场景例来进行说明。
作为一般的Web应用的架构模式,来实现秒杀的业务过程基本如下。一个用户发起秒杀的请求,服务器结束并解析这个请求;然后按照商品的ID,到商品数据库中,查询是否有库存(可以是秒杀专门的库存);如果查到,则返回查询结果,同时将库存减一;客户端收到秒杀成功结果,进行后续操作如支付等;如果没有查到,则返回秒杀失败。其实,如果使用前后端分类架构和接口实现模式的话,这一过程已经被优化到只进行业务处理的形式了。传统的Web页面模式,这些过程,还可能涉及页面输出和渲染,以及业务状态的维持,所需要的处理能力更高。
让我们继续深入分析,这一过程的问题在于,这个处理流程比较长,而且涉及到的环节有一定的依赖关系,很难并行或者异步处理。另外,这个过程涉及到了数据库操作,在整个系统中,这个操作的代价是比较高昂的(另一套网络连接、处理过程)。还有另一个问题就是,由于这里数据库是单一的流程节点,所以整个系统很难简单的通过加入新的处理节点,来进行横向的扩展,瓶颈最终还是反映在数据库上。而对于稳定性的影响,最主要的原因还是处理流程过长,需要进行状态维持,无法快速结束响应,导致资源无法及时释放并占用堆积,最终超过软硬件资源规模,造成应用和系统崩溃。
在进入下一个环节之前,这里笔者还是想要先来破解一个"迷思"。就是在信息技术领域,如果你了解了其基本原理和底层逻辑之后,就会知道,其实是没有什么"黑科技"的,那大多数都是营销用语。计算机和软件的基础原理都是相同的,那些看起来不可思议的高性能的算法,都是通过精心设计和细节的打磨,在不断的应用和实践中,逐步优化和改进而来的。所以千万不要以为用来一个什么软件、算法或者程序库,就可以获得百倍的性能提升,在外部条件不变的前提下,只能说明原来的实现有很大的问题。这样我们也能够理解,现在比较成熟的应用模式,比如编程语言,常用的软件库,经过长时间的发展和改善,其实性能都大致差不多,优化的空间,还是主要在于有针对性的场景和系统架构的设计。
架构和优化构想
基本的问题和瓶颈大概搞明白了,下面,我们就进入实质的内容,针对抢位这个应用场景,分析和提出对应的构想,以及一些具体的处理和解决方案。特别是考虑到抢票这一应用场景可能独特和极端,也可能需要在一些基础技术,采取一些非常规的比较特别的技术手段和做法,才有可能在更大的程度上更好的适应这个应用场景。
下面笔者先构想并列举这些要点,然后再深入分别进行深入详细探讨:
- 负载均衡
- 异步处理
- 服务状态
- 高精度计时
- 简化请求和响应
- 子进程
- 前后端分离
- 防抖处理
- 限流处理
- 前后端分离
- 缓存
- 硬件
负载均衡
首先最容易想到的,肯定就是并行处理了。单个系统的处理能力不够,就使用多个系统,分担处理能力。这也是现代化Web应用系统进行规模扩展最常用的模式和手段-负载均衡。
负载均衡的基本模式是,由一个位于前端的负载均衡设备和系统,在接收网络请求后,自己并不实际的进行业务处理,而是分配给更后端的多个业务应用系统,由它们来进行处理。这样,前端负载均衡设备本身只负责流量的转发和分配,这个处理比较简单,可以处理的业务规模很大;而实际的业务处理,由后面的业务系统完成,当处理能力不足的时候,可以通过配置多个功能相同的业务系统来进行分担;这样就可以快速线性(理论上)的扩展整个系统的处理能力。
从这个模式我们也可以看到,负载均衡这个设计,其实和应用系统本身的设计关系不大,更多的是一种应用的网络架构方式。当然,应用系统应当改造成为可以方便使用负载均衡架构的方式,如无状态化。
负载均衡设备可以是硬件设备(如F5),也可以是如Nginx这种反向代理程序的软件实现。它们都有配套的负载均衡算法,可以比较科学合理的进行网络流量的分发。
实际上,在本文的场景中,负载均衡并不是我们讨论的重点,因为我们后面可以看到,在限定的应用场景中,我们使用单一应用服务系统,就可以达到很高的处理能力。负载均衡的部署模式,更多是一种更通用的解决方案和进一步扩展的基础。
异步处理
笔者认为,在本文的应用场景中,其实提高系统基础能力的最核心的要点,是异步处理。
前面的业务分析中,我们可以发现,抢位操作的核心,其实是确定请求发起的顺序。而完成的业务流程却包括确定顺序之后的信息查询和状态响应等环节。再考虑到抢位的特点是可预定位置的数量非常有限,大大小于请求的数量。所以,我们应当把处理聚焦到在第一时间确定并处理有效的抢位请求。
这样,我们可以考虑把整个业务过程大体拆分成为请求排序和抢位确认两个基本阶段(先不考虑后续阶段),并进行异步的处理。具体如下:
- 用户发起抢位请求
- 服务端收到抢位请求
- 服务端记录请求的用户标识和请求时间
- 服务端立刻响应处理结果为"已收到请求"
- 客户端收到服务端响应后,等待片刻,基于响应进一步查询服务端的处理结果
- 服务端使用异步方式,使用请求时间进行排序,查询位置保留信息,并确认抢位结果
- 后续客户端查询时,服务端响应抢位结果
- ...其他后续业务过程
这样的做法,可以让服务器在第一时间就可以快速响应客户端请求,并其实已经确认了抢位的结果(时间戳确定和排序)。然后才进行了实际不影响抢位结果的后续操作。可以大幅度提高在抢位开始时系统的处理能力。后续的操作,可以在已经确定抢位结果之后,再进行处理,总体提升了应用体验,并降低了系统阻塞和崩溃的风险。
现实生活中,有一个非常类似的场景,就是银行里的排位机。进入银行后,人们不需要实际的在窗口前排位,而是在排位机上另一个排位号,然后就可以在旁边休息,等待银行叫号后进行实际业务办理。这样可以大大提升银行业务办理的用户体验,同时也可以有效的控制和发挥银行的业务办理能力。
这里笔者有一个有趣的发现。我们都指定JavaScript程序执行主要模式就是异步操作,这也是其能够高效运行IO类应用程序的核心机制。这里我们将这个程序运行的机制,移植到了业务处理的流程当中,也可以起到类似的效果,其底层的概念和原理是相同的。
后面我们还可以看到,如何在这个异步处理的基础上,可以进一步改进和优化。
服务状态
前面的业务流程分析中,我们可以发现,其实这个业务过程和状态可以分为几个阶段,并不是在每个阶段都需要完整的处理过程和能力的。区分这些阶段和状态,并进行合理的规划和处理,可以大大的减少实际需要的操作。
从时间过程来看,我们可以将抢位操作的服务状态分为下面几个阶段:
- 抢位前
在抢位开始时间之前,用户在客户端可能会迫不及待的发起网络请求,但对于服务器而言,其实这些请求都是无效的,应当予以"拒绝",而不需要进行任何实际的业务处理(如数据库查询)。
处理的方式,可以是简单的响应一个"未开始"的信息。更简单的就是响应一个状态码。或者如果想要惩罚一下这些有些恶意的请求,甚至可以考虑将其重定向到一个网络黑洞当中?
- 抢位中
在服务端,应当有一个计时器或者状态维护程序,可以将整个服务状态,切换到"服务中"。这时会正式的开启抢位程序接口,并且接收和处理正常的抢位的请求。
- 抢位后
同样的道理,也应当在系统中,设计一个时间关闭机制,不处理在特定时间之后的抢位请求。但可以开放抢位结果查询的接口,这并不矛盾和冲突。
除了服务时间状态之外,我们还可以进一步考虑在其中加入业务状态,来进行更精细化的控制。我们可以引入一个用户请求的计数器,结合留位信息,来进行业务响应的控制。基本上有以下状态:
- 有位
当刚开始抢位,有足够的位置情况下,可以直接响应给客户端"有位"的状态,这时基本上可以确定他们已经抢位成功。因为会严格按照时间顺序来进行后续派位处理。
- 待定
当抢位快满的时候,由于需要异步处理,当前不能确定是否完全抢位成功,可以返回"待定"的状态。提示用户可以随后进行查询。
- 位满
当抢位的计数完全超过留位的数量,基本可以确定所有的位置已满,就可以响应给用户"位满"的信息,提示用户抢位失败。其实这时也不需要进行后续查询了。另外,在位满之后,即使抢位结束时间未到,这时抢位活动也可以结束了。在逻辑上的效果是相同的。
通过对服务时间和业务状态的规划和管理,可以减少很多不必要的请求处理,从而提升系统的性能和稳定性。
高精度计时
前面我们已经谈到,抢位的本质就是一个排序机制,而这个排序的核心依据,就是先来后到,就是记录服务器收到用户请求的那个时间点。这里有一个"同时性"的问题,就是如果用户是"同时"发起请求,如何确定其先后呢?其实从逻辑上而言,并没有同时性的问题,我们看到的同时,只是因为使用的时间分辨率不够。理论上有了足够的时间分辨率,我们就可以辨析最细微的时间差异。
以Nodejs为例,使用Date.now()可以获得当前的时间,但这个时间戳其实是以毫秒作为单位的。就是这个函数运行返回的那个正整数,是从1970年以来到当前时间过去的毫秒的数量。在比较极端的抢位场景中,可能在一秒钟内,涌入几十万个请求,这时每毫秒就是数百个,这个分辨率看起来就是不够用的。
幸运的是,在时间处理方面,除了Date类之外,nodejs还提供了一种更高精度的计时机制:hrtime(High Reselution Time,高精度时间),我们可以将其应用作为高分辨率时间戳使用。和一般的Unix时间戳的精度为毫秒相比,hrtime在理论上的精度是皮秒(ps, 毫秒的百万分之一),但在项目中,我们基于计算和处理方便,选择使用的精度为微秒级(us,1/1000ms)。
在nodejs中,使用hrtime,通常不直接使用时间获取函数,而是先确定一个起点,然后在需要计时的时候,计算当前时间和起点之间的差异,得到一个相对时间。这个时间是一个数组,由两个正整数组成,第一个是和起始时间差异的秒计数,第二个是其皮秒计数,两种结合起来计算和起始时间的差异,并可以根据需求进行转换。
下面是在项目中,使用hrtime的概念示例代码:
js
const tstart = process.hrtime();
// 测试延时
setTimeout(()=>{
const tend = process.hrtime(tstart);
const microseconds = (tend[0] * 1e6 + tend[1] / 1e3 >>>0).toString(36).padStart(6,"0") ;
console.log(`执行时间: ${microseconds} 微秒`);
}, 1500);
// 执行时间: 00w6bm 微秒
这个处理过程如下:
- 先确定一个起始计时点
- 需要计时时,计算一个和起始计时点的差异
- 将这个差异,转换成为微秒计数
- 将微秒值,转换为36进制表示
- 项目选择6位长度的36进制整数,理论可容纳的时间长度为2176s,一个半小时
- 如果需要更长的时间,简单增加编码长度即可
这样,在应用过程中,每一个网络请求的时间,都可以转换成为一个分辨率为微秒的6位字符串编码,每秒钟的编码容量为100万个,足以应对一般的秒杀场景。
简化请求和响应
前面已经提到,本文中,示例接口的实现,基于HTTP协议。比如,如果客户端进行抢位操作,就需要使用程序发起一个HTTP请求。 在HTTP协议中。其操作方法是有语义和规范的。POST用于增加数据和信息,GET用于信息查询。抢位这个操作,在语义上属于提交数据给服务器,规范上应当使用POST方法。
但从性能优化的角度来看,我们应当灵活处理。在本文的场景中,考虑到抢位操作对于性能的敏感性,可以考虑使用HTTP GET方法。因为我们在服务端的基本实现,是基于Nodejs的HTTP模块。使用这个模块,技术实现上,POST的操作是预期到可能有数据要传输到服务器端,所以,其完整的POST处理流程,是有上传信息流的处理机制的。这样,相对于GET方法,就多出来一系列额外的环节。而GET就简单很多,请求数据就是简单的PATH地址,服务端也只需要处理这个地址就可以了。就是说,从理论上而言,针对HTTP协议的操作,GET应当比POST简单高效。
实际的示例代码和操作中,我们将看到,程序没有使用传统的POST封装请求参数,而是将参数封装到了GET方法的Path路径当中,无论在客户端还是服务端,就可以节省一个HTTP请求体数据处理的环节。同时网络传输的信息更加精简小巧。
在响应方面的处理也是类似的道理。基于简化处理数据流程的想法,示例代码没有使用JSON对响应进行封装,而是直接使用一个简单的字符串来表达处理结果。这样在服务端可以减少数据处理流程。
子进程
现代化的计算机硬件体系中,处理器都是多核的处理器。但JS语言本身却是一个单进程的执行机制。为了更有效的现代处理器多核的硬件结构,nodejs引入了子进程的模式,让应用程序可以使用群集的方式进行运行,从而提高整体的处理能力。
理论上而言,应用的主进程,可以将处理任务尽可能的平均分摊到各个子进程当中,可以大幅度提升应用的总体处理能力。但根据不同的应用业务特点不同,可能在实际上,可能无法达到理想的效果。
例如,在笔者的测试环境当中,虽然笔者对应用程序进行了群集化改造。但从测试的结果(后详)来看,使用集群确实可以提高系统性能,但这个能力提升和使用的子进程数量,并不是线性关系。例如,我们使用单进程可以得到12158qps的处理能力,而使用4个子进程,只能将性能提高到25374qps,而不是4倍的关系。
其实笔者的系统是一个12个逻辑核的系统,但就算把所有的逻辑核都用上,也基本上没有再多的提升了。可能是本身这个机制比较简单,单独的处理器能力无法有效发挥的缘故吧。所以,在这种情况下,我们应当对应用程序进行相关的测试,选择比较合适的配置方式,不至于浪费低效的处理能力。
防抖处理
在进行抢位操作的时候,用户的心理是非常急躁的,他们会不停的刷新页面或者点击提交按钮。但实际上,对于业务应用而言,这些额外的操作和请求是没有意义的,所以最好有一个过滤和拦阻机制,就是防抖(Debouncing)。
在前后端分类的Web应用体系中,防抖一般在前端实现。最简单的实现方式就是在用户点击按钮,正常进行提交和网络请求之后,系统进入一个"冷静期",不再响应用户再次点击的操作。这样可以避免短时间之内,不合理的产生大量的网络请求。当然,为了安抚用户,比较好的防抖设计,还会友好的提示用户不要在短时间内重复提交,或者即使用户频繁点击,也有所响应,却不真正的发起网络请求。
关于这部分的具体实现,主要在前端配合完成,这里作为后端系统设计的内容,就不进行深入的探讨了。
限流处理
虽然防抖能够处理和整理大部分"正常"的业务请求,但考虑到实际上有很多请求其实是一种使用程序来模拟用户请求的计算机程序来构造的,这其实是一种不正常应用流量。所以,除了防抖之外,还需要在服务端,也实现一定的限流措施。
如果是服务使用多个后端集群,并使用负载均衡或者反向代理的系统架构,则限流的处理,应当由作为接入接口的负载均衡设备来实现,如可以选择硬件设备(如F5)或者软件(如Nginx或者Haproxy)等等。无论哪种实现方式,它们在内部机制中,都具备可以进行流量控制的功能。我们只需要进行合理的配置和使用即可,通常不需要在服务应用程序内部进行开发和实现。
如果确实需要应用程序本身提供相关的限流功能,可以选择成熟的限流算法,如固定窗口、滑动窗口、令牌桶等等算法,这并不是本文重点关注的问题,读者只需简单了解即可。
前后端分离
本文讨论的应用示例,本来就是一个接口设计,是假设Web应用本身已经实现了前后端分离的设计模式的。但如果读者想要在自己的传统Web应用中实践本文的设计构想的话,首先需要考虑的就是将Web应用改造成为前后端分离架构。
传统的Web应用来做秒杀,本身就要付出非常大的代价。一般情况下,完整的HTML页面包含的文本和数据,本身就比简单的接口数据要大很多,服务端不光需要处理业务数据,还需要进行页面的渲染和输出,性能和容量都会受到很大的挑战。而且,在抢位应用中,用户会进行频繁的页面表单提交,让这一情况进一步恶化,特别是这种情况,让防抖机制和服务状态管理不容易实施。也使一些前端处理和计算的实现比较困难,给前后端状态和信息协作带来了障碍,不利于系统的性能改进。
因此,在有可能的情况下,这一类应用,或者说所有的性能敏感的Web应用,都应当使用前后端分离的架构。
关于前后端设计和处理的相关内容,可以参考笔者另一篇博文:《前后端分离的Web应用开发模式的思考》 ,这里不再赘述。
缓存
对于一般的Web应用而言,缓存是一个非常重要而常用的性能优化手段。但笔者觉得比较意外的,本文的抢位应用的场景,基本上没有缓存技术可以发挥的空间。所以在实现中,考虑到应用和部署的简化,和业务本身的生命周期有限,就没有应用任何缓存技术。
也许在后续的改进中,可以考虑给响应数据进行缓存,也是比较合理的。因为理论上服务器确实只需要一次性的响应抢位请求结果,后续的请求都应该是无效的。
具体实现方面,可以分为服务端和客户端两种方式。服务端可以引入如Redis这种缓存,在重复请求时响应已生成的结果;在客户端,可以告知响应数据的缓存周期,由客户端浏览器或者程序来处理重复请求的问题。
硬件配置
在很多时候哦,特别是软件和架构优化到一定程度,可大幅度提升的可能性已经不是很高的情况下,直接升级硬件系统的配置,反而是一种效费比更高的方案。
以笔者后来进行的bun应用测试为例,原来的测试系统(Intel i5 3550S/4C)可以获得35000左右的qps,后来使用一台新的AMD Ryzen7 6800H/16C,基本相同的程序和架构,就取得了将近18万的qps。
笔者认为,需要注意的情况是,在硬件配置升级之前,可能需要先识别一下,硬件配置的瓶颈和要点在什么地方。本例中,这个瓶颈,或者说要点,应该是在核心的性能(IPC)和数量之上。很多时候,硬件的限制,主要是在内存容量、磁盘性能和网络性能上。如果能够有针对性的改善,可以以比较少的成本获取更高收益。
示例应用和代码
基于上面的思考和规划,笔者使用nodejs实现了一个示例接口程序。并进行了相关的性能和容量测试。
示例代码
示例代码在文中就显得比较长了(200行),笔者放在Github上了:
这个代码可以直接独立运行,没有其他依赖程序。在代码中,基本上已经实现和贯彻了前面几乎所有的主要相关设计和考量。如果需要应用到实际项目中,读者基本上只需要进行非常简单的调整和适配,特别是实现自己需要的异步业务处理过程,包括结果查询接口,就可以集成到自己的应用系统当中。
这里再从实现和代码等技术应用的角度,总结一下相关要点:
- 使用接口状态控制机制,控制在未开启、正常处理、需要排队、队列排满等的状态和相应的响应信息
- 使用hrtime精确的记录用户请求时间
- 使用cluster机制,充分利用多核硬件系统,主子进程间通过IPC协调并分工合作
- 子进程主要负责请求处理和响应
- 主进程主要负责状态管理和实际的数据处理服务
- 主进程启动后,发送消息,通知子进程进行各种操作
- 子进程记录请求信息后,立刻返回响应
- 子进程处理响应后,将实际业务信息传给主进行进行处理
- 主进程中,使用另一个服务程序来实际处理业务(现使用一个函数模拟)
- 后续用户可以通过另一个接口检查抢位结果或进一步操作(未具体实现)
基本业务处理流程
而在业务和使用的角度是这样的,相关的主要处理过程是这样的:
1 系统配置,设置开放/结束时间,抢位的数量/排队数量
2 系统启动,会自动基于时间检查接口状态
3 开放时间前请求,系统直接响应未就绪状态
4 开放时间后,接收请求,记录请求信息和时间(服务器),并压入内存队列,同时检查可用排位数量
5 如果计数正常,立即响应"OK(正常处理)"信息
6 如果超过保留数量,则响应"排队中"信息
7 如果超过排位预设值计数,响应"队列已满"信息
8 另一个服务程序,处理队列内容,并将处理结果写入数据库(需要外部实现)
9 用户需要再次检查和确认抢位结果,需要访问对应的实现接口,也可以在前端设置成为隔一段时间自动检查(需要外部实现)
本文提供的示例代码,主要还是针对抢位请求的核心处理环节。在完整的抢位应用中,还需要补充结果记录、后续查询、后续应用等环节,但这些内容已经可以和接口核心功能解耦,这里就不展开讨论了。
测试
上面的示例代码,能够在本地的Nodejs环境中运行,并且创建一个Web服务程序,可以用来模拟高性能的抢位应用场景。对于这个应用,我们可以使用curl命令行程序和autocannon批量测试程序,来对其进行功能和性能的测试。
使用curl进行测试非常简单,直接访问一个合适的地址,并且检查响���结果即可:
shell
-- 未开启或过时关闭
PS C:\Users\yanjh> curl http://127.0.0.1:5000/reg/0000
ERR:NotOpen
-- 正常
PS C:\Users\yanjh> curl http://127.0.0.1:5000/reg/0000
OK:01je6pkk-g5il-1da77ddab36b11aa883f108551fed1b7d6cad2e6
-- 已排满
PS C:\Users\yanjh> curl http://127.0.0.1:5000/reg/0000
FULL:003uddas-ife5-eda09fb659c3c71ac3db94e991224c916b39138d
显然,curl命令,只能模拟单个的HTTP请求,主要作开发测试时的功能检查来使用,真正的性能和压力测试,需要批量处理功能,来模拟大规模的并发请求。
笔者在这里,使用的是autucannon这个工具。
AutoCannon的安装和适配
简单操作起见,本项目中,使用一个简单的测试npm工具-autocannon来对接口进行测试。在使用这个工具之前,需要先将其作为npm进行全局化的安装:
npm i autocannon -g
一般情况下,安装完成后,就可以直接使用autocannon命令,来执行压力测试了。但在本例中,涉及到服务器端的处理,我们希望其能够处理和生成动态的内容。简单的autocannon命令行请求的地址是固定的,要修改可变的地址或者内容,需要对其进行编程,这个过程比较麻烦。笔者这里采用在服务器端进行处理来模拟可变的请求参数。
这里的修改主要内容是,理论上而言,客户端ID来自请求参数(URL的一部分,标识请求用户的ID,修改后为0000),但在测试的时候,我们将其替换成为一个随机字符串,作为客户端ID,就可以模拟很多不同客户端访问接口的状态。
当然需要注意,这只是为了测试而进行的修改。在实际部署应用的时候,需要再修改回来。
测试参数和结果
autocannon可以配置相关的测试参数来完成测试工作。常见的测试参数包括:
- -c 连接数量,此处为800
- -d 持续时间,此处为20秒
- 测试URL,安装程序配置,可以在本地接口完成
所以,完成的测试命令和执行结果如下:
shell
PS C:\Users\yanjh> autocannon -c 800 -d 20 http://127.0.0.1:5000/reg/0000
Running 20s test @ http://127.0.0.1:5000/reg/0000
800 connections
┌─────────┬───────┬───────┬───────┬───────┬─────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼───────┼───────┼───────┼───────┼─────────┼──────────┼────────┤
│ Latency │ 60 ms │ 63 ms │ 85 ms │ 92 ms │ 65.3 ms │ 11.46 ms │ 250 ms │
└─────────┴───────┴───────┴───────┴───────┴─────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬────────┬─────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼────────┼─────────┤
│ Req/Sec │ 8,599 │ 8,599 │ 12,327 │ 12,879 │ 12,158.4 │ 879.49 │ 8,599 │
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼────────┼─────────┤
│ Bytes/Sec │ 1.78 MB │ 1.78 MB │ 2.56 MB │ 2.68 MB │ 2.53 MB │ 185 kB │ 1.78 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴────────┴─────────┘
Req/Bytes counts sampled once per second.
# of samples: 20
244k requests in 20.11s, 50.6 MB read
PS C:\Users\yanjh> autocannon -c 800 -d 20 http://127.0.0.1:5000/reg/0000
Running 20s test @ http://127.0.0.1:5000/reg/0000
800 connections
┌─────────┬───────┬───────┬───────┬───────┬──────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼───────┼───────┼───────┼───────┼──────────┼──────────┼────────┤
│ Latency │ 12 ms │ 23 ms │ 65 ms │ 70 ms │ 31.12 ms │ 18.94 ms │ 237 ms │
└─────────┴───────┴───────┴───────┴───────┴──────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬──────────┬─────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
│ Req/Sec │ 16,607 │ 16,607 │ 25,855 │ 28,415 │ 25,374.4 │ 2,290.82 │ 16,604 │
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
│ Bytes/Sec │ 3.45 MB │ 3.45 MB │ 5.38 MB │ 5.91 MB │ 5.28 MB │ 478 kB │ 3.44 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴──────────┴─────────┘
Req/Bytes counts sampled once per second.
# of samples: 20
508k requests in 20.14s, 106 MB read
前面已经提到,这里其实进行了两次测试,分别是单线程和cluster的执行模式,后者使用了4个逻辑CPU。我们以后者为准,可以看到示例中应用的主要相关性能如下:
- 响应延迟: 平均31.12ms
- 请求处理性能:平均 25374qps
- 流量: 5.28MB/s
- 容量: 20秒内完成51万个请求
笔者系统的软硬件配置如下:
- CPU: MD Ryzen 5 3600 6-Core Processor,3.6GHz
- OS: Windows 10 Enterprise 22H2
- MEM: 32G, 但实际上示例程序包括子进程的占用只有70M左右
- Nodejs: V22.2
番外: BUN
在进行系统架构和实现的过程中,基于性能优化的考量,笔者突然想到还有另一个JS技术框架可以选择,就是以性能而著称的bun。deno暂时就不考虑了,因为它基本上是一个nodejs的优化版本,主要解决的是nodejs在开发过程方面的一些历史和设计问题,本身在性能和执行方面并没有太大的进步。而Bun的主要诉求,就是为了提供更高的性能。
幸运的是,将笔者测试使用的项目,从nodejs移植到bun环境,并没有遇到太大的麻烦。毕竟,bun也是一个标准的JS执行环境,并且其在设计实现过程中,也充分考虑到了nodejs生态的兼容。
移植的bun程序,和原本的nodejs程序,最大的差异,在于以下两点,就是Web服务的实现,和群集模式的实现,下面是相关的代码:
ts
// server.ts
import { serve } from "bun";
import { createHmac } from "crypto";
const slaveID = Math.random().toString(36).slice(2,8);
....
process.on("message", (message) => {
// print message from parent
// console.log("From Master:",message);
switch(message?.code) {
case "start":
SVKEY = message.SVKEY;
serve({
hostname: message.HOST || "127.0.0.1" ,
port: message.PORT || 6080,
development: false,
reusePort: true, // Share the same port across multiple processes This is the important part!
fetch: handleReq
});
break;
}
});
// 主程序, 使用spawn而非cluster机制
// index.ts
import { spawn } from "bun";
for (let i = 0; i < cpus; i++) {
buns[i] = spawn({
cmd: ["bun", "./server.ts"],
ipc: onMessage,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
}
这里不过于深入讨论实现细节。简单而言就是bun程序使用ts语言,语法和nodejs基本上是一样的。相关的模块和功能,除了使用方法之外,实现的机制和nodejs差异也不是很大。移植和重构也是很方便的。其他比如和开发调试工具的集成,包括第三方扩展程序的兼容等等,现在笔者没有时间和机会来进行验证。
笔者修改后的代码,也放在github之上了,有兴趣的读者可以研究和运行测试一下:
最后再来说一下,测试过程中,笔者还走了一个小小的弯路。笔者最初的测试开发,是在Windows平台上完成的。当时基本也没有用群集模式,就是简单的应用服务。测试的结果大概标准的nodejs程序是13000qps,但使用bun大概也是这个数值。当时就觉得好像有问题。但后来有机会找到了一台linux的物理机器(配置不高, intel i5 3550s),就简单移植测试了一下,得到了下面这个测试结果:
shell
[yanjh@john-eosdev ~]$ oha http://192.168.10.81:6080/reg/0000 -z 10s -c 500
Summary:
Success rate: 100.00%
Total: 10.0253 secs
Summary:
Success rate: 100.00%
Total: 10.0253 secs
Slowest: 0.1408 secs
Fastest: 0.0001 secs
Average: 0.0134 secs
Requests/sec: 37112.7847
Total data: 8.87 MiB
Size/request: 25 B
Size/sec: 905.91 KiB
Response time histogram:
0.000 [1] |
0.014 [234175] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.028 [122179] |■■■■■■■■■■■■■■■■
0.042 [13586] |■
0.056 [1377] |
0.070 [219] |
0.085 [30] |
0.099 [175] |
0.113 [87] |
0.127 [148] |
0.141 [23] |
可以看到,bun群集模式,做到37000qps,平均响应使用20ms,确实是比nodejs的性能要好一点。虽然没有其官方网站上说的那么夸张,但如果没有其他的因素,作为简单的Web应用,又可以保证稳定可靠的话,是值得迁移到bun上面的。毕竟直接可以多出一万多的qps,对于这个抢位的应用而言,是非常可观的。
另外这个测试结果还说明了一个问题:开发无所谓,但生产环境或者看重性能的话,不要在Windows上运行bun应用程序。
后来,笔者还有机会在另外一台PC上进行了测试,发现了bun好像确实更能够发挥硬件的能力:
shell
yanjh@DESKTOP-3HKFPPI:~$ oha http://127.0.0.1:6080/reg/0000 -z 10s -c 500
Summary:
Success rate: 100.00%
Total: 10.0042 secs
Slowest: 0.0823 secs
Fastest: 0.0000 secs
Average: 0.0028 secs
Requests/sec: 179987.7940
Total data: 42.93 MiB
Size/request: 25 B
Size/sec: 4.29 MiB
Response time histogram:
0.000 [1] |
0.008 [1714194] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.016 [75356] |■
0.025 [9492] |
0.033 [1061] |
0.041 [132] |
0.049 [63] |
0.058 [63] |
0.066 [137] |
0.074 [56] |
0.082 [10] |
一些问题
在本文成文和实践过程中,笔者发现可能有一些问题,但由于时间和环境的限制,尚没有一个明确的结论和答案,这里先记录下来,帮助后续的思考和内容完善。
- 用户认证
本文示例中,完全没有考虑用户认证的问题。理论上,抢位之前,用户应当已经完成了用户认证的过程。抢位操作时,请求信息也应当被带在请求中,便于服务端进行区别和处理。具体实现方面,可以考虑URL和Header的方式,但现在还没有机会进行验证,并检查这些处理对性能和业务的影响。
- 负载均衡部署的影响
逻辑上而言,可以将本接口设计使用负载均衡的方式进行横向扩展。但这里有一个问题,就是请求的时间问题。在单个节点中,虽然使用多线程,但高精度时间也是可以保证的。但如果跨多个系统的话,如何保证这个高精度时间也可以同样有效呢。初步的构想是尽量保证应用节点的时间同步,此外使用一个机制,能够在抢位开始前,在应用节点中,设置相同的起始时间戳。希望有机会可以验证一下。
- 排序的极端情况
前面我们已经提到了,可以使用高精度时间,将抢位排序依据的时间分辨率提高到了毫秒级。但如果这个分辨率还是不够,是否可以进一步提高。此外,如果不需要继续提供,是否可以接受再加入额外的排序依据来确定顺序。基本的想法就是将时间结合用户ID在做一遍hash,来作为次要的排序依据,应当就可以很好的解决问题。
小结
本文探讨了设计一个高并发大规模抢位应用服务,应当注意的问题,和可以采取的一些技术处理措施,包括异步处理、简化请求响应、高精度时间、群集运行模式等等,希望对有机会架构类似系统的读者有所启发和帮助。