結論先講

k6 腳本就是 JavaScript,但它不是跑在 Node.js 上。 k6 有自己的 Go runtime,所以你不能 require('axios') 也不能 import fs。但它內建的 httpcheckgroup 已經夠用了。搞懂這幾個 API,你就能寫出涵蓋 90% 場景的壓測腳本。


最小可用腳本

import http from 'k6/http';
import { check } from 'k6';
 
export const options = {
  vus: 10,
  duration: '30s',
};
 
export default function () {
  const res = http.get('http://localhost:3000/health');
  check(res, {
    'status is 200': (r) => r.status === 200,
  });
}

這就是一個完整的壓測腳本。10 個 VU,跑 30 秒,每個 VU 不斷打 /health

幾個重要的事:

  • export default function 是每個 VU 的「主迴圈」。k6 會不斷重複執行這個函式
  • options 控制 VU 數量和時間
  • check 不會讓測試失敗——它只是記錄「這個 assertion 通過了幾次」。你在報告裡看到 checks: 98% ✓ 就知道有 2% 的 request 沒回 200

Stages:模擬真實流量的升降

固定 10 VU 跑 30 秒太假了。真實流量是慢慢上來、維持一段時間、然後慢慢下去。

export const options = {
  stages: [
    { duration: '1m', target: 50 },    // 1 分鐘內從 0 爬到 50 VU
    { duration: '3m', target: 50 },    // 維持 50 VU 跑 3 分鐘
    { duration: '1m', target: 100 },   // 1 分鐘內爬到 100 VU
    { duration: '3m', target: 100 },   // 維持 100 VU 跑 3 分鐘
    { duration: '1m', target: 0 },     // 1 分鐘內降到 0(收尾)
  ],
};

我們壓測平台用的標準 stages:每個 VU 等級維持 3 分鐘。為什麼?

  • 太短(30 秒): 系統還沒穩定就結束了,數據不準
  • 太長(10 分鐘): 9 框架 × 7 VU 等級 = 63 次,每次 10 分鐘要跑 10 小時
  • 3 分鐘: 足夠讓系統穩定,也不會跑太久

Group:給你的操作取名字

如果腳本裡有多個操作,你需要知道「哪個操作慢」。用 group 包起來:

import { group } from 'k6';
 
export default function () {
  group('Register', function () {
    http.post('http://localhost:3000/api/users/register', JSON.stringify({
      email: `user_${__VU}_${__ITER}@test.com`,
      password: 'Test1234!',
      name: 'Test User',
    }), { headers: { 'Content-Type': 'application/json' } });
  });
 
  group('Login', function () {
    const loginRes = http.post('http://localhost:3000/api/users/login', JSON.stringify({
      email: `user_${__VU}_${__ITER}@test.com`,
      password: 'Test1234!',
    }), { headers: { 'Content-Type': 'application/json' } });
 
    const token = JSON.parse(loginRes.body).token;
 
    group('Get Profile', function () {
      http.get('http://localhost:3000/api/users/me', {
        headers: { Authorization: `Bearer ${token}` },
      });
    });
  });
}

__VU 是當前 VU 的編號,__ITER 是當前迭代次數。用它們組合出唯一的 email,避免 VU 之間的資料衝突。

在報告裡你會看到每個 group 的獨立指標——Register 平均多少 ms、Login 平均多少 ms。這比「整體平均 500ms」有用太多了。


混合場景腳本:加權隨機

我們的混合場景是 70% Read + 20% Write + 10% Auth。怎麼實作?

export default function () {
  const rand = Math.random();
 
  if (rand < 0.7) {
    // 70% Read
    if (Math.random() < 0.5) {
      group('Post List', () => http.get(`${BASE_URL}/api/posts`));
    } else {
      group('Post Detail', () => http.get(`${BASE_URL}/api/posts/1`));
    }
  } else if (rand < 0.9) {
    // 20% Write
    group('Create Post', () => {
      http.post(`${BASE_URL}/api/posts`, JSON.stringify({
        title: `Post ${Date.now()}`,
        content: 'Benchmark test content',
      }), { headers: authHeaders });
    });
  } else {
    // 10% Auth
    group('Register + Login', () => {
      // register and login flow
    });
  }
}

