Micrometer — chọn đúng loại meter, giữ tag khỏi nổ
Counter, gauge, timer, distribution summary đo được thứ khác nhau. Và mỗi giá trị tag mới là một chuỗi thời gian mới — chỗ hoá đơn monitoring phình ra.
TL;DR: Micrometer là facade đo lường — viết code đo một lần bằng bốn loại meter (Counter đếm sự kiện, Gauge đo giá trị tức thời, Timer vừa đếm vừa ghi thời lượng, DistributionSummary ghi phân bố một đại lượng không phải thời gian), rồi đổi backend Prometheus/OTLP/CloudWatch chỉ bằng đổi dependency. Spring Boot tự expose sẵn http.server.requests, jvm.*, hikaricp.* qua autoconfiguration. Cạm bẫy tốn kém nhất không phải chọn sai loại meter, mà là gắn tag có tập giá trị không giới hạn (userId, orderId, URL thô) — mỗi tổ hợp giá trị tag sinh một chuỗi thời gian riêng, chi phí lưu trữ nhân theo tích các chiều.
TaskFlow có dashboard vận hành với chỉ số nổi bật: tasks_inprogress: 12.480. Một sáng nọ, on-call engineer nhìn con số này và báo động — hệ thống đang tồn đọng gần 12,5 nghìn task, gấp khoảng 40 lần mức bình thường (khoảng 300). Team dừng deploy, tra log, không thấy request nào bị kẹt thật.
Hoá ra chẳng có task nào tồn đọng. Con số 12.480 là tổng số task đã bắt đầu kể từ lần restart gần nhất — engineer viết registry.counter("tasks.inprogress").increment() mỗi khi task bắt đầu, nhưng Counter không có method giảm nên task hoàn thành không trừ đi được. Chọn sai loại meter đã biến "đang xử lý" thành "đã từng bắt đầu", và không ai trả lời nổi: 12.480 tính từ thời điểm nào?
Bài này giải thích bốn loại meter Micrometer trả lời bốn câu hỏi khác nhau, cách viết metric nghiệp vụ đúng cho TaskFlow, và cạm bẫy đắt giá nhất khi đo lường production: cardinality của tag.
1. Micrometer là facade — cùng ý tưởng với SLF4J
Ở spring-core, bạn đã học SLF4J: viết code log một lần bằng interface trung lập (Logger), đến lúc chạy mới quyết định binding thật (Logback, Log4j2) xử lý dòng log đó đi đâu. Micrometer áp đúng ý tưởng facade đó cho đo lường: interface MeterRegistry trung lập, code nghiệp vụ gọi registry.counter(...) mà không cần biết dữ liệu sau đó chảy tới Prometheus, OTLP hay CloudWatch.
| SLF4J (đã học ở spring-core) | Micrometer |
|---|---|
Interface Logger trung lập | Interface MeterRegistry trung lập |
| Binding thật: Logback, Log4j2 | Registry implementation: PrometheusMeterRegistry, OtlpMeterRegistry |
| Đổi binding = đổi dependency | Đổi backend đo lường = đổi dependency |
flowchart TB
CODE["counter.increment()"]
CODE --> REG["MeterRegistry (facade)"]
REG --> B1["Prometheus"]
REG --> B2["OTLP"]
REG --> B3["CloudWatch"]SLF4J tách "viết log" khỏi "log đi đâu". Micrometer tách "đo cái gì" khỏi "số liệu chảy về đâu".
2. Bốn loại meter và câu hỏi mỗi loại trả lời
Mỗi loại meter sinh ra để trả lời đúng một câu hỏi. Dùng nhầm loại không phải lỗi cú pháp — code vẫn compile, vẫn chạy, chỉ số liệu đọc ra sai nghĩa.
2.1 Counter — "đã xảy ra bao nhiêu lần"
Counter chỉ tăng, không bao giờ giảm. Nó trả lời đúng một câu hỏi: sự kiện này đã xảy ra bao nhiêu lần kể từ khi tiến trình khởi động?
private final Counter tasksCreated;
public TaskMetrics(MeterRegistry registry) {
this.tasksCreated = Counter.builder("taskflow.tasks.created")
.register(registry);
}
public void onTaskCreated() {
tasksCreated.increment();
}
Giá trị tuyệt đối gần như vô nghĩa một mình: "12.480 task đã tạo" không nói gì về tải hiện tại, phải tính tốc độ thay đổi qua thời gian (Prometheus dùng rate()) mới ra con số vận hành được. Counter còn reset về 0 mỗi khi restart, nên so hai mốc thời gian mà không biết uptime dễ hiểu sai.
Dùng sai thì sai thế nào: hook mở bài đã cho thấy hậu quả — dùng Counter để biểu diễn "số đang xử lý" (đại lượng cần giảm khi task xong) làm con số leo thang vô hạn, không phản ánh trạng thái thực.
2.2 Gauge — "giá trị ngay lúc đo"
Gauge trả lời câu hỏi khác hẳn: giá trị ngay lúc đo là bao nhiêu? Lên xuống tự do — kích thước hàng đợi, số kết nối đang mở, dung lượng heap còn trống.
private final Queue<Task> pendingQueue = new ConcurrentLinkedQueue<>();
public TaskQueueMetrics(MeterRegistry registry) {
registry.gauge("taskflow.queue.pending", pendingQueue, Queue::size);
}
pendingQueue là field của bean — Spring giữ bean sống suốt vòng đời container, nên gauge luôn có object để đọc.
Micrometer docs nói rõ: mọi cách tạo gauge chỉ giữ weak reference tới object được đo, để không cản trở garbage collection. Object bị GC thì gauge báo NaN hoặc ngừng report hẳn — không throw lỗi nào. Cách sai điển hình: gauge lên một snapshot cục bộ trong method — snapshot không còn strong reference nào khác, sớm muộn bị GC, gauge lặng lẽ biến mất khỏi dashboard.
Sai ở đây thường là đo trên một snapshot cache thay vì đọc trực tiếp bộ đếm sống, dễ dính đúng cạm bẫy weak reference vừa nêu.
2.3 Timer — "vừa đếm vừa ghi thời lượng"
Timer gộp hai việc vào một meter: đếm số lần sự kiện xảy ra (như Counter) và ghi lại thời lượng mỗi lần (đơn vị luôn là thời gian).
private final Timer taskProcessingTimer;
public TaskMetrics(MeterRegistry registry) {
this.taskProcessingTimer = Timer.builder("taskflow.tasks.processing")
.register(registry);
}
public void processTask(Task task) {
taskProcessingTimer.record(() -> doProcess(task));
}
Timer ghi lại toàn bộ phân bố thời lượng, không chỉ trung bình cộng — đọc phân bố đó qua percentile P99 để dành cho bài tiếp theo — Percentile và SLO. Đổi sang dùng Gauge đo "task cuối cùng mất bao lâu" thì chỉ còn một con số tại một thời điểm, mất hết phân bố và không biết task chậm là ngoại lệ hay thường xuyên.
2.4 DistributionSummary — "phân bố của một đại lượng không phải thời gian"
Cùng cơ chế ghi phân bố như Timer, nhưng đo đại lượng khác thời gian — kích thước payload, số item trong giỏ hàng.
private final DistributionSummary payloadSize;
public TaskMetrics(MeterRegistry registry) {
this.payloadSize = DistributionSummary.builder("taskflow.tasks.payload.size")
.baseUnit("bytes")
.register(registry);
}
public void onRequestReceived(long contentLength) {
payloadSize.record(contentLength);
}
Dùng Counter cộng dồn tổng byte thay vào đó cho bạn tổng tích luỹ, nhưng mất hoàn toàn hình dạng phân bố — không biết đa số request nhỏ và một số ít cực lớn, hay tất cả cỡ trung bình.
3. Metric có sẵn qua autoconfiguration
Spring Boot tự đăng ký sẵn http.server.requests, jvm.*, hikaricp.*, system.*/process.* qua autoconfiguration — chỉ cần tự viết metric nghiệp vụ (section 4) cho phần chưa có.
Bạn đã học ở bài 01: Actuator chỉ mặc định expose health, các endpoint khác — gồm /actuator/metrics và /actuator/prometheus — phải expose có chủ đích và nằm sau management port tách riêng. Hai endpoint đó là cửa ngõ đọc mọi meter viết ở bài này; vì metric lộ chi tiết nội bộ, chúng cần đúng lớp bảo vệ đã dựng ở bài 01, không phải endpoint công khai.
4. Viết metric nghiệp vụ — đăng ký một lần
Meter nghiệp vụ nên đăng ký một lần khi bean khởi tạo, giữ instance làm field — không gọi registry.counter(...) mới mỗi khi xử lý request.
Cách sai thường gặp: build lại Counter.builder(...).register(registry).increment() ngay trong method xử lý request. Cách đúng: đăng ký một lần cho cả 3 giá trị hữu hạn của priority ngay lúc khởi tạo, giữ trong Map, chỉ tra cứu khi tăng — dòng .tag(...) để trống, tự điền trước khi xem đáp án:
@Service
public class TaskMetrics {
private final Map<Priority, Counter> tasksCreatedByPriority;
public TaskMetrics(MeterRegistry registry) {
this.tasksCreatedByPriority = new EnumMap<>(Priority.class);
for (Priority p : Priority.values()) {
tasksCreatedByPriority.put(p, Counter.builder("taskflow.tasks.created")
/* TODO: tag nao an toan - priority (p.name()) hay task.getId()? */
.register(registry));
}
}
public void onTaskCreated(Task task) {
tasksCreatedByPriority.get(task.getPriority()).increment();
}
}
Điền dòng .tag(...) còn thiếu — đối chiếu quy tắc cardinality ở mục 5 trước khi chọn giữa priority và task.getId().
Đáp án: .tag("priority", p.name()). Priority chỉ có 3 giá trị hữu hạn biết trước nên an toàn; gắn task.getId() mở một chuỗi thời gian mới cho mỗi task, đúng cạm bẫy cardinality mục 5 cảnh báo.
Gọi lại registry.counter(name, tags) với đúng tổ hợp tên+tag không tạo bộ đếm trùng — Micrometer tự tra và trả về instance cũ, nhưng vẫn tốn một lượt tra cứu mỗi lần trên hot path. Quan trọng hơn: đăng ký rải rác xoá mất kỷ luật khai tag tại một chỗ duy nhất (section 5).
Cách viết đúng chỉ khả thi vì Priority có đúng 3 giá trị biết trước — pre-register hết lúc khởi tạo. Tag lấy giá trị không biết trước thì kỹ thuật này bất khả thi — dấu hiệu sớm cho biết bạn đang gắn nhầm loại tag.
5. Vì sao hoá đơn monitoring phình ra?
Cardinality của một metric là số chuỗi thời gian (time series) riêng biệt nó tạo ra. Mỗi tổ hợp giá trị tag khác nhau — không phải mỗi tag, mà mỗi tổ hợp — sinh một chuỗi thời gian mới. Chi phí lưu trữ nhân theo tích số giá trị từng tag, không phải tổng.
Counter taskflow.tasks.created có 3 tag hữu hạn: status (3 giá trị), priority (3 giá trị), plan gói dịch vụ (3 giá trị):
flowchart TB
M["Counter: tasks_created_total"]
M --> D1["tag status: 3 gia tri"]
D1 --> D2["tag priority: 3 gia tri"]
D2 --> D3["tag plan: 3 gia tri"]
D3 --> R1["= 27 chuoi thoi gian"]
R1 -. "them tag userId: 100.000 gia tri" .-> R2["= 2.700.000 chuoi thoi gian"]3 × 3 × 3 = 27 chuỗi thời gian — nhỏ, backend nào cũng chịu được. Giờ một engineer thêm tag userId "để lọc theo user cho tiện" trên workspace 100.000 người dùng: 27 × 100.000 = 2.700.000 chuỗi thời gian cho đúng một metric. Nhân thêm vài chục metric khác bị gắn tương tự, hoá đơn monitoring phình lên nhanh mà không ai chủ đích quyết định.
Quy tắc thoát: tag chỉ nhận tập giá trị hữu hạn biết trước tại thời điểm viết code — trạng thái, mức ưu tiên, loại gói dịch vụ. ID định danh một entity cụ thể (user, order, request) hoặc URL thô còn path variable không thuộc về metric — chúng thuộc về log và trace, nơi mỗi bản ghi đứng độc lập thay vì cộng dồn vào một chuỗi thời gian. Nối tiếp ở bài 05 — Observation API và bài 06 — Tracing OTLP.
6. Common tags — application, env, instance
Ba tag gần như mọi metric đều cần — application, env, instance (pod nào). Khai riêng từng meter tốn công — đặt một lần ở tầng registry (YAML, hoặc code qua MeterRegistryCustomizer), mọi meter tự động thừa hưởng:
management:
metrics:
tags:
application: taskflow
env: production
Common tags không làm cardinality nổ vì cố định theo instance, không biến thiên theo request.
Pitfall thường gặp
@Service
public class TaskMetrics {
private final MeterRegistry registry;
public TaskMetrics(MeterRegistry registry) {
this.registry = registry;
}
public void onTaskCreated(Task task) {
registry.counter("taskflow.tasks.created",
"userId", task.getOwnerId(),
"priority", task.getPriority().name()
).increment();
}
}Đào sâu
Spec / reference chính thức:
- Micrometer Concepts — định nghĩa gốc Counter, Gauge, Timer, DistributionSummary.
- Micrometer — Gauges — hành vi weak reference của Gauge (section 2.2).
- Spring Boot — Metrics — danh sách metric autoconfigure sẵn + cấu hình common tags.
Ghi chú: đọc khi cần tra chi tiết một loại meter hoặc cấu hình datasource/cache cụ thể.
Liên hệ các bài khác
- Bài 01 — Actuator endpoint — cửa ngõ đọc mọi meter viết ở bài này, cần nằm sau lớp bảo vệ đã dựng ở đó.
- Bài 04 — Percentile và SLO — đọc phân bố Timer vừa thu thập qua P99 thay vì trung bình cộng.
- Bài 05 — Observation API — tự động tạo Timer + trace từ một API thống nhất.
- Bài 06 — Tracing OTLP — nơi ID (userId, orderId, requestId) thuộc về.
Tóm tắt
- Giá trị tuyệt đối của Counter vô nghĩa một mình — cần
rate()để ra tốc độ thay đổi; Counter cũng reset về 0 mỗi lần restart nên so hai mốc thời gian mà không biết uptime dễ hiểu sai. - Gauge chỉ sống khi có strong reference giữ object đo — object bị GC thì gauge lặng lẽ biến mất, không throw lỗi nào.
- Tag chỉ nhận giá trị hữu hạn biết trước; muốn định danh một entity cụ thể thì đó là việc của log và trace.
- Common tags (application, env, instance) đặt một lần ở tầng registry, không lặp lại tay ở từng meter.
Tự kiểm tra
Q1Vì sao dùng Counter để đếm "số task đang xử lý" (in-progress) là chọn sai loại meter? Nên dùng gì thay và vì sao?▸
increment(), không thể giảm — nó chỉ trả lời được "đã xảy ra bao nhiêu lần", không biểu diễn được một đại lượng cần tăng rồi giảm theo thời gian thực. Đúng loại meter là Gauge — đọc giá trị hiện tại từ một bộ đếm sống mỗi lần bị scrape, thay vì cộng dồn không giới hạn như Counter.Q2Gauge theo dõi kích thước hàng đợi của TaskFlow biến mất khỏi dashboard sau vài phút dù hệ thống vẫn chạy bình thường. Giải thích cơ chế và cách fix.▸
NaN hoặc ngừng report — không exception, không log. Fix: giữ object được đo làm field của một bean sống suốt vòng đời container.Q3Có một Counter với 3 tag, mỗi tag 3 giá trị hữu hạn. Có bao nhiêu chuỗi thời gian? Nếu thêm tag userId với 100.000 giá trị thì con số đó thành bao nhiêu?▸
Q4Giải thích vì sao nên đăng ký Counter/Timer là field khởi tạo trong constructor thay vì gọi registry.counter(...) mới mỗi lần xử lý request.▸
registry.counter(name, tags) với đúng tên+tag không tạo bộ đếm trùng, nhưng vẫn tốn thêm một lượt tra cứu mỗi lần trên hot path. Quan trọng hơn: đăng ký ở constructor buộc khai tag cố định tại một chỗ duy nhất; đăng ký rải rác khiến ai đó dễ vô tình đổi giá trị tag thành một trường không giới hạn như ID mà không ai để ý.Q5Common tags (application, env, instance) nên đặt ở đâu trong Spring Boot, và vì sao đặt ở tầng registry thay vì tự thêm tag thủ công vào từng metric?▸
management.metrics.tags.* trong application.yml, hoặc một bean MeterRegistryCustomizer gọi registry.config().commonTags(...) — cấu hình một lần, mọi meter tự động thừa hưởng. Tự thêm tay dễ quên, dễ đặt sai tên, không đồng bộ khi nhiều service cùng viết; common tags cũng không làm cardinality nổ vì cố định theo instance.Q6Timer vừa đếm vừa ghi thời lượng. Việc đọc phân bố đó qua percentile P99 để dành cho bài nào, và vì sao bài này chưa đi sâu vào đó?▸
Q7Vì sao tag chỉ nên nhận tập giá trị hữu hạn biết trước, còn ID của một entity cụ thể (user, order, request) lại thuộc về log và trace thay vì metric?▸
Bài tiếp theo: Percentile và SLO
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên