Hai phần đầu loạt bài nói về log — trụ cột trả lời câu hỏi "chuyện gì đã xảy ra với request này". Nhưng log không trả lời tốt một câu hỏi khác, quan trọng không kém: "hệ thống đang thế nào tổng thể?" — bao nhiêu request/giây, bao nhiêu đang xử lý, tỉ lệ lỗi bao nhiêu. Đọc log để trả lời những câu đó là đếm hàng triệu dòng, tốn kém và chậm. Đây là việc của metric — trụ cột thứ hai của observability: những con số được tổng hợp liên tục, rẻ để lưu và nhanh để truy vấn.

Metric có vài loại cơ bản, và hiểu đúng loại nào cho việc gì là nền tảng. Hai loại dùng nhiều nhất: counter (bộ đếm chỉ tăng — tổng số request, tổng lỗi) và gauge (đồng hồ lên xuống — số request đang xử lý, dung lượng RAM, số kết nối mở). Go có sẵn gói expvar trong thư viện chuẩn để làm việc này mà không cần thư viện ngoài. Bài này (phần 3 loạt Observability) đo thật hai loại metric đó — và nhân tiện chứng minh một điều mà nhiều người biết trên lý thuyết nhưng chưa thấy tận mắt: đếm đồng thời mà không atomic thì mất dữ liệu khủng khiếp.

Cơ chế: counter chỉ tăng, gauge lên xuống

Khác biệt giữa hai loại nằm ở ngữ nghĩa, và nó quyết định bạn đọc chúng ra sao:

  • Counter là một số chỉ đi lên: request_total, error_total, bytes_sent. Bản thân giá trị tuyệt đối ít ý nghĩa (5 triệu request kể từ lúc khởi động thì sao?); cái có ý nghĩa là tốc độ tăng của nó — hệ giám sát lấy hiệu hai lần đọc (delta) chia thời gian để ra "request/giây".
  • Gauge là một số lên xuống tự do: inflight (số request đang xử lý), memory_bytes, open_connections. Ở đây giá trị tuyệt đối tại một thời điểm mới là cái ta quan tâm — "ngay bây giờ có bao nhiêu request đang chạy".

Ảnh chụp đoạn mã nền tối minh hoạ metric nền tảng counter vs gauge với expvar, khối counter chỉ tăng tổng tích luỹ var reqTotal bằng expvar.NewInt request_total var errTotal bằng expvar.NewInt error_total gọi reqTotal.Add 1 mỗi request errTotal.Add 1 mỗi lỗi, khối gauge lên xuống ảnh chụp tức thời var inflight bằng expvar.NewInt inflight gọi inflight.Add 1 vào xử lý rồi xử lý rồi inflight.Add -1 xong giảm, khối dưới vì sao phải atomic ++ thường bị mất cập nhật khi đua naive++ là đọc cộng ghi hai goroutine đè nhau data race mất cập nhật còn av.Add 1 expvar.Int.Add dùng atomic.AddInt64 bên trong không mất an toàn với mọi số goroutine

Hình 1: Counter (expvar.NewInt + Add(1)) cho tổng tích luỹ như request_total, error_total; gauge (inflight.Add(1) khi vào, Add(-1) khi xong) cho mức tức thời. Và điểm cốt tử: ++ trên int thường là data race mất cập nhật, còn expvar.Int.Add dùng atomic.AddInt64 bên trong nên an toàn.

expvar làm hai việc: cung cấp kiểu expvar.Int (và Float, Map, String) an toàn đồng thời, và tự động expose tất cả metric đã đăng ký qua endpoint HTTP /debug/vars dưới dạng JSON. Bạn khai một expvar.NewInt("ten"), gọi .Add() khắp nơi, và metric hiện ra ở /debug/vars mà không cần viết thêm gì.

Đo thật trong go-lab

Mình chạy trong go-lab (golang 1.23): tung 5000 request qua 50 worker song song, mỗi request tăng counter và tăng/giảm gauge inflight quanh lúc xử lý. Rồi mình chạy riêng một phép thử đồng thời khắc nghiệt để đo sự mất mát của việc đếm không atomic.

Ảnh chụp bảng kết quả chạy thật trong go-lab output thật golang 1.23 expvar 5000 request 50 worker, khối COUNTER cộng dồn đếm đúng nhờ atomic request_total expvar.Int 5000 đúng bằng số request tung ra error_total 500 10 phần trăm của 5000, khối vì sao counter phải atomic 50 goroutine nhân 100k lần ++ đúng ra phải 5.000.000 expvar.Int atomic 5.000.000 đúng int thường ++ đua 906.857 mất 4.093.143 bằng 81.9 phần trăm do race, khối GAUGE lên xuống inflight cuối cùng 0 mọi request đã xong inflight đỉnh 50 lớn hơn 1 nghĩa là song song thật xấp xỉ số worker, chú thích expvar expose tất cả qua debug vars dạng JSON request_total 5000 error_total 500 inflight 0

Hình 2: Kết quả thật — counter request_total = 5000 (đúng số request), error_total = 500; phép thử đồng thời: đúng ra 5.000.000, expvar.Int atomic đếm đúng 5.000.000, còn int thường ++ chỉ được 906.857 — mất 81.9%; gauge inflight về 0 cuối cùng, đỉnh 50 (song song thật).

