后端服务性能测试能力建设 101
本文作者为来自 RingCentral 铃盛的 Henry
前言
随着 Webinar 项目在铃盛中国落地,测试开发团队 (SDET)也收到为项目提供后端服务性能测试解决方案的需求。由于我们组之前并没有承担同类任务,因此这次工作的开展对团队而言也算得上是一次从零开始的能力建设。在提供支持的过程中,团队无论是在认知层面还是执行层面都曾遇到过不少问题,但我们也通过解决这些问题收获了大量的第一手知识和经验。本文打算通过对这些经验进行分享,一方面作为相关工作的总结和回顾,一方面也是希望能借此与大家进行交流。如标题所示,本文主要会关注基础性的话题,所以也希望非技术线的同学也能通过本文对我们的工作有所了解。
需要提醒的是,本文中所讨论的性能测试专指面向后端服务的性能测试,与 SDET 团队所提供的另一项针对客户端的性能测试方案是两个截然不同的领域。针对客户端进行的性能测试更侧重于确保客户端体验和可靠性,而针对后端服务进行的性能测试则有不同的目的。所以接下来我们就从后端性能测试需要解决什么问题开始聊起吧!
性能测试目标
任何测试的执行都意味着成本的支出,正因如此当我们接到一项测试需求时,首先需要明确我们能得到什么回报。那么,对后端服务进行性能测试能如何帮助团队更好地交付软件?根据实践的经验总结,大概可以归纳为以下三个方面。
度量性能指标
后端服务在进行设计时通常都会对性能表现有一定的预期,因此当团队交付一个后端组件时质量团队除了需要对它的功能是否符合要求进行验证外,还需要对它的关键性能指标进行测量以确保它满足设计需求。类似的度量需求也会出现在进行性能优化时,以及跨版本的性能基准测试中。指标度量的另一个意义是为生产部署提供依据,也即回答我们需要准备多少的系统资源,才能满足预计的使用需求. 对于使用了弹性伸缩技术的系统,测量结果也可以做为设定阈值的依据。
衡量服务性能的基本性能指标包括响应时间和吞吐率,我们会在后面进一步介绍它们的意义。除了服务自身的指标,相应的系统指标也需要关注,常见的系统指标包括 CPU、内存、网络、磁盘 IO 等。
验证系统可靠性
系统的可靠性对于服务能否长时间运行十分重要,它也是性能测试的目标之一,即对服务在高压下的表现是否符合预期进行判断。一些重要的系统健壮性特征包括:是否存在异常的资源使用(如内存泄漏、文件句柄泄漏等),服务在达到压力阈值后是否正确触发弹性伸缩,扩容后的服务负载是否均衡,服务崩溃后是否被正确隔离, 服务在崩溃后能否自行恢复等。
模拟真实场景
测试的目的在于减少问题发生的风险,因此很自然地,如果条件允许,相信绝大多数人会希望在系统上线前能对真实场景进行模拟来进一步排除可能存在的风险。除了风险预防,人们往往也会希望在生产系统出现性能问题后能复现出相同的错误场景,以确保问题能被正确修复,此时也会有对问题场景进行模拟的需求。针对不同服务的特点,此类测试通常会采用流量回放或者流量合成的方法来实施。
术语定义
在一般语境下,性能测试/负载测试/压力测试往往会被视为是同类型测试,因为这类测试往往需要通过对服务施加不同程度的压力来实现,执行测试的的工具和方法也有很大重合,因此经常会被不加以区分地使用。但在实际工作中我们发现,如此混用会在沟通中,尤其是跨团队和跨国的沟通中带来一些不必要的麻烦。为了避免这样的情况出现,在参照多数共识的情况下我们重新对他们的定义进行确认。负载测试 (Load Testing) 专指以在不同负载水平下以度量性能指标为目标的测试,而压力测试(Stress Testing)专指以分析系统健壮性为目标的测试。对于以模拟真实场景为目标的测试,我们称其为仿真测试(Simulation Testing)。而在仿真测试的基础之上,我们还通过与手工测试团队进行合作来进一步保障系统质量,这样的测试被我们称为混合测试(Hybrid Testing)。由于以上测试或多或少都围绕着性能这一核心要素,因此统称为性能测试(Performance Testing)。
在 Webinar 项目中这些测试类型都有不同程度的应用,我们会在后面进行具体的介绍。不过我们的筑基工作还完成,眼下只完成了目标的确认,在开始介绍具体的方法和案例前,我们先简单讨论下必要的基础知识。
知识基础
实际经历告诉我们,在缺少经验的情况下动手开展性能测试,往往一开始就会遇到类似这样的问题:“需要给系统施加多大的压力”,“哪些指标需要度量”,“测试参数的设计是否合理”,“测试是否通过”。性能测试的目的之一就是去获取这些未知的系统参数来帮助团队更好地了解系统,所以通常在测试开始前这些问题并不存在现成的答案。也因此性能测试实际上可以被视为是一种探索性测试。而探索性测试最应该避免的情况就是漫无目的地去乱测一通,乱测一通的后果常常会是无法得到想要的结果,或者是没有能力对结果进行分析,从而无法为团队提供有效的反馈。为了避免这些问题,我们首先得对被测系统在不同负载下的响应特征有一定的了解。
系统模型
一个被普遍使用的系统模型如上图所示,这个模型展示了负载(Load),响应时间(Response Time)和吞吐率(Throughput)之间的关系。理解这个模型以及这三个可度量指标的关系能为性能测试方案的设计带来很大帮助。
熟悉随机过程的同学可能会意识到这个图某种程度上对应了排队论里最简单的 D/D/c/K 模型,即固定到达率/固定服务时间/服务率 c/队列长度 K. 如果你观察一个初始负载为 0 的 D/D/c/K 系统的吞吐率与到达率的关系,你就会观察到下面的图形。与前面的负载模型对比很容易就可以看出二者存在某种联系。接下来让我们丢掉这些抽象的概念,通过一个简单的超市收银的例子来理解这个模型背后的原理。
假设一家超市的收银区有 C 个收银柜台,收银区最多可容纳 K 个人,每笔交易所需的处理时间是固定的 (Deterministic service time),并且用户的到达时间间隔也是固定的(Deterministic arrival interval)那么这个排队模型就可以被记 D/D/c/K.
因为柜台数量和服务时间是固定的,因此这家超市每秒可处理的交易数也是固定的,这个固定值即为吞吐率,通常以每秒可处理请求数(Request Per Second,RPS)来度量。顾客从进入收银区排队到完成交易离开所需的时间即为响应时间,响应时间由排队时间和服务时间构成。而停留在这个收银区的总人数,包括排队中的人数和正在接受服务的人数即为系统的负载,用并发用户数,或者简称并发数(Concurrency)表示。
根据上面的定义,我们很容易可以看出来,在每一时刻, 它们三者满足下面这个简单的关系:
吞吐率 x 响应时间 = 并发数
固定负载 vs 固定到达率
看完模型介绍或许会想,知道这些知识有什么用呢?实际执行过性能测试的朋友可能会留意到测试工具往往会提供多种压力生成模式,而最常见的两种模式莫过于固定负载(即固定请求并发数)和固定请求到达速率(即固定每秒请求数)。对于什么时候该采用何种模式相信不少人可能都会有疑问。而通过上面的模型就可以很好地解答这个问题。
固定系统负载
如果你能使系统里在任意时刻的负载是固定的(处于系统中的总人数固定不变), 即每完成一个请求就立即发起一个新的请求,此时系统就会保持在一个称之为平稳过程的特殊情形,在这种情形下任意时刻吞吐率和响应时间的期望值都将会是一个常数。这个性质对于测量指标十分友好,只需要设定一个固定的并发数保持一段时间,就可以通过简单的求均值的方式相对精确地评估系统的响应时间和吞吐率。有些工具如 JMeter 还会提供所谓的 Ramp Up 模式,即在一个特定的并发数上测试一段时间并计算出吞吐率和响应时间后,自动将并发数增加到一个新的水平上重复进行测试,由此即可得出不同负载水平下的性能指标。
为了能测量到更可靠的结果,测试的持续时间需要足够长,因为我们是使用统计的方式来获得测量数据,因此数据越多一般来说结果也会越精确。具体需要执行多长时间测试可以根据实际测试的效果来定。如果非要提供一个参考依据的话,那么可以尝试下薛定谔介绍的根号 N 方法,即如果执行实验的次数是 N,那么所得结果的误大概会是根号 N 分之一。根据这个方法,进行 10,000 次请求误差大概能被控制在 1%。考虑到实际测试中被测系统需要一定时间才能进入稳定状态,而临近结束时的实际负载往往会低于设定值,因此在计算平均吞吐率和响应时间时舍弃测试刚开始时和临近结束时的部分数据可以进一步提高结果度量的准确性。
固定请求到达率
除了固定负载外,另一种常见的测试模式是固定请求的到达速率(以 RPS 为单位),采用这种测试模式时,如果设置的速率小于系统的最大吞吐率,实际上可以认为与固定负载模式是相同的,因为此时系统处于线性区,一个恒定的请求到达速率对应于一个恒定的系统负载。
但如果把请求速率设定为一个大于系统最大吞吐率的数值时,未被及时处理的请求会积压在系统中,此时会观察到系统的负载将会随时间以一种近似线性的方式持续增加,即并发用户数持续增大。在这种情形下系统始终无法处于稳定状态,所以我们无法通过简单的求均值的方式对参数进行估算,因此通常不建议在参数测量时使用这样的模式。这一模式更适合于对系统进行定性的观察和分析,例如分析一个实测最大吞吐率为 100RPS 的系统在面临 200RPS 的请求速率时可以存活多长时间,期间会发生什么等。
模型特点
从模型的图形我们可以很容易根据性质的差异将它划分为三个区域,分别是低负载、高负载和塌陷区。每个区域都具备不同的性质,简单讨论如下:
低负载 (线性)
在这一水平下,由于系统资源使用率还未饱和,尚未出现排队等待资源的情况,因此在这一阶段随着负载的增加不会出现性能下降的情况。具体则体现在响应时间的基本不变,而系统吞吐率则会随负载线性增加,直到进入高负载区。用上面超市的例子来理解就是当收银区的总人数小于柜台数时,无论来多少人都可以立即得到服务而无需排队,所以每个人通过收银区的时间是一个常数。
实际系统中由于随着负载增加会存在资源竞争的情况,所以事实上响应时间还是会有所变化,但变化的幅度较小,因此在估算时近似认为是常数通常也不会有太大问题。
高负载 (饱和)
在这一水平下,由于系统资源的使用已经饱和,请求已经开始出现排队但队列尚未溢出的情况,因此在这一区间的主要特征表现为无论并发数如何增加,系统的吞吐率近似可以认为是一个常数,性能则开始出现下降,直到进入过载区。用上面超市的例子来理解就是此时收银区已经开始出现排队的情况,所有柜台满负荷运行,所以此时单位时间离开收银区的人数(即吞吐率)是固定的,但通过收银区的平均时间会随着收银区的总人数增加而增加(因为平均排队时长正比于总负载)。
塌陷 (崩溃)
当系统进入这个区域时可以认为服务已经处于不可用的状态,已经出现请求因资源不足被放弃或处理失败的情况,具体表现为吞吐率和成功率的急剧下降。实际生产中必需要避免系统进入这样的状态。如果不幸进入这个状态系统需要具备在负载减少后能自行恢复的能力,这也是为什么我们需要对系统的可靠性进行测试的原因。
应用
理解模型性质对于分析测试结果和设计出高效的测试方案有很大意义。如果要快速估算出一个系统的最大吞吐率,只要选取一个足够大的负载进行一次测试即可,因为在饱和区吞吐率近似是个常数。另一方面,我们只需要选取两组较小的负载,测得对应的吞吐率,就可以估算出线性区的斜率。再结合先前测出的最大吞吐率,就可以计算出从低负载进入到高负载状态的拐点大概在什么位置,这样一来我们对服务整体的性能表现就能有一个基本的了解。在这个基础上可以进一步设置不同的负载来测量系统的性能。相对地,如果测试数据与模拟预测结果有效大偏差,则需要考虑测试方法是否存在不严谨的地方,或者对服务和资源配置进行分析以对模型进行必要的修正等。
案例分享
前面花了很大的篇幅介绍了测试的目的和基本的理论知识,现在终于可以开始讲讲如何实际应用这些知识来制定和实际执行相应的测试方案。
静态网站性能测试
由于 Webinar 的参会者在开始时需要通过网页进入会议, 这一产品特性决定了该网站访问峰值会集中在 Webinar 开始的时刻,因此对它进行性能测试就十分必要。而这个案例还有一个额外的背景是,由于在一次内部众测中已经发现该网站实际的性能水平远低于预期,通过对整体架构进行评审研发团队已经发现架构本身存在不合理的设计,计划重构方案采用 CDN 进行分流。但对于是立即部署新的方案,还是先对旧方案进行改良做为过渡仍然无法统一意见。综合以上原因,我们希望能通过测试为决策提供参考依据。
目标明确后,接下就该着手设计方案了。对静态网站进行性能测试大概是所有性能测试中最简单的科目,但有些问题还是需要实际弄脏手后才知道答案,所以这里我们还是简单讨论下思路做为案例分享的前菜吧。
还记得我们之前提到过,性能测试可以被认为是一种探索性测试,在进行性能测试时一个颇为有用的原则是:不需要在一开始就指望通过一个面面具到的方法去得到所有问题的答案;相反地,先尝试用最简单的方法能得到容易获取的答案,再根据得到的结果改进方法往往是一种更为有效的探索方式。
先前我们有提到,性能测试关注的两大性能指标是响应时间和吞吐率。由于响应时间对于网络带宽十分敏感,而这次的测试任务所提供的环境无法保障我们能得到充分的带宽,所以我们不妨先只把注意力放在对带宽不敏感的吞吐率上。另一方面,打开一个静态网站实际上往往需要加载大量不同的静态资源,如果要对每个地址都进行测试显然会增加测试的复杂度。这里我们不妨再大胆做一次简化,只针对首页的 index.html 加载进行测试。如果这样的方案行不通,我们再考虑对测试方法进行改进。
方案简化到这个程度后就可以开始着手挑选合适的工具。由于测试环境的特殊性,我们只有 2 台 t3.small ec2 实例可被用于这次任务,因此测试工具本身最好不要有太大开销。综合考虑下 wrk 被选中用于执行这次测试。
在测试设计方面,由于我们优先关注吞吐率,因此选择跳过低负载区而直接针对高负载区进行测试。具体来说就是针对特定的 URL 以 1000,2000,4000,6000 并发数进行每组 30s 的测试,由于测试目标之一是验证对旧方案进行改良是否有效,因此我们也验证了不同服务实例数(2, 3, 4)下的性能数据。测试结束后记录每组测试的并发数、响应时间、吞吐率和错误率等数据到测试报告中进行分析,最后得到以下结论:
继续沿用当前架构,每增加一个实例只能提供额外 700RPS 的吞吐率。
在 6000 并发水平时开始观察到服务器返回 500 错误。
为了进一步演示在 6000 并发数下已出现异常,我们又额外追加如下测试场景:在并发数为 6000 的情况下将 wrk 的测试执行时间设定为 2 分钟,同时手动在浏览器打开页面进行验证。通过这样的测试手段我们不出意外地在浏览器中也观察到页面因服务返回 500 错误而无法完成加载的情况,直观地演示了实际存在的问题。在进一步对后端组件的监控数据进行分析进一步确认存在的风险后,团队最后决定立即采用基于 CDN 的方案进行部署也就顺理成章了。
当然,实际测试的执行还有很多值得关注的工程细节,例如调整 Linux 的系统和内核参数以避免默认配置成为限制;以及安装监控工具对节点性能进行监控等。这些内容在网络上已经有很多公开成熟的讨论,会在文后提供相应的链接供大家参考,此处就不进行展开。
系统级性能测试能力建设
需求背景
对于采用了微服务架构的团队来说,在实施性能测试时, 通常会需要在两种不同条件下进行测试。其中一种我们称之为组件级测试,这种测试会将被测组件的所有外部依赖都进行隔离,只对组件本身的性能进行测试。另一种则被我们称之为系统级测试,即将被测组件放置于真实部署的测试环境后再对其进行测试。
两种不同类型的测试都有其存在的意义。组件级测试由于隔离了外部依赖,因此测试起来简单快速,而且相同条件下多次测试的结果能保持较好的一致性。这些特点使得组件级测试十分适合做为基准测试添加至 CI 系统中自动执行。但另一方面,由于组件级测试隔离了真实的外部依赖,测试结果只反映组件自身性能,且测试条件过于理想,从防范综合风险的角度看,只依赖组件级测试显然是不够的,因此也就有了系统级性能测试的需求。而相比于实施组件级的测试,实施系统级测试会更为复杂,因此需要在执行方案和自动化工具的选择上做更慎重的选择。
方案选择
以往的经验告诉我们,自动化测试落地的成功与否关键在于能耐二字。能即能力,即是否掌握相应的工具和方法。在开源社区为这一领域贡献了大量成熟易用工具的今天,找到一个现成的方案并实施并不是什么难事,真正的难点在于是否有确保它们始终有效地被执行的耐力,也即耐字上。这一点在产品迭代越来越快的今天尤其重要。很多团队和个人一开始满怀热情地投入测试自动化中但却半途而废的,或者无法真正大规模应用,问题往往出在无法应对产品特性持续地变更和应对各种可能存在的外部问题上。正因为如此,在系统级性能测试的能力建设这一课题上,一个必需要提前思考清楚的问题是,我们选择的方案是否具备可持续性,这将决定我们最终得到的是一个长期投入成本可接受的有效解决方案,还是一个仅仅可供演示的噱头。
执行组件级测试时我们往往可以通过合理设计的 mock 数据来简化测试执行所需的前置步骤,而在执行系统级测试时这样的方法往往行不通。执行系统级测试时往往需要通过调用一系列业务接口将被测系统设置到一个正确的状态后才能开始对目标执行测试,以 Webinar 项目为例,假设我们的测试目标是对 10,000 实名用户加入会议接口的性能进行验证,那么为了能在系统中真实模拟这个场景,我们需要先执行完包括用户登录=>预约会议=>获取 10,000 参会者信息=>向所有参会者发出邀请=>获取他们唯一的参会接口=>开始会议等一系列操作后,才能开始对加入会议这个接口进行测试。
面对这种情况,我们首先对可能的备选方案进行评估。首先考虑使用 JMeter 做为我们的压测工具并以实现完全自动化为目标。可以预见的是如果选这一方案,我们需要为所有使用到的业务接口编写 Java 代码,并且随着被测系统的版本演进需要持续投入资源进行维护以确保的接口实现与调用逻辑与当前被测系统的版本一致。完成这些工作相当于要求将开发团队已经通过 TypeScript 实现过的业务逻辑重新用 Java 实现一次,这其中除了需要编写庞大的代码库外,还会涉及到大量的跨团队沟通工作,这些都需要占用开发人员大量时间,因此我们认为这一方案不具备可持续性,对于一个正处于快速迭代阶段的系统更是如此。同时我们也了解到公司有使用 JMeter 的国外团队因为类似的原因在实施同类测试时并不顺利,因此这一选项被暂时搁置。
我们也对另一个流行的开源解决方案 k6 进行了评估,考查它的原因在于它支持以 JavaScript 编写脚本,可能有望解决测试成本的问题。但进一步了解后发现它所使用的 JavaScript 执行引擎实际上无法做到让我们直接复用研发团队代码的愿望,因此这一方案也被暂时搁置。不过相比于 JMeter,k6 在许多应用场景上其独特的优势,因此我们也会考虑未来在一些合适的场景里使用它替代目前在其它场景下使用的 JMeter.
除此之外我们也对其它开源方案,如 Gatling,artillery 都进行了评估,但都无法完全满足我们的预期(但也不否认这些工具的强大之处)。虽然评估的几个方案并不满足我们对工具的需求,但这个过程也帮助我们对于需要找到怎样的解决方案有了更清楚的认识。
为了能有效控代码维护的成本,我们希望能对开发团队的代码进行复用。我们之所以对代码复用如此执着,除了“懒”和“抠”之外,还有一个重要的原因来自于我们对开发团队的信心。我们曾在更早的时候对 Webinar 项目的代码有所了解。该项目的客户端基于 Web 技术构建。开发团队在进行项目规划时严格将 UI 相关的代码与 UI 无关的代码(我们将其统称为 Headless 代码)进行隔离,并且以独立的 npm 包形式发布以供第三方使用。这部分 Headless 代码不仅包括了较底层的由 OpenAPI 自动生成的 HTTP Client 和开发团队编写的 WebSocket 通信协议封装,也包含了更上层的业务逻辑代码。
我们曾经对这部分 Headless 代码在 Node.js 环境使用的可行性进行过验证,得到的结果十分乐观:我们不仅能像在浏览器中一样直接在 Node.js 里用使用些代码,我们甚至能在单一进程内实例化出多个互相隔离的客户端实例, 这意味着我们可以轻易地在单一进程中模拟大量的用户。而 Node.js 本身的异步特性使它天然地就胜任 IO 密集型任务。如果我们能通过在 Node.js 中重用开发团队代码的方式来做为我们的解决方案,我们就有信心能控制解决方案的成本,保障测试的可持续性。
技术方案设计
既然寻找现成的方案无果,那接下来我们就需要评估以团队现有的积累是否有能力通过创新性的手段来攒出一个满足项目需求的解决方案。
虽然现在我们已经确定了通过 Node.js 做为我们的执行环境来实现对代码的重用,但这只解决了最基本的如何生成压力的问题。作为一个完整的性能测试解决方案,我们还需提供以下两方面能力。
其一是数据采集和分析的能力。还记得在最初的测试目标中我们讨论过性能测试的目标之一是就是性能指标的测量,为此我们需要记录每个一请求发生的时间,持续的时长,请求的方法和地址,响应的状态码等数据,通过这些数据我们就可以通过分析工具得到包括吞吐量,响应时间和负载等重要指标。我们曾经在其它项目中使用过 Jupyter,核心方案是通过 pandas 进行数据分析,再由 plotly 进行可视化,因此我们有自信数据分析不是问题。
而在数据采集层面,如果采用手动埋点的方式,这显然又是一项费时费力的工作。做为一个合格的“懒”人,我们当然首先要先寻找是否有现成的方案。答案是显然的,在实现可观测性(Observability)已然成为后端服务最佳实践的今天,找到一个支持 Node.js 的开源 APM 解决方案来实现 HTTP 请求的自动采集并不是什么难事。通过评估和验证最终我们选择了基于 Microsoft ApplicationInsight 实现我们的数据采集方案。当然,对于非 HTTP 请求的性能指标,比如测量由多个请求所构成的事务,我们也支持手动添加埋点。在最终的实现里,采集到的数据除了会保存在本地文件中,在测试结束后由脚本汇总交给 JupyterLab 生成详细的 HTML 分析报告外,同时也支持上报到 InfluxDB 中,由 Grafana 展示测试的实时状态。
另一个我们需要提供的能力是向目标系统生成足够压力的能力。虽然之前我们提到我们能在一个 Node.js 进程中通过实例化多个客户端的方式来生成大量负载,但也不要天真地认为只用一个进程就能满足所压力生成的需求。如何解决这个问题,相信很多人能脱口而出地说出答案:分布式,具体来说就是通过控制一组分布在同一机器或者不同机器上的多个 Node.js 进程来生成压力。如果要实现分布式,那不避免地就要实现 RPC 协议来满足进程间通信的需求。如果已经想到这点,那说明你是一个合格的工程师,那么我相信你也知道,要实现分布式协议,从接口设计到服务实现其实都需要花费不少时间,即使是借助支持代码自动生成的 Swagger 或者 gRPC 也未必见得会是一件轻松的事情。此时做为一个合格的“懒”人就应该开始思考:如何能不依靠 RPC 实现分布式的代码执行?
一般情况下,如果你提出这样的想法,身边的工程师多半会用一种同情的眼光看你。因为这个想法就如同告诉别人你要发明永动机一样,会被认为缺乏常识。但如果结合这里采用的特定的技术栈,其实会发现这个想法并非异想天开。
SDET 团队所承担的另一项重要职责是为不同的项目团队提供自动化测试解决方案,目前部门每天实际执行的脚本总数,单计入 UI 自动化部分,保守估计有 3 万条/次以上。而其中有很大一部分 Web 自动化是基于 Puppeteer 实现的。熟悉 Puppeteer 原理的同学应该会知道它依赖一个被称为 Chrome Debug Protocol(CDP)的调试协议。正是由于这个协议,我们才能在自动化测试中向浏览器注入代码;也正是因为 CDP,我们才能在浏览器或者 VsCode 中对在 Node.js 执行的代码进行在线调试。所以自然地,如果我们能通过 CDP 协议在 Node.js 中执行代码,也即把 CDP 协议作为一个 RPC 协议来使用,分布式执行的问题不就迎刃而解了么?于是我们就有了如图所示的解决方案。
敲定完总体的设计后,我们还需要思考一个重要问题:如何规划代码结构?很显然,如果我们把所有的代码都通过 CDP 下发到每个 Node.js 执行,那么所编写的测试脚本就会变得十分臃肿,不利于长期维护。为了解决这个问题,我们可以考虑借鉴游戏设计中的一些做法。话说起来,测试开发与游戏设计看起来像是两个八杆子打不着的领域,但在思考方式上其实颇有相通的地方。想想看,在测试脚本中同时控制大量虚拟用户向目标接口发起请求这件事,与在花园里摆上一堆植物来打僵尸不是一回事么?
基于这样的想法,我们的代码分为两个部分,一部分用于实现不同类型的虚拟用户的能力(相当于建模不同种类的植物),这部分代码会在 Node.js 进程启动时通过-r 选项被加载,可以通过暴露在全局变量中的入口访问和使用这部分代码。另一部分则是测试脚本,测试脚本通过 CDP 连接到所有的 Node.js 进程后通过调用预加载到远程进程中的代码来控制测试的执行流程(相当于将植物布置到场地上)。这样一来不仅使得脚本的编写和维护更加轻松,也让它们具备更好的可读性。
所有问题都解决后,最后我们通过一个脚本片段来了解它实际的工作方式。
在上面的代码片段中,测试脚本首先通过 CDP 连接到 3 个 Node.js 进程。这些进程时在启动时已经将 clientManager 和 lodash,BlueBird 等工具库加载到全局变量中。其中的 clientManager 是我们设计用来批量操作多个虚拟用户实例的一个门面封装。接下便是在 ResourceMapper 中规划资源的使用。ResourceMapper 的设计参考了大数据中常见的 map 操作。此例中我们将总数为 600 的虚拟用户平均分配到 3 个进程中,并为它们提供唯一 ID,通过这一操作 ResourceMapper 会记录每个资源 ID 所在的进程,未来只需要指定 ID 就可以通过 map 操作自动将操作分发到相应的进程中。至此我们就完成了所有准备工作。
接下来的代码演示了如何对用户的登录行为进行性能测试。首先我们要选定执行这项操作的用户 ID,这里选择全部用户,因此 map 操作的目标 ID 为[0, 600).接下来我们需要再传入一个方法来决定我们要在每个 Node.js 上执行怎样的操作。这里需要注意,19-27 行代码会通过 CDP 被发送到远程的 Node.js 进程中执行。我们通过在本地使用 declare 来声明在远程进程里已定义好的全局变量的方式,使得我们在编写这部分代码的时候依旧可以享受 TypeScript 和 VsCode 所带来的代码自动补全的便利。同时代码执行所依赖的参数列表通过第 27 行代码发送到远程进程中,它的实现利用了 JavaScript 函数的 toString 和 apply 方法。而具体执行的测试内容相信大家通过代码本身也可以看出:配置用户登录信息,执行登录操作,加入到指定的会议链接中。至于实际的请求是如何被构建和发送的,则是利用了开发团队所发布的代码库。从脚本片段中可以看出先前所定下的可读性和可维护性目标被切实地达成了。
在知识基础部分我们有提到,性能测试中一个很重要的部分是控制压力生成模式。此例采用的是最常见的固定负载的方案,也就是固定并发率的形式。第 29 行代码决定了并发执行操作的进程数,这里设置为 3,即让 3 个进程同时执行操作。第 26 行代码则决定了每个进程内的虚拟用户的并发数,这里设定为 10。这样一来,实际的并发数就等于二者相乘的结果 30。需要指出的是,这里的压力控制并非框架所提供的能力。此例中我们使用 BlueBird 提供的 map 方法来实现并发,如果想要实现更复杂的并发模式,在编码时也可以选用其它工具,如 RxJs 等。
最后的代码会在测试结束后从所有远程节点中回收测试数据并写入文件。数据文件在之后会通过 Jupyter 进行分析和可视化。Jupyter 的交互式界面和 Python 社区所提供的强大数据分析和可视化工具可以让我们轻松地对各种数据处理方法进行实验,并将结果生成为 HTML 报告进行发布。
得益于 Jupyter 所提供的灵活性,使我们在脚本设计时可以在单一脚本内完成多组配置的测试(如下图所示),然后在数据分析时对数据进行自动分组分别计算出性能指标,从而进一步提高测试工作的效率。
有关 Jupyter 的使用并非本文主题,因此这里不做展开,有兴趣的同学可参考文后的链接。
执行能力建设
解决完用例交付的可持续性问题后,最后还需要解决测试的可持续性问题。我们的目标是做到输入指定脚本和参数后能做到一键执行并自动得到结果。得益于团队近年来持续性的基础能力建设,我们可以快速使用既有的开源基础设施和工具来完成这部分工作。
由于被测环境位于 AWS,因此测试工具也被要求部署到 AWS 上执行。为了控制执行成本,测试所用的所有设备都通过按需启动的方式使用。具体来说,我们使用了 Terraform 创建 AWS 实例,然后通过 Ansible 对实例进行批量配置(安装 git, nvm, Node.js 等)。
每次测试执行时我们通过一个带参数的 Jenkins Pipeline 编排所有的步骤:首先我们通过 Ansible Playbook 批量启动节点并进行代码更新和 Node.js 进程启动。随后测试脚本从另一个节点连接到这些 Node.js 进程开始执行测试并保存测试数据。在测试执行后调会再次调用 Ansible Playbook 立即关闭这些节点。最后通过 Jupyter 提供的命令行工具 nbconvert 调用一个用于数据分析的 Notebook 生成最终的 HTML 报告,并使用 Jenkins 提供的发布工具进行发布。
由于采用按需启动的方式执行测试,我们实现了控制测试执行成本的目标:假定每次测试执行需要 30 分钟,如果使用 20 台 r4.xlarge 实例用于生成负载,这一类型实例的单价为 $0.32/h,平均每次测试的硬件成本大概只需要 $3。由于测试由于未必总是需要如此多的节点,因此实际执行成本通常会进一步低于这里的估算。
成果
至此我们就完成了所有系统级性能测试能力建设工作。如果只看最终的成果清单会发现实际所需的编码工作并不多:
● 核心代码<300 行的 TypeScript 源码
● Terraform 和 Ansible Playbook 配置脚本若干
● Jupyter Notebook x 1
● Jenkins Pipeline Script x 1
然而正是通过这些看起微不足道的工作,我们将每次测试所需要的执行时间压缩到 30 分钟以内,截止目前支持的常规性能测试场景也达到了 20 个以上,新的测试场景支持从脚本设计到投入执行的时间通常也能控制在 1 天之内(实际上花费的时间更多地取决于外部限制而非测试方案本身)。这样的结果应该可以算是成功实现了低成本量产性能测试方案的目标吧!
效率的提升的直接结果除了测试成本的降低,也让我们能以更敏捷的方式为团队提供支持,实现更高效的团队合作。
在某次生产性能问题的调查和复现过程中,原本负责该任务的国外团队使用基于 JMeter 的半自动化方案,每次测试的执行时间需要 2-4 小时(大部分时间花费在构造测试数据和前置条件等准备工作中),由此导致开发团队无法及时得到反馈,相关工作推进缓慢。由于这一任务有较高的优先级,很快包括我们在内的更多工程团队也被动员参与其中。投入相关工作后我们通过对测试场景进行有效简化,重新设计了有针对性的测试脚本和全自动化的执行方案,成功将一次测试的执行时间压缩到 10 分钟左右。测量执行时间成数量级的减少所带来的直接结果是逆转了原本开发团队需要等待测试反馈的局面,开发团队提供新的改进方案并部署后 30 分钟内就能得到我们提供的有效反馈并进行下一次的尝试。通过高效的跨团队合作,我们很快在测试环境中观察到与生产相同的问题现象并确认原因,随后又通过相同的测试证实了修复措施的有效性。通过协助这一问题的解决,我们所提供的解决方案的有效性得到了更为广泛的认可,并在此之后成为该项目系统级性能测试所采用的主力解决方案。
混合测试
聊完能力建设这个核心话题后,最后来点轻松的内容做为甜点结束吧!通常软件测试的执行都是由每一个测试人员独立完成,因此在大多数情况下,测试执行时的系统负载往往会低于实际的情况。这种工作模式对于一般的功能验证来说问题不大,但对于预防性能风险显然是不够的。因此在能力允许的情况下,我们依旧有必要对产品在高负载情况下的表现开展测试工作。这一需求对于经常需要面对大用户负载的 Webinar 来说尤其突出。针对这一需求,我们的设想是,在手动测试的基础上,设法模拟出大量的用户负载对真实的用户行为进行模拟。由于这一方案同时结合了手动测试和自动测试的能力,所以我们将其称之为混合测试。而 SDET 团队所需要解决的问题就是,如何在只有少数人参与测试的情况下模拟大量的参会者。
有了先前的能力建设,要实现这一能力并不困难。由于在先前的系统级性能测试能力建设中我们使用了开发团队所提供的代码库,因此实际上这个系统已经具备了模拟大部分真实用户行为的能力。所以我们需要做的工具就是利用这些能力来模拟真实的用户行为。
那么我们应该怎样利用自动化方案来实现气氛组呢?最简单的一种做法是,我们可以通过让一个脚本处于一个长时间的循环中,在这个循环中每个虚拟用户都去做一个预先设定的动作即可。不过这样实现的气氛组比较单调。为了进一步让模拟的场景可以更加真实,让虚拟用户的行为更加丰富,我们还尝试使用了游戏 AI 中使用的行为树来定义用户行为。而之所以能实现这些能力的一个重要原因在于,我们采用的基于 Node.js 的性能测试技术方案允许我们用通用编程方法去实现任何你所需要的能力,再经由 CDP 协议实现分布式的控制和运行。
通过这个案例可以看到,基于先前的能力建设,我们不仅满足了传统性能测试所需要的核心能力,同时也为其它创新方案的落地提供了一个灵活而强大的平台。
结语
后端服务的性能测试做为质量保障体系的重要一环,之前由于项目原因缺少相关的能力建设,因此这次从 0 到 1 的能力建设工作对于 SDET 团队来说是一个十分宝贵的经验,在知识积累和能力建设方面填补了团队的空白。
可以看到,在技术方面我们最终采用了一种看起来并不常规,但某种程度上可称之为与团队能力更加相符的解决方案。不同于一般性的开发工作,做为质量保障系统的一部分,SDET 团队在进行技术创新时,往往也需要从质量成本管理的角度兼顾到成本因素,把方案的可持续性做为决策的重要目标。因此 SDET 团队长期以来在技术能力建设上会有意地选择主流的通用方案进行落地,这样的选择一方面可以降低实施的成本和风险,另一方面也更有利于在不同的项目和团队中推广使用。在我们最终所选用的技术栈中,Jenkins Pipeline 已在日常测试的执行和 CI/CD 的落地过程中有广泛应用;Ansible 已被用于大量测试设备的批量配置;Jupyter 在客户端性能测试中被用于进行数据算法试验和可视化;Node.js 和 TypeScript 长期以来作为团队主要的自动化脚本交付工具有了良好的群众基础;而 CDP 协议也因为先前对 Web 自动化技术原理的研究对团队而言并不陌生。正因为有了之前在其它领域的能力建设和经验积累,才使得我们有机会在这些能力之上推出适合团队需求的解决方案。这样的实践,或许从某种程度上可以印证横井军平所提出的 “成熟技术的水平思考” 这一研发策略的有效性吧!
外部链接
Gatling 提供的 Linux 参数调优建议:
https://gatling.io/docs/gatling/reference/current/core/operations/
Jupyter 进行数据可视化:
https://plotly.com/python/ipython-notebook-tutorial/
基于 Node.js 的分布式性能测试解决方案:
https://github.com/link89/mriya
版权声明: 本文为 InfoQ 作者【RingCentral铃盛】的原创文章。
原文链接:【http://xie.infoq.cn/article/259538b51bbdc432c865f61d9】。文章转载请联系作者。
评论