4.1 配套代码:k6 进阶用法与压测机自查
对应小节:4.1 负载生成器 基础的三种曲线在 第 3 章 code 04,这里只给本章新增的部分。
一、多场景组合(一个脚本测多种流量)
// loadtest/multi-scenario.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate, Counter } from 'k6/metrics';
// 自定义指标(对应第 1 章的四层指标)
const readLatency = new Trend('read_latency_ms', true);
const writeLatency = new Trend('write_latency_ms', true);
const businessErrors = new Counter('business_errors');
const okRate = new Rate('ok_rate');
export const options = {
scenarios: {
// 读路径:占 90% 流量,开环恒定
read_path: {
executor: 'constant-arrival-rate',
rate: 450, timeUnit: '1s', duration: '10m',
preAllocatedVUs: 500, maxVUs: 3000,
exec: 'readScenario',
tags: { path: 'read' },
},
// 写路径:占 10% 流量,开环较低速率
write_path: {
executor: 'constant-arrival-rate',
rate: 50, timeUnit: '1s', duration: '10m',
preAllocatedVUs: 200, maxVUs: 1000,
exec: 'writeScenario',
startTime: '30s', // 错开启动,避免冷启动叠加
tags: { path: 'write' },
},
},
thresholds: {
'http_req_failed{path:read}': ['rate<0.01'],
'http_req_duration{path:read}': ['p(99)<200'],
'read_latency_ms': ['p(99)<200'],
'write_latency_ms': ['p(99)<500'],
ok_rate: ['rate>0.99'],
business_errors: ['count<100'],
},
discardResponseBodies: true,
summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)', 'max'],
};
function zipf(max) {
return Math.max(1, Math.floor(max * Math.pow(Math.random(), 3)) + 1);
}
export function readScenario() {
const res = http.get(`${__ENV.BASE_URL}/orders/${zipf(1_000_000)}`, {
tags: { name: 'GET /orders/{id}' }, // ⚠️ 路由模板,不是具体 URL
});
readLatency.add(res.timings.duration);
const ok = check(res, { 'read ok': (r) => r.status === 200 });
okRate.add(ok);
if (!ok) businessErrors.add(1);
}
export function writeScenario() {
const payload = JSON.stringify({ userId: Math.floor(Math.random() * 1000) + 1, amount: 100 });
const res = http.post(`${__ENV.BASE_URL}/orders`, payload, {
headers: { 'Content-Type': 'application/json', 'X-Load-Test': 'true' },
tags: { name: 'POST /orders' },
});
writeLatency.add(res.timings.duration);
const ok = check(res, { 'write ok': (r) => r.status === 201 });
okRate.add(ok);
if (!ok) businessErrors.add(1);
}
两个值得注意的写法:
| 写法 | 为什么 |
|---|---|
exec: 'readScenario' |
一个脚本里定义多个函数,各自绑定到不同场景 |
startTime: '30s' |
错开启动——否则两个场景同时冷启动,前 30 秒的数据不可用 |
X-Load-Test: true 头 |
让服务端/追踪系统能区分压测流量(第 4.6 节) |
tags: { name: '...' } |
用路由模板控制指标基数 |
二、压测机自查脚本(每次压测必跑)
#!/usr/bin/env bash
# tools/check-load-generator.sh <SUMMARY_JSON> <EXPECTED_RATE>
#
# 判断"压测机是不是瓶颈"——这是数据分析前的第一道关。
set -uo pipefail
SUMMARY="${1:?usage: check-load-generator.sh <summary.json> <expected_rate>}"
EXPECTED="${2:?}"
echo "═══ 压测机自查 ═══"
echo
# ① 到达率偏差(最重要)
python3 - "$SUMMARY" "$EXPECTED" <<'PY'
import json, sys
summary, expected = sys.argv[1], float(sys.argv[2])
m = json.load(open(summary))["metrics"]
actual = m["http_reqs"]["rate"]
diff = actual / expected - 1
print(f"① 到达率检查")
print(f" 设定 : {expected:>10.1f} req/s")
print(f" 实际 : {actual:>10.1f} req/s")
print(f" 偏差 : {diff:>+10.1%}")
if abs(diff) > 0.05:
print(" ❌ 偏差 > 5% —— 压测机可能是瓶颈,服务端数据不可用")
print(" 排查方向:preAllocatedVUs 不足 / 压测机 CPU 打满 / 端口耗尽")
else:
print(" ✅ 通过")
print()
# ② 分布形态检查(P99/P50 比值)
d = m["http_req_duration"]
p50, p99 = d.get("med", 0), d.get("p(99)", 0)
if p50 > 0:
ratio = p99 / p50
print(f"② 分布形态检查")
print(f" P50 {p50:.1f} ms, P99 {p99:.1f} ms, 比值 {ratio:.1f}")
if ratio < 2:
print(" ⚠️ P99/P50 < 2 —— 分布过于'干净',怀疑闭环模型导致尾延迟被低估")
else:
print(" ✅ 分布形态符合重尾特征")
print()
# ③ 错误率
failed = m["http_req_failed"]["rate"]
print(f"③ 错误率检查")
print(f" {failed:.2%}")
if failed > 0.01:
print(" ❌ > 1% —— 延迟数据可能因剔除失败请求而虚低")
else:
print(" ✅ 通过")
PY
echo
echo "═══ 压测机 OS 层检查 ═══"
# ④ 临时端口与连接状态(TIME_WAIT 堆积是常见瓶颈)
if command -v ss > /dev/null 2>&1; then
echo "④ 连接状态:"
ss -s | head -5
TW=$(ss -tan 2>/dev/null | grep -c TIME-WAIT || echo 0)
echo " TIME_WAIT 数量: $TW"
[ "$TW" -gt 20000 ] 2>/dev/null && echo " ⚠️ TIME_WAIT 过多,考虑开启 tcp_tw_reuse 或增加连接复用"
fi
# ⑤ 压测机负载
echo
echo "⑤ 压测机负载(如果你能看到):"
echo " 运行 'top' 或 'pidstat -u 1' 观察 k6 进程的 CPU"
echo " 如果 k6 进程 CPU 接近 100%(单核),说明压测客户端是瓶颈"
三、压测机瓶颈的三种典型表现
| 表现 | 诊断 | 修法 |
|---|---|---|
| 实际到达率 < 设定值,且 k6 CPU 打满 | 客户端算不过来 | 用多台压测机 / 用更轻的脚本(discardResponseBodies) |
| 实际到达率 < 设定值,k6 CPU 不高 | preAllocatedVUs 不足 |
提高 preAllocatedVUs 和 maxVUs |
连接建立耗时增长、TIME_WAIT 堆积 |
端口/连接复用问题 | 开启 keep-alive、调 tcp_tw_reuse、增加 ulimit -n |
四、交叉验证:用第二种工具确认
这是第 0 章 0.6 节的「方法 4」:如果两份不同工具报出的 P99 差一个数量级,说明至少有一个踩了协调遗漏。
# 工具 1:k6(开环)
k6 run --summary-export=k6.json loadtest/profile-constant.js
# 工具 2:wrk2(恒定速率 + HdrHistogram 修正)
wrk2 -t4 -c100 -d60s -R500 --latency http://127.0.0.1:8080/orders/1
# 工具 3:wrk(闭环)—— 故意用它来看差异
wrk -t4 -c100 -d60s --latency http://127.0.0.1:8080/orders/1
# 对比三者的 P99
echo "k6 : $(python3 -c "import json;print(json.load(open('k6.json'))['metrics']['http_req_duration']['p(99)'])") ms"
预期:k6(开环)和 wrk2(开环)的 P99 应该接近;wrk(闭环)的 P99 会明显偏低。
如果 k6 与 wrk2 差很多,说明其中一方的配置有问题(通常是 preAllocatedVUs 不足导致 k6 降速)。
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 preAllocatedVUs 从 500 改成 30 |
实际到达率会明显低于设定值——这就是协调遗漏在工具层面的体现 |
去掉 discardResponseBodies: true |
压测机 CPU 上升,实际到达率可能下降(因为解析响应体很贵) |
把两个场景的 startTime 都设为 0 |
冷启动叠加,前 30 秒的数据不可用 |
给读场景加上 X-Load-Test 头并在服务端区分指标 |
压测流量与真实流量可以分开分析 |
| 用 wrk2 压同一个接口 | 对比两者的 P99——这是验证数据可信度的标准动作 |
六、这段代码的局限
check-load-generator.sh的 TIME_WAIT 阈值(20000)是经验值,取决于压测机的net.ipv4.ip_local_port_range与连接复用情况。- 多场景脚本会让指标标签变复杂:
path:read这类标签会成倍增加序列数,注意基数。 - k6 单机性能有限:需要更高并发时,应该用 k6 的分布式模式(
k6 cloud或自建多实例 + 结果聚合),而不是在同一台机器上开更多 VU。 - 压测写接口会污染数据:本文件里写场景用的是随机
userId,但订单表会持续增长——长时间压测前要设计数据清理或分片隔离。