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. Bài này đo khoảng cách giữa hai thứ đó, và tìm ra hai kết quả tôi không đoán trước.

Bốn cách tính p99, bốn kết quả khác nhau

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à đó 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%)

Điều đáng nhớ

Nói lại theo cách khác, vì cả hai kết quả đều 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%.

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à đó 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ố.

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%.

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

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ư trung vị của cả nước không phải trung bình các trung vị tỉnh. 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 thực tế

  • 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ột histogram_quantile, đó là lỗi. Chuyển sum 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_quantile biế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à avg che đ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.

Chỗ tôi không kết luận được

Con số 19,7% là của phân bố này với bucket này. Đổi hình dạng phân bố hoặc đổi cấu hình bucket thì vị trí của sai số lớn nhất sẽ dịch đi. Cái tôi tin là cơ chế — sai số bám theo độ dốc của phân bố trong bucket — chứ không phải "p90 luôn là chỗ tệ nhất". Nếu bạn muốn biết chỗ tệ nhất của mình ở đâu, cách duy nhất là làm đúng phép đo này trên dữ liệu của bạn.

Instance hỏng của tôi hỏng rất dứt khoát: toàn bộ 100.000 mẫu đều chậm. Một instance hỏng nửa vời — chỉ 20% request chậm — sẽ cho sai số nhỏ hơn nhiều, và tôi không đo dải trung gian đó.

Tôi không đo ảnh hưởng của rate() và cửa sổ thời gian. Truy vấn thật gần như luôn có dạng histogram_quantile(0.99, sum by (le) (rate(...[5m]))), và rate thêm một tầng ước lượng nữa lên trên tầng nội suy bucket. Tôi truy vấn trên giá trị tức thời để cô lập đúng một nguồn sai số. Sai số thật trên bảng điều khiển của bạn sẽ lớn hơn con số trong bài này, không nhỏ hơn.

Một điều tôi đã đoán sai và phải đo lại. Lần chạy đầu, tôi so avg(p99) trên mười instance đồng nhất và ra sai số +3,8%. Nếu dừng ở đó, bài này sẽ kết luận rằng avg(p99) là một xấp xỉ chấp nhận được — một kết luận đúng với dữ liệu tôi có và sai với thực tế. Điều khiến tôi đo tiếp là nhận ra mình đã dựng một thí nghiệm không thể phát hiện được vấn đề: nếu mọi instance giống nhau thì trung bình của chúng tất nhiên gần đúng. Thí nghiệm chỉ có nghĩa khi các instance khác nhau, và ngay khi cho một cái hỏng, sai số nhảy từ 3,8% lên 77,2%.

Thử ba mươi giây

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ị.