Tưởng tượng bạn muốn biết học sinh cao nhất của một trường cao bao nhiêu. Cách đúng: xếp tất cả học sinh cả trường thành một hàng rồi nhìn người cuối. Cách sai mà nghe rất hợp lý: hỏi mỗi lớp "em cao nhất lớp em là bao nhiêu", rồi lấy trung bình các con số đó. Khi mọi lớp na ná nhau, hai cách gần bằng nhau và không ai để ý. Nhưng ngày trường nhận một cầu thủ bóng rổ cao 2 mét vào đúng một lớp, cách trung bình sẽ chia chiều cao khổng lồ đó cho cả chục lớp và làm nó gần như biến mất. avg(p99) trên dashboard của bạn chính là cách hỏi-từng-lớp đó, và bài này tôi đo cho thấy nó nói dối tệ đến mức nào đúng lúc có sự cố.
Phần trước kết luận rằng nên vẽ p99 thay vì trung bình, và tôi đã cẩn thận nói rõ mọi con số phân vị ở đó là phân vị chính xác, tính bằng cách sắp xếp một triệu mẫu. Bảng điều khiển thật thì không làm thế — nó hỏi Prometheus. Tôi đo khoảng cách giữa hai thứ đó, và tìm ra hai kết quả không đoán trước. (Đo bằng Prometheus 2.54.1 thật trên một triệu mẫu; không có phần cứng đặc biệt nào.)
Bảng số liệu
Cùng một tập một triệu mẫu độ trễ của phần trước, nạp vào Histogram thật, truy vấn bằng Prometheus 2.54.1.
Bốn cách tính p99:
| Cách | Kết quả | Sai |
|---|---|---|
| Sắp xếp 1 triệu mẫu (mốc đối chiếu) | 161,33 ms | — |
histogram_quantile, bucket mặc định |
165,32 ms | +2,5% |
histogram_quantile, bucket chỉnh tay |
156,28 ms | −3,1% |
avg(p99) trên 10 instance |
167,52 ms | +3,8% |
Bốn con số nằm trong khoảng 5% của nhau. Nhìn riêng bảng này thì kết luận hợp lý là "cách nào cũng được" — và đó chính là cái bẫy.
Sai số không đều giữa các phân vị:
| Chính xác | Bucket mặc định | Sai | Bucket chỉnh tay | Sai | |
|---|---|---|---|---|---|
| p50 | 20,37 ms | 20,76 ms | +2,0% | 20,51 ms | +0,7% |
| p90 | 36,95 ms | 44,22 ms | +19,7% | 41,87 ms | +13,3% |
| p99 | 161,33 ms | 165,32 ms | +2,5% | 156,28 ms | −3,1% |
| p99.9 | 776,33 ms | 798,57 ms | +2,9% | 789,31 ms | +1,7% |
avg(p99) khi có instance hỏng (10 instance, mỗi cái 100.000 mẫu):
| p99 thật (gộp) | avg(p99) |
max(p99) |
|
|---|---|---|---|
| Không cái nào hỏng | 161,34 ms | 148,06 ms (−8,2%) | 168,56 ms (+4,5%) |
| 1 cái hỏng | 1 565,67 ms | 357,40 ms (−77,2%) | 2 246,69 ms (+43,5%) |
| 2 cái hỏng | 1 774,00 ms | 568,67 ms (−67,9%) | 2 270,47 ms (+28,0%) |
Hai kết quả ngược với chỗ tôi định nhìn
Phân vị sai nhiều nhất là p90, không phải p99. Tôi vào bài với giả định rằng đuôi càng xa thì ước lượng càng tệ, nên p99.9 sẽ sai nhất. Số đo nói ngược: p90 sai 19,7% trong khi p99 và p99.9 chỉ sai quanh 2,5–2,9%. Một kết quả làm tôi phải ngồi nghĩ lại toàn bộ trực giác về histogram.
avg(p99) hỏng đúng lúc bạn cần nó nhất. Khi mọi instance khoẻ, nó sai 8,2% — hoàn toàn chấp nhận được, và đó chính là lý do không ai phát hiện. Khi có một instance hỏng, nó cho 357 ms trong khi sự thật là 1 566 ms: báo nhẹ đi 4,4 lần. Cảnh báo đặt ở 1 giây sẽ im lặng suốt sự cố. Đây không phải sai số nhỏ — đây là một dashboard nói dối đúng vào giờ phút quan trọng.
Chỉnh bucket giúp, nhưng không nhiều như tôi tưởng. Bucket tôi chọn riêng cho phân bố này kéo sai số p90 từ 19,7% xuống 13,3% — đỡ hơn một phần ba, nhưng vẫn là sai số hai chữ số. Với p99 nó còn đổi dấu, từ +2,5% thành −3,1%.
Và max(p99) không cho con số đúng, nhưng nó là thứ duy nhất báo động đúng hướng. Nó vượt lên 2 246 ms khi có instance hỏng — sai +43,5% so với sự thật, nhưng sai theo hướng khiến bạn đi xem, thay vì theo hướng ru ngủ.
Vì sao p90 lại sai nhất
histogram_quantile không có dữ liệu thô. Nó chỉ biết "có bao nhiêu mẫu nhỏ hơn hoặc bằng mỗi cận bucket", rồi tìm bucket chứa phân vị cần tính và nội suy tuyến tính bên trong bucket đó. Nội suy tuyến tính giả định các mẫu rải đều trong bucket.
Giả định đó sai ở mức độ khác nhau tuỳ chỗ. p90 của tôi là 36,95 ms, rơi vào bucket (0,025 ; 0,05] của cấu hình mặc định. Đó là vùng dốc nhất của phân bố: mật độ mẫu giảm rất nhanh từ cận dưới lên cận trên, nên phần lớn mẫu nằm dồn về phía 25 ms. Giả định "rải đều" đẩy ước lượng lên phía giữa bucket, và ra 44,22 ms thay vì 36,95 ms.
p99 thì rơi vào bucket (0,1 ; 0,25] — bucket này rộng hơn (150 ms so với 25 ms), nhưng nó nằm trong vùng đuôi, nơi phân bố đã thoải và gần đều hơn. Giả định của nội suy khớp tốt hơn, nên sai số nhỏ hơn dù bucket rộng gấp sáu. Bài học rút ra không phải "bucket hẹp thì tốt", mà là: sai số phụ thuộc vào độ dốc của phân bố bên trong bucket, không phụ thuộc bề rộng của bucket. Muốn giảm sai số ở đâu thì đặt bucket dày ở đúng chỗ đó.
Còn avg(p99) sai vì một lý do đơn giản hơn nhiều: phân vị không cộng được. p99 của toàn hệ không phải là trung bình các p99 thành phần, cũng như học sinh cao nhất trường không phải trung bình các "cao nhất lớp". Khi mọi instance có cùng phân bố, trung bình các p99 tình cờ gần đúng — nên sai lầm này sống sót qua mọi lần kiểm tra ở môi trường khoẻ mạnh. Khi một instance lệch hẳn, toàn bộ 100.000 mẫu chậm của nó dồn vào phần đuôi của tập gộp và đẩy p99 thật lên rất cao, trong khi phép trung bình chia đều nó cho mười và làm nó gần như biến mất. Cách đúng là gộp bucket rồi mới tính phân vị:
histogram_quantile(0.99, sum by (le) (rate(lat_bucket[5m])))
chứ không phải avg của các p99 đã tính sẵn.
Nghĩa là gì trong công việc hằng ngày
- Kiểm lại mọi truy vấn phân vị trong bảng điều khiển của bạn ngay hôm nay. Nếu thấy
avg(bọc ngoài mộthistogram_quantile, đó là lỗi. Chuyểnsum by (le)vào trong. - Đừng đặt ngưỡng cảnh báo sát mép nếu bạn dùng phân vị từ Histogram. Sai số 19,7% ở p90 nghĩa là ngưỡng "p90 dưới 40 ms" sẽ kêu suốt ngày dù sự thật là 36,95 ms. Hoặc nới ngưỡng, hoặc đặt bucket dày quanh ngưỡng đó.
- Chọn bucket theo ngưỡng bạn quan tâm, không theo mặc định. Nếu SLO của bạn là "p90 dưới 50 ms", hãy có cận bucket đúng ở 0,05 và vài cận sát quanh đó. Cận bucket là chỗ duy nhất
histogram_quantilebiết chắc chắn. - Với nhiều instance,
max(p99)là chỉ số bổ sung tốt. Nó không cho con số đúng, nhưng nó phát hiện được "một máy đang hỏng" — thứ màavgche đi và thứ mà p99 gộp cũng không phân biệt được với "cả cụm chậm đều". - Kết hợp với hai phần trước, quy tắc đầy đủ là: vẽ p50 và p99 gộp đúng cách, thêm
max(p99)theo instance, và biết rằng con số hiện trên màn hình có sai số một chữ số ở đuôi và có thể hai chữ số ở giữa.
Cùng một cái bẫy "phân vị không cộng được", đổi ngăn xếp
Chuyện "không được lấy trung bình của một phân vị, và ước lượng phân vị từ histogram có sai số phụ thuộc độ dốc" không riêng gì Prometheus — nó là cái bẫy thống kê chung của mọi hệ đo độ trễ.
Trong thế giới Java, cái bẫy kinh điển là gộp latency percentile qua nhiều node bằng cách trung bình. Micrometer/Dropwizard Metrics cho bạn percentile tính sẵn per-instance, và nếu backend của bạn (hay một dashboard) trung bình chúng lại thì dính đúng lỗi avg(p99) này. Cách đúng trong hệ Java hiện đại là dùng histogram xuất được (Micrometer's publishPercentileHistogram xuất bucket kiểu Prometheus) rồi gộp bucket, chứ không xuất quantile đã tính. HdrHistogram thì cho phép cộng các histogram lại rồi tính percentile trên tổng — đúng tinh thần "gộp bucket rồi mới tính".
Trong Go — ngôn ngữ viết Prometheus — thư viện client phơi bày cả hai lựa chọn và tài liệu cảnh báo thẳng: Summary tính quantile tại chỗ per-instance và không gộp được giữa các instance, còn Histogram xuất bucket để gộp bằng histogram_quantile. Chọn Summary cho một dịch vụ nhiều bản sao là tự tạo ra đúng cái bẫy avg(p99) ở tầng thiết kế metric.
Còn ở cơ sở dữ liệu, cùng nguyên tắc hiện ra khi tổng hợp thống kê: percentile_cont(0.99) của PostgreSQL trên bảng gốc cho phân vị đúng, nhưng nếu bạn tính p99 theo từng phân vùng rồi trung bình các p99 đó lên thì sai y hệt. Và các cột thống kê xấp xỉ (như t-digest trong một số extension) cũng có sai số tập trung ở vùng phân bố dốc — đúng câu chuyện p90 sai nhất ở trên.
Mẫu số chung: phân vị là một thứ bậc trên tập gộp, không phải một đại lượng cộng được — nên muốn phân vị của toàn hệ, bạn phải gộp dữ liệu (bucket, mẫu, digest) trước rồi mới tính, không bao giờ tính rồi mới trung bình. Và mọi ước lượng phân vị từ dạng nén (histogram, digest) đều gánh sai số lớn nhất ở nơi phân bố dốc nhất, không phải ở đuôi xa nhất. Nên câu hỏi đúng trước mỗi con số p99 trên dashboard không phải "nó bao nhiêu" mà là "nó được gộp trước hay tính trước" — vì hai thứ đó cho hai câu trả lời khác nhau 4,4 lần đúng lúc có sự cố.
Muốn tự thấy dòng thứ hai của cái bẫy, chạy thử:
docker run --rm python:3.12-slim python - <<'EOF'
import random, statistics
random.seed(11)
PER = 50_000
def khoe():
a = [random.lognormvariate(-3.9, 0.45) for _ in range(int(PER*0.99))]
a += [random.lognormvariate(-0.7, 0.35) for _ in range(PER - len(a))]
return a
def hong(): return [random.lognormvariate(0.0, 0.35) for _ in range(PER)]
def p99(a): s = sorted(a); return s[int(0.99*(len(s)-1))]
for n in (0, 1):
inst = [khoe() for _ in range(10-n)] + [hong() for _ in range(n)]
that = p99([x for i in inst for x in i])
tb = statistics.fmean([p99(i) for i in inst])
print("%d instance hong: p99 that %8.1f ms | avg(p99) %8.1f ms | sai %+.1f%%"
% (n, that*1000, tb*1000, 100*(tb-that)/that))
EOF
Dòng đầu ra sai vài phần trăm và trông vô hại. Dòng thứ hai là lý do đừng bao giờ lấy trung bình của một phân vị.