Hãy hình dung một quán ăn có tám bàn. Khi khách thứ chín bước vào, không có gì "hỏng" cả — món vẫn ngon, chưa ai bị tính nhầm, tốc độ ra món của bếp không đổi. Cái duy nhất thay đổi là: giờ có người phải đứng chờ ngoài cửa. Nếu bạn chỉ chấm điểm quán bằng "món có ngon không" và "hoá đơn có đúng không", bạn sẽ thấy quán hoàn hảo đúng vào lúc nó vừa hết chỗ — và cơn khủng hoảng xếp hàng chỉ hiện ra rất lâu sau đó. Bốn tín hiệu vàng (thông lượng, độ trễ, lỗi, độ bão hoà) là để tránh đúng cái mù đó, nhưng hiếm khi được đo cạnh nhau trên một sự cố. Bài này tôi dựng một dịch vụ HTTP thật tám worker, quét tải từ 1 tới 64 client, đo cả bốn ở mỗi mức — và thứ tự chúng phản ứng mới là điều đáng nhớ.

(Các con số là thông lượng và độ trễ đo trong container, không phải perf counter phần cứng.)

Bảng số liệu

Dịch vụ HTTP viết bằng Python, giới hạn 8 worker, mỗi request tốn 20 ms chờ (mô phỏng một lời gọi cơ sở dữ liệu). Client đóng vòng, hết hạn sau 500 ms. Trung vị của ba lần chạy.

Client Traffic p50 p99 Errors Saturation Hàng đợi
1 38 req/s 26,9 ms 31,3 ms 0,0% 12% 1
2 76 req/s 26,9 ms 31,2 ms 0,0% 25% 1
4 156 req/s 25,7 ms 31,0 ms 0,0% 50% 1
8 325 req/s 24,0 ms 31,0 ms 0,0% 100% 1
12 347 req/s 29,6 ms 54,6 ms 0,0% 100% 5
16 350 req/s 45,1 ms 89,9 ms 0,0% 100% 9
32 349 req/s 90,9 ms 230,6 ms 0,0% 100% 24
64 352 req/s 179,7 ms 484,1 ms 0,7% 100% 60

Bốn tín hiệu, đọc theo thứ tự chúng phản ứng

Saturation chạm trần ở C = 8, khi ba tín hiệu kia còn đẹp hoàn hảo. Độ trễ p50 là 24,0 ms — thấp hơn cả lúc rảnh. p99 là 31,0 ms, y hệt mức nền. Lỗi bằng 0. Nếu bảng điều khiển của bạn chỉ có độ trễ và lỗi, dịch vụ này trông khoẻ mạnh đúng vào lúc nó vừa hết công suất — đúng như quán ăn vừa kín tám bàn.

Traffic một mình đánh lừa người đọc. Ở C = 8 thông lượng là 325 req/s; ở C = 64 nó là 352 req/s. Hai con số gần như giống nhau, nhưng một bên phục vụ 8 client với hàng đợi rỗng, còn bên kia có 60 request đang xếp hàng và p99 gần nửa giây. Thông lượng phẳng có thể là "vừa đủ" và cũng có thể là "đã hết". Và từ C = 8 lên C = 64, client tăng 8 lần thì thông lượng chỉ tăng 8%, còn p99 tăng 15,6 lần — toàn bộ tải thêm vào biến thành thời gian xếp hàng, không thành công việc.

Errors là tín hiệu đến cuối cùng. Nó chỉ nhúc nhích ở C = 64, và chỉ 0,7%. Lúc đó người dùng đã chịu p99 gần 500 ms từ rất lâu. Cảnh báo dựa trên tỷ lệ lỗi là cảnh báo dựa trên thứ xảy ra sau cùng.

Vì sao độ trễ tăng tuyến tính, và saturation biết trước

Dịch vụ có tám worker và mỗi request giữ một worker trong 20 ms, nên công suất trần là 8 / 0,020 = 400 req/s. Đo được 350 req/s — phần chênh là chi phí của Python và tầng mạng. Dưới trần, thêm client là thêm thông lượng: mỗi client mới lấp một worker đang rảnh, và độ trễ không đổi vì không ai phải chờ — đó là đoạn C = 1 tới 8, nơi thông lượng tăng gần như tuyến tính từ 38 lên 325 req/s. Từ C = 8 trở đi không còn worker rảnh: client thứ chín phải xếp hàng, và định luật Little mô tả chính xác chuyện xảy ra sau đó — thời gian chờ bằng số khách chia cho tốc độ phục vụ. Kiểm lại trên số đo:

Dự đoán C / thông lượng Đo được Lệch
C = 16 45,7 ms 45,1 ms 1,3%
C = 32 91,7 ms 90,9 ms 0,9%
C = 64 181,8 ms 179,7 ms 1,2%

