Có một câu đùa cũ trong thống kê: một người đứng một chân trong xô nước đá và một chân trong xô nước sôi, "trung bình" thì anh ta thấy dễ chịu. Con số trung bình là như thế — nó gộp mọi cực đoan thành một điểm giữa không ai thật sự sống ở đó. Với độ trễ dịch vụ, cái xô nước sôi là nhóm nhỏ người dùng đang chờ nửa giây, còn xô nước đá là đám đông chờ 20 ms; trung bình bảo "ổn cả" trong khi một nhóm đang bỏng. Bài này tôi đo cho ra chính xác trung bình nói dối bao nhiêu, và con số nào nói thật.
Phần trước đo giá của bốn kiểu chỉ số và kết luận rằng Histogram đắt gấp chín lần Counter về số chuỗi. Phần này trả lời câu còn lại: có đáng không. Tôi sinh một triệu mẫu độ trễ có hình dạng thật, nạp vào một Histogram thật, dựng một Prometheus thật để truy vấn, rồi so ba con số mà mọi bảng điều khiển đều có sẵn: trung bình, p50, và p99. (Đo bằng Prometheus 2.54.1 thật; con số là thống kê độ trễ, không phải perf counter.)
Bảng số liệu
Một triệu mẫu độ trễ: 99% quanh 20 ms, 1% rơi vào đuôi dài quanh 500 ms — hình dạng quen thuộc của một dịch vụ có một phụ thuộc thỉnh thoảng chậm. Nạp vào prometheus_client, phục vụ qua /metrics, Prometheus 2.54.1 thu thập và truy vấn.
| Giá trị | So với p50 | |
|---|---|---|
| p50 | 20,37 ms | 1,00× |
| Trung bình | 27,45 ms | 1,35× |
| p90 | 36,95 ms | 1,81× |
| p99 | 161,33 ms | 7,92× |
| p99.9 | 776,33 ms | 38,1× |
| max | 2 133,85 ms | 104,8× |
Rồi tôi cho tỷ lệ request chậm tăng dần và xem ba chỉ số phản ứng thế nào:
| Tỷ lệ chậm | p50 | Trung bình | p99 |
|---|---|---|---|
| 0,5% (nền) | 20,28 ms | 25,01 ms | 64,84 ms |
| 1,0% | 20,38 ms | 27,43 ms (+10%) | 134,81 ms (+108%) |
| 2,0% | 20,43 ms | 32,47 ms (+30%) | 492,99 ms (+660%) |
| 3,0% | 20,57 ms | 37,62 ms (+50%) | 580,15 ms (+795%) |
| 5,0% | 20,93 ms (+3%) | 47,70 ms (+91%) | 666,17 ms (+927%) |
Ba con số, ba câu trả lời khác nhau
Tỷ lệ hỏng tăng mười lần thì p50 nhích 3%. Từ 20,28 lên 20,93 ms. Nếu bạn theo dõi độ trễ trung vị, bạn sẽ không thấy gì trong suốt cả sự cố.
Trung bình tăng 91% — nghe nhiều, nhưng vẫn quá ít. Nó đi từ 25 lên 48 ms. Một cảnh báo đặt ở "gấp đôi mức bình thường" sẽ nổ đúng lúc 5% người dùng đã chịu độ trễ trên nửa giây suốt một thời gian.
p99 tăng 927%. Nó nhạy gấp hơn mười lần trung bình, và nó bắt đầu kêu từ mức 1% — chỗ mà trung bình mới chỉ nhúc nhích 10%.
Và trung bình nằm sai chỗ ngay cả khi mọi thứ bình thường. Ở trạng thái nền, trung bình là 27,45 ms — chỉ cao hơn trung vị 1,35 lần, nhưng thấp hơn p99 tới 5,88 lần. Nó không mô tả trải nghiệm của đa số (p50 làm việc đó tốt hơn) và cũng không mô tả trải nghiệm của người xui (p99 làm việc đó). Nó là một con số không đại diện cho ai — đúng anh chàng một chân nước đá một chân nước sôi.
Vì sao trung bình lì còn phân vị nhạy
Trung bình là tổng chia số lượng, nên mỗi mẫu đóng góp đúng tỷ trọng của nó trong tổng thể. Khi 99% mẫu nằm quanh 20 ms, chúng ghim trung bình xuống gần đó bất kể đuôi có tệ đến đâu. Muốn kéo trung bình lên gấp đôi, bạn cần phần đuôi đóng góp bằng cả phần thân — với đuôi 500 ms và thân 20 ms, tức là cần khoảng 4% lưu lượng bị chậm. Đúng như bảng: ở 5% thì trung bình mới tăng 91%.
Phân vị thì không cộng gộp, nó đếm vị trí. p99 hỏi "mẫu chậm thứ 1% là bao nhiêu", nên nó nhảy ngay khi phần đuôi vượt qua ngưỡng 1%. Đó là lý do nó đi từ 64,84 lên 134,81 ms khi tỷ lệ chậm chỉ đi từ 0,5% lên 1% — đuôi vừa đủ rộng để chạm tới vị trí mà p99 đang đứng. Nói ngắn: trung bình đo tổng khối lượng đau, phân vị đo ai đang đau. Với hệ thống phục vụ người dùng, câu hỏi thứ hai mới là câu hỏi đúng.
Có một chi tiết đáng tin cậy và hơi trớ trêu ở đây. Trung bình mà Prometheus tính từ lat_sum / lat_count cho 27,45 ms — khớp chính xác với trung bình tính từ một triệu mẫu thô, không sai một chữ số. Lý do là _sum và _count không đi qua bucket nào cả; chúng là hai bộ đếm thật. Trong khi đó mọi phân vị lấy từ Histogram đều chỉ là ước lượng nội suy. Vậy con số chính xác nhất mà Histogram cho bạn lại đúng là con số ít dùng nhất.
Nghĩa là gì trong công việc hằng ngày
- Bỏ trung bình khỏi bảng điều khiển chính, hoặc để nó ở hàng dưới. Nó không sai, nó chỉ trả lời một câu hỏi không ai hỏi.
- Vẽ tối thiểu p50 và p99 cạnh nhau. Khoảng cách giữa hai đường mới là thứ nói lên hình dạng phân bố. Ở đây tỷ số p99/p50 đi từ 3,2 lên 31,8 trong suốt sự cố — một chỉ số cảnh báo tốt hơn cả hai con số riêng lẻ.
- Đặt cảnh báo trên phân vị, không trên trung bình. Với dữ liệu này, ngưỡng "p99 vượt 200 ms" bắt được sự cố ở mức 1,5% lưu lượng; ngưỡng "trung bình gấp đôi" phải đợi tới 5%.
- Nhớ lại phần 1 của sê-ri: p99 mù trước sự cố chạm dưới 1% lưu lượng. Cộng với bài này, quy tắc đầy đủ là: theo dõi p50, p99 và p99.9, và biết rằng mỗi cái có một vùng mù riêng.
- Nếu buộc phải chọn đúng một con số, hãy chọn p99, không phải trung bình.
Còn một cách đọc bảng nữa đáng để ý. Nhìn cột p50: nó gần như bất động qua cả năm mức, từ 20,28 tới 20,93 ms. Điều đó không phải vì trung vị là chỉ số tồi, mà vì nó đang trả lời đúng câu hỏi của nó — trải nghiệm của người dùng điển hình thật sự không đổi. Sự cố này không làm chậm mọi người; nó làm chậm một nhóm nhỏ, rất nặng. Đó là lý do vì sao đọc một mình p50 rồi kết luận "hệ thống bình thường" là một suy luận đúng về dữ liệu nhưng sai về thực tế. Cặp p50 và p99 vẽ cạnh nhau giải quyết được chuyện đó mà không cần thêm chỉ số nào: khi hai đường tách xa nhau, bạn biết có một nhóm đang chịu thứ mà đa số không chịu, kể cả khi cả hai đường đều nằm dưới ngưỡng cảnh báo của chính chúng.
Cùng một cái bẫy "trung bình che đuôi", đổi ngăn xếp
Chuyện "trung bình bị đám đông bình thường ghim xuống và mù trước một nhóm nhỏ chịu nặng, còn phân vị mới bắt được" không riêng gì Prometheus — nó là bài học đo lường trải khắp mọi tầng đo độ trễ.
Trong thế giới Java, đây đúng là lý do Micrometer khuyên bật publishPercentileHistogram() thay vì chỉ báo Timer.mean(), và vì sao HdrHistogram (thư viện đo độ trễ kinh điển của giới Java, từ Gil Tene) ra đời với khẩu hiệu gần như là "đừng bao giờ tin trung bình độ trễ". Gil Tene còn có cả một bài giảng nổi tiếng về "coordinated omission" — trung bình không chỉ che đuôi mà còn đo hụt nó khi tải cao. Cùng thông điệp bài này, chỉ khác dữ liệu.
Trong Go — ngôn ngữ viết Prometheus — client library phơi bày đúng cặp _sum/_count (cho trung bình chính xác nhưng ít dùng) và bucket (cho phân vị ước lượng nhưng đáng dùng) mà bài này đo. Cộng đồng SRE, phần lớn dùng Go stack, đã chuẩn hoá "SLO đặt trên percentile, không trên mean" chính vì con số 927% vs 91% ở trên.
Còn ở cơ sở dữ liệu, cùng bài học hiện ra khi đọc hiệu năng query: AVG(duration) trong dashboard che mất đúng cái đuôi mà PERCENTILE_CONT(0.99) (Postgres) hay slow query log phơi ra. Một DBA nhìn mean của pg_stat_statements có thể yên tâm trong khi 1% truy vấn chậm nhất làm khách hàng khổ sở — đúng cái mù của trung bình 20 ms.
Mẫu số chung: trung bình là một phép gộp cộng dồn nên bị đa số ghim và mù trước thiểu số cực đoan, còn phân vị là một phép đếm-vị-trí nên nhạy đúng với cái đuôi mà người dùng thật sự cảm nhận. Với bất kỳ đại lượng nào có phân bố lệch đuôi dài — độ trễ, thời gian chờ, kích thước — trung bình mô tả "tổng thể chia đều" chứ không mô tả "ai đang khổ". Nên câu hỏi đúng trước mỗi con số trên dashboard không phải "trung bình bao nhiêu" mà là "tôi đang muốn biết đám đông thế nào (p50) hay muốn biết người xui nhất thế nào (p99)" — và trung bình không trả lời tốt câu nào trong hai.
Muốn tự thấy ba cột nói ba chuyện khác nhau, chạy thử:
docker run --rm python:3.12-slim python - <<'EOF'
import random, statistics
random.seed(7)
N = 200_000
def sinh(tail):
a = [random.lognormvariate(-3.9, 0.45) for _ in range(int(N*(1-tail)))]
a += [random.lognormvariate(-0.7, 0.35) for _ in range(N - len(a))]
a.sort(); return a
print("%-12s %10s %12s %12s" % ("ti le cham", "p50", "trung binh", "p99"))
for t in (0.005, 0.01, 0.02, 0.05):
a = sinh(t)
print("%10.1f%% %9.2f ms %9.2f ms %9.2f ms"
% (t*100, a[N//2]*1000, statistics.fmean(a)*1000, a[int(0.99*N)]*1000))
EOF
Đọc theo cột. Cột p50 gần như là một hằng số, cột trung binh bò lên chậm rãi, cột p99 thì nhảy. Ba cột đó là ba câu trả lời khác nhau cho cùng một câu hỏi "hệ thống có ổn không", và chỉ một trong ba nói thật.