Một câu văn đúng ngữ pháp vẫn có thể sai hoàn toàn về nghĩa — "tôi cho con mèo ăn cái bàn" không phạm luật nào của tiếng Việt, chỉ phạm luật của thực tế. Trình kiểm chính tả bó tay với loại lỗi đó, vì nó chỉ kiểm hình thức. Truy vấn PromQL cũng vậy: bốn lỗi bảng điều khiển tôi gặp nhiều nhất đều đúng cú pháp, Prometheus vui vẻ trả kết quả, biểu đồ vẫn vẽ ra — và tất cả đều sai nghĩa. Điểm chung khiến chúng sống dai: không cái nào sinh ra một dòng lỗi nào. Bài này tôi dựng và đo cả bốn bằng chính Prometheus.
(Các con số đo trong container Prometheus, không phải perf counter phần cứng.)
Bảng số liệu
Prometheus 2.54.1, 900 chuỗi, thu thập mỗi 1 giây. Dữ liệu cố tình có hai thứ mà hệ thống thật luôn có: counter bị đặt lại (mô phỏng tiến trình khởi động lại) và đột biến gấp 20 lần kéo dài 5 giây.
Lỗi 1 — cửa sổ rate() ngắn hơn hai lần thu thập:
| Biểu thức | Số điểm |
|---|---|
rate(...[1s]) |
0 — biểu đồ trống |
rate(...[2s]) |
31 |
rate(...[5s]) |
31 |
Lỗi 2 — rate(sum()) thay vì sum(rate()):
| Trung bình | Đỉnh | |
|---|---|---|
sum(rate(...)) — đúng |
59 769 req/s | 76 985 |
rate(sum(...)) — sai |
205 543 req/s | 407 015 |
Cao gấp 3,44 lần. Lỗi 3 — vẽ counter thô: sum(http_requests_total) đi từ 2 379 619 lên 9 977 845 trong 150 giây, luôn tăng. Lỗi 4 — step lớn hơn cửa sổ rate:
| step / cửa sổ | Số điểm | Đỉnh nhìn thấy | Bỏ sót |
|---|---|---|---|
step=1s, rate[10s] |
151 | 208 286 | 0% |
step=5s, rate[10s] |
31 | 208 286 | 0% |
step=15s, rate[10s] |
11 | 206 778 | 1% |
step=60s, rate[10s] |
3 | 53 026 | 75% |
Bốn lỗi, không cái nào kêu lên
Điều làm chúng sống lâu là không lỗi nào trong bốn lỗi này sinh ra thông báo lỗi: bảng điều khiển trông bình thường, ai đó đã duyệt nó, và nó nằm đó nhiều tháng cho tới lúc có sự cố mà nó không giúp được gì. Lỗi 1 cho biểu đồ trống rỗng — rate() cần ít nhất hai mẫu trong cửa sổ để tính được độ dốc, mà với chu kỳ thu thập 1 giây thì cửa sổ 1 giây thường chỉ chứa một mẫu, nên Prometheus trả về không có gì, không phải lỗi. Lỗi 2 cho con số cao gấp 3,44 lần — đây là lỗi nguy hiểm nhất vì kết quả trông hợp lý, chỉ là sai: đỉnh 407 015 so với 76 985 thật. Lỗi 4 giấu 75% chiều cao đỉnh — với step=60s trên cửa sổ rate[10s], mỗi điểm chỉ nhìn 10 giây trong mỗi 60 giây, nên 50 giây dữ liệu giữa hai điểm không được nhìn tới lần nào, và đột biến rơi vào khoảng đó thì biến mất hoàn toàn.
Vì sao
Lỗi 2 đáng giải thích kỹ nhất. rate() của Prometheus biết cách xử lý counter bị đặt lại: khi thấy giá trị tụt xuống, nó hiểu là tiến trình khởi động lại và cộng bù phần đã mất — nhưng nó chỉ làm được điều đó trên từng chuỗi riêng lẻ. Khi bạn viết rate(sum(...)), phép sum chạy trước và gộp 900 chuỗi thành một; nếu một chuỗi trong đó bị đặt lại, tổng chỉ tụt xuống một chút — không đủ để trông giống một lần đặt lại, nhưng đủ để rate coi đó là dữ liệu hợp lệ rồi bù vào bằng một cú tăng giả ở điểm sau. Với 180 chuỗi của dịch vụ worker bị đặt lại mỗi 30 giây, những cú bù giả đó cộng dồn thành 3,44 lần. Quy tắc: rate() luôn phải là phép trong cùng — gộp sau, không bao giờ gộp trước.
Lỗi 3 đơn giản hơn: counter là tổng tích luỹ từ lúc tiến trình khởi động, vẽ nó ra chỉ cho một đường dốc lên mãi mãi — nó không nói được tốc độ hiện tại, không nói lúc nào tăng lúc nào giảm, và một lần khởi động lại sẽ thành cú sụt thẳng đứng trong khi không có gì hỏng. Lỗi 4 đến từ việc step và cửa sổ rate là hai thứ độc lập: step quyết định bao nhiêu điểm được tính, cửa sổ rate quyết định mỗi điểm nhìn lại bao xa; khi step lớn hơn cửa sổ, giữa các điểm có khoảng trống mà không điểm nào chạm tới.
Ra thực tế thì cửa sổ rate phải ít nhất gấp đôi chu kỳ thu thập, an toàn hơn là gấp bốn (thu thập 15 giây thì dùng rate(...[1m])). rate() luôn nằm trong cùng: sum(rate(x[5m])), không bao giờ rate(sum(x)[5m:]) — đây là lỗi dễ mắc nhất khi viết truy vấn cho một chỉ số nhiều nhãn. Không bao giờ vẽ counter thô: tên chỉ số kết thúc bằng _total thì cần rate() hoặc increase(). Đặt step bằng $__interval của Grafana và cửa sổ rate bằng $__rate_interval — Grafana tính hai biến đó sao cho cửa sổ luôn phủ được khoảng giữa các điểm, gõ cứng con số là tự chuốc lỗi 1 hoặc lỗi 4. Và kiểm tra bảng điều khiển cũ bằng cách chạy lại cùng truy vấn với step nhỏ hơn nhiều rồi so đỉnh; nếu đỉnh cao hơn hẳn, bảng của bạn đang giấu mất phần đó.
Có một điểm chung đáng nói riêng: cả bốn đều là lỗi đúng cú pháp nhưng sai ngữ nghĩa — Prometheus không có cách nào biết ý bạn là gì. rate(sum(x)) là một truy vấn hoàn toàn hợp lệ và có ý nghĩa trong vài trường hợp hiếm; step=60s với rate[10s] cũng vậy nếu bạn thật sự chỉ muốn lấy mẫu thưa. Hệ thống không thể từ chối chúng, nên toàn bộ trách nhiệm dồn lên người viết truy vấn — và trong thực tế truy vấn thường được sao chép từ một bảng điều khiển khác rồi sửa tên chỉ số. Lỗi lan theo cách đó, và nó lan cùng với sự tự tin, vì bảng gốc "vẫn chạy tốt".
Mẫu số chung
Lỗi 2 — rate(sum()) so với sum(rate()) — không phải chuyện của Prometheus mà là một sai lầm phổ quát về thứ tự của phép gộp: khi bạn có một phép biến đổi phi tuyến (rate, average, tỷ lệ) và một phép gộp (sum, group), làm cái nào trước cho ra hai con số khác nhau, và chỉ một thứ tự đúng nghĩa.
Trong thế giới Java, đây đúng là bẫy "trung bình của trung bình" với Stream: groups.stream().mapToDouble(g -> g.average()).average() không bằng trung bình của toàn bộ phần tử, vì mỗi nhóm có số lượng khác nhau — phải gộp tổng và đếm trước rồi mới chia (sum/count toàn cục), đúng như phải rate từng chuỗi trước rồi mới sum. Ai từng gộp p99 bằng cách lấy trung bình các p99 đã vấp cùng lỗi tầng gộp.
Trong Go, cùng khuôn khi gộp metric từ nhiều shard: cộng các tỷ lệ đã tính sẵn của từng shard cho ra rác, phải cộng tử số và mẫu số riêng rồi mới chia. Đây là lý do các thư viện metric khuyên xuất counter thô (tử/mẫu) chứ đừng xuất tỷ lệ đã tính — để tầng gộp làm phép chia sau cùng.
Còn ở cơ sở dữ liệu, đây là hai lỗi SQL kinh điển: AVG(AVG(x)) sai vì bỏ trọng số nhóm (đúng bài "trung bình của trung bình"), và SUM sau một JOIN fan-out đếm nhân bản mỗi hàng lên theo số dòng khớp — cả hai đều là phép gộp chạy sai tầng. Câu trả lời giống hệt: gộp ở đúng mức hạt, hoặc tính tử/mẫu riêng rồi mới kết hợp.
Mẫu số chung: với một phép phi tuyến ghép cùng một phép gộp, thứ tự không giao hoán — "gộp rồi biến đổi" và "biến đổi rồi gộp" ra hai kết quả khác nhau, và gần như luôn chỉ có một thứ tự đúng nghĩa (thường là biến đổi/tính-per-đơn-vị trước, gộp sau). Vì thế câu hỏi đúng khi viết bất kỳ biểu thức gộp nào không phải "cú pháp có chạy không" — nó luôn chạy — mà là "phép gộp của tôi đang đứng ở tầng nào, và ở tầng đó nó còn đúng nghĩa không"; hệ thống sẽ không bao giờ hỏi hộ bạn câu đó.
Muốn tự soi lỗi 2 trên hệ của mình trong ba mươi giây, so hai con số:
# Chay tren Prometheus cua ban - so hai con so
P=http://localhost:9090
M=http_requests_total # doi thanh mot counter co that cua ban
END=$(date +%s); START=$((END-3600))
echo "sum(rate(...)) - dung:"
curl -s -G "$P/api/v1/query" --data-urlencode "query=sum(rate(${M}[5m]))" \
| python3 -c 'import sys,json;r=json.load(sys.stdin)["data"]["result"];print(" ",r[0]["value"][1] if r else "khong co du lieu")'
echo "rate(sum(...)) - sai:"
curl -s -G "$P/api/v1/query" --data-urlencode "query=rate(sum(${M})[5m:])" \
| python3 -c 'import sys,json;r=json.load(sys.stdin)["data"]["result"];print(" ",r[0]["value"][1] if r else "khong co du lieu")'
Nếu hai con số bằng nhau, hệ thống của bạn hiện chưa có tiến trình nào khởi động lại — và lỗi này đang chờ lần triển khai tiếp theo để lộ ra.