每個 VU 每次迭代隨機決定做什麼操作。跑夠多次之後,自然會趨近 70/20/10 的比例。


Check:不只是 status code

很多人的 check 只寫 status === 200。但壓測時你應該驗更多東西:

check(res, {
  'status is 200': (r) => r.status === 200,
  'response time < 2s': (r) => r.timings.duration < 2000,
  'body has token': (r) => JSON.parse(r.body).token !== undefined,
  'body is not empty': (r) => r.body.length > 0,
});

為什麼?因為有些框架在高併發時會回 200 但 body 是空的——server 沒處理完就回了。只檢查 status code 會漏掉這種問題。


Tag:讓指標可以切片

如果你想在 Grafana 裡按操作類型篩選,用 tag:

http.get(`${BASE_URL}/api/posts`, {
  tags: { operation: 'read', endpoint: 'post_list' },
});
 
http.post(`${BASE_URL}/api/posts`, body, {
  tags: { operation: 'write', endpoint: 'create_post' },
});

這樣在 InfluxDB/Grafana 裡你可以查「只看 read 操作的 P95」或「只看 write 操作的 error rate」。


檔案上傳腳本

k6 的檔案上傳不太直覺。你不能直接讀本地檔案(記得嗎,k6 不跑在 Node.js 上)。要用 open() 在 init 階段讀檔,然後在 VU 階段用 http.file() 包裝:

import http from 'k6/http';
 
// init 階段:讀檔(只執行一次)
const testFile = open('./test-file-1mb.bin', 'b');
 
export default function () {
  const data = {
    file: http.file(testFile, 'test-file.bin', 'application/octet-stream'),
  };
 
  http.post(`${BASE_URL}/api/files/upload`, data);
}

open() 是 k6 的特殊函式,只能在 init 階段(export default function 外面)使用。它把檔案讀進記憶體,然後所有 VU 共享這份資料。


常見的坑

1. 不要在 VU 函式裡做 setup

// 錯的 ❌ — 每個 VU 每次迭代都在 register
export default function () {
  http.post('/register', ...);
  http.post('/login', ...);
  http.get('/profile', ...);
}
 
// 對的 ✅ — setup 階段 register,VU 階段只做要測的操作
export function setup() {
  const res = http.post('/register', ...);
  return { token: JSON.parse(res.body).token };
}
 
export default function (data) {
  http.get('/profile', {
    headers: { Authorization: `Bearer ${data.token}` },
  });
}

setup() 只跑一次,結果傳給所有 VU。這樣你測的是「Get Profile 的效能」而不是「Register + Login + Get Profile 的效能」。

2. sleep 不是可選的

export default function () {
  http.get('/api/posts');
  sleep(1);  // 模擬用戶的思考時間
}

沒有 sleep,每個 VU 會不停送 request——這不是壓力測試,是 DDoS。加 sleep(1) 模擬用戶看完頁面後才點下一個。

不過我們的跨框架比較刻意不加 sleep,因為我們要測的是「框架的極限」而非「真實用戶場景」。這是有意的選擇,在結果解讀時要記得。

3. 動態資料要小心

// 每個 VU 用同一組帳號 → 會衝突
const email = 'test@test.com';
 
// 用 VU 編號 + 迭代次數組合 → 唯一
const email = `user_${__VU}_${__ITER}@test.com`;

下一篇

用 Grafana 讀懂壓測數據 — 腳本寫好、壓測跑完,然後呢?打開 Grafana 看到一堆線和數字,哪些要看、哪些可以忽略、什麼圖形代表「系統正在崩潰」。


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:控制變因的藝術:跨框架壓測怎麼做到公平 → 下一篇:用 Grafana 讀懂壓測數據:哪些數字要看、哪些可以忽略