Khớp trong khoảng 1,3%. Đó là lý do độ trễ tăng tuyến tính theo số client sau điểm bão hoà, và cũng là lý do saturation biết trước: nó đo cái đang cạn, còn độ trễ đo hậu quả của việc đã cạn.

Ra thực tế thì đo saturation cho tài nguyên thật sự khan hiếm của bạn, đừng mặc định đo CPU: ở đây tài nguyên là worker chứ không phải CPU — dịch vụ chờ I/O nên CPU gần như rảnh suốt; với ứng dụng khác thì đó có thể là kết nối cơ sở dữ liệu, kích thước hàng đợi luồng, hay số file descriptor. Cảnh báo trên saturation với ngưỡng dưới 100% — ở đây trần là 8, một ngưỡng "trên 6 worker bận trong 5 phút" cho bạn thời gian phản ứng trước khi độ trễ bắt đầu leo, đợi tới 100% là đợi tới lúc hàng đợi đã bắt đầu dài ra. Đừng bao giờ đọc thông lượng một mình — luôn ghép với saturation hoặc độ dài hàng đợi: cặp "thông lượng phẳng + saturation 100%" là chữ ký của bão hoà, còn "thông lượng phẳng + saturation 40%" nghĩa là tải thật sự đang thấp. Và nếu chỉ đo được một tín hiệu thì đo saturation; đo được hai thì thêm p99 — errors gần như luôn là tín hiệu thừa cho mục đích phát hiện sớm, nó hữu ích để biết mức độ thiệt hại chứ không phải để biết có chuyện.

Mẫu số chung

Cái quy luật "hàng đợi bắt đầu dài ra trước khi độ trễ leo, nên phải theo dõi tài nguyên đang cạn chứ không phải triệu chứng sau khi cạn" là định luật Little đội lốt, và nó đúng ở mọi tầng có một nhóm tài nguyên hữu hạn.

Trong thế giới Java, đây đúng là chuyện xảy ra với thread pool của Tomcat (maxThreads) hay connection pool HikariCP: khi mọi luồng/kết nối đều bận, request thứ N+1 vào hàng đợi và độ trễ bắt đầu leo — nên chỉ số cần cảnh báo là hikaricp.connections.active / pending chạm trần pool, không phải độ trễ query. Nhiều sự cố Java kinh điển là "DB vẫn nhanh, app vẫn ít CPU, mà chậm" — vì cái cạn là pool, đúng cái worker ở đây.

Trong Go, cùng khuôn: một worker pool giới hạn bằng buffered channel hay golang.org/x/sync/semaphore, khi đầy thì goroutine chặn ở khâu lấy vé — và số cần theo dõi là độ dài hàng đợi vé / số goroutine đang blocked, không phải thời gian xử lý. runtime phơi ra đủ để đo cái đang cạn trước khi nó thành độ trễ.

Còn ở cơ sở dữ liệu, đây là bài học vận hành số một: pg_stat_activity cho thấy số phiên active so với max_connections, và một pool sát trần khiến truy vấn xếp hàng chờ khe — độ trễ query đo được vẫn đẹp trong khi thời gian chờ để có kết nối mới là chỗ nổ. Định luật Little ở đây là công thức nền của mọi capacity planning cho DB.

Mẫu số chung: trong bất kỳ hệ nào có một nhóm tài nguyên hữu hạn, độ bão hoà (nhóm đầy tới đâu) là tín hiệu dẫn trước, còn độ trễ và lỗi là tín hiệu theo sau — vì hàng đợi phải hình thành trước rồi độ trễ mới leo, và lỗi chỉ đến khi hàng đợi tràn. Vì thế câu hỏi đúng để phát hiện sớm không phải "dịch vụ có đang chậm không" mà là "tài nguyên khan hiếm của tôi đầy tới bao nhiêu phần trăm" — vì lúc bạn thấy nó chậm thì hàng đợi đã dài từ lâu.

Muốn tự kiểm dịch vụ của mình có đang hành xử như một hàng đợi không, chạy trong ba mươi giây:

docker run --rm python:3.12-slim python - <<'EOF'
# Dinh luat Little tren so lieu do duoc: W = C / thong luong
do_duoc = [(8,325,24.0), (12,347,29.6), (16,350,45.1), (32,349,90.9), (64,352,179.7)]
print("%4s %10s %14s %12s %8s" % ("C","tps","du doan","do duoc","lech"))
for C, tps, p50 in do_duoc:
    du = 1000*C/tps
    print("%4d %10d %11.1f ms %9.1f ms %7.1f%%" % (C, tps, du, p50, 100*(p50-du)/du))
EOF

Cột cuối cho biết dịch vụ của bạn có đang hành xử như một hàng đợi hay không. Nếu độ trễ đo được khớp với số client / thông lượng, bạn đang bão hoà và mọi tối ưu vi mô đều vô ích — thứ duy nhất giúp được là thêm worker hoặc làm mỗi request nhanh hơn.