前言:使用Locust时的困扰
评心而论,Locust是非常优秀的一款性能测试框架,但并不代表它能没有缺点。
当我们需要模拟高并发时,Locust的表现可能不是那么尽如人意。笔者在工作中定期需要做生产环境的稳定性压测,这种场景下往往需要笔者使用Locust的master-worker模式进行分布式压测。
基于此,对负载生成机的硬件要求是让人烦恼一件事;此外,在使用Locust进行分布式压测时另一个痛点是每当我们的脚步有改动,需要我们把脚本文件复制到每个负载生成机上。
那么,有没有比Locust更加适合做高并发的稳定性压测任务的框架呢?
几经遴选,笔者选择了一款高并发场景下的稳定性压测利器-k6。
01
K6的异军突起
Grafana k6是一个开源免费并且可扩展的的高性能压力测试工具,其本身是用Go语言编写的,内嵌了JavaScript运行时,我们可以使用JavaScript以便轻松编写测试脚本。
为了实现最大性能,其架构设计带来了一些权衡:不运行在NodeJS环境中!如果想使用NodeJSApi导入npm模块或库,我们可以使用webpack打包npm模块,然后在测试脚本中导入它们。
02
K6环境部署和api介绍
MacOS系统上安装:
brewinstallk6
Windows系统上安装:
1、使用Chocolatey包管理器可以使用以下命令安装非官方的k6包:
chocoinstallk6
2、使用Windows包管理器,从k6社区安装官方包:
wingetinstallk6--sourcewinget
3、下载最新的官方安装程序。
https://dl.k6.io/msi/k6-latest-amd64.msi
HTTP模块: http模块处理各种类型的HTTP请求和方法。
batch()并行发出多个HTTP请求;del()发出一个HTTPDELETE请求。
get()发出一个HTTPGET请求;head()发出一个HTTPHEAD请求。
options()发出一个HTTPOPTIONS请求;patch()发出一个HTTPPATCH请求。
post()发出一个HTTPPOST请求;put()发出一个HTTPPUT请求。
request()发出任何类型的HTTP请求。
我们做压测的过程中,遇到最多的场景是使用get和post请求。以下是模拟post请求的示例。
import http from 'k6/http';
export default function(){
consturl='http://test.k6.io/login';
constpayload=JSON.stringify({
email:'kevin@gamil.com',
password:'helloK6'});
constparams={
headers:{
'Content-Type':'application/json'},
};
http.post(url,payload,params);
}
k6内置的指标metrics
每次k6测试都会发出内置和自定义指标。每种支持的协议也有其特定的指标。
标准内置指标无论测试使用的是什么协议,k6总是收集以下指标:
checks成功检查的速率。
data_received接收到的数据量。
data_sent计数器发送的数据量,跟踪单个URL的数据。
iteration_duration完成一次完整迭代所需的时间,包括设置和拆除所花费的时间。
iterations VUs执行JS脚本(默认函数)的总次数。
vus当前活动的虚拟用户数量。
vus_max计量器虚拟用户的最大可能数量(预先分配VU资源,以避免在增加负载时影响性能)。
HTTP特定内置指标当测试进行HTTP请求时,才会生成这些指标。
注意:对于所有http_req_*指标,时间戳是在请求结束时发出的。换句话说,时间戳发生在k6接收到响应体的末尾,或者请求超时时。
http_req_blocked开始请求前等待空闲TCP连接所花费的时间。
http_req_connecting与远程主机建立TCP连接所花费的时间。
http_req_duration请求的总时间。它等于http_req_sending+http_req_waiting+http_req_receiving(即远程服务器处理请求并响应需要多长时间,不包括初始DNS查找/连接时间)。
http_req_failed根据setResponseCallback确定的失败请求的速率。
http_req_receiving从远程主机接收响应数据所花费的时间。
http_req_sending向远程主机发送数据所花费的时间。
http_req_tls_handshaking与远程主机进行TLS会话握手所花费的时间。
http_req_waiting等待远程主机响应所花费的时间(即"首字节时间"或"TTFB")。
http_reqs k6生成的总HTTP请求数。
校验Check
校验用于验证测试中的布尔条件。我们实际工作中经常需要使用校验来验证系统是否以预期内容响应。例如,校验可以验证POST请求的响应状态码是否为200,或者响应体是否包含指定的字符串。
校验类似于许多测试框架中所说的断言,但失败的校验不会导致测试中止或以失败状态结束。相反,k6会持续跟踪失败校验的比率,测试继续运行。
每个校验都会创建一个速率指标。要使校验导致测试中止或失败,可以将其与阈值结合使用。
检查HTTP响应码校验非常适合对HTTP请求和响应进行断言的编码。例如,以下代码片段确保HTTP响应码为200:
import {check} from 'k6';
import http from 'k6/http';
export default function(){
constres=http.get('http://test.k6.io/');
check(res,{'isstatus200':(r)=>r.status===200,});
}
检查响应体中的文字有时,即使是HTTP200响应也可能包含错误消息。在这些情况下,我们可以添加一个校验以验证响应体,如下所示:
import {check} from 'k6';
import http from 'k6/http';
export default function(){
constres=http.get('http://test.k6.io/');
check(res,{'验证响应报文包含文本':(r)=>r.body.includes('操作成功'),});
}
添加多个校验
我们也可以在单个check()语句中添加多个校验:
import {check} from 'k6';
impor thttp from 'k6/http';
export default function(){
const res=http.get('http://test.k6.io/');
check(res,{'验证http状态码是否200':(r)=>r.status===200,
'验证响应报文包含文本':(r)=>r.body.includes('操作成功')});
}
注意:当校验失败时,测试脚本将继续执行,不会返回"失败"的退出状态。如果需要根据校验结果使整个测试失败,我们必须将校验与阈值结合使用。
阈值
阈值是为测试指标定义的通过/失败标准。如果被测试系统的性能不符合阈值条件,测试将以失败状态结束。
通常,我们在压测时使用阈值来验证是否满足服务等级(SLA)。例如,我们可以为以下期望的任何组合创建阈值:
少于1%的请求返回错误。
95%的请求响应时间低于200毫秒。
99%的请求响应时间低于400毫秒。
特定端点始终在300毫秒内响应。
阈值表达式语法阈值表达式求值为真或假。阈值表达式必须采用以下格式:
<aggregation_method><operator><value>
以下是一些阈值表达式示例:
avg<200//平均持续时间必须小于200毫秒count>=500//计数必须大于或等于500
p(90)<300//90%的样本必须低于300
按类型聚合方法k6根据指标类型聚合指标。这些聚合方法构成阈值表达式的一部分。
k6提供了聚合方法计数器count和rate计量器value速率rate趋势avg,min,max,med和p(N)等指标,其中N指定阈值百分位数值,表示为0.0到100之间的数字。例如p(99.99)表示99.99百分位数,值以毫秒为单位。
选项
选项配置测试运行的行为。选项可以是标签、阈值、用户代理以及虚拟用户数和迭代次数的方式。
importhttpfrom'k6/http';
export const options={
hosts:{'test.k6.io':'1.2.3.4'},
stages:[
{duration:'1m',target:10},
{duration:'1m',target:20},
{duration:'1m',target:0},],
thresholds:{http_req_duration:['avg<100','p(95)<200']},
noConnectionReuse:true,
userAgent:'MyK6UserAgentString/1.0',
};
exportdefaultfunction(){
http.get('http://test.k6.io/');
}
Discard response bodies指定是否应该丢弃响应体
Duration指定测试运行的总持续时间的字符串;与vus选项一起,它是具有恒定VUs执行器的单一场景的快捷方式
HTTP debug记录所有HTTP请求和响应
Iterations指定执行脚本的固定迭代次数;与vus选项一起,它是具有共享迭代执行器的单一场景的快捷方式
Results output指定结果输出
RPS每秒钟全局最大请求次数(不鼓励使用,改用到达率执行器)
Scenarios定义高级执行场景
Setup timeout指定setup()函数在被终止之前允许运行的时间
Showl ogs一个布尔值,指定是否将云日志打印到终端
Stages指定VUs的目标数量以增加或减少的对象列表;具有坡道VUs执行器的单一场景的快捷选项
Thresholds配置在什么条件下测试是成功或不成功的
Throw一个布尔值,指定是否在HTTP请求失败时抛出错误
VUs指定同时运行的VUs数量
运行本地测试要运行一个简单的本地脚本:
创建并初始化一个新脚本,运行以下命令:
$k6 new
这个命令在当前目录创建一个名为script.js的新脚本文件。我们也可以指定不同的文件名作为k6 new命令的参数,例如k6 new my-test.js。
使用以下命令运行k6:
$k6 run my-script.js
添加虚拟用户,用多个虚拟用户和指定的持续时间来运行负载测试:
$k6run--vus10--duration 30s script.js
上面这个命令运行一个30秒,10个虚拟用户的负载测试
注意:k6用虚拟用户(VUs)并行运行多个迭代。一般来说,更多的虚拟用户意味着模拟更多的流量。
VUs本质上是并行的while(true)循环。脚本用JavaScript编写,作为ES6模块,所以可以按我们喜欢的方式将较大的测试拆分成较小的部分或制作可复用的组件。
初始化上下文和默认函数要运行测试,你需要有初始化代码,它准备测试,以及VU代码,它发出请求。
init上下文中的代码定义函数并配置测试选项(如持续时间)。
每个测试还有一个默认函数,它定义VU逻辑。
//初始化exportdefaultfunction(){//VU代码:在这里执行操作...}
初始化代码首先运行,并且每个VU只调用一次。默认代码运行的次数或持续时间根据测试选项配置。
设置选项而不是每次运行脚本时都键入--vus 10和--duration 30s,我们可以在JavaScript文件中设置选项:
import http from 'k6/http';
import {sleep} from 'k6';
export const options={vus:10,duration:'30s',};
export default function() {http.get('http://test.k6.io');
sleep(1);
}
分阶段增减VUs你可以在测试期间分阶段增减VUs的数量。要配置坡度,使用options.stages属性。
import http from 'k6/http';
import {check,sleep} from 'k6';
export const options={
stages:[ {duration:'30s',target:20},
{duration:'1m30s',target:10},
{duration:'20s',target:0},],};
export default function() {
const res=http.get('https://httpbin.test.k6.io/');
check(res,{'statuswas200':(r)=>r.status==200});
sleep(1);
}
k6支持三种执行模式来运行k6测试脚本:本地、分布式和云端。
本地:测试执行完全发生在一台机器、容器或CI服务器上。
k6 run script.js
分布式:测试执行跨Kubernetes集群分布。
将以下YAML保存为k6-resource.yaml:
---apiVersion:k6.io/v1alpha1
kind:K6metadata:name:k6-samplespec:parallelism:4script:configMap:name:'k6-test'file:'script.js'
使用以下命令应用资源:
kubectlapply-f/path/to/k6-resource.yaml
云端:测试在GrafanaCloudk6上运行。
k6 cloudscript.js
此外,基于云的解决方案可以在云基础设施上运行云测试,并接受来自本地或分布式测试的测试结果。
03
稳定性压测实战
以笔者所在公司的业务的稳定性压测为例,我们要求如下:
1:本地化运行脚本。
2:模拟10k的qps。
3:尽量不使用多台负载机。
4:稳定性压测覆盖尽量多的业务接口。
基于上面4点考虑,笔者设计了的压测脚本如下:
import http from 'k6/http';
import { check,sleep } from 'k6';
import crypto from 'k6/crypto';
import { SharedArray } from 'k6/data';
// 用户的token,环境变量
const accessToken = "";
// web后端的host地址
const webHost = "https://ptwebf.xbongbong.com.cn";
// 移动端的host地址
const appHost = "https://testdingtalkapi.xbongbong.com.cn";
// 请求头模板
let webHeaders = {
'Host': 'ptwebf.xbongbong.com.cn',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json;charset=UTF-8',
'sign': ''
};
let appHeaders = {
'Host': 'testdingtalkapi.xbongbong.com.cn',
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json;charset=UTF-8',
'sign': ''
};
// 随机获取一条测试用例
const case_data_list = new SharedArray('cases', function () {
const f = JSON.parse(open('./test1_sop_load.json'));
return f;
});
// 读取接口文件
const api_data_list = new SharedArray('api', function () {
const api_list = JSON.parse(open('./api_list_all.json'));
return api_list;
});
export default function () {
// 随机选择一个测试用例
const caseData = case_data_list[Math.floor(Math.random() * case_data_list.length)];
// 返回指定api_name对应的URL
let apiItem = api_data_list.find(item => item.api_name === caseData.api_name);
// 构建请求头
let requestHeader;
let host;
let sign_code;
let needProcessContext;
// 需要处理请求头的sign值
if (caseData.api_name.includes("web")) {
host = webHost;
needProcessContext = JSON.stringify(caseData.request_params) + accessToken;
sign_code = crypto.sha256(needProcessContext, 'hex');
webHeaders['sign'] = sign_code;
requestHeader = webHeaders;
} else {
host = appHost;
needProcessContext = JSON.stringify(caseData.request_params) + accessToken;
sign_code = crypto.sha256(needProcessContext, 'hex');
appHeaders['sign'] = sign_code;
requestHeader = appHeaders;
}
// 拼装请求的url并发送请求
let request_url = host + apiItem.api_url;
let response_data = http.post(request_url, JSON.stringify(caseData.request_params), { 'headers': requestHeader });
// 检查响应状态码和数据
check(response_data, {
'status is 200': (response_data) => response_data.status === 200,
'response msg': (response_data) => response_data.body.includes('操作成功'),}
);
sleep(1);
}
export const options = {
// discardResponseBodies: true,
stages: [
{ duration: '5m', target: 4000 }, // 初始阶段5分钟,4000个虚拟用户
{ duration: '35m', target: 4000 }, // 持续阶段30分钟,4000个虚拟用户
{ duration: '40m', target: 0 }, // 退出阶段5分钟,逐渐减少到0个虚拟用户
],
thresholds: {
// 95%的请求时间应该小于3500ms
http_req_duration: ['p(95)<3500'],
// 事务失败率小于1%
http_req_failed: ['rate<0.01'],
},
};
在以上脚本中实现了从测试用例数据中随机读取用例以便覆盖800+的业务接口;在实际执行时,笔者需要开启3个进程同时压测。为了便于读者理解,下面贴出接口和用例的数据。
其中接口的数据统一放置在api_list_all.json文件中。
接口用例的数据结构如下图:
实际压测效果的QPS(服务器端监控)统计图如下:
04
结语
性能测试是一项工程性很强的工作,路漫漫其修远兮!笔者抛砖引玉,期待更多的小伙伴分享自己的实施案例。
......
本文为51Testing软件测试网
第八十期51测试天地内容
剩余精彩内容请点击下方
阅读原文 查看