团队现有的压测流程越来越成为瓶颈。基于JMeter的脚本是静态的,每次调整并发数、目标URL或持续时间都需要修改XML文件,重新打包,然后手动分发到几台固定的压测机上执行。测试结束后,聚合数百万行的JTL结果文件进行分析,通常是几个小时甚至第二天的事情。我们需要一个动态、弹性、结果实时可见的解决方案。
初步构想是建立一个平台,允许开发者通过API或简单的UI提交压测任务,定义目标、并发数和持续时间。系统应自动拉起所需数量的压测代理(Agent),执行任务,并将详细的性能指标实时回传到一个中央数据存储,以便即时查询和分析。
技术选型是这个构想的第一个关键决策点。
- 调度与执行层: Kubernetes显得过于庞大。我们不需要Service、Ingress等复杂的网络概念,只需要一个能按需、大规模、参数化地启动和销毁短生命周期容器化任务的调度器。HashiCorp Nomad因其轻量级、灵活性和对非容器化任务的良好支持而进入视野。其
parameterized jobs
特性似乎是为动态任务量身定做的。 - 压测代理实现: Go和Java是传统选项,但团队的通用技术栈是TypeScript/Node.js。Node.js的事件驱动、非阻塞I/O模型非常适合实现一个高并发的HTTP请求发生器。使用TypeScript可以保证代码的健壮性和可维护性。
- 指标存储与分析层: 这是最具争议的部分。Prometheus和InfluxDB是时序数据的标准选择。但在压测场景下,我们需要的不仅仅是时间序列图表。我们经常需要对失败的请求进行深度分析,比如按错误信息、HTTP状态码、目标路径进行聚合,甚至对返回的body内容进行部分搜索。这种需求兼具时序分析和日志检索的特点。Solr,作为一个成熟的搜索引擎,凭借其强大的Faceting能力、动态字段(Dynamic Fields)和近实时(NRT)的索引能力,完全可以胜任这个角色。我们可以将每一条请求的指标(延迟、状态码、字节数)和元数据(测试ID、Agent ID、目标URL)作为一个文档存入Solr,利用其分析能力实时切分和聚合数据。
最终方案确定:使用Nomad调度TypeScript编写的压测代理,代理将压测指标实时写入Solr集群,以供即时分析。
第一步:定义 Nomad 任务规约 (Job Specification)
Nomad的核心是Job文件。我们需要一个模板化的Job,它能接收压测参数并动态生成任务。这里的关键是使用meta
块来定义变量。
pressure-test-agent.nomad
job "pressure-test-agent" {
datacenters = ["dc1"]
type = "batch" # 批处理类型,适合短生命周期任务
# 参数化配置,允许在提交任务时动态传入
parameterized {
meta_required = ["TARGET_URL", "TEST_ID"]
meta_optional = {
CONCURRENCY = "10",
DURATION_SECONDS = "60",
AGENT_COUNT = "1"
}
}
group "agents" {
# 动态设置Agent实例数量
count = meta.AGENT_COUNT
# 当任务失败时,不自动重启
restart {
attempts = 2
interval = "1m"
delay = "15s"
mode = "fail"
}
# 资源限制
task "http-agent" {
driver = "docker"
config {
image = "node:18-alpine"
# 确保容器退出后不被立即移除,方便排查问题
remove = false
volumes = [
"/local/path/to/agent:/app"
]
work_dir = "/app"
command = "node"
args = ["dist/index.js"]
}
# 将Nomad的meta数据作为环境变量注入到容器中
# 这是实现动态压测的核心
env {
TARGET_URL = meta.TARGET_URL
TEST_ID = meta.TEST_ID
CONCURRENCY = meta.CONCURRENCY
DURATION_SECONDS= meta.DURATION_SECONDS
SOLR_HOST = "http://your.solr.host:8983"
AGENT_ID = "${NOMAD_ALLOC_ID}" # 使用Nomad内置变量作为唯一Agent ID
}
resources {
cpu = 500 # 500 MHz
memory = 256 # 256MB
}
}
}
}
这个Job文件定义了一个批处理任务。通过nomad job run -meta "TARGET_URL=http://api.service/health" -meta "TEST_ID=test-001" pressure-test-agent.nomad
这样的命令,就可以启动一个包含1个Agent实例、并发为10、持续60秒的压测任务。Nomad会将meta
中的所有键值对作为环境变量注入容器,我们的TypeScript Agent将从中读取配置。
第二步:实现高并发的 TypeScript 压测代理
代理的核心职责是:解析环境变量,启动指定并发的请求循环,收集指标,并批量推送到Solr。
项目结构:
.
├── src
│ ├── agent.ts # 核心压测逻辑
│ ├── config.ts # 配置加载与校验
│ ├── index.ts # 入口文件
│ └── solrClient.ts # Solr推送客户端
├── package.json
└── tsconfig.json
src/config.ts
- 环境配置加载
// src/config.ts
import { z } from 'zod';
// 使用Zod进行环境变量的强类型校验
const envSchema = z.object({
TARGET_URL: z.string().url(),
TEST_ID: z.string().min(1),
CONCURRENCY: z.coerce.number().int().positive().default(10),
DURATION_SECONDS: z.coerce.number().int().positive().default(60),
SOLR_HOST: z.string().url(),
AGENT_ID: z.string().min(1),
});
export type AppConfig = z.infer<typeof envSchema>;
function loadConfig(): AppConfig {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:', result.error.flatten());
process.exit(1);
}
return result.data;
}
export const config = loadConfig();
在真实项目中,配置的健壮性至关重要。使用zod
可以确保程序在启动时就因配置错误而快速失败,而不是在运行时产生难以预料的行为。
src/solrClient.ts
- 指标推送模块
直接与Solr的/update/json/docs
API交互。关键在于实现批量推送和错误处理,以减少网络开销和提高吞吐量。
// src/solrClient.ts
import axios from 'axios';
import { config } from './config';
// 定义Solr文档结构
export interface MetricDocument {
id: string; // uuid
test_id_s: string;
agent_id_s: string;
timestamp_dt: string; // ISO format
target_url_s: string;
latency_ms_i: number;
http_status_i: number;
response_size_l: number;
error_s?: string;
}
const SOLR_CORE_URL = `${config.SOLR_HOST}/solr/pressure_metrics/update/json/docs?commitWithin=5000`;
const BATCH_SIZE = 1000;
const FLUSH_INTERVAL = 2000; // ms
let metricBuffer: MetricDocument[] = [];
let flushTimer: NodeJS.Timeout | null = null;
async function flush() {
if (metricBuffer.length === 0) {
return;
}
const batch = metricBuffer;
metricBuffer = [];
try {
// 使用axios进行POST请求
await axios.post(SOLR_CORE_URL, batch, {
headers: { 'Content-Type': 'application/json' },
});
console.log(`Flushed ${batch.length} metrics to Solr.`);
} catch (error) {
console.error('Failed to flush metrics to Solr:', error.response?.data || error.message);
// 失败重入队列,实际生产环境需要更复杂的重试和死信队列逻辑
metricBuffer.unshift(...batch);
}
}
export function pushMetric(metric: Omit<MetricDocument, 'id' | 'timestamp_dt'>) {
const doc: MetricDocument = {
...metric,
id: crypto.randomUUID(),
timestamp_dt: new Date().toISOString(),
};
metricBuffer.push(doc);
if (metricBuffer.length >= BATCH_SIZE) {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flush();
} else if (!flushTimer) {
flushTimer = setTimeout(() => {
flush();
flushTimer = null;
}, FLUSH_INTERVAL);
}
}
export async function finalFlush() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
await flush();
}
这里的commitWithin=5000
参数告诉Solr在5秒内软提交这批文档,使其可被搜索,这是实现近实时的关键。同时,实现了基于数量和时间的双重缓冲策略,以平衡延迟和吞吐量。
src/agent.ts
- 核心压测引擎
这里使用Promise.all
配合async/await
来实现并发控制。
// src/agent.ts
import axios, { AxiosError } from 'axios';
import { config } from './config';
import { pushMetric } from './solrClient';
async function singleRequest(): Promise<void> {
const startTime = process.hrtime.bigint();
let status = 0;
let responseSize = 0;
let errorMsg: string | undefined;
try {
const response = await axios.get(config.TARGET_URL, {
// 禁用默认的Axios异常抛出,以便我们可以处理所有状态码
validateStatus: () => true,
// 设置超时
timeout: 10000,
});
status = response.status;
responseSize = response.headers['content-length'] ? parseInt(response.headers['content-length'], 10) : response.data?.length || 0;
} catch (error) {
const axiosError = error as AxiosError;
status = axiosError.response?.status || -1; // -1 for client-side errors like timeout
errorMsg = axiosError.code || axiosError.message;
} finally {
const endTime = process.hrtime.bigint();
const latencyMs = Number((endTime - startTime) / 1000000n);
pushMetric({
test_id_s: config.TEST_ID,
agent_id_s: config.AGENT_ID,
target_url_s: config.TARGET_URL,
latency_ms_i: latencyMs,
http_status_i: status,
response_size_l: responseSize,
error_s: errorMsg,
});
}
}
async function worker(stopSignal: { stop: boolean }) {
while (!stopSignal.stop) {
await singleRequest();
}
}
export async function runTest() {
console.log(`Starting test with config:`, config);
const stopSignal = { stop: false };
const testEndTime = Date.now() + config.DURATION_SECONDS * 1000;
// 设置一个总的超时来停止所有worker
setTimeout(() => {
console.log('Test duration reached. Stopping workers...');
stopSignal.stop = true;
}, config.DURATION_SECONDS * 1000);
const workers: Promise<void>[] = [];
for (let i = 0; i < config.CONCURRENCY; i++) {
workers.push(worker(stopSignal));
}
// 等待所有worker自然结束(当stopSignal变为true时)
await Promise.all(workers);
console.log('All workers stopped. Performing final flush to Solr...');
}
src/index.ts
- 入口
// src/index.ts
import { runTest } from './agent';
import { finalFlush } from './solrClient';
async function main() {
try {
await runTest();
} catch (error) {
console.error('An unhandled error occurred during the test run:', error);
} finally {
// 确保程序退出前,所有缓冲区的指标都被发送
await finalFlush();
console.log('Test finished.');
process.exit(0);
}
}
main();
第三步:配置 Solr 作为指标存储
我们需要创建一个名为 pressure_metrics
的Core,并定义其Schema。Schema的设计直接影响查询性能和分析能力。
使用 Solr Schema API (或直接修改 managed-schema
文件) 来定义字段:
{
"add-field": [
{"name":"id", "type":"string", "indexed":true, "stored":true, "required":true, "multiValued":false},
{"name":"test_id_s", "type":"string", "indexed":true, "stored":true, "docValues": true},
{"name":"agent_id_s", "type":"string", "indexed":true, "stored":true, "docValues": true},
{"name":"timestamp_dt", "type":"pdate", "indexed":true, "stored":true, "docValues": true},
{"name":"target_url_s", "type":"string", "indexed":true, "stored":true},
{"name":"latency_ms_i", "type":"pint", "indexed":true, "stored":true, "docValues": true},
{"name":"http_status_i", "type":"pint", "indexed":true, "stored":true, "docValues": true},
{"name":"response_size_l", "type":"plong", "indexed":true, "stored":true, "docValues": true},
{"name":"error_s", "type":"string", "indexed":true, "stored":true}
]
}
这里的关键点:
- 字段类型后缀: 遵循Solr的最佳实践,如
_s
for string,_i
for integer,_dt
for date。 -
docValues="true"
: 对所有需要用于聚合(Faceting)、排序或函数查询的字段启用docValues
。这是一个列式存储结构,可以极大地提升分析查询的性能,避免全量加载倒排索引,是高性能分析的基石。test_id_s
,agent_id_s
,timestamp_dt
,latency_ms_i
,http_status_i
都是典型的分析维度。
在solrconfig.xml
中,确保autoSoftCommit
配置合理,以实现近实时搜索。
<updateHandler class="solr.UpdateHandler">
<autoSoftCommit>
<maxTime>3000</maxTime>
</autoSoftCommit>
</updateHandler>
架构整合与工作流
下面是整个系统的运行时序图。
sequenceDiagram participant Operator as 运维/开发者 participant Nomad participant Agent as TypeScript Agent participant Solr Operator->>Nomad: nomad job run -meta ... pressure-test-agent.nomad Nomad->>Nomad: 解析Job, 创建Allocation Nomad->>Agent: 在Nomad Client上启动容器, 注入环境变量 Agent->>Agent: 读取环境变量, 启动并发Worker loop 压测期间 Agent->>Target Service: 发起HTTP请求 Target Service-->>Agent: HTTP响应 Agent->>Agent: 记录延迟、状态码等指标 Agent->>Solr: 批量推送指标文档 (buffer & flush) end Note right of Agent: 压测时间结束, 停止所有Worker Agent->>Solr: 执行Final Flush, 清空缓冲区 Agent->>Nomad: 任务执行完毕, 进程退出 Nomad->>Nomad: 标记Allocation为Completed Operator->>Solr: 使用查询API实时分析数据 Solr-->>Operator: 返回聚合分析结果 (如P99延迟, 错误码分布)
实时分析与成果
压测任务启动后,几乎是立刻就可以在Solr中查询到数据。Solr的JSON Facet API是进行复杂统计分析的利器。例如,要计算test-001
这个测试任务的P99延迟、平均延迟、QPS、以及按HTTP状态码分类的请求数:
GET /solr/pressure_metrics/select?q=test_id_s:"test-001"&rows=0&json.facet={
"qps": "unique(_ts)",
"stats": {
"type": "query",
"q": "*:*",
"facet": {
"avg_latency": "avg(latency_ms_i)",
"p99_latency": "percentile(latency_ms_i, 99)",
"total_requests": "count(*)"
}
},
"codes": {
"type": "terms",
"field": "http_status_i",
"limit": 10
}
}
这个查询的响应会立刻返回一个结构化的JSON,包含了所有我们关心的核心指标,无需等待数据导出和离线处理。这种即时反馈的能力,彻底改变了我们进行性能调优的方式。
局限与未来展望
这套架构并非没有缺点。
- Solr存储成本: Solr为每个指标文档都建立了索引,存储开销远大于专门的时序数据库。必须配合严格的TTL(Time-To-Live)策略定期清理旧的压测数据,否则磁盘会迅速膨胀。
- 查询模式: 虽然Solr的分析能力强大,但它并不原生支持PromQL这类时序查询语言,对于习惯了Prometheus生态的工程师来说有学习成本。
- Agent能力单一: 当前的Agent只支持简单的GET请求,真实业务场景需要支持POST、自定义Header、认证、动态数据等。下一步的迭代方向是构建一个插件化的Agent,允许用户通过上传简单的脚本来定义更复杂的压测逻辑。
- 控制平面缺失: 目前任务的启动和结果的查询还是手动的。一个完整的平台需要一个Web UI或CLI工具,封装Nomad和Solr的API,提供任务管理、历史追溯和结果可视化的能力。
尽管存在这些局限,但这套基于Nomad、TypeScript和Solr的组合拳,成功地用一套相对小众但极其匹配场景的技术栈,解决了一个核心的工程效率问题。它证明了在技术选型时,跳出“标准答案”的框架,深入理解工具的本质特性,往往能构建出更高效、更契合的系统。