Đo điểm gãy (breaking point) hệ thống PHP/Node.js bằng k6

Điểm gãy (breaking point, còn gọi là knee — "đầu gối" của đường cong độ trễ theo tải) là mức tải thấp nhất mà ở đó hệ thống không còn giữ được cam kết của nó nữa: độ trễ p95/p99 vượt SLO (service level objective — mục tiêu chất lượng dịch vụ đã cam kết), tỉ lệ lỗi vượt ngân sách lỗi, hoặc số request xử lý xong mỗi giây ngừng đi theo số request được gửi tới. Dưới điểm đó, tăng tải thì độ trễ chỉ nhích nhẹ; vượt qua nó, hàng đợi bắt đầu dài ra không giới hạn và độ trễ tăng vọt theo cấp số — trong lab PHP của bài này, cùng một endpoint /api/db có p95 109 ms ở 150 request/giây nhưng 1,11 giây ở 180 request/giây, tức tải tăng 20% mà độ trễ đuôi tăng hơn 10 lần.

Con số này quan trọng vì ba quyết định vận hành đều dựa vào nó. Thứ nhất là capacity planning (lập kế hoạch năng lực): biết một node chịu được bao nhiêu request/giây ở SLO thì mới tính được cần mấy node cho đỉnh tải dự kiến, kể cả khi mất một node (N+1). Thứ hai là ngưỡng autoscale (tự động thêm máy): phải scale-out trước điểm gãy một khoảng đủ cho thời gian khởi động máy mới, nên ngưỡng CPU/RPS kích hoạt scale phải suy ra từ điểm gãy đo được, không phải chọn đại 70% CPU. Thứ ba là rate limit (giới hạn tốc độ): trần toàn hệ thống đặt dưới điểm gãy một khoảng an toàn — cách chọn khoảng đó đã có trong bài Cách tính con số rate limit; bài này lo phần đứng trước: đo điểm gãy sao cho con số đáng tin.

Sau bài này, người đọc làm được bốn việc: viết một test k6 kiểu mở (open model) tăng tải theo bậc thang cho một API PHP hoặc Node.js; tính số VU cần cấp phát bằng Little's law; theo dõi đúng tín hiệu bão hoà phía server (php-fpm status, event-loop delay, hàng đợi pool kết nối) trong lúc bắn tải; và đọc kết quả từng bậc để báo cáo "max sustainable RPS" (tải cao nhất duy trì được) kèm khoảng an toàn, độ dao động giữa các lần chạy và giới hạn của phép đo. Mọi con số trong bài lấy từ hai lab chạy thật trên VPS này (Laravel 13.39 trên nginx 1.31.8 + php-fpm 8.5.12, Express 5.3 trên Node v26.13.0, PostgreSQL 18.6, k6 v2.3.0); phạm vi là môi trường lab/staging, không bàn quy trình chạy trên production.

Thuật ngữ dùng trong bài
VU (virtual user — user ảo, mỗi VU là một runtime JavaScript chạy một iteration tại một thời điểm) · iteration (một lần chạy hàm default của script) · executor (bộ lập lịch quyết định khi nào bắt đầu iteration) · open model / closed model (mô hình mở: request đến theo lịch cố định bất kể server nhanh hay chậm / mô hình đóng: mỗi VU chờ response rồi mới gửi tiếp) · throughput / goodput (số request xử lý xong mỗi giây / phần trong đó thành công và đủ nhanh để dùng được) · saturation (bão hoà: tài nguyên đã bận hết, việc mới phải xếp hàng) · coordinated omission (công cụ đo vô tình "phối hợp" với server bị đo nên bỏ sót thời gian chờ thật) · threshold (ngưỡng pass/fail của k6 trên một metric) · SLO (mục tiêu chất lượng, ví dụ p95 < 200 ms).
Môi trường lab và giới hạn của nó: VPS 4 vCPU (AMD EPYC, KVM), 7,8 GB RAM. App ghim 2 core (cpu 2,3), PostgreSQL ghim 1 core (cpu 1), k6 ghim 1 core (cpu 0) — cùng một máy, chung kernel, chung network stack, chung dockerd. Vì vậy con số tuyệt đối chỉ đúng cho máy này; giá trị của lab là phương pháp và hình dạng của đường cong. Script và output gốc: labs/performance/php/ và labs/performance/nodejs/ (mỗi thư mục có README.md, output trong out/). Kiến thức k6 tra từ tài liệu chính thức (grafana.com/docs/k6, bản v2.3.x) qua Context7; chỗ nào chưa kiểm được ghi "chưa verify".

0. Hướng dẫn từng bước: đo điểm gãy bằng k6

Phần này là quy trình làm thật, đi theo đúng thứ tự cần làm. Các phần 1–4 phía sau giải thích vì sao từng bước lại như vậy (k6 sinh ra để làm gì, mô hình executor/VU, internals, giới hạn lý thuyết). Toàn bộ quy trình gói trong sơ đồ dưới: điểm mấu chốt là tiêu chí gãy phải viết ra trước khi bắn tải, và con số báo cáo là bậc cuối cùng còn đạt, không phải thời điểm test bị abort.

flowchart TD
  S1["1. Viết tiêu chí gãy<br/>SLO p95/p99, error budget,<br/>goodput, dropped_iterations, saturation"]
  S2["2. Cô lập môi trường<br/>ghim CPU, tách máy bắn tải,<br/>tắt autoscale, dữ liệu giống thật"]
  S3["3. Viết script k6<br/>ramping-arrival-rate theo bậc,<br/>tag mỗi request theo bậc"]
  S4["4. Tính preAllocatedVUs / maxVUs<br/>Little: VU = rate x latency"]
  S5["5. Thresholds<br/>per-step = thước đo,<br/>abortOnFail = phanh an toàn"]
  S6["6. Bật quan sát phía server<br/>fpm status, event-loop delay,<br/>pool waiting, CPU từng container"]
  S7["7. Chạy: smoke, dò thô, bậc mịn"]
  S8["8. Đọc từng bậc<br/>p95 bậc, ok/s vs sent/s,<br/>in-flight, tài nguyên bão hoà"]
  S9{"Lặp lại đủ 3 lần,<br/>knee lệch nhau tối đa 1 bậc?"}
  S10["9. Báo cáo max sustainable RPS<br/>= bậc cuối còn đạt, trừ margin,<br/>kèm môi trường + độ dao động"]
  S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8 --> S9
  S9 -- "Chưa" --> S2
  S9 -- "Rồi" --> S10
Video (76 giây): k6 tăng tải từng bậc tới điểm gãy — php-fpm hết worker, Node kẹt 1 core, cluster dời điểm gãy, mô hình closed che điểm gãy. Mọi con số lấy từ lab trong bài.

0.1 Bước 1 — Viết tiêu chí gãy trước khi bắn tải

Lý do phải viết trước: nếu chạy xong mới nhìn đồ thị để "chọn" điểm gãy, ai cũng có xu hướng chọn chỗ đẹp. Tiêu chí viết sẵn biến việc đọc kết quả thành phép so sánh cơ học. Một bậc tải (step) được coi là gãy khi bất kỳ điều kiện nào dưới đây đúng trong suốt cửa sổ giữ tải của bậc đó (không phải một giây lẻ):

  1. Vi phạm SLO độ trễ. p95 (hoặc p99) của http_req_duration tính riêng cho các request bắt đầu trong bậc đó vượt SLO. Lab PHP dùng SLO p95 < 200 ms cho /api/db, lab Node dùng p95 < 100 ms. Lấy riêng từng bậc là điều bắt buộc: p95 của cả bài test là trung bình trộn giữa bậc nhẹ và bậc nặng nên luôn thấp hơn thực tế ở bậc nặng (phần 0.5 có số đo).

  2. Vượt ngân sách lỗi (error budget). Tỉ lệ http_req_failed trong bậc vượt ngưỡng, cả hai lab dùng 1% (rate<0.01, giống ví dụ trong docs k6). Mặc định k6 coi status 200–399 là thành công; 4xx/5xx và lỗi transport (timeout, connection refused) đều tính là lỗi.

  3. Goodput không còn đi theo tải gửi. Số response thành công mỗi giây (ok/s) thấp hơn rõ rệt số request gửi mỗi giây (sent/s). Ví dụ thật: ở bậc 240 request/giây của lab PHP (lần diag-tcp-2), k6 gửi 239,9/giây nhưng chỉ nhận 211,3 response thành công/giây — 12% tải đang dồn vào hàng đợi. Tiêu chí này ít nhạy với nhiễu hơn tiêu chí độ trễ, nên là "mỏ neo" khi p95 dao động.

  4. Bộ bắn tải không giữ được lịch. dropped_iterations > 0 sau giai đoạn khởi động, hoặc số VU đang chạy chạm maxVUs (k6 in cảnh báo Insufficient VUs). Lúc này tải thực gửi đã thấp hơn tải định gửi, và các con số độ trễ không còn đại diện cho user thật (phần 4.2 giải thích vì sao).

  5. Một tài nguyên phía server bão hoà kéo dài. Với php-fpm: listen queue > 0 liên tục và idle processes = 0. Với Node: ELU (event loop utilization — tỉ lệ thời gian event loop bận) chạm 1,00 và event-loop delay p99 tăng vọt, hoặc pool.waitingCount của pg > 0 liên tục. Tiêu chí này không tự nó là "gãy" (một tài nguyên chạy 90% vẫn có thể đạt SLO), nhưng nó gọi tên thứ gây ra gãy — thiếu nó thì báo cáo chỉ nói "hỏng ở 180" mà không nói "vì CPU của app box".

Tiêu chíĐo bằng gìNgưỡng dùng trong labĐiểm yếu
SLO độ trễ theo bậcsub-metric http_req_duration{stage:...} / {step:...}PHP p95 < 200 ms, Node p95 < 100 msNhạy với nhiễu: một lần khựng 1–2 giây của host đủ làm hỏng p95 cả bậc 20 giây
Ngân sách lỗihttp_req_failed theo bậc< 1%Đến muộn: hệ thống thường chậm rất lâu trước khi bắt đầu trả lỗi
Goodput bám tảiok/s so với sent/s (tính từ CSV hoặc summary)ok/s ≥ ~98% sent/sKhông thấy được độ trễ tăng khi hàng đợi còn ngắn
Giữ được lịchdropped_iterations, vus so với maxVUs0 drop sau khởi độngCó thể drop vì cấu hình k6 chứ không phải vì server (phần 4.3)
Bão hoà serverphp-fpm status, ELU / event-loop delay, waitingCount, cgroup CPUqueue > 0 kéo dài, ELU ≈ 1Cần instrument trước; không có thì đoán mò
Cách nói khi phỏng vấn: "Trước khi chạy tôi viết ra năm điều kiện, bậc nào dính một trong số đó là gãy: p95 hoặc p99 của riêng bậc đó vượt SLO, tỉ lệ lỗi vượt một phần trăm, số response thành công mỗi giây tụt khỏi số request gửi đi, công cụ bắn tải bắt đầu bỏ iteration vì hết user ảo, và có một tài nguyên phía server bão hoà kéo dài. Bốn điều đầu quyết định bậc nào gãy, điều cuối giải thích vì sao gãy. Viết trước là để không chọn điểm gãy theo cảm tính sau khi đã nhìn đồ thị."

0.2 Bước 2 — Cô lập môi trường

Một phép đo điểm gãy chỉ có giá trị khi trả lời được câu "con số này là giới hạn của cái gì". Các nguồn nhiễu phổ biến và cách lab xử lý:

  1. Ghim tài nguyên cho từng thành phần. Lab chạy mọi thứ bằng container và ghim CPU bằng --cpuset-cpus: k6 ở cpu 0, PostgreSQL ở cpu 1, app ở cpu 2,3 (với PHP, nginx và php-fpm dùng chung 2 core này, coi như một "app box" 2 vCPU). Nhờ vậy khi app chạm 100% thì chắc chắn đó là 2 core của app, và k6 không tranh CPU với app. Kèm giới hạn RAM (--memory) để lỗi hết bộ nhớ hiện ra thay vì lấn sang service khác.

  2. Tách máy bắn tải. Khuyến nghị đúng là k6 chạy trên máy riêng, cùng vùng mạng với hệ thống bị đo. Lab này buộc phải chạy cùng host, nên mọi con số mang caveat: k6 và app chia nhau kernel, network stack và dockerd; độ trễ đo được gồm cả phần xử lý mạng trên cùng máy. Docs k6 khuyến nghị giữ CPU của k6 dưới 80%, vì khi k6 dùng 100% CPU thì độ trễ đo được bị thổi phồng (phần 4.4).

  3. Tắt autoscale và mọi thứ "co giãn". Docs k6 nói thẳng: bật autoscale khi chạy breakpoint test thì thứ tìm được là giới hạn hoá đơn cloud, không phải giới hạn hệ thống. Cùng lý do, php-fpm trong lab chạy pm = static để số worker cố định trong suốt test.

  4. Warm-up. Request đầu tiên sau khi khởi động luôn khác trạng thái ổn định: OPcache chưa biên dịch file (PHP), V8 chưa JIT (Node), buffer cache của PostgreSQL lạnh, pool kết nối chưa mở. Lab Node chạy một scenario warmup 20 request/giây trước bậc đầu và mở sẵn đủ 10 kết nối pg lúc boot — vì lần thử đầu không làm vậy, pool co lại còn 1–2 kết nối trong lúc warm-up (do idleTimeoutMillis mặc định 10 giây) và bậc 100 request/giây đầu tiên có p95 278 ms chỉ vì phải mở kết nối mới. Lab PHP bắn vài lượt request trước khi đo và bật opcache.validate_timestamps=0 cùng config/route cache.

  5. Dữ liệu giống thật. Lab Node có bảng 1.000.000 dòng và chọn ngẫu nhiên 1.000 khách hàng cho mỗi request; lab PHP tra theo khoá chính ngẫu nhiên trên 10.000 dòng. Nếu mọi request cùng hỏi một ID, cache sẽ trả lời hết và điểm gãy đo được cao hơn thực tế; ngược lại, nếu mọi request cùng ghi một dòng thì lock trên dòng đó gãy sớm hơn thực tế.

  6. Đo nhiễu của chính host trước khi tin nó. Lab Node chạy một vòng lặp ngủ 1 ms ghim vào cpu 2 trong lúc app rảnh, ghi lại mỗi lần thức dậy trễ hơn 30 ms. Kết quả: 94 lần trễ trong 130 giây, tối đa 147 ms, dồn vào mấy giây đầu mỗi phút; cron sysstat chạy mỗi 5 phút. Một lần khựng như vậy rơi vào giữa cửa sổ đo là đủ làm p95 của cả bậc vượt SLO:

### noise check start 13:09:12 (taskset -c 2, threshold 30 ms)
13:09:18 stall_ms=94
13:09:19 stall_ms=147
Oct 30 11:20:05 vmi3164815 systemd[1]: sysstat-collect.service: Consumed 1.046s CPU time.
Cách nói khi phỏng vấn: "Trước khi đo hệ thống, tôi đo độ nhiễu của chính cái máy: một vòng lặp ngủ một mili giây ghim vào core của app, đếm số lần nó thức dậy trễ. Trên VPS lab có gần trăm lần trễ hơn 30 mili giây trong hai phút, lần tệ nhất gần 150 mili giây, dồn vào đầu mỗi phút. Biết vậy nên tôi căn mỗi bậc tải bắt đầu đúng đầu phút để cú khựng rơi vào đoạn chuyển bậc không được tính, và tôi báo điểm gãy dưới dạng khoảng chứ không phải một con số."

Hệ quả thực hành của mục 6: lab Node căn để mỗi bậc bắt đầu đúng đầu phút, phần "khựng" rơi vào 8 giây chuyển bậc (RAMP) không được gắn tag đo. Trên một môi trường staging thật, nên chạy cùng phép đo nhiễu này trước một lần — nếu host khựng hàng trăm mili giây thì không có SLO p95 100 ms nào đo ổn định được trên nó.

0.3 Bước 3 — Viết script k6: ramping-arrival-rate theo bậc thang

Docs k6 khuyến nghị executor ramping-arrival-rate cho breakpoint test, vì đây là executor kiểu mở: tải tiếp tục tăng theo lịch ngay cả khi hệ thống bắt đầu chậm (phần 2.2 giải thích vì sao ramping-vus không làm được điều đó). Docs mô tả breakpoint test là "tăng chậm, không có bậc nghỉ"; cả hai lab chọn biến thể bậc thang (mỗi bậc giữ tải một khoảng) vì mỗi bậc cần đủ mẫu để p95/p99 ổn định — thực tế nên làm hai pha: một lần ramp liên tục để tìm vùng gãy thô, rồi bậc thang mịn quanh vùng đó để chốt con số (đây là tổng hợp của bài, không phải quy trình docs k6 quy định).

Script của lab PHP, labs/performance/php/k6/stepped.js (nguyên văn từ dòng 10 đến hết file, bỏ phần comment đầu; số dòng nhắc tới bên dưới là số dòng trong file gốc):

import http from 'k6/http';
import exec from 'k6/execution';

const URL = __ENV.URL || 'http://tmp-perf-php-nginx/api/db';
const STEPS = (__ENV.STEPS || '50,100,150').split(',').map(Number);
const HOLD = Number(__ENV.HOLD || 20);
const SLO = Number(__ENV.SLO_MS || 250);
const ABORT_P95 = Number(__ENV.ABORT_P95_MS || SLO * 2.5);
const ABORT = __ENV.ABORT !== '0';
const VUS = Number(__ENV.VUS || 600);
const TIMEOUT = __ENV.TIMEOUT || '10s';

const label = (r) => 'r' + String(r).padStart(4, '0');

const stages = [];
for (const r of STEPS) {
  stages.push({ target: r, duration: '0s' });          // jump instantly to the step rate
  stages.push({ target: r, duration: `${HOLD}s` });    // hold it
}

const thresholds = {
  // global guard: abort once the run is clearly broken. Thresholds are evaluated on the CUMULATIVE metric
  // (all steps so far), so the abort lags the knee; a looser p95 keeps a 1-2 s host stall from aborting early.
  http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: ABORT, delayAbortEval: '10s' }],
  http_req_duration: [{ threshold: `p(95)<${ABORT_P95}`, abortOnFail: ABORT, delayAbortEval: '10s' }],
};
for (const r of STEPS) {
  // per-step sub-metrics (no abort) so the end-of-test summary prints one line per step
  thresholds[`http_req_duration{stage:${label(r)}}`] = [`p(95)<${SLO}`];
  thresholds[`http_req_failed{stage:${label(r)}}`] = ['rate<0.01'];
  thresholds[`http_reqs{stage:${label(r)}}`] = ['count>=0'];
}

export const options = {
  discardResponseBodies: true,
  summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max', 'count'],
  thresholds,
  scenarios: {
    step: {
      executor: 'ramping-arrival-rate',
      startRate: STEPS[0],
      timeUnit: '1s',
      preAllocatedVUs: VUS,
      maxVUs: VUS,
      stages,
    },
  },
};

export default function () {
  const elapsed = (Date.now() - exec.scenario.startTime) / 1000;
  const idx = Math.min(Math.floor(elapsed / HOLD), STEPS.length - 1);
  http.get(URL, { tags: { stage: label(STEPS[idx]) }, timeout: TIMEOUT });
}

Đọc từng khối:

  1. Dòng 13–20 — tham số qua biến môi trường. STEPS là danh sách tốc độ (request/giây) của từng bậc, HOLD là số giây giữ mỗi bậc, SLO_MS là SLO p95 dùng cho từng bậc, ABORT_P95_MS mặc định gấp 2,5 lần SLO, VUS là số VU cấp phát và cũng là trần số request đang bay (in-flight), TIMEOUT là timeout phía client 10 giây. Một script dùng cho mọi thí nghiệm, chỉ đổi biến môi trường — kết quả các lần chạy so sánh được với nhau.

  2. Dòng 24–28 — dựng stages bậc thang. Mỗi bậc là hai stage: {target: r, duration: '0s'} nhảy tức thì lên tốc độ r (docs k6: stage có duration 0 là tăng tức thời), rồi {target: r, duration: '20s'} giữ nguyên tốc độ đó. Với STEPS=60,90,120,150,180,210,240,300 và HOLD=20, test dài tối đa 160 giây.

  3. Dòng 30–35 — threshold toàn cục có abort. http_req_failed < 1% và http_req_duration p95 < 500 ms (2,5 × SLO 200 ms), cả hai abortOnFail với delayAbortEval: '10s'. Comment trong code đã ghi lý do nới ngưỡng: threshold tính trên metric tích luỹ từ đầu test nên abort luôn đến trễ; ngưỡng lỏng để một lần khựng 1–2 giây của host không dừng test sớm.

  4. Dòng 36–41 — sub-metric theo bậc, không abort. Với mỗi bậc khai báo ba threshold trên metric có tag stage:rNNNN. Threshold count>=0 luôn đạt — mục đích duy nhất là ép k6 in ra một dòng riêng cho từng bậc trong summary cuối. Đây là mẹo phổ biến mà cả hai lab dựa vào: sub-metric chỉ hiện trong summary khi có threshold tham chiếu tới nó.

  5. Dòng 43–57 — options. discardResponseBodies: true để không giữ body trong RAM (docs k6 khuyến nghị cho test lớn); summaryTrendStats thêm p(99) và count vì mặc định summary chỉ có tới p95; scenario step dùng ramping-arrival-rate, startRate bằng bậc đầu, timeUnit: '1s' nên target nghĩa là iteration mỗi giây, preAllocatedVUs = maxVUs = 600 để k6 không phải cấp phát VU giữa chừng.

  6. Dòng 59–63 — mỗi iteration một request, gắn tag bậc. Thời gian từ lúc scenario bắt đầu chia cho HOLD ra chỉ số bậc; request được gắn tag stage theo bậc mà nó bắt đầu. Không có sleep() cuối hàm: với executor arrival-rate, nhịp đã do executor quyết định (docs k6 dặn không thêm sleep).

Cách nói khi phỏng vấn: "Script của tôi dùng executor tăng theo tốc độ đến, mỗi bậc gồm một bước nhảy tức thì lên tốc độ mới và một đoạn giữ nguyên hai mươi giây. Mỗi request được gắn nhãn theo bậc mà nó bắt đầu, và tôi khai báo một ngưỡng giả cho từng nhãn để báo cáo cuối in ra độ trễ, tỉ lệ lỗi và số request của từng bậc. Ngưỡng có quyền dừng test chỉ có hai cái, đặt lỏng, vì nó tính trên toàn bộ dữ liệu từ đầu nên luôn đến trễ — nó là phanh an toàn chứ không phải thước đo."

Script của lab Node, labs/performance/nodejs/k6/breakpoint.js, giải quyết thêm ba vấn đề mà stepped.js chưa có: tách cửa sổ chuyển bậc khỏi cửa sổ đo, có scenario warm-up riêng, và chạy được cả mô hình mở lẫn đóng để so sánh (nguyên văn các đoạn chính):

const stages = [];
for (const s of STEPS) {
  stages.push({ target: s, duration: `${RAMP}s` });
  stages.push({ target: s, duration: `${HOLD}s` });
}

const stepName = (i) => `${String(i + 1).padStart(2, '0')}_${MODEL === 'open' ? 'rps' : 'vus'}${STEPS[i]}`;
// ... (warm-up căn theo phút, dòng 31-40)
const scenario = MODEL === 'open'
  ? { executor: 'ramping-arrival-rate', startRate: 0, timeUnit: '1s', startTime: `${WARMUP}s`,
      preAllocatedVUs: Number(__ENV.PRE_VUS || 100), maxVUs: Number(__ENV.MAX_VUS || 300), stages }
  : { executor: 'ramping-vus', startVUs: 0, startTime: `${WARMUP}s`, stages, gracefulRampDown: '5s' };

const ABORT = __ENV.NOABORT !== '1'; // NOABORT=1 keeps running past the break (to watch the queue)
// Abort thresholds only look at HOLD windows of the breakpoint scenario (tag phase:hold):
// warm-up and ramp windows are excluded because every minute boundary (host stall) falls
// in a ramp window. They are CUMULATIVE over all hold windows so far, so they trip some
// time after the real knee. delayAbortEval counts from the TEST start (observed: abort
// fired ~24 s after k6 start with delayAbortEval=20s), so the warm-up is added.
const ABORT_DELAY = `${WARMUP + 30}s`;
const thresholds = {
  'http_req_failed{phase:hold}': [{ threshold: 'rate<0.01', abortOnFail: ABORT, delayAbortEval: ABORT_DELAY }],
  'http_req_duration{phase:hold}': [{ threshold: `p(95)<${SLO}`, abortOnFail: ABORT, delayAbortEval: ABORT_DELAY }],
  'http_req_duration{scenario:breakpoint}': ['max>=0'], // display only (includes ramp windows)
  dropped_iterations: ['count>=0'], // display only
};
// Per-step sub-metrics (no abort): the summary then shows p95/p99, fail rate and
// request count for every HOLD window -> the knee is visible step by step.
STEPS.forEach((_, i) => {
  thresholds[`http_req_duration{step:${stepName(i)}}`] = [`p(95)<${SLO}`];
  thresholds[`http_req_failed{step:${stepName(i)}}`] = ['rate<0.01'];
  thresholds[`http_reqs{step:${stepName(i)}}`] = ['count>=0'];
});

export const options = {
  discardResponseBodies: true,
  scenarios: {
    warmup: { executor: 'constant-arrival-rate', rate: 20, timeUnit: '1s', duration: `${WARMUP}s`,
              preAllocatedVUs: 20, maxVUs: 50, exec: 'warm' },
    breakpoint: scenario,
  },
  thresholds,
};
// ...
export default function () {
  const t = (Date.now() - exec.scenario.startTime) / 1000;
  const i = Math.floor(t / STEP_LEN);
  const inHold = i < STEPS.length && (t - i * STEP_LEN) >= RAMP;
  http.get(`${BASE}${ENDPOINT}`, { timeout: TIMEOUT,
    tags: { step: inHold ? stepName(i) : 'ramp', phase: inHold ? 'hold' : 'ramp' } });
  if (MODEL === 'closed') sleep(SLEEP);
}
  1. Mỗi bậc = RAMP 8 giây + HOLD 22 giây. Thay vì nhảy tức thì, tốc độ tăng tuyến tính trong 8 giây rồi giữ 22 giây. Chỉ request bắt đầu trong HOLD mới được gắn tag step:NN_rpsX và phase:hold; request trong RAMP gắn step:'ramp'. Lý do: chuyển bậc là lúc hàng đợi đang điều chỉnh, và trên VPS này cũng là lúc cú khựng đầu phút rơi vào — không nên trộn nó vào số liệu của bậc.

  2. Hai scenario. warmup (constant-arrival-rate 20 request/giây, gọi hàm warm) chạy trước; breakpoint có startTime bằng độ dài warm-up nên bắt đầu ngay sau đó. Với MODEL=open là ramping-arrival-rate (preAllocatedVUs 100, maxVUs 400 trong mọi lần chạy chính); với MODEL=closed là ramping-vus cộng sleep(0.2) làm think time (thời gian "suy nghĩ" giữa hai thao tác của user).

  3. Threshold abort chỉ đọc cửa sổ HOLD. http_req_failed{phase:hold} và http_req_duration{phase:hold} có abortOnFail, còn delayAbortEval bằng WARMUP + 30s — con số này đến từ một phát hiện của lab (phần 0.5). http_req_duration{scenario:breakpoint} và dropped_iterations có threshold luôn đạt chỉ để được in ra.

  4. Timeout 5 giây mỗi request. Mặc định của k6 là 60 giây; đặt ngắn để request kẹt trong hàng đợi biến thành lỗi đếm được trong thời gian test, thay vì giữ VU cả phút.

Cách nói khi phỏng vấn: "Bản thứ hai của script chia mỗi bậc thành tám giây tăng dần và hai mươi hai giây giữ nguyên, chỉ request trong đoạn giữ nguyên mới được tính vào số liệu của bậc. Nó có một kịch bản khởi động riêng để làm nóng JIT và pool kết nối, và chạy được cả hai mô hình mở và đóng bằng cùng một file để so sánh công bằng. Timeout phía client đặt năm giây để request kẹt hiện thành lỗi ngay trong test."

Hai cạm bẫy khi tự viết script. Một: trang breakpoint testing trong docs k6 có snippet đặt executor: 'ramping-arrival-rate' và stages thẳng trong options, không qua scenarios; trong khi docs options định nghĩa stages ở cấp cao nhất là lối tắt cho một scenario ramping-vus. Đọc theo docs thì snippet đó nhiều khả năng chạy mô hình đóng — ngược với điều trang đó khuyến nghị (chưa verify bằng lab; luôn khai báo trong scenarios: {} là an toàn). Hai: trong summary, con số /s in cạnh http_reqs{stage:...} là số request của bậc chia cho tổng thời gian test, không phải tốc độ của bậc (lab: bậc 150 của lần 1a lab Node in 3300 35.143395/s dù tốc độ thật là 150/giây) — tính tốc độ bậc từ số request chia thời gian giữ.

0.4 Bước 4 — Tính preAllocatedVUs và maxVUs bằng Little's law

Trong executor arrival-rate, mỗi iteration cần một VU rảnh để chạy, và một VU bận suốt thời gian chờ response. Số VU đang bận cùng lúc chính là số request đang bay, và Little's law cho biết con số đó:

L = λ × W
số VU bận = tốc độ đến (iteration/giây) × thời gian một iteration (giây)

Docs k6: preAllocatedVUs = [median_iteration_duration × rate] + constant_for_variance
Cách nói khi phỏng vấn: "Số user ảo cần bằng tốc độ gửi nhân với thời gian mỗi request. Bắn ba trăm request một giây, mỗi request hai mươi mili giây, thì chỉ có khoảng sáu request đang bay. Nhưng tôi phải tính theo độ trễ ở vùng gãy chứ không phải lúc khoẻ: cũng ba trăm request một giây mà mỗi cái mất hai giây thì cần sáu trăm user ảo. Docs k6 cũng đưa đúng công thức này, cộng thêm một khoảng đệm cho dao động."

Các ví dụ tính bằng số thật của hai lab:

  1. Lab PHP: VUS = 600. Bậc cao nhất là 300 request/giây. Lúc khoẻ, /api/db mất khoảng 20 ms, nên chỉ cần 300 × 0,02 = 6 VU. Nhưng mục đích là đi qua điểm gãy, nơi độ trễ lên tới vài giây: 300 × 2 s = 600 VU. Vì vậy preAllocatedVUs = maxVUs = 600. Ở đây maxVUs còn đóng vai trò trần số request in-flight: khi số request đang chờ chạm 600, k6 bắt đầu drop — và lúc đó chắc chắn hệ thống đã gãy từ lâu.

  2. Lab Node: preAllocatedVUs = 100, maxVUs = 400. Bậc cao nhất 450–550 request/giây, ở sát SLO (100 ms) cần 450 × 0,1 = 45 VU, nhân đôi làm đệm ra khoảng 100. Trần 400 có nghĩa: ở 200 request/giây, k6 bắt đầu drop khi độ trễ trung bình vượt 400 / 200 = 2 giây.

  3. Kiểm tra ngược bằng số đo. Ở bậc 180 của lab PHP (lần chạy r2), cột "max VUs" đo được là 228. Little's law đảo lại: W = L / λ = 228 / 180 ≈ 1,27 giây — khớp với p95 của bậc đó là 1.113 ms. Nghĩa là nhìn số VU đang chạy cũng đọc được độ trễ: bậc 150 chỉ cần tối đa 20 VU, bậc 180 cần 228 — hàng đợi đã hình thành.

Đánh đổi khi chọn hai con số này. preAllocatedVUs quá thấp: k6 phải cấp phát thêm VU giữa test, docs k6 cảnh báo việc này có thể làm quá tải máy bắn tải và làm lệch kết quả, và lab thấy drop xuất hiện dù chưa chạm maxVUs (phần 4.3). maxVUs quá cao "cho chắc": mỗi VU là một runtime JavaScript riêng nên tốn RAM (phần 3.2), và trên Grafana Cloud maxVUs bị tính tiền kể cả khi không dùng. maxVUs quá thấp: k6 drop sớm, che mất phần đuôi độ trễ. Cách làm hợp lý: maxVUs = tốc độ bậc cao nhất × timeout client (không request nào giữ VU lâu hơn timeout) — lab Node: 400 ≈ 80 request/giây × 5 s thì quá thấp cho bậc 550, và đúng là lần 1c đã chạm trần 400 (phần 4.5); muốn không bao giờ drop ở 550 request/giây thì cần 550 × 5 = 2.750 VU.

0.5 Bước 5 — Thresholds: abortOnFail là phanh an toàn, không phải thước đo

Threshold trong k6 là biểu thức pass/fail trên một metric; dạng dài cho phép abortOnFail (dừng test ngay khi ngưỡng hỏng) và delayAbortEval (chờ một khoảng trước khi được phép dừng, để có đủ mẫu). Test kết thúc với threshold hỏng thì k6 trả exit code khác 0 (lab ghi nhận mã 99) — đây là cơ chế dùng làm cổng CI. Cấu trúc dùng trong lab Node, rút gọn:

thresholds: {
  // phanh an toàn (tích luỹ từ đầu test): chỉ để dừng khi đã gãy rõ
  'http_req_failed{phase:hold}':   [{ threshold: 'rate<0.01', abortOnFail: true, delayAbortEval: `${WARMUP + 30}s` }],
  'http_req_duration{phase:hold}': [{ threshold: 'p(95)<100', abortOnFail: true, delayAbortEval: `${WARMUP + 30}s` }],
  // thước đo (không abort): mỗi bậc một dòng trong summary
  'http_req_duration{step:04_rps150}': ['p(95)<100'],
  'http_req_failed{step:04_rps150}':   ['rate<0.01'],
  'http_reqs{step:04_rps150}':         ['count>=0'],
}
Cách nói khi phỏng vấn: "Tôi tách ngưỡng thành hai loại. Loại có quyền dừng test chỉ đọc các cửa sổ giữ tải, kiểm tra tỉ lệ lỗi dưới một phần trăm và p95 dưới SLO, và chỉ được phép dừng sau khi khởi động xong cộng ba mươi giây. Loại còn lại gắn vào từng bậc, không dừng gì cả, chỉ để báo cáo in ra số liệu từng bậc — điểm gãy tôi đọc từ loại này."

