Bài trước ta dùng distributed tracing để trả lời "thời gian của một request cụ thể nằm ở đâu". Nhưng để trả lời "hệ thống nói chung có khỏe không, tốc độ lỗi bao nhiêu, độ trễ p99 đang tăng chứ", trace là công cụ sai — nó quá đắt và quá chi tiết. Câu trả lời là trụ cột quan sát thứ hai: metrics.

Metrics là những con số tổng hợp, rẻ, và hằng bộ nhớ — chúng không phình theo lưu lượng như log hay trace. Prometheus là chuẩn de facto cho metrics, và client_golang là thư viện chính thức. Bài này mổ xẻ ba loại metric cốt lõi, giải thích vì sao histogram thắng "giá trị trung bình", và đo thẳng chi phí của việc đo — bao gồm một cái bẫy hiệu năng phổ biến với nhãn.

Ba loại metric cốt lõi

Prometheus có ba loại metric bạn sẽ dùng 95% thời gian:

// Counter: CHỈ tăng — đếm tổng số sự kiện (request, lỗi)
reqTotal := prometheus.NewCounterVec(
	prometheus.CounterOpts{Name: "http_requests_total"},
	[]string{"method", "code"}, // nhãn: tách theo chiều
)
// Gauge: lên xuống tự do — giá trị tức thời (đang xử lý, RAM)
inFlight := prometheus.NewGauge(prometheus.GaugeOpts{Name: "http_in_flight"})
// Histogram: phân bố vào các khoảng (bucket) — độ trễ, kích thước
latency := prometheus.NewHistogram(prometheus.HistogramOpts{
	Name:    "http_latency_seconds",
	Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25},
})
  • Counter chỉ tăng, không bao giờ giảm. Đếm những thứ tích lũy: tổng request, tổng lỗi, tổng byte. Bạn không đọc giá trị tuyệt đối của counter — bạn dùng rate() trên nó để ra tốc độ (request/giây).
  • Gauge lên xuống tự do. Đo giá trị tức thời: số request đang xử lý, độ sâu hàng đợi, bộ nhớ đang dùng, nhiệt độ.
  • Histogram đặt mỗi quan sát vào một hoặc nhiều bucket, để tính phân bố — độ trễ, kích thước payload.

Ghi metric trong đường xử lý

Việc ghi metric nằm rải trong đường xử lý request:

inFlight.Inc()                          // +1 khi bắt đầu
// ... xử lý request, đo d là độ trễ ...
reqTotal.WithLabelValues("GET", code).Inc()  // đếm theo (method, code)
latency.Observe(d.Seconds())            // đưa vào histogram
inFlight.Dec()                          // -1 khi xong

Nhãn (label) là chiều để cắt lát metric: reqTotal với nhãn method và code cho phép hỏi "tốc độ lỗi 5xx của POST" mà không cần metric riêng cho mỗi tổ hợp. Nhưng nhãn là con dao hai lưỡi — ta sẽ thấy ở phần đánh đổi.

Ảnh chụp đoạn mã Go nền tối minh hoạ metrics với Prometheus client trong Go counter gauge histogram và chi phí đo, ba loại metric cốt lõi Counter chỉ tăng đếm tổng số sự kiện request lỗi reqTotal bằng prometheus NewCounterVec CounterOpts Name http_requests_total nhãn method code tách theo chiều, Gauge lên xuống tự do giá trị tức thời đang xử lý RAM inFlight bằng prometheus NewGauge GaugeOpts Name http_in_flight, Histogram phân bố vào các khoảng bucket độ trễ kích thước latency bằng prometheus NewHistogram Buckets 0.005 0.01 0.025 0.05 0.1 0.25, ghi metric trong đường xử lý inFlight Inc cộng 1 khi bắt đầu xử lý request đo d là độ trễ reqTotal WithLabelValues GET code Inc đếm theo method code latency Observe d Seconds đưa vào histogram inFlight Dec trừ 1 khi xong, vì sao histogram không phải trung bình trung bình giấu đuôi 99 phần trăm nhanh cộng 1 phần trăm cực chậm vẫn ra avg đẹp histogram giữ đếm tích lũy theo từng bucket le less-or-equal từ đó tính được phân vị p50 p95 p99 cái người dùng thật cảm nhận bucket phải chọn hợp miền giá trị chọn sai thì phân vị thô, metric vs trace log metric số tổng hợp rẻ hằng bộ nhớ không phình theo lưu lượng hợp cho cảnh báo và bảng điều khiển trace một request cụ thể bài trước đắt hơn để chẩn đoán log sự kiện văn bản tốn nhất khi nhiều

Hình 1: Ba loại metric Prometheus trong Go — counter (chỉ tăng), gauge (lên xuống), histogram (phân bố theo bucket) — cách ghi trong đường xử lý, và vì sao histogram hơn giá trị trung bình.

Đo thật: /metrics sau 1000 request

Chạy demo giả lập 1000 request rồi kết xuất theo đúng định dạng văn bản mà endpoint /metrics trả ra cho Prometheus:

http_in_flight         => 0            // gauge: đã xử lý xong hết
http_requests_total  code="200" => 948   // counter theo nhãn
http_requests_total  code="500" => 52    // ~5% lỗi
http_latency_seconds  count=1000 sum=39.763s avg=39.8ms
    le<=0.005  count=67       le<=0.050  count=632
    le<=0.010  count=130      le<=0.100  count=1000
    le<=0.025  count=323      le<=0.250  count=1000

Đọc được: counter tách 948 request thành công và 52 lỗi (đúng ~5% tôi cấy vào). Gauge http_in_flight về 0 vì mọi request đã xong. Và histogram — phần thú vị nhất — cho biết phân bố độ trễ.

Vì sao histogram, không phải trung bình

Giá trị trung bình (avg=39.8ms) là một cái bẫy. Nó giấu cái đuôi: một dịch vụ mà 99% request trả trong 10ms nhưng 1% mất 5 giây vẫn có thể ra trung bình đẹp — trong khi đúng cái 1% đó là trải nghiệm tệ khiến người dùng bỏ đi. Bạn không quan tâm request trung bình; bạn quan tâm request tệ nhất mà đa số vẫn gặp — tức phân vị p95, p99.

Histogram giữ đếm tích lũy theo từng bucket (le = less-or-equal). Từ output: 632/1000 request dưới 50ms, và toàn bộ 1000 dưới 100ms. Từ những con số này Prometheus nội suy ra phân vị: p50 nằm đâu đó trong khoảng 25–50ms (vì 323 dưới 25ms, 632 dưới 50ms), còn p95 và p99 đều dưới 100ms. Đây chính là cách histogram_quantile() trong Prometheus hoạt động — và là lý do bạn gần như luôn muốn histogram cho độ trễ, không phải một gauge trung bình.

Cái giá: bucket phải chọn hợp miền giá trị. Nếu mọi request rơi vào cùng một bucket (chọn bucket quá thô), phân vị tính ra rất thô. Chọn bucket bao trùm dải độ trễ thực tế của dịch vụ bạn.

Ảnh chụp bảng kết quả đo thật nền tối metrics Prometheus trong Go chạy bằng go run và go test bench Go 1.23 arm64 10 core client_golang v1.20, kết xuất định dạng Prometheus sau 1000 request http_in_flight bằng 0 gauge đã xử lý xong hết http_requests_total code 200 bằng 948 counter theo nhãn http_requests_total code 500 bằng 52 khoảng 5 phần trăm lỗi, http_latency_seconds count 1000 sum 39.763s avg 39.8ms le nhỏ hơn bằng 0.005 count 67 le 0.010 count 130 le 0.025 count 323 le 0.050 count 632 le 0.100 count 1000 le 0.250 count 1000 bucket tích lũy 632 trên 1000 dưới 50ms p50 nằm trong 25 đến 50ms tất cả dưới 100ms p95 p99 cũng dưới 100ms đây là cách tính phân vị, chi phí mỗi thao tác metric không cấp phát BenchmarkGaugeSet 0.73 ns mỗi op 0 allocs BenchmarkCounterInc 1.66 ns mỗi op 0 allocs BenchmarkCounterVecCached 1.68 ns mỗi op 0 allocs BenchmarkHistogramObserve 10.15 ns mỗi op 0 allocs BenchmarkCounterVecWithLabels 23.89 ns mỗi op 0 allocs, tất cả 0 cấp phát metric gần như miễn phí an toàn để rải khắp WithLabelValues tra cứu nhãn mỗi lần 23.9 ns khoảng 14 lần counter thường cache handle đã gắn nhãn về đúng 1.68 ns mẹo tối ưu chính, cốt lõi counter chỉ tăng đếm tổng dùng rate ra tốc độ gauge lên xuống giá trị tức thời histogram phân bố theo bucket tính p50 p95 p99 hơn hẳn trung bình nhãn tách theo chiều cẩn thận cardinality nổ đừng để user_id chi phí 0 alloc cache handle gắn nhãn để tránh 24 ns tra cứu đánh đổi metric tổng hợp mất chi tiết nhãn cardinality cao giết RAM

