Có một khoản chi phí ẩn mà gần như không ai tính, vì nó không hiện lên chính cái màn hình đang gây ra nó: giá của việc nhìn. Mỗi bảng điều khiển đang mở là một cỗ máy lặng lẽ bắn truy vấn vào Prometheus đều đặn vài giây một lần, cả khi không ai ngó tới — như một cái vòi nước rỉ trong phòng trống, không ai nghe nhưng đồng hồ vẫn quay. Sê-ri đã đo giá của việc sinh chỉ số và giá của việc tính phân vị; bài này đo giá của việc xem, trên một Prometheus có hơn hai mươi nghìn chuỗi.
(Các con số đo trong container Prometheus, là thời gian và dung lượng, không phải perf counter phần cứng.)
Bảng số liệu
Prometheus 2.54.1, một exporter sinh 21 563 chuỗi, thu thập mỗi giây trong 150 giây — tổng 3,23 triệu mẫu. Mỗi lần thu thập tải về 1,85 MB và tốn 44 ms. Khoảng xem của bảng điều khiển là 140 giây. Bốn kiểu panel thường gặp, ở bước 1 giây:
| Panel | Số đường vẽ | Số điểm | Thời gian |
|---|---|---|---|
sum(rate(...)) |
1 | 141 | 260 ms |
sum by (service) (rate(...)) |
5 | 705 | 259 ms |
topk(10, rate(...)) |
381 | 1 410 | 264 ms |
rate(...) không gộp |
21 000 | 2 961 000 | 488 ms |
Cùng panel không gộp, đổi bước:
| Bước | Số điểm | JSON | Thời gian |
|---|---|---|---|
| 1s | 2 961 000 | 97,54 MB | 461 ms |
| 5s | 609 000 | 22,30 MB | 201 ms |
| 15s | 210 000 | 9,54 MB | 147 ms |
| 60s | 63 000 | 4,83 MB | 95 ms |
Cả bảng điều khiển, bước 15 giây, làm tươi mỗi 10 giây:
| Số panel | Thời gian | Một người xem | Mười người xem |
|---|---|---|---|
| 5 | 242 ms | 2% một nhân | 24% |
| 10 | 367 ms | 4% | 37% |
| 20 | 726 ms | 7% | 73% |
| 40 | 1 226 ms | 12% | 123% |
Bốn điều, hai cái ngược trực giác
Gộp bằng sum by không làm Prometheus nhẹ đi. Panel trả về một đường mất 260 ms; panel trả về 21 000 đường mất 488 ms — chỉ gấp 1,88 lần, dù số điểm trả về gấp hai mươi nghìn lần. Lý do đơn giản: cả bốn panel đều phải đọc đủ 21 000 chuỗi để tính rate, gộp chỉ cắt bớt phần trả về chứ không cắt phần đọc. Cái sum by thật sự cứu là trình duyệt, không phải máy chủ: 97,54 MB JSON cho một panel duy nhất ở bước 1 giây — Grafana phải tải, phân tích và vẽ 2,96 triệu điểm lên một khung hình rộng vài trăm pixel, đó mới là chỗ bảng điều khiển đứng hình. Bước là đòn bẩy lớn nhất, nhưng lớn không đều: từ 1 giây lên 60 giây, JSON giảm 20,2 lần trong khi thời gian truy vấn chỉ giảm 4,85 lần, vì phần đọc 3,23 triệu mẫu không đổi, chỉ phần trả về đổi. Và chi phí nhân với số người đang mở: bốn mươi panel làm tươi mỗi 10 giây tốn 12% một nhân — chấp nhận được cho một người, nhưng mười người cùng mở là 123%, tức hơn một nhân trọn vẹn, dành riêng cho việc vẽ lại những biểu đồ mà phần lớn thời gian không ai nhìn.
Vì sao phần đọc mới là chỗ tốn, không phải phần trả về
Một truy vấn rate(http_requests_total[30s]) trên khoảng 140 giây bắt Prometheus làm ba việc: tìm các chuỗi khớp bộ chọn, đọc mẫu của chúng trong khoảng thời gian, rồi tính rate tại mỗi mốc bước. Việc thứ hai chiếm phần lớn thời gian và không phụ thuộc vào bước: dù bạn xin một điểm hay ba nghìn điểm, số mẫu phải đọc từ ổ đĩa là như nhau. Đó là lý do đường cong thời gian thoải hơn đường cong dung lượng — bước điều khiển số lần tính, còn bộ chọn và khoảng xem điều khiển số mẫu phải đọc. Muốn giảm phần đọc thì phải thu hẹp bộ chọn (thêm nhãn vào truy vấn) hoặc rút ngắn khoảng xem. sum by (service) không thu hẹp bộ chọn — nó vẫn khớp cả 21 000 chuỗi rồi cộng lại ở cuối; ngược lại rate(http_requests_total{service="api"}[30s]) chỉ chạm một phần năm số chuỗi, và đó mới là cách làm truy vấn rẻ đi thật. Còn 44 ms mỗi lần thu thập là khoản phí nền — 4,4% một nhân chỉ để nạp dữ liệu, trước khi bất kỳ ai mở bất kỳ bảng nào.
Ra thực tế thì đặt bước theo độ rộng biểu đồ, đừng đặt cứng: Grafana có biến $__interval tính bước theo số pixel, dùng nó thay vì gõ 15s hay tệ hơn là 1s — một biểu đồ rộng 800 pixel không thể hiện nhiều hơn 800 điểm, nên mọi điểm sau đó là băng thông vứt đi. Đừng vẽ panel không gộp trên chỉ số nhiều chuỗi; nếu thật sự cần thấy từng chuỗi thì dùng topk và giới hạn khoảng xem, vì một panel 21 000 đường không đọc được bằng mắt, nó chỉ làm trình duyệt chậm. Lọc bằng nhãn trong chính truy vấn chứ đừng lọc bằng cách gộp: {service="api"} rẻ hơn sum by (service) rất nhiều dù cả hai cho ra một đường. Xem lại chu kỳ làm tươi trước khi xem lại truy vấn — đổi từ 10 giây sang 60 giây cắt ngay sáu lần chi phí mà không đổi một ký tự nào, và với dữ liệu thu thập mỗi 15 giây thì làm tươi mỗi 10 giây vốn đã là vẽ lại cùng một thứ. Và nhớ nhân với số người xem: một bảng treo trên màn hình lớn trong phòng làm việc là một người xem chạy 24 giờ mỗi ngày.
Mẫu số chung
Cái phát hiện lõi ở đây — gộp/chiếu chỉ cắt phần trả về, không cắt phần đọc; muốn rẻ thật phải lọc ngay tại nguồn — là một trong những nguyên lý hiệu năng dữ liệu phổ quát nhất, và nó chính là chuyện của cơ sở dữ liệu trước cả khi là chuyện của Prometheus.
Trong thế giới Java, đây đúng là bẫy JPA "nạp hết rồi lọc trong bộ nhớ": findAll().stream().filter(...) kéo cả bảng về JVM rồi mới bỏ đi phần thừa — tốn y như đọc 21 000 chuỗi rồi sum; cách đúng là đẩy điều kiện xuống câu query (findByServiceEquals / WHERE) để tầng dưới đọc ít đi. Cùng bài học với Stream: filter trước map rẻ hơn map trước filter khi phép map đắt, vì bạn thu hẹp trước khi làm việc nặng.
Trong Go, cùng nguyên tắc "predicate pushdown": một client đọc toàn bộ rồi lọc bằng if trong vòng lặp tốn băng thông và bộ nhớ, còn đẩy bộ lọc xuống query (hoặc dùng server-side filter của API) cắt ngay phần truyền và phần đọc — giống hệt thêm nhãn {service="api"} thay vì sum by.
Còn ở cơ sở dữ liệu, đây là bài học kinh điển nhất: WHERE với một index (predicate pushdown) giảm số hàng đọc, trong khi SELECT chỉ vài cột hay GROUP BY chỉ giảm số hàng trả về — một truy vấn SELECT count(*) ... GROUP BY vẫn phải quét đủ bảng nếu không có index trên cột lọc, đúng như sum by vẫn đọc đủ 21 000 chuỗi. EXPLAIN phân biệt rõ "rows scanned" với "rows returned", và tối ưu thật luôn nhắm vào con số đầu.
Mẫu số chung: gộp, chiếu, giới hạn hiển thị chỉ thu nhỏ đầu ra — còn chi phí thật nằm ở lượng dữ liệu phải đọc để tính ra đầu ra đó, và cái duy nhất cắt được phần đọc là lọc ngay tại nguồn (thu hẹp bộ chọn, đẩy điều kiện xuống, dùng index). Vì thế câu hỏi đúng khi một truy vấn chậm không phải "nó trả về bao nhiêu" mà là "nó phải đọc bao nhiêu để trả về chừng đó" — vì bạn có thể trả về một con số duy nhất mà vẫn quét cả kho dữ liệu để có nó.
Muốn tự thấy phần trả về co lại theo bước thế nào, chạy trong ba mươi giây:
# Doi <prom> thanh dia chi Prometheus cua ban
P=http://localhost:9090
Q='rate(http_requests_total[5m])'
END=$(date +%s); START=$((END-21600)) # 6 gio
for STEP in 15 60 300; do
SZ=$(curl -s -G "$P/api/v1/query_range" \
--data-urlencode "query=$Q" \
--data-urlencode "start=$START" --data-urlencode "end=$END" \
--data-urlencode "step=${STEP}s" | wc -c)
printf " step %4ss -> %8.2f MB\n" "$STEP" "$(echo "$SZ/1048576" | bc -l)"
done
Ba dòng, cùng một truy vấn, cùng một khoảng xem. Nếu dòng đầu tính bằng chục megabyte, đó là số byte mà mỗi người mở bảng điều khiển đang tải về, mỗi lần làm tươi.