如何在进行负载测试时减少三倍的代码行数
负载测试的核心理念是将所有可以自动化的部分自动化。选择一个工具,编写配置和测试场景,然后模拟实际负载。代码越少越好。
自动化负载测试并不像乍看起来那么难。只需要合适的工具。
在本文中,我将展示如何在不降低性能的情况下将测试实用程序的代码量缩减三倍。我还将解释为什么 Yandex.Tank 与 Pandora 的结合对我来说不起作用。
什么是负载测试
我叫 Sergey,是 Tarantool 架构团队的一名开发人员。Tarantool 是一个内存计算平台,旨在处理高达数十万 RPS 的超高负载。因此,负载测试对我们来说至关重要,我每天都会进行。我相信几乎每个人都清楚负载测试的重要性,但为了以防万一,我们还是先回顾一下基本知识。负载测试的结果会显示系统在不同场景下的表现:
-
在什么情况下系统的哪些部分处于空闲状态?
-
请求响应时间大约是多久?
-
系统在什么负载下会变得不稳定?
-
系统的哪个部分导致故障?
-
其中哪一部分限制了整体性能?
为什么我们需要特殊的工具进行负载测试
在 Tarantool 上开发应用程序时,我们经常需要测试存储过程的性能。应用程序通过iproto二进制协议访问该过程。并非所有语言都支持通过 iproto 进行测试。Tarantool 提供多种语言的连接器,您需要使用其中一种来编写测试。
大多数测试工具仅支持 HTTP,这对我们来说并非可行。当然,我们可以添加一些控件,充分利用它,但这对最终用户没有帮助。由于我们将存储过程传递到客户端,因此通过 HTTP 进行测试并不可靠。
常见的负载测试工具
起初,我们考虑过一款名为 JMeter 的热门工具。然而,它的性能并不令人满意。它用 Java 编写,因此占用大量内存且速度缓慢。此外,我们用它来通过 HTTP 进行测试,这意味着需要通过特殊控件进行间接测试。之后,我们尝试为每个项目编写自定义的 Go 实用程序,但最终却徒劳无功,因为测试完成后代码就被丢弃,一遍又一遍地重复编写毫无意义。这显然不是一种系统性的方法。让我重申一下,我们希望在负载测试中尽可能地实现自动化。因此,我们最终选择了 Yandex.Tank 和 Pandora,因为这个组合看起来是一个完美的工具,能够满足所有需求:
-
它可以轻松适应任何项目。
-
它速度很快,因为 Pandora 是用 Go 编写的。
-
我们的团队在 Go 方面拥有丰富的经验,因此制定方案不会有问题。
但也有缺点。
我们为什么停止使用 Yandex.Tank
我们使用 Yandex.Tank 的时间很短,以下是我们放弃它的几个主要原因。
大量的实用代码。允许使用 Tarantool 的 Pandora 包装器包含约 150 行代码,其中大部分代码没有任何测试逻辑。
不断重新编译源代码。当我们必须持续加载系统并同时生成各种数据量时,我们遇到了这个问题。我们找不到一种便捷的外部方法来控制数据生成参数,而且预生成也不可行。因此,我们每次都修改数据并编译新的源代码。这样的操作在每个测试场景中最多可能会生成 20 个加载器二进制文件。
使用独立 Pandora 时数据稀缺。Yandex.Tank是一个封装器,提供了相当简洁的指标可视化。Pandora 是生成负载的引擎。实际上,我们使用了两种不同的工具,这并不总是很方便(幸好我们有 Docker)。
配置文件选项不太直观。JSON和 YAML 配置本身就是一个敏感话题。但如果不清楚某个选项如何根据值运行,那就真的令人不快了。对我们来说,startup
这样的选项就是如此。它对完全不同的值产生了相同的结果,这使得评估系统的实际性能变得困难。
所有这些在我们的一个项目中造成了以下情况:
-
大量的源代码
-
指标不明确
-
过于复杂的配置。
是什么让我们来到k6
k6是一款用 Go 编写的负载测试工具,就像 Pandora 一样。因此,性能无需担心。k6 的吸引力在于其模块化设计,这有助于避免频繁地重新编译源代码。使用 k6,我们可以编写模块来访问 Tarantool 接口并执行其他操作,例如生成数据。由于模块彼此独立,因此无需重新编译每个模块。相反,您可以在用 JavaScript 编写的场景中自定义数据生成参数!没错,您没看错。不再需要 JSON 或 YAML 配置,k6 测试场景就是代码!场景可以分为几个阶段,每个阶段模拟不同类型的负载。如果您更改场景,则无需重新编译 k6 二进制文件,因为它们彼此不依赖。这使得它们成为两个完全独立的组件,并用编程语言编写。您终于可以不再需要配置,只需编写代码即可。
我们的应用程序
这个 Lua 测试应用程序存储了车型信息。我使用它来测试数据库的写入和读取操作。该应用程序包含两个主要组件:API 和存储。API 组件为用户提供用于读写的 HTTP 控件,而存储组件负责应用程序与数据库的交互。交互场景如下:用户发送请求,控件调用处理该请求所需的数据库函数。您可以在 GitHub 上查看该应用程序。
让 k6 与应用程序协同工作
要创建 k6 Tarantool 交互模块,我们首先需要使用xk6框架编写一个 Go 模块。该框架提供了编写自定义 k6 模块的工具。首先,注册该模块,以便 k6 可以使用它。我们还需要定义一个新类型及其接收函数,即从 JavaScript 场景调用的方法:
package tarantool
import (
"github.com/tarantool/go-tarantool"
"go.k6.io/k6/js/modules"
)
func init() {
modules.Register("k6/x/tarantool", new(Tarantool))
}
// Tarantool is the k6 Tarantool extension
type Tarantool struct{}
我们已经可以使用这个模块了,但它目前还不能做太多事情。让我们对它进行编程,让它连接到 Tarantool 实例并调用Call
Go 连接器提供的函数:
// Connect creates a new Tarantool connection
func (Tarantool) Connect(addr string, opts tarantool.Opts) (*tarantool.Connection, error) {
if addr == "" {
addr = "localhost:3301"
}
conn, err := tarantool.Connect(addr, opts)
if err != nil {
return nil, err
}
return conn, nil
}
// Call invokes a registered Tarantool function
func (Tarantool) Call(conn *tarantool.Connection, fnName string, args interface{}) (*tarantool.Response, error) {
resp, err := conn.Call(fnName, args)
if err != nil {
return nil, err
}
return resp, err
}
这段代码已经比 Pandora 与 Tarantool 配合使用所需的代码紧凑得多了。Pandora 版本大约有 150 行代码,现在我们只剩下 30 行了。然而,我们还没有实现任何逻辑。剧透一下:最终代码量大约只有 50 行。k6 会处理剩下的一切。
从场景中与模块进行交互
首先,我们将该自定义模块导入到我们的场景中:
import tarantool from "k6/x/tarantool";
现在让我们创建一个连接:
const conn = tarantool.connect("localhost:3301");
connect
是我们在模块中声明的接收函数。如果要传递存储连接选项的对象,请将其作为第二个参数传入一个简单的 JSON 对象中。剩下的就是声明测试阶段并启动测试:
export const setup = () => {
tarantool.insert(conn, "cars", [1, "cadillac"]);
};
export default () => {
console.log(tarantool.call(conn, "box.space.cars:select", [1]));
};
export const teardown = () => {
tarantool.delete(conn, "cars", "pk", [1]);
};
此示例中有三个测试阶段:
-
setup
在测试之前执行。您可以在此处准备数据或显示信息消息。 -
default
,这是主要的测试场景。 -
teardown
测试完成后执行。您可以在此处删除测试数据或显示其他信息消息。
测试启动并完成后,您将看到如下输出:
您可以从此输出中了解到以下内容:
-
正在运行什么场景。
-
数据是写入控制台还是通过 InfluxDB 聚合。
-
场景参数。
-
场景
console.log
输出。 -
执行过程。
-
指标。
这里最有趣的指标是iteration_duration
,表示延迟,和iterations
,表示执行的总迭代次数及其每秒的平均次数 - 所需的 RPS。
更实质性的事情怎么样?
让我们创建一个由三个节点组成的测试平台,其中两个节点组成一个集群。第三个节点将托管 k6 的负载系统以及一个包含 Influx 和 Grafana 的 Docker 容器。我们将向该节点发送指标并将其可视化。
每个集群节点将如下所示:
我们不会将存储及其副本放置在同一个节点上:如果第一个存储位于第一个节点,则其副本位于第二个节点。我们的空间(本质上是 Tarantool 中的一张表)将包含三个字段:id
、bucket_id
和model
。我们将基于创建一个主键,id
并基于创建一个索引bucket_id
:
local car = box.schema.space.create(
'car',
{
format = {
{'car_id', 'string'},
{'bucket_id', 'unsigned'},
{'model', 'string'},
},
if_not_exists = true,
}
)
car:create_index('pk', {
parts = {'car_id'},
if_not_exists = true,
})
car:create_index('bucket_id', {
parts = {'bucket_id'},
unique = false,
if_not_exists = true,
})
让我们测试一下汽车对象的创建。为此,我们将编写一个用于生成数据的 k6 模块。前面我提到了 30 行实用程序代码,下面是剩余的 20 行测试逻辑:
var bufferData = make(chan map[string]interface{}, 10000)
func (Datagen) GetData() map[string]interface{} {
return <-bufferData
}
func (Datagen) GenerateData() {
go func() {
for {
data := generateData()
bufferData <- data
}
}()
}
func generateData() map[string]interface{} {
data := map[string]interface{}{
"car_id": uniuri.NewLen(5),
"model": uniuri.NewLen(5),
}
return data
}
我省略了初始化函数和用于调用其他函数的类型定义部分。现在让我们创建接收器函数,我们将在 JavaScript 场景中调用它们。有趣的是,我们可以在不丢失任何数据的情况下使用通道。假设你有一个函数向该通道写入数据bufferData
,另一个函数从该通道读取数据。如果你在读取场景中调用第二个函数,则不会丢失任何数据。
generateData
是一个生成汽车模型及其 的函数id
。这是一个内部函数,未扩展到我们的模块。generateData
它会启动一个 goroutine,以便我们始终生成足够的数据用于插入。该基准测试台的测试场景如下:
import datagen from "k6/x/datagen";
import tarantool from "k6/x/tarantool";
const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");
const baseScenario = {
executor: "constant-arrival-rate",
rate: 10000,
timeUnit: "1s",
duration: "1m",
preAllocatedVUs: 100,
maxVUs: 100,
};
export let options = {
scenarios: {
conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
},
};
export const setup = () => {
console.log("Run data generation in the background");
datagen.generateData();
};
export const conn1test = () => {
tarantool.call(conn1, "api_car_add", [datagen.getData()]);
};
export const conn2test = () => {
tarantool.call(conn2, "api_car_add", [datagen.getData()]);
};
export const teardown = () => {
console.log("Testing complete");
};
它变得更大了。新增了一个选项变量,允许我们配置测试行为。我为每个场景创建了两个场景和一个专用函数。由于集群由两个节点组成,我们需要测试与这两个节点的同时连接。如果使用单个函数(之前的默认设置)执行此操作,则无法指望集群满载。每个时间单元,在第二个路由器空闲时向第一个路由器发送请求,然后在第一个路由器空闲时向第二个路由器发送请求。因此,性能会下降。但是,这种情况是可以避免的,我们稍后会继续讨论。
现在让我们看一下测试场景。在 下executor
,我们指定要启动的测试类型。如果将此值设置为constant-arrival-rate
,则该场景将模拟恒定负载。假设我们想在一分钟内为 100 个虚拟用户产生 10,000 RPS 的性能。让我们使用数据库(而不是控制台)来输出结果,以便信息随后显示在仪表板上:
目标是 10,000 RPS,结果只有 8,600 RPS,这还算不错。很可能是因为加载器所在的客户端计算机的计算能力不足。我在我的 MacBook Pro(2020 年中)上进行了这项测试。以下是有关延迟和虚拟用户的数据:
那么灵活性如何?
就灵活性而言,一切都堪称完美。您可以修改场景以检查指标、收集指标等等。此外,您还可以通过以下方式之一优化场景:
n 个连接 — n 个场景
这是我们上面讨论过的基本场景:
const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");
const baseScenario = {
executor: "constant-arrival-rate",
rate: 10000,
timeUnit: "1s",
duration: "1m",
preAllocatedVUs: 100,
maxVUs: 100,
};
export let options = {
scenarios: {
conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
},
};
n 个连接 — 1 个场景
在此场景中,每次迭代时都会随机选择要测试的连接。测试单位为 1 秒,这意味着我们每秒从以下声明的连接中随机选择一个:
const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");
const conns = [conn1, conn2];
const getRandomConn = () => conns[Math.floor(Math.random() * conns.length)];
export let options = {
scenarios: {
conntest: {
executor: "constant-arrival-rate",
rate: 10000,
timeUnit: "1s",
duration: "1m",
preAllocatedVUs: 100,
maxVUs: 100,
},
},
};
这个场景可以简化为单个连接。为此,我们需要设置一个 TCP 均衡器(nginx、envoy、haproxy),不过这个以后再讲。
n 个连接 — n 个场景 + 限制和检查
您可以使用限制来控制获取的指标。如果 95 百分位延迟大于 100 毫秒,则测试将被视为不成功。您可以为一个参数设置多个限制。您还可以添加检查,例如,查看到达服务器的请求百分比。百分比表示为 0 到 1 之间的数字:
const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");
const baseScenario = {
executor: "constant-arrival-rate",
rate: 10000,
timeUnit: "1s",
duration: "10s",
preAllocatedVUs: 100,
maxVUs: 100,
};
export let options = {
scenarios: {
conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
conn2test: Object.assign({ exec: "conn2test" }, baseScenario),
},
thresholds: {
iteration_duration: ["p(95) < 100", "p(90) < 75"],
checks: ["rate = 1"],
},
};
n 个连接 — n 个场景 + 限制和检查 + 顺序启动
顺序启动场景是本文介绍的场景中最复杂的。假设您想要检查n 个存储过程,但又不想在特定时间加载系统。在这种情况下,您可能需要指定启动测试的时间,您可以在第二个场景中执行此操作。但请记住,您的第一个场景可能仍在运行。您可以通过gracefulStop
参数设置其执行的时间限制。如果您将其设置gracefulStop
为 0 秒,则第一个场景在第二个场景启动时肯定会停止:
const conn1 = tarantool.connect("172.19.0.2:3301");
const conn2 = tarantool.connect("172.19.0.3:3301");
const baseScenario = {
executor: "constant-arrival-rate",
rate: 10000,
timeUnit: "1s",
duration: "10s",
gracefulStop: "0s",
preAllocatedVUs: 100,
maxVUs: 100,
};
export let options = {
scenarios: {
conn1test: Object.assign({ exec: "conn1test" }, baseScenario),
conn2test: Object.assign({ exec: "conn2test", startTime: "10s" }, baseScenario),
},
thresholds: {
iteration_duration: ["p(95) < 100", "p(90) < 75"],
checks: ["rate = 1"],
},
};
与 Yandex.Tank + Pandora 的性能比较
我们在上述应用程序上比较了这两款工具。Yandex.Tank 的路由器 CPU 负载为 53%,存储 CPU 负载为 32%,产生了 9,616 RPS。对于 k6,它的路由器 CPU 负载为 54%,存储 CPU 负载为 40%,产生了 9,854 RPS。这些是 10 次测试的平均数据。
为什么会这样?Pandora 和 k6 都是用 Go 编写的。尽管基础相似,但 k6 允许你以更像编程的方式测试应用程序。
结论
k6 是一款简单易用的工具。一旦您学会了如何使用它,就可以针对任何项目重新配置它,并节省资源。首先创建一个核心模块,然后将逻辑附加到其中。无需从头重写测试,因为您可以使用其他项目的模块。
k6 也是一款精简的负载测试工具。我的测试逻辑和包装器代码量不到 50 行。您可以根据您的业务逻辑、场景和客户需求编写自定义模块。
k6 是关于编程的,而不是配置文件。您可以在这里试用 k6 ,并在这里试用示例应用程序。
在我们的网站上获取 Tarantool ,并随时在我们的 Telegram 聊天中提问。