文档目录

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,但订单表会持续增长——长时间压测前要设计数据清理或分片隔离。