Lab tìm ra hai hành vi quan trọng mà docs không viết thẳng:

  1. Threshold tính trên dữ liệu tích luỹ từ đầu test, nên abort luôn đến sau điểm gãy thật. Docs nói threshold được đánh giá và abort "ngay khi threshold hỏng", nhưng không nói threshold nhìn cửa sổ trượt hay toàn bộ. Lab cho thấy là toàn bộ. Lần 2a của lab Node: bậc 150 request/giây có p95 37,28 ms, bậc 175 có p95 800,05 ms — nhưng threshold abort (p95 của tất cả cửa sổ HOLD từ đầu) chỉ là 277,05 ms lúc dừng:

        http_req_duration{phase:hold}
        ✗ 'p(95)<100' p(95)=277.05ms
        http_req_duration{step:04_rps150}
        ✓ 'p(95)<100' p(95)=37.28ms
        http_req_duration{step:05_rps175}
        ✗ 'p(95)<100' p(95)=800.05ms
    time="2026-10-30T11:22:13Z" level=error msg="thresholds on metrics 'http_req_duration{phase:hold}, http_req_duration{step:05_rps175}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
    Cách nói khi phỏng vấn: "Trong báo cáo cuối của k6, bậc một trăm năm mươi request một giây có p95 ba mươi bảy mili giây, bậc một trăm bảy mươi lăm có p95 tám trăm, nhưng ngưỡng dừng test — tính trên mọi cửa sổ đo từ đầu — chỉ đọc ra hai trăm bảy mươi bảy. Con số tám trăm mới là sự thật của bậc gãy; con số hai trăm bảy mươi bảy là trung bình pha loãng. Dòng lỗi cuối cho biết test dừng trong bậc một trăm bảy mươi lăm, tức là phanh vẫn kịp, chỉ là nó không cho biết gãy ở đâu."

    Lab PHP thấy cùng hiện tượng ở mức nặng hơn: lần r2, bậc 180 có p95 1,11 s nhưng p95 tích luỹ của toàn test chỉ 528,68 ms khi abort (ngưỡng abort 500 ms), và abort chỉ xảy ra sau khi đã chạy hết bậc 180, một giây vào bậc 210. Để ý thêm: các bậc 90 và 120 cũng hỏng SLO theo bậc nhưng không đủ kéo p95 tích luỹ qua 500 ms:

      █ THRESHOLDS
        http_req_duration
        ✗ 'p(95)<500' p(95)=528.68ms
          {stage:r0150}
          ✓ 'p(95)<200' p(95)=108.9ms
          {stage:r0180}
          ✗ 'p(95)<200' p(95)=1.11s
    time="2026-10-30T11:33:08Z" level=error msg="thresholds on metrics 'http_req_duration, http_req_duration{stage:r0090}, http_req_duration{stage:r0120}, http_req_duration{stage:r0180}, http_req_duration{stage:r0210}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
    Cách nói khi phỏng vấn: "Ở lab PHP, p95 tích luỹ của cả test là năm trăm hai mươi tám mili giây, vừa qua ngưỡng dừng năm trăm, trong khi bậc một trăm tám mươi đã là một phẩy một giây. Hai bậc chín mươi và một trăm hai mươi cũng hỏng SLO theo bậc nhưng bậc một trăm năm mươi phía sau lại đạt, nên đó là khựng thoáng qua chứ không phải gãy. Chỉ bảng theo bậc mới cho thấy điều này; nhìn ngưỡng tổng thì chỉ thấy một con số."
  2. delayAbortEval đếm từ lúc test bắt đầu, không phải từ lúc scenario bắt đầu. Lần thử v3 của lab Node đặt delayAbortEval: '20s' với warm-up khoảng 20 giây; abort xảy ra 23 giây sau khi k6 khởi động — tức chỉ khoảng 3 giây sau khi scenario breakpoint bắt đầu, chưa kịp có mẫu nào của bậc đầu (timestamp trong log k6 là UTC, dòng ### là giờ CET = UTC+1):

    ### k6 start 11:44:40 (waited 19s for minute alignment)
    time="2026-10-30T10:45:03Z" level=error msg="thresholds on metrics 'http_req_duration{scenario:breakpoint}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
    ### done 11:45:04

    Script cuối vì thế dùng delayAbortEval = WARMUP + 30s.

Cách nói khi phỏng vấn: "Ngưỡng của k6 tính trên toàn bộ dữ liệu từ đầu test chứ không phải cửa sổ trượt. Trong lab, bậc gãy có p95 tám trăm mili giây nhưng p95 tích luỹ lúc dừng mới hai trăm bảy mươi bảy, vì nó bị pha loãng bởi hàng nghìn request tốt của các bậc trước. Thêm nữa, thời gian trì hoãn đếm từ đầu test nên phải cộng cả thời gian khởi động vào. Vì vậy tôi không bao giờ báo cáo thời điểm abort là điểm gãy — tôi đọc từng bậc."

Tính được độ trễ của abort. p95 tích luỹ vượt ngưỡng khi số mẫu xấu B (chậm hơn ngưỡng) chiếm quá 5% tổng số mẫu:

B / (G + B) > 0,05   ⇔   B > G / 19
G = số mẫu tốt tích luỹ từ các bậc trước
Cách nói khi phỏng vấn: "p95 tích luỹ chỉ vượt ngưỡng khi request chậm chiếm hơn năm phần trăm tất cả request từ đầu test, tức là số request chậm phải lớn hơn khoảng một phần mười chín số request tốt đã có. Test càng dài thì càng cần nhiều request chậm mới dừng được, nên abort càng trễ. Ở lab, bốn bậc đầu có hơn chín nghìn request tốt, nên bậc gãy phải gom thêm khoảng năm trăm request chậm mới kích hoạt dừng."

Áp vào lần 2a: bốn cửa sổ HOLD đầu có 1.100 + 2.200 + 2.750 + 3.300 = 9.350 request, gần như tất cả nhanh hơn 100 ms; cần B > 9.350 / 19 ≈ 492 request chậm. Bậc 175 có 770 request trong HOLD khi bị dừng, phần lớn chậm (p50 364 ms) — khớp bậc độ lớn với ước tính. Hệ quả cho thiết kế: muốn abort sớm hơn thì đặt abort trên sub-metric của từng bậc thay vì metric toàn cục, nhưng khi đó một lần khựng 1–2 giây cũng có thể dừng test (lần thử v4 của lab Node gặp đúng chuyện này khi abort trên cả cửa sổ RAMP). Lựa chọn của hai lab là: abort lỏng trên metric tích luỹ làm phanh an toàn, còn điểm gãy đọc từ sub-metric theo bậc.

0.6 Bước 6 — Quan sát phía server cùng lúc

k6 chỉ thấy phía client: request chậm đi, lỗi tăng. Muốn biết cái gì gãy thì phải nhìn tài nguyên phía server trong cùng khoảng thời gian, theo USE method của Brendan Gregg: với mỗi tài nguyên, xem utilization (mức bận), saturation (độ bão hoà, tức hàng đợi) và errors (lỗi).

Tài nguyênUtilizationSaturationErrors
CPU app box (PHP: php-fpm + nginx)cgroup cpu.stat của container, % một corecgroup cpu.pressure (PSI — tỉ lệ thời gian task chờ CPU)—
Pool worker php-fpmactive processes / total processeslisten queue (request chờ worker rảnh)nginx 502/504 trong error log
Event loop NodeELU (performance.eventLoopUtilization)event-loop delay p99 (monitorEventLoopDelay)client timeout
Pool kết nối pg (Node)totalCount − idleCountwaitingCount"timeout exceeded when trying to connect"
PostgreSQLCPU của container (1 core)pg_stat_activity theo wait_event"too many clients already"
Máy bắn tải k6CPU core 0dropped_iterations, vus chạm maxVUslog Request Failed

PHP: php-fpm status page. Bật pm.status_path và — quan trọng — pm.status_listen: status được phục vụ bởi một pool ẩn riêng, nên vẫn trả lời được khi mọi worker chính đều bận. Không có dòng này thì đúng lúc cần xem nhất (pool đầy), request xem status cũng phải xếp hàng. Cấu hình của lab:

[www]
listen = __LISTEN__
listen.mode = 0666
listen.backlog = __BACKLOG__
pm = static
pm.max_children = __MAX_CHILDREN__
pm.max_requests = 0
; status page served by a hidden extra pool, so it still answers when all workers are busy
pm.status_path = /fpm-status
pm.status_listen = /run/php/status.sock

# nginx.conf.tpl: location riêng cho status, đi qua socket của pool ẩn
    location = /fpm-status {
      allow 127.0.0.1; allow 172.16.0.0/12; allow 192.168.0.0/16; allow 10.0.0.0/8; deny all;   # sampler container
      include fastcgi_params;
      fastcgi_param SCRIPT_NAME /fpm-status;
      fastcgi_param SCRIPT_FILENAME /fpm-status;
      fastcgi_pass unix:/run/php/status.sock;
    }
Cách nói khi phỏng vấn: "Tôi bật trang status của php-fpm và cho nó chạy trên một pool ẩn riêng qua một socket riêng. Lý do là đúng lúc cần xem nhất — khi mọi worker đều bận — thì request xem status cũng phải xếp hàng sau các request thường nếu đi chung pool. nginx có một location riêng trỏ vào socket đó và chỉ cho mạng nội bộ truy cập."
# lib.sh: sampler chạy trong container riêng, ghim cpu 0 (cùng core với k6), 2 giây một mẫu
while :; do echo "$(date -u +%T) $(wget -qO- 'http://tmp-perf-php-nginx/fpm-status?json')"; sleep 2; done
Cách nói khi phỏng vấn: "Một vòng lặp nhỏ cứ hai giây gọi trang status dạng JSON và ghi kèm giờ, chạy trong container riêng ghim vào core của máy bắn tải để không lấy CPU của app. Tôi tránh lấy mẫu bằng docker exec hay docker stats dày đặc vì chính các lệnh đó làm dockerd tốn CPU trên mọi core, tức là công cụ quan sát làm nhiễu thứ được quan sát."

Đọc status ở thời điểm gãy của lab PHP (lần r2, bậc 180 request/giây): trong 4 giây, idle processes từ 15 về 0, active processes lên đủ 20, và listen queue từ 2 lên 51 rồi 186. Cùng lúc đó cả 20 kết nối PostgreSQL đều idle chờ ClientRead — database đang chờ PHP, không phải ngược lại, nên thứ gãy là CPU của app box chứ không phải database:

### fpm-status exp1-db-mc20-r2 last 6 samples (every 2 s, UTC)
11:33:01 {"pool":"www","process manager":"static","start time":1793359574,"start since":407,"accepted conn":11079,"listen queue":2,"max listen queue":22,"listen queue len":511,"idle processes":15,"active processes":5,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:03 {"pool":"www","process manager":"static","start time":1793359574,"start since":409,"accepted conn":11323,"listen queue":51,"max listen queue":51,"listen queue len":511,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:05 {"pool":"www","process manager":"static","start time":1793359574,"start since":411,"accepted conn":11747,"listen queue":186,"max listen queue":188,"listen queue len":511,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
### pg-activity exp1-db-mc20-r2 last 6 lines (time, state, wait_event_type, wait_event, count)
11:33:02 idle Client ClientRead 20;
11:33:05 idle Client ClientRead 20;
Cách nói khi phỏng vấn: "Ở thời điểm gãy, trang status của php-fpm cho thấy số worker rảnh tụt từ mười lăm về không trong bốn giây và hàng đợi tăng từ hai lên gần hai trăm. Câu hỏi tiếp theo là worker bận vì làm việc hay vì chờ. Tôi xem pg_stat_activity: hai mươi kết nối đều đang rảnh chờ client gửi lệnh, nghĩa là database chờ PHP. Vậy nút thắt là CPU của app, thêm worker hay nâng cấp database đều không giúp."

Hai chi tiết vận hành từ lab PHP. Một: max children reached luôn bằng 0 trong mọi lần chạy, vì với pm = static bộ đếm này không hoạt động (file www.conf.default của PHP 8.5.12 ghi rõ chỉ đếm cho dynamic và ondemand) — đừng dùng nó làm tín hiệu bão hoà với pool static. Hai: khi nginx nói chuyện với php-fpm qua unix socket, status page "mù": listen queue len và listen queue đều là 0 suốt lúc 7.604 request bị từ chối; qua TCP thì thấy được hàng đợi. Phần failure mode của bài đi sâu vào khác biệt này; cơ chế backlog và 502/504 đã có trong bài Scale nginx + PHP-FPM.

Node: event-loop delay, ELU, pool waiting. Node chạy JavaScript trên một thread mỗi process, nên CPU của container không nói đủ: một process chỉ dùng tối đa một core dù container được cấp hai. Tín hiệu đúng là event-loop delay và ELU. App lab tự in mỗi 2 giây một dòng JSON (đoạn code từ app/server.js):

  // The histogram records the full gap between 10 ms timer ticks, so an idle loop reads
  // ~10 ms. We subtract the resolution: eld_* = extra delay beyond the tick.
  const ELD_RES_MS = 10;
  const eld = monitorEventLoopDelay({ resolution: ELD_RES_MS });
  eld.enable();
  let eluPrev = performance.eventLoopUtilization();
  // ...
  function snapshot() {
    const elu = performance.eventLoopUtilization(eluPrev);
    eluPrev = performance.eventLoopUtilization();
    const cpu = process.cpuUsage(cpuPrev); cpuPrev = process.cpuUsage();
    const cpuMs = (cpu.user + cpu.system) / 1000;
    const s = {
      t: new Date().toISOString(), pid: process.pid,
      rps: Math.round(win.served / (LOG_EVERY_MS / 1000)),
      err5xx: win.err5xx,
      eld_p50_ms: ms(eld.percentile(50)), eld_p99_ms: ms(eld.percentile(99)), eld_max_ms: ms(eld.max),
      elu: Math.round(elu.utilization * 100) / 100,
      cpu_cores: Math.round(cpuMs / LOG_EVERY_MS * 100) / 100,          // 1.00 = one full core
      cpu_ms_per_req: win.served ? Math.round(cpuMs / win.served * 100) / 100 : null,
      pool_total: pool.totalCount, pool_idle: pool.idleCount, pool_waiting: pool.waitingCount,
      // ... pool_waiting_max, inflight, rss_mb
    };
Cách nói khi phỏng vấn: "Tôi bật histogram độ trễ event loop của Node với độ phân giải mười mili giây và trừ đi mười mili giây đó, vì histogram ghi cả khoảng giữa hai tick nên loop rảnh cũng đọc ra mười. Mỗi hai giây tôi lấy p50, p99, max của độ trễ, tỉ lệ thời gian event loop bận, CPU tiêu thụ chia cho số request, và ba con số của pool kết nối: tổng, rảnh, và số request đang chờ kết nối. Số chờ kết nối khác không kéo dài là pool đã bão hoà."

Hai cặp dòng thật cho thấy hai kiểu gãy khác nhau. Cặp đầu (lần 2a, endpoint CPU-bound /cpu): ở 150 request/giây ELU 0,81 và delay p99 6,38 ms; khi qua điểm gãy ELU lên 1, delay p99 592 ms — event loop bận 100%, request xếp hàng trong buffer socket của kernel. Cặp sau (lần 1a, endpoint /db): ở 150 request/giây pool_waiting_max 0; khi gãy, pool_waiting_max 85 trong khi ELU chỉ 0,56 — event loop rảnh một nửa, hàng đợi nằm ở pool kết nối (và nút thắt thật là PostgreSQL 1 core, CPU 0,78–0,87 core):

{"t":"2026-10-30T11:21:45.694Z","pid":1,"rps":150,"err5xx":0,"eld_p50_ms":3.25,"eld_p99_ms":6.379999999999999,"eld_max_ms":6.690000000000001,"elu":0.81,"cpu_cores":0.82,"cpu_ms_per_req":5.48,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":79}
{"t":"2026-10-30T11:22:04.151Z","pid":1,"rps":73,"err5xx":0,"eld_p50_ms":465.53,"eld_p99_ms":592.41,"eld_max_ms":592.41,"elu":1,"cpu_cores":1.13,"cpu_ms_per_req":15.62,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":79}
{"t":"2026-10-30T11:08:57.058Z","pid":1,"rps":150,"err5xx":0,"eld_p50_ms":0.28999999999999915,"eld_p99_ms":6.539999999999999,"eld_max_ms":11.71,"elu":0.21,"cpu_cores":0.25,"cpu_ms_per_req":1.68,"pool_total":10,"pool_idle":9,"pool_waiting":0,"pool_waiting_max":0,"inflight":1,"inflight_max":5,"rss_mb":82}
{"t":"2026-10-30T11:09:05.072Z","pid":1,"rps":149,"err5xx":0,"eld_p50_ms":1.7699999999999996,"eld_p99_ms":86.14,"eld_max_ms":252.01,"elu":0.56,"cpu_cores":0.62,"cpu_ms_per_req":4.17,"pool_total":10,"pool_idle":0,"pool_waiting":59,"pool_waiting_max":85,"inflight":69,"inflight_max":96,"rss_mb":84}
Cách nói khi phỏng vấn: "Với endpoint tính toán nặng, lúc gãy tôi thấy tỉ lệ bận của event loop chạm một và độ trễ event loop p99 nhảy từ vài mili giây lên gần sáu trăm. Với endpoint truy vấn database, lúc gãy event loop vẫn rảnh một nửa nhưng có tám mươi lăm request chờ kết nối từ pool mười kết nối. Cùng là Node, cùng gãy ở khoảng hai trăm request một giây, nhưng thuốc chữa hoàn toàn khác: một bên cần thêm process, một bên cần làm database nhanh hơn."

Cơ chế phase của event loop và thread pool của libuv nằm trong hai bài Event loop phases và Thread pool chặn event loop; ở đây chỉ cần biết đọc hai con số trên. Chú ý thêm: biến đếm request đang trong Express (inflight_max) luôn bằng 1 với handler đồng bộ, kể cả khi client đợi 5 giây — handler đồng bộ không bao giờ có hai request cùng lúc bên trong app, hàng đợi nằm ngoài tầm nhìn của app.

CPU từng container. Đọc từ cgroup v2 (cpu.stat, quy ra số core) đáng tin hơn docker stats --no-stream: lab Node từng thấy docker stats in 286% cho container chỉ được cấp 2 CPU — lỗi lấy mẫu của lần đọc một phát. Ba dòng dưới đây là bằng chứng "một process Node chỉ dùng một core": lần 2a (1 process) ở điểm gãy app dùng đúng 1,00 core, host cpu3 = 100% còn cpu2 = 6%; lần 2b (cluster 2 worker) app dùng 1,96 core, cả cpu2 và cpu3 đều 100%:

12:21:46 cores node-app=0.83 node-k6=0.15 node-pg=0.00 php-pg=0.00 | host% cpu0=19 cpu1=18 cpu2=49 cpu3=45
12:22:06 cores node-app=1.00 node-k6=0.23 node-pg=0.01 php-pg=0.00 | host% cpu0=23 cpu1=8 cpu2=6 cpu3=100
12:28:09 cores node-app=1.96 node-k6=0.45 node-pg=0.03 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=39 cpu1=13 cpu2=100 cpu3=100
Cách nói khi phỏng vấn: "Container được cấp hai core, nhưng ở điểm gãy với một process Node, container dùng đúng một core: một core trăm phần trăm, core kia gần như rảnh. Chạy cluster hai worker thì cả hai core đều trăm phần trăm. Đó là lý do với Node tôi nhìn CPU theo từng core và nhìn độ trễ event loop, chứ không nhìn phần trăm CPU trung bình của container — năm mươi phần trăm trên hai core có thể là một core đã chết đứng."

0.7 Bước 7 — Chạy: smoke, dò thô, rồi bậc mịn

Thứ tự chạy, mỗi bước có lý do riêng:

  1. Smoke test (vài chục giây, tải rất thấp): kiểm tra script đúng URL, response đúng status, sampler phía server ghi được. Lab Node có smoke.sh chạy 7 giây. Bỏ bước này thì một lỗi 500 do cấu hình sai trông như một hệ thống "rất nhanh" — response lỗi thường rẻ hơn response thật.

  2. Dò thô: một lần ramp liên tục hoặc bậc thô (+50–100%) đến khi gãy, để biết vùng gãy nằm khoảng nào. Lab PHP biết từ lần thử đầu rằng gãy ở khoảng 150–240 request/giây.

  3. Bậc mịn quanh vùng gãy: bước +10–20% gần knee. Lab dùng 150 → 180 (+20%) cho PHP và 150 → 175 (+17%) cho Node /cpu. Bước càng nhỏ thì con số càng chính xác nhưng test càng dài; trên host nhiễu như VPS này, bước nhỏ hơn biên độ dao động giữa các lần chạy (±1 bậc, phần 0.9) là vô ích.

  4. Độ dài mỗi bậc: lab dùng 20–22 giây vì thời gian lab có hạn và host chạy chung. Số mẫu ở đuôi quyết định độ tin cậy của percentile — công thức ở dưới. Trên staging thật nên giữ mỗi bậc 1–3 phút.

số mẫu ở đuôi pX = n × (1 − X/100),   n = tốc độ × thời gian giữ
bậc 150 req/s × 22 s = 3.300 request  →  p99 dựa trên 33 request chậm nhất
bậc 150 req/s × 120 s = 18.000 request →  p99 dựa trên 180 request chậm nhất
Cách nói khi phỏng vấn: "Độ tin cậy của p99 phụ thuộc vào số request nằm ở một phần trăm chậm nhất. Giữ một bậc một trăm năm mươi request một giây trong hai mươi hai giây thì p99 chỉ dựa trên ba mươi ba request — một cú khựng ngắn đủ làm nó nhảy. Giữ hai phút thì có một trăm tám mươi request ở đuôi, ổn định hơn nhiều. Vì vậy trên staging tôi giữ mỗi bậc một đến ba phút."

Lệnh chạy thật của hai lab (k6 chạy trong container, ghim cpu 0; trên staging thật thì thay bằng k6 run trên máy bắn tải riêng, các cờ giữ nguyên):

# PHP lab (php-results.md, lib.sh k6run): k6 trong container ghim cpu 0, xuất CSV từng request + summary JSON
docker run --rm --name tmp-perf-php-k6 --network tmp-perf-php-net --cpuset-cpus 0 --memory 1g \
  -e K6_CSV_TIME_FORMAT=unix_milli -v $LAB/k6:/scripts:ro -v $OUT:/out -u 0 grafana/k6:latest run --quiet --no-color \
  --summary-mode=full --summary-export=/out/<tag>.summary.json --out csv=/out/<tag>.csv.gz \
  -e URL=http://tmp-perf-php-nginx/api/db -e STEPS=60,90,120,150,180,210,240,300 -e HOLD=20 -e SLO_MS=200 -e VUS=600 \
  /scripts/stepped.js

# Node lab (lib.sh k6run): chờ khoá chung của host, chờ lab kia yên, căn giây :40 rồi mới chạy
docker run --rm --name tmp-perf-node-k6 --network tmp-perf-node-net --user 0:0 \
  --cpuset-cpus 0 --memory 1g --memory-swap 1g \
  -v $LAB/k6:/scripts:ro -v $LAB/out:/out -e ENDPOINT=/cpu -e MODEL=open -e STEPS=50,100,125,150,175,200,250 \
  -e SLO_P95=100 -e PRE_VUS=100 -e MAX_VUS=400 \
  grafana/k6:latest run --quiet --summary-mode=full \
  --summary-trend-stats 'avg,med,p(90),p(95),p(99),max' \
  --summary-export /out/2a-cpu-single-open.summary.json /scripts/breakpoint.js
Cách nói khi phỏng vấn: "Tôi chạy k6 trong container ghim một core riêng, giới hạn RAM, cùng mạng nội bộ với app. Hai đầu ra quan trọng: file JSON tóm tắt cuối test để script phân tích tính bảng theo bậc, và với lab PHP thêm file CSV ghi từng request để tính được cả số response thành công từng giây. Tôi thêm p99 vào danh sách thống kê vì mặc định k6 chỉ in tới p95."

0.8 Bước 8 — Đọc kết quả từng bậc để tìm knee

Với mỗi bậc, đọc bốn nhóm số theo thứ tự: (a) p95/p99 của bậc so với SLO; (b) ok/s so với sent/s; (c) số VU tối đa đang chạy — theo Little's law nó tỉ lệ với độ trễ; (d) tài nguyên server nào chạm trần. Ví dụ 1 là lần chạy sạch nhất của lab PHP (exp1-db-mc20-r2: /api/db, pm.max_children = 20, SLO p95 200 ms). Bảng do analyze.py tính từ CSV từng request và cgroup (nguồn: php/out/exp1-db-mc20-r2.report.txt); fpm% và ngx% là % của một core, tối đa 200%; PSI là % thời gian task php-fpm chờ CPU:

Bậc (req/s)giữ (s)sent/sok/smax VUsp50 msp95 msp99 msfpm%ngx%ms CPU/okPSIĐạt SLO?
602060,060,031018284658,51đạt
902090,088,83914343471951112,033không (thoáng qua)
12020120,0121,227232363651151310,636không (thoáng qua)
15020150,0149,82019109154127139,345đạt
18020179,9176,62283711131260141168,968không — gãy
2101174,6220,647229332362———68abort tại đây

Đọc bảng:

  1. Bậc 180 là gãy thật. p95 nhảy từ 109 lên 1.113 ms (gấp 10 lần), max VUs từ 20 lên 228 (Little: hàng đợi khoảng 1,2 giây), ok/s 176,6 đã hụt sau sent/s 179,9, PSI 68% (task php-fpm chờ CPU 2/3 thời gian), fpm + nginx dùng 157% trên 200%. Status page và pg_stat_activity ở phần 0.6 xác nhận: 20/20 worker bận, hàng đợi 186, database rảnh.

  2. Bậc 90 và 120 không đạt nhưng không phải gãy. p95 vượt SLO nhưng p50 vẫn 14–23 ms, goodput bám đủ tải, max VUs chỉ 27–39, và bậc 150 phía sau lại đạt SLO với p95 109 ms. Đây là dấu hiệu của các cú khựng ngắn của host (phần 0.2), không phải hàng đợi tăng dần. Theo đúng tiêu chí ở 0.1, "bậc cuối còn đạt mà mọi bậc trước cũng đạt" của lần chạy này là 60 request/giây — một con số rõ ràng sai so với hành vi của hệ thống. Đây là lý do bước 9 bắt buộc lặp lại, và lý do tiêu chí phải yêu cầu "kéo dài trong cả bậc" kèm goodput, không chỉ một con số p95.

  3. CPU mỗi request giải thích trần lý thuyết. Cột "ms CPU/ok" khoảng 8,5–12 ms CPU của app box cho mỗi response. 2 core = 2.000 ms CPU mỗi giây, chia 8,9 ms ≈ 225 request/giây là trần nếu CPU chạy 100%. Gãy ở 150–180 tức khoảng 67–80% của trần — đúng hình dạng hàng đợi: độ trễ bùng nổ trước khi CPU chạm 100% (phần 4.6).

Diễn biến theo từng giây quanh điểm gãy (persec.py: số request xong mỗi giây, p50/p95 của các request đó, CPU từng thành phần) cho thấy gãy không từ từ: trong 2 giây, số request xong mỗi giây rơi từ 145 xuống 68 trong khi php-fpm vẫn đốt 110% CPU, rồi p95 vọt lên 1,4 giây:

### persec.py exp1-db-mc20-r2 t=92..101 (knee at the 180 rps step)
utc       t    done  p50ms  p95ms  non200 |  fpm%  ngx%   pg%  psi  dockerd%
11:32:59   93    184     15     43       0 |   143    15    31   56       30
11:33:01   95    145     28     88       0 |   147    16    32   56       15
11:33:02   96     92    240    438       0 |   110    32    57   59       24
11:33:03   97     68   1197   1407       0 |   110    32    57   59       24
11:33:04   98    236   1113   1341       0 |   118    20    23   63       26
11:33:05   99    263    829   1074       0 |   168    14    39   63        7
11:33:06  100    253    402    585       0 |   170    13    27   68       25
Cách nói khi phỏng vấn: "Nhìn theo từng giây, điểm gãy không phải đường dốc thoải. Trong hai giây, số request hoàn thành mỗi giây rơi từ một trăm bốn mươi lăm xuống sáu mươi tám dù php-fpm vẫn đốt hơn một core, rồi p95 lên một giây bốn. Khi không còn khoảng dư, một trục trặc nhỏ tạo ra hàng đợi và hàng đợi không tự xả được vì tải vẫn đến đều. Đó là lý do tôi cần dư địa dưới điểm gãy chứ không vận hành sát nó."

Ví dụ 2 là lần 2a của lab Node (/cpu, pbkdf2Sync khoảng 5,5 ms CPU mỗi request, 1 process, SLO p95 100 ms). Dòng summary của từng bậc, nguyên văn:

      { step:01_rps50 }............: avg=19.27ms  med=8.91ms   p(90)=48.52ms  p(95)=73.62ms  p(99)=142.26ms max=173.5ms
      { step:02_rps100 }...........: avg=10.87ms  med=6.59ms   p(90)=19.18ms  p(95)=34.38ms  p(99)=79.78ms  max=193.4ms
      { step:03_rps125 }...........: avg=8.38ms   med=6.13ms   p(90)=11.04ms  p(95)=17.57ms  p(99)=62.28ms  max=107.41ms
      { step:04_rps150 }...........: avg=10.45ms  med=6.19ms   p(90)=20.91ms  p(95)=37.28ms  p(99)=60.9ms   max=101.99ms
      { step:05_rps175 }...........: avg=439.38ms med=364.48ms p(90)=696.38ms p(95)=800.05ms p(99)=860.39ms max=866.54ms
    dropped_iterations.............: 196    1.290608/s
    vus............................: 80     min=0          max=117
    vus_max........................: 140    min=120        max=140
Cách nói khi phỏng vấn: "Bốn bậc đầu, từ năm mươi đến một trăm năm mươi request một giây, p95 đều dưới bảy mươi tư mili giây. Bậc một trăm bảy mươi lăm có trung vị ba trăm sáu mươi tư và p95 tám trăm. Tải chỉ tăng mười bảy phần trăm mà độ trễ đuôi tăng hai mươi lần, đó là dấu hiệu của hàng đợi chứ không phải request chậm dần. Bậc cuối còn đạt là một trăm năm mươi."
Bậcp95 bậc (ms)ELU (cửa sổ 2 s)delay p99 (ms)CPU app (core)Kết luận
150 req/s37,30,81–0,965–440,82–0,97đạt; event loop bận ~90%
175 req/s800,11,00437–5921,00–1,18gãy; event loop bão hoà

Trần lý thuyết: 1.000 ms ÷ 5,5–6 ms CPU mỗi request ≈ 165–180 request/giây cho một core. Bậc 150 tương ứng mức bận khoảng 83–91% — trùng với ELU đo được — và bậc 175 đã sát hoặc vượt trần. Với endpoint CPU-bound, điểm gãy đo được và tính toán khớp nhau tốt; với endpoint chờ I/O, phải tính theo tài nguyên đang được chờ (pool, database).

0.9 Bước 9 — Lặp lại và báo cáo "max sustainable RPS"

Một lần chạy không đủ: Gregg gọi đây là câu hỏi "Does it reproduce?" trong checklist benchmark, docs k6 cũng dặn chạy lại sau mỗi lần tinh chỉnh. Lab cho thấy lý do rất cụ thể. Bảng dưới gom các lần chạy /api/db với 20 worker của lab PHP (các lần diag-* giữ bậc 15 giây, không abort, xen kẽ TCP và unix socket — cấu hình không hoàn toàn giống nhau; số lấy từ php/out/*.report.txt):

Lần chạy120 req/s: p95 / ok/s180 req/s: p95 / ok/s240 req/s: p95 / ok/s
exp1 r1 (TCP, giữ 20 s)1.914 ms / 91,6 — abort sau 5 s (khựng ~2 s)——
exp1 r2 (TCP, giữ 20 s)236 ms / 121,21.113 ms / 176,6—
diag-tcp-1265 ms / 122,61.506 ms / 164,13.640 ms / 207,5 (172 drop)
diag-tcp-2282 ms / 119,7378 ms / 180,33.919 ms / 211,3
diag-unix-1946 ms / 121,3312 ms / 179,8723 ms / 228,5
diag-unix-236 ms / 120,747 ms / 180,51.720 ms / 213,2

Hai cách đọc bảng này cho hai con số khác nhau, và báo cáo phải nói rõ cả hai:

  1. Knee theo goodput khá ổn định: ở 120 request/giây mọi lần chạy đều trả đủ ~120 ok/s; ở 180 có 4/5 lần trả đủ (164,1 ở diag-tcp-1 là ngoại lệ); ở 240 không lần nào trả đủ (207–229 ok/s). Knee thông lượng nằm giữa 180 và 240 request/giây, trần goodput khoảng 210–260.

  2. Knee theo SLO p95 200 ms không ổn định trên host này: bậc 120 chỉ đạt SLO trong 1/6 lần; bậc 180 đạt trong 1/5 lần. Lý do không phải hệ thống mà là các cú khựng 1–3 giây của VPS (php-fpm vẫn đốt CPU nhưng hoàn thành ít request, steal time không hiện vì KVM guest báo steal = 0). Với SLO chặt và host nhiễu, con số "max sustainable RPS ở SLO" không đo lặp lại được — kết luận trung thực là "host này không đủ yên để chốt con số tuyệt đối", không phải chọn lần chạy đẹp nhất.

Lab Node cho bức tranh tương tự ở biên độ nhỏ hơn: cùng /cpu 1 process, lần 2a đạt SLO tới 150 request/giây (p95 37,3 ms) và gãy ở 175; lần 3b bậc 150 đã có p95 142,8 ms (bậc đạt cuối là 100). Endpoint /db: lần cuối gãy ở 200 (bậc 150 p95 31,4 ms), một lần thử trước đó đạt tới 250 (p95 36,6 ms) và gãy ở 275. Knee lệch ±1 bậc giữa các lần chạy.

Công thức báo cáo đề xuất (tổng hợp của bài, không phải chuẩn công bố):

max_sustainable = min qua các lần chạy ( bậc cuối cùng đạt mọi tiêu chí, với mọi bậc trước cũng đạt )
capacity_kế_hoạch = max_sustainable × margin        (margin thường gặp 0,6–0,8 — thông lệ, chưa verify như chuẩn)
số_node = ceil( tải_đỉnh_dự_kiến / capacity_kế_hoạch ) + 1   (N+1: mất một node không đẩy các node còn lại qua knee)
Cách nói khi phỏng vấn: "Tôi lấy bậc cuối cùng đạt mọi tiêu chí trong từng lần chạy, rồi lấy giá trị nhỏ nhất qua ít nhất ba lần, chứ không lấy lần đẹp nhất. Sau đó nhân với một hệ số an toàn khoảng sáu mươi đến tám mươi phần trăm, vì sát điểm gãy độ trễ tăng phi tuyến, điểm gãy dao động giữa các lần đo, và traffic thật khác traffic test. Cuối cùng chia tải đỉnh cho con số đó rồi cộng thêm một node để mất một máy vẫn không bị đẩy qua điểm gãy."

Ví dụ tính (minh hoạ, dùng số lab Node /cpu): max sustainable = min(150, 100) = 100 request/giây mỗi process; margin 0,7 → 70 request/giây mỗi process; tải đỉnh giả định 500 request/giây → ceil(500 / 70) + 1 = 8 + 1 = 9 process. Nếu dùng lần chạy đẹp nhất (150 × 0,7 = 105) sẽ ra ceil(500 / 105) + 1 = 6 — chênh 3 process chỉ vì chọn lần chạy. Bốn lý do cần margin: độ trễ tăng phi tuyến khi gần bão hoà (phần 4.6); knee lệch ±1 bậc giữa các lần đo; traffic production trộn nhiều endpoint và dữ liệu khác lab; và cần dư địa cho N+1. Google SRE book chỉ nói phải đánh đổi giữa mức sử dụng và biên an toàn, không đưa con số; khoảng 60–80% là thông lệ, chưa tìm thấy nguồn chuẩn.

Một báo cáo điểm gãy dùng lại được phải kèm: phiên bản (runtime, framework, database, k6), cấu hình (số worker, pool, timeout), phần cứng và cách ghim, bộ dữ liệu, hình dạng tải (endpoint nào, tỉ lệ ra sao), độ dài bậc, số lần chạy và độ lệch giữa chúng, tài nguyên bão hoà ở điểm gãy, và caveat của môi trường (ở đây: cùng host). Thiếu những thứ đó, con số không so sánh được với lần đo sau.

0.10 Checklist

1. Bài toán gốc và bối cảnh ra đời

1.1 Trước k6: công cụ cho QA, hoặc công cụ không có kịch bản

Trước 2017, các lựa chọn load test mã nguồn mở chia hai nhóm. Nhóm một là công cụ đầy đủ tính năng nhưng không thân thiện với developer: JMeter (Java, test plan dựng bằng GUI và lưu thành XML, mỗi user ảo là một thread), Gatling (DSL Scala), Locust (Python). Nhóm hai là công cụ cực nhẹ nhưng gần như không có kịch bản: wrk, ab (viết bằng C, chỉ có Lua hook hoặc không có gì). Khoảng trống mà k6 nhắm vào là test as code (test viết thành code) bằng một ngôn ngữ phổ biến — JavaScript — để developer giữ trong git và chạy trong CI, với thresholds làm cổng pass/fail, đóng gói trong một binary Go duy nhất tiết kiệm tài nguyên. Định vị "lấy developer làm trung tâm, hợp với CI" là cách Grafana/k6 tự mô tả — hợp lý, nhưng là nguồn từ chính vendor.

1.2 Dòng thời gian

Thời điểmSự kiện
Đầu 2017Load Impact (công ty SaaS load testing ở Stockholm) phát hành k6 mã nguồn mở — Wikipedia ghi 18/01/2017, blog của đội Load Impact ghi tháng 2/2017
2020Load Impact đổi tên thành k6
17/06/2021Grafana Labs thông báo mua lại k6; từ đây k6 được định vị là "load testing trong hệ sinh thái observability" (đẩy kết quả vào Prometheus/Grafana)
2025k6 1.0 GA tại GrafanaCON 2025: cam kết SemVer (thay đổi phá vỡ chỉ ở bản major, có cảnh báo deprecate trước), ít nhất 2 năm vá lỗi nghiêm trọng mỗi major, Go API ổn định cho extension, TypeScript native
2026k6 2.x: 2.0 gỡ một số thứ đã deprecate; 2.1 thêm cơ chế feature flag với cờ thử nghiệm native-histograms cho Trend metric; 2.3.0 (bản dùng trong bài) thêm label tĩnh cho Prometheus remote write

Giấy phép: k6 mã nguồn mở theo AGPL-3.0 (kiểm qua GitHub API ngày 30/10/2026). Với công cụ chạy nội bộ để test thì AGPL hiếm khi là vấn đề, nhưng nếu định nhúng k6 vào một sản phẩm cung cấp cho bên ngoài thì phải hỏi pháp chế.

1.3 Suy ra: k6 tối ưu cho gì, không tối ưu cho gì

Tối ưu choKhông tối ưu cho
Kịch bản HTTP/API (cùng gRPC, WebSocket, module browser) viết bằng JS/TS, nằm trong repo, chạy trong CIDùng lại thư viện npm/Node: k6 không phải Node, không có fs, os, không có module resolution của Node (phần 3.3)
Lập lịch mô hình mở (arrival-rate) — đúng thứ breakpoint test cầnGhi-và-phát-lại bằng GUI cho người không lập trình (đất của JMeter)
Thresholds như cổng SLO, exit code khác 0 cho CISinh RPS thô cực cao trên một endpoint với ít CPU nhất (wrk/wrk2 nhẹ hơn)
Xuất metric: JSON, CSV, Prometheus remote write, web dashboardHiệu chỉnh độ trễ chính xác cho coordinated omission khi thiếu VU (phần 4.2)

Với bài toán của bài này — tìm điểm gãy của một API PHP/Node — điểm mạnh quyết định là executor arrival-rate và threshold theo tag; điểm yếu đáng lo nhất là k6 không hiệu chỉnh coordinated omission, nên phải tự canh dropped_iterations. Khi nào chọn công cụ khác (wrk2, Vegeta, Gatling, Locust) thuộc phần so sánh ở sau bài.

2. Mô hình tư duy cốt lõi

2.1 Executor lên lịch iteration, VU chạy iteration, metric thành pass/fail

Toàn bộ k6 gói trong một câu: một test k6 là một lịch (executor) quyết định khi nào bắt đầu iteration, và một VU chạy từng iteration một; mỗi request sinh ra mẫu metric; thresholds biến các tổng hợp của mẫu thành pass/fail. Mọi tính năng khác là hệ quả:

  1. VU đơn luồng. Docs k6: VU là đơn luồng như các runtime JavaScript khác, nên một VU chỉ chạy một iteration (và event loop của nó) tại một thời điểm. Mỗi VU có runtime JavaScript riêng.

  2. Vòng đời: init code (chạy một lần cho mỗi VU, không được gửi HTTP) → setup() một lần → code VU mỗi iteration → teardown() một lần (bị bỏ qua nếu setup ném lỗi).

  3. Executor chia hai họ. Mô hình đóng (dựa trên VU: constant-vus, ramping-vus, shared-iterations, per-vu-iterations): iteration mới chỉ bắt đầu khi iteration trước của VU đó xong, nên tốc độ đến bị gắn với thời gian phản hồi. Mô hình mở (arrival-rate: constant-arrival-rate, ramping-arrival-rate): iteration bắt đầu theo lịch, độc lập với việc iteration trước đã xong chưa — docs k6: "thời gian phản hồi của hệ thống bị đo không còn ảnh hưởng tới tải đặt lên nó".

flowchart TD
  EX["Executor<br/>(lịch: ramping-arrival-rate)"]
  POOL["Pool VU đã cấp phát<br/>preAllocatedVUs ... maxVUs"]
  DROP["dropped_iterations++<br/>(không xếp hàng)"]
  VU["VU rảnh chạy 1 iteration<br/>(runtime JS Sobek riêng)"]
  HTTP["http.get: gửi request,<br/>chờ response"]
  SMP["Mẫu metric có tag<br/>http_req_duration, http_req_failed,<br/>http_reqs, iterations"]
  AGG["Tổng hợp trong tiến trình<br/>(theo metric và sub-metric tag)"]
  THR["Thresholds<br/>pass / fail / abortOnFail"]
  OUT["Đầu ra: summary, JSON, CSV,<br/>Prometheus remote write"]
  EX -- "đến giờ bắt đầu iteration" --> POOL
  POOL -- "có VU rảnh" --> VU
  POOL -- "hết VU" --> DROP
  VU --> HTTP --> SMP --> AGG
  AGG --> THR
  SMP --> OUT

Các hệ quả suy ra từ abstraction, không cần học thuộc riêng: (1) vì VU bận suốt iteration, executor mở cần một pool VU, và khi pool cạn thì iteration bị bỏ chứ không xếp hàng — đó là dropped_iterations; (2) vì mô hình đóng gắn tải với độ trễ, server chậm thì nhận ít tải hơn đúng lúc nó đang khó khăn — breakpoint test kiểu đóng báo cáo điểm gãy cao hơn và mượt hơn thực tế (phần 2.2); (3) vì tổng hợp dựa trên mẫu có tag, threshold đặt được trên bất kỳ metric hay sub-metric nào (theo bậc, theo endpoint) và có thể dừng test.

2.2 Open model và closed model: vì sao breakpoint test phải dùng mô hình mở

Trong mô hình đóng, mỗi VU là một vòng lặp "gửi → chờ → nghỉ → gửi". Thông lượng vì vậy bị chặn bởi chính độ trễ:

throughput (closed) ≈ VUs / (R + Z)
R = thời gian phản hồi, Z = think time (sleep)

Lab 3c: 60 VU, Z = 0,2 s, đo được 169,3 req/s
→ R ≈ 60 / 169,3 − 0,2 ≈ 0,154 s   (median đo được 140 ms)
Cách nói khi phỏng vấn: "Trong mô hình đóng, mỗi user ảo chỉ gửi request mới khi request cũ xong, nên thông lượng bằng số user chia cho tổng thời gian phản hồi cộng thời gian nghỉ. Hệ quả là khi server chậm đi, tải tự giảm theo, và độ trễ bị chặn trên ở mức số user chia thông lượng trừ thời gian nghỉ. Trong lab, sáu mươi user ảo với hai trăm mili giây nghỉ cho ra một trăm sáu mươi chín request một giây, suy ra độ trễ khoảng một trăm năm mươi lăm mili giây, khớp với trung vị đo được."

User thật của một API công khai không cư xử như vậy: một nghìn người mở app lúc 8 giờ sáng không đợi người khác nhận response xong mới bấm. Lab Node so sánh trực tiếp trên cùng endpoint /cpu 1 process, cùng mức tải danh nghĩa (lần 3b mở, lần 3c đóng, cả hai không abort):

### 3b-cpu-single-open-noabort  script=breakpoint.js  env=ENDPOINT=/cpu MODEL=open NOABORT=1 STEPS=50,100,150,200,250,300 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
      { step:03_rps150 }...........: avg=27.1ms   med=7.88ms   p(90)=77.6ms   p(95)=142.82ms p(99)=171.47ms max=184.53ms
      { step:04_rps200 }...........: avg=1.1s     med=595.98ms p(90)=4.99s    p(95)=5s       p(99)=5s       max=5.16s
    http_req_failed................: 13.65% 3834 out of 28078
    dropped_iterations.............: 2574   12.679184/s
### 3c-cpu-single-closed-noabort  script=breakpoint.js  env=ENDPOINT=/cpu MODEL=closed NOABORT=1 STEPS=10,20,30,40,50,60 SLO_P95=100 SLEEP=0.2
      { step:04_vus40 }............: avg=21.64ms  med=15.11ms  p(90)=48.57ms  p(95)=62.93ms  p(99)=93.24ms  max=115.97ms
      { step:06_vus60 }............: avg=151.34ms med=140.08ms p(90)=204.03ms p(95)=261.87ms p(99)=439.02ms max=466.83ms
    http_req_failed................: 0.00%  0 out of 21531
    dropped_iterations.............: 0      0/s
Cách nói khi phỏng vấn: "Cùng một app, cùng mức tải danh nghĩa, mô hình mở cho thấy mười ba phẩy sáu lăm phần trăm request thất bại vì timeout năm giây và hơn hai nghìn năm trăm iteration bị bỏ; mô hình đóng báo không lỗi nào, không iteration nào bị bỏ, p95 chỉ hai trăm sáu mươi mili giây. Mô hình đóng không nói dối về những request nó đã gửi, nó chỉ không gửi những request mà user thật sẽ gửi trong lúc chờ. Đó là coordinated omission, và đó là lý do breakpoint test phải dùng executor theo tốc độ đến."

Điểm tinh tế: ở 40 VU (danh nghĩa ~194 request/giây), mô hình đóng báo 179,4 request/giây với p95 62,9 ms — đạt SLO — trong khi app đã bão hoà (ELU 0,98–1,00, đúng 1 core). Mô hình đóng không bao giờ báo "hỏng", chỉ báo "chậm hơn một chút". Vẫn có chỗ cho mô hình đóng: hệ thống có số client cố định (worker xử lý queue, job batch, pool kết nối nội bộ giữa hai service) thật sự cư xử như vòng lặp đóng, và test cho chúng nên mô phỏng đúng như vậy. Ngay trong docs k6 cũng có mâu thuẫn: trang hướng dẫn "test for performance" dùng ramping-vus cho ví dụ breakpoint, ngược với trang breakpoint testing.

Hai vòng lặp, vẽ riêng. Mô hình đóng: tải là một vòng kín, server chậm thì vòng quay chậm.

flowchart TD
  C1["VU gửi request"] --> C2["Chờ response<br/>(server chậm = chờ lâu)"]
  C2 --> C3["sleep think time"] --> C1
  C2 -.-> C4["Server chậm hơn<br/>⇒ VU gửi thưa hơn<br/>⇒ tải tự giảm, hàng đợi bị giấu"]

Mô hình mở: lịch đứng ngoài vòng lặp, server chậm thì số VU bận tăng lên còn tải giữ nguyên.

flowchart TD
  O1["Lịch: N iteration mỗi giây"] --> O2["Lấy VU rảnh trong pool"]
  O2 --> O3["Gửi request,<br/>VU bận tới khi xong"]
  O2 -- "pool cạn" --> O4["dropped_iterations"]
  O3 -.-> O5["Server chậm hơn<br/>⇒ nhiều VU bận cùng lúc<br/>⇒ tải giữ nguyên, hàng đợi lộ ra"]

3. Internals và quyết định thiết kế

3.1 Engine Go, JavaScript chạy trong Sobek

Docs k6: để có hiệu năng tốt, engine k6 viết bằng Go và nhúng một máy ảo JavaScript là Sobek — bản fork của goja do Grafana duy trì. Phần I/O (kết nối TCP, HTTP client, goroutine) do Go đảm nhận; JavaScript chỉ là lớp kịch bản. Được gì: một binary tĩnh duy nhất, không cần runtime nào khác; I/O đồng thời rẻ nhờ goroutine; khởi động nhanh. Đánh đổi gì: JavaScript chạy bằng trình thông dịch viết bằng Go, không có JIT như V8, nên logic kịch bản nặng CPU (parse JSON lớn, mã hoá bằng JS thuần) đắt hơn trên mỗi VU — điều này hợp lý về mặt kiến trúc nhưng chưa tìm thấy benchmark chính thức (chưa verify về độ lớn). Hệ quả thực hành: giữ hàm iteration càng mỏng càng tốt (như hai script lab: một http.get, không parse body), để CPU của máy bắn tải dành cho việc gửi request.

3.2 Mỗi VU một runtime: cô lập đổi lấy bộ nhớ

Mỗi VU có một runtime JavaScript riêng, mỗi runtime có bản sao của mọi module JS. Được gì: cô lập thật sự, không có state chia sẻ ngầm giữa các VU (không có chuyện một biến toàn cục bị VU khác ghi đè). Đánh đổi gì: bộ nhớ tăng tuyến tính theo số VU — docs k6 ước lượng test đơn giản dùng khoảng 1–5 MB mỗi VU (1.000 VU = 1–5 GB), và file dữ liệu nạp bằng open() trong init code bị nhân bản theo từng VU, trừ khi dùng SharedArray. Số đo thật của lab Node, lần 1c — 420 VU được cấp phát (400 của scenario breakpoint + 20 của warm-up), container k6 dùng 146 MiB:

    vus_max........................: 420    min=120           max=420
tmp-perf-node-k6 cpu=54.93% mem=146MiB / 1GiB
Cách nói khi phỏng vấn: "Mỗi user ảo của k6 là một runtime JavaScript riêng nên bộ nhớ tăng theo số user ảo. Docs ước lượng một đến năm megabyte mỗi user ảo cho script đơn giản. Trong lab của tôi, bốn trăm hai mươi user ảo với script chỉ một lệnh GET và bỏ body response thì container k6 dùng một trăm bốn mươi sáu megabyte, tức khoảng một phần ba megabyte mỗi user ảo, thấp hơn ước lượng của docs vì script rất mỏng."

146 MiB ÷ 420 ≈ 0,35 MiB mỗi VU — thấp hơn khoảng 1–5 MB của docs, phù hợp với script chỉ một http.get và discardResponseBodies: true. Con số này không tổng quát hoá được cho script có dữ liệu test lớn hoặc giữ body; dùng nó như một điểm dữ liệu, không như quy tắc.

3.3 Không phải Node.js

Docs k6 nói thẳng "k6 không phải Node.js, cũng không phải trình duyệt": các API Node như fs, os không có; require()/import không có cơ chế tìm module kiểu Node, nên package npm muốn dùng phải bundle (ví dụ bằng webpack) và chỉ chạy được nếu không phụ thuộc API Node. Hệ quả cho người viết test API Node.js: không thể import thẳng client SDK nội bộ của service vào script k6; phải viết lại phần gọi HTTP bằng k6/http. Ngược lại, Artillery chạy trên Node nên dùng được thư viện npm — một trong số ít lý do thực tế để chọn nó.

3.4 Mặc định của HTTP client làm thay đổi kết quả

  1. Timeout mặc định 60 giây. Request treo lâu hơn mới thành lỗi. Trùng với fastcgi_read_timeout mặc định 60 giây của nginx, nên bên nào bỏ cuộc trước là một cuộc đua. Hai lab đặt rõ: lab PHP cho nginx fastcgi_read_timeout 5s và k6 timeout 10 giây — nginx luôn bỏ trước, client thấy 504 thay vì timeout của chính nó; lab Node không có proxy, k6 timeout 5 giây — client thấy dòng Request Failed ... request timeout (3.834 lần trong lần 3b).

  2. Keep-alive bật mặc định. noConnectionReuse tắt hẳn keep-alive; noVUConnectionReuse (mặc định false) tắt việc dùng lại kết nối giữa các iteration của cùng VU. Với arrival-rate và nhiều VU, số kết nối TCP xấp xỉ số VU đang bận — tức là theo Little's law, số kết nối tăng theo độ trễ.

  3. Thế nào là lỗi. http_req_failed dùng setResponseCallback; mặc định status 200–399 là "expected". Sub-metric {expected_response:true} của http_req_duration chỉ gồm request thành công — dùng nó khi muốn độ trễ không bị response lỗi "nhanh" kéo xuống.

  4. http_req_duration đo gì. Bằng sending + waiting + receiving, không gồm DNS và thời gian kết nối (có metric riêng http_req_connecting, http_req_blocked). Và nó chỉ đo những request đã được gửi — request bị drop không có mặt (phần 4.2).

3.5 Pipeline metric: từ mẫu đến threshold

Mỗi request sinh ra một nhóm mẫu (http_req_duration, http_req_waiting, http_req_failed, http_reqs...), mỗi mẫu mang tag (URL, method, status, scenario, và tag tự đặt như step, phase). k6 tổng hợp mẫu trong tiến trình theo bốn loại metric — Trend (avg, min, max, med, p(N)), Rate (rate), Counter (count, rate), Gauge (value) — và đồng thời đẩy mẫu ra các output nếu được bật. Ba quyết định thiết kế đáng chú ý:

  1. Sub-metric theo tag là công cụ chính để chia bậc. Threshold 'http_req_duration{step:04_rps150}' tạo một sub-metric chỉ gồm mẫu có tag đó. Hai lab dựa hoàn toàn vào cơ chế này để có số liệu từng bậc mà không cần hệ thống metric ngoài.

  2. Mỗi URL khác nhau là một time series riêng (từ v0.41). URL chứa ID động (/orders/123, /orders/124...) làm số series bùng nổ và tốn RAM — gom bằng tag name (ví dụ tags: { name: 'order_detail' }).

  3. Summary là tổng hợp trong bộ nhớ, output là luồng mẫu. Summary cuối test (và threshold) đọc từ tổng hợp trong tiến trình; percentile của Trend tính từ các mẫu đã giữ, nên bộ nhớ tăng theo số mẫu trong test dài (hành vi ở v2.x chưa verify; v2.1 có cờ thử nghiệm --features native-histograms). Output --out csv=... ghi từng mẫu (lab PHP dùng để tính ok/s từng giây), --summary-export ghi JSON tổng hợp (lab Node dùng), Prometheus remote write (-o experimental-prometheus-rw, mặc định chỉ xuất p(99) của Trend, chu kỳ đẩy 5 giây) đưa số liệu về cùng Grafana với metric của server — cách tốt nhất để đặt độ trễ k6 cạnh CPU server trên cùng trục thời gian.

flowchart TD
  R["Request xong"] --> S["Mẫu: http_req_duration=37ms<br/>tags: step=04_rps150, phase=hold,<br/>status=200, scenario=breakpoint"]
  S --> A1["Metric gốc<br/>http_req_duration"]
  S --> A2["Sub-metric<br/>http_req_duration{step:04_rps150}"]
  S --> A3["Sub-metric<br/>http_req_duration{phase:hold}"]
  A3 --> T1["Threshold abort<br/>p(95)<100, tích luỹ"]
  A2 --> T2["Threshold báo cáo<br/>in một dòng mỗi bậc"]
  S --> O["Output: CSV từng mẫu,<br/>Prometheus RW mỗi 5 s"]
  A1 --> SUM["Summary cuối test,<br/>--summary-export JSON"]
  A2 --> SUM

3.6 Bảng quyết định thiết kế và cái giá

Quyết địnhĐược gìTrả giá gìẢnh hưởng tới đo điểm gãy
Engine Go + SobekBinary tĩnh, I/O rẻ bằng goroutineJS thông dịch, không JITGiữ iteration mỏng để k6 không thành nút thắt
Một runtime mỗi VUCô lập hoàn toànRAM tăng theo VUmaxVUs lớn tốn RAM máy bắn tải; dùng SharedArray
Không phải NodeKhông chia sẻ event loop giữa các userKhông dùng được npm/API Node trực tiếpViết lại lời gọi API bằng k6/http
Arrival-rate cấp phát VU trướcKhông tốn chi phí cấp phát giữa testPhải tính preAllocatedVUsTính sai thì drop do cấu hình, không do server
Bỏ iteration khi hết VU (không xếp hàng)Máy bắn tải không bị dồn ứKhông đo được độ trễ của request "lẽ ra đã gửi"Phải canh dropped_iterations (phần 4.2)
Threshold trên tổng hợp tích luỹĐơn giản, rẻ, kết quả cuối khớp summaryAbort đến trễ, không có cửa sổ trượtĐọc knee từ sub-metric theo bậc
Time series theo URLMetric chi tiết theo URLBùng nổ series với URL độngGom bằng tag name

4. Đảm bảo và giới hạn

4.1 k6 đảm bảo gì và không đảm bảo gì

Với executor arrival-rate, k6 đảm bảo lịch bắt đầu iteration: rate iteration được rải đều trong mỗi timeUnit (docs: rate 10 trên 1 giây là một iteration mỗi 100 ms) — với điều kiện có VU rảnh. Nó không đảm bảo: (1) độ trễ của request bị trễ lịch hay bị bỏ được tính vào kết quả; (2) tải thực gửi bằng tải định gửi khi pool VU cạn hoặc máy bắn tải hết CPU; (3) threshold phản ánh trạng thái hiện tại thay vì tích luỹ. Ba giới hạn này là ba mục tiếp theo.

4.2 Coordinated omission: k6 sửa được một nửa

Gil Tene đặt tên hiện tượng: hệ thống đo vô tình phối hợp với hệ thống bị đo theo cách tránh đo các giá trị ngoại lai. Công cụ đúng dùng lịch tĩnh và đo độ trễ từ thời điểm request lẽ ra được gửi:

latency_đúng = (thời_điểm_nhận − thời_điểm_dự_định_gửi)
             = thời_gian_chờ_trễ_lịch + service_time
Cách nói khi phỏng vấn: "Độ trễ đúng phải tính từ lúc request lẽ ra được gửi theo kế hoạch, không phải từ lúc nó thực sự được gửi. Nếu công cụ bị kẹt và gửi trễ hai giây, user thật đã đợi hai giây đó rồi. wrk2 làm đúng như vậy bằng cách tính từ thời điểm theo kế hoạch. k6 thì giữ được lịch gửi nhưng khi hết user ảo nó bỏ hẳn request và chỉ đếm số bị bỏ, không cộng thời gian chờ vào độ trễ."

ScyllaDB chia bộ lập lịch thành hai loại: queuing (bắn bù ngay các request lỡ lịch — đúng nhất; wrk2, YCSB, cassandra-stress) và queueless (bỏ request lỡ lịch). Executor arrival-rate của k6 là lịch tĩnh + queueless: không có VU rảnh thì iteration bị bỏ và đếm vào dropped_iterations, còn http_req_duration chỉ gồm request đã gửi. Một maintainer k6 năm 2020 viết rằng executor arrival-rate (từ v0.27.0) "giải quyết phần lớn nhất của coordinated omission", còn HDR histogram và logic hiệu chỉnh vẫn là việc cần làm. Hiện trạng ở v2.x: không tài liệu nào nhắc tới hiệu chỉnh — chưa verify, nên giả định là không có.

Quy tắc thực hành rút ra: trong breakpoint test kiểu mở, từ lúc dropped_iterations > 0 (sau warm-up) hoặc vus chạm maxVUs, số độ trễ của k6 không còn đại diện cho user thật — coi như đã qua knee. Lab Node lần 1c minh hoạ: p95 trong cửa sổ đo dừng ở khoảng 1 giây vì hai trần nhân tạo — pool trả lỗi sau 1 giây chờ kết nối, và k6 không giữ quá 400 request đang bay (= maxVUs) — còn phần tải vượt trần thì bị bỏ (6.168 iteration) và không xuất hiện trong bất kỳ con số độ trễ nào. Hàng đợi thật mà user gặp phải dài hơn con số k6 in ra.

4.3 Ngữ nghĩa của dropped_iterations

Docs k6: dropped_iterations đếm "iteration không được bắt đầu vì thiếu VU (với executor arrival-rate) hoặc thiếu thời gian (hết maxDuration với executor dựa trên iteration)". Hai nguyên nhân với arrival-rate: cấu hình executor không đủ, hoặc hệ thống bị đo không chịu nổi tốc độ đến. Docs gợi ý cách phân biệt: drop ở đầu test → cấp thêm VU; drop về sau → hệ thống đang xuống cấp, iteration dài ra. Docs cũng khuyên coi một tỉ lệ drop chấp nhận được là một phần của error budget và đặt threshold trên nó.

Lab bổ sung một chi tiết docs không nói rõ: drop có thể xuất hiện khi số VU đang chạy còn xa maxVUs. Lần 2a: maxVUs 400, số VU chạy cao nhất 117, vậy mà có 196 iteration bị bỏ:

### 2a-cpu-single-open  script=breakpoint.js  env=ENDPOINT=/cpu MODEL=open STEPS=50,100,125,150,175,200,250 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
    dropped_iterations.............: 196    1.290608/s
    vus............................: 80     min=0          max=117
    vus_max........................: 140    min=120        max=140
Cách nói khi phỏng vấn: "Bỏ iteration không chỉ xảy ra khi chạm trần user ảo. Trong lab, trần là bốn trăm, số đang chạy cao nhất chỉ một trăm mười bảy, vậy mà vẫn có gần hai trăm iteration bị bỏ. Lý do khớp với cảnh báo trong docs: khi phải cấp phát user ảo giữa test, k6 không kịp bắt đầu iteration đúng giờ. Vì vậy tôi cấp phát trước đủ cho vùng gãy chứ không trông vào trần tối đa."

vus_max tăng từ 120 (100 của breakpoint + 20 của warm-up) lên 140 nghĩa là k6 đã phải cấp phát thêm 20 VU giữa test. Docs k6 có câu cảnh báo khớp với quan sát này: preAllocatedVUs quá thấp làm giảm thời gian test chạy đúng tốc độ mong muốn, vì phải liên tục cấp phát tài nguyên. Cơ chế chính xác bên trong (iteration bị bỏ trong lúc VU mới đang khởi tạo) là suy luận, chưa verify bằng source code. Lần 1a cho cùng mẫu hình: 130 drop, VU cao nhất 103, vus_max 132. Khi thật sự chạm trần, k6 in cảnh báo riêng — lần 1c:

time="2026-10-30T12:04:45Z" level=warning msg="Insufficient VUs, reached 400 active VUs and cannot initialize more" executor=ramping-arrival-rate scenario=breakpoint
{"t":"2026-10-30T12:04:45.683Z","pid":1,"rps":436,"err5xx":0,"eld_p50_ms":0.3200000000000003,"eld_p99_ms":7.510000000000002,"eld_max_ms":11.18,"elu":0.38,"cpu_cores":0.44,"cpu_ms_per_req":1.01,"pool_total":10,"pool_idle":0,"pool_waiting":390,"pool_waiting_max":389,"inflight":400,"inflight_max":400,"rss_mb":93}
{"t":"2026-10-30T12:04:47.683Z","pid":1,"rps":403,"err5xx":59,"eld_p50_ms":0.4800000000000004,"eld_p99_ms":29.85,"eld_max_ms":70.94,"elu":0.49,"cpu_cores":0.56,"cpu_ms_per_req":1.4,"pool_total":10,"pool_idle":0,"pool_waiting":388,"pool_waiting_max":389,"inflight":398,"inflight_max":400,"rss_mb":94,"errors":{"timeout exceeded when trying to connect":59}}
Cách nói khi phỏng vấn: "Khi chạm trần bốn trăm user ảo, k6 in cảnh báo 'Insufficient VUs'. Cùng lúc phía app thấy đúng bốn trăm request đang bay và ba trăm chín mươi request chờ kết nối từ pool mười kết nối. Hai giây sau app bắt đầu trả lỗi 500 vì chờ kết nối quá một giây. Hai đầu đo khớp nhau từng con số: số user ảo bận của k6 chính là số request đang nằm trong app."

Hệ quả cuối cùng: những iteration bị bỏ không bao giờ tới server, nên số lỗi phía server (log 5xx, error log nginx) đánh giá thấp thiệt hại. Báo cáo phải cộng cả hai phía.

4.4 Máy bắn tải cũng có điểm gãy

Docs k6 về test lớn: một tiến trình k6 dùng được mọi core, một instance có thể chạy 30.000–40.000 VU và tới 300.000 request/giây trong một số trường hợp; nhưng phải giữ k6 dưới 80% CPU — "nếu k6 dùng 100% CPU để sinh tải, test sẽ bị bóp và độ trễ đo được có thể lớn hơn thực tế rất nhiều". Kèm theo: tắt swap, giữ RAM dưới 90%, và threshold có abortOnFail tốn CPU vì được đánh giá liên tục (docs gợi ý bỏ chúng trong test cực lớn — ở quy mô vài trăm request/giây như lab thì không đáng kể).

Lab ghim k6 vào một core riêng (cpu 0) và thấy core đó bận hơn hẳn đúng lúc hệ thống gãy: ở bậc 300 request/giây ổn định của lần 1c, cpu 0 bận khoảng 40–47%; khi chuyển lên bậc 600 và k6 đang giữ 400 VU chờ, cpu 0 lên 94%. Dòng đầu dưới đây cũng là một lời cảnh báo về chính công cụ đo: con số cgroup của container k6 (0,71 core) không khớp với mức bận của cpu 0 trên host (25%) trong cùng dòng, dù k6 bị ghim vào cpu 0 — hai bộ đếm lấy mẫu lệch cửa sổ (nguyên nhân chưa verify), nên phần này đọc theo cột host%:

13:04:03 cores node-app=0.77 node-k6=0.71 node-pg=0.12 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=25 cpu1=11 cpu2=17 cpu3=12
13:04:23 cores node-app=0.49 node-k6=0.50 node-pg=0.22 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=45 cpu1=23 cpu2=22 cpu3=30
13:05:04 cores node-app=0.64 node-k6=0.78 node-pg=0.15 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=94 cpu1=15 cpu2=37 cpu3=16
Cách nói khi phỏng vấn: "Máy bắn tải cũng có điểm gãy, và nó thường đến cùng lúc với điểm gãy của hệ thống bị đo, vì khi server chậm thì k6 phải giữ nhiều user ảo đang chờ hơn. Trong lab, core của k6 từ năm mươi lăm phần trăm lên chín mươi tư phần trăm đúng lúc app gãy. Nên tôi coi số liệu sau điểm gãy là kém tin cậy, và với kết quả quan trọng tôi kiểm chéo bằng một máy bắn tải thứ hai hoặc wrk2."

Hệ quả: số liệu sau knee vừa bị coordinated omission vừa có thể bị máy bắn tải bóp méo — một lý do nữa để báo cáo bậc cuối còn đạt chứ không phải hành vi sau gãy. Kiểm tra "đây là giới hạn của server hay của k6": CPU k6 dưới 80%, không drop ở đầu test, mạng chưa chạm băng thông, và có một tài nguyên phía server bão hoà đúng tại knee (USE method). Ngoài CPU, hệ điều hành của máy bắn tải còn giới hạn cổng tạm (Linux mặc định 32768 60999) và số file descriptor; docs k6 có hướng dẫn chỉnh ip_local_port_range, tcp_tw_reuse, ulimit -n nhưng cũng cảnh báo không chỉnh mù quáng.

4.5 Little's law: ba cách dùng trong một breakpoint test

L = λ × W     (trung bình dài hạn; chỉ ổn định khi tốc độ đến ≤ tốc độ ra)

1) Số VU cần:            VU ≈ rate × iteration_duration
2) Trần của một pool:    λ_max ≈ N_worker / W_giữ_worker
3) Kiểm tra chéo:        W ≈ L_đang_bay / λ
Cách nói khi phỏng vấn: "Little's law nói số việc đang nằm trong hệ thống bằng tốc độ đến nhân thời gian mỗi việc ở lại. Tôi dùng nó ba cách: tính số user ảo cần cấp cho k6, tính trần của một pool như php-fpm có N worker thì tối đa N chia thời gian giữ worker request mỗi giây, và kiểm tra chéo — đếm số request đang bay chia cho tốc độ là ra độ trễ, so với độ trễ k6 đo được. Định luật chỉ đúng khi hệ thống ổn định; qua điểm gãy thì hàng đợi tăng mãi và không còn trung bình nào để nói."

Số kiểm chứng từ lab (chi tiết cơ chế pool php-fpm đã có trong bài Scale nginx + PHP-FPM, mục Little's law; bài Cách tính con số rate limit có lab 4 worker × 50 ms):

  1. Trần pool, PHP /api/slow (pg_sleep 100 ms). Thời gian giữ worker đo được W = 0,112 s. Với 5 worker, dự đoán 5 / 0,112 = 44,6 request/giây; đo được 43,5 ok/s ở bậc 45 — sai 2,5%. Với 10 worker dự đoán 89, lần tốt nhất đo được 79,7 (90%). Với 40 worker dự đoán 357 nhưng CPU (khoảng 10 ms mỗi request trên 2 core → trần khoảng 200) chặn trước: đo được 135–142. Little's law cho trần của một tài nguyên; trần của hệ thống là trần nhỏ nhất.

  2. Kiểm tra chéo, Node lần 1c. Khoảng 400 request đang bay, app phục vụ khoảng 430 request/giây → W = 400 / 430 ≈ 0,93 s; p50 đo được ở bậc 550 là 892 ms. Pool 10 kết nối giữ mỗi kết nối khoảng 23 ms (20 ms pg_sleep + round-trip) → trần ≈ 10 / 0,023 ≈ 430 request/giây, khớp số app phục vụ được.

  3. Đọc độ trễ từ VU, PHP r2 bậc 180. 228 VU / 180 request/giây ≈ 1,27 s, khớp p95 1.113 ms (phần 0.4).

4.6 Vì sao độ trễ gãy trước khi CPU chạm 100%: hàng đợi và USL

Lý thuyết hàng đợi đơn giản nhất (M/M/1: đến ngẫu nhiên, thời gian phục vụ ngẫu nhiên, một server) cho thời gian chờ trung bình tăng theo ρ/(1−ρ), với ρ là mức sử dụng. Đây là mô hình xấp xỉ, dùng để hiểu hình dạng, không để tính chính xác:

W_chờ ≈ S × ρ / (1 − ρ)        S = thời gian phục vụ, ρ = λ × S

ρ = 0,50 → chờ = 1 × S
ρ = 0,80 → chờ = 4 × S
ρ = 0,90 → chờ = 9 × S
ρ = 0,95 → chờ = 19 × S
Cách nói khi phỏng vấn: "Thời gian chờ trong hàng đợi tăng theo tỉ số giữa phần bận và phần rảnh. Bận năm mươi phần trăm thì chờ bằng một lần thời gian phục vụ, tám mươi phần trăm thì bốn lần, chín mươi phần trăm thì chín lần, chín mươi lăm thì mười chín lần. Nên độ trễ bùng nổ trước khi CPU chạm trăm phần trăm, và vận hành ở chín mươi phần trăm nghĩa là một dao động nhỏ cũng đẩy độ trễ lên gấp đôi."

Áp vào Node /cpu: S ≈ 6 ms, ở 150 request/giây ρ ≈ 0,9 → chờ trung bình khoảng 54 ms theo M/M/1; p95 đo được 37,3 ms (lần 2a) và 142,8 ms (lần 3b). Thời gian phục vụ của pbkdf2Sync gần như cố định, và hàng đợi có thời gian phục vụ cố định (M/D/1) chờ khoảng một nửa M/M/1 — nên con số đo được nằm quanh vùng ước lượng, và việc hai lần chạy chênh nhau gần 4 lần chính là "ở ρ = 0,9, nhiễu nhỏ bị khuếch đại". Đây là cơ sở toán học cho margin ở phần 0.9.

USL (Universal Scalability Law, Neil Gunther, công bố lần đầu ở CMG 1993) mô tả thông lượng theo mức đồng thời N:

X(N) = γN / (1 + α(N − 1) + βN(N − 1))
α = contention  (tranh chấp tài nguyên chung, xếp hàng)   → thông lượng tiệm cận trần 1/α
β = coherency   (chi phí đồng bộ, "nói chuyện chéo")      → thông lượng đi lùi sau N_max
N_max = sqrt((1 − α) / β)
Cách nói khi phỏng vấn: "USL nói thông lượng theo số việc đồng thời bị kéo xuống bởi hai thứ. Thứ nhất là tranh chấp, các việc phải xếp hàng chờ một tài nguyên chung, làm thông lượng phẳng ra ở một trần. Thứ hai là chi phí đồng bộ giữa các việc, tăng theo bình phương số việc, làm thông lượng đi lùi sau một điểm cực đại. Nếu đo sáu đến tám điểm tải và fit được hai hệ số, tôi biết hệ thống đang phẳng ra vì tranh chấp hay đi lùi vì đồng bộ, và dự đoán được đỉnh."

Cách dùng và giới hạn: USL tính theo mức đồng thời N, không phải theo request/giây gửi đi, nên phải fit từ test mô hình đóng hoặc từ mức đồng thời đo được (L = λW), không fit mù từ tốc độ gửi. Cần 6–8 điểm đo sạch; hai lab của bài có 4–6 bậc mỗi lần, nhiễu ±1 bậc, nên không fit USL (chưa verify trên lab này). Dù vậy, hình dạng định tính có thấy: lab PHP thêm worker từ 20 lên 80 không tăng thông lượng (CPU là tài nguyên tranh chấp — đúng kiểu α), còn đẩy quá trần thì goodput sụp (lần exp3sat 5 worker: 26 ok/s ở 60 gửi, 0 ở 90) — kiểu "đi lùi" nhưng do timeout và làm phí việc cho request đã bị bỏ, không phải do β theo nghĩa của Gunther.

4.7 Giới hạn của chính threshold

  1. Tích luỹ, không cửa sổ trượt (lab xác nhận ở phần 0.5): abort đến sau knee; trễ hơn khi test dài hơn (B > G/19 với p95).

  2. Chu kỳ đánh giá. Docs chỉ nói rõ cho Grafana Cloud: threshold được đánh giá mỗi 60 giây, nên abort có thể trễ tới 60 giây. Chu kỳ khi chạy k6 cục bộ không được ghi trong tài liệu đã đọc (chưa verify); lab thấy abort xảy ra trong vòng vài giây sau khi ngưỡng tích luỹ bị vượt.

  3. delayAbortEval đếm từ đầu test (lab xác nhận ở phần 0.5), không phải từ đầu scenario.

  4. Threshold luôn đạt vẫn tốn. Mỗi sub-metric là một tổng hợp riêng trong bộ nhớ; vài chục bậc × ba sub-metric thì không đáng kể, vài nghìn thì phải cân nhắc.

4.8 Tổng hợp: giới hạn nào ảnh hưởng con số nào

Giới hạnBiểu hiệnLàm sai con số nàoPhòng tránh
Coordinated omission (mô hình đóng)0 lỗi, 0 drop, p95 "vừa phải" dù server bão hoàKnee báo cao hơn thậtDùng arrival-rate
Queueless drop (mô hình mở)dropped_iterations > 0, Insufficient VUsĐộ trễ đuôi sau knee thấp hơn thậtCoi drop là đã qua knee; đặt threshold trên drop
Cấp phát VU giữa testDrop khi VU còn xa maxVUsTải thực thấp hơn định gửipreAllocatedVUs theo độ trễ vùng gãy
Máy bắn tải bão hoàCPU k6 > 80%Độ trễ phồng lênMáy riêng, kiểm chéo bằng máy thứ hai/wrk2
Threshold tích luỹAbort trễ một hoặc nhiều bậc"Điểm gãy" lấy từ thời điểm abortĐọc sub-metric theo bậc
Nhiễu host, ít mẫu mỗi bậcKnee lệch ±1 bậc giữa các lầnCon số không lặp lại được≥ 3 lần, bậc dài 1–3 phút, báo cáo min và độ lệch
Hàng đợi phi tuyếnĐộ trễ bùng trước khi CPU 100%Kế hoạch sát kneeMargin 0,6–0,8 (thông lệ)

5. Failure modes (lab tái hiện)

Phần này không mô tả lỗi theo trí nhớ. Mỗi kiểu hỏng dưới đây đều được tái hiện bằng lab chạy thật trên VPS 4 vCPU này, rồi đọc ra từ log của k6, nginx, php-fpm và Postgres. 5.1 là phía PHP (Laravel trên nginx + php-fpm), 5.2 là phía Node.js (do người viết khác thực hiện, cùng máy, cùng cách bắn tải và cùng cách đọc số) để hai bên so được với nhau. Host dùng chung nên có nhiễu: có những đợt khựng 1–3 giây không rõ nguyên nhân. Vì vậy mọi kết quả ghi dưới dạng khoảng, và mọi phép so sánh đều chạy ít nhất hai lần (tag -r2).

5.1 Laravel trên nginx + php-fpm

Môi trường. Laravel 13.39, PHP 8.5.12 (opcache bật, validate_timestamps=0, đã chạy config/route cache, APP_DEBUG=false), nginx 1.31.8, PostgreSQL 18.6 (max_connections=100, scram-sha-256), k6 2.3.0. Tất cả đều được ghim CPU (pin): k6 chạy trên cpu 0, Postgres trên cpu 1, còn nginx và php-fpm dùng chung cpu 2,3. Như vậy "app box" có đúng 2 vCPU. php-fpm chạy pm = static, listen.backlog = 511. nginx nói chuyện với php-fpm qua TCP tmp-perf-php-fpm:9000, trừ các run có chữ unix trong tên thì dùng unix socket. Có ba endpoint: /api/db đọc một dòng theo khoá chính (PK lookup) trên bảng 10k dòng, /api/slow chạy select pg_sleep(0.1), còn /api/ping chỉ đi qua framework. Script nằm ở labs/performance/php/ (setup.sh, run.sh, k6/stepped.js). Mọi khối output dán bên dưới đều có nguyên văn trong labs/performance/php/out/, gom lại ở out/excerpts-failure-modes-php.log.

Cách bắn tải và cách đọc bảng. k6 dùng executor ramping-arrival-rate, tức open model (mô hình mở: request đến theo nhịp cố định, server chậm thì client cũng không chậm lại theo). Tải tăng theo bậc, mỗi bậc giữ 20 s. SLO là p95 dưới 200 ms. Run tự dừng (abortOnFail) khi p95 cộng dồn vượt 2,5 × SLO hoặc lỗi vượt 1 %. Trong các bảng: "200/s" là goodput (số response 200 hoàn thành mỗi giây), "k6 dropped" là số iteration k6 không bắt đầu được vì hết VU, "in-flight max" là số request đang bay cùng lúc. Các cột "fpm %", "nginx %" tính theo % của một core, nên app box tối đa 200 %; "pg %" là % của core duy nhất của Postgres. "ms CPU/req" = CPU (fpm + nginx) ÷ số response 200. "psi %" là cpu.pressure some avg10 của cgroup php-fpm, tức tỉ lệ thời gian có tiến trình PHP sẵn sàng chạy nhưng phải chờ CPU. Knee (điểm gãy) được định nghĩa là bậc đầu tiên mà p95 vượt SLO và không hồi lại, goodput không bám kịp tải gửi vào, listen queue tăng và mọi worker đều bận. Một cú khựng 1–2 s đơn lẻ thì không tính là knee.

flowchart TD
  K6["k6 2.3.0 trên cpu 0<br/>open model, tối đa 600 request in-flight"] --> NG["nginx 1.31.8<br/>cpu 2,3"]
  NG -->|"FastCGI qua TCP :9000<br/>hoặc unix socket"| FPM["php-fpm 8.5.12 + Laravel 13.39<br/>pm=static, cpu 2,3 (chung với nginx)"]
  FPM -->|"PDO pgsql"| PG["PostgreSQL 18.6 trên cpu 1<br/>max_connections=100"]
  S["sampler trên cpu 0: fpm-status 2 s,<br/>pg_stat_activity 2 s, cgroup CPU/PSI"] -.-> FPM
  S -.-> PG
[www]
listen = __LISTEN__
listen.backlog = __BACKLOG__
pm = static
pm.max_children = __MAX_CHILDREN__
request_terminate_timeout = 30s
Cách nói khi phỏng vấn: "Pool php-fpm trong lab để static, số worker cố định, mỗi worker xử lý một request tại một thời điểm. Request đến lúc mọi worker đều bận thì nằm chờ trong hàng đợi của socket, hàng đợi này dài tối đa 511. Nếu một request chạy quá 30 giây thì php-fpm tự giết worker đó. Số worker và độ dài hàng đợi là hai núm chỉnh tôi thay đổi trong các thí nghiệm bên dưới."
      fastcgi_pass __FCGI__;   # tcp: tmp-perf-php-fpm:9000 | unix: unix:/run/php/php-fpm.sock
      fastcgi_connect_timeout 5s;
      fastcgi_read_timeout 5s;     # > 5s waiting for PHP (queue + work) => nginx 504
Cách nói khi phỏng vấn: "Phía nginx tôi đặt timeout chờ PHP là 5 giây, gồm cả lúc chờ kết nối lẫn lúc chờ header trả về. Quá 5 giây là nginx tự trả 504 cho client, dù request vẫn còn nằm trong hàng đợi của PHP. Timeout này cộng với độ dài backlog quyết định lúc quá tải client nhận lỗi gì, và như phần sau cho thấy, quyết định cả việc PHP có đang làm việc vô ích hay không."

5.1.1 Exp0 — Laravel mặc định: mỗi request mở một connection Postgres mới

(1) Thiết lập. Mặc định Laravel không dùng persistent connection (kết nối giữ lại giữa các request). Mỗi request PHP sẽ mở một kết nối TCP tới Postgres, xác thực SCRAM-SHA-256, chạy query rồi đóng. Phía Postgres, mỗi kết nối mới lại fork một backend process. Run ./run.sh exp0 dùng max_children=20, DB_PERSISTENT=false, endpoint /api/db, các bậc tải 20 → 40 → 60 rps.

rps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
2019.920.10936172391-4334222.63
4040.040.0023791155-6746717.89
6054.554.4010121016641745-10168919.738
      { stage:r0020 }..............: avg=58.3ms   med=35.87ms  p(90)=108.64ms p(95)=171.57ms p(99)=391.32ms max=481.9ms  count=399 
      { stage:r0040 }..............: avg=44.99ms  med=36.76ms  p(90)=69.8ms   p(95)=90.72ms  p(99)=155.2ms  max=248.73ms count=800 
      { stage:r0060 }..............: avg=464.84ms med=209.92ms p(90)=1.61s    p(95)=1.66s    p(99)=1.74s    max=2.06s    count=940 
Cách nói khi phỏng vấn: "Laravel để mặc định, mỗi request một connection mới, thì ở 40 request mỗi giây p95 vẫn khoảng 90 mili giây. Lên 60 thì p95 nhảy lên khoảng 1,7 giây và k6 tự dừng. Lúc đó CPU của app box mới dùng chừng một nửa. Tức là thứ gãy trước không phải PHP mà là một chỗ khác."

(2) Gãy ở đâu: core duy nhất của Postgres. Ở bậc 40 rps, Postgres đã dùng 67 % core của nó. Chia ra: 0,67 × 1000 ms ÷ 40 request ≈ 16,75 ms CPU Postgres cho mỗi request, trong khi bản thân query chỉ là một PK lookup. Từ đó suy ra trần lý thuyết của Postgres là 1000 ÷ 16,75 ≈ 60 rps, khớp với chỗ gãy đo được. Ở bậc 60, tải thực gửi vào chỉ được 54,5 rps (run bị abort giữa bậc), pg % trung bình 89 %, còn từng giây thì 93–97 %. Trong lúc đó fpm + nginx chỉ dùng 101 + 6 = 107 % trên 200 %.

utc       t    done  p50ms  p95ms  non200 |  fpm%  ngx%   pg%  psi  dockerd%
10:48:49   40     55     54    121       0 |    69     4    65    9       30
10:48:50   41     61     57     88       0 |    83     4    83   10       32
10:48:51   42     51     56    167       0 |    83     4    84   10       21
10:48:52   43     61    173    249       0 |   102     5    94   12       28
10:48:53   44     65     72    150       0 |    96     4    88   12       34
10:48:54   45     55     59    146       0 |    87     4    83   17       30
10:48:55   46     62     76    164       0 |    97     5    92   19       30
10:48:56   47     54     70    159       0 |    97     5    87   19       33
10:48:57   48     60    252    322       0 |   117     6    93   23       28
10:48:58   49     54    291    353       0 |   116     6    97   23       29
10:48:59   50     62    265    326       0 |   108     6    93   28       46
10:49:00   51     58    297    452       0 |   108     6    93   28       46
10:49:01   52     37    380    534       0 |   128     6    95   28       35
Cách nói khi phỏng vấn: "Tôi xem theo từng giây quanh điểm gãy. Số request hoàn thành không tăng được quá khoảng 55 đến 65 mỗi giây, cột CPU của Postgres leo lên rồi dính ở 93–97 phần trăm, và đúng lúc đó latency p50 nhảy từ khoảng 60 lên 300 đến 380 mili giây. App box vẫn còn gần một nửa CPU. Nên bottleneck là core của database, không phải PHP."
10:48:50 cpu0=34% cpu1=82% cpu2=61% cpu3=61% 
10:48:52 cpu0=39% cpu1=92% cpu2=65% cpu3=66% 
10:48:54 cpu0=48% cpu1=96% cpu2=74% cpu3=71% 
10:48:56 cpu0=48% cpu1=94% cpu2=68% cpu3=69% 
10:48:58 cpu0=48% cpu1=96% cpu2=72% cpu3=73% 
10:49:00 cpu0=59% cpu1=99% cpu2=80% cpu3=81% 
10:49:02 cpu0=45% cpu1=98% cpu2=83% cpu3=79% 
10:49:04 cpu0=43% cpu1=96% cpu2=70% cpu3=66% 
10:49:06 cpu0=55% cpu1=99% cpu2=79% cpu3=80% 
Cách nói khi phỏng vấn: "Nhìn CPU từng core của máy host cũng ra kết luận đó. cpu1, core ghim cho Postgres, nằm ở 92–99 phần trăm. Hai core của app chỉ khoảng 65 đến 83 phần trăm. Một core ở sát 100 phần trăm trong khi các core khác còn dư là dấu hiệu kinh điển của một tài nguyên đơn luồng bị nghẽn."

(3) Tiền kết nối ở phía PHP. Tôi đo thêm bằng tay: chạy PDO thô trong php-fpm, gọi getrusage trước và sau connect + query. Kết quả: mở connection mới tốn khoảng 6,2–6,3 ms CPU trong worker PHP. Nếu dùng lại handle persistent thì chỉ còn 0,47–0,52 ms, tức chênh khoảng 5,8 ms CPU cho mỗi request. Ở khối dưới, p=1 nghĩa là persistent, fast=1 là select 1 thay cho pg_sleep(0.1).

?p=1&fast=1 n= 30 median cpu_us= 470 median wall_ms= 1.2
?p=1 n= 30 median cpu_us= 518 median wall_ms= 102.4
?fast=1 n= 30 median cpu_us= 6283 median wall_ms= 13.7
 n= 30 median cpu_us= 6213 median wall_ms= 115.7
Cách nói khi phỏng vấn: "Tôi đo riêng chi phí mở kết nối ngay trong worker PHP. Dùng lại kết nối có sẵn thì một request tốn khoảng nửa mili giây CPU. Mở kết nối mới thì tốn hơn sáu mili giây, chủ yếu là bắt tay xác thực SCRAM. Phía Postgres còn mất thêm một lần fork process cho mỗi kết nối, nên tổng chi phí rơi chủ yếu vào database."

(4) Vì sao persistent connection đổi hẳn bức tranh. Cấu hình được thêm vào config/database.php:

'options' => [PDO::ATTR_PERSISTENT => (bool) env('DB_PERSISTENT', false)],
Cách nói khi phỏng vấn: "Tôi bật persistent connection của PDO cho kết nối pgsql. Khi đó mỗi worker php-fpm giữ đúng một kết nối Postgres suốt đời của nó, 20 worker là 20 kết nối, không phải bắt tay hay fork lại nữa. Đổi lại, số kết nối tới database giờ bằng tổng số worker trên mọi máy, và đó là cái trần mới phải canh."

Sau khi bật, Postgres chỉ còn khoảng 10–13 % core ở 60 rps (exp1 bên dưới), tức 0,13 × 1000 ÷ 60 ≈ 2 ms CPU Postgres cho mỗi request, giảm khoảng 8 lần. Knee chuyển từ 40–60 rps lên 150–180 rps, gấp khoảng 3 lần, và lúc này nút cổ chai chuyển sang CPU của app box. Toàn bộ các thí nghiệm sau đều dùng persistent PDO. Trong production, cách hay gặp hơn là đặt pooler (PgBouncer) ở giữa. Lab này chưa verify PgBouncer, chỉ đo persistent PDO. Còn chi phí ~16 ms phía Postgres gồm bao nhiêu phần SCRAM, bao nhiêu phần fork thì cũng chưa tách riêng.

flowchart TD
  A["Request vào worker php-fpm"] --> B{"DB_PERSISTENT?"}
  B -->|"false (mặc định)"| C["TCP connect + SCRAM-SHA-256<br/>~6,2 ms CPU phía PHP"]
  C --> D["Postgres fork backend mới<br/>~16 ms CPU Postgres mỗi request"]
  D --> E["Trần ≈ 1000 / 16,75 ≈ 60 rps<br/>knee đo được 40–60 rps"]
  B -->|"true"| F["Dùng lại handle của worker<br/>~0,5 ms CPU phía PHP"]
  F --> G["~2 ms CPU Postgres mỗi request<br/>knee chuyển lên 150–180 rps"]

5.1.2 Exp1 — /api/db, max_children = 20: tìm knee và trần lý thuyết

(1) Kết quả theo bậc. Run sạch nhất là round 2 (exp1-db-mc20-r2). Ở 60 rps p95 là 18 ms. Ở 90 và 120 có hai đợt p95 343 ms và 236 ms nhưng tự hồi. Ở 150 p95 là 109 ms. Đến 180 rps thì p95 lên 1,11 s và không hồi lại, sang đầu bậc 210 thì run bị abort.

rps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
6060.060.003101828-465108.51
9090.088.803914343471-95112312.033
120120.0121.202723236365-115132610.636
150150.0149.802019109154-12713269.345
180179.9176.602283711131260-14116318.968
210174.6220.6047229332362-0000.068
      { stage:r0060 }..............: avg=10.83ms  med=9.7ms    p(90)=15.44ms  p(95)=18.04ms  p(99)=27.86ms  max=50.04ms  count=1199 
      { stage:r0090 }..............: avg=63.21ms  med=13.88ms  p(90)=235.89ms p(95)=343.27ms p(99)=471.13ms max=640.99ms count=1800 
      { stage:r0120 }..............: avg=58.09ms  med=23.33ms  p(90)=161.9ms  p(95)=236.25ms p(99)=365.25ms max=613.98ms count=2399 
      { stage:r0150 }..............: avg=32.48ms  med=19.2ms   p(90)=76.81ms  p(95)=108.9ms  p(99)=154.07ms max=339.68ms count=3000 
      { stage:r0180 }..............: avg=221.02ms med=37.41ms  p(90)=969.4ms  p(95)=1.11s    p(99)=1.26s    max=1.55s    count=3599 
      { stage:r0210 }..............: avg=236.24ms med=229.48ms p(90)=315.22ms p(95)=331.77ms p(99)=361.5ms  max=436.76ms count=254  
Cách nói khi phỏng vấn: "Mỗi dòng ở đây là p95 của một bậc tải. Đến 150 request mỗi giây vẫn dưới 200 mili giây, lên 180 thì p95 thành hơn một giây. Ở 90 và 120 cũng có vượt SLO nhưng hồi lại ngay ở bậc sau, nên tôi xếp đó là nhiễu của host chứ không phải điểm gãy. Vì vậy tôi báo knee là khoảng 150 đến 180, không phải một con số."

(2) Mỗi request tốn bao nhiêu CPU. Lấy theo ba cách. Cột "ms CPU/req" dưới tải dao động 8,5–12 ms. Đo tuần tự bằng counter của cgroup cho ra khoảng 9 ms của php-fpm (cột nginx trong phép đo này bị lẫn vòng wget chạy trong container nginx nên bỏ qua). Đo tay bằng cpu.stat ra 10,1 ms. Bên trong PHP, Laravel boot tốn khoảng 2,3–2,5 ms và một query khoảng 0,7–1,1 ms. Phần còn lại là FastCGI, TCP, kernel và nginx.

/api/ping x300 sequential: wall/request=9 ms  php-fpm CPU/request=7111 us  nginx CPU/request=3439 us
/api/db x300 sequential: wall/request=13 ms  php-fpm CPU/request=9039 us  nginx CPU/request=3318 us
/api/slow x300 sequential: wall/request=126 ms  php-fpm CPU/request=14169 us  nginx CPU/request=5017 us
?fast=1 n= 20 median boot_cpu_us= 2293 query_cpu_us= 681 wall_ms= 3.6
 n= 20 median boot_cpu_us= 2475 query_cpu_us= 1062 wall_ms= 105.6
/api/db: fpm cgroup 10102 us/request (user 7466 sys 2635)
/api/slow: fpm cgroup 10956 us/request (user 8500 sys 2455)
Cách nói khi phỏng vấn: "Tôi đo CPU cho một request bằng cách đọc bộ đếm CPU của cgroup trước và sau ba trăm request tuần tự. Một request /api/db tốn khoảng 9 đến 10 mili giây CPU trên app box. Trong đó Laravel boot chỉ khoảng 2,5 mili giây và query khoảng 1 mili giây. Phần lớn còn lại là chi phí quanh request: FastCGI, socket, kernel. Muốn giảm thì phải giảm đúng phần đó, chứ tối ưu query thêm không đáng kể."

(3) Tính trần lý thuyết. Trên 2 core, mỗi giây có 2 × 1000 = 2000 ms CPU. Nếu mỗi request tốn C ms thì throughput tối đa là 2000 ÷ C:

X_max = (số core × 1000 ms) / CPU_ms_mỗi_request
C = 10 ms  ->  X_max = 2000 / 10 = 200 rps
C =  8 ms  ->  X_max = 2000 /  8 = 250 rps
=> trần lý thuyết ≈ 200–250 rps; knee đo được 150–180 rps (≈ 70–79 % CPU)
Cách nói khi phỏng vấn: "App box có hai core, tức mỗi giây có hai nghìn mili giây CPU để tiêu. Một request tốn khoảng 8 đến 10 mili giây, chia ra thì trần là 200 đến 250 request mỗi giây. Thực tế knee nằm ở 150 đến 180, tương ứng khoảng 70 đến 80 phần trăm CPU. Ta không bao giờ chạy được sát 100 phần trăm mà latency vẫn ổn, vì hàng đợi tăng rất nhanh khi gần bão hoà."

(4) Vì sao knee nằm dưới trần 20–30 %. Ở bậc 150, fpm + nginx dùng 127 + 13 = 140 % trên 200 %, tức 70 % CPU. Ở 180 là 141 + 16 = 157 %, tức 78,5 %. Cùng lúc đó psi tăng từ 45 % lên 68 %. Lấy một ví dụ minh hoạ (mô hình đơn giản, không phải số đo) với trần ~220 rps. Host khựng 2 s ở 180 rps sẽ dồn lại 360 request. Lúc đó app chỉ còn dư 220 − 180 = 40 rps để trả nợ hàng đợi, mất khoảng 9 s mới hết. Cũng cú khựng đó ở 150 rps dồn 300 request, dư 70 rps, mất khoảng 4 s. Đây chính là cái thấy được ở round 1: một cú khựng ~2 s ở bậc 120 rps làm số request hoàn thành tụt xuống 29–35/s trong khi php-fpm vẫn đốt khoảng 107 % CPU, và chừng đó đủ làm abort cả run:

utc       t    done  p50ms  p95ms  non200 |  fpm%  ngx%   pg%  psi  dockerd%
10:35:57   38     91     18     83       0 |    94    12    26   10        8
10:35:58   39     90     14     82       0 |   118    11    28   12        0
10:35:59   40     66     18     61       0 |    90    10    21   12        0
10:36:00   41    113    430    653       0 |   103    27    42   15        2
10:36:01   42     94    196    314       0 |   145    14    31   15       12
10:36:02   43     29    886    916       0 |   107    21    38   26       51
10:36:03   44     35   1590   1848       0 |   107    21    38   26       51
10:36:04   45    192   1611   1957       0 |   125    10    24   35       73
10:36:05   46     24   1332   1444       0 |   148    22    46   35       13
Cách nói khi phỏng vấn: "Đây là một cú khựng khoảng hai giây của host. CPU của PHP vẫn cao nhưng số request hoàn thành mỗi giây rơi từ khoảng 90 xuống 29, và p95 vọt lên gần 2 giây. Chỉ một đợt như vậy cũng đủ làm k6 dừng test ở 120 request mỗi giây. Vì thế tôi không bao giờ báo knee từ một lần chạy, mà chạy lặp lại rồi báo theo khoảng."

(5) Lúc gãy thật (round 2, bậc 180) nhìn từ ba phía. Theo từng giây: p95 nhảy lên 1,1–1,4 s và số request hoàn thành tụt xuống 68–92/s. fpm-status: active processes 20/20, listen queue lên 51 rồi 186. Postgres: cả 20 kết nối persistent đều idle ở ClientRead, nghĩa là database đang ngồi chờ PHP gửi query, không phải PHP chờ database.

utc       t    done  p50ms  p95ms  non200 |  fpm%  ngx%   pg%  psi  dockerd%
11:33:00   94    177     44    156       0 |   144    16    31   56       34
11:33:01   95    145     28     88       0 |   147    16    32   56       15
11:33:02   96     92    240    438       0 |   110    32    57   59       24
11:33:03   97     68   1197   1407       0 |   110    32    57   59       24
11:33:04   98    236   1113   1341       0 |   118    20    23   63       26
11:33:05   99    263    829   1074       0 |   168    14    39   63        7
11:33:06  100    253    402    585       0 |   170    13    27   68       25
11:33:07  101    190    227    341       0 |   145    16    30   68       28
Cách nói khi phỏng vấn: "Ở bậc 180, trong khoảng một hai giây p95 tăng từ khoảng 150 mili giây lên hơn một giây, còn số response mỗi giây tụt xuống. Độ dồn ép CPU của PHP lên gần 60 đến 70 phần trăm, tức phần lớn thời gian có tiến trình PHP đang đứng chờ tới lượt CPU. Đó là chữ ký của CPU bão hoà, không phải database chậm."
11:33:01 {"pool":"www","process manager":"static","start time":1793359574,"start since":407,"accepted conn":11079,"listen queue":2,"max listen queue":22,"listen queue len":511,"idle processes":15,"active processes":5,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:03 {"pool":"www","process manager":"static","start time":1793359574,"start since":409,"accepted conn":11323,"listen queue":51,"max listen queue":51,"listen queue len":511,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:05 {"pool":"www","process manager":"static","start time":1793359574,"start since":411,"accepted conn":11747,"listen queue":186,"max listen queue":188,"listen queue len":511,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:07 {"pool":"www","process manager":"static","start time":1793359574,"start since":413,"accepted conn":12246,"listen queue":44,"max listen queue":188,"listen queue len":511,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:33:00 idle Client ClientRead 20;
11:33:02 idle Client ClientRead 20;
11:33:05 idle Client ClientRead 20;
11:33:07 idle Client ClientRead 20;
Cách nói khi phỏng vấn: "Trang status của php-fpm cho thấy cả 20 worker đều bận và hàng đợi ở socket nhảy từ 2 lên 186 chỉ trong bốn giây. Cùng lúc đó, trong pg_stat_activity, cả 20 kết nối đều idle, đang chờ client gửi lệnh. Ghép hai cái lại thì kết luận được ngay: database rảnh và đang chờ PHP, nên muốn scale thì phải thêm CPU cho app chứ không phải nâng database."

(6) Bão hoà và TCP so với unix socket. Run ./run.sh diag bỏ abort, cho TCP và unix socket chạy xen kẽ, mỗi kiểu 2 lần, mỗi bậc 15 s. Goodput khi bão hoà nằm khoảng 210–260 rps. Lần tốt nhất là run fail-unix, đạt 296 response/s ở tải 300 nhưng p95 383 ms. Trong độ nhiễu của host này, TCP và unix không khác nhau rõ về throughput. unix tốn ít hơn khoảng 0,5–1 ms CPU mỗi request. Khác biệt lớn nằm ở cách hỏng: TCP ra 504, unix ra 502 (xem 5.1.5).

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp95 mslỗifpm %ms CPU/req
diag-tcp-1180180.0164.101321506-1439.7
diag-tcp-1240228.5207.51725973640504:71668.8
diag-tcp-1300254.1287.46895993434504:51786.7
diag-unix-1180179.9179.8060312-1559.3
diag-unix-1240239.8228.50137723-1718.1
diag-unix-1300300.5260.705412477502:7441747.2
diag-tcp-2180179.9180.3084378-1448.8
diag-tcp-2240239.9211.303953919-1548.1
diag-tcp-2300195.6220.515666004497504:511537.5
diag-unix-2180179.9180.501547-1227.3
diag-unix-2240239.9213.203911720-1608.0
diag-unix-2300296.6260.1495392649502:9241656.8
flowchart TD
  A["CPU mỗi request ≈ 8–10 ms<br/>(boot ~2,5 + query ~1 + FastCGI/kernel/nginx)"] --> B["2 core = 2000 ms CPU mỗi giây"]
  B --> C["Trần lý thuyết 2000 / 8–10 = 200–250 rps"]
  C --> D["Knee đo được 150–180 rps<br/>CPU 70–79 %, psi 45–68 %"]
  D --> E["Quá knee: 20/20 worker bận,<br/>listen queue 51 → 186, p95 lên giây"]
  E --> F["Postgres idle ClientRead<br/>= DB chờ PHP, CPU app là nút cổ chai"]

5.1.3 Exp2 — max_children 5 / 20 / 80 / 120: thêm worker không có nghĩa là thêm throughput

(1) 5 worker: pool là nút cổ chai. Cả hai round đều chững ở khoảng 85–90 rps. Round 1 p95 lên 736 ms ngay ở bậc 90. Round 2 giữ được bậc 90 (p95 116 ms) nhưng gãy ở 120 (p95 2,3 s). Lúc đó CPU fpm chỉ 81–118 % trên 200 %, tức CPU còn dư. Áp Little's law ngược lại: mỗi worker bị giữ 5 ÷ ~88 ≈ 57 ms cho một request, trong khi request chỉ cần ~10 ms CPU. Phần còn lại là thời gian chờ: vài round-trip tới Postgres (Laravel gửi vài câu lệnh mỗi request), chờ tới lượt CPU, và các cú khựng. Chỉ có 5 slot thì mỗi lần chờ là một slot bị bỏ không.

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp2-db-mc56060.060.001101727-506109.31
exp2-db-mc59090.085.905110736959-8191610.416
exp2-db-mc512034.388.2079764956989-0000.016
exp2-db-mc5-r26060.060.401917139330-7081712.910
exp2-db-mc5-r29090.089.50817116199-92102311.321
exp2-db-mc5-r212070.770.90287209623162344-118132218.539
11:26:03 {"pool":"www","process manager":"static","start time":1793359286,"start since":277,"accepted conn":3087,"listen queue":137,"max listen queue":137,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:26:05 {"pool":"www","process manager":"static","start time":1793359286,"start since":279,"accepted conn":3275,"listen queue":275,"max listen queue":275,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:26:07 {"pool":"www","process manager":"static","start time":1793359286,"start since":281,"accepted conn":3555,"listen queue":153,"max listen queue":275,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
Cách nói khi phỏng vấn: "Với 5 worker, status page cho thấy cả 5 đều bận, không còn worker rảnh, và hàng đợi lên tới 275, trong khi CPU còn gần một nửa. Đây là trường hợp pool quá nhỏ so với thời gian mỗi request giữ worker. Ở trường hợp này tăng worker mới đúng là có tác dụng."

(2) 20 so với 80 worker: knee giống nhau. Với 80 worker, round 1 có p95 29 ms ở bậc 150 (sau một đợt nhiễu 277 ms ở bậc 90) và gãy ở 180, giống 20 worker round 2. Round 2 của 80 worker gãy ngay ở 150 với p95 3,1 s và 584 request in-flight, trong khi 20 worker round 2 ở 150 chỉ có p95 109 ms. Thêm worker không thêm throughput vì 2 core đã là giới hạn. Ngược lại, quá knee thì 80 request runnable chia nhau 2 CPU, nên mọi request cùng chậm. Với 20 worker thì phần dư nằm chờ trong hàng đợi, còn phần đang chạy vẫn xong nhanh. Listen queue của 80 worker cũng lên 430–502, sát độ dài backlog 511.

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp2-db-mc806060.060.101103796-516109.43
exp2-db-mc809090.090.005510277592-86102010.622
exp2-db-mc80120120.0120.10241367191-10311239.516
exp2-db-mc80150150.0150.006112965-12012258.818
exp2-db-mc80180179.9171.60213149721276-13515328.845
exp2-db-mc80210141.3189.60282115715291764-178134810.161
exp2-db-mc80-r26060.060.00516227465-7791914.320
exp2-db-mc80-r29090.090.102620181284-98112312.027
exp2-db-mc80-r2120120.0120.0081999179-115132510.733
exp2-db-mc80-r2150132.4132.1355843631363812-122213010.867
11:40:05 {"pool":"www","process manager":"static","start time":1793359995,"start since":410,"accepted conn":8204,"listen queue":430,"max listen queue":430,"listen queue len":511,"idle processes":0,"active processes":80,"total processes":80,"max active processes":80,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:40:08 {"pool":"www","process manager":"static","start time":1793359995,"start since":413,"accepted conn":8512,"listen queue":478,"max listen queue":502,"listen queue len":511,"idle processes":1,"active processes":79,"total processes":80,"max active processes":80,"max children reached":0,"slow requests":0,"memory peak":8388608}
11:40:10 {"pool":"www","process manager":"static","start time":1793359995,"start since":415,"accepted conn":8637,"listen queue":279,"max listen queue":502,"listen queue len":511,"idle processes":0,"active processes":80,"total processes":80,"max active processes":80,"max children reached":0,"slow requests":0,"memory peak":8388608}
Cách nói khi phỏng vấn: "Tăng lên 80 worker trên cùng hai core thì lúc quá tải cả 80 worker đều bận và hàng đợi vẫn lên tới gần 500. Thêm worker không tạo thêm CPU, nó chỉ cho nhiều request chạy song song hơn và chia nhỏ hai core ra. Kết quả là request nào cũng chậm, thay vì một phần chờ trong hàng còn phần kia xong nhanh."

(3) Cái giá của worker dư: RAM và kết nối Postgres. Đo sau warm-up. Từ 20 lên 80 worker, container php-fpm tăng 170,1 − 62,7 = +107 MiB, Postgres tăng 172,7 − 67,16 = +106 MiB, vì có 80 backend thay vì 20. Tính ra mỗi worker thêm khoảng 107 ÷ 60 ≈ 1,8 MiB ở phía PHP. Con số nhỏ vì endpoint rất nhẹ (Private ~1,5 MiB/worker). App thật thường tốn hàng chục MB mỗi worker. Nếu để memory_limit mặc định 128M thì trường hợp xấu nhất là 128M × 80 ≈ 10 GB so với container 768 MiB. Đó mới là lý do thật không được cấp dư worker. (Lab không đo app nặng. Con số 128M là giá trị mặc định của PHP, chưa verify trong image này.)

fpm workers=6 avg RSS=25.3 MiB avg PSS=6.0 MiB avg Private=1.9 MiB sum PSS=35.7 MiB
fpm container: 35.98MiB / 768MiB  postgres backends(lab db): 5  pg container: 40.02MiB / 512MiB
fpm workers=21 avg RSS=28.4 MiB avg PSS=2.9 MiB avg Private=1.6 MiB sum PSS=60.6 MiB
fpm container: 62.7MiB / 768MiB  postgres backends(lab db): 20  pg container: 67.16MiB / 512MiB
fpm workers=81 avg RSS=28.6 MiB avg PSS=1.9 MiB avg Private=1.5 MiB sum PSS=151.7 MiB
fpm container: 170.1MiB / 768MiB  postgres backends(lab db): 80  pg container: 172.7MiB / 512MiB
fpm workers=121 avg RSS=29.0 MiB avg PSS=1.8 MiB avg Private=1.5 MiB sum PSS=212.6 MiB
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  sorry, too many clients already
fpm container: 241.4MiB / 768MiB  postgres backends(lab db):   pg container: 208.2MiB / 512MiB
Cách nói khi phỏng vấn: "Bốn cấu hình 5, 20, 80 và 120 worker, đo sau warm-up. Bộ nhớ php-fpm và bộ nhớ Postgres tăng gần như tuyến tính theo số worker, vì mỗi worker giữ một kết nối persistent và mỗi kết nối là một backend process bên Postgres. Đến 120 worker thì chính lệnh psql để đếm kết nối cũng bị từ chối, vì Postgres đã đầy."

(4) 120 worker > max_connections 100: 16,7 % request lỗi 500. Run ./run.sh exp2-pgconn chạy không abort. Kết quả: 16,69 % request trả 500 ở mọi mức tải: 16,43 % ở 50 rps, 17,92 % ở 100, 15,96 % ở 150. Con số này khớp gần đúng với tỉ lệ 20 ÷ 120 = 16,7 %. Có 120 worker thì 100 worker lấy được kết nối persistent, 20 worker còn lại lần nào cũng nhận FATAL: sorry, too many clients already, và nginx chia request đều cho các worker. Lỗi trả về nhanh nên client thấy p50 vẫn đẹp. Mỗi lỗi lại ghi nguyên một stack trace, nên log php-fpm của run khoảng 60 s này dài 122 132 dòng.

rps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
5049.941.90291626500:1643951110.52
10099.877.802671423283309500:358105133515.151
150150.4130.40151948611513500:479147145012.369
    http_req_failed................: 16.69% 1001 out of 5996
      { stage:r0050 }..............: 16.43% 164 out of 998
      { stage:r0100 }..............: 17.92% 358 out of 1997
      { stage:r0150 }..............: 15.96% 479 out of 3001
[2026-10-30 10:39:18] production.ERROR: SQLSTATE[08006] [7] connection to server at "tmp-perf-php-pg" (172.23.0.2), port 5432 failed: FATAL:  sorry, too many clients already (Connection: pgsql, Host: tmp-perf-php-pg, Port: 5432, Database: lab, SQL: select id, name, price from products where id = 554
Cách nói khi phỏng vấn: "Cho php-fpm 120 worker trong khi Postgres chỉ cho 100 kết nối, thì khoảng một phần sáu request lỗi 500 ở mọi mức tải, kể cả khi tải rất nhẹ. Tỉ lệ đó đúng bằng 20 trên 120: hai mươi worker không bao giờ lấy được kết nối. Đây là lỗi cấu hình chứ không phải lỗi tải, nên có test tải tới đâu nó cũng không biến mất. Quy tắc của tôi: tổng worker trên mọi node cộng với queue worker và kết nối quản trị phải nhỏ hơn max_connections, hoặc đặt PgBouncer ở giữa."
flowchart LR
  A["max_children trên cùng 2 vCPU"] --> B["5 worker: plateau ~85–90 rps<br/>CPU còn dư → pool là nút cổ chai"]
  A --> C["20 worker: knee 150–180 rps<br/>CPU là nút cổ chai"]
  A --> D["80 worker: knee vẫn 150–180 rps<br/>+107 MiB PHP, +106 MiB Postgres"]
  A --> E["120 worker > max_connections 100<br/>16,7 % request 500 ở mọi mức tải"]

5.1.4 Exp3 — /api/slow (chờ IO 100 ms) và Little's law

(1) Dự đoán trước khi đo. Mỗi request /api/slow giữ worker trong W giây. Khi không tải, W đo được hai cách: k6 p50 ở bậc thấp nhất là 109–112 ms, và %d trong access log của php-fpm cho p50 114 ms. Little's law (định luật Little: số việc đang xử lý = tốc độ đến × thời gian ở trong hệ thống) cho ra throughput tối đa của một pool N worker:

L = λ × W          (số request đang giữ worker = tốc độ × thời gian giữ)
X_max = N / W      (L không vượt quá N worker)
W = 0,112 s:  N=5 -> 44,6 rps | N=10 -> 89,3 rps | N=20 -> 178,6 rps | N=40 -> 357,1 rps
Trần CPU: ~10 ms CPU/request trên 2 core -> 2000 / 10 ≈ 200 rps, bất kể N
Cách nói khi phỏng vấn: "Mỗi request chiếm một worker khoảng 112 mili giây, phần lớn là ngồi chờ database. Có N worker thì mỗi giây xử lý được tối đa N chia cho 0,112: 5 worker khoảng 45, 10 worker khoảng 89, 40 worker khoảng 357. Nhưng mỗi request vẫn tốn khoảng 10 mili giây CPU, nên hai core chặn trên ở khoảng 200 dù có bao nhiêu worker. Tôi tính con số này trước, rồi dùng lab để kiểm xem nó đúng tới đâu."

(2) N = 5: Little's law trúng. Ở round 1, bậc 40 rps có p95 120 ms. Bậc 45 đạt 43,5 response/s, bằng 97,5 % dự đoán 44,6, và p95 nhảy lên 2,07 s vì hàng đợi tăng. Round 2 bị một cú khựng ngay ở 40 rps (chỉ 31,5 response/s) nên gãy sớm hơn. fpm-status lúc gãy: 5/5 active, listen queue 85–89.

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp3-slow-mc52019.920.104112158195-223412.32
exp3-slow-mc53030.030.104109115120-22338.10
exp3-slow-mc54040.040.005111120136-32459.02
exp3-slow-mc54543.643.5010041720692093-386710.310
exp3-slow-mc5-r22019.920.102112127149-203511.50
exp3-slow-mc5-r23030.030.107112144182-304711.41
exp3-slow-mc5-r24031.731.5011612327632840-5691620.515
      { stage:r0030 }..............: avg=110ms    med=109.22ms p(90)=113.18ms p(95)=115.41ms p(99)=119.71ms max=122.12ms count=600 
      { stage:r0040 }..............: avg=112.06ms med=110.53ms p(90)=116.45ms p(95)=120.21ms p(99)=136.15ms max=163.82ms count=800 
      { stage:r0045 }..............: avg=709.96ms med=416.68ms p(90)=2.04s    p(95)=2.06s    p(99)=2.09s    max=2.1s     count=873 
10:42:05 {"pool":"www","process manager":"static","start time":1793356837,"start since":88,"accepted conn":2586,"listen queue":85,"max listen queue":85,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
10:42:07 {"pool":"www","process manager":"static","start time":1793356837,"start since":90,"accepted conn":2677,"listen queue":89,"max listen queue":89,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
10:42:09 {"pool":"www","process manager":"static","start time":1793356837,"start since":92,"accepted conn":2764,"listen queue":61,"max listen queue":94,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
Cách nói khi phỏng vấn: "Với 5 worker, ở 30 và 40 request mỗi giây p95 vẫn khoảng 115 đến 120 mili giây, tức gần bằng thời gian xử lý thuần. Lên 45, sát mức Little's law dự đoán là 44,6, thì p95 nhảy lên khoảng 2 giây và hàng đợi socket lên gần 90. Throughput đo được là 43,5, lệch dự đoán chưa tới 3 phần trăm. Đây là trường hợp pool là nút cổ chai thuần, và công thức dự đoán gần như chính xác."

(3) N = 10 và N = 40: nút cổ chai tiếp theo xuất hiện. N = 10 dự đoán 89 rps. Run tốt nhất (diag-acc, không abort) đạt 79,7 response/s ở tải 80, bằng 90 % dự đoán, với CPU fpm ≤ 76 %. Hai round có abort thì chỉ được 51–58 response/s ở tải 60 vì dính khựng. N = 40 dự đoán 357 rps, nhưng trần CPU chỉ khoảng 200. Đo được 135–142 response/s ở tải 150, p95 2,2–2,7 s. Nghĩa là "thêm worker" chỉ có ích khi worker đang chờ, không có ích khi worker đang tính.

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp3-slow-mc104040.040.005110115121-30458.51
exp3-slow-mc106060.057.6043112753854-5771111.18
exp3-slow-mc108062.676.2076864943968-627119.114
exp3-slow-mc10-r24040.040.009116189255-5271214.76
exp3-slow-mc10-r26051.151.1014315921582414-88112319.527
diag-acc-slow-mc104040.040.108116166222-5071114.14
diag-acc-slow-mc106060.060.109547515781687-76101714.313
diag-acc-slow-mc108080.079.7073440878913-7391510.316
runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp3-slow-mc4010099.9100.2022114147228-798138.713
exp3-slow-mc40150142.3141.9045012126772801-12413219.649
exp3-slow-mc40-r2100100.0100.0038113184477-91101810.115
exp3-slow-mc40-r2150149.9135.2025414122382444-11913239.858
exp3-slow-mc40-r220074.8160.10470225623912429-13112239.063
N workerDự đoán N/W (W = 0,112 s)Mức cao nhất mà p95 còn ổnĐo được khi bão hoà
544,6 rps40 rps (p95 120 ms, r1)43,5 response/s ở tải 45 = 97,5 % dự đoán; r2 khựng ở 40 (31,5/s)
1089,3 rps40 rps (p95 115–189 ms)79,7/s ở tải 80 (diag-acc) = 90 %; r1/r2 51–58/s ở tải 60
20178,6 rps120 rps (p95 440 ms, exp3sat)sụp ở tải 240 (50,9/s, 1692 lỗi 504)
40357,1 rps (trần CPU ≈ 200)100 rps (p95 147–184 ms)135–142/s ở tải 150, p95 2,2–2,7 s

(4) Thời gian thật sự đi đâu. Bật access log của cả php-fpm và nginx (ACCESSLOG=1 ./run.sh diag-acc, N = 10). Thời gian worker giữ request (%d) vẫn ổn định với p50 114–123 ms. Nhưng $request_time của nginx thì p50 lên 155–550 ms. Phần chênh là thời gian nằm trong listen backlog của php-fpm. upstream_connect_time vẫn ≈ 0 ms vì kernel đã hoàn tất bắt tay TCP và đưa kết nối vào backlog, nên nginx tưởng là đã "kết nối xong". Các cửa sổ ~20 s này theo đồng hồ của access log nên chỉ khớp tương đối với các bậc tải.

php-fpm window 0: n=398 fpm_ms p50=114 p95=138 max=617 | cpu_ms p50=10.0 p95=20.0
php-fpm window 1: n=949 fpm_ms p50=123 p95=253 max=1032 | cpu_ms p50=10.0 p95=30.0
php-fpm window 2: n=1357 fpm_ms p50=119 p95=177 max=351 | cpu_ms p50=10.0 p95=20.0
php-fpm window 3: n=925 fpm_ms p50=114 p95=137 max=166 | cpu_ms p50=10.0 p95=20.0
nginx   window 0: n=400 request_time p50=113ms p95=137ms | upstream_connect p50=0ms p95=2ms max=9ms
nginx   window 1: n=954 request_time p50=280ms p95=1563ms | upstream_connect p50=0ms p95=4ms max=56ms
nginx   window 2: n=1367 request_time p50=155ms p95=672ms | upstream_connect p50=0ms p95=2ms max=73ms
nginx   window 3: n=942 request_time p50=550ms p95=893ms | upstream_connect p50=0ms p95=1ms max=38ms
Cách nói khi phỏng vấn: "Tôi so hai đồng hồ. php-fpm báo mỗi request được xử lý khoảng 115 đến 120 mili giây. nginx báo cùng request đó mất 155 đến 550 mili giây. Hiệu số chính là thời gian request nằm chờ trong hàng đợi socket trước khi có worker nhận. Thời gian connect gần bằng 0 vì kernel đã nhận kết nối thay cho php-fpm, nên trên dashboard upstream connect trông vẫn ổn trong khi người dùng đang phải chờ."
12:08:22 active Timeout PgSleep 10;
12:08:24 active Timeout PgSleep 6;12:08:24 idle Client ClientRead 4;
12:08:26 idle Client ClientRead 10;
12:08:28 active Timeout PgSleep 10;
12:08:30 active Timeout PgSleep 10;
12:08:32 active Timeout PgSleep 10;
Cách nói khi phỏng vấn: "Trong lần đẩy N bằng 10 quá sức, pg_stat_activity cho thấy hầu hết các mẫu đều có đủ 10 kết nối active và chờ ở PgSleep. Tức là pool đầy toàn worker đang chờ IO, không phải đang tính. Gặp chữ ký này thì tăng worker, hoặc chuyển phần chờ ra queue, là đúng thuốc, miễn RAM và max_connections còn chỗ. Còn nếu kết nối idle ở ClientRead như exp1 thì thêm worker là sai thuốc."
flowchart TD
  A["Request vào listen backlog của php-fpm"] --> B["Worker rảnh nhận request"]
  B --> C["Giữ worker W ≈ 112 ms<br/>100 ms pg_sleep + ~10 ms CPU"]
  C --> D["Pool tối đa X = N / W<br/>N=5 → 44,6 rps, N=10 → 89 rps"]
  D --> E{"N / W vượt trần CPU 200 rps?"}
  E -->|"không"| F["Pool là nút cổ chai<br/>thêm worker có ích"]
  E -->|"có"| G["CPU là nút cổ chai<br/>N=40 chỉ đạt 135–142 rps"]

5.1.5 Dấu hiệu hỏng nhìn từ phía client

(1) TCP backlog: chậm dần, rồi 504. Khi nginx nối tới php-fpm qua TCP, request dư nằm trong accept queue của kernel. fpm-status thấy được hàng đợi này: listen queue lên tới 512, lớn hơn listen queue len 511 một đơn vị (kernel cho vượt một đơn vị so với backlog, chưa verify bằng đọc source kernel). Client thấy 200 chậm dần. Khi request chờ PHP quá fastcgi_read_timeout 5s thì nhận 504 "while reading response header". Khi backlog đầy, kernel bỏ SYN, nginx connect không xong trong 5 s và trả 504 "while connecting to upstream". Run exp3sat-slow-mc10 có 3029 dòng lỗi gồm cả hai loại. Dòng cuối của khối dưới là một ví dụ nguyên văn lấy từ run N = 5:

--- nginx error log (3029 lines), distinct messages:
   2812 [error] upstream timed out (110: Operation timed out) while reading response header from upstream
    217 [error] upstream timed out (110: Operation timed out) while connecting to upstream
2026/10/30 12:07:19 [error] 30#30: *409 upstream timed out (110: Operation timed out) while connecting to upstream, client: 172.23.0.7, server: , request: "GET /api/slow HTTP/1.1", upstream: "fastcgi://172.23.0.3:9000", host: "tmp-perf-php-nginx"
Cách nói khi phỏng vấn: "Đi qua TCP thì quá tải trông như một cái hàng chờ dài. Request vẫn được nhận, chờ quá 5 giây thì nginx trả 504 với lý do đọc header timeout. Khi hàng chờ của kernel đầy hẳn thì kết nối còn không vào được, và nginx báo 504 với lý do connect timeout. Đọc đúng hai câu này trong error log là biết đang nghẽn ở PHP hay đã tràn backlog."

(2) Sụp throughput khi quá tải: php-fpm làm những request mà nginx đã bỏ. Run ./run.sh exp3sat đẩy /api/slow vượt N/W và không abort. Với N = 5: tải 60 rps chỉ còn 26,4 response/s (670 lỗi 504); tải 90 rps còn 0,0 response/s (1799 lỗi 504). Trong khi đó php-fpm không hề nghỉ. accepted conn tăng từ 1747 lên 2631 trong 20 s của bậc 90, tức (2631 − 1747) ÷ 20 ≈ 44 request/s, đúng bằng N/W = 44,6. Worker vẫn chạy hết tốc lực, nhưng mỗi request nó nhặt ra từ backlog đã nằm đó quá 5 s, nginx đã trả 504 và đóng kết nối, nên toàn bộ công việc là vô ích. Hàng đợi là FIFO, nên khi đã có hơn 5 s request tồn thì mọi request sau đều trễ. Goodput rơi về 0 dù server vẫn bận. Đây là congestion collapse (sụp vì nghẽn).

runrps mục tiêugửi/s200/sk6 droppedin-flight maxp50 msp95 msp99 mslỗifpm %nginx %pg %ms CPU/reqpsi %
exp3sat-slow-mc53029.930.104111120131-26369.80
exp3sat-slow-mc56060.026.40303500150155062504:6704581119.910
exp3sat-slow-mc59090.00.00540500490779126504:179943810nan6
exp3sat-slow-mc106059.960.1013794121402283-76101614.215
exp3sat-slow-mc10120117.765.559600459070288108504:10396991511.915
exp3sat-slow-mc1018099.50.01595600600390899134504:199071815nan17
exp3sat-slow-mc20120119.8119.7064215440539-113132310.538
exp3sat-slow-mc20240135.650.920876005015781692930:5 504:169295202822.754
12:07:21 {"pool":"www","process manager":"static","start time":1793361982,"start since":59,"accepted conn":1747,"listen queue":512,"max listen queue":512,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
12:07:41 {"pool":"www","process manager":"static","start time":1793361982,"start since":79,"accepted conn":2631,"listen queue":424,"max listen queue":512,"listen queue len":511,"idle processes":0,"active processes":5,"total processes":5,"max active processes":5,"max children reached":0,"slow requests":0,"memory peak":8388608}
Cách nói khi phỏng vấn: "Hai mẫu status này cách nhau 20 giây, trong lúc client nhận đúng 0 response thành công. Vậy mà bộ đếm accepted conn vẫn tăng khoảng 44 mỗi giây, đúng bằng năng lực của pool. Nghĩa là PHP vẫn làm việc hết sức, nhưng toàn làm những request mà nginx đã trả 504 và bỏ đi từ trước. Vì thế quá tải kéo dài thì throughput không đứng ở mức trần mà rơi về 0. Cách chữa là cắt bớt tải trước khi nó vào hàng đợi, chứ không phải nới timeout."
sequenceDiagram
  participant C as Client k6
  participant N as nginx
  participant Q as Listen backlog
  participant F as Worker php-fpm
  C->>N: GET /api/slow
  N->>Q: connect thành công, kernel xếp vào hàng
  Note over Q: hàng đợi hơn 5 s vì 5 worker đều bận
  N-->>C: 504 sau fastcgi_read_timeout 5s
  N->>Q: đóng kết nối upstream
  F->>Q: accept request cũ nhất
  F->>F: chạy đủ 112 ms cho request đã bị bỏ
  Note over F: 44 request mỗi giây vẫn được xử lý, goodput bằng 0

(3) Unix socket: 502 ngay lập tức, và status page bị mù. Cùng pool 20 worker nhưng dùng unix socket (./run.sh fail-unix). Khi backlog đầy, connect() non-blocking của nginx trả EAGAIN ngay, và client nhận 502 trong vài mili giây (fail fast: hỏng nhanh). Ở bậc 600, p50 chỉ 4 ms vì phần lớn là 502. Tổng cộng 7604 request bị từ chối, bằng 34,03 %. Nguy hiểm hơn là fpm-status báo listen queue 0 và listen queue len 0 suốt run, trong khi 20/20 worker bận. Các run TCP cùng cấu hình pool thì báo 511 và hàng đợi khác 0. Theo những gì tôi biết về source php-fpm, nó đọc độ dài hàng đợi qua TCP_INFO, nên với unix socket con số luôn là 0 (chưa verify bằng đọc source trong lab này; lab chỉ quan sát được kết quả là 0).

--- nginx error log (7604 lines), distinct messages:
   7604 [error] connect() to unix:/run/php/php-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream
2026/10/30 10:52:55 [error] 30#30: *201 connect() to unix:/run/php/php-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream, client: 172.23.0.6, server: , request: "GET /api/db HTTP/1.1", upstream: "fastcgi://unix:/run/php/php-fpm.sock:", host: "tmp-perf-php-nginx"
10:53:20 {"pool":"www","process manager":"static","start time":1793357355,"start since":245,"accepted conn":14072,"listen queue":0,"max listen queue":0,"listen queue len":0,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
10:53:22 {"pool":"www","process manager":"static","start time":1793357355,"start since":247,"accepted conn":14590,"listen queue":0,"max listen queue":0,"listen queue len":0,"idle processes":0,"active processes":20,"total processes":20,"max active processes":20,"max children reached":0,"slow requests":0,"memory peak":8388608}
Cách nói khi phỏng vấn: "Dùng unix socket, khi hàng đợi đầy thì nginx bị từ chối kết nối ngay và trả 502 với lỗi Resource temporarily unavailable. Run này có hơn bảy nghìn sáu trăm dòng như vậy. Cái bẫy là status page của php-fpm lúc đó báo hàng đợi bằng 0, vì nó không đo được hàng đợi của unix socket. Nếu alert chỉ dựa vào listen queue thì sẽ không bao giờ kêu. Phải alert thêm trên tỉ lệ 502 của nginx và trên số worker active bằng tổng worker."
    http_req_failed................: 34.03% 7604 out of 22343
    dropped_iterations.............: 150    2.422638/s
      { stage:r0400 }..............: 32.48% 1901 out of 5852
      { stage:r0600 }..............: 63.40% 5703 out of 8995
Cách nói khi phỏng vấn: "Tính trên cả run có 34 phần trăm request lỗi. Ở bậc 400 là 32 phần trăm, bậc 600 là 63 phần trăm. Ngoài ra còn 150 request k6 không gửi đi được vì đã hết slot in-flight. So với TCP, unix socket hỏng nhanh và rõ ràng hơn, client biết ngay để retry hoặc báo lỗi, nhưng mất đi tín hiệu hàng đợi trên status page."
flowchart TD
  A["Mọi worker php-fpm đều bận, backlog đầy"] --> B{"nginx nối php-fpm bằng gì?"}
  B -->|"TCP :9000"| C["Kernel giữ trong accept queue;<br/>khi đầy thì bỏ SYN"]
  C --> D["504 'while reading response header'<br/>hoặc 504 'while connecting' sau 5 s"]
  C --> E["fpm-status thấy listen queue 512 / len 511"]
  B -->|"unix socket"| F["connect() trả EAGAIN ngay"]
  F --> G["502 'Resource temporarily unavailable'<br/>trong vài ms"]
  F --> H["fpm-status báo listen queue 0, len 0<br/>= mù"]

(4) Client tự bão hoà: k6 "Insufficient VUs" và dropped iterations. Open model giới hạn số request in-flight ở maxVUs = 600. Theo Little's law, số in-flight = tốc độ × latency. Ở 300 rps, chỉ cần latency đạt 600 ÷ 300 = 2 s là k6 hết VU. Từ đó k6 in cảnh báo và đếm dropped_iterations, tức những request chưa bao giờ tới server. Hệ quả: số lỗi đếm ở phía server (log nginx) thấp hơn thiệt hại thật. Trong diag-tcp-2, bậc 300 rps chỉ gửi được 195,6 rps và có 1566 iteration bị bỏ. Đọc kết quả load test mà bỏ qua dòng này thì sẽ báo "server chịu được" trong khi thật ra là client không bắn đủ.

time="2026-10-30T10:53:01Z" level=warning msg="Insufficient VUs, reached 600 active VUs and cannot initialize more" executor=ramping-arrival-rate scenario=step
    dropped_iterations.............: 1566   25.051628/s
Cách nói khi phỏng vấn: "Khi server chậm lại, số request đang chờ trong k6 tăng theo, đến 600 thì k6 hết slot và bắt đầu bỏ request, in ra cảnh báo Insufficient VUs. Những request bị bỏ không bao giờ tới server, nên log server trông đỡ hơn thực tế. Mỗi lần đọc kết quả load test tôi đều kiểm tra dropped iterations trước, khác 0 thì con số throughput phải đọc lại."

(5) max children reached luôn bằng 0 với pm static. Trong mọi run, kể cả lúc 5/5 hay 80/80 worker bận và hàng đợi 500, bộ đếm này vẫn là 0. Tài liệu ngay trong www.conf.default của PHP 8.5.12 nói rõ nó chỉ được đếm với pm dynamic và ondemand. Ai dựng alert "max children reached > 0" trên pool static thì alert đó không bao giờ kêu.

; Set listen(2) backlog.
; Default Value: 511 (-1 on Linux, FreeBSD and OpenBSD)
;listen.backlog = 511
;   start since          - number of seconds since FPM has started;
;   accepted conn        - the number of request accepted by the pool;
;   listen queue         - the number of request in the queue of pending
;                          connections (see backlog in listen(2));
;   max listen queue     - the maximum number of requests in the queue
;                          of pending connections since FPM has started;
;   listen queue len     - the size of the socket queue of pending connections;
;   idle processes       - the number of idle processes;
;   active processes     - the number of active processes;
;   total processes      - the number of idle + active processes;
;   max active processes - the maximum number of active processes since FPM
;                          has started;
;   max children reached - number of times, the process limit has been reached,
;                          when pm tries to start more children (works only for
;                          pm 'dynamic' and 'ondemand');
; Value are updated in real time.
Cách nói khi phỏng vấn: "Chỉ số max children reached hay được khuyên dùng để biết pool thiếu worker. Nhưng chính file cấu hình mẫu của php-fpm ghi rõ nó chỉ đếm với chế độ dynamic và ondemand. Lab của tôi chạy static, nghẽn tới mức hàng đợi 500 mà chỉ số này vẫn bằng 0. Với pool static, tôi theo dõi active processes so với total processes, và listen queue nếu đi qua TCP."

(6) Postgres hết kết nối (exp2, 120 worker): lỗi 500 trả về nhanh, kèm SQLSTATE[08006] [7] ... FATAL: sorry, too many clients already trong log Laravel, tỉ lệ lỗi gần như cố định theo tỉ lệ worker thừa, không phụ thuộc mức tải (xem 5.1.3).

Client thấyDòng log đặc trưngNguyên nhân trong lab
200 nhưng chậm dần (giây)fpm-status listen queue > 0, active = totalTCP backlog đang tích lại (exp1, exp2, exp3)
504 sau đúng 5 supstream timed out ... while reading response headerchờ trong backlog + xử lý > fastcgi_read_timeout
504 sau 5 supstream timed out ... while connecting to upstreamTCP backlog đầy, SYN bị bỏ
502 tức thìconnect() to unix:... failed (11: Resource temporarily unavailable)unix socket backlog đầy
500 nhanh, tỉ lệ cố địnhFATAL: sorry, too many clients alreadytổng worker > max_connections
Không thấy gì (request không được gửi)k6 Insufficient VUs, dropped_iterationsin-flight chạm maxVUs

5.1.6 Phát hiện và phục hồi: tune cái gì, theo thứ tự nào

(1) Phát hiện: xác định nút cổ chai trước khi đụng vào cấu hình. Có bốn câu hỏi, đọc theo thứ tự. (a) Worker có bận hết không? So active processes với total processes trên fpm-status; listen queue chỉ có ý nghĩa khi đi qua TCP. (b) Nếu bận hết thì worker đang tính hay đang chờ? Xem pg_stat_activity. Kết nối idle ở ClientRead nghĩa là database đang chờ PHP, tức CPU app là nút cổ chai (exp1). Kết nối active ở một wait event (như PgSleep, lock, IO) nghĩa là worker đang chờ, tức pool hoặc database là nút cổ chai (exp3). (c) Core nào chạm 100 %? Một core Postgres ở 93–97 % trong khi app còn dư là exp0. App box ở 70–80 % kèm cpu.pressure 45–70 % là exp1. (d) nginx đang trả lỗi gì? Đọc đúng câu trong error log theo bảng ở 5.1.5. Ngoài ra luôn so $request_time của nginx với thời gian worker (%d): chênh lớn tức là đang xếp hàng.

(2) Thứ tự tune (từ rẻ và tác động lớn đến đắt).

Bước 1: giảm chi phí mỗi request trước. Bật persistent connection hoặc đặt pooler. Trong exp0, chỉ riêng bước này đã đưa knee từ 40–60 lên 150–180 rps, gấp khoảng 3 lần, mà không thêm phần cứng. Opcache, config/route cache cũng thuộc bước này (lab đã bật sẵn, không đo riêng phần đóng góp của chúng).

Bước 2: chỉnh max_children theo loại tải, không theo cảm giác. Với endpoint nặng CPU như /api/db, 20 và 80 worker cho cùng knee, nên chọn mức nhỏ nhất đủ để CPU bận (trong lab là 20), phần dư để nằm trong hàng đợi. Với endpoint nặng chờ IO, N ≈ X mục tiêu × W. Ví dụ muốn 80 rps với W = 0,112 s thì cần 80 × 0,112 ≈ 9 worker, cộng thêm khoảng đệm. Nhưng N bị chặn bởi hai trần: RAM (memory_limit × N phải vừa máy) và Postgres (tổng worker trên mọi node + queue worker + kết nối quản trị < max_connections; exp2 cho thấy vượt là 500 ngay). Công thức tính số worker và bảng so sánh các chế độ pm có trong bài scale nginx + PHP-FPM.

Bước 3: cho hệ thống hỏng nhanh thay vì hỏng chậm. 5.1.5 (2) cho thấy hàng đợi dài hơn timeout của nginx sẽ biến toàn bộ công việc của PHP thành vô ích. Hàng đợi chỉ nên dài cỡ vài trăm ms tới 1–2 s xử lý. Có thể đạt điều đó bằng listen.backlog nhỏ, hoặc dùng unix socket để hỏng nhanh bằng 502. Đặt fastcgi_read_timeout nhỏ hơn timeout của client và hợp với request_terminate_timeout. Lab mới chứng minh hiện tượng (unix socket ra 502 tức thì, TCP để 504 sau 5 s). Hiệu quả của việc giảm listen.backlog trên TCP thì chưa verify.

Bước 4: cắt tải ở cửa vào (load shedding). Giới hạn tốc độ ở nginx (limit_req) hoặc ở API gateway, đặt ngay dưới knee đo được (ví dụ ~150 rps cho cấu hình exp1). Phần vượt nhận 429/503 ngay thay vì kéo cả hệ thống vào collapse. Cách tính ngưỡng và phạm vi limit xem series rate limit. Trong lab này chưa verify hiệu quả của limit_req lên goodput.

Bước 5: scale ngang khi CPU mỗi request đã hết chỗ giảm. Thêm node app sau load balancer. Lưu ý trần max_connections tính trên tổng các node, nên thường phải đặt PgBouncer trước khi thêm node. Các bẫy session, file, cache, scheduler khi scale ngang có trong bài scale nginx + PHP-FPM.

(3) Phục hồi khi đã sụp. Thêm worker vào lúc CPU đang bão hoà làm mọi request cùng chậm hơn (exp2, 80 worker). Nới timeout thì làm hàng đợi dài thêm và PHP càng làm nhiều việc vô ích hơn (5.1.5 (2)). Việc đúng là giảm tải vào: bật rate limit hoặc trả 503 sớm cho phần vượt knee, rồi đợi backlog rút hết. Restart php-fpm sẽ xả sạch backlog nhưng cắt ngang cả request đang chạy. Lab này dừng mỗi run ngay khi gãy nên chưa đo pha phục hồi: chưa đo backlog rút mất bao lâu sau khi giảm tải, và chưa thử restart. Sau sự cố, chạy lại test bậc thang để cập nhật knee, vì knee mới chính là con số capacity cần dùng cho kế hoạch.

flowchart TD
  A["p95 vượt SLO / lỗi 5xx tăng"] --> B{"active = total worker?"}
  B -->|"không"| C["Không phải pool: tìm chỗ chậm<br/>(query, external call)"]
  B -->|"có"| D{"pg_stat_activity?"}
  D -->|"idle ClientRead"| E["CPU app là nút cổ chai<br/>giảm CPU/request, scale ngang"]
  D -->|"active + wait event"| F["Worker đang chờ IO<br/>tăng N theo N = X × W, trong trần RAM và max_connections"]
  A --> G{"Một core Postgres ~100 %?"}
  G -->|"có"| H["Kiểm tra connection per request<br/>bật persistent / PgBouncer"]
  A --> I{"Goodput giảm khi tải tăng?"}
  I -->|"có: collapse"| J["Cắt tải ở cửa vào, hàng đợi ngắn,<br/>không nới timeout, không thêm worker"]

5.1.7 Câu hỏi còn mở và chỗ chưa verify

(1) Các cú khựng 1–3 s chưa rõ nguyên nhân. Trong các cú khựng, php-fpm vẫn đốt CPU (ví dụ ~107 % ở round 1 exp1) nhưng hoàn thành rất ít request. Máy là KVM guest (AMD EPYC 2.0 GHz, 4 vCPU), luôn báo steal = 0, nên không nhìn được tranh chấp ở tầng hypervisor. Vòng lặp hiệu chuẩn CPU trên cpu 2 lúc rảnh ổn định trong ±7 %. Lab Node chạy song song đã được tuần tự hoá bằng flock và đo được chỉ ~1 % CPU trong lúc tôi đo. Vậy nên nguyên nhân chưa xác định. Đây là lý do mọi knee ghi theo khoảng, và trên máy riêng có máy bắn tải tách biệt thì số có thể gọn và cao hơn.

(2) 14 ms so với 10 ms CPU cho /api/slow. Đo tuần tự 300 request cho 14,2 ms CPU php-fpm mỗi request. Đo tay 50 request cho 11,0 ms. %C của php-fpm dưới tải cho p50 10 ms. Tôi chưa giải thích được độ lệch này, nên dùng ~10 ms làm CPU của một request /api/slow trong các phép tính. Tương tự, "ms CPU/req" của /api/db dao động 7–14 ms giữa các run ở cùng mức tải.

(3) Danh sách chưa verify trong 5.1: PgBouncer (chỉ đo persistent PDO); chi phí ~16 ms Postgres mỗi kết nối gồm bao nhiêu phần SCRAM, bao nhiêu phần fork; cơ chế php-fpm đọc hàng đợi qua TCP_INFO (chỉ quan sát được kết quả bằng 0 với unix socket); vì sao kernel cho hàng đợi lên 512 khi backlog là 511; giá trị memory_limit trong image và RAM của worker trong app thật; hiệu quả của limit_req, của việc giảm listen.backlog trên TCP, và của restart php-fpm khi đang collapse; thời gian phục hồi sau khi giảm tải.

5.2 Express trên Node.js

Phần này lặp lại câu hỏi của 5.1 cho một app Express: tăng tải theo từng bậc cho tới khi app vượt SLO, rồi chỉ ra tài nguyên nào hết trước và client nhìn thấy gì khi nó hết. Có ba kiểu endpoint, mỗi kiểu hỏng theo một cách: /db (IO-bound, tức phần lớn thời gian là chờ Postgres), /dbsleep (pool-bound, giữ một connection 20 ms mà gần như không tốn CPU), và /cpu (CPU-bound, chặn event loop khoảng 5,5 ms mỗi request). Muốn đọc phần này cần nắm event loop chạy theo phase thế nào (Event loop Node.js — 6 phase) và chuyện code đồng bộ chặn loop ra sao (Thread pool và chặn event loop).

Thành phầnPhiên bản / cấu hìnhPin CPU
App tmp-perf-node-appNode v26.13.0, Express 5.3.0, pg 8.23.1, pool max=10cpu 2,3 (2 core), 1 GB
DB tmp-perf-node-pgPostgreSQL 18.6, bảng orders 1.000.000 dòng, đã pg_prewarmcpu 1, 512 MB
Load generator tmp-perf-node-k6k6 v2.3.0cpu 0, 1 GB
MáyVPS 4 vCPU AMD EPYC (KVM), dùng chung với lab PHP (chạy tuần tự qua flock)—

Mỗi bậc tải gồm RAMP 8 s (tăng tuyến tính lên mức mới) và HOLD 22 s (giữ nguyên). Chỉ request trong cửa sổ HOLD mới được gắn tag step và phase:hold, nên SLO p95 < 100 ms được chấm riêng cho từng cửa sổ 22 s. Bậc đầu tiên luôn bắt đầu đúng đầu phút, lý do nằm ở 5.2.7 (máy có những cú khựng ở giây đầu mỗi phút). Script, log thô và cách chạy lại nằm ở labs/performance/nodejs/ (bash setup.sh, rồi bash run.sh 1a … 3c, rồi bash cleanup.sh). Mọi khối output dưới đây chép nguyên văn từ labs/performance/nodejs/out/. Giờ trong log k6 và log app là UTC, giờ trong *.dockerstats.log là giờ máy (UTC+1).

// app/server.js (trích) — ba endpoint và cấu hình pool
const pool = new Pool({ /* host, user, ... */
  max: POOL_MAX,                       // 10
  idleTimeoutMillis: 0,                // không đóng connection rảnh (xem 5.2.7)
  connectionTimeoutMillis: Number(process.env.PG_CONN_TIMEOUT_MS || 3000),
});
// mở sẵn đủ 10 connection lúc khởi động (pg không tự tạo tới `min`)
Promise.all(Array.from({ length: POOL_MAX }, () => pool.query('SELECT 1')));

app.get('/cpu', (req, res) => {        // CPU-bound: chặn event loop ~5,5 ms
  const key = crypto.pbkdf2Sync('password', 'salt', PBKDF2_ITER, 32, 'sha256');
  res.json({ pid: process.pid, k: key.toString('hex', 0, 4) });
});
app.get('/db', (req, res) => runQuery(res,  // IO-bound: Postgres làm phần việc CPU
  'SELECT count(*) AS n, sum(amount) AS total, max(created_at) AS last FROM orders WHERE customer_id = $1',
  [1 + Math.floor(Math.random() * 1000)]));
app.get('/dbsleep', (req, res) => runQuery(res, 'SELECT pg_sleep(0.02) AS slept', [])); // pool-bound
Cách nói khi phỏng vấn: "Tôi dựng ba endpoint để mỗi cái bị nghẽn ở một chỗ khác nhau. Endpoint CPU dùng hàm băm mật khẩu bản đồng bộ, nên trong khoảng năm mili giây nó giữ luôn luồng chính và không request nào khác được xử lý. Endpoint DB chạy một câu tổng hợp, phần nặng nằm ở Postgres. Endpoint còn lại chỉ ngủ hai mươi mili giây trong Postgres, nên thứ duy nhất nó tiêu là một chỗ trong pool mười connection. Tôi cũng mở sẵn đủ mười connection lúc khởi động để lúc tải tăng app không phải đi bắt tay connection mới."
// k6/breakpoint.js (trích) — open model, SLO chấm theo từng cửa sổ HOLD
const scenario = MODEL === 'open'
  ? { executor: 'ramping-arrival-rate', startRate: 0, timeUnit: '1s', startTime: `${WARMUP}s`,
      preAllocatedVUs: Number(__ENV.PRE_VUS || 100), maxVUs: Number(__ENV.MAX_VUS || 300), stages }
  : { executor: 'ramping-vus', startVUs: 0, startTime: `${WARMUP}s`, stages, gracefulRampDown: '5s' };
const thresholds = {
  'http_req_failed{phase:hold}':   [{ threshold: 'rate<0.01', abortOnFail: ABORT, delayAbortEval: ABORT_DELAY }],
  'http_req_duration{phase:hold}': [{ threshold: `p(95)<${SLO}`, abortOnFail: ABORT, delayAbortEval: ABORT_DELAY }],
};
STEPS.forEach((_, i) => {   // sub-metric theo từng bậc, không abort, chỉ để in p95 từng bậc
  thresholds[`http_req_duration{step:${stepName(i)}}`] = [`p(95)<${SLO}`];
});
Cách nói khi phỏng vấn: "Tôi dùng open model, tức là k6 bắn theo lịch số request mỗi giây, không cần đợi response trước rồi mới gửi tiếp. Mỗi bậc tôi gắn tag riêng để đọc được p95 của từng bậc, còn ngưỡng dừng test chỉ nhìn vào các cửa sổ giữ tải ổn định. Ngưỡng dừng chỉ là phanh an toàn. Muốn biết điểm gãy ở đâu thì đọc p95 từng bậc, không đọc thời điểm test bị dừng."
flowchart TD
  K6["k6 (cpu 0)<br/>ramping-arrival-rate"] --> APP["Express app (cpu 2,3)<br/>1 process hoặc cluster 2 worker"]
  APP --> POOL["pg Pool max=10<br/>hàng đợi waitingCount"]
  POOL --> PG["PostgreSQL 18.6 (cpu 1)"]
  APP -.-> MET["cửa sổ 2 s: ELD p99, ELU,<br/>cpu_cores, pool_waiting_max"]

Bảng tóm tắt điểm gãy (knee, điểm từ đó latency tăng vọt). "Bậc cuối đạt SLO → bậc đầu vỡ" là kết quả lần chạy chính thức. Cột cuối là khoảng dao động qua các lần chạy thử.

Thí nghiệmBậc cuối đạt SLOBậc đầu vỡ SLONút thắtDao động qua các lần chạy
1a /db, pool 10150 req/s (p95 31,4 ms)200 req/s (p95 572,0 ms)CPU Postgres (1 core)vỡ ở 200–275
1b /dbsleep, pool 10300 req/s (p95 41,0 ms)350 req/s (p95 193,7 ms)10 connection của poolvỡ ở 350–450
1c /dbsleep, timeout pool 1 s300 req/s550 req/s → HTTP 500hàng đợi pool quá 1 s—
2a /cpu, 1 process150 req/s (p95 37,3 ms)175 req/s (p95 800,1 ms)1 core, event loopvỡ ở 100–175
2b /cpu, cluster 2 worker200 req/s (p95 35,6 ms)250 req/s (p95 215,6 ms)2 corevỡ ở 200–250
3 closed vs open, /cpuclosed 60 VU: 169,3 req/s, 0 lỗi, 0 dropped. Open 200–300 req/s: 10,79–25,60% lỗi, 2.574 dropped, 3.834 timeout

5.2.1 Thí nghiệm 1a — /db IO-bound, pool max=10: Postgres gãy trước, event loop vẫn rảnh

exp_1a() { # IO-bound /db: Postgres does ~1-2 ms CPU per query, pool max=10
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1; pg_prewarm
  local x0; x0=$(pg_xact)
  k6run 1a-db-open breakpoint.js ENDPOINT=/db MODEL=open \
    STEPS=100,150,200,225,250,275,300 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
  echo "pg xact_commit delta=$(( $(pg_xact) - x0 ))" | tee -a "$OUT/1a-db-open.k6.log"
}
Cách nói khi phỏng vấn: "Thí nghiệm này chỉ có một process Node, pool mười connection, và trước khi chạy tôi nạp sẵn bảng cùng index vào bộ nhớ đệm của Postgres để không đo nhầm thời gian đọc đĩa. Tải tăng theo bậc từ một trăm lên ba trăm request mỗi giây. Cuối test tôi đếm số transaction Postgres đã commit để chắc là tải thật sự xuống tới database."
  1. Kết quả. Bậc 100 req/s có p95 9,6 ms. Bậc 150 req/s có p95 31,4 ms, vẫn đạt SLO. Sang 200 req/s thì p50 lên 46,3 ms, p95 572,0 ms, k6 dừng test lúc 11:09:15Z. Không có request nào lỗi (http_req_failed 0,00%): app chỉ chậm đi, không trả lỗi. Bậc 200 chỉ có 1.283 request vì abort xảy ra khoảng 7 s sau khi cửa sổ HOLD bắt đầu, nên cột "done/s" của analyze.py (đếm trên 22 s) ra 58,3 là thấp giả, đừng đọc nó như throughput thật.

  2. Nút thắt là CPU của Postgres, không phải event loop. Ở bậc 200, container Postgres dùng 0,78–0,87 core trên đúng 1 core được cấp, và core 1 của máy bận 79–88% (core này còn chia với tiến trình khác trên host không pin CPU). Cùng lúc đó Node chỉ có ELU (event loop utilization, tỉ lệ thời gian loop đang bận) 0,30–0,56 và dùng 0,36–0,62 core. Nghĩa là event loop còn rảnh gần một nửa thời gian, nó chỉ ngồi chờ kết quả query.

  3. Hàng đợi pool chỉ là chỗ triệu chứng lộ ra. pool_waiting_max (số request phải xếp hàng chờ connection, lấy max trong cửa sổ 2 s) là 0–3 ở bậc 150, nhảy lên 85 rồi 100 ở bậc 200, và số request đang xử lý dở (inflight_max) lên 96–111. Nhìn dashboard thấy pool đầy rất dễ kết luận "tăng pool lên". Nhưng pool đầy chỉ vì từng query giữ connection lâu hơn, và query lâu hơn vì Postgres đang hết CPU. Tăng pool lên 20 thì có thêm 10 query cùng tranh 1 core, nên hàng đợi chỉ dời từ trong Node sang trong Postgres.

  4. Tính trần bằng số đo. Ở 100 req/s, Postgres dùng 0,29–0,33 core, tức khoảng 2,9–3,3 ms CPU cho mỗi query (EXPLAIN ANALYZE riêng lẻ chỉ báo 1,9 ms; phần chênh có lẽ là protocol, parse, commit, nhưng chưa tách được, chưa verify). Một core chạy được tối đa khoảng 1000 / 3–3,5 ≈ 285–330 query/s. Ở 200 req/s, mức sử dụng tính theo lý thuyết khoảng 0,6–0,7, cộng thêm tải lạ trên core 1 thì thành 79–88% như đo được. Ở mức sử dụng đó, thời gian xếp hàng tăng theo kiểu ρ/(1−ρ) (ρ là mức sử dụng; xấp xỉ M/M/1, chỉ dùng để hình dung): ρ = 0,85 thì thời gian chờ đã gấp khoảng 5,7 lần thời gian phục vụ. Vì vậy latency vỡ khi CPU còn chưa chạm 100%.

CPU Postgres mỗi query ≈ 0,29–0,33 core ÷ 100 query/s ≈ 2,9–3,3 ms
Trần 1 core           ≈ 1000 ms ÷ 3–3,5 ms ≈ 285–330 query/s
Trần pool             ≈ 10 connection ÷ ~4 ms giữ mỗi query ≈ 2.500 query/s   → pool không phải giới hạn
Thời gian chờ (M/M/1) ≈ ρ/(1−ρ) × thời gian phục vụ; ρ = 0,85 → ≈ 5,7×
Cách nói khi phỏng vấn: "Tôi lấy số core Postgres đang dùng chia cho số query mỗi giây thì ra khoảng ba mili giây CPU cho một query. Một core vì vậy chỉ gánh được khoảng ba trăm query mỗi giây. Pool mười connection thì gánh được cỡ hai nghìn rưỡi, nên pool không phải chỗ hẹp. Latency vỡ ở hai trăm, tức trước khi CPU chạm trần, vì khi một tài nguyên bận khoảng tám mươi lăm phần trăm thì thời gian xếp hàng đã gấp mấy lần thời gian xử lý."
# out/1a-db-open.k6.log — p95 từng bậc, lỗi, lý do abort
      { step:02_rps150 }...........: avg=9.51ms   med=4.84ms  p(90)=15.4ms   p(95)=31.37ms  p(99)=93.98ms  max=339.76ms
      { step:03_rps200 }...........: avg=154.26ms med=46.3ms  p(90)=482.15ms p(95)=572.04ms p(99)=667.84ms max=790.97ms
      { step:03_rps200 }...........: 0.00%  0 out of 1283
time="2026-10-30T11:09:15Z" level=error msg="thresholds on metrics 'http_req_duration{phase:hold}, http_req_duration{step:03_rps200}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
# out/1a-db-open.app.log — bậc 150 (11:08:59) rồi bậc 200 (11:09:05, 11:09:15)
{"t":"2026-10-30T11:08:59.058Z","pid":1,"rps":150,"err5xx":0,"eld_p50_ms":0.34999999999999964,"eld_p99_ms":15.23,"eld_max_ms":21.21,"elu":0.23,"cpu_cores":0.26,"cpu_ms_per_req":1.74,"pool_total":10,"pool_idle":9,"pool_waiting":0,"pool_waiting_max":3,"inflight":1,"inflight_max":14,"rss_mb":82}
{"t":"2026-10-30T11:09:05.072Z","pid":1,"rps":149,"err5xx":0,"eld_p50_ms":1.7699999999999996,"eld_p99_ms":86.14,"eld_max_ms":252.01,"elu":0.56,"cpu_cores":0.62,"cpu_ms_per_req":4.17,"pool_total":10,"pool_idle":0,"pool_waiting":59,"pool_waiting_max":85,"inflight":69,"inflight_max":96,"rss_mb":84}
{"t":"2026-10-30T11:09:15.073Z","pid":1,"rps":166,"err5xx":0,"eld_p50_ms":1.3699999999999992,"eld_p99_ms":65.3,"eld_max_ms":319.52,"elu":0.49,"cpu_cores":0.52,"cpu_ms_per_req":3.15,"pool_total":10,"pool_idle":0,"pool_waiting":48,"pool_waiting_max":100,"inflight":58,"inflight_max":111,"rss_mb":86}
# out/1a-db-open.dockerstats.log — cgroup cores (1.00 = 1 core) + % bận từng CPU của host
12:08:58 cores node-app=0.26 node-k6=0.23 node-pg=0.55 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=24 cpu1=56 cpu2=18 cpu3=18
12:09:13 cores node-app=0.37 node-k6=0.43 node-pg=0.86 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=39 cpu1=88 cpu2=25 cpu3=20
12:09:15 cores node-app=0.52 node-k6=0.44 node-pg=0.87 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=39 cpu1=83 cpu2=31 cpu3=20
Cách nói khi phỏng vấn: "Tôi đọc ba nguồn cùng một khoảnh khắc. Phía k6 thấy p95 từ ba mươi mốt mili giây vọt lên gần sáu trăm, không lỗi nào. Phía app thấy hàng chờ pool từ không lên một trăm, nhưng event loop chỉ bận khoảng một nửa thời gian. Phía cgroup thấy Postgres dùng gần trọn core của nó. Ghép ba nguồn lại thì chỗ hẹp nằm ở database, còn Node chỉ là chỗ hàng đợi hiện ra."
flowchart TD
  A["k6: 200 req/s"] --> B["Express event loop<br/>ELU 0,30–0,56 (còn rảnh)"]
  B --> C["pg Pool 10 connection<br/>waiting 0 → 85–100"]
  C --> D["Postgres 1 core<br/>0,78–0,87 core, cpu1 79–88%"]
  D --> E["query lâu hơn → giữ connection lâu hơn<br/>→ p95 31,4 → 572,0 ms"]
  style D fill:#7a1f1f,stroke:#ff6b6b,color:#fff

Qua các lần chạy, bậc vỡ đầu tiên của 1a dao động từ 200 tới 275 req/s. Ở attempts-v1, bậc 250 vẫn đạt (p95 36,6 ms) và tới 275 mới vỡ. Lý do là core 1 của Postgres chia với tiến trình khác trên host, nên dư địa CPU đổi theo từng phút. Bài học khi báo cáo cho team: đưa ra một khoảng, không đưa một con số.

5.2.2 Thí nghiệm 1b — /dbsleep 20 ms: giới hạn là pool, kiểm bằng Little's law

exp_1b() { # pool-bound /dbsleep: 20 ms pg_sleep, ~no Postgres CPU -> ceiling = 10 conns / 20 ms
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1
  k6run 1b-dbsleep-open breakpoint.js ENDPOINT=/dbsleep MODEL=open \
    STEPS=200,300,350,400,450,500,550 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
}
Cách nói khi phỏng vấn: "Tôi tách riêng ảnh hưởng của pool bằng một query chỉ ngủ hai mươi mili giây trong Postgres. Nó giữ connection nhưng gần như không tốn CPU của ai, nên chỉ còn số connection là giới hạn. Nhờ vậy tính được trần lý thuyết trước khi chạy, rồi so với số đo."
  1. Dự đoán bằng Little's law (định luật Little: số việc đang nằm trong hệ thống = tốc độ đến × thời gian mỗi việc ở trong hệ thống, L = λ × W). Pool có N = 10 chỗ, mỗi request giữ một chỗ W giây, nên tốc độ tối đa là λ_max = N / W. Với W = 20 ms thì trần là 10 / 0,020 = 500 req/s. Nhưng thực tế W không phải 20 ms: thêm round-trip mạng Docker, parse và trả connection về pool, nên ở 300 req/s p50 là 22,3 ms. Ở 550 req/s (thí nghiệm 1c) pool phục vụ đều khoảng 430/s, suy ra W ≈ 10 / 430 ≈ 23 ms, và trần thực tế khoảng 430–480 req/s chứ không phải 500.

  2. Kết quả. 200 req/s: p95 25,5 ms. 300 req/s: p50 22,3 ms (gần đúng 20 ms ngủ), p95 41,0 ms. 350 req/s: p50 122,3 ms, p95 193,7 ms, k6 dừng test lúc 11:15:13Z. Postgres chỉ dùng 0,15–0,35 core, Node có ELU ≤ 0,50. Thứ duy nhất bão hoà là 10 connection: pool_waiting_max lên 91, 108 rồi 132.

  3. Vì sao vỡ ở 350 trong khi trần là ~430? Theo Little's law, ở 350 req/s số connection đang bận là 350 × 0,023 ≈ 8 trên 10, tức ρ ≈ 0,8. Ở mức đó hàng đợi bình thường đã dài ra, mà đầu phút 11:15:00 máy lại khựng (eld_max 320 ms trong cửa sổ RAMP). Trong khoảng khựng đó request vẫn đến nhưng không ai lấy ra xử lý, nên dồn thành 132 request chờ. Phần dư công suất chỉ khoảng 430 − 350 ≈ 80 req/s, nên phải mất vài giây mới xả hết: app phục vụ 373–387 req/s trong lúc xả, cao hơn mức đến. Những giây xả đó rơi vào cửa sổ HOLD và đẩy p95 qua 100 ms. Đó là hành vi thật của một hệ không còn dư địa, nhưng cũng là lý do điểm gãy lệch ±1 bậc giữa các lần chạy (350–450).

  4. Điểm đối chứng. Ở attempts-v5 (cùng app, SLO 100 ms), bậc 450 req/s được phục vụ đủ 449–453 req/s nhưng p50 đã là 40,6 ms, tức khoảng 2 lần thời gian phục vụ, và p95 142,7 ms. Pool vẫn kéo được throughput gần trần, nhưng latency đã trả giá cho việc xếp hàng. Đây là ví dụ rõ cho câu "throughput chưa chạm trần không có nghĩa là còn đạt SLO".

L = λ × W                      (Little's law)
λ_max = N ÷ W = 10 ÷ 0,020 s   = 500 req/s   (lý thuyết, chỉ tính 20 ms ngủ)
W thực ≈ 10 ÷ ~430 req/s       ≈ 23 ms       → λ_max thực ≈ 430–480 req/s
Ở 350 req/s: L = 350 × 0,023   ≈ 8 connection bận / 10  → ρ ≈ 0,8
Cách nói khi phỏng vấn: "Pool mười connection mà mỗi request giữ một connection hai mươi mili giây thì tối đa được năm trăm request mỗi giây. Nhưng đo thật thì mỗi lần giữ khoảng hai mươi ba mili giây, nên trần thật chỉ khoảng bốn trăm ba mươi. Ở ba trăm năm mươi, tám trên mười connection đã bận thường xuyên, nên chỉ cần máy khựng nửa giây là hàng đợi dồn lên hơn một trăm và phải mất vài giây mới xả hết. Tôi dùng chính công thức này để tính cỡ pool: lấy tải mục tiêu nhân thời gian giữ connection rồi chừa dư địa để pool không thường xuyên bận quá khoảng bảy mươi phần trăm."
# out/1b-dbsleep-open.k6.log
      { step:02_rps300 }...........: avg=25.1ms   med=22.27ms  p(90)=29.8ms   p(95)=40.98ms  p(99)=70.9ms   max=151.6ms 
      { step:03_rps350 }...........: avg=121.28ms med=122.31ms p(90)=179.06ms p(95)=193.74ms p(99)=228.68ms max=258.8ms 
time="2026-10-30T11:15:13Z" level=error msg="thresholds on metrics 'http_req_duration{phase:hold}, http_req_duration{step:03_rps350}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
# out/1b-dbsleep-open.app.log — 300 req/s sạch, rồi cú khựng đầu phút 11:15:00 và hàng đợi pool
{"t":"2026-10-30T11:14:52.188Z","pid":1,"rps":300,"err5xx":0,"eld_p50_ms":0.25,"eld_p99_ms":3.8499999999999996,"eld_max_ms":6.890000000000001,"elu":0.3,"cpu_cores":0.33,"cpu_ms_per_req":1.1,"pool_total":10,"pool_idle":4,"pool_waiting":0,"pool_waiting_max":2,"inflight":6,"inflight_max":13,"rss_mb":82}
{"t":"2026-10-30T11:15:04.246Z","pid":1,"rps":139,"err5xx":0,"eld_p50_ms":4.52,"eld_p99_ms":265.78,"eld_max_ms":320.04,"elu":0.46,"cpu_cores":0.61,"cpu_ms_per_req":4.38,"pool_total":10,"pool_idle":0,"pool_waiting":106,"pool_waiting_max":108,"inflight":116,"inflight_max":119,"rss_mb":83}
{"t":"2026-10-30T11:15:06.258Z","pid":1,"rps":253,"err5xx":0,"eld_p50_ms":1.3800000000000008,"eld_p99_ms":100.82,"eld_max_ms":256.08,"elu":0.5,"cpu_cores":0.55,"cpu_ms_per_req":2.2,"pool_total":10,"pool_idle":0,"pool_waiting":133,"pool_waiting_max":132,"inflight":143,"inflight_max":143,"rss_mb":86}
{"t":"2026-10-30T11:15:08.262Z","pid":1,"rps":387,"err5xx":0,"eld_p50_ms":0.7300000000000004,"eld_p99_ms":17.51,"eld_max_ms":29.939999999999998,"elu":0.4,"cpu_cores":0.46,"cpu_ms_per_req":1.19,"pool_total":10,"pool_idle":0,"pool_waiting":47,"pool_waiting_max":132,"inflight":57,"inflight_max":140,"rss_mb":90}
# out/1b-dbsleep-open.dockerstats.log — Postgres gần như rảnh
12:15:02 cores node-app=0.75 node-k6=0.64 node-pg=0.35 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=47 cpu1=25 cpu2=41 cpu3=22
Cách nói khi phỏng vấn: "Log cho thấy ở ba trăm request mỗi giây pool còn ba bốn connection rảnh và không ai phải chờ. Ngay sau đầu phút, event loop khựng ba trăm hai mươi mili giây, hàng chờ pool lên một trăm ba mươi hai, rồi app phục vụ gần bốn trăm mỗi giây để xả. Trong lúc đó Postgres chỉ dùng một phần ba core. Nghẽn ở đây là số connection, không phải CPU database."
flowchart TD
  A["350 req/s đến"] --> B["pg Pool: 10 chỗ<br/>mỗi request giữ ~23 ms"]
  B --> C["trần ≈ 10 ÷ 0,023 ≈ 430 req/s<br/>ρ ≈ 0,8"]
  C --> D["khựng đầu phút 320 ms<br/>→ dồn 132 request chờ"]
  D --> E["dư chỉ ~80 req/s để xả<br/>→ p95 HOLD 193,7 ms"]
  style B fill:#7a1f1f,stroke:#ff6b6b,color:#fff

5.2.3 Thí nghiệm 1c — timeout pool 1 s ở 550 req/s: HTTP 500 "timeout exceeded when trying to connect"

exp_1c() { # failure mode: pool queue wait capped at 1 s -> 5xx "timeout exceeded when trying to connect"
  # Offered 550/s > pool ceiling (10 conns / 20 ms = 500/s). Latency SLO relaxed to 2 s so
  # that the abort comes from the http_req_failed rate<0.01 threshold (5xx), not from p95.
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1 -e PG_CONN_TIMEOUT_MS=1000
  k6run 1c-dbsleep-pooltimeout breakpoint.js ENDPOINT=/dbsleep MODEL=open \
    STEPS=300,550,600 SLO_P95=2000 PRE_VUS=100 MAX_VUS=400
}
Cách nói khi phỏng vấn: "Tôi cố tình bắn vượt trần pool và đặt thời gian chờ connection tối đa một giây. Ngưỡng latency được nới lên hai giây để test dừng vì tỉ lệ lỗi chứ không vì p95. Mục đích là thấy tận mắt app trả lỗi gì khi hàng đợi pool quá dài, và lỗi xuất hiện ở thời điểm nào."
  1. Kết quả. Bậc 300 sạch. Bậc 550 req/s: k6 hoàn tất 432,9 req/s, thành công 429,0 req/s, p50 892,0 ms, p95 1.016,8 ms, 0,90% lỗi (86/9.523). Bậc 600: 18,86% lỗi, và ngưỡng http_req_failed{phase:hold} dừng test lúc 12:05:11Z. Phía app, hàng chờ pool (pool_waiting_max) tăng đều 200 → 255 → 329 → 389, rồi các dòng lỗi timeout exceeded when trying to connect xuất hiện: 59, sau đó 252 và 196 trong một cửa sổ 2 s.

  2. Little's law đoán đúng p50. Hàng đợi dừng ở 389 vì k6 chỉ có tối đa 400 VU, tức tối đa 400 request đang bay (k6 báo Insufficient VUs và bỏ 6.168 iteration). Có L ≈ 400 request trong hệ thống và λ ≈ 430 req/s đi ra thì mỗi request nằm trong hệ thống W = L / λ ≈ 400 / 430 ≈ 0,93 s. Đo được p50 là 892 ms. Vì 0,93 s nằm ngay dưới mốc 1 s connectionTimeoutMillis, chỉ phần đuôi phân phối vượt mốc và nhận 500, nên tỉ lệ lỗi ở bậc 550 mới chỉ 0,90%.

  3. Điều suy ra cho production (suy luận, chưa verify bằng lab). Con số 400 ở đây là trần do công cụ test đặt ra. Người dùng thật không bị giới hạn kiểu đó: nếu không có gì chặn trước, hàng đợi sẽ dài quá 1 s và gần như mọi request đều chờ đủ 1 s rồi mới nhận 500. Đó là kiểu hỏng tệ nhất: tốn tài nguyên chờ mà vẫn thất bại. Vì vậy connectionTimeoutMillis nên được coi là một nút load shedding (chủ động từ chối bớt tải để phần còn lại vẫn chạy được): đặt nhỏ hơn SLO thì app báo lỗi sớm và trả tài nguyên cho request khác. Nên trả 503 kèm Retry-After thay vì 500 như app lab đang làm, việc này chưa test.

  4. Client thấy gì. Với k6 đây là HTTP 500 bình thường, chỉ hiện trong tỉ lệ http_req_failed. k6 không in dòng warning nào, vì nó chỉ log lỗi ở tầng transport. Phải đọc log app mới biết lỗi đến từ pool. Không thấy ECONNRESET hay connection refused ở bất kỳ lần chạy nào: Node vẫn nhận kết nối bình thường, và quá tải chỉ thể hiện bằng việc xếp hàng.

W = L ÷ λ ≈ 400 request đang bay ÷ ~430 req/s ≈ 0,93 s   (đo: p50 892 ms)
0,93 s < connectionTimeoutMillis 1 s → chỉ phần đuôi bị 500 (0,90% ở bậc 550, 18,86% ở bậc 600)
Cách nói khi phỏng vấn: "Khi tải vượt trần pool, thời gian chờ bằng độ dài hàng đợi chia cho tốc độ phục vụ. Bốn trăm request chia cho bốn trăm ba mươi mỗi giây ra gần một giây, và p50 đo được là tám trăm chín mươi hai mili giây, khớp. Mốc timeout pool một giây nằm ngay trên con số đó nên chỉ phần đuôi bị lỗi. Ở production, nếu số người dùng không bị giới hạn thì gần như ai cũng chờ đủ một giây rồi mới nhận lỗi. Vì vậy tôi đặt timeout pool như một chốt chặn tải, ngắn hơn SLO."
# out/1c-dbsleep-pooltimeout.k6.log
      { step:02_rps550 }...........: avg=846.65ms med=892ms    p(90)=1s       p(95)=1.01s    p(99)=1.04s    max=1.15s   
      { step:02_rps550 }...........: 0.90%  86 out of 9523
      { step:03_rps600 }...........: 18.86% 136 out of 721
    dropped_iterations.............: 6168   68.577897/s
time="2026-10-30T12:04:45Z" level=warning msg="Insufficient VUs, reached 400 active VUs and cannot initialize more" executor=ramping-arrival-rate scenario=breakpoint
time="2026-10-30T12:05:11Z" level=error msg="thresholds on metrics 'http_req_failed{phase:hold}, http_req_failed{step:03_rps600}' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
# out/1c-dbsleep-pooltimeout.app.log — hàng chờ pool chạm 389, rồi 500 từ pool
{"t":"2026-10-30T12:04:45.683Z","pid":1,"rps":436,"err5xx":0,"eld_p50_ms":0.3200000000000003,"eld_p99_ms":7.510000000000002,"eld_max_ms":11.18,"elu":0.38,"cpu_cores":0.44,"cpu_ms_per_req":1.01,"pool_total":10,"pool_idle":0,"pool_waiting":390,"pool_waiting_max":389,"inflight":400,"inflight_max":400,"rss_mb":93}
{"t":"2026-10-30T12:04:47.683Z","pid":1,"rps":403,"err5xx":59,"eld_p50_ms":0.4800000000000004,"eld_p99_ms":29.85,"eld_max_ms":70.94,"elu":0.49,"cpu_cores":0.56,"cpu_ms_per_req":1.4,"pool_total":10,"pool_idle":0,"pool_waiting":388,"pool_waiting_max":389,"inflight":398,"inflight_max":400,"rss_mb":94,"errors":{"timeout exceeded when trying to connect":59}}
{"t":"2026-10-30T12:05:03.331Z","pid":1,"rps":488,"err5xx":252,"eld_p50_ms":0.6199999999999992,"eld_p99_ms":18.46,"eld_max_ms":677.34,"elu":0.66,"cpu_cores":1.2,"cpu_ms_per_req":2.45,"pool_total":10,"pool_idle":0,"pool_waiting":135,"pool_waiting_max":389,"inflight":145,"inflight_max":400,"rss_mb":102,"errors":{"timeout exceeded when trying to connect":252}}
Cách nói khi phỏng vấn: "Log k6 cho thấy bậc năm trăm năm mươi có p50 gần chín trăm mili giây, gần một phần trăm lỗi, và k6 đã hết VU nên phải bỏ hơn sáu nghìn iteration. Log app cho thấy đúng lúc đó hàng chờ pool chạm ba trăm tám mươi chín, và lỗi trả về là pool hết thời gian chờ connection. Hai log khớp nhau, nên tôi chắc lỗi năm trăm này đến từ pool chứ không từ Postgres."
flowchart TD
  A["550 req/s đến"] --> B["pool 10 connection<br/>phục vụ ~430 req/s"]
  B --> C["hàng chờ 200 → 389<br/>(trần 400 VU của k6)"]
  C --> D{"chờ connection > 1 s?"}
  D -- "không (đa số)" --> E["200 OK, p50 892 ms"]
  D -- "có (phần đuôi)" --> F["HTTP 500<br/>timeout exceeded when trying to connect"]
  style F fill:#7a1f1f,stroke:#ff6b6b,color:#fff

5.2.4 Thí nghiệm 2a — /cpu một process: một core 100%, core kia ngồi chơi

exp_2a() { # CPU-bound /cpu, single process (2 cores allowed)
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1
  k6run 2a-cpu-single-open breakpoint.js ENDPOINT=/cpu MODEL=open \
    STEPS=50,100,125,150,175,200,250 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
}
Cách nói khi phỏng vấn: "Container được cấp hai core nhưng chỉ chạy một process Node. Tôi muốn xem khi handler đồng bộ ăn CPU thì một process dùng được bao nhiêu trong hai core đó, và khi quá tải thì hàng đợi nằm ở đâu."
  1. Trần tính trước. Microbench pbkdf2Sync 24.000 vòng trong cùng image, pin cùng cpuset, cho 5,52 ms mỗi lần gọi. Trong server, CPU mỗi request đo được 5,46–6,38 ms ở bậc 150 (thêm Express, JSON, syscall). Một luồng JS vì vậy chạy được tối đa khoảng 1000 / 5,5–6 ≈ 165–180 req/s.

  2. Kết quả. 150 req/s: p95 37,3 ms. 175 req/s: p50 364,5 ms, p95 800,1 ms, k6 dừng lúc 11:22:13Z, có 5 dòng request timeout (quá 5 s). Đúng như tính trước, điểm gãy nằm giữa 150 và 175.

  3. Một core 100%, core kia rảnh. Lúc bão hoà, cgroup báo app dùng đúng 1,00 core. Host cho thấy cpu3 = 100% còn cpu2 chỉ 5–9%. Trong bậc 150, luồng JS lúc nằm trên cpu3 (72–74%), lúc trên cpu2 (77–95%, cpu3 còn 3–6%): scheduler có thể dời luồng sang core khác, nhưng mỗi lúc chỉ có một core làm việc. Thỉnh thoảng app vượt lên 1,08–1,18 core, đó là thread của libuv và GC chạy song song, không phải JS. Nếu dashboard chỉ có "CPU container = 50% của 2 core" thì trông như còn dư một nửa, trong khi luồng chính đã kín.

  4. Hàng đợi vô hình với app. inflight_max luôn là 1, kể cả khi client đã chờ 5 s. Lý do: handler đồng bộ chạy từ đầu tới cuối trong một lượt của event loop, nên trong Express không bao giờ có hai request cùng dở dang. Request mới nằm trong socket buffer của kernel, và loop chỉ đọc chúng khi tới phase poll (xem 6 phase của event loop). Tín hiệu nhìn thấy được là event loop delay (ELD, độ trễ của timer so với lịch): eld_p99 từ 5–44 ms ở bậc 150 nhảy lên 437–592 ms ở bậc 175, và ELU = 1,00.

  5. Chữa thế nào. Có ba hướng. Một là chạy nhiều process (node:cluster, thí nghiệm 2b) để dùng hết các core. Hai là chuyển việc nặng khỏi luồng chính: crypto.pbkdf2 bản async chạy trong thread pool của libuv, mặc định 4 thread (xem Thread pool và chặn event loop), hoặc dùng worker_threads. Ba là scale ngang. Biến thể async và worker_threads chưa chạy trong lab này (chưa verify). Với tech lead, điểm quan trọng nhất là chọn đúng metric: với Node, alert nên đặt trên ELD p99 và ELU, không đặt trên CPU% của container hay số request in-flight.

CPU mỗi request ≈ 5,5–6 ms (microbench 5,52 ms; trong server 5,46–6,38 ms ở bậc 150)
Trần 1 luồng JS  ≈ 1000 ÷ 5,5–6 ≈ 165–180 req/s   → đo: đạt 150, vỡ 175
Cách nói khi phỏng vấn: "Mỗi request ăn khoảng năm mili giây rưỡi CPU, mà một process Node chỉ có một luồng chạy JavaScript. Một giây nghìn mili giây chia cho năm rưỡi ra khoảng một trăm bảy mươi request. Đo thật thì một trăm năm mươi vẫn ổn, một trăm bảy mươi lăm thì vỡ. Tôi tính được trần trước khi chạy test, và test chỉ để xác nhận."
# out/2a-cpu-single-open.k6.log
      { step:04_rps150 }...........: avg=10.45ms  med=6.19ms   p(90)=20.91ms  p(95)=37.28ms  p(99)=60.9ms   max=101.99ms
      { step:05_rps175 }...........: avg=439.38ms med=364.48ms p(90)=696.38ms p(95)=800.05ms p(99)=860.39ms max=866.54ms
time="2026-10-30T11:22:11Z" level=warning msg="Request Failed" error="Get \"http://tmp-perf-node-app:3000/cpu\": request timeout"
# out/2a-cpu-single-open.app.log — 150 req/s (ELU 0,81), rồi 175 req/s: ELU 1, ELD p99 ~0,4–0,6 s, inflight_max vẫn 1
{"t":"2026-10-30T11:21:49.700Z","pid":1,"rps":150,"err5xx":0,"eld_p50_ms":3.289999999999999,"eld_p99_ms":5.34,"eld_max_ms":5.789999999999999,"elu":0.81,"cpu_cores":0.82,"cpu_ms_per_req":5.46,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":79}
{"t":"2026-10-30T11:22:04.151Z","pid":1,"rps":73,"err5xx":0,"eld_p50_ms":465.53,"eld_p99_ms":592.41,"eld_max_ms":592.41,"elu":1,"cpu_cores":1.13,"cpu_ms_per_req":15.62,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":79}
{"t":"2026-10-30T11:22:10.902Z","pid":1,"rps":200,"err5xx":0,"eld_p50_ms":407.33,"eld_p99_ms":436.96,"eld_max_ms":436.96,"elu":1,"cpu_cores":1.08,"cpu_ms_per_req":5.44,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":80}
# out/2a-cpu-single-open.dockerstats.log — bậc 150 trên cpu2, lúc vỡ trên cpu3; core kia ~5%
12:21:54 cores node-app=0.89 node-k6=0.18 node-pg=0.00 php-pg=0.00 | host% cpu0=19 cpu1=5 cpu2=89 cpu3=3
12:22:06 cores node-app=1.00 node-k6=0.23 node-pg=0.01 php-pg=0.00 | host% cpu0=23 cpu1=8 cpu2=6 cpu3=100
12:22:10 cores node-app=1.00 node-k6=0.23 node-pg=0.00 php-pg=0.00 | host% cpu0=24 cpu1=5 cpu2=5 cpu3=100
Cách nói khi phỏng vấn: "Ba dòng app cho thấy bức tranh: event loop bận một trăm phần trăm, độ trễ timer lên nửa giây, nhưng số request đang xử lý vẫn là một. Hàng đợi nằm trong kernel nên app không đếm được. Dòng cgroup cho thấy container dùng đúng một core, một CPU kín còn CPU kia gần như không làm gì. Vì vậy tôi không bao giờ dùng CPU phần trăm của container để đánh giá một app Node chạy một process."
flowchart TD
  A["175 req/s đến"] --> B["kernel socket buffer<br/>(hàng đợi app không thấy)"]
  B --> C["1 luồng JS: pbkdf2Sync ~5,5 ms/request<br/>ELU 1,00, inflight_max 1"]
  C --> D["cpu3 = 100%"]
  C -.-> E["cpu2 = 5–9% (bỏ phí)"]
  C --> F["ELD p99 437–592 ms<br/>p95 800,1 ms, 5 timeout"]
  style C fill:#7a1f1f,stroke:#ff6b6b,color:#fff

5.2.5 Thí nghiệm 2b — node:cluster 2 worker: 200 → 250, lợi 1,3–1,6× chứ không phải 2×

exp_2b() { # CPU-bound /cpu, node:cluster 2 workers
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 2
  k6run 2b-cpu-cluster2-open breakpoint.js ENDPOINT=/cpu MODEL=open \
    STEPS=100,200,250,300,350,400,450 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
}
Cách nói khi phỏng vấn: "Cùng container hai core, lần này process chính fork ra hai worker, mỗi worker có event loop riêng, và process chính chia kết nối cho hai worker theo vòng tròn. Mọi thứ khác giữ nguyên để so được với lần chạy một process."
  1. Kết quả. 200 req/s: p95 35,6 ms. 250 req/s: p95 215,6 ms, k6 dừng lúc 11:28:21Z. Điểm gãy dời từ 150 → 175 (một process) lên 200 → 250. Lúc bão hoà, cả cpu2 và cpu3 đều 100%, container dùng 1,96 core, mỗi worker có ELU 0,97–1,00. eld_p99 của worker lên tới 1.481 ms ở 11:28:04: cú khựng đầu phút rơi đúng cửa sổ RAMP sang bậc 250 và để lại hàng đợi.

  2. Lợi bao nhiêu. Tính theo SLO: bậc cuối đạt là 200 so với 150, tức 1,33×. Tính theo throughput lúc bão hoà: cộng hai worker trong cùng cửa sổ 2 s ra 238–287 req/s, phần lớn khoảng 249–257, so với 174–200 req/s của một process ở 2a, tức khoảng 1,3–1,6×. Không đạt 2×.

  3. Vì sao không đạt 2×. Đo được: CPU mỗi request của mỗi worker là 5,45–5,76 ms ở đầu bậc 200 (11:27:41–11:27:47), rồi tăng lên 6,1–8,1 ms trong chính bậc đó (11:27:49–11:28:01), trong khi tải không đổi (mỗi worker vẫn khoảng 100 req/s) và hai core của host đi từ ~60% lên ~80%. Hai core vì vậy chỉ làm được khoảng 1,5 lần việc. Process chính gần như không tốn gì: phần chênh giữa CPU container và tổng CPU hai worker chỉ khoảng 0,0–0,1 core. Nguyên nhân CPU mỗi request tăng khi cả hai vCPU cùng bận chưa được tách riêng (chưa verify). Giả thuyết hợp lý: hai vCPU của KVM dùng chung core vật lý, cache hoặc xung nhịp, hoặc bị máy khác trên host chen.

  4. Hệ quả tổ chức mà người ta hay quên. Mỗi worker có pool riêng: log khởi động in pool_prewarmed: 10 cho cả pid 14 và pid 15, tức 2 worker giữ 20 connection Postgres. Số connection bằng số worker nhân max của pool. Đây đúng là phép nhân max_children × 1 connection của PHP-FPM ở 5.1: scale theo process thì số connection DB cũng nhân lên theo. Ngoài ra, chạy nhiều worker hơn số core không giúp gì vì không còn core rảnh.

# out/run-2b.console.log + out/2b-cpu-cluster2-open.app.log — 1 primary, 2 worker, mỗi worker 10 connection
{"role":"primary","pid":1,"workers":2,"schedulingPolicy":"SCHED_RR"}
{"pid":14,"pool_prewarmed":10}
{"pid":15,"pool_prewarmed":10}
# out/2b-cpu-cluster2-open.k6.log
      { step:02_rps200 }...........: avg=11.17ms  med=6.85ms  p(90)=22.51ms  p(95)=35.57ms  p(99)=66.3ms   max=95.79ms 
      { step:03_rps250 }...........: avg=59.9ms   med=21.75ms p(90)=178.82ms p(95)=215.57ms p(99)=329.02ms max=434.44ms
# app: cùng bậc 200 req/s, CPU mỗi request 5,45 ms → 8,11 ms; bậc 250: cú khựng 1.481 ms, rồi 150 + 137 req/s
{"t":"2026-10-30T11:27:43.402Z","pid":14,"rps":98,"err5xx":0,"eld_p50_ms":0.5199999999999996,"eld_p99_ms":6.129999999999999,"eld_max_ms":6.73,"elu":0.53,"cpu_cores":0.53,"cpu_ms_per_req":5.45,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":73}
{"t":"2026-10-30T11:27:55.450Z","pid":14,"rps":98,"err5xx":0,"eld_p50_ms":3.25,"eld_p99_ms":53.77,"eld_max_ms":67.79,"elu":0.8,"cpu_cores":0.79,"cpu_ms_per_req":8.11,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":73}
{"t":"2026-10-30T11:28:04.082Z","pid":15,"rps":46,"err5xx":0,"eld_p50_ms":4.130000000000001,"eld_p99_ms":1481.08,"eld_max_ms":1481.08,"elu":0.98,"cpu_cores":1.17,"cpu_ms_per_req":25.35,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":75}
{"t":"2026-10-30T11:28:09.524Z","pid":14,"rps":150,"err5xx":0,"eld_p50_ms":66.22,"eld_p99_ms":387.67,"eld_max_ms":387.67,"elu":1,"cpu_cores":1,"cpu_ms_per_req":6.68,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":74}
{"t":"2026-10-30T11:28:10.207Z","pid":15,"rps":137,"err5xx":0,"eld_p50_ms":8.329999999999998,"eld_p99_ms":148.99,"eld_max_ms":148.99,"elu":0.99,"cpu_cores":0.99,"cpu_ms_per_req":7.21,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":76}
# out/2b-cpu-cluster2-open.dockerstats.log — cả hai core 100%
12:28:09 cores node-app=1.96 node-k6=0.45 node-pg=0.03 php-fpm=0.00 php-nginx=0.00 php-pg=0.00 | host% cpu0=39 cpu1=13 cpu2=100 cpu3=100
Cách nói khi phỏng vấn: "Log khởi động cho thấy mỗi worker tự mở mười connection, nên hai worker là hai mươi connection vào database. Trong cùng một bậc tải, CPU cho mỗi request tăng từ năm rưỡi lên tám mili giây khi cả hai core cùng nóng. Lúc vỡ, cả hai CPU kín và hai worker cộng lại chỉ được gần ba trăm request mỗi giây. Vì thế tôi nói cluster trên máy này cho khoảng một phẩy năm lần, và tôi nói rõ là chưa tách được nguyên nhân."
flowchart TD
  A["250 req/s đến"] --> P["primary: accept + chia vòng tròn<br/>(~0,0–0,1 core)"]
  P --> W1["worker pid 14<br/>ELU ~1, cpu2 100%"]
  P --> W2["worker pid 15<br/>ELU ~1, cpu3 100%"]
  W1 --> S["tổng ~250–290 req/s<br/>= 1,3–1,6× một process"]
  W2 --> S
  W1 -.-> DB["10 connection"]
  W2 -.-> DB2["10 connection<br/>→ 20 connection vào Postgres"]

5.2.6 Thí nghiệm 3 — closed model (ramping-vus) vs open model (ramping-arrival-rate) trên /cpu

Hai thí nghiệm này cùng app /cpu một process, cùng mức tải danh nghĩa, chỉ khác cách k6 sinh tải. Closed model (mô hình đóng): có N người dùng ảo (VU). Mỗi VU gửi một request, chờ response, ngủ 0,2 s (think time) rồi mới gửi tiếp. Open model (mô hình mở): request được bắt đầu theo lịch, ví dụ 200 cái mỗi giây, bất kể các request trước đã xong hay chưa. Người dùng web công khai đến theo kiểu mở: họ không biết và không quan tâm server của mình đang chậm.

exp_3b() { # open model at the same nominal rates, NO abort -> watch the queue past the knee
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1
  k6run 3b-cpu-single-open-noabort breakpoint.js ENDPOINT=/cpu MODEL=open NOABORT=1 \
    STEPS=50,100,150,200,250,300 SLO_P95=100 PRE_VUS=100 MAX_VUS=400
}
exp_3c() { # closed model WITHOUT abort, same VU steps as 3a -> watch it self-limit past the knee
  docker build -q -t "$IMG" "$LAB/app" >/dev/null; start_app 1
  k6run 3c-cpu-single-closed-noabort breakpoint.js ENDPOINT=/cpu MODEL=closed NOABORT=1 \
    STEPS=10,20,30,40,50,60 SLO_P95=100 SLEEP=0.2
}
Cách nói khi phỏng vấn: "Tôi chạy cùng một app hai lần, đều không dừng giữa chừng. Một lần cho k6 bắn theo lịch số request mỗi giây. Lần kia cho k6 giả lập một số người dùng cố định, mỗi người chờ trả lời xong, nghỉ hai trăm mili giây rồi mới bấm tiếp. Số người dùng được chọn sao cho tải danh nghĩa bằng nhau giữa hai lần, để so sánh công bằng."
  1. Tải danh nghĩa của closed model. Mỗi VU mất (think time + thời gian response) cho một vòng, nên tải danh nghĩa là VU / (0,2 s + ~0,006 s). 40 VU ≈ 194 req/s, 60 VU ≈ 291 req/s. Chúng tương ứng với các bậc 200 và 300 req/s của open model.

  2. Cùng tải, kết luận ngược nhau. Closed 40 VU (≈194 req/s danh nghĩa): k6 báo 179,4 req/s, p95 62,9 ms, đạt SLO, dù app lúc đó đã bão hoà (ELU 0,98–1,00, 1,00 core). Open 200 req/s: 167,2 req/s thành công, p95 5.000,4 ms, 10,79% request lỗi. Closed 60 VU (≈291 req/s danh nghĩa): 169,3 req/s, p95 261,9 ms, max 466,8 ms, 0 lỗi, 0 dropped iteration, 0 timeout. Open 300 req/s: 166,6 req/s thành công, 25,60% lỗi. Trong cả lần chạy open có 3.834 dòng request timeout, 2.574 iteration bị bỏ và cảnh báo Insufficient VUs.

  3. Coordinated omission (bỏ sót có phối hợp: công cụ đo tự giảm tốc cùng hệ thống bị đo, nên bỏ sót đúng những request chịu latency tệ nhất). Ở closed 60 VU, lẽ ra phải có ~291 request mỗi giây, nhưng k6 chỉ gửi 169,3, vì VU nào đang kẹt chờ response chậm thì không gửi request tiếp theo. Trong cửa sổ HOLD 22 s, danh nghĩa là 291 × 22 ≈ 6.400 request, k6 thực gửi 3.724. Khoảng 2.700 request mà người dùng thật lẽ ra đã gửi không tồn tại trong số liệu, và đó chính là những request lẽ ra phải chờ lâu nhất.

  4. Vì sao latency của closed model bị "kẹp trần". Áp Little's law cho vòng kín: N = X × (R + Z), với N = số VU, X = throughput, R = thời gian response, Z = think time. Suy ra R = N / X − Z = 60 / 169,3 − 0,2 ≈ 0,154 s, khớp p50 140,1 ms đo được. Máy có bão hoà cỡ nào thì R vẫn bị chặn quanh mức đó, vì càng chậm thì k6 càng gửi ít. Ở open model không có cái phanh đó: request đến đều, hàng đợi trong socket buffer dài mãi cho tới khi chạm timeout 5 s của client. Hai lần chạy, app ở cùng trạng thái (ELU 1,00, 1 core kín), nhưng một bên báo "chậm hơn chút" và bên kia báo "một phần tư request lỗi".

  5. Closed model vẫn có thể vượt SLO latency, nhưng không bao giờ báo lỗi. Ở 3a (closed, có abort), bậc 30 VU chỉ đạt 120,5 req/s so với ~145 danh nghĩa, p95 143,2 ms, và test dừng ở bậc 40 VU vì p95 cộng dồn là 100,3 ms. Vẫn là 0 lỗi, 0 dropped, 0 timeout. Closed model chỉ nói được "chậm hơn", không nói được "hỏng".

  6. Khi nào dùng cái nào. Closed model đúng khi phía client thật sự đóng: một số worker batch cố định, service nội bộ có pool kết nối cố định gọi sang, hoặc soak test theo số phiên. Với traffic công khai, khách đến không phụ thuộc latency của mình, nên capacity test phải dùng arrival-rate executor và phải đọc dropped_iterations: iteration bị bỏ là tải k6 không giao được, và báo cáo cần ghi rõ con số đó.

Tải danh nghĩa closed = VU ÷ (Z + R) = 60 ÷ (0,2 + 0,006) ≈ 291 req/s   → k6 thực gửi 169,3 req/s
Request "biến mất" trong HOLD 22 s ≈ 291 × 22 − 3.724 ≈ 6.400 − 3.724 ≈ 2.700
Latency bị kẹp: R = N ÷ X − Z = 60 ÷ 169,3 − 0,2 ≈ 0,154 s   (đo: p50 140,1 ms)
Cách nói khi phỏng vấn: "Trong closed model, mỗi người dùng ảo phải chờ trả lời mới gửi tiếp, nên khi server chậm thì máy tạo tải cũng chậm theo. Sáu mươi người dùng lẽ ra tạo gần ba trăm request mỗi giây, nhưng thực tế chỉ gửi một trăm bảy mươi. Trong hai mươi hai giây có khoảng hai nghìn bảy trăm request không bao giờ được gửi, và đó đúng là những request lẽ ra chịu latency tệ nhất. Latency đo được vì vậy bị kẹp quanh một trăm năm mươi mili giây, trong khi cùng server đó dưới open model có một phần tư request bị timeout năm giây. Hiện tượng này gọi là coordinated omission."
# out/3c-cpu-single-closed-noabort.k6.log — closed: 40 và 60 VU, toàn bài 0 lỗi, 0 dropped
      { step:04_vus40 }............: avg=21.64ms  med=15.11ms  p(90)=48.57ms  p(95)=62.93ms  p(99)=93.24ms  max=115.97ms
      { step:06_vus60 }............: avg=151.34ms med=140.08ms p(90)=204.03ms p(95)=261.87ms p(99)=439.02ms max=466.83ms
    http_req_failed................: 0.00%  0 out of 21531
      { step:06_vus60 }............: 3724   18.681003/s
    dropped_iterations.............: 0      0/s
# out/3b-cpu-single-open-noabort.k6.log — open: 200/250/300 req/s
      { step:04_rps200 }...........: avg=1.1s     med=595.98ms p(90)=4.99s    p(95)=5s       p(99)=5s       max=5.16s   
      { step:04_rps200 }...........: 10.79% 445 out of 4123
      { step:05_rps250 }...........: 22.99% 1251 out of 5440
      { step:06_rps300 }...........: 25.60% 1262 out of 4928
    dropped_iterations.............: 2574   12.679184/s
time="2026-10-30T11:42:43Z" level=warning msg="Request Failed" error="Get \"http://tmp-perf-node-app:3000/cpu\": request timeout"
time="2026-10-30T11:43:15Z" level=warning msg="Insufficient VUs, reached 400 active VUs and cannot initialize more" executor=ramping-arrival-rate scenario=breakpoint
# app, cùng trạng thái bão hoà: closed 60 VU (11:50:39) và open 200 req/s (11:42:40) đều ELU 1
{"t":"2026-10-30T11:50:39.322Z","pid":1,"rps":173,"err5xx":0,"eld_p50_ms":118.65,"eld_p99_ms":197.49,"eld_max_ms":197.49,"elu":1,"cpu_cores":1.05,"cpu_ms_per_req":6.04,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":75}
{"t":"2026-10-30T11:42:40.188Z","pid":1,"rps":200,"err5xx":0,"eld_p50_ms":640.12,"eld_p99_ms":889.15,"eld_max_ms":889.15,"elu":1,"cpu_cores":1.31,"cpu_ms_per_req":6.58,"pool_total":10,"pool_idle":10,"pool_waiting":0,"pool_waiting_max":0,"inflight":0,"inflight_max":1,"rss_mb":76}
Cách nói khi phỏng vấn: "Hai log k6 đặt cạnh nhau là đủ thấy. Closed model ở sáu mươi người dùng báo không lỗi nào, không bỏ iteration nào, p95 hai trăm sáu mươi mili giây. Open model ở cùng tải báo một phần tư request lỗi và hơn ba nghìn tám trăm lần timeout. Trong khi đó log app của cả hai lần đều cho thấy event loop bận một trăm phần trăm. Cùng một server, đo bằng công cụ khác thì ra hai kết luận ngược nhau."
flowchart TD
  C1["Closed: 60 VU"] --> C2["VU gửi 1 request,<br/>CHỜ response"]
  C2 --> C3["server chậm → VU gửi ít đi<br/>169,3 req/s thay vì ~291"]
  C3 --> C4["latency kẹp ≈ N/X − Z ≈ 154 ms<br/>0 lỗi, 0 dropped"]
  C3 --> C5["~2.700 request không được gửi<br/>(coordinated omission)"]
  style C5 fill:#7a1f1f,stroke:#ff6b6b,color:#fff
flowchart TD
  O1["Open: 200–300 req/s theo lịch"] --> O2["request đến đều,<br/>không chờ ai"]
  O2 --> O3["hàng đợi trong socket buffer<br/>dài ra liên tục"]
  O3 --> O4["timeout 5 s: 10,79–25,60% lỗi<br/>3.834 request timeout"]
  O3 --> O5["400 VU đều kẹt → Insufficient VUs<br/>2.574 dropped iteration"]
  style O4 fill:#7a1f1f,stroke:#ff6b6b,color:#fff

5.2.7 Bẫy phương pháp gặp trong lab (và cách đã xử lý)

  1. Máy khựng ở đầu mỗi phút. Một vòng lặp sleep 1 ms pin vào cpu 2, chạy khi app rảnh, trong 130 s ghi được 94 lần thức dậy trễ hơn 30 ms, lâu nhất 147 ms. 29 lần rơi vào giây :00–:07 của phút (8 giây), 65 lần trong 52 giây còn lại, tức mật độ ở đầu phút cao gấp khoảng 2,9 lần. Thêm nữa, sysstat-collect chạy mỗi 5 phút và tốn 1,046 s CPU một lần. Ở các lần chạy đầu, event loop của app thấy những cú khựng 300–1.300 ms ngay đầu phút: attempts-v2/1a ghi eld_max 1.035,95 ms lúc 10:20:05Z, khi app chỉ dùng 0,52 core và hàng chờ pool lên 162. Cách xử lý: căn mỗi bậc bắt đầu đúng đầu phút để mọi mốc phút rơi vào cửa sổ RAMP 8 s không gắn tag, và ngưỡng abort chỉ đọc cửa sổ HOLD. Như 5.2.2 đã cho thấy, cách này vẫn không xoá được hàng đợi do cú khựng để lại khi hệ đã sát trần.

# out/noise-stall-detector.log
### noise check start 13:09:12 (taskset -c 2, threshold 30 ms)
13:09:19 stall_ms=147
13:10:03 stall_ms=77
5-55/10 * * * * root command -v debian-sa1 > /dev/null && debian-sa1 1 1
Oct 30 11:20:05 vmi3164815 systemd[1]: sysstat-collect.service: Consumed 1.046s CPU time.
Cách nói khi phỏng vấn: "Trước khi tin số đo, tôi đo độ ồn của chính máy test bằng một vòng lặp ngủ một mili giây, xem nó thức dậy trễ bao lâu. Máy này khựng tới gần một trăm năm mươi mili giây, dày hơn hẳn ở mấy giây đầu phút, và có một job thống kê hệ thống chạy mỗi năm phút. Nên tôi căn các bậc tải để mốc phút luôn rơi vào lúc đang tăng tải, và chỉ chấm SLO trong lúc giữ tải ổn định."
  1. Ngưỡng abort là cộng dồn, nên luôn dừng trễ hơn điểm gãy. k6 tính threshold trên toàn bộ dữ liệu từ đầu test tới thời điểm đó, nên p95 cộng dồn chỉ vượt ngưỡng sau khi bậc vỡ đã kéo đủ nhiều mẫu xấu. Ví dụ 3a: bậc 30 VU đã có p95 143,2 ms, nhưng test chỉ dừng trong bậc 40 VU. Muốn tìm điểm gãy thì đọc sub-metric theo từng bậc (http_req_duration{step:...}). Abort chỉ dùng làm phanh an toàn.

  2. delayAbortEval tính từ lúc test bắt đầu, không phải lúc scenario bắt đầu. Ở attempts-v3, warm-up 20 s, delayAbortEval: '20s': k6 khởi động 10:44:40Z và abort lúc 10:45:03Z, tức chỉ khoảng 3–4 s sau khi scenario breakpoint bắt đầu, lúc vẫn đang ramp. Script cuối cùng đặt WARMUP + 30s. Cũng ở vòng đó, attempts-v4 đặt abort trên {scenario:breakpoint}, gồm cả cửa sổ RAMP, nên cú khựng đầu phút trong RAMP làm p95 cộng dồn vượt sớm. Vì thế bản cuối chỉ abort trên {phase:hold}.

// k6/breakpoint.js — delayAbortEval phải cộng thêm thời gian warm-up
const ABORT_DELAY = `${WARMUP + 30}s`;
Cách nói khi phỏng vấn: "Tôi bị dính một lỗi khá tinh: thời gian trì hoãn trước khi xét ngưỡng dừng test được k6 tính từ lúc test khởi động, không phải từ lúc scenario chính bắt đầu. Có hai mươi giây warm-up nên test bị dừng ngay giây thứ ba của scenario chính. Tôi sửa bằng cách cộng thời gian warm-up vào. Bài học là đọc thời điểm abort trong log rồi đối chiếu với lịch chạy, đừng tin cấu hình nói gì."
  1. Pool nguội khi tăng tải. Mặc định của pg là idleTimeoutMillis = 10000. Trong warm-up 20 req/s, pool tự co về 1–2 connection (attempts-v1/attempt1-nowarmup ghi pool_total: 2). Khi tải tăng, app phải mở 8–10 connection cùng lúc, eld_max lên 110–328 ms và ngay bậc 100 req/s đầu tiên p95 đã 278 ms, tức một "điểm gãy" giả. Bản cuối đặt idleTimeoutMillis: 0 và mở sẵn đủ max connection lúc boot. min của pg không tự tạo connection (theo tài liệu node-postgres tra qua Context7). Phần nào của việc mở connection chặn event loop thì chưa tách được (chưa verify).

  2. Giữ lại các lần chạy bị loại. out/attempts-v1..v5/ được giữ làm bằng chứng, kèm lý do loại. Một phần vì nó cho ra các điểm đối chứng (450 req/s phục vụ được ở v5, 250 req/s đạt ở v1/1a). Phần quan trọng hơn: nó cho thấy điểm gãy dao động ±1 bậc, và đó cũng là một kết quả cần báo cáo.

  3. Sai số của công cụ đo. k6 chạy cùng máy với app (chung kernel, network stack, dockerd), nên số tuyệt đối chỉ đúng cho máy này. docker stats --no-stream đôi khi báo vượt cpuset (app 286,00% khi chỉ có 2 CPU ở 2b, Postgres 107,02% với 1 CPU ở 1a), nên số CPU tin cậy lấy từ cpu.stat của cgroup.

Thư mụcKhác gìVì sao loại
attempts-v1/RAMP 5 s + HOLD 20 s, không căn phút, idle timeout mặc địnhpool nguội, lab PHP chạy chồng, khựng rơi vào HOLD
attempts-v2/có sampler cgroup + chờ lab PHP rảnh, chưa căn phútkhựng đầu phút (sysstat 11:20, 11:25) rơi vào HOLD
attempts-v3/đã căn phút, delayAbortEval: '20s'abort ~4 s sau khi scenario chính bắt đầu
attempts-v4/abort trên {scenario:breakpoint} (gồm RAMP)khựng trong RAMP làm vượt ngưỡng cộng dồn sớm
attempts-v5/1c với SLO 100 ms, bậc 300/450/500dừng theo p95 ở 450 trước khi có lỗi 5xx; giữ điểm "450 phục vụ được, p50 40,6 ms"

Chưa verify trong phần 5.2: (1) vì sao CPU mỗi request trong server (5,2–8 ms) cao hơn microbench 5,52 ms và tăng khi cả hai vCPU cùng bận; (2) phần nào của việc mở connection pg chặn event loop; (3) biến thể crypto.pbkdf2 async và worker_threads cho /cpu; (4) hành vi khi tắt keep-alive, có TLS hoặc reverse proxy đứng trước; (5) load generator đặt ở máy khác; (6) đề xuất trả 503 + Retry-After thay cho 500 khi pool hết thời gian chờ.

5.3 So sánh: PHP-FPM và Node.js hỏng theo cách nào

Hai lab không đo cùng một endpoint: PHP chạy Laravel thật (/api/db tốn ~8–10 ms CPU mỗi request, SLO 200 ms), còn Node chạy endpoint tổng hợp với SLO 100 ms. Vì vậy không được đọc bảng dưới như "PHP hay Node nhanh hơn". Cái so được là hình dạng của sự cố: hàng đợi nằm ở đâu, ai nhìn thấy nó, và client nhận lỗi gì.

  1. Đơn vị concurrency. PHP-FPM theo kiểu process-per-request (mỗi request chiếm trọn một worker): một worker giữ request suốt cả lúc tính toán lẫn lúc chờ IO. Ở /api/slow (ngủ 100 ms) với 5 worker, Little's law dự đoán 5 / 0,112 = 44,6 req/s và đo được 43,5. Node chỉ có một event loop cho mỗi process. Lúc chờ IO, loop không bị giữ, chỉ có connection của pool bị giữ: ở 1b, 10 connection với W ≈ 23 ms cho trần ~430 req/s, trong khi ELU vẫn ≤ 0,50. Cùng một công thức N / W, nhưng thứ đóng vai "N" khác nhau: ở PHP là số worker, ở Node là số connection DB.

  2. Hàng đợi nằm ở đâu, ai thấy. PHP: listen backlog của FPM. Qua TCP, trang status thấy được (listen queue 186, rồi chạm 512 với listen queue len 511). Qua unix socket, trang status mù hẳn (0 suốt cả lúc 7.604 request bị từ chối). Node CPU-bound: hàng đợi nằm trong socket buffer của kernel, app không đếm được (inflight_max = 1), chỉ lộ qua ELD p99 437–592 ms. Node IO-bound: hàng đợi nằm ngay trong process (pool.waitingCount 85–389), đọc được dễ.

  3. Dùng CPU. PHP-FPM tự rải ra cả hai core vì có nhiều process (fpm + nginx 155–190% trên 200%). Node một process chỉ dùng một core (cpu3 = 100%, cpu2 = 5–9%), phải thêm node:cluster mới dùng được core thứ hai, và chỉ lợi 1,3–1,6×.

  4. Client nhận lỗi gì. PHP: 504 sau fastcgi_read_timeout 5s (TCP), 502 ngay lập tức với EAGAIN khi backlog unix đầy, 500 "too many clients already" khi max_children 120 vượt max_connections 100 (16,7% request). Node: client timeout 5 s với CPU-bound (3.834 lần ở 3b), 500 "timeout exceeded when trying to connect" khi pool chờ quá 1 s (1c). Không lab nào thấy ECONNRESET hay connection refused từ Node.

  5. Chi phí của mỗi đơn vị concurrency. PHP: tăng từ 20 lên 80 worker tốn thêm 107 MiB cho php-fpm và 106 MiB cho Postgres (80 backend), mà không thêm throughput vì CPU đã là giới hạn. Node: RSS mỗi process 73–102 MB trong suốt lab. Nhưng cluster cũng nhân số connection (2 worker × 10 = 20), nên bài toán "số process × connection mỗi process ≤ max_connections" tồn tại ở cả hai stack.

TrụcPHP-FPM + nginx (5.1)Express trên Node (5.2)
Đơn vị concurrencyworker process, giữ request cả lúc CPU lẫn lúc IO1 event loop/process; IO chỉ giữ connection pool
Little's law ("N" là gì)N = số worker: 5 / 0,112 = 44,6 dự đoán, 43,5 đoN = số connection: 10 / ~0,023 ≈ 430, đo 429–453
Hàng đợi khi quá tảiFPM listen backlog (186 → 512)CPU: socket buffer kernel (vô hình); IO: pool.waitingCount (85–389)
Metric phát hiện sớmfpm status listen queue (TCP), cpu.pressureELD p99, ELU, pool_waiting
Dùng 2 coretự động (155–190% / 200%)1 process = 1 core; cluster 2 worker = 1,3–1,6×
Lỗi client nhận504 (5 s), 502 EAGAIN (unix), 500 too many clientstimeout 5 s (CPU), 500 pool timeout (IO)
Knee đo được/api/db 150–180 req/s (SLO 200 ms)/db 150 → 200; /cpu 150 → 175, cluster 200 → 250 (SLO 100 ms)
flowchart TD
  P1["PHP-FPM: request"] --> P2["listen backlog FPM<br/>(status thấy qua TCP, mù qua unix)"]
  P2 --> P3["worker 1..N<br/>bận cả lúc CPU lẫn lúc chờ IO"]
  P3 --> P4["504 sau 5 s (TCP)<br/>502 EAGAIN ngay (unix)"]
  style P3 fill:#7a1f1f,stroke:#ff6b6b,color:#fff
flowchart TD
  N1["Node: request"] --> N2["socket buffer kernel<br/>(app không thấy)"]
  N2 --> N3["1 event loop / process"]
  N3 --> N4["CPU-bound: loop kín, ELD tăng<br/>→ client timeout 5 s"]
  N3 --> N5["IO-bound: chờ pool.waitingCount<br/>→ 500 khi chờ quá 1 s"]
  style N3 fill:#7a1f1f,stroke:#ff6b6b,color:#fff
Cách nói khi phỏng vấn: "Cả hai stack đều tuân theo Little's law, nhưng thứ bị hết khác nhau. Ở PHP, cái hết là worker, vì worker bị giữ cả lúc chờ database. Năm worker với request một trăm mười hai mili giây thì trần là bốn mươi bốn request mỗi giây, đo được bốn mươi ba rưỡi. Ở Node, chờ database không tốn event loop mà chỉ tốn connection, nên cái hết là pool, hoặc là chính luồng JavaScript nếu handler ăn CPU. Cái nguy hiểm của Node là hàng đợi khi handler ăn CPU nằm trong kernel, app không đếm được, nên tôi giám sát bằng độ trễ event loop chứ không bằng số request đang xử lý. Còn PHP thì phải nhìn listen queue của FPM, và nhớ là qua unix socket thì con số đó luôn bằng không."

6. Vận hành trong production

Phạm vi: mục này mô tả breakpoint test như một thực hành của team trên lab/staging; đây không phải quy trình chạy breakpoint test trên production.

6.1 Theo dõi metric gì, ở lớp nào

1. Khung đọc metric dùng xuyên suốt là USE method (phương pháp USE của Brendan Gregg): với mỗi tài nguyên, xem utilization (phần trăm thời gian bận), saturation (độ dài hàng đợi — việc đang phải chờ) và errors (số lỗi) [S33]. Breakpoint test chỉ cho biết ở mức tải nào hệ thống gãy; USE trả lời tài nguyên nào gãy. Quan trọng: áp dụng USE cho cả bộ bắn tải chứ không chỉ cho hệ thống bị test (SUT — system under test), vì một k6 bị nghẽn CPU tạo ra một knee (điểm gãy) giả [S8][S34].

LớpPHP (nginx + php-fpm + Laravel)Node.js (Express + pg)Lab thấy gì khi tới knee
Bộ bắn tải k6CPU của tiến trình k6 (giữ ≤ 80% [S8]), dropped_iterations, vus so với maxVUs, dòng cảnh báo Insufficient VUs PHP: container k6 dùng 19–25% CPU ở 150–180 rps (k6 không phải nút cổ chai). Node 1c: Insufficient VUs, reached 400 active VUs, 6 168 iteration bị bỏ.
Cổng vàonginx: phân bố status 502/504/499; $request_time so với thời gian php-fpm tự đo (%d); $upstream_connect_time Không có proxy trong lab; client thấy timeout (k6 request timeout) hoặc 500 từ app PHP diag-acc: php-fpm p50 114–123 ms nhưng nginx p50 280–550 ms — phần chênh là thời gian nằm trong backlog. Node 3b: 3 834 dòng request timeout.
Runtime / workerphp-fpm status: listen queue, active processes, idle processes, slow requests; CPU cgroup và cpu.pressure Event-loop delay p99 (monitorEventLoopDelay), ELU (eventLoopUtilization), CPU theo từng core PHP exp1 r2: listen queue 0 → 51 → 186, idle 0/20, psi 45–70%. Node 2a: ELD p99 5–44 ms ở 150 req/s → 437–592 ms ở 175, ELU 1.00.
Pool kết nối DBMột kết nối persistent mỗi worker → số backend = số worker pool.waitingCount, totalCount, idleCount [S44] Node 1a: pool_waiting_max 0–3 → 85–100 ở 200 req/s. PHP mc 120 > max_connections 100: 16,7% request trả 500.
PostgreSQLpg_stat_activity (state, wait_event), số backend so với max_connections, CPU của core DB PHP exp1: cả 20 backend idle ClientRead (DB đang chờ PHP). PHP exp0: Postgres 89–97% một core. Node 1a: Postgres 0,78–0,87 core.

2. Trên Unix socket, status page của php-fpm bị mù. Trong lượt fail-unix, nginx từ chối 7 604 request với lỗi connect() ... (11: Resource temporarily unavailable) trong khi status page báo listen queue = 0 và listen queue len = 0 suốt thời gian đó; cùng cấu hình pool qua TCP thì status page thấy hàng đợi (511 / khác 0). Hệ quả vận hành: nếu nginx nói chuyện với php-fpm qua Unix socket, cảnh báo dựa trên listen queue sẽ không bao giờ kêu — phải dựa vào tỉ lệ 502 ở nginx. Ngược lại, qua TCP thì client thấy 504 sau fastcgi_read_timeout (lab đặt 5 s) thay vì 502 tức thì.

3. max children reached luôn bằng 0 với pm = static. Lab chạy static ở mọi lượt và chỉ số này đứng yên ở 0 kể cả khi 20/20 worker bận và hàng đợi lên 186. Comment trong www.conf.default của PHP 8.5.12 nói rõ chỉ số này "works only for pm 'dynamic' and 'ondemand'". Ai copy dashboard từ một hệ thống dùng dynamic sang một pool static sẽ có một panel luôn xanh.

4. Với Node, đếm request đang xử lý (in-flight) không thấy hàng đợi của handler CPU-bound. Ở endpoint /cpu (pbkdf2Sync), inflight_max bằng 1 trong mọi cửa sổ 2 giây, kể cả khi client đang chờ 5 giây: handler đồng bộ không bao giờ để hai request cùng nằm trong Express, hàng đợi nằm trong socket buffer của kernel. Tín hiệu đúng là event-loop delay và ELU = 1.00. Thêm một chi tiết dễ bỏ sót: ở 2a container được phép dùng 2 core nhưng host ghi cpu3 = 100% còn cpu2 = 5–9% — một tiến trình Node chỉ dùng một core cho JavaScript, nên "CPU container 50%" có thể đã là bão hoà.

5. Pool pg mặc định chờ vô hạn. connectionTimeoutMillis mặc định là 0 (= chờ mãi) [S44], nên pool cạn hiện ra dưới dạng độ trễ chứ không phải lỗi. Lab 1c đặt 1 000 ms: offered 550 req/s, app phục vụ 366–457 req/s, hàng đợi pool lên 389, rồi app bắt đầu trả 500 timeout exceeded when trying to connect (59, 20, rồi 252 và 196 lỗi trong các cửa sổ 2 giây). Chọn giá trị này là chọn kiểu gãy: chậm dần (mặc định) hay lỗi nhanh (có timeout).

6.2 Đưa kết quả ra khỏi k6: JSON, web dashboard, Prometheus remote write

1. End-of-test summary dạng JSON là đầu ra rẻ nhất và đủ cho CI. Cả hai lab đều dùng --summary-export; phần per-step trong bài được tính từ file JSON này bằng analyze.py (lab Node) hoặc từ CSV từng request (--out csv, lab PHP — file .csv.gz lớn vì mỗi request một dòng). Lệnh thật của lab Node:

k6 run --quiet --summary-mode=full \
  --summary-trend-stats 'avg,med,p(90),p(95),p(99),max' \
  --summary-export /out/1a-db-open.summary.json /scripts/breakpoint.js
Cách nói khi phỏng vấn: "Cuối mỗi lần chạy tôi cho k6 xuất toàn bộ bảng tổng kết ra một file JSON, có thêm p99 vì mặc định k6 không in p99. File đó là artifact của pipeline, nên so sánh giữa hai lần chạy chỉ là so hai file. Để thấy knee theo từng bậc tải, tôi gắn tag cho từng bậc rồi đọc sub-metric của từng tag trong JSON, chứ không nhìn con số tổng của cả lần chạy."

Muốn tự định dạng (ghi cả text ra màn hình lẫn JSON ra file, hoặc thêm báo cáo riêng) thì dùng handleSummary(): hàm này trả về một map, key là stdout, stderr hoặc đường dẫn file (ghi đè), value là chuỗi hoặc ArrayBuffer; khi đã export handleSummary thì k6 không tự in summary mặc định nữa, phải gọi textSummary() [S16]:

import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';

export function handleSummary(data) {
  return {
    stdout: textSummary(data, { indent: ' ', enableColors: false }),
    'out/summary.json': JSON.stringify(data),
  };
}
Cách nói khi phỏng vấn: "Hàm này chạy một lần khi test kết thúc và nhận toàn bộ số liệu tổng kết. Nó trả về danh sách 'ghi cái gì ra đâu': một bản chữ cho màn hình CI và một bản JSON đầy đủ cho máy đọc. Lưu ý là khai báo hàm này thì bảng tổng kết mặc định biến mất, nên phải tự gọi lại hàm in bản chữ."

2. Web dashboard có sẵn trong k6: bật bằng biến môi trường, xem trực tiếp ở http://127.0.0.1:5665, và có thể xuất một file HTML khi test kết thúc. Chu kỳ gom số mặc định 10 giây và biểu đồ chỉ hiện khi test dài hơn 3 lần chu kỳ [S21]. Hợp cho một buổi chạy staging có người ngồi xem; không hợp để so nhiều lần chạy với nhau hay để đặt cạnh metric của SUT.

K6_WEB_DASHBOARD=true \
K6_WEB_DASHBOARD_EXPORT=report.html \
k6 run breakpoint.js
Cách nói khi phỏng vấn: "Khi chạy thủ công trên staging tôi bật dashboard có sẵn của k6 để nhìn tải và độ trễ theo thời gian ngay lúc chạy, cuối buổi nó ghi ra một file HTML để đính kèm vào báo cáo. Nhưng dashboard đó chỉ thấy phía k6; muốn biết tài nguyên nào gãy thì vẫn phải nhìn metric của server, nên với breakpoint test nghiêm túc tôi đẩy số liệu vào Prometheus."

3. Prometheus remote write là đầu ra quan trọng nhất cho breakpoint test, vì nó đặt số liệu của k6 lên cùng trục thời gian với metric của nginx, php-fpm, Node và Postgres. Ba điểm phải biết trước khi dùng [S21]:

K6_PROMETHEUS_RW_SERVER_URL=http://prometheus:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS='p(95),p(99),max' \
K6_PROMETHEUS_RW_LABELS="testid=bp-php-db-r1,env=staging" \
k6 run -o experimental-prometheus-rw breakpoint.js
Cách nói khi phỏng vấn: "Tôi cho k6 đẩy số liệu thẳng vào Prometheus của staging, mỗi năm giây một lần, kèm một nhãn định danh lần chạy. Tôi khai báo thêm p95 vì mặc định nó chỉ gửi p99. Cái lợi là độ trễ phía client và hàng đợi php-fpm hay pool Postgres nằm trên cùng một biểu đồ, nên chỉ được đúng giây nào thì hệ thống gãy và gãy ở đâu. Module này vẫn đang experimental nên tôi ghim phiên bản k6."

Ba truy vấn dùng để đọc knee từ chuỗi thời gian (tên series theo quy ước tiền tố k6_, Counter có hậu tố _total [S21]; tên chính xác của series percentile — có thêm hậu tố đơn vị hay không — chưa verify, xem trong Prometheus UI sau lần chạy đầu):

# request k6 thực sự gửi đi mỗi giây
sum(rate(k6_http_reqs_total{testid="bp-php-db-r1"}[30s]))

# iteration bị bỏ vì hết VU: > 0 sau warm-up = đã qua knee
sum(rate(k6_dropped_iterations_total{testid="bp-php-db-r1"}[30s]))

# p95 độ trễ theo thời gian (gauge do k6 tự tính trước khi gửi)
max(k6_http_req_duration_p95{testid="bp-php-db-r1"})
Cách nói khi phỏng vấn: "Tôi vẽ ba đường. Đường thứ nhất là số request mỗi giây k6 gửi được, nó phải bám theo các bậc tải đã lên lịch; khi nó tách khỏi bậc thì hệ thống không theo kịp. Đường thứ hai là số iteration bị bỏ vì hết user ảo — cứ khác không sau giai đoạn khởi động là tôi coi như đã qua điểm gãy. Đường thứ ba là p95 theo từng khoảng thời gian, đọc cạnh hàng đợi của server để biết tài nguyên nào bão hoà trước."

4. Chọn đầu ra nào: JSON cho CI (rẻ, diff được, không cần hạ tầng); web dashboard cho một buổi chạy tay; Prometheus remote write khi cần tìm nguyên nhân. Lý do remote write gần như bắt buộc với breakpoint test: threshold của k6 tính trên số cộng dồn từ đầu test (cả hai lab đều thấy abort trễ ít nhất một bậc so với knee, xem mục 9), nên knee phải đọc từ chuỗi thời gian theo từng bậc chứ không phải từ thời điểm abort.

6.3 Gắn vào CI: threshold làm cổng chặn

1. Cơ chế: khi một threshold sai ở cuối test (hoặc bị abortOnFail cắt giữa chừng), k6 thoát với exit code khác 0 [S7] — trong cả hai lab đó là 99 (k6 exit=99 (99 = threshold abortOnFail / failed thresholds) trong log Node). CI chỉ cần coi exit code khác 0 là job đỏ. Vì vậy threshold chính là cổng chặn (gate) — với điều kiện threshold được viết từ SLO, không phải từ con số "trông ổn".

2. Breakpoint test không làm gate được. Theo chính định nghĩa của Grafana, "a breakpoint test must cause system failure" [S1]: nó được thiết kế để đỏ. Thứ làm gate trong mỗi merge request là smoke test và average-load test (tải bằng mức trung bình production) với threshold theo SLO; breakpoint chạy theo lịch, kết quả là một con số capacity ghi vào báo cáo/ADR chứ không phải xanh/đỏ. Grafana xếp breakpoint vào loại chạy "a few times" [S2].

3. Chọn mức tải cho gate sao cho không chập chờn. Lab cho thấy độ lệch giữa các lần chạy lớn nhất ở gần knee: cùng bậc 150 req/s trên Node một tiến trình, p95 là 37,3 ms ở lượt 2a và 142,8 ms ở lượt 3b — một gate p(95)<100 ở bậc này sẽ đỏ/xanh ngẫu nhiên. Ở bậc 100 req/s (khoảng 2/3 knee) hai lượt cho 34,4 ms và 37,5 ms. Quy tắc rút ra: gate chạy ở mức tải bằng khoảng nửa tới hai phần ba knee đã đo, nơi kết quả ổn định; vùng sát knee để dành cho breakpoint test có người đọc.

perf-average-load:                 # gate cho merge request
  stage: perf
  image:
    name: grafana/k6@sha256:3e4186aea62e11a45560bfc51e19eeafe82fc07ce069c78aff44b7ff893d9a5e
    entrypoint: [""]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - k6 run --summary-export=summary.json -e BASE=$STAGING_URL tests/perf/average-load.js
  artifacts:
    when: always
    paths: [summary.json]

perf-breakpoint:                   # chạy theo lịch, không chặn merge
  stage: perf
  image:
    name: grafana/k6@sha256:3e4186aea62e11a45560bfc51e19eeafe82fc07ce069c78aff44b7ff893d9a5e
    entrypoint: [""]
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - k6 run --summary-export=bp.json -e BASE=$STAGING_URL tests/perf/breakpoint.js || [ $? -eq 99 ]
  artifacts:
    when: always
    paths: [bp.json]
Cách nói khi phỏng vấn: "Pipeline có hai job. Job thứ nhất chạy với mọi merge request: tải trung bình lên staging, threshold lấy từ SLO, threshold sai thì k6 trả mã lỗi và merge request bị chặn. Job thứ hai chạy theo lịch: breakpoint test, vốn được thiết kế để gãy, nên mã 99 của k6 được coi là bình thường, còn kết quả là file JSON để người đọc ra con số capacity. Image k6 được ghim theo digest để hai lần chạy dùng đúng một phiên bản."

File YAML trên là mẫu viết cho bài (GitLab CI), chưa chạy thật trên một runner; digest là đúng image grafana/k6 (k6 v2.3.0) mà hai lab đã dùng.

flowchart TD
  MR["Merge request"] --> AVG["average-load trên staging<br/>threshold = SLO"]
  AVG --> GATE{"k6 exit code"}
  GATE -->|"0"| MERGE["Cho merge"]
  GATE -->|"khác 0"| BLOCK["Chặn, xem summary.json"]
  SCHED["Lịch định kỳ / trước mùa cao điểm"] --> BP["breakpoint trên staging<br/>ramping-arrival-rate"]
  BP --> READ["Đọc knee theo từng bậc<br/>từ Prometheus + JSON"]
  READ --> ADR["Cập nhật con số capacity trong ADR"]
  ADR --> GATECFG["Chỉnh mức tải của gate<br/>khoảng 1/2 – 2/3 knee"]
  GATECFG --> AVG

6.4 Capacity planning: từ knee đo được tới số node

1. Con số mang đi lập kế hoạch là max sustainable rate (mức tải cao nhất giữ được SLO): bậc cuối cùng còn đạt mọi tiêu chí, không phải bậc abort. Sau đó nhân với một hệ số headroom (khoảng dư chừa lại dưới capacity). Mức 60–80% là thông lệ phổ biến, không có nguồn chính thức nào quy định — SRE book chỉ nói phải cân bằng giữa utilization và biên an toàn [S32]; bài Tính con số rate limit dùng cùng khoảng này và lab ở đó cho thấy 90% là quá sát (p50 284 ms với thời gian xử lý 50 ms). Bốn lý do cần biên: độ trễ tăng phi tuyến gần bão hoà (hàng đợi), knee dịch giữa các lần chạy, traffic mix và dữ liệu production khác staging, và mất một node thì các node còn lại không được bị đẩy qua knee.

năng lực dùng được / node = knee_đo_được × headroom                     (headroom 0,6 – 0,8)
số node                    = ceil(peak_dự_kiến ÷ năng lực dùng được) + 1       (+1 = dự phòng N+1)
kiểm tra DB: số node × kết nối mỗi node   ≤ max_connections của Postgres
             peak × CPU Postgres mỗi request ≤ số core DB × headroom
Cách nói khi phỏng vấn: "Tôi lấy bậc tải cao nhất mà một node còn giữ được SLO, nhân với khoảng 0,7 để chừa chỗ cho dao động, ra năng lực dùng được của một node. Lấy đỉnh tải dự kiến chia cho con số đó, làm tròn lên, rồi cộng thêm một node để chết một node vẫn không vượt điểm gãy. Cuối cùng tôi kiểm phía database, vì thêm node ứng dụng thì tăng số kết nối và tăng CPU Postgres, và rất hay chính database mới là chỗ gãy."

2. Ví dụ có tính toán — PHP. Đỉnh tải 500 request/giây là con số minh hoạ; mọi con số còn lại lấy từ lab (một "app box" 2 vCPU, /api/db, pm.max_children = 20, PDO persistent, SLO p95 200 ms). Lab cho knee ở 150–180 rps: lượt sạch nhất (exp1 r2) đạt p95 109 ms ở 150 rps và 1,11 s ở 180 rps. Nhưng cũng lượt đó có những bậc thấp hơn vượt SLO vì host khựng 1–2 giây (343 ms ở 90 rps, 236 ms ở 120 rps), và hai lượt diag qua TCP có p95 265–282 ms ngay ở 120 rps. Hai trường phái cho ra hai con số:

Cách chọn kneeKnee / node× 0,7500 ÷ năng lực+ N+1
Theo định nghĩa của lab (vượt SLO và không hồi lại; bỏ qua cú khựng ngắn)150 rps105 rps4,76 → 56 node
Bảo thủ (cú khựng của host cũng là thật, production cũng có)120 rps84 rps5,95 → 67 node

Kiểm N+1 với phương án 6 node: mất một node, 5 node gánh 500 rps = 100 rps/node = 67% của 150 — vẫn dưới knee. Trường phái thứ nhất tin rằng headroom đã hấp thụ nhiễu; trường phái thứ hai cho rằng cú khựng là một phần của môi trường thật nên phải nằm trong con số. Trên host dùng chung như lab này tôi chọn phương án bảo thủ; trên máy riêng với bộ bắn tải riêng, nơi các lần chạy lặp lại sát nhau, phương án thứ nhất hợp lý hơn.

3. Kiểm phía database — chỗ ví dụ này thật sự gãy. Mỗi worker giữ một kết nối persistent, nên 6–7 node × 20 worker = 120–140 kết nối, vượt max_connections = 100. Lab đã cho thấy hậu quả: 120 worker với giới hạn 100 làm 16,7% request trả 500 FATAL: sorry, too many clients already ở mọi mức tải (đúng 20/120 worker không có kết nối). Phải có PgBouncer (connection pooler) hoặc nâng giới hạn — không phải giảm worker tuỳ tiện, vì 5 worker cho plateau chỉ ~88 rps. Về CPU: với kết nối persistent Postgres tốn khoảng 2 ms CPU mỗi request (pg ≈ 13% một core ở 60 rps), nên 500 rps ≈ 1,0 core — một Postgres một core sẽ ở 100%, trong khi lab Node cho thấy Postgres một core đã gãy ở 0,78–0,87 core. Cần ít nhất 1,0 ÷ 0,7 ≈ 1,43 → 2 core cho DB. Phép nhân tuyến tính từ một node lên nhiều node chưa verify (lab không dựng nhiều node).

4. Ví dụ — Node.js. Endpoint /cpu với node:cluster 2 worker trên 2 vCPU: bậc cuối đạt SLO (p95 100 ms) là 200 req/s (p95 35,6 ms), 250 req/s gãy (215,6 ms); các lượt thử trước gãy ngay ở 200, nên con số bảo thủ thấp hơn 200. Lấy 200: 200 × 0,7 = 140 → 500 ÷ 140 = 3,57 → 4 → +1 = 5 node. Nếu traffic là /db thay vì /cpu, phép tính đổi hẳn: lab 1a gãy ở 200 req/s vì Postgres (khoảng 3–3,5 ms CPU Postgres mỗi query) chứ không phải vì Node (ELU ≤ 0,56). 500 req/s × 3–3,5 ms = 1,5–1,75 core Postgres → ÷ 0,7 → 2,1–2,5 → 3 core. Thêm node Node.js không giúp gì khi knee nằm ở DB. Về kết nối: 2 worker × pool max 10 = 20 kết nối mỗi node, 5 node = 100 — chạm đúng max_connections mặc định, không còn chỗ cho migration, cron hay psql của người trực.

5. Đừng ngoại suy tuyến tính theo số core. Lab Node 2b: từ 1 lên 2 worker trên 2 core, knee theo SLO chỉ tăng ~1,3× (200 so với 150) và throughput bão hoà ~1,4–1,6×, không phải 2×, vì CPU mỗi request tăng từ 5,3–5,7 ms lên 6,3–8 ms khi cả hai vCPU cùng bận. USL (Universal Scalability Law) mô tả đúng hình dạng này: thành phần tranh chấp α kéo đường throughput nằm ngang, thành phần coherency β kéo nó đi xuống [S26]. Vì vậy cách an toàn là đo lại breakpoint với đúng số node dự kiến trên staging trước mùa cao điểm, thay vì nhân con số của một node.

flowchart TD
  K["Knee đo được<br/>bậc cuối còn đạt SLO"] --> H["× headroom 0,6 – 0,8<br/>(thông lệ, không có chuẩn)"]
  H --> N["Số node = ceil(peak ÷ năng lực) + 1"]
  N --> C{"Kết nối DB đủ?<br/>node × kết nối ≤ max_connections"}
  C -->|"không"| P["PgBouncer hoặc nâng giới hạn"]
  C -->|"có"| U{"CPU DB đủ?<br/>peak × CPU/request ≤ core × headroom"}
  P --> U
  U -->|"không"| DB["Scale DB trước, đo lại"]
  U -->|"có"| V["Breakpoint lại với đúng số node trên staging"]
  DB --> V
  V --> RL["Đổi capacity thành rate limit<br/>(bài rate-limit/cach-tinh-limit)"]

6. Con số capacity cuối cùng đi tiếp hai chỗ: giới hạn toàn hệ thống (rate limit hoặc concurrency limit) — cách đổi từ capacity sang ngưỡng, percentile theo user và burst nằm ở bài Tính con số rate limit; và cảnh báo utilization trên production (vd cảnh báo khi tải vượt năng lực dùng được ở trên), để biết trước khi chạm knee chứ không phải sau.

6.5 Bao lâu chạy lại một lần

Sự kiệnLoại testLý do
Mỗi merge requestsmoke + average-load, threshold làm gateBắt regression sớm, rẻ
Đổi hạ tầng: loại máy, số core, pm.max_children, số worker cluster, pool size, bản PHP/Node/PostgresbreakpointKnee là thuộc tính của cấu hình; đổi cấu hình là con số cũ hết giá trị [S1]
Trước mùa cao điểm / chiến dịchbreakpoint + spikeBiết điểm gãy và cách gãy trước khi traffic tới (bài học Pokémon GO, mục 9)
Định kỳ, vd mỗi quý (nhận định, không có nguồn)breakpointDữ liệu lớn dần làm query chậm dần dù code không đổi
Sau mỗi lần tuningbreakpoint lặp ≥ 3 lầnGrafana: "repeat after each tuning" [S1]; Gregg: "Does it reproduce?" [S34]

6.6 Chi phí và gánh nặng cho team

1. Môi trường: một staging giống production về cấu hình và dữ liệu, tắt autoscaling (nếu không, thứ đo được là "the limit of your cloud account bill" [S1]), và một máy bắn tải riêng. Lab này phải chạy k6 cùng host với app (chỉ ghim vào core khác) và trả giá bằng nhiễu: bộ dò khựng ghi 94 lần thức dậy trễ quá 30 ms trong 130 s, tối đa 147 ms, dồn vào đầu mỗi phút; vì vậy knee chỉ báo được dưới dạng khoảng ±1 bậc.

2. Thời gian người: một lần chạy k6 thì ngắn (lab Node: 1a từ k6 start 12:07:40 tới done 12:09:17, 3b khoảng 3,5 phút), nhưng chi phí thật nằm ở việc làm cho phép đo đáng tin. Lab Node bỏ đi năm vòng thử (attempts-v1 tới v5): pool lạnh, lab PHP chạy chồng, cú khựng đầu phút rơi vào cửa sổ đo, delayAbortEval tính từ đầu test, abort trên cả cửa sổ ramp. Mỗi lỗi đó cho ra một con số sai trông rất hợp lý. Ngân sách hợp lý cho một breakpoint test có người đọc là cả một buổi của một người hiểu USE và lý thuyết hàng đợi, không phải "bấm chạy 5 phút".

3. Kỹ năng: viết script k6 (JavaScript, nhưng không phải Node — không dùng được fs, npm package phải bundle [S15]); đọc status page php-fpm, event-loop delay, pg_stat_activity; hiểu open/closed model và coordinated omission. Người viết script và người đọc dashboard server nên ngồi cùng nhau trong lần chạy.

4. Trực đêm: breakpoint test không gây sự cố 3 giờ sáng vì chạy trên staging trong giờ làm. Nó giảm sự cố 3 giờ sáng bằng cách cho người trực một con số: "vượt X rps/node là đang ở vùng gãy, triệu chứng đầu tiên là listen queue / pool.waitingCount tăng". Runbook nên ghi chính các triệu chứng lab đã thấy (bảng ở 6.1).

5. Chi phí tool: k6 OSS miễn phí; Grafana Cloud k6 tính theo VUh và tính cả maxVUs dù không dùng tới [S6] (bảng giá chưa nghiên cứu, chưa verify). Chi phí lớn hơn thường là staging giống production được giữ chạy.

7. So sánh và ma trận quyết định

1. Trục quan trọng nhất cho breakpoint test là open model (lịch gửi request độc lập với độ trễ của server): tool nào chỉ có closed model thì tự giảm tải khi server chậm và che mất điểm gãy (lab 3c, mục 9). Trục thứ hai là cách tool xử lý request "lẽ ra phải gửi" khi bị trễ: wrk2 đo độ trễ từ thời điểm dự kiến gửi (sửa coordinated omission) [S22]; k6 bỏ iteration và đếm vào dropped_iterations, không lùi mốc thời gian [S4][S24].

ToolOpen modelViết kịch bảnHiệu quả tài nguyênPhân tánBáo cáoLearning curveTuyển người (nhận định)
k6Có — constant/ramping-arrival-rate [S3]; hết VU thì bỏ iterationJS/TS chạy trên Sobek (không phải Node) [S15]Go; ~1–5 MB/VU, 30–40k VU/instance theo docs [S8]k6-operator (Kubernetes) / Grafana Cloud; OSS không có master-worker sẵn (chi tiết chưa verify)Summary + threshold, JSON/CSV, Prometheus RW, web dashboard [S16][S17][S21]Thấp với devDễ: ai biết JS đều đọc được
JMeterMột phần — Open Model Thread Group (gắn nhãn experimental), throughput timer [S46]GUI + XML; Groovy/JSR223JVM, một thread mỗi user (nặng hơn theo review 2020 của k6, có thiên vị) [S13]Có remote controller/agent (chi tiết chưa verify)HTML dashboard, listenerGUI dễ bắt đầu; bảo trì plan lớn khóPhổ biến với QA/perf tester
GatlingCó — injectOpen (constantUsersPerSec, rampUsersPerSec) và injectClosed [S47]Java/Kotlin/ScalaJVM, bất đồng bộ (chưa có số)Clustering ở bản trả phí (chưa verify)HTML report đầy đủTrung bìnhHợp đội JVM
LocustKhông thật sự — user lặp với wait_time; constant_throughput chỉ là trần "at most" mỗi user [S48]PythonPython/gevent, chậm hơn mỗi core theo review 2020 [S13]Master/worker có sẵn (chi tiết chưa verify)Web UI, CSVThấp với đội PythonHợp đội Python
wrkKhông — số kết nối cố định (closed)Lua hookC, rất nhẹKhôngText tối giảnThấpKhông cần tuyển riêng
wrk2Có — -R throughput cố định, đo từ thời điểm dự kiến, HdrHistogram → sửa CO [S22]LuaC, rất nhẹKhôngPercentile HdrHistogramThấpKhông cần tuyển riêng
VegetaCó — "constant request rate", README nói tránh CO [S49]CLI targets / thư viện GoGo, nhẹKhông (chạy nhiều bản)Text/JSON/HDR plotThấpKhông cần tuyển riêng
ArtilleryCó — phase theo arrivalRate VU mới, rampTo, trần maxVusers [S50]YAML + JS (Node, dùng được npm) [S13]Node; cảnh báo khi quá 80% một core (2020) [S13]Cloud runner (chưa verify)JSON/HTMLThấpHợp đội Node

2. Về số liệu so sánh hiệu năng giữa các tool: benchmark nhiều tool duy nhất tìm được là bài review năm 2020 của chính k6, tác giả Ragnar Lönn — đồng sáng lập k6 [S13]. Vừa là nhà cung cấp tự chấm, vừa cũ 6 năm (k6 khi đó chưa có 1.0, Artillery và Locust đã đổi nhiều). Cột "hiệu quả tài nguyên" ở trên vì vậy chỉ nên đọc như hướng, không phải thứ hạng. Cột "tuyển người" là nhận định, không có số liệu thị trường.

3. Kiểm chéo: cách rẻ để không phải tin một tool là bắn cùng endpoint bằng hai tool open model (vd k6 và wrk2 hoặc Vegeta) từ cùng máy; nếu knee trùng nhau và server cho thấy một tài nguyên bão hoà tại đó thì con số đáng tin [S34]. Lab trong bài chỉ dùng k6 — kiểm chéo bằng tool thứ hai chưa làm.

Dùng k6 khi team viết JS/TS, muốn test nằm trong git, chạy trong CI với threshold làm gate, đẩy số vào Prometheus/Grafana, và cần ramp theo arrival rate cho breakpoint/capacity test trên API. Dùng wrk2/Vegeta cho một đường cong bão hoà nhanh trên một endpoint với bộ bắn tải nhẹ nhất, hoặc để kiểm chéo số của k6. Dùng JMeter khi QA sở hữu test qua GUI và cần nhiều giao thức (JDBC, JMS...). Dùng Gatling trong đội JVM muốn code-first và report đẹp. Dùng Locust trong đội Python — nhưng đừng lấy số breakpoint từ vòng lặp user mặc định (closed model). Tránh k6 khi script cần npm package hay API của Node, hoặc khi team không ai viết và bảo trì code JS.

8. Góc nhìn tech lead: tác động tổ chức

8.1 Ai sở hữu cái gì

1. Đội sản phẩm sở hữu script (trong repo của service, review như code): kịch bản, dữ liệu test, tag theo endpoint. Lý do: người đổi endpoint là người biết endpoint đắt lên ở đâu, và threshold trong CI chỉ có tác dụng khi người bị chặn merge cũng là người sửa được.

2. Platform/SRE sở hữu môi trường và cách đọc: staging giống production, máy bắn tải riêng, Prometheus nhận remote write, dashboard đặt metric k6 cạnh metric SUT, lịch chạy breakpoint. Tech lead sở hữu con số capacity trong ADR, kèm điều kiện đo — vì con số đó quyết định số node, ngân sách và rate limit.

8.2 SLO trước, test sau

Knee chỉ có nghĩa so với một SLO: lab Node dùng p95 < 100 ms và lấy được knee 150–175 req/s cho /cpu; với /dbsleep, lượt 1b (SLO 100 ms) gãy ở bậc 350 req/s, còn lượt 1c nới SLO lên 2 s (và hạ pool timeout xuống 1 s) để chính lỗi 5xx cắt test thì "điểm gãy" lùi tới bậc 550–600 req/s (0,90% rồi 18,86% lỗi). Cùng endpoint, hai định nghĩa, hai con số. Nếu team chưa thống nhất SLO, breakpoint test chỉ đo được "lúc sập" — con số muộn nhất và vô dụng nhất. Thứ tự đúng: chốt SLO (percentile, ngưỡng, tỉ lệ lỗi), viết thành threshold, rồi mới chạy. Định nghĩa vận hành của knee dùng trong bài: bậc thấp nhất mà p95/p99 vượt SLO, hoặc lỗi vượt ngân sách, hoặc throughput thực ngừng bám tải đặt ra, hoặc xuất hiện dropped_iterations — kèm một tài nguyên bão hoà trên server [S31][S32][S33].

8.3 License AGPL-3.0

k6 OSS dùng AGPL-3.0 (spdx_id của repo grafana/k6 trên GitHub) [S30]. Phần dưới là cách hiểu của bài, không phải tư vấn pháp lý, chưa verify với pháp chế:

8.4 Grafana Cloud k6 hay OSS: lock-in

Trụck6 OSS tự chạyGrafana Cloud k6
Bắn tải phân tánTự lo (nhiều máy, k6-operator trên Kubernetes)Có sẵn, nhiều vùng
Lưu và xem kết quảPrometheus/Grafana của mình qua remote writeTrên cloud của Grafana
Threshold / abortĐánh giá liên tục trong tiến trìnhĐánh giá mỗi 60 s → abort có thể trễ tới 60 s [S7]
Chi phíMáy + ngườiTính theo VUh, maxVUs bị tính kể cả không dùng [S6]
Lock-inScript dùng API riêng của k6 (k6/http, executor); metric ở định dạng Prometheus trung lậpThêm lock-in ở lớp lưu trữ, dashboard, lịch chạy

Khuyến nghị: dashboard và lịch sử kết quả nằm ở Prometheus/Grafana của mình (qua remote write) kể cả khi dùng cloud để bắn tải — phần khó chuyển nhất (lịch sử capacity qua các release) khi đó không bị khoá. Từ 1.0, k6 theo SemVer và hỗ trợ mỗi major ≥ 2 năm [S29]; nhưng 2.0 đã gỡ một số tính năng deprecated [S30], nên ghim phiên bản và đọc release note khi nâng.

8.5 Exit strategy

Thứ cần giữ độc lập với tool là load model: các mức arrival rate theo bậc, tỉ lệ traffic mix giữa endpoint, think time, bộ dữ liệu và quy mô của nó, SLO dùng làm threshold. Viết nó thành tài liệu riêng (hoặc một file cấu hình mà script đọc vào), không chôn trong code k6. Khi đó mọi tool open model (Gatling injectOpen, Vegeta, wrk2 cho từng endpoint) dựng lại được cùng phép đo; phần phải viết lại chỉ là lớp script. Metric đã ở Prometheus nên dashboard không phải làm lại.

flowchart TD
  LM["Load model (tài liệu riêng)<br/>bậc tải, mix, data, SLO"] --> K6["k6 script"]
  LM --> GT["Gatling injectOpen"]
  LM --> VG["Vegeta / wrk2<br/>từng endpoint"]
  K6 --> PR["Prometheus của team"]
  GT --> PR
  VG --> PR
  PR --> DASH["Dashboard + lịch sử capacity"]

8.6 Mẫu ADR chép được

## ADR-NN: Capacity của service X và cách đo

Bối cảnh: cần số node cho mùa cao điểm; SLO p95 < 200 ms, lỗi < 1%.
Quyết định:
- Đo bằng k6 (ramping-arrival-rate, bậc 20 s), trên staging, autoscaling tắt,
  máy bắn tải riêng, dữ liệu cỡ production, chạy 3 lần.
- Capacity = bậc cuối đạt SLO ở lần chạy tệ nhất; lập kế hoạch ở 70% con số đó, +1 node.
- Ghi kèm: commit, phiên bản PHP/Node/Postgres, pm.max_children / pool size, traffic mix.
Hệ quả:
- Đo lại khi đổi bất kỳ thứ nào trong danh sách trên.
- Rate limit toàn hệ thống suy ra từ con số này (xem bài rate limit).
- Tool: k6 OSS (AGPL-3.0, chỉ dùng nội bộ); kết quả ở Prometheus của team; load model
  lưu riêng để chạy lại bằng tool khác nếu cần.
Cách nói khi phỏng vấn: "Trong ADR tôi không ghi một con số trơ trọi mà ghi cả cách ra con số: tool, kiểu tải, môi trường, dữ liệu, số lần chạy, và lấy lần tệ nhất. Kế hoạch dùng 70% con số đó cộng một node dự phòng. Tôi cũng ghi danh sách những thay đổi bắt buộc phải đo lại, để một năm sau người khác đọc vẫn biết con số còn dùng được hay không. Cuối cùng là ghi license và nơi lưu kết quả, để đổi tool thì không mất lịch sử."

9. Anti-patterns và bài học thực tế

Danh sách gồm các lỗi có nguồn công khai và các lỗi chính hai lab trong bài đã mắc hoặc đã đo được.

  1. Breakpoint bằng closed model (ramping-vus, vòng lặp user của Locust, thread của JMeter). Mỗi VU chờ response rồi mới gửi tiếp, nên server chậm thì tải tự giảm — knee trông cao hơn và êm hơn thực tế; đây chính là coordinated omission [S3][S23]. Chính tutorial "test for performance" của Grafana dùng ramping-vus cho ví dụ breakpoint [S9]. Lab Node đo cùng endpoint /cpu bằng hai model, không abort:

    # 3c closed model (ramping-vus + sleep 0.2), bậc 60 VUs
          { step:06_vus60 }............: avg=151.34ms med=140.08ms p(90)=204.03ms p(95)=261.87ms p(99)=439.02ms max=466.83ms
        http_req_failed................: 0.00%  0 out of 21531
        dropped_iterations.............: 0      0/s
    # 3b open model (ramping-arrival-rate), bậc 200 req/s
          { step:04_rps200 }...........: avg=1.1s     med=595.98ms p(90)=4.99s    p(95)=5s       p(99)=5s       max=5.16s
        http_req_failed................: 13.65% 3834 out of 28078
        dropped_iterations.............: 2574   12.679184/s
    Cách nói khi phỏng vấn: "Cùng một server đã bão hoà, bản đo kiểu đóng báo p95 khoảng 260 mili-giây, không lỗi nào, không iteration nào bị bỏ — nhìn như chỉ hơi chậm. Bản đo kiểu mở ở mức tải tương đương báo p95 chạm timeout 5 giây, gần 14% request lỗi trên cả lần chạy và hơn hai nghìn rưỡi iteration không gửi được. Khác nhau vì user ảo kiểu đóng ngồi chờ server nên tự gửi chậm lại, còn người dùng thật thì không chờ nhau. Kiểu đóng chỉ báo 'chậm hơn', không bao giờ báo 'gãy'."
  2. Bỏ qua dropped_iterations. Khi k6 hết VU, iteration không được gửi và không có mặt trong số liệu độ trễ — báo cáo độ trễ lúc đó là độ trễ của một mức tải chưa từng được tạo ra [S4]. Lab PHP: các lượt chạm maxVUs 600 đều có cảnh báo Insufficient VUs và những request đó không bao giờ tới server, nên đếm lỗi phía server đánh giá thấp thiệt hại.

  3. Bộ bắn tải 100% CPU hoặc chạy cùng máy với SUT. k6 dùng hết CPU thì "result metrics ... have a much larger response time than in reality" [S8]. Lab này chạy cùng host (ghim core riêng) và vẫn bị nhiễu: chi phí CPU của cùng một request PHP ở cùng mức tải dao động 7–14 ms giữa các lượt, Node có cú khựng 300–1 300 ms ở ranh giới phút trong các vòng thử đầu. Kết quả: knee chỉ báo được dưới dạng khoảng, và mọi so sánh phải chạy lặp.

  4. Chỉ đọc trung bình. Summary mặc định không có p99 [S16]; Gil Tene: trung bình giấu outlier, percentile và max mới là thứ người dùng cảm thấy [S23]. Thêm p(99) vào summaryTrendStats ngay từ đầu.

  5. Để autoscaling bật trong lúc tìm điểm gãy — đo được trần hoá đơn cloud chứ không phải trần hệ thống [S1].

  6. Một endpoint, một user ID, DB rỗng. Cache trúng 100% cho kết quả đẹp phi thực tế; hoặc ngược lại, mọi request tranh một dòng và tạo hot lock. Dữ liệu và số lượng khoá phải giống production (tổng hợp; SRE book nhắc phải theo dõi state và kiểm tra tính đúng ở tải cao với service có state [S32]).

  7. Lấy điểm abort làm capacity. Threshold trong k6 tính trên số cộng dồn từ đầu test, nên abort luôn trễ hơn knee. Lab Node 3a: bậc 30 VUs đã có p95 143,2 ms (SLO 100 ms) nhưng abort chỉ xảy ra trong bậc 40 VUs; lab PHP exp1 r2: bậc 180 rps p95 1,11 s, abort ở đầu bậc 210. Abort là lúc đã gãy; capacity là bậc cuối cùng còn đạt SLO, đọc từ sub-metric theo từng bậc, rồi trừ headroom [S1][S32].

  8. delayAbortEval tính từ lúc bắt đầu test, không phải từ lúc scenario bắt đầu (lab Node, attempts-v3): với warm-up 20 s và delayAbortEval: '20s', abort bắn ~24 s sau khi k6 khởi động, tức 4 s sau khi breakpoint scenario chạy. Script cuối dùng WARMUP + 30s. Tương tự, đặt threshold abort trên cả cửa sổ ramp (attempts-v4) làm một cú khựng trong ramp cắt test quá sớm.

  9. Lỗi được tính như thành công nhanh. Trang 500/502 trả về rất nhanh nên trung bình độ trễ giảm khi hệ thống gãy. Lab PHP: 120 worker vượt max_connections làm 16,7% request trả 500 ở mọi mức tải, nhanh với client — nhưng mỗi lỗi ghi một stack trace đầy đủ, log php-fpm của lượt ~60 s đó dài 122 132 dòng. Luôn đọc độ trễ cùng http_req_failed và lọc {expected_response:true} [S34].

  10. Timeout lệch nhau giữa k6 (mặc định 60 s [S18]), nginx fastcgi_read_timeout (mặc định 60 s [S38]), PHP và request_terminate_timeout [S36]: kết quả là một mớ 499/502/504/status 0 khó đọc. Lab đặt rõ (nginx 5 s, k6 10 s ở PHP; k6 5 s ở Node) để biết bên nào bỏ cuộc trước.

  11. maxVUs thật lớn "cho chắc". Cấp VU giữa chừng làm méo kết quả, và trên cloud thì bị tính tiền dù không dùng [S6]. Tính preAllocatedVUs theo Little's law (tốc độ × thời gian một iteration ở đầu chậm + dư).

  12. Hình dạng tải "tàu lượn" lên xuống nhiều lần — không còn quy được hiệu ứng về mức tải nào [S2].

  13. Bài học công khai — ra mắt Pokémon GO (2016). Team đặt mục tiêu tải 1×, kịch bản xấu nhất ≈ 5×; traffic thật lên 50× mục tiêu, gấp 10 lần kịch bản xấu nhất; hệ thống trụ được vì Google CRE đã chuẩn bị capacity trước [S51]. Bài học cho breakpoint test: biết mình gãy ở đâu và gãy theo kiểu nào, không chỉ biết mình qua được đỉnh dự kiến.

  14. Bài học AWS: "If they haven't load tested their service to the point where it breaks, and far beyond the point where it breaks, they should assume that the service will fail in the least desirable way possible"; kết quả lý tưởng khi quá tải là goodput (phần throughput dùng được) đi ngang chứ không rơi về 0, và việc từ chối một request phải rẻ — một dòng log vô tình cũng làm load shedding đắt lên [S31]. Lab PHP thấy đúng kiểu gãy tệ nhất: /api/slow với 5 worker, offered 60 rps → 26,4 ok/s, 90 rps → 0 ok/s, nginx trả 504 trong khi php-fpm vẫn chạy các request client đã bỏ.

  15. Bài học SRE: test cả khả năng hồi phục — hệ thống có tự thoát chế độ suy giảm không, tải phải giảm bao nhiêu thì mới thoát [S32]. Lab trong bài chưa đo recovery.

  16. Kết nối Postgres mới cho mỗi request (mặc định của Laravel). Lab PHP exp0: không dùng persistent PDO, knee rơi xuống 40–60 rps (so với 150–180 rps khi persistent), Postgres một core ở 89–97% trong khi CPU app chỉ dùng một nửa; mỗi kết nối mới tốn ~6,2 ms CPU phía PHP so với 0,5 ms khi dùng lại (bắt tay SCRAM-SHA-256), thêm một lần fork backend phía Postgres. Breakpoint test là cách rẻ nhất để phát hiện nút cổ chai "không nằm ở code".

  17. Nhiều php-fpm worker hơn max_connections của Postgres. 120 worker persistent với giới hạn 100: 20 worker không bao giờ có kết nối, 16,7% request lỗi SQLSTATE[08006] ... FATAL: sorry, too many clients already ở mọi mức tải — kể cả 50 rps. Đây là lỗi cấu hình, không phải lỗi tải, nhưng chỉ lộ ra khi đủ request để chạm tới mọi worker.

  18. "Thêm worker là tăng capacity". Lab PHP: 20 và 80 worker cho cùng khoảng knee 150–180 rps vì 2 CPU đã là giới hạn; 80 worker tốn thêm +107 MiB php-fpm và +106 MiB Postgres (80 backend), và qua knee thì 80 request runnable chia nhau 2 CPU nên request nào cũng chậm. Thêm worker chỉ giúp khi worker đang chờ (IO), không giúp khi đang tính — Little's law dự đoán đúng plateau khi pool là nút cổ chai (5 worker × 112 ms → 44,6 rps dự đoán, đo 43,5).

  19. Pool lạnh khi ramp. Lab Node attempts-v1: idleTimeoutMillis mặc định 10 000 ms làm pool co còn 1–2 kết nối trong warm-up 20 req/s; khi ramp, app mở 8–10 kết nối cùng lúc, eld_max lên 110–328 ms và bậc 100 req/s đầu tiên có p95 278 ms — một knee giả ở mức tải rất thấp. Bản cuối đặt idleTimeoutMillis: 0 và mở sẵn đủ max kết nối lúc khởi động (min của pg không tự tạo kết nối). Tương tự với OPcache lạnh hay JIT của V8: luôn có warm-up trước bậc đo đầu tiên.

  20. Một lần chạy là một kết luận. Lượt PHP r1 abort ở 120 rps chỉ vì một cú khựng 2 giây (completion rơi còn 29–35/giây trong khi php-fpm vẫn dùng ~107% CPU); lượt r2 cùng cấu hình chạy sạch tới 150 rps. Chạy ≥ 3 lần, báo trung vị và khoảng [S34].

10. Câu hỏi phụ interviewer hay hỏi tiếp

Vì sao không lấy thời điểm k6 abort làm capacity?
Threshold tính trên số cộng dồn từ đầu test nên abort luôn trễ ít nhất một bậc (lab Node 3a: bậc 30 VUs đã vượt SLO, abort ở bậc 40). Abort là lúc đã gãy; capacity là bậc cuối còn đạt SLO, đọc từ sub-metric theo bậc, rồi trừ headroom.
Headroom 60–80% lấy từ đâu?
Thông lệ, không có chuẩn chính thức; SRE book chỉ nói phải cân utilization với biên an toàn. Lý do kỹ thuật: thời gian chờ tăng phi tuyến gần bão hoà, knee dịch ±1 bậc giữa các lần chạy, production khác staging, và phải chịu được mất một node.
Tăng pm.max_children có tăng throughput không?
Chỉ khi worker đang chờ IO. Lab: 5 worker là nút cổ chai (~88 rps dù CPU còn dư), 20 và 80 worker cùng knee 150–180 rps vì CPU đã hết, 120 worker vượt max_connections 100 → 16,7% lỗi 500.
Node trên máy 2 core, CPU container mới 50% mà đã chậm — vì sao?
JavaScript của một tiến trình chạy trên một core: lab 2a có một core 100%, core kia 5–9%. Nhìn event-loop delay và ELU, không nhìn CPU tổng. node:cluster 2 worker nâng knee từ 150 lên 200 req/s (~1,3×), không phải 2×.
Đưa breakpoint test vào CI thế nào?
Không làm gate, vì nó được thiết kế để gãy. Gate là average-load với threshold theo SLO, chạy ở khoảng 1/2–2/3 knee nơi kết quả ổn định; breakpoint chạy theo lịch, coi exit 99 là bình thường và xuất JSON/Prometheus để người đọc.
Số đo trên staging có dùng cho production được không?
Chỉ khi staging giống về loại máy, cấu hình pool, dữ liệu và traffic mix, và bộ bắn tải nằm máy riêng. Dù vậy vẫn là cận trên lạc quan — production có noisy neighbor, cache lạnh, cron; vì thế mới có headroom và cảnh báo utilization trên production.
Làm sao biết knee là của server chứ không phải của k6?
CPU của k6 dưới 80% (lab PHP: container k6 19–25%), không có dropped_iterations ngay từ đầu, mạng chưa chạm băng thông, và server có một tài nguyên bão hoà đúng lúc đó (listen queue, pool waiting, ELU). Tốt hơn nữa: tool thứ hai cho cùng knee.
k6 là AGPL — công ty dùng được không?
Chạy nội bộ để test thì hệ thống bị test không bị lây license; nghĩa vụ công bố mã phát sinh khi sửa/đóng gói k6 rồi phân phối hoặc cung cấp như dịch vụ cho bên ngoài. Vẫn phải kiểm chính sách nội bộ — có nơi cấm AGPL theo mặc định. (Cách hiểu, chưa verify với pháp chế.)
Khi nào closed model mới là đúng?
Khi hệ thống thật cũng đóng: số client cố định và mỗi client chờ response mới gửi tiếp — worker xử lý batch, một service nội bộ có giới hạn concurrency. Với traffic công khai (người dùng đến độc lập với độ trễ) thì phải dùng arrival rate [S3].
Có capacity rồi thì đặt rate limit bao nhiêu?
Giới hạn toàn hệ thống ở 60–80% capacity (hoặc concurrency limit theo instance), giới hạn theo user từ percentile của log thật — chi tiết và lab ở bài Tính con số rate limit.

Chưa verify

Đã được lab xác nhận (không còn trong danh sách trên): threshold abort tính cộng dồn và trễ hơn knee; delayAbortEval đếm từ đầu test; ánh xạ 502/504 với php-fpm (TCP: 504 khi hết fastcgi_read_timeout hoặc khi backlog đầy lúc connect; Unix socket: 502 tức thì với EAGAIN).

Phụ lục: lab đã chạy

Môi trường

Thành phầnPhiên bản / cấu hình
HostVPS KVM 4 vCPU AMD EPYC, 7,8 GB RAM (7 941 MB), không swap, kernel 6.8.0-147-generic, Docker 29.3.1; host dùng chung với các service khác, steal luôn báo 0
Bộ bắn tảik6 v2.3.0 (commit/e088784614, go1.27.1), image grafana/k6@sha256:3e4186ae…; cùng host, ghim --cpuset-cpus 0 --memory 1g
App PHPLaravel 13.39.0, PHP 8.5.12 (fpm-fcgi, NTS), OPcache bật (validate_timestamps=0, JIT tắt), nginx 1.31.8 (worker_processes 2, fastcgi_read_timeout 5s); pm = static, listen.backlog = 511, PDO persistent (trừ exp0); php-fpm + nginx ghim chung --cpuset-cpus 2,3 (php-fpm 768 MB, nginx 256 MB)
App NodeNode.js v26.13.0 (node:lts-alpine), Express 5.3.0, pg 8.23.1 (Pool max=10, mở sẵn, idleTimeoutMillis=0, connectionTimeoutMillis=3000); WORKERS=1 hoặc node:cluster 2; ghim --cpuset-cpus 2,3 --memory 1g
PostgreSQL18.6 (postgres:18-alpine / postgres:alpine, cùng digest), max_connections=100, scram-sha-256; ghim --cpuset-cpus 1 --memory 512m. PHP: bảng 10k dòng; Node: bảng orders 1 000 000 dòng (86 MB), shared_buffers=128MB, pg_prewarm
MạngBridge riêng tmp-perf-php-net / tmp-perf-node-net, không publish port, không TLS
Cách ly giữa hai labHai lab dùng cùng cpuset; mọi lần bắn tải lấy chung flock .../k6-breakpoint/load.lock và chờ container lab kia rảnh; lab Node căn bậc theo ranh giới phút để cú khựng đầu phút rơi vào cửa sổ ramp không được đo

Chạy lại

cd /var/www/chiennn/cv/labs/performance/php
./setup.sh                      # image tmp-perf-php-fpm, app Laravel tạm, Postgres + 10k dòng
./run.sh exp1                   # /api/db, max_children 20: bắn tải mở theo bậc tới khi gãy
./run.sh exp2 | exp12           # max_children 5 / 80 (exp12 = 5, 20, 80 liền nhau); ROUND=2 thêm hậu tố -r2
./run.sh exp2-pgconn            # max_children 120 > max_connections 100
./run.sh exp3 | exp3sat         # /api/slow (pg_sleep 100 ms): Little's law, và đẩy quá capacity
./run.sh exp0                   # mặc định Laravel: kết nối mới mỗi request
./run.sh fail-unix | diag       # failure mode qua Unix socket; TCP vs unix + /api/ping
ACCESSLOG=1 ./run.sh diag-acc   # %d/%C của php-fpm + thời gian upstream của nginx cho /api/slow
./cpu-per-request.sh 10 300     # CPU mỗi request khi chạy tuần tự, từ cgroup
./calib.sh                      # vòng lặp CPU cố định trên cpu 2 (vCPU có ổn định không)
./cleanup.sh [--app]            # xoá container / volume / network / image (và app)
Cách nói khi phỏng vấn: "Mỗi thí nghiệm PHP là một lệnh: dựng lại php-fpm và nginx sạch với số worker cần thử, khởi động nóng, rồi bắn tải kiểu mở theo bậc dưới một khoá chung để không đụng lab khác. Có thêm hai script đo chi phí CPU mỗi request và kiểm tra bản thân vCPU có ổn định không, vì trên máy ảo dùng chung đó là nguồn nhiễu lớn nhất."
cd /var/www/chiennn/cv/labs/performance/nodejs
bash setup.sh                 # network, Postgres + seed, image app (không bắn tải)
bash run.sh all               # 1a 1b 1c 2a 2b 3a 3b 3c, mỗi lần k6 lấy khoá flock chung
python3 analyze.py out/2a-cpu-single-open
bash noise-check.sh           # bộ dò khựng của host (chạy dưới khoá nếu lab khác đang chạy)
bash cleanup.sh               # xoá container tmp-perf-node-*, network, image app
Cách nói khi phỏng vấn: "Lab Node có một script dựng môi trường, một script chạy tám thí nghiệm, và một script phân tích đọc file JSON tổng kết để ra bảng theo từng bậc. Tôi chạy thêm một bộ dò khựng của host để biết nhiễu ở đâu ra — chính nó cho thấy máy khựng vào đầu mỗi phút, nên tôi căn các bậc tải để ranh giới phút rơi vào khoảng không đo."

Script → làm gì → dùng ở mục nào

ScriptLàm gìMục
php/k6/stepped.jsramping-arrival-rate theo bậc, tag stage mỗi bậc, threshold abort cộng dồn + sub-metric từng bậc5, 9
php/run.sh exp0Kết nối Postgres mới mỗi request (Laravel mặc định)5, 9
php/run.sh exp1, ROUND=2 … exp12/api/db, 20 worker: knee 150–180 rps5, 6.4, 9
php/run.sh exp2, exp125 / 20 / 80 worker trên cùng 2 CPU5, 9, 10
php/run.sh exp2-pgconn120 worker > max_connections 100 → 16,7% lỗi 5005, 6.4, 9
php/run.sh exp3, exp3sat/api/slow: Little's law với 5/10/20/40 worker, đẩy quá capacity → 5045, 9
php/run.sh fail-unix, diag, diag-accUnix socket vs TCP (502 vs 504, status page mù), thời gian trong backlog5, 6.1
php/cpu-per-request.sh, php/calib.shCPU mỗi request; độ ổn định của vCPU5, 6.4
php/analyze.py, php/persec.pyBảng theo bậc từ CSV k6 + CPU cgroup; vết theo từng giây5
nodejs/k6/breakpoint.jsMột script tham số: warm-up + breakpoint open/closed, bậc RAMP 8 s + HOLD 22 s, abort chỉ đọc HOLD5, 9
nodejs/run.sh 1a, 1b/db (Postgres là nút cổ chai), /dbsleep (pool 10 là nút cổ chai)5, 6.1, 6.4
nodejs/run.sh 1cPool cạn với connectionTimeoutMillis=1000 → 5005, 6.1
nodejs/run.sh 2a, 2b/cpu một tiến trình vs node:cluster 2 worker5, 6.1, 6.4, 10
nodejs/run.sh 3a, 3b, 3cClosed vs open, có và không abort5, 6.3, 9
nodejs/analyze.py, nodejs/sampler.pyBảng theo bậc từ summary.json; CPU cgroup mỗi container + quiesce5
nodejs/microbench.sh, nodejs/noise-check.shChi phí pbkdf2Sync mỗi lần gọi; bộ dò khựng của host5, 6.6

Ghi chú gốc của lab

Nguồn

  1. [S1] k6 docs — Breakpoint testing: grafana.com/docs/k6/latest/testing-guides/test-types/breakpoint-testing/
  2. [S2] k6 docs — Load test types: grafana.com/docs/k6/latest/testing-guides/test-types/
  3. [S3] k6 docs — Open and closed models: grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/
  4. [S4] k6 docs — Dropped iterations: grafana.com/docs/k6/latest/using-k6/scenarios/concepts/dropped-iterations/
  5. [S5] k6 docs — Built-in metrics reference: grafana.com/docs/k6/latest/using-k6/metrics/reference/
  6. [S6] k6 docs — Arrival-rate VU allocation: grafana.com/docs/k6/latest/using-k6/scenarios/concepts/arrival-rate-vu-allocation/
  7. [S7] k6 docs — Thresholds: grafana.com/docs/k6/latest/using-k6/thresholds/
  8. [S8] k6 docs — Running large tests: grafana.com/docs/k6/latest/testing-guides/running-large-tests/
  9. [S9] k6 docs — Get started: test for performance: grafana.com/docs/k6/latest/examples/get-started-with-k6/test-for-performance/
  10. [S10] k6 docs — Ramping arrival rate: grafana.com/docs/k6/latest/using-k6/scenarios/executors/ramping-arrival-rate/; instant load increase: link; distribute workloads: link
  11. [S11] k6 docs — Constant arrival rate: grafana.com/docs/k6/latest/using-k6/scenarios/executors/constant-arrival-rate/
  12. [S12] k6 docs — Glossary (Sobek): grafana.com/docs/k6/latest/reference/glossary/; extension template: link
  13. [S13] Ragnar Lönn, "Open source load testing tool review: 2020" (blog k6, do nhà cung cấp viết): k6.io/blog/comparing-best-open-source-load-testing-tools/
  14. [S14] k6 docs — Test lifecycle: grafana.com/docs/k6/latest/using-k6/test-lifecycle/
  15. [S15] k6 docs — Modules: grafana.com/docs/k6/latest/using-k6/modules/; JS compatibility mode: link
  16. [S16] k6 docs — Options reference: grafana.com/docs/k6/latest/using-k6/k6-options/reference/; custom summary: link
  17. [S17] k6 docs — CSV output: grafana.com/docs/k6/latest/results-output/real-time/csv/; analyze results (JSON): link
  18. [S18] k6 docs — HTTP Params: grafana.com/docs/k6/latest/javascript-api/k6-http/params/
  19. [S19] k6 docs — setResponseCallback: grafana.com/docs/k6/latest/javascript-api/k6-http/set-response-callback/
  20. [S20] k6 docs — Ramping VUs: grafana.com/docs/k6/latest/using-k6/scenarios/executors/ramping-vus/; graceful stop: link
  21. [S21] k6 docs — Web dashboard: grafana.com/docs/k6/latest/results-output/web-dashboard/; Prometheus remote write: link
  22. [S22] wrk2 README (Gil Tene): github.com/giltene/wrk2
  23. [S23] ScyllaDB, "On Coordinated Omission" (2021): scylladb.com/2021/04/22/on-coordinated-omission/; Gil Tene, "How NOT to Measure Latency": link
  24. [S24] Comment của maintainer k6 về CO (Hacker News, 11/2020): news.ycombinator.com/item?id=25240114
  25. [S25] Little's law: en.wikipedia.org/wiki/Little's_law
  26. [S26] Neil Gunther, "How to Quantify Scalability" (USL): perfdynamics.com/Manifesto/USLscalability.pdf
  27. [S27] Grafana Labs press release, mua lại k6 (17/06/2021): grafana.com/press/2021/06/17/…
  28. [S28] Wikipedia — k6 (software): en.wikipedia.org/wiki/K6_(software); Grafana guest blog: link
  29. [S29] Grafana blog — k6 1.0: grafana.com/blog/grafana-k6-1-0-release/
  30. [S30] k6 GitHub releases (v2.0–v2.3.0): github.com/grafana/k6/releases
  31. [S31] AWS Builders' Library — Using load shedding to avoid overload: aws.amazon.com/builders-library/using-load-shedding-to-avoid-overload/
  32. [S32] Google SRE book — Addressing Cascading Failures ("Test Until Failure and Beyond"): sre.google/sre-book/addressing-cascading-failures/
  33. [S33] Brendan Gregg — The USE Method: brendangregg.com/usemethod.html
  34. [S34] Brendan Gregg — Benchmarking checklist: brendangregg.com/blog/2018-06-30/benchmarking-checklist.html
  35. [S35] k6 docs — Fine-tune OS: grafana.com/docs/k6/latest/set-up/fine-tune-os/
  36. [S36] PHP manual — FPM configuration: php.net/manual/en/install.fpm.configuration.php
  37. [S37] PHP manual — FPM status page: php.net/manual/en/fpm.status.php
  38. [S38] nginx — ngx_http_fastcgi_module: nginx.org/en/docs/http/ngx_http_fastcgi_module.html; proxy module: link
  39. [S39] nginx — ngx_http_upstream_module: nginx.org/en/docs/http/ngx_http_upstream_module.html
  40. [S40] PHP manual — OPcache configuration: php.net/manual/en/opcache.configuration.php
  41. [S41] Node.js — Don't block the event loop: nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop
  42. [S42] Node.js — perf_hooks: nodejs.org/api/perf_hooks.html
  43. [S43] Node.js — CLI (UV_THREADPOOL_SIZE): nodejs.org/api/cli.html#uv_threadpool_sizesize
  44. [S44] node-postgres — Pool API: node-postgres.com/apis/pool
  45. [S45] Node.js — cluster: nodejs.org/api/cluster.html
  46. [S46] JMeter component reference: jmeter.apache.org/usermanual/component_reference.html
  47. [S47] Gatling — Injection: docs.gatling.io/concepts/injection/
  48. [S48] Locust — Writing a locustfile: docs.locust.io/en/stable/writing-a-locustfile.html
  49. [S49] Vegeta README: github.com/tsenart/vegeta
  50. [S50] Artillery — Test script reference: artillery.io/docs/reference/test-script
  51. [S51] Google Cloud blog — Bringing Pokémon GO to life on Google Cloud: cloud.google.com/blog/…/bringing-pokemon-go-to-life-on-google-cloud