Hình 2: Kết xuất /metrics thật sau 1000 request (counter 948/52, histogram với bucket tích lũy để tính phân vị) và chi phí mỗi thao tác đo bằng benchmark — mọi thao tác 0 cấp phát, nhưng WithLabelValues tốn 24 ns so với 1,7 ns khi cache handle.

Đo thật: chi phí mỗi thao tác metric

Metric được ghi trong đường nóng (hot path) của mọi request, nên chi phí của nó quan trọng. Benchmark từng loại:

BenchmarkGaugeSet             0.73 ns/op    0 allocs
BenchmarkCounterInc           1.66 ns/op    0 allocs
BenchmarkCounterVecCached     1.68 ns/op    0 allocs
BenchmarkHistogramObserve     10.15 ns/op   0 allocs
BenchmarkCounterVecWithLabels 23.89 ns/op   0 allocs

Tin tốt trước: mọi thao tác đều 0 cấp phát. Bên trong, các metric dùng atomic (đã đo ở các bài đồng thời trước) chứ không phải mutex cho counter/gauge, nên chúng gần như miễn phí và an toàn để rải khắp code. Gauge set 0,73 ns, counter inc 1,66 ns, histogram observe 10 ns — không đáng kể so với việc xử lý một request thật.

Nhưng có một cái bẫy: WithLabelValues("GET", "200") tốn 23,89 ns mỗi lần gọi — gấp ~14 lần một counter thường. Lý do: mỗi lần gọi nó phải băm các giá trị nhãn và tra cứu đúng đối tượng counter trong map. Nếu bạn gọi WithLabelValues trong hot path cho mỗi request, bạn trả cái giá tra cứu này mỗi lần.

Cách sửa: cache lại handle đã gắn nhãn. BenchmarkCounterVecCached tra cứu một lần rồi tái sử dụng đối tượng counter — và nó về đúng 1,68 ns, ngang counter thường. Khi biết trước tổ hợp nhãn (ví dụ một handler cố định method), lấy handle một lần lúc khởi tạo thay vì tra cứu mỗi request.

Đánh đổi cần cân nhắc

Metric mất chi tiết — đó là điểm mạnh lẫn điểm yếu. Metric là số tổng hợp: bạn biết tốc độ lỗi 5% nhưng không biết request nào lỗi hay vì sao. Để trả lời "vì sao", bạn cần trace (bài trước) hoặc log. Đúng chiến lược quan sát là dùng cả ba: metric để phát hiện và cảnh báo (rẻ, luôn bật), trace để chẩn đoán ca cụ thể, log để chi tiết.

Cardinality nhãn là kẻ giết bộ nhớ số một. Mỗi tổ hợp giá trị nhãn tạo một chuỗi thời gian (time series) riêng trong Prometheus. Nhãn method (vài giá trị) và code (chục giá trị) thì ổn. Nhưng đặt user_id hay request_id làm nhãn — với hàng triệu giá trị — tạo hàng triệu time series, và giết cả ứng dụng lẫn Prometheus server bằng bộ nhớ. Quy tắc: nhãn chỉ dùng cho các chiều có tập giá trị nhỏ, đóng.

Histogram tốn bộ nhớ theo số bucket × số tổ hợp nhãn. Một histogram nhiều bucket nhân với nhiều nhãn có thể nổ số series. Nếu cần phân vị chính xác trên cardinality cao, cân nhắc summary (tính phân vị phía client) hoặc giảm bucket — mỗi lựa chọn có đánh đổi riêng về độ chính xác và khả năng tổng hợp.

Ba ý mang về

  1. Ba loại metric cho ba câu hỏi khác nhau: counter (chỉ tăng, đếm tổng — dùng rate() ra tốc độ), gauge (lên xuống, giá trị tức thời), histogram (phân bố theo bucket) — đo thật 1000 request cho counter 948/52 và histogram dựng được phân vị.
  2. Histogram hơn hẳn giá trị trung bình vì trung bình giấu cái đuôi: đo thật, đếm tích lũy theo bucket (632/1000 dưới 50ms, tất cả dưới 100ms) cho phép tính p50/p95/p99 — cái người dùng thật cảm nhận, không phải request trung bình.
  3. Metric gần như miễn phí (0 cấp phát) nhưng WithLabelValues tra cứu tốn 24 ns mỗi lần (gấp ~14 lần counter thường); cache handle đã gắn nhãn đưa về 1,7 ns — và cẩn thận cardinality nhãn, đừng bao giờ đặt user_id/request_id làm nhãn.

Phần sau ta quay lại điều phối hệ thống phân tán: distributed lock với Redis trong Go — cách nhiều tiến trình giành quyền độc chiếm một tài nguyên, và những cái bẫy của khóa phân tán.