Mọi cuộc thảo luận về thiết kế hệ thống rồi cũng quy về hai con số: độ trễthông lượng. Nghe đơn giản, nhưng đây là chỗ sai lệch bắt đầu — vì phần lớn chúng ta được dạy tư duy tuyến tính, trong khi hệ thống có hàng đợi lại hành xử phi tuyến một cách tàn nhẫn.

Bài này thiết lập bốn nền tảng dùng lại suốt 17 bài sau: phân biệt latency với throughput, ghi nhớ bậc độ lớn của các loại độ trễ, hiểu vì sao giá trị trung bình là con số lừa dối, và cuối cùng là công thức giải thích tại sao một server "còn 30% CPU rảnh" vẫn có thể đang chết.

1.1 Latency và Throughput: hai đại lượng, không phải một

Latency là thời gian phục vụ một request, đo bằng đơn vị thời gian. Throughput là số request phục vụ được trong một đơn vị thời gian, đo bằng req/s. Chúng liên quan nhưng không suy ra được nhau, và điều quan trọng hơn cả: tối ưu cái này thường làm hại cái kia.

Ví dụ dễ thấy nhất là ghép lô (batching). Gom 100 lệnh ghi rồi đẩy xuống database một lần giúp throughput tăng vọt vì chi phí cố định mỗi lần ghi được chia đều. Nhưng request đến đầu tiên phải chờ 99 request kia đến đủ mới được xử lý — latency của nó tăng lên. Nén dữ liệu cũng vậy: tiết kiệm băng thông (tăng throughput trên cùng đường truyền) nhưng thêm thời gian nén/giải nén vào mỗi request.

Kỹ thuật Tác động lên throughput Tác động lên latency
Batching Tăng mạnh (chia đều chi phí cố định) Xấu đi (phải chờ đủ lô)
Nén dữ liệu Tăng (ít byte hơn trên cùng đường truyền) Xấu đi (thêm thời gian nén/giải nén)
Cache Tăng (bớt việc cho tầng sau) Tốt lên (đường đi ngắn hơn)
Thêm replica Tăng gần tuyến tính Tốt lên gián tiếp (giảm chờ hàng đợi, không làm mỗi request nhanh hơn)

Dòng cuối bảng chứa một ý tinh tế và rất hay bị hiểu sai: thêm server không làm một request đơn lẻ được xử lý nhanh hơn. Nếu handler mất 20 ms CPU thì nó vẫn mất 20 ms, dù bạn có 1 hay 100 server. Thêm server chỉ làm giảm thời gian xếp hàng. Khi hệ thống rảnh rỗi, thêm server hầu như không cải thiện latency chút nào.

🕳️ Cạm bẫy: suy latency từ throughput
"Server chịu được 1000 req/s, vậy mỗi request mất 1 ms" — sai. Kết luận đó chỉ đúng nếu server xử lý tuần tự đúng một request tại một thời điểm. Thực tế nếu server chạy 50 request song song, mỗi request có thể mất 50 ms mà throughput vẫn là 1000 req/s. Quan hệ đúng giữa ba đại lượng này chính là định luật Little ở mục 1.4 — và nó cần ba con số, không phải hai.

1.2 Bậc độ lớn của latency: bảng số định hình mọi kiến trúc

Đây có lẽ là bảng số quan trọng nhất mà một người thiết kế hệ thống cần nằm lòng. Không phải để nhớ chính xác từng con số, mà để nhớ khoảng cách giữa chúng — vì chính khoảng cách đó quyết định kiến trúc.

Bậc độ lớn latency (thang logarit) 1ns 100ns 10µs 1ms 100ms 10s L1 cache ~1 ns RAM ~100 ns SSD (NVMe) ~100 µs RTT trong cùng DC ~0,5 ms HDD seek ~10 ms RTT Hà Nội ↔ Singapore ~40–60 ms RTT liên lục địa ~150 ms Timeout mặc định Mỗi 110 px = 100 lần. RAM nhanh hơn RTT liên lục địa khoảng 1,5 triệu lần.
Thang logarit: mỗi bước 110 px tương ứng 100 lần. Chính vì thang này là logarit mà trực giác tuyến tính của chúng ta liên tục sai.

