Khi chọn kiểu chỉ số, trực giác ai cũng lo cái giá lúc ghi — hàm observe() này có làm chậm vòng lặp nóng không? Nhưng đó là nhìn nhầm hoá đơn. Giống như mua một con thú cưng: giá lúc mua gần như không đáng kể so với tiền ăn, tiền khám, tiền dọn mỗi ngày trong nhiều năm sau. Chỉ số cũng vậy — cái bạn trả không phải lúc gọi .inc() mà là số chuỗi thời gian nó để lại, thứ Prometheus kéo về và lưu mỗi mười lăm giây, mãi mãi. Sê-ri chuyển từ log sang chỉ số, và câu hỏi đầu tiên ai cũng gặp là chọn Counter, Gauge, Histogram hay Summary; phần lớn tài liệu trả lời theo ngữ nghĩa, còn tôi đo giá tiền — và câu trả lời nằm ở chỗ khác hẳn chỗ tôi tưởng lúc bắt đầu.

(Các con số đo trong container, là thời gian và số dòng, không phải perf counter phần cứng.)

Bảng số liệu

prometheus_client trên Python 3.12 trong container, 500.000 lần ghi mỗi lô, trung vị của 5 lô, lặp lại cả bài ba lần. Giá mỗi lần ghi:

Kiểu ns So với Counter
Counter.inc() 192,1 1,00×
Gauge.set() 202,7 1,06×
Summary.observe() 328,3 1,71×
Histogram.observe() 486,4 2,53×

Số chuỗi thời gian mỗi kiểu sinh ra (một chỉ số, không nhãn, đọc từ /metrics):

Kiểu Số dòng Số byte Gồm những gì
Gauge 1 32 một giá trị hiện tại
Counter 2 127 tổng cộng dồn + mốc tạo
Summary 3 142 số lần + tổng
Histogram (15 bucket) 18 518 mỗi bucket một dòng + tổng + số lần

Khi gắn nhãn, cùng một số giá trị nhãn:

Số giá trị nhãn Counter Histogram Tỷ lệ
1 2 dòng 18 dòng 9×
10 20 dòng 180 dòng 9×
100 200 dòng 1 800 dòng 9×
1 000 2 000 dòng (78 KB) 18 000 dòng (700 KB) 9×

Bốn điều, và một giả định sai của tôi

Chọn kiểu chỉ số không phải chuyện hiệu năng lúc ghi. Cả bốn kiểu đều dưới nửa micro giây, khoảng cách rộng nhất chỉ 2,53 lần. Ở 10.000 lần ghi mỗi giây, kiểu đắt nhất tốn 4,9 ms CPU mỗi giây — 0,49% một nhân; không quyết định kiến trúc nào đáng đưa ra dựa trên con số đó. Chỗ thật sự tính tiền là số chuỗi thời gian: một Histogram sinh 18 dòng cho mỗi tổ hợp nhãn so với 2 dòng của Counter — gấp 9 lần, và tỷ lệ đó giữ nguyên ở mọi mức nhãn tôi thử. Và nó nhân lên chứ không cộng vào — một Histogram gắn một nhãn có 1 000 giá trị cho ra 18 000 chuỗi và 700 KB trong /metrics, mà Prometheus sẽ kéo tệp đó về mỗi 15 giây, mãi mãi, rồi lưu từng chuỗi một. Summary rẻ hơn Histogram vì nó làm ít hơn: trong prometheus_client, Summary chỉ giữ số lần và tổng, không tính phân vị phía client — ba dòng, 142 byte; nếu bạn cần p99 thì Summary ở thư viện này không cho bạn thứ đó.

Vì sao

Counter và Gauge chỉ cần cộng hoặc gán một số, chênh 1,06 lần giữa chúng nằm trong nhiễu. Histogram.observe() phải làm nhiều hơn hẳn: với mỗi giá trị, nó tìm xem giá trị rơi vào những bucket nào rồi tăng bộ đếm của tất cả các bucket từ đó trở lên (bucket của Prometheus là kiểu tích luỹ) — với 15 bucket mặc định, đó là tới 15 phép cộng thay vì một, cộng thêm tổng và số lần ra 2,53 lần. Nhưng cái làm Histogram đắt trong vận hành không phải 486 ns kia: mỗi bucket là một chuỗi thời gian riêng, có tên riêng và bộ nhãn riêng, được lưu và đánh chỉ mục độc lập; mười lăm bucket cộng _sum, _count và dòng _created thành 18 dòng. Nhân với số tổ hợp nhãn, và số đó nhân tiếp với nhau nếu bạn có nhiều nhãn: hai nhãn 50 giá trị là 2 500 tổ hợp, tức 45 000 chuỗi từ một chỉ số duy nhất. Đây là lý do lời khuyên "đừng gắn nhãn có nhiều giá trị" nghe giống nhau cho mọi kiểu chỉ số nhưng thực tế nghiêm trọng gấp chín lần với Histogram.

Ra thực tế thì đừng chọn Counter thay vì Histogram để tiết kiệm CPU — bạn tiết kiệm 294 ns mỗi lần ghi và mất khả năng biết phân bố độ trễ, một món hời tệ. Chọn theo câu hỏi cần trả lời: Counter cho "bao nhiêu lần", Gauge cho "hiện đang là bao nhiêu", Histogram cho "phân bố ra sao và p99 là bao nhiêu" — phần trước đã cho thấy chọn sai phân vị là mù trước sự cố, và không có Histogram thì bạn không có phân vị nào để chọn. Trước khi thêm một nhãn vào Histogram, hãy nhân ra: số bucket × số giá trị nhãn 1 × số giá trị nhãn 2; nếu tích đó vượt vài nghìn cho một chỉ số, bỏ nhãn đó hoặc gộp giá trị lại. Giảm số bucket là cách rẻ nhất để cắt chi phí Histogram — mười lăm bucket mặc định hiếm khi cần, sáu tới tám bucket chọn đúng khoảng độ trễ của bạn cắt hơn một nửa số chuỗi mà gần như không mất thông tin dùng được. Và kiểm kích thước /metrics của chính bạn bằng curl -s localhost:9090/metrics | wc -c: nếu nó tính bằng megabyte, thủ phạm gần như luôn là một Histogram gắn nhãn sai.

Mẫu số chung

Cái bẫy sâu nhất ở đây là người ra quyết định không phải người trả giá: người viết dòng observe() thấy ứng dụng chạy nhanh y như cũ, test vẫn xanh — còn hoá đơn (bộ nhớ Prometheus, băng thông mỗi lần kéo, tốc độ mọi truy vấn chạm chỉ số, dung lượng lưu trữ) rơi xuống một hệ khác do một đội khác vận hành, vài tuần sau. Khuôn "chi phí ghi rẻ, chi phí nhân bản về sau mới đắt và ẩn" lặp ở mọi tầng.

Trong thế giới Java, đây đúng là bùng nổ cardinality của Micrometer: gắn một Tag giá trị vô hạn (userId, requestId) vào một Timer — mà Timer publish histogram nếu bật publishPercentileHistogram — nhân số chuỗi lên khủng khiếp, và JVM vẫn chạy mượt trong khi backend metric nghẹt. Lời khuyên số một của Micrometer là "tag phải có tập giá trị hữu hạn, biết trước", đúng cùng bài học ở đây.

Trong Go, prometheus client cũng cảnh báo y hệt về nhãn cardinality cao, và một HistogramVec gắn nhãn động là cách kinh điển làm nổ bộ nhớ Prometheus — chi phí Observe() không đáng kể, chi phí số chuỗi mới là thứ giết.

Còn ở cơ sở dữ liệu, cùng khuôn là write amplification của index thứ cấp: thêm một index rất rẻ để viết câu lệnh (CREATE INDEX), nhưng từ đó mọi INSERT/UPDATE phải cập nhật thêm index đó — người thêm index không phải người trả cái giá âm thầm trên mọi lần ghi và mọi lần lưu, đúng như người thêm nhãn Histogram không trả giá cho 18 000 chuỗi.

Mẫu số chung: chi phí thật của một thứ "chỉ thêm vào" thường không phải chi phí lúc thêm mà là chi phí nhân lên theo thời gian và quy mô ở một nơi khác, do một người khác gánh — nên rẻ-lúc-thêm và đắt-về-sau hay đi cùng nhau. Vì thế câu hỏi đúng khi thêm một chỉ số (hay một index, một tag) không phải "nó tốn bao nhiêu lần ghi" mà là "nó sẽ tồn tại bao nhiêu chuỗi/bản sao, và ai là người trả cái giá đó" — vì số lần ghi rơi vào tiến trình bạn (gần như miễn phí), còn số chuỗi rơi vào hệ khác và không nằm trong tầm kiểm soát của người viết dòng đó.

Muốn tự thấy nó nhân lên thế nào, chạy trong ba mươi giây:

docker run --rm python:3.12-slim sh -c '
pip -q install prometheus_client
python - <<EOF
from prometheus_client import Counter, Histogram, CollectorRegistry, generate_latest

for k in (1, 10, 100, 1000):
    rg = CollectorRegistry()
    c = Counter("c_total", "d", ["endpoint"], registry=rg)
    h = Histogram("h", "d", ["endpoint"], registry=rg)
    for i in range(k):
        c.labels(endpoint="e%d" % i).inc()
        h.labels(endpoint="e%d" % i).observe(0.1)
    out = generate_latest(rg).decode()
    n = len([l for l in out.splitlines() if l and not l.startswith("#")])
    print("%5d gia tri nhan -> %6d dong, %8d byte" % (k, n, len(out)))
EOF'

Nhìn cột cuối khi con số nhãn đi từ 100 lên 1 000. Đó là thứ bạn sẽ trả tiền mỗi mười lăm giây, cho tới khi ai đó gỡ cái nhãn ấy ra.