Sê-ri chuyển từ log sang chỉ số. Câu hỏi đầu tiên ai cũng gặp là chọn kiểu nào: Counter, Gauge, Histogram hay Summary. Phần lớn tài liệu trả lời theo ngữ nghĩa — cái nào tăng đơn điệu, cái nào lên xuống được. Bài này đo giá tiền, và hoá ra câu trả lời nằm ở chỗ khác hẳn với chỗ tôi tưởng lúc bắt đầu.

Bốn kiểu chỉ số: giá mỗi lần ghi và số chuỗi sinh ra

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
10 20 dòng 180 dòng
100 200 dòng 1 800 dòng
1 000 2 000 dòng (78 KB) 18 000 dòng (700 KB)

Điều đáng nhớ

Nói lại theo cách khác, vì đây là chỗ tôi vào bài với giả định sai:

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, và khoảng cách rộng nhất chỉ là 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 có 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 Countergấ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. 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, Summary ở thư viện này không cho bạn thứ đó.

Vì sao

CounterGauge chỉ cần cộng hoặc gán một số. Chênh lệch 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, ta 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 vì sao 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.

Nghĩa là gì trong thực tế

  • Đừ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ễ. Đó là món hời tệ.
  • Chọn theo câu hỏi bạn 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 của sê-ri đã 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ố, hãy 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 thiết; 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.
  • Kiểm tra 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.

Có một cách nhìn khác giúp quyết định nhanh hơn. Hãy hỏi: chỉ số này sẽ tồn tại bao nhiêu chuỗi, chứ không phải sẽ được ghi bao nhiêu lần. Số lần ghi ảnh hưởng tới CPU của tiến trình bạn, và bảng trên cho thấy nó gần như không đáng kể. Số chuỗi ảnh hưởng tới bộ nhớ của Prometheus, tới lượng dữ liệu truyền mỗi lần kéo, tới tốc độ mọi truy vấn chạm vào chỉ số đó, và tới hoá đơn lưu trữ — bốn thứ, và không thứ nào nằm trong tầm kiểm soát của người viết dòng observe().

Đó cũng là lý do lỗi này khó phát hiện: người thêm nhãn không phải người trả giá. Ứng dụng chạy nhanh y như cũ, các bài kiểm thử vẫn xanh, và hậu quả chỉ hiện ra vài tuần sau ở một hệ thống khác do một đội khác vận hành.

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

Đây là prometheus_client của Python, và thư viện quyết định rất nhiều. Đặc biệt là chuyện Summary không tính phân vị: đó là lựa chọn của thư viện này, không phải của Prometheus. Các thư viện khác — và Summary trong đặc tả gốc — có thể tính phân vị trượt phía client, và khi đó Summary sẽ đắt hơn Histogram chứ không rẻ hơn. Đừng mang con số 1,71 lần sang ngôn ngữ khác.

Tôi đo giá ghi trong một luồng, không có tranh chấp. prometheus_client dùng khoá cho các thao tác này; với nhiều luồng cùng ghi vào một chỉ số, con số sẽ khác và tôi không đo trường hợp đó.

Số byte của /metrics không bằng chi phí lưu trữ ở Prometheus. Prometheus nén rất mạnh chuỗi thời gian, nên 700 KB mỗi lần kéo không có nghĩa là 700 KB mỗi 15 giây trên đĩa. Cái tôi đo là số chuỗilượng dữ liệu truyền mỗi lần kéo — hai thứ đó đúng, còn chi phí lưu trữ cuối cùng là một phép đo khác mà tôi chưa làm.

Một điều tôi đã đoán sai trước khi đo. Tôi vào bài với giả định rằng khoảng cách giữa các kiểu sẽ đủ lớn để đáng cân nhắc lúc viết mã — kiểu như Histogram đắt gấp mười Counter. Số đo cho 2,53 lần, và cả bốn đều nằm dưới nửa micro giây. Giả định của tôi sai, và điều đáng nói là nó sai theo hướng làm tôi lo nhầm chỗ: nếu tin nó, tôi đã đi tối ưu chỗ tốn 0,49% một nhân trong khi bỏ qua chỗ nhân số chuỗi lên chín lần.

Thử 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.