Hãy đọc bảng này theo hướng ứng dụng. RAM nhanh hơn một round-trip liên lục địa khoảng 1,5 triệu lần. Điều đó giải thích ba quyết định kiến trúc kinh điển:

  • Vì sao cache thắng gần như luôn luôn: đổi một truy vấn qua mạng (~1 ms) thành một lần đọc RAM (~100 ns) là tiết kiệm 10.000 lần. Không có tối ưu thuật toán nào cho tỉ lệ như vậy.
  • Vì sao CDN tồn tại: khi người dùng ở cách server nửa vòng trái đất, 150 ms là giới hạn vật lý — sẽ nói rõ ở Bài 6. Cách duy nhất là đưa dữ liệu lại gần họ.
  • Vì sao "N+1 query" là tội đồ: 100 truy vấn tuần tự × 1 ms = 100 ms, trong khi gộp thành một truy vấn có thể chỉ 3 ms. Vấn đề không nằm ở database mà ở số lần đi qua mạng.
🕳️ Cạm bẫy: tối ưu sai tầng
Dành hai ngày tối ưu một hàm từ 50 µs xuống 5 µs, trong khi request đó đang chờ một round-trip mạng 150 ms. Bạn vừa cải thiện 0,03% tổng thời gian. Trước khi tối ưu bất cứ thứ gì, hãy xác định độ trễ đang nằm ở bậc nào — đó cũng là lý do Bài 16 (Observability) tồn tại.

1.3 Tail latency: vì sao giá trị trung bình là con số lừa dối

Phân phối latency của hệ thống thực gần như luôn lệch phải: phần lớn request nhanh, một phần nhỏ chậm hẳn, và cái đuôi dài đó kéo giá trị trung bình đi. Kết quả là trung bình không đại diện cho ai cả — không phải trải nghiệm điển hình, cũng không phải trải nghiệm tệ nhất.

Phân phối latency thực tế luôn lệch phải p50 = 20ms trung bình = 42ms p95 = 180ms p99 = 420ms Trung bình nằm bên phải p50 và vẫn thấp hơn p95 gần 4 lần: nó không mô tả được ai.
Hình dạng này là mặc định của hệ thống thực, không phải trường hợp bệnh lý. Đuôi phải sinh ra từ GC pause, cache miss, tranh chấp lock, retry, và hàng đợi.

Vì vậy ta dùng phân vị (percentile). Nói "p99 = 420 ms" nghĩa là 99% request nhanh hơn 420 ms, và 1% chậm hơn. Với một dịch vụ nhận 10 triệu request mỗi ngày, cái "1%" đó là 100.000 lần người dùng có trải nghiệm tệ — mỗi ngày.

Vì sao p99 của service lại thành p90 của người dùng

Đây là phần khiến tail latency nguy hiểm hơn nhiều so với cảm nhận ban đầu. Một trang web hiện đại thường phải gọi nhiều service để dựng xong một màn hình. Nếu mỗi lời gọi độc lập và xác suất một lời gọi bị chậm là $p$, thì xác suất người dùng gặp ít nhất một lời gọi chậm khi trang gọi $n$ service là:

$$P(\text{cham}) = 1 - (1 - p)^n$$

Trong đó $p$ là xác suất một lời gọi vượt ngưỡng chậm (ví dụ $p = 0{,}01$ ứng với ngưỡng p99), và $n$ là số service mà trang phải gọi. Thay số:

Số service gọi ($n$) Xác suất gặp ít nhất một lời gọi chậm Nghĩa là
1 1,0% p99 của service = p99 của người dùng
10 9,6% p99 của service ≈ p90 của người dùng
50 39,5% gần 4/10 lượt tải trang bị ảnh hưởng
100 63,4% chậm trở thành trạng thái bình thường

