Bài 1 kết lại bằng một câu hỏi chưa trả lời: $\mu$ — tốc độ phục vụ tối đa của server — thực tế là bao nhiêu? Con số đó không đọc được từ tài liệu và không suy ra được từ cấu hình máy. Chỉ có một cách: đo.
Bài này dựng môi trường đo dùng chung cho cả 18 bài, rồi trả lời ba câu hỏi bằng số liệu thật: một request đi qua những hàng đợi nào bên trong Node, một handler viết sai kiểu gây thiệt hại bao nhiêu, và server của bạn bão hoà ở đâu.
Số của bạn sẽ khác — máy khác, kernel khác. Điều phải giống nhau là hình dạng của các đường cong và tỉ lệ giữa các con số. Nếu hình dạng khác hẳn, gần như chắc chắn phép đo có vấn đề, và mục 2.4 nói về đúng chuyện đó.
2.1 Vòng đời một HTTP request trong Node http thuần
Lab này dùng module http của Node, không Express, không dependency nào. Lý do không phải là
chủ nghĩa tối giản: framework che mất chính những chỗ có hàng đợi, mà hàng đợi lại là nhân vật chính của
cả series.
Trước khi code JavaScript của bạn được gọi, một request đã đi qua vài lớp — và mỗi lớp đều có hàng đợi riêng:
epoll trên Linux, kqueue trên macOS — và đó là hai chữ bạn thấy trong hộp giữa
của sơ đồ. Vì sao phải biết: libuv là nơi chờ hàng nghìn kết nối cùng lúc mà không tốn hàng nghìn luồng. Nó cũng có một thread pool riêng cho vài loại việc nặng (đọc file, mã hoá, DNS) — đó là chỗ Node thật sự chạy song song. Nhưng thread pool đó không chạy code JavaScript của bạn: mọi callback vẫn phải xếp hàng để vào đúng một luồng JS duy nhất, và đó là nút thắt mà cả mục 2.2 nói tới.
Hệ quả trực tiếp: nếu handler của bạn chiếm luồng đó, toàn bộ server dừng lại — không chỉ request đó. Mục 2.2 sẽ đo thiệt hại cụ thể.
2.2 Event loop và tội đồ blocking
App trong lab có hai endpoint chậm như nhau về mặt thời gian, nhưng khác nhau về bản chất — và đây là toàn bộ vấn đề:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Block the event loop for `ms` — simulates a heavy synchronous handler. */
function blockFor(ms) {
const until = Date.now() + ms;
// Busy loop: does NOT yield to the event loop, so every other request queues up.
while (Date.now() < until) {
/* burning CPU on purpose */
}
}
// ...inside the request handler:
if (p === '/slow-async') {
const ms = Number(url.searchParams.get('ms') || 50);
await sleep(ms); // yields the event loop => other requests still get served
return json(res, 200, { ok: true, mode: 'async', ms, instance: INSTANCE });
}
if (p === '/slow-sync') {
const ms = Number(url.searchParams.get('ms') || 50);
blockFor(ms); // BLOCKS the event loop => every other request waits
return json(res, 200, { ok: true, mode: 'sync-blocking', ms, instance: INSTANCE });
}
Gọi lẻ từng cái, hai endpoint này trả về sau cùng một khoảng thời gian. Sự khác biệt chỉ lộ ra khi có request khác cùng lúc:
/fast. Hình mô tả đúng hiện
tượng được đo ở bảng dưới.
Số đo thật
Chạy tải nền lên /slow-sync?ms=20 với 4 kết nối, đồng thời đo /fast bằng 10 kết
nối. Lặp lại ba lần, không sửa gì giữa các lần:
| Lần đo | /fast khi rảnh |
/fast khi có handler chặn chạy nền |
Tỉ lệ sụt |
|---|---|---|---|
| 1 | 29.647 req/s | 129 req/s | 229× |
| 2 | 28.922 req/s | 116 req/s | 250× |
| 3 | 29.521 req/s | 137 req/s | 216× |
Một handler đồng bộ 20 ms làm throughput của endpoint khác sụp khoảng
200–250 lần, và p99 của nó tăng từ 0,71 ms lên khoảng 160 ms. Endpoint
/fast không hề bị sửa dòng nào — nó chỉ có lỗi là ở chung tiến trình.
*Sync trong request handlerblockFor() ít khi xuất hiện dưới dạng vòng lặp lộ liễu như trên. Nó đến từ
những dòng trông rất vô hại: crypto.pbkdf2Sync(...) để băm mật khẩu · fs.readFileSync(...) đọc file cấu
hình "chỉ một lần" · JSON.parse() trên payload vài MB · zlib.gzipSync(...) ·
một vòng lặp xử lý mảng 100.000 phần tử. Suy nghĩ sai thường gặp: "chậm thì chỉ request đó chậm thôi". Bảng số trên cho thấy hậu quả rơi lên toàn bộ các request khác. Và vì bạn thường chỉ đo endpoint mình vừa viết, thiệt hại lại xuất hiện ở nơi khác — cực khó truy.
2.3 Dựng lab dùng chung cho cả series
Toàn bộ track "chạy thật" của 18 bài dùng một stack Docker duy nhất, mở rộng dần qua
profiles. Bạn dựng một lần ở bài này rồi chỉ bật thêm profile khi bài sau cần.
name: sysdesign-lab
# Shared config for every app server — a YAML anchor, so it is not repeated 3 times.
x-app-base: &app-base
image: node:22-alpine
working_dir: /app
volumes:
- ./app:/app:ro # bind-mount: KHONG can Dockerfile, khong can npm install
command: ['node', 'app.js']
deploy:
resources:
limits:
cpus: '1.0' # CO CHU Y: xem phan cuoi bai
memory: 256M
services:
app1:
<<: *app-base
profiles: ['base', 'lb', 'cache', 'db']
environment:
INSTANCE: 'app1'
DB_DELAY_MS: '40'
GRACEFUL: '1'
ports:
- '3001:3000' # chi app1 mo cong ra host, de bai nay goi truc tiep
# ... app2, app3, lb, redis, postgres — enabled per profile ...
loadgen:
image: node:22-alpine
profiles: ['tools'] # not started automatically; invoked by hand to measure
working_dir: /loadgen
volumes:
- ./loadgen:/loadgen:ro
entrypoint: ['node']
deploy:
resources:
limits:
cpus: '2.0' # more than the app, so the tool is not the bottleneck
docker compose version; phải ra v2 trở lên, vì
docker-compose bản v1 (có dấu gạch nối) không hiểu profiles. Không có Docker thì bạn vẫn đọc hết bài được và vẫn đối chiếu được với Traffic Lab ở cuối bài — chỉ mất phần tự đo số của máy mình.
Dựng lên và kiểm tra app còn sống:
cd blog/sysdesign/sysdesign-lab
docker compose --profile base up -d
curl -s http://localhost:3001/health
# {"status":"ok","instance":"app1","uptimeSec":1,"inFlight":1,"totalRequests":2}
arm64) nhưng
image node:22-alpine có sẵn trong máy lại là linux/amd64 — nên container chạy
qua tầng giả lập. Docker chỉ in một dòng cảnh báo mờ nhạt giữa hàng chục dòng log rồi
vẫn chạy bình thường. Hậu quả: chậm nhiều lần, độ trễ nhiễu nặng, và mọi số đo về sau đều vô nghĩa — mà không có gì báo lỗi. Vì vậy hãy tiền kiểm, và tiền kiểm từ bên trong container:
uname -m → so với →
docker compose exec app1 node -e "console.log(process.arch)"
Hai kết quả phải khớp (
arm64 ↔ arm64, x86_64 ↔
amd64). Nếu lệch: docker pull --platform linux/arm64 node:22-alpine.
2.4 Đo cho đúng
Phần này là lý do lab dùng bộ đo tải tự viết (loadgen/loadgen.js) thay vì
wrk. Không phải vì wrk kém — nó tốt hơn nhiều — mà vì nếu công cụ đo là hộp đen
thì chính bài học về cách đo cũng biến mất theo.
Bốn nguyên tắc, và cái giá khi bỏ qua
1. Bỏ giai đoạn warm-up. Vài giây đầu, JIT chưa tối ưu hoá code, cache chưa ấm, kết nối chưa mở đủ. Số liệu giai đoạn này không đại diện cho trạng thái vận hành. Bộ đo của lab xoá sạch mọi mẫu thu được trước mốc warm-up thay vì lấy trung bình cả quá trình.
2. Hiểu số kết nối chính là $L$. Bộ đo chạy theo vòng đóng (closed-loop): mỗi kết nối chỉ gửi request tiếp theo sau khi nhận được phản hồi. Vì vậy số kết nối = số request đồng thời tối đa = chính đại lượng $L$ trong định luật Little ở Bài 1. Đây không phải chi tiết kỹ thuật vụn — mục 2.5 sẽ dùng đúng quan hệ này để xác nhận công thức bằng số đo.
3. Đừng để bộ đo và server tranh cùng CPU. Nếu cả hai cùng giành lõi, bạn không biết mình
đang đo giới hạn của server hay của công cụ. Trong lab, app bị giới hạn 1 CPU và bộ đo được 2 CPU, và ta
luôn kiểm chứng bằng docker stats:
$ docker stats --no-stream --format '{{.Name}} CPU={{.CPUPerc}} MEM={{.MemUsage}}'
cpu-probe CPU=101.20% MEM=50.55MiB / 7.653GiB # bo do: 101% tren HAN MUC 200%
sysdesign-lab-app1-1 CPU=82.72% MEM=81.96MiB / 256MiB # app: 83% tren han muc 100%
Đọc hai dòng này: app đang ở 83% của 1 CPU được cấp — gần bão hoà. Bộ đo dùng 101% trên hạn mức 200%, tức mới khoảng một nửa công suất. Nhờ vậy ta kết luận được: cái trần throughput đo được là trần của server, không phải của công cụ đo. Nếu bộ đo chạm 100% hạn mức của nó, mọi số phải bỏ đi.
4. Một lần đo không phải là một phép đo. Bảng ba lần lặp ở mục 2.2 cho thấy cùng một thí nghiệm, không sửa gì, cho ra tỉ lệ sụt 229× / 250× / 216×. Nếu chỉ chạy một lần rồi ghi "sụp 250 lần" thì đó là con số của một lần chạy, không phải của hệ thống. Cách trung thực là báo khoảng: 200–250 lần.
Người dùng thật không lịch sự như vậy: họ vẫn bấm, vẫn F5, vẫn ập vào lúc server đang chết. Đó là đo vòng mở (open-loop), và nó luôn cho p99 tệ hơn.
Vì thế con số p99 trong toàn bộ series này nên được đọc là giới hạn dưới của mức tệ — thực tế production sẽ xấu hơn. Đây cũng là lý do
loadgen.js ghi rõ ba giới hạn của nó ngay
trong phần chú thích đầu file, thay vì để người đọc tự đoán.
2.5 Tìm knee point: server của bạn bão hoà ở đâu
Giờ trả lời câu hỏi của Bài 1. Ta tăng dần số kết nối và ghi lại throughput cùng latency. Lệnh cho mỗi điểm đo:
for c in 1 2 4 8 16 32 64 128; do
docker compose run --rm loadgen loadgen.js \
--url http://app1:3000/fast -c $c -d 6 -w 2 --json
done
Kết quả đo thật trên máy nêu ở đầu bài:
| Kết nối | Throughput (req/s) | p50 (ms) | p99 (ms) | Nhận xét |
|---|---|---|---|---|
| 1 | 8.344 | 0,12 | 0,15 | Chưa dùng hết công suất |
| 2 | 14.118 | 0,14 | 0,20 | Gần gấp đôi — vẫn còn dư |
| 4 | 29.604 | 0,13 | 0,27 | Knee — throughput chạm trần, latency chưa tăng |
| 8 | 30.531 | 0,22 | 0,48 | Throughput đứng, latency bắt đầu tăng |
| 16 | 30.577 | 0,47 | 0,99 | Đỉnh throughput; p99 đã gấp 6,6 lần |
| 32 | 30.278 | 0,99 | 2,03 | Chỉ còn trả giá latency |
| 64 | 29.694 | 2,06 | 4,14 | Throughput bắt đầu giảm |
| 128 | 28.967 | 4,27 | 8,29 | Tệ hơn mọi mặt so với 16 kết nối |
Đọc bảng này là toàn bộ giá trị của bài. Từ 16 lên 128 kết nối, throughput giảm 5% trong khi p99 tăng 8,4 lần. Sau knee, mọi request đồng thời tăng thêm là thuần chi phí — chỉ để xếp hàng dài hơn. Đây chính là cái vách phi tuyến của Bài 1, lần này bằng số đo của một máy thật.
Xác nhận định luật Little bằng thực nghiệm
Phép đo thuyết phục nhất lại đến từ một endpoint khác. Dùng
/slow-sync?ms=5 — chiếm đúng 5 ms CPU mỗi request — thì trên một CPU, tốc độ phục vụ tối đa
phải là $\mu = 1000/5 = 200$ req/s. Đo thật:
| Kết nối (L) | Throughput đo được | Latency trung bình đo được (W) | $L / \lambda$ theo định luật Little |
|---|---|---|---|
| 1 | 198,8 req/s | 5,0 ms | 5,0 ms |
| 2 | 197,3 req/s | 10,1 ms | 10,0 ms |
| 8 | 200,0 req/s | 40,0 ms | 40,0 ms |
| 16 | 199,6 req/s | 80,1 ms | 80,0 ms |
| 32 | 200,0 req/s | 160,1 ms | 160,0 ms |
| 64 | 199,7 req/s | 320,5 ms | 320,0 ms |
Cột cuối tính từ $W = L/\lambda$ với $\lambda = 200$. Sai lệch so với số đo dưới 0,3% ở mọi mức. Định luật Little không phải lý thuyết trang trí — nó là đẳng thức đúng từng con số trên máy thật.
Và để ý điều đáng ngạc nhiên nhất trong bảng: throughput phẳng 200 req/s ngay từ 1 kết nối. Với một handler chiếm CPU trên server một luồng, server đã bão hoà ở đúng một request đồng thời. Mọi kết nối thêm vào không mua được gì cả — chỉ làm hàng đợi dài ra và latency tăng tuyến tính.
/fast chịu được 29.000 req/s" là
đúng ở cả 4 kết nối (p99 = 0,27 ms) lẫn 128 kết nối (p99 = 8,29 ms) — hai hệ thống hoàn toàn khác nhau
dưới góc nhìn người dùng. Một phát biểu đầy đủ phải có dạng: "29.600 req/s ở 4 kết nối đồng thời với p99 = 0,27 ms, đo trên [cấu hình máy]".
1. Knee của bạn ở bao nhiêu kết nối? (Máy nhiều lõi hơn chưa chắc cao hơn — app bị giới hạn 1 CPU.)
2. Đổi sang
/slow-sync?ms=5, throughput có phẳng ở ~200 req/s không? Nếu
lệch nhiều, kiểm lại bước tiền kiểm kiến trúc CPU ở mục 2.3.
3. Bỏ
-w 2 (không warm-up) và so lại: số liệu chênh bao nhiêu?
Đối chiếu với mô phỏng
Traffic Lab dưới đây mô phỏng đúng tình huống vừa đo. Chọn kiến trúc "1. Một server duy nhất" rồi kéo tải: bạn sẽ thấy lại chính hình dạng của bảng số — throughput chạm trần rồi đứng, còn hàng đợi và p99 tiếp tục dâng.
Giới hạn đã rõ — giờ làm gì với nó
Ta đã có $\mu$ bằng số đo, biết knee ở đâu, và biết một handler chặn event loop gây thiệt hại 200–250 lần. Nhưng dù viết code hoàn hảo, một server vẫn có trần cứng: một CPU, một tiến trình, một điểm chết.
Bài 3 sẽ đặt nginx lên trước và nhân app thành 3 replica, rồi đo lại chính những con số này. Trên đường đi ta sẽ gặp hai điều phản trực giác: một thuật toán phân phối tải cho biểu đồ "số request mỗi node" trông cân bằng hoàn hảo nhưng p99 lại tệ gần gấp đôi lựa chọn khác, và việc thiếu graceful shutdown khiến mỗi lần deploy là một đợt lỗi 502 đếm được cho người dùng thật.
📖 Tài liệu tham khảo
Tải mã nguồn lab
App server viết bằng module http thuần (0 dependency, kèm client Redis tối giản nói trực
tiếp giao thức RESP) và bộ đo tải tự viết có ghi rõ ba giới hạn:
Bình luận