k6 Load Testing: Stress-Test Your APIs Before Launch

Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.5k 字数|

属于我们的Performance & Scalability系列

阅读完整指南

k6 负载测试:在发布前对您的 API 进行压力测试

在没有进行负载测试的情况下交付产品是一场赌博。在用户为你找到突破点之前,你并不知道你的突破点——通常是在产品发布、病毒式传播或销售高峰期间。 k6 是现代负载测试工具,可让您用 JavaScript 编写测试,从 CI 运行它们,并在用户之前发现您的性能上限。它对开发人员友好、资源高效(k6 使用 goroutine,而不是线程),并与 Grafana 和 Prometheus 干净地集成以获取实时指标。

本指南涵盖了 k6,从第一个脚本到复杂的多场景负载测试、自定义指标、阈值、CI 集成以及 Node.js/NestJS API 的生产安全测试模式。

要点

  • k6 脚本是 JavaScript,但在 Go 运行时中运行 - 没有 Node.js API(没有 require、没有 fs、没有 setTimeout
  • 虚拟用户(VU)是并发模拟用户;迭代是单独的脚本执行
  • 运行前始终设置阈值 - 失败的阈值会停止测试并导致 CI 失败
  • 使用场景(constant-vus、ramping-vus、constant-arrival-rate)来模拟真实流量模式
  • 切勿在未与运营人员协调的情况下加载测试生产 - 针对登台或生产克隆进行测试
  • http_req_duration 指标是您的主要 SLA 指标 - p95 和 p99 比平均值更重要
  • 速率限制您的 k6 运行者 — 负载测试流量不应耗尽您的速率限制预算
  • 使用 k6 Cloud 或 Grafana k6 进行跨地理区域的分布式负载测试

安装和第一个脚本

# macOS
brew install k6

# Ubuntu/Debian
sudo gpg -k && sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6

# Windows (via Chocolatey)
choco install k6

您的第一个 k6 脚本:

// k6/homepage-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

// Custom metric: error rate
const errorRate = new Rate('error_rate');

export const options = {
  // Ramp up to 50 VUs over 1 minute, hold 2 minutes, ramp down
  stages: [
    { duration: '1m', target: 50 },
    { duration: '2m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th pct < 500ms
    http_req_failed:   ['rate<0.01'],                // Less than 1% errors
    error_rate:        ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export default function () {
  const res = http.get(`${BASE_URL}/`);

  const ok = check(res, {
    'status is 200':              (r) => r.status === 200,
    'response time < 500ms':     (r) => r.timings.duration < 500,
    'body contains ECOSIRE':     (r) => r.body.includes('ECOSIRE'),
  });

  errorRate.add(!ok);
  sleep(1); // 1 second think time between iterations
}

运行它:

k6 run k6/homepage-load.js
k6 run --env BASE_URL=https://staging.ecosire.com k6/homepage-load.js
k6 run --vus 100 --duration 30s k6/homepage-load.js  # Quick smoke test

带身份验证的 API 负载测试

// k6/api-load.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';

const apiCalls = new Counter('api_calls');
const authDuration = new Trend('auth_duration');

export const options = {
  scenarios: {
    // Constant arrival rate: 100 requests/second regardless of VU count
    constant_load: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '3m',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  thresholds: {
    http_req_duration:            ['p(95)<300', 'p(99)<500'],
    'http_req_duration{type:api}': ['p(95)<200'],
    http_req_failed:              ['rate<0.005'], // 0.5% error budget
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001';

// setup() runs once before VUs start
export function setup() {
  // Authenticate and return tokens for VUs to use
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: __ENV.TEST_EMAIL,
    password: __ENV.TEST_PASSWORD,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, { 'login successful': (r) => r.status === 200 });

  const { accessToken } = loginRes.json();
  return { accessToken };
}

export default function (data) {
  const { accessToken } = data;
  const headers = {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  };

  group('Contacts API', () => {
    // List contacts
    const listRes = http.get(`${BASE_URL}/contacts?page=1&limit=20`, {
      headers,
      tags: { type: 'api', endpoint: 'contacts-list' },
    });

    check(listRes, {
      'list contacts: 200': (r) => r.status === 200,
      'list contacts: has items': (r) => r.json('data') !== null,
    });

    apiCalls.add(1);
    sleep(0.5);

    // Create a contact
    const createRes = http.post(`${BASE_URL}/contacts`, JSON.stringify({
      name: `Load Test User ${Date.now()}`,
      email: `load-test-${Date.now()}@example.com`,
    }), {
      headers,
      tags: { type: 'api', endpoint: 'contacts-create' },
    });

    check(createRes, {
      'create contact: 201': (r) => r.status === 201,
    });

    apiCalls.add(1);
  });

  sleep(1);
}

// teardown() runs once after all VUs finish
export function teardown(data) {
  // Clean up test data if needed
  console.log(`Test complete. Data: ${JSON.stringify(data)}`);
}

提升 VU 场景

对具有多个阶段和场景的真实流量模式进行建模:

// k6/stress-test.js
export const options = {
  scenarios: {
    // Scenario 1: Normal traffic baseline
    normal_traffic: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 20 },
        { duration: '5m', target: 20 },
      ],
      gracefulRampDown: '30s',
    },

    // Scenario 2: Sudden spike (marketing campaign goes live)
    traffic_spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      startTime: '7m', // Starts after baseline is stable
      stages: [
        { duration: '30s', target: 200 }, // Rapid spike
        { duration: '2m',  target: 200 }, // Hold spike
        { duration: '30s', target: 20  }, // Fall back
      ],
    },

    // Scenario 3: Soak test for memory leaks
    soak_test: {
      executor: 'constant-vus',
      vus: 10,
      duration: '30m', // Run for 30 minutes
      startTime: '10m',
    },
  },
  thresholds: {
    'http_req_duration{scenario:normal_traffic}': ['p(95)<300'],
    'http_req_duration{scenario:traffic_spike}':  ['p(95)<800'],
    http_req_failed:                              ['rate<0.02'],
  },
};

博客/站点地图抓取测试

// k6/sitemap-crawl.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// Load URLs from sitemap (pre-fetched and saved as JSON)
const urls = new SharedArray('sitemap urls', function () {
  // In real usage, fetch sitemap XML and parse URLs beforehand
  // SharedArray is loaded once and shared across all VUs (memory efficient)
  return JSON.parse(open('./sitemap-urls.json'));
});

export const options = {
  vus: 20,
  duration: '5m',
  thresholds: {
    http_req_duration: ['p(95)<2000'], // Public pages can be slower
    http_req_failed:   ['rate<0.01'],
  },
};

export default function () {
  // Each VU picks a random URL from the sitemap
  const url = urls[Math.floor(Math.random() * urls.length)];

  const res = http.get(url, {
    headers: {
      // Identify as load tester in logs
      'User-Agent': 'k6-load-tester/1.0',
    },
    timeout: '10s',
  });

  check(res, {
    'status is 200':           (r) => r.status === 200,
    'no error page':           (r) => !r.body.includes('Error'),
    'has canonical tag':       (r) => r.body.includes('rel="canonical"'),
    'response time < 2000ms': (r) => r.timings.duration < 2000,
  });

  sleep(Math.random() * 2 + 1); // Random 1-3 second think time
}

自定义指标和阈值

// k6/checkout-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Rate, Trend, Gauge } from 'k6/metrics';

// Custom metrics for business-specific tracking
const checkoutSuccesses = new Counter('checkout_successes');
const checkoutFailures  = new Counter('checkout_failures');
const checkoutDuration  = new Trend('checkout_duration');
const activeCheckouts   = new Gauge('active_checkouts');

export const options = {
  stages: [
    { duration: '1m', target: 10 },
    { duration: '3m', target: 30 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    checkout_duration:   ['p(95)<3000'],   // Checkout < 3s at p95
    checkout_failures:   ['count<10'],     // Max 10 absolute failures
    http_req_duration:   ['p(99)<2000'],
  },
};

export default function (data) {
  activeCheckouts.add(1);
  const start = Date.now();

  // Step 1: Add to cart
  const cartRes = http.post('/api/cart/add', JSON.stringify({
    productId: 'prod_odoo_customization',
    quantity: 1,
  }), { headers: { 'Content-Type': 'application/json' } });

  if (!check(cartRes, { 'add to cart: 200': (r) => r.status === 200 })) {
    checkoutFailures.add(1);
    activeCheckouts.add(-1);
    return;
  }

  sleep(2);

  // Step 2: Create checkout session
  const checkoutRes = http.post('/api/billing/checkout', JSON.stringify({
    cartId: cartRes.json('cartId'),
  }), { headers: { 'Content-Type': 'application/json' } });

  const success = check(checkoutRes, {
    'checkout session created': (r) => r.status === 200,
    'has session URL':          (r) => r.json('url') !== null,
  });

  if (success) {
    checkoutSuccesses.add(1);
    checkoutDuration.add(Date.now() - start);
  } else {
    checkoutFailures.add(1);
  }

  activeCheckouts.add(-1);
  sleep(1);
}

Grafana 输出集成

在测试期间将 k6 指标流式传输到 InfluxDB 并在 Grafana 中可视化:

# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 k6/api-load.js

# Or use the k6 Grafana integration (k6 v0.45+)
k6 run --out experimental-prometheus-rw k6/api-load.js
# docker-compose.monitoring.yml
services:
  influxdb:
    image: influxdb:2.7
    ports: ["8086:8086"]
    environment:
      INFLUXDB_DB: k6
      INFLUXDB_ADMIN_USER: admin
      INFLUXDB_ADMIN_PASSWORD: password

  grafana:
    image: grafana/grafana:latest
    ports: ["3030:3000"]
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
    volumes:
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards
      - ./grafana/datasources:/etc/grafana/provisioning/datasources

CI 集成

# .github/workflows/ci.yml (load test section)
load-tests:
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main' # Only on main branch
  needs: [deploy-staging]
  steps:
    - uses: actions/checkout@v4

    - name: Install k6
      run: |
        sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
          --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
        echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
          | sudo tee /etc/apt/sources.list.d/k6.list
        sudo apt-get update && sudo apt-get install k6

    - name: Run smoke test (30 VUs, 60s)
      run: |
        k6 run --vus 30 --duration 60s \
          --env BASE_URL=${{ secrets.STAGING_URL }} \
          --env TEST_EMAIL=${{ secrets.TEST_EMAIL }} \
          --env TEST_PASSWORD=${{ secrets.TEST_PASSWORD }} \
          apps/api/k6/api-load.js

    - name: Upload k6 results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: k6-results
        path: k6-results/

性能基线

建立基线并对回归发出警报:

|端点 | p50 | p50 | p95 | p95 | p99 | p99 |错误预算| |---|---|---|---|---| | GET /(主页)| 120 毫秒 | 400 毫秒 | 800 毫秒 | 0.1% | | 代码0 | 50 毫秒 | 150 毫秒 | 300 毫秒 | 0.5% | | 代码0 | 80 毫秒 | 200 毫秒 | 400 毫秒 | 0.5% | | 代码0 | 200 毫秒 | 500 毫秒 | 1000 毫秒 | 1% | | 代码0 | 500 毫秒 | 1500 毫秒 | 3000 毫秒 | 1% | | 代码0 | 100 毫秒 | 300 毫秒 | 600 毫秒 | 0.1% |


常见问题

VU 数和每秒请求数有什么区别?

VU(虚拟用户)是并发模拟用户。每个 VU 在循环中运行默认函数。每秒请求数取决于每次迭代的运行速度。如果每个 VU 迭代需要 2 秒(包括睡眠时间)并且您有 100 个 VU,则您将获得大约 50 RPS。当您需要以特定 RPS 为目标(无论迭代持续时间如何)时,请使用 constant-arrival-rate 执行程序。

我应该针对生产还是登台进行负载测试?

总是更喜欢舞台表演。生产负载测试存在影响真实用户、耗尽速率限制、根据测试数据创建真实订单以及触发真实 Webhook 事件的风险。如果您必须测试生产,请在低流量时段进行,使用测试支付令牌,并制定回滚计划。 k6 Cloud 支持地理分布式负载生成,因此您可以在不触及源点的情况下测试 CDN 边缘性能。

如何在长时间测试期间处理身份验证令牌过期?

对于短期测试(15 分钟以下),在 setup() 中获取令牌并通过 data 将其传递给所有 VU。对于长时间测试(超过 15 分钟),请在测试脚本中实现令牌刷新:在默认函数中检查令牌年龄,并在接近到期时刷新。将令牌存储在每个 VU 本地的 JavaScript 变量中。

stagesscenarios 有什么区别?

stages 是单个 ramping-vus 场景的简写 - 适用于简单的斜坡上升/保持/斜坡下降模式。 scenarios 为您提供完全控制:多个并发流量模式、每个场景的不同执行器、每个场景的阈值以及指标中的场景标记。使用 scenarios 进行实际的多模式测试(同时基线 + 尖峰 + 浸泡)。

单个 k6 进程可以处理多少个 VU?

现代硬件上的单个 k6 进程可以支持 5,000-10,000 个 VU 并生成 50,000-100,000 RPS,具体取决于脚本复杂性和响应大小。对于更高的负载,请使用 k6 cloud 或在负载分配器后面运行多个 k6 实例。每个 VU 都非常轻量级(一个 Goroutine)——对于同等的 VU 数量,k6 使用的资源比 JMeter 或 Gadling 少得多。


后续步骤

在用户发现应用程序处于压力下之前,负载测试可以揭示应用程序的真实性能概况。本指南中的模式(斜坡场景、自定义阈值、CI 集成和 Grafana 仪表板)为您提供持续查找和修复性能瓶颈的基础架构。

ECOSIRE 通过 k6 负载测试构建经过性能验证的 NestJS API,涵盖主页、API 端点、结帐流程和完整的站点地图爬网。 探索我们的后端工程服务,了解我们如何从第一天开始就提高性能。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天