Đây chính là lý do các hệ thống lớn theo dõi tới p99,9 và tại sao việc chia nhỏ thành nhiều service làm bài toán tail latency khó hơn hẳn — một điểm ta sẽ tính bằng con số cụ thể ở Bài 15.

ℹ️ Phân vị không cộng được
Nếu request đi qua ba tầng có p99 lần lượt 10 ms, 20 ms, 30 ms thì p99 tổng không phải 60 ms. Lý do: các tầng khó khi nào cùng lúc rơi vào trường hợp tệ nhất của mình. Muốn biết p99 tổng, bạn phải đo trên toàn bộ đường đi chứ không cộng số của từng chặng. Cũng vì thế, mọi biểu đồ hiển thị "tổng p99 các tầng" đều nên bị nghi ngờ.
🕳️ Cạm bẫy: coordinated omission
Phần lớn công cụ đo tải hoạt động theo vòng đóng: mỗi kết nối chỉ gửi request tiếp theo sau khi nhận được phản hồi. Nghĩa là khi server chậm, công cụ tự động gửi ít hơn — đúng những request lẽ ra phải rơi vào giai đoạn tệ nhất lại không bao giờ được gửi. Kết quả: p99 đo được đẹp giả tạo. Bài 2 sẽ tái tạo hiện tượng này bằng chính bộ đo của lab để bạn thấy con số lệch bao nhiêu.

1.4 Định luật Little và lý thuyết hàng đợi

Giờ đến phần giải thích được hầu hết những gì "lạ" trong vận hành hệ thống. Bắt đầu bằng một đẳng thức đơn giản đến mức đáng ngạc nhiên — định luật Little:

$$L = \lambda W$$

Trong đó $L$ là số request trung bình đang nằm trong hệ thống, $\lambda$ (lambda) là tốc độ request đến (req/s), và $W$ là thời gian trung bình mỗi request nằm trong hệ thống (giây). Đẳng thức này đúng cho mọi hệ thống ổn định, không cần giả định gì về phân phối.

Ứng dụng trực tiếp: nếu dịch vụ của bạn nhận 500 req/s và mỗi request mất trung bình 40 ms, thì số request đang xử lý đồng thời là $L = 500 \times 0{,}04 = 20$. Đó chính là con số bạn cần để chọn kích thước connection pool hoặc số worker — không phải đoán.

Utilization và cái vách phi tuyến

Gọi $\mu$ (mu) là tốc độ phục vụ tối đa của server (req/s). Mức chiếm dụng là:

$$\rho = \frac{\lambda}{\mu}$$

Để đi xa hơn, ta cần một mô hình hàng đợi cụ thể. Đơn giản nhất là M/M/1, và ba ký hiệu đó đọc từ trái sang phải là: request đến ngẫu nhiên, thời gian phục vụ ngẫu nhiên, và có một server.

ℹ️ Đọc ký hiệu M/M/1
Chữ M thứ nhất — cách request đến. "Tiến trình Poisson" nghe học thuật nhưng ý rất đời thường: request đến độc lập và ngẫu nhiên, không hẹn giờ. Trung bình 10 request mỗi giây không có nghĩa là cứ 100 ms một cái — có giây nhận 6, có giây nhận 15. Đây gần đúng với lưu lượng thật từ nhiều người dùng không liên quan tới nhau.

Chữ M thứ hai — thời gian phục vụ. "Phân phối exponential" nghĩa là phần lớn request xử lý nhanh, một số ít lâu hơn hẳn, và không có trần trên. Đây chính là hình dạng lệch phải ở mục 1.3.

Số 1 — có một server. Đúng một luồng phục vụ. Thêm server thành M/M/c, và bài 3 sẽ đo xem thêm vào giúp được đến đâu.

Hệ thống thật hiếm khi khớp hoàn hảo cả ba giả định. Nhưng M/M/1 vẫn đáng học vì nó cho ra đúng hình dạng của đường cong — cái vách dựng đứng khi ρ tiến tới 1 — và hình dạng đó mới là thứ trực giác tuyến tính của chúng ta liên tục bỏ sót.

