Bài 2 đã đo được giới hạn cứng của một server: knee ở khoảng 4 kết nối đồng thời, và với handler chiếm CPU thì throughput đứng yên ở 200 req/s ngay từ một kết nối. Dù code hoàn hảo, một tiến trình vẫn là một tiến trình — và một điểm chết.
Bài này đặt nginx lên phía trước, nhân app thành ba replica, rồi đo lại. Kết quả có hai điều phản trực giác đến mức mình phải chạy lại nhiều lần để chắc: một thuật toán phân phối cho biểu đồ hoàn toàn cân bằng nhưng p95 tệ hơn 340 lần, và graceful shutdown — thứ mọi tài liệu đều khuyên — hoá ra không tạo khác biệt nào đo được trong trường hợp phổ biến nhất.
3.1 Scale dọc và scale ngang
Scale dọc (scale up) là cho máy khoẻ hơn: thêm CPU, thêm RAM. Scale ngang (scale out) là thêm máy. Hai hướng này không tương đương, và lựa chọn sai hướng là một trong những sai lầm đắt nhất.
Scale dọc thắng ở sự đơn giản: không phải nghĩ về phân tán, không mất tính nhất quán, không thêm hop mạng. Nhưng nó có ba giới hạn cứng:
- Trần vật lý. Không tồn tại máy mạnh vô hạn.
- Giá tăng phi tuyến. Máy gấp đôi hiệu năng thường đắt hơn gấp đôi, nhất là ở phân khúc cao cấp.
- Vẫn là một điểm chết. Đây là giới hạn nghiêm trọng nhất và không thể mua để khắc phục — máy 128 lõi chết thì hệ thống vẫn chết.
Scale ngang gỡ được cả ba, nhưng có một điều kiện tiên quyết không thương lượng: ứng dụng phải stateless. Cụ thể là không giữ trong RAM tiến trình bất cứ thứ gì mà request sau cần đọc lại — session, cache cục bộ, bộ đếm, kết quả tính dở.
Dấu hiệu nhận biết: bug biến mất khi scale về 1 replica. Nếu thấy triệu chứng đó, đừng đi tìm race condition — hãy đi tìm state cục bộ.
3.2 L4 và L7: load balancer "thấy" được gì
Khác biệt cốt lõi giữa hai tầng không phải tốc độ mà là lượng thông tin mà load balancer đọc được — và cái giá phải trả để đọc nó.
3.3 Thuật toán phân phối — và phép đo làm mình phải chạy lại nhiều lần
Bốn thuật toán thường gặp nhất:
| Thuật toán | Cách chọn replica | Điểm yếu |
|---|---|---|
| Round Robin | Lần lượt theo vòng | Không biết replica nào đang bận — chia đều số lượng, không chia đều tải |
| Least Connections | Replica có ít kết nối đang mở nhất | Cần theo dõi trạng thái; "ít kết nối" chưa chắc là "ít tải" |
| Random | Chọn ngẫu nhiên | Phương sai lớn, dễ dồn cục ngẫu nhiên |
| Random of two choices | Lấy ngẫu nhiên 2, chọn cái ít việc hơn | Gần như không có — rẻ như random mà chất lượng gần least-conn |
Khi mọi request tốn như nhau và mọi replica khoẻ như nhau, cả bốn cho kết quả gần giống nhau. Sự khác biệt chỉ lộ ra khi có lệch — và trong thực tế thì luôn có lệch: một replica vừa bị GC pause, một replica đang chạy trên máy chủ ồn ào hơn, một replica vừa restart nên cache còn lạnh.
Thí nghiệm: một replica bị suy giảm
Đây là kịch bản tất định và rất thật: ba replica, trong đó một replica bị chiếm event
loop (mình bơm tải /slow-sync?ms=30 trực tiếp vào app3, không qua load
balancer). Rồi đo /fast qua load balancer. Lặp 3 lần cho mỗi thuật toán:
| Thuật toán | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| Round Robin | 147 req/s | 0,50 ms | 119,64 ms | 149,70 ms |
| 215 req/s | 0,43 ms | 119,42 ms | 119,79 ms | |
| 145 req/s | 0,41 ms | 120,91 ms | 149,49 ms | |
| Least Connections | 6.511 req/s | 0,23 ms | 0,35 ms | 2,64 ms |
| 5.892 req/s | 0,23 ms | 0,35 ms | 59,11 ms | |
| 8.883 req/s | 0,23 ms | 0,35 ms | 0,64 ms |
Đọc bảng này chậm lại một chút, vì nó chứa ba điều đáng giá:
- Throughput chênh khoảng 40 lần (~170 req/s so với ~7.100 req/s). Cùng phần cứng, cùng mã nguồn, khác đúng một dòng cấu hình.
- p95 chênh khoảng 340 lần (~120 ms so với 0,35 ms). Với round robin, cứ khoảng một phần ba request bị đưa vào replica đang bị chặn và phải chờ tới lượt.
- Nhưng p50 gần như y hệt (0,4–0,5 ms so với 0,23 ms). Đây là phần nguy hiểm: nếu bảng điều khiển của bạn hiển thị latency trung vị, thảm hoạ này hoàn toàn vô hình.
app3 đang bị chặn.
app3 đang bị chặn hoàn toàn, phân phối vẫn là
11 / 10 / 9 trên 30 request — cân bằng gần như hoàn hảo. Một bảng điều khiển hiển thị
"requests per node" sẽ hiện ba cột bằng nhau, xanh hết, trong khi throughput đã sụp 40 lần. Round robin luôn chia đều số lượng — đó là định nghĩa của nó. Vậy nên chỉ số đó không mang thông tin gì về cân bằng tải. Hãy nhìn utilization và latency theo replica. Và ngược lại: khi least-conn hoạt động đúng, biểu đồ số lượng sẽ trông lệch — đừng "sửa" nó.
Cài đặt bốn thuật toán
Chỉ vài dòng mỗi thuật toán. Đây là mã thật đang chạy trong bộ mô phỏng của series
(sysdesign-sim-engine.js), nên bạn có thể đối chiếu hành vi mô phỏng với lab:
// `alive` = list of replicas still alive. Each replica has: busy (in-flight count), queueDepth.
function pickReplica(alive, policy, rng) {
if (alive.length === 0) return null; // whole tier is dead => must drop, cannot hang
if (alive.length === 1) return alive[0];
switch (policy) {
case 'round-robin': {
const r = alive[this.rrCursor % alive.length];
this.rrCursor++;
return r; // does NOT read replica state at all
}
case 'least-connections': {
let best = alive[0];
for (const r of alive) {
if (r.busy + r.queueDepth < best.busy + best.queueDepth) best = r;
}
return best; // reads state => avoids the congested node
}
case 'random':
return alive[Math.floor(rng.next() * alive.length)];
case 'random-two-choices': {
// "Power of two choices": sample 2 replicas at random, keep the less busy one.
// Cheap like random, but balance quality is close to least-connections.
const a = alive[Math.floor(rng.next() * alive.length)];
const b = alive[Math.floor(rng.next() * alive.length)];
return a.busy + a.queueDepth <= b.busy + b.queueDepth ? a : b;
}
}
}
Bốn nhánh switch ở trên là toàn bộ khác biệt giữa các thuật toán: hai nhánh cuối
(least-connections, random-two-choices) đọc trạng thái replica trước khi chọn,
hai nhánh đầu (round-robin, random) thì không hề biết replica nào đang bận.
Trong nginx thì chỉ là một dòng trong khối upstream (khối khai báo nhóm server backend mà
nginx phân phối tải tới):
upstream app_pool {
# nginx defaults to round robin (nothing to declare for it).
# Uncomment the line below to switch to least connections, then RE-MEASURE:
least_conn;
server app1:3000 max_fails=2 fail_timeout=5s;
server app2:3000 max_fails=2 fail_timeout=5s;
server app3:3000 max_fails=2 fail_timeout=5s;
# Persistent connections to upstream: without this, nginx opens a new TCP
# connection for EVERY request, and most of the latency you measure will
# just be the internal TCP handshake cost.
keepalive 64;
}
nginx -s reload đọc file bị hụt byte
sed -i) trong khi file đang được bind-mount vào container làm nginx đọc phải trạng thái nửa
vời: nginx: [emerg] pread() returned only 2830 bytes instead of 2832
Nguy hiểm ở chỗ reload thất bại nhưng nginx vẫn chạy với cấu hình cũ. Mình đã đo được một bộ số "sau khi đổi thuật toán" mà thực chất vẫn là round robin — và phải bỏ đi.
Cách đúng: ghi file kiểu nguyên tử (ghi ra file tạm rồi
mv) hoặc restart container, và
luôn xác nhận cấu hình đang có hiệu lực thay vì tin rằng nó đã đổi:
docker compose exec lb grep -c '^ *least_conn;' /etc/nginx/conf.d/default.conf
3.4 Health check, failover và graceful shutdown
Load balancer chỉ hữu ích nếu nó biết replica nào còn sống. Có hai cách biết, và nginx bản mã nguồn mở chỉ có một:
-
Chủ động (active): load balancer định kỳ gọi
/health. Phát hiện sớm, rút node ra trước khi có request nào bị ảnh hưởng. Đây là tính năng của nginx Plus, không có trong bản mã nguồn mở. -
Thụ động (passive): đếm lỗi thực tế.
max_fails=2 fail_timeout=5snghĩa là sau 2 lần thất bại trong 5 giây thì tạm loại node đó. Hệ quả quan trọng: vài request đầu tiên sau khi node chết vẫn phải thất bại — đó là cái giá của việc phát hiện.
Nhưng biết replica nào chết mới là nửa đầu câu chuyện. Nửa sau là graceful shutdown: khi
chính replica đó chủ động dừng (deploy, scale down, lệnh vận hành), nó không nên thoát ngay lập tức. Trình
tự đúng: tự đánh dấu unhealthy để load balancer thôi gửi request mới tới, rồi
drain — đợi các request đang xử lý dở hoàn tất — rồi mới thoát tiến trình. Tín hiệu Unix
kích hoạt bước này thường là SIGTERM (yêu cầu dừng êm ái, tiến trình còn cơ hội tự dọn
dẹp trước khi thoát), khác với SIGKILL (ngắt ngay lập tức, không có cơ hội dọn dẹp).
docker compose stop gửi SIGTERM trước, và chỉ gửi SIGKILL nếu tiến trình không tự thoát sau
một khoảng thời gian chờ.
Thí nghiệm: dừng một replica trong lúc đang có tải
Chạy tải /fast qua load balancer với 8 kết nối, đến giây thứ 5 thì
docker compose stop app3. Mình kỳ vọng thấy một loạt lỗi 502. Kết quả thực tế khác hẳn:
| Chỉ số | Có graceful shutdown | Không graceful shutdown |
|---|---|---|
| Lỗi mà client nhìn thấy | 0 | 0 |
| Mã trạng thái | toàn bộ 200 | toàn bộ 200 |
| p99 | 0,92 ms | 0,90 ms |
| Latency lớn nhất | 2.011 ms | 2.011 ms |
| Số request nginx phải thử lại upstream khác | 27 | 31 |
| Số request mất ≥ 1 giây | 29 | 27 |
Hai phát hiện, và cả hai đều đi ngược điều mình dự đoán trước khi đo.
Thứ nhất: không có lỗi nào. Nguyên nhân nằm trong chính cấu hình
proxy_next_upstream — nginx tự thử lại request ở upstream khác. Log của nginx cho thấy rõ
từng trường hợp:
172.21.0.6 "GET /fast HTTP/1.1" status=200 \
to=172.21.0.2:3000, 172.21.0.4:3000 \
ut=2.001, 0.000 rt=2.001 tries=504, 200
# How to read this line:
# to= two upstreams => already retried
# ut= first upstream took 2.001s (proxy_connect_timeout expired), second took ~0s
# tries=504, 200 => first attempt timed out, second attempt succeeded
# status=200 => CLIENT NEVER SAW AN ERROR, only extra latency
Đây là tin tốt và tin xấu cùng lúc. Tin tốt: retry cứu được tính sẵn sàng. Tin xấu:
nó biến mất mát tính sẵn sàng thành mất mát độ trễ, và độ lớn của mất mát đó bằng đúng
proxy_connect_timeout mà bạn đặt — ở đây là 2 giây. Nếu bạn chỉ theo dõi tỉ lệ lỗi, bạn sẽ
kết luận "deploy hoàn toàn êm" trong khi ~30 người dùng vừa phải chờ 2 giây.
Thứ hai: graceful shutdown không tạo khác biệt nào đo được. 27 so với 31 request bị thử
lại là nằm trong nhiễu. Điều này thoạt nghe như phản bác mọi tài liệu vận hành, nhưng lý do rất đơn giản:
/fast hoàn thành trong 0,2 ms, nên tại thời điểm nhận SIGTERM
hầu như không có request nào đang bay để mà drain. Log của app xác nhận:
"đánh dấu unhealthy, chờ 0 request đang xử lý..."
Vậy graceful shutdown có ích khi nào? Đo lại với request dài
Lặp lại đúng thí nghiệm nhưng đổi sang /slow-async?ms=500 — request đủ dài để luôn có vài
request đang bay khi node bị dừng:
| Chỉ số (request 500 ms) | Có graceful shutdown | Không graceful shutdown |
|---|---|---|
| Lỗi client nhìn thấy | 0 | 0 |
| Latency lớn nhất | 860,78 ms | 2.514,83 ms |
| Log của app khi nhận SIGTERM | "chờ 5 request đang xử lý..." → drain xong → thoát | "thoát ngay, request đang xử lý bị cắt" |
Giờ khác biệt hiện ra rõ: 861 ms so với 2.515 ms — đuôi latency tệ hơn khoảng
2,9 lần. Năm request đang bay bị cắt giữa đường; nginx phải thử lại chúng ở một upstream
khác, và phần lớn chi phí đó là 2 giây chờ hết proxy_connect_timeout trước khi thử lại, cộng
thêm thời gian làm lại phần việc dở dang.
/health bao giờ, nên việc "đánh dấu unhealthy" chẳng ai nghe
thấy. Đó chính là lý do vẫn còn ~30 request phải trả giá 2 giây. Trình tự deploy đúng phải là: rút node khỏi load balancer trước (qua API của LB, hoặc chờ đủ một chu kỳ health check chủ động), rồi mới gửi SIGTERM, rồi drain, rồi thoát. Thiếu bước đầu thì graceful shutdown chỉ giải quyết được nửa vấn đề.
Đây cũng là lý do timeout là một trong những tham số quan trọng nhất của hệ phân tán — Bài 17 sẽ quay lại đúng con số 2 giây này.
app3 trong thí nghiệm ở mục 3.3: sống theo mọi tiêu chí TCP,
nhưng p95 là 120 ms. /health phải thực sự thực thi được một chút logic, và tốt hơn nữa là kiểm tra cả dependency
thiết yếu. Nhưng đừng thái quá: nếu /health trả lỗi chỉ vì database chậm, cả pool sẽ bị rút
ra cùng lúc và bạn tự gây ra sự cố toàn phần.
3.5 Sticky session và cái giá của state cục bộ
Khi app giữ session trong RAM, cách sửa nhanh nhất là bắt mỗi người dùng luôn về đúng một replica —
sticky session. Trong nginx chỉ cần một dòng ip_hash;. Nó chạy được, và đó
chính là vấn đề.
| Hệ quả | Vì sao xảy ra |
|---|---|
| Mất cân bằng tải | Người dùng không sinh ra lượng tải như nhau; một replica có thể trúng toàn người dùng nặng |
| Mất session khi replica chết | State chỉ nằm trong RAM của replica đó — chết là mất, người dùng bị đăng xuất |
| Chặn autoscale | Thêm replica không giúp gì cho người dùng đã bị ghim; bớt replica là mất session |
| Deploy nào cũng gây gián đoạn | Mỗi lần restart replica là toàn bộ người dùng ghim vào nó bị ảnh hưởng |
| Sai lệch với NAT | ip_hash ghim theo IP, nên cả một công ty sau NAT bị dồn vào một replica |
Lời giải đúng là đưa session ra store dùng chung — Redis, hoặc token có chữ ký để không cần lưu gì cả. Khi đó app thật sự stateless và mọi vấn đề trên tan biến. Bài 5 sẽ dựng đúng tầng đó.
Tự làm lại các phép đo
# 1) Bring up nginx + 3 replicas
cd blog/sysdesign/sysdesign-lab
docker compose --profile lb up -d
sleep 10 # IMPORTANT: let all 3 replicas finish warming up
# 2) Check the distribution (round robin => counts should be roughly equal)
for i in $(seq 1 30); do curl -s http://localhost:8080/whoami; echo; done \
| grep -o '"instance":"app[0-9]"' | sort | uniq -c
# 3) DEGRADE exactly one replica: hit app3 directly, bypassing the LB
docker compose run --rm -d --name hog loadgen \
loadgen.js --url "http://app3:3000/slow-sync?ms=30" -c 3 -d 25 -w 0
# 4) Measure /fast through the LB while app3 is blocked
docker compose run --rm loadgen \
loadgen.js --url http://lb:8080/fast -c 6 -d 10 -w 3
# 5) Switch to least_conn, then RE-MEASURE. Write the file atomically, not with sed -i:
sed 's/^ # least_conn;/ least_conn;/' nginx/lb.conf > /tmp/lb.new \
&& mv /tmp/lb.new nginx/lb.conf
docker compose restart lb
# CONFIRM the config actually took effect before trusting the next measurement:
docker compose exec lb grep -c '^ *least_conn;' /etc/nginx/conf.d/default.conf
# 6) Count requests nginx retried, and requests that took >= 1 second
docker compose logs --no-log-prefix lb | grep -c 'to=[^ ]*,'
docker compose logs --no-log-prefix lb | grep -oE 'rt=[0-9.]+' \
| awk -F= '$2>=1{n++} END{print n+0}'
Ba câu hỏi nên tự trả lời sau khi chạy: phân phối số lượng có cân bằng không (nên là có), p95 chênh bao nhiêu giữa hai thuật toán, và p50 có phản ánh được vấn đề không (nên là không).
Đối chiếu với mô phỏng
Traffic Lab dưới đây cho phép đổi thuật toán phân phối và giết replica ngay trên trang. Chọn kiến trúc "2. LB + nhiều app server", đặt tải đủ cao, rồi bấm "Giết 1 app server" để xem hàng đợi dồn sang các replica còn lại và utilization nhảy lên.
Ba replica rồi — nhưng chúng đang giành nhau cái gì?
Ta đã gỡ được điểm chết duy nhất và nhân throughput lên. Nhưng để ý một điều trong toàn bộ bài này: cả ba replica đều đọc cùng một nguồn dữ liệu. Scale tầng stateless là phần dễ; nó chỉ đẩy điểm nghẽn xuống tầng sau.
Bài 4 sẽ mở rộng nginx từ load balancer thành API gateway — định tuyến theo path, TLS
termination, và một cạm bẫy bảo mật cụ thể: vì sao không bao giờ được tin header
X-Forwarded-For do client gửi lên.
📖 Tài liệu tham khảo
Tải mã nguồn thực hành
Cấu hình nginx L7 dùng trong bài (có log to=$upstream_addr để tự đếm phân phối) và lõi mô
phỏng chứa cài đặt bốn thuật toán phân phối:
Bình luận