Ba điều rút ra từ số thật:

  • Counter đếm chính xác nhờ atomic. request_total bằng đúng 5000 dù 50 worker cùng gọi Add(1) liên tục. error_total = 500 (đúng 10%). expvar.Int dùng atomic.AddInt64 bên trong, nên mỗi lần Add là một thao tác nguyên tử không bị đè.
  • Không atomic thì mất tới 81.9%. Đây là con số gây sốc nhất. Cho 50 goroutine mỗi cái ++ một biến int thường 100.000 lần (tổng đáng lẽ 5 triệu), kết quả chỉ còn 906.857 — mất hơn 4 triệu cập nhật. Vì naive++ thực ra là ba bước đọc → cộng → ghi, và khi hai goroutine đọc cùng giá trị rồi cùng ghi, một cập nhật biến mất. Ở mức tương tranh cao, phần lớn cập nhật rơi rụng. Đây không phải lỗi hiếm gặp lý thuyết — nó là 80% dữ liệu bốc hơi.
  • Gauge phản ánh mức tức thời. inflight tăng khi request vào, giảm khi xong; cuối cùng về 0 (mọi request đã kết thúc), và đỉnh đạt 50 — đúng bằng số worker, xác nhận có song song thật sự. Một gauge inflight cao kéo dài là dấu hiệu hệ thống đang ùn.

Toàn bộ metric này expose qua /debug/vars dạng JSON {"request_total":5000,"error_total":500,"inflight":0} — một hệ giám sát chỉ cần gọi endpoint đó định kỳ là có số liệu.

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

Counter phải monotonic, và hệ giám sát phải chịu được reset. Counter chỉ tăng — nhưng khi tiến trình khởi động lại, nó về 0. Điều này không phải lỗi: các hệ như Prometheus tính rate bằng cách lấy delta giữa hai lần đọc, và có logic phát hiện khi giá trị tụt (reset) để không tính ra rate âm. Vì vậy đừng bao giờ "sửa" counter bằng cách tự reset nó giữa chừng, cũng đừng dùng gauge cho thứ vốn là counter — bạn sẽ làm hỏng phép tính rate. Quy tắc: cái gì bản chất tích luỹ thì là counter, để hệ giám sát lo phần delta.

Gauge chỉ là ảnh chụp — đọc giữa chừng có thể lệch. Một gauge cho giá trị tại thời điểm đọc. Nếu inflight dao động nhanh, hai lần đọc cách nhau vài mili giây có thể ra số rất khác, và bạn có thể "bỏ lỡ" một đỉnh ngắn giữa hai lần lấy mẫu. Để bắt đỉnh, hoặc lấy mẫu dày hơn, hoặc dùng một metric riêng theo dõi giá trị lớn nhất (như peakInflight trong demo, cập nhật bằng compare-and-swap). Đừng kết luận "hệ thống chưa bao giờ quá 50 inflight" chỉ vì các lần đọc rời rạc không thấy cao hơn.

/debug/vars là công khai — đừng expose ra Internet. expvar tự đăng ký handler /debug/vars vào http.DefaultServeMux ngay khi bạn import nó. Endpoint này phơi toàn bộ metric (và cả cmdline, memstats mặc định) cho bất kỳ ai gọi được — rò rỉ thông tin nội bộ về hệ thống. Trên production, đặt nó sau xác thực, hoặc phục vụ trên một cổng nội bộ riêng chỉ hệ giám sát truy cập được, không bao giờ hở thẳng ra ngoài.

expvar đơn giản nhưng thiếu label và histogram. Đây là giới hạn thật khiến expvar hiếm khi đủ cho production nghiêm túc. Nó chỉ có số vô hướng — không có label (không thể tách request_total theo endpoint hay status một cách gọn), và không có histogram (không đo được phân phối latency, p99). Với những thứ đó cần Prometheus client (phần 5). expvar tốt để hiểu khái niệm counter/gauge và cho các công cụ nhỏ, nhưng hệ thống thật thường cần nhiều hơn.

Ba ý mang về

  1. Metric là trụ cột trả lời "hệ thống thế nào tổng thể", với hai loại nền tảng: counter (chỉ tăng — request_total, đọc bằng tốc độ tăng) và gauge (lên xuống — inflight, đọc bằng giá trị tức thời); expvar cung cấp cả hai và expose qua /debug/vars.
  2. Đếm đồng thời bắt buộc atomic — bằng chứng đo thật: 50 goroutine ++ một int thường 100k lần mỗi cái làm mất 81.9% số đếm (906.857 thay vì 5 triệu), trong khi expvar.Int (dùng atomic.AddInt64) đếm đúng tuyệt đối; ++ không phải một thao tác nguyên tử.
  3. Mỗi loại có luật riêng, và expvar có giới hạn: counter phải monotonic (để hệ giám sát tính delta, chịu được reset khi restart), gauge chỉ là ảnh chụp (có thể lỡ đỉnh giữa hai lần đọc); /debug/vars công khai nên đừng hở ra Internet; và expvar thiếu label/histogram nên production thường cần Prometheus.

Nguồn

Phần sau ta giải quyết câu hỏi mà counter và gauge không trả lời được: phân phối của latency. Vì sao "latency trung bình 50ms" có thể che giấu một thảm hoạ, và làm sao đo p50/p99 bằng histogram để thấy sự thật.