Với mô hình đó, thời gian trung bình một request nằm trong hệ là:

$$W = \frac{1}{\mu - \lambda}$$

Hãy để ý mẫu số. Khi $\lambda$ tiến tới $\mu$, mẫu số tiến tới 0 và $W$ tiến tới vô cùng. Đây không phải sự cố — đây là hành vi bình thường của mọi hàng đợi.

Thời gian CHỜ trong hàng đợi theo mức chiếm dụng ρ = 1 ρ=0,5 → chờ ×1 ρ=0,7 → chờ ×2,3 ρ=0,9 → chờ ×9 ρ=0,95 → chờ ×19 0 0,25 0,5 0,75 1,0 mức chiếm dụng ρ = λ/μ cao thấp
Vùng xanh (ρ < 0,6) là nơi trực giác tuyến tính còn đúng. Từ vùng vàng trở đi, mỗi phần trăm tải thêm vào phải trả bằng nhiều phần trăm độ trễ.
Các mốc trên hình là thời gian chờ trong hàng đợi so với thời gian phục vụ, tức $\rho/(1-\rho)$ — không phải tổng thời gian trong hệ $W$. Hai đại lượng này chênh nhau đúng 1: ở $\rho = 0{,}7$, chờ trong hàng đợi gấp 2,3 lần thời gian phục vụ, còn tổng thời gian trong hệ gấp 3,3 lần lúc rảnh.

Con số cụ thể cho thời gian chờ trong hàng đợi (không tính thời gian phục vụ):

Mức chiếm dụng ρ Thời gian chờ so với lúc rảnh Cảm nhận vận hành
0,50 ×1 Thoải mái
0,70 ×2,3 "CPU mới 70%, còn dư mà" — nhưng p99 đã xấu rõ
0,90 ×9 Rung lắc, sự cố nhỏ thành sự cố lớn
0,95 ×19 Một đợt burst nhẹ là sập
0,99 ×99 Về mặt thực tế là đã chết
🕳️ Cạm bẫy: "CPU mới 70%, còn thoải mái"
Câu này được nói ra hàng ngày trong các cuộc họp vận hành, và nó bỏ qua bảng trên. Ở ρ = 0,7 thời gian chờ đã gấp 2,3 lần so với hệ rảnh; ở 0,9 là gấp 9 lần. Tệ hơn: đây là con số trung bình, còn p99 xấu đi nhanh hơn nữa. Một hệ thống chạy đều ở 70% không phải "còn dư 30%" mà là đã ở rìa vùng phi tuyến.

Và xin đừng "giải quyết" bằng cách tăng độ dài hàng đợi. Hàng đợi dài hơn không tăng $\mu$ — nó chỉ đổi hình thức thất bại từ từ chối nhanh sang chờ rồi timeout, thường là tệ hơn cho người dùng. Bài 13 sẽ nói vì sao trả 429 ngay lại tử tế hơn.

Thấy nó xảy ra: Traffic Lab một node

Đọc bảng số là một chuyện, thấy đường cong dựng đứng dưới tay mình là chuyện khác. Demo dưới đây chạy một bộ mô phỏng sự kiện rời rạc thật (không phải animation vẽ sẵn): kéo thanh tải và quan sát p99 cùng độ dài hàng đợi. Đường xanh là lý thuyết $W = 1/(\mu-\lambda)$, các điểm vàng là số mô phỏng đo được — hai thứ này phải khớp nhau, và đó cũng là cách engine của series được kiểm chứng.

🧪 Thử ngay
Chọn kiến trúc "1. Một server duy nhất" rồi kéo thanh Tải đưa vào từ 10 lên dần. Để ý ba mốc: ở 25 rps (ρ = 0,5) p99 gần như không đổi; ở 40 rps (ρ = 0,8) p99 bắt đầu tách khỏi p50; ở 50 rps (ρ = 1,0) hàng đợi dâng vô hạn và p99 không còn hội tụ về giá trị nào.

Mở Traffic Lab ở tab riêng →

Mã nguồn: tính phân vị và mô phỏng M/M/1

Hai hàm dưới đây là mã thật đang chạy trong engine của series. Hàm percentile dùng phương pháp "nearest-rank" — cùng cách mà các công cụ đo tải phổ biến báo cáo, nên số của bạn so sánh được với số của chúng.

percentile_va_mm1.js
// Tinh phan vi theo phuong phap nearest-rank (khong noi suy).
// Luu y: KHONG sua doi mang dau vao - da tung co bug vi ham sort() tai cho.
function percentile(values, p) {
  if (!values || values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  if (p <= 0) return sorted[0];
  if (p >= 100) return sorted[sorted.length - 1];
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
}

// Ly thuyet hang doi M/M/1. Tra ve null khi rho >= 1: hang doi tang vo han,
// KHONG co trang thai on dinh - dung tra ve mot con so gia o day.
function mm1Theory(lambda, mu) {
  const rho = lambda / mu;
  if (rho >= 1) return { rho, W: null, Wq: null, L: null, stable: false };
  const W = 1 / (mu - lambda);      // tong thoi gian trong he (giay)
  const Wq = rho / (mu - lambda);   // rieng thoi gian CHO trong hang doi
  const L = lambda * W;             // dinh luat Little
  return { rho, W, Wq, L, stable: true };
}

// Doi chieu nhanh: mu = 50 req/s (moi request 20ms)
for (const lambda of [25, 35, 45, 49]) {
  const t = mm1Theory(lambda, 50);
  console.log(`rho=${t.rho.toFixed(2)}  W=${(t.W * 1000).toFixed(1)}ms  L=${t.L.toFixed(2)}`);
}
// rho=0.50  W=40.0ms  L=1.00
// rho=0.70  W=66.7ms  L=2.33
// rho=0.90  W=200.0ms L=9.00
// rho=0.98  W=1000.0ms L=49.00   <-- chi tang lambda tu 45 len 49 ma W gap 5 lan

Bốn dòng kết quả cuối là toàn bộ bài học của mục này gói lại: tăng tải từ 45 lên 49 req/s — chỉ 9% — làm thời gian trong hệ tăng từ 200 ms lên 1000 ms, tức 5 lần.

ℹ️ Về độ tin cậy của các con số trong series này
Bộ mô phỏng dùng cho mọi demo đã được đối chiếu với công thức M/M/1 ở các mức ρ từ 0,20 đến 0,95, sai số dưới 4%; định luật Little cũng khớp (L lý thuyết 4,000 so với 3,884 đo được). Bạn có thể tự kiểm bằng cách tải mã nguồn cuối bài và chạy node sysdesign-engine-selftest.mjs — 42 mục kiểm chứng, không cần cài thêm gì.

Toàn bộ số hiệu năng trong series đến từ máy đo thật hoặc từ bộ mô phỏng đã kiểm chứng. Không có con số nào được bịa, và các bài có lab đều ghi rõ cấu hình máy đo.

1.5 Ước lượng back-of-envelope

Kỹ năng cuối của bài: từ một yêu cầu bằng lời, ra được các con số để bắt đầu thiết kế. Đây là bước phải làm trước khi vẽ bất cứ sơ đồ nào, vì nếu không thì mọi lựa chọn về sau chỉ là phỏng đoán.

Làm mẫu với một bài toán cụ thể: 10 triệu người dùng hoạt động hằng ngày (DAU).

uoc_luong.txt
# 1) DAU -> RPS trung binh
10.000.000 DAU x 20 request/nguoi/ngay = 200.000.000 request/ngay
200.000.000 / 86.400 giay              = ~2.315 req/s   (trung binh)

# 2) He so dinh: luu luong KHONG deu trong ngay
#    Thuc te thuong dat 2-5 lan trung binh vao gio cao diem.
2.315 x 3                              = ~7.000 req/s   (dinh)

# 3) Ti le doc/ghi: dinh hinh toan bo kien truc
#    Gia dinh 100:1 (mang xa hoi, tin tuc, thuong mai dien tu deu quanh muc nay)
doc:  ~6.930 req/s   -> cache + read replica giai quyet duoc (Bai 5, Bai 7)
ghi:  ~70 req/s      -> mot primary con thua suc, CHUA can sharding (Bai 8)

# 4) Dung luong
#    Moi request ghi ~2 KB payload
70 req/s x 2 KB x 86.400              = ~12 GB/ngay
                                       = ~4,4 TB/nam  -> can nghi ve archival

# 5) Bang thong ra
#    Moi response doc ~10 KB
6.930 req/s x 10 KB                    = ~69 MB/s = ~550 Mbps  -> CDN (Bai 6)

Năm bước đó mất chưa tới hai phút nhưng đã loại bỏ được rất nhiều lựa chọn sai. Đáng chú ý nhất: với 70 ghi/giây, sharding là không cần thiết — một quyết định tiết kiệm hàng tháng công sức và vô số phức tạp vĩnh viễn. Nếu bỏ qua bước ước lượng, rất dễ bắt đầu bằng một kiến trúc phân tán cho một bài toán mà một server đơn giải quyết được.

🕳️ Cạm bẫy: thiết kế cho con số trung bình
Hai sai sót phổ biến nhất trong bước này, và cả hai đều dẫn tới sập vào đúng lúc đông người nhất:

1. Quên hệ số đỉnh. Thiết kế cho 2.315 req/s rồi giờ cao điểm nhận 7.000 req/s — hệ thống chạy ở ρ ≈ 3, tức là không có trạng thái ổn định nào cả.
2. Quên độ lệch đọc/ghi. Nhìn con số tổng 7.000 req/s rồi kết luận "phải sharding ngay", trong khi thực tế chỉ có 70 ghi/giây. Tối ưu sai tầng, trả giá phức tạp mà không được lợi ích.
🔬 Đào sâu: vì sao ước lượng thô lại đủ dùng
Mục đích của back-of-envelope không phải ra số chính xác mà là ra đúng bậc độ lớn. Sai 2 lần thì không sao — kiến trúc cho 3.500 và 7.000 req/s về cơ bản giống nhau. Nhưng sai 100 lần thì khác hẳn: 70 req/s và 7.000 req/s là hai hệ thống hoàn toàn khác nhau. Vì vậy hãy làm tròn mạnh tay, dùng lũy thừa của 10, và tập trung vào việc không sai bậc.

Còn một câu hỏi chưa trả lời

Toàn bộ bài này xoay quanh $\mu$ — tốc độ phục vụ tối đa. Bảng số, đường cong, mọi ước lượng đều dựa vào nó. Nhưng ta chưa hề nói $\mu$ của server bạn thực tế là bao nhiêu.

Con số đó không đọc được từ tài liệu và không đoán được từ cấu hình máy. Phải đo. Bài 2 sẽ dựng một app Node thật trong Docker, chạy tải tăng dần, và tìm điểm mà throughput ngừng tăng nhưng độ trễ bùng nổ — cái vách bạn vừa thấy trong lý thuyết, lần này bằng số đo của chính máy bạn. Trên đường đi, ta cũng sẽ thấy một handler viết sai kiểu có thể làm throughput sụp 195 lần.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Lõi mô phỏng dùng cho mọi demo trong series, kèm bộ kiểm chứng 42 mục chạy được bằng Node (không cần cài thêm gì). Bộ kiểm chứng import cả hai file còn lại, nên phải tải đủ cả ba vào cùng một thư mục rồi chạy node sysdesign-engine-selftest.mjs:

Tải về sysdesign-sim-engine.js Tải về sysdesign-hashring.js Tải về sysdesign-engine-selftest.mjs

Bài viết liên quan trong series

Bài 2: Dựng Lab & Đo Giới Hạn Một Server Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận