本节目标:让
libraryd的请求量、耗时、借还次数、逾期数量和缓存命中率以稳定的名字与 tag 暴露在/metrics,并能被 Prometheus 直接抓取。
适用版本:JDK 25(Temurin 25.0.5)、Micrometer 1.17.2、Prometheus 文本格式 0.0.4、Javalin 7.2.4。
承接的问题
12 章的日志能回答「这次借阅为什么失败」,但回答不了「过去五分钟借阅失败率是多少」「P99 耗时有没有上升」「缓存命中率是不是掉了」。这些问题需要指标:按时间窗口聚合的数值,而不是一条条事件。
libraryd 已经有一个 /metrics 端点,但自定义指标只有两个:
# HELP libraryd_http_server_requests_seconds HTTP requests handled by libraryd
# TYPE libraryd_http_server_requests_seconds summary
libraryd_http_server_requests_seconds_count{method="GET",status="200"} 3
libraryd_http_server_requests_seconds_sum{method="GET",status="200"} 0.046044875
# HELP libraryd_loans_overdue Active loans past their due date
# TYPE libraryd_loans_overdue gauge
libraryd_loans_overdue 0.0
请求耗时有了,借阅次数、归还次数、逾期扫描次数、缓存命中率还没有。更麻烦的是命名和 tag 一旦定错,后面改起来会破坏看板和告警。本节先把 Micrometer 的模型讲清楚,再把缺的指标接进 libraryd。
核心机制一:Meter 类型与命名
Micrometer 把指标抽象成几种 Meter:
| 类型 | 语义 | libraryd 示例 | Prometheus 形态 |
|---|---|---|---|
Counter | 只增不减的累计值 | 借阅次数、归还次数、扫描次数 | _total |
Timer | 次数、总耗时、最大值 | HTTP 请求耗时 | _seconds_count、_seconds_sum、_seconds_max |
Gauge | 瞬时值,可升可降 | 当前逾期借阅数、缓存条目数 | 无后缀 |
DistributionSummary | 非时间的分布 | 请求体大小、响应字节数 | _count、_sum、_max |
FunctionCounter | 从对象状态派生的累计值 | Caffeine 的命中/未命中次数 | _total |
命名遵循「点分隔、base unit、复数」的习惯。Micrometer 在导出到 Prometheus 时把点换成下划线,Counter 自动加 _total,Timer 自动加 _seconds。所以代码里写:
Counter.builder("libraryd.loans.borrowed")
.description("Loans created")
.register(registry);
Prometheus 里看到的是:
# HELP libraryd_loans_borrowed_total Loans created
# TYPE libraryd_loans_borrowed_total counter
libraryd_loans_borrowed_total 1.0
名字里不要再写 count、total、seconds,否则会出现 libraryd_loans_borrowed_total_total 或 _seconds_seconds。单位统一用 Micrometer 的基础单位:时间用秒,大小用字节。日志里可以写 duration_ms,指标里不要写毫秒,否则看板上的单位会混。
libraryd 的 HTTP Timer 名字是 libraryd.http.server.requests,不是 libraryd.http.requests。前缀 server 表示这是服务端视角,后续如果加客户端调用耗时,可以用 libraryd.http.client.requests 区分。
核心机制二:Telemetry 的装配
libraryd 把 Micrometer 与 OpenTelemetry 的装配收在一个类里:
package com.plume.library;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics;
import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics;
import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics;
import io.micrometer.core.instrument.binder.system.ProcessorMetrics;
import io.micrometer.prometheusmetrics.PrometheusConfig;
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.exporter.logging.LoggingSpanExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
public final class Telemetry implements AutoCloseable {
private final PrometheusMeterRegistry registry;
private final SdkTracerProvider tracerProvider;
private final OpenTelemetrySdk openTelemetry;
private final Tracer tracer;
public Telemetry() {
this.registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
new ClassLoaderMetrics().bindTo(registry);
new JvmMemoryMetrics().bindTo(registry);
new JvmThreadMetrics().bindTo(registry);
new ProcessorMetrics().bindTo(registry);
Resource resource = Resource.getDefault().merge(
Resource.create(Attributes.builder()
.put("service.name", "libraryd")
.put("service.version", "1.0.0")
.build()));
this.tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create()))
.setResource(resource)
.build();
this.openTelemetry = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.build();
this.tracer = openTelemetry.getTracer("com.plume.library", "1.0.0");
}
public MeterRegistry registry() {
return registry;
}
public String scrape() {
return registry.scrape();
}
public Tracer tracer() {
return tracer;
}
@Override
public void close() {
openTelemetry.close();
tracerProvider.close();
registry.close();
}
}
几个关键点:
PrometheusMeterRegistry同时是MeterRegistry和抓取器。registry()给业务注册 meter,scrape()生成文本。- 四个 JVM binder 提供类加载、内存、线程、CPU 指标,排查内存和线程问题时不用另装 agent。
Resource里的service.name会进入追踪;指标侧的service标签由 Prometheus 抓取配置补充,不在 Micrometer 里重复。- 参考工程的
Telemetry只持有PrometheusMeterRegistry。单元测试里用SimpleMeterRegistry更轻,不需要启动 HTTP 抓取器。
/metrics 路由用 text/plain; version=0.0.4; charset=utf-8 写出 scrape() 的内容。本机 GET 的响应头与首行:
HTTP/1.1 200 OK
Content-Type: text/plain; version=0.0.4; charset=utf-8
Content-Length: 6756
# TYPE jvm_buffer_count_buffers gauge
jvm_buffer_count_buffers{id="direct"} 2.0
注意 Content-Type 里的 version=0.0.4。Prometheus 靠这个版本号解析文本格式;写成 text/plain 不带版本,某些采集器会拒绝。
单元测试里可以用 io.micrometer.core.instrument.simple.SimpleMeterRegistry,它不做导出,只在内存里保存 meter,断言时不用起容器。测试里要避免全局静态 registry,否则不同测试之间会互相污染。
核心机制三:HTTP Timer 与 tag 基数
HttpMetrics 在每次请求结束时记录耗时:
public void record(Context context, long elapsedNanos) {
Duration duration = Duration.ofNanos(elapsedNanos);
Timer.builder("libraryd.http.server.requests")
.description("HTTP requests handled by libraryd")
.tag("method", context.method().name())
.tag("status", String.valueOf(context.statusCode()))
.register(registry)
.record(duration);
log.info("http_request method={} path={} status={} duration_ms={} requestId={}",
context.method().name(),
context.path(),
context.statusCode(),
duration.toMillis(),
RequestContext.requestId(context));
}
这里只有 method 和 status 两个 tag,没有 route。这是有意的:context.path() 是具体路径,/api/books/9787111213826 里的 ISBN 每个请求都不同,直接作为 tag 会生成几十万个时间序列,Prometheus 的内存和查询都会崩。当前实现把路径放在日志里,指标只按方法和状态聚合。
如果确实需要按路由聚合,Javalin 7 提供了匹配到的端点模板:
Endpoint matched = context.endpoints().matchedHttpEndpoint();
String route = matched == null ? "unmatched" : matched.path;
本机验证输出:
GET /api/books -> route=/api/books status=200
GET /api/books/9787111213826 -> route=/api/books/{isbn} status=200
GET /api/nope -> route=unmatched status=404
/api/books/{isbn} 是有限集合,适合作为 tag;/api/nope 归到 unmatched,避免 404 扫描把基数打爆。把上面的 route 加进 Timer.builder,指标就变成:
Timer.builder("libraryd.http.server.requests")
.tag("method", context.method().name())
.tag("status", String.valueOf(context.statusCode()))
.tag("route", route)
.register(registry)
.record(duration);
代价是每个「方法 × 状态 × 路由」组合一条时间序列。libraryd 的路由不到 15 个,状态码常见 5 个,组合上限可控;如果路由是用户生成的(例如多租户路径),不要加这个 tag。
本机抓取到的 HTTP 指标:
libraryd_http_server_requests_seconds_count{method="GET",status="200"} 3
libraryd_http_server_requests_seconds_sum{method="GET",status="200"} 0.046044875
libraryd_http_server_requests_seconds_max{method="GET",status="200"} 0.03956775
libraryd_http_server_requests_seconds_count{method="POST",status="201"} 2
Micrometer 1.17 的 Prometheus registry 默认把 Timer 导出成 summary,包含 _count、_sum、_max 三条。它没有分位数(quantile),因为分位数需要客户端配置。要看 P95/P99,需要启用 histogram:
Timer.builder("libraryd.http.server.requests")
.publishPercentileHistogram()
.register(registry);
启用后会多出 _bucket 序列,存储成本上升。不要默认给所有 Timer 开 histogram,只给真正需要分位数的关键接口开。
核心机制四:业务计数器与派生指标
HTTP 指标只能说明「请求慢」,不能说明「借阅成功了多少」。libraryd 用装饰器把借阅仓储包一层,在调用前后计数:
package com.plume.library;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanRepository;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public final class MeteredLoanRepository implements LoanRepository {
private final LoanRepository delegate;
private final Counter borrowed;
private final Counter returned;
private final Counter overdueScans;
public MeteredLoanRepository(LoanRepository delegate, MeterRegistry registry) {
this.delegate = delegate;
this.borrowed = Counter.builder("libraryd.loans.borrowed")
.description("Loans created")
.register(registry);
this.returned = Counter.builder("libraryd.loans.returned")
.description("Loans returned")
.register(registry);
this.overdueScans = Counter.builder("libraryd.overdue.scan")
.description("Overdue scans executed")
.register(registry);
}
@Override
public Loan save(Loan loan) {
Loan saved = delegate.save(loan);
borrowed.increment();
return saved;
}
@Override
public Loan update(Loan loan, long fineCents) {
Loan updated = delegate.update(loan, fineCents);
if (loan.returnedOn() != null) {
returned.increment();
}
return updated;
}
@Override
public List<Loan> findOverdue(LocalDate today) {
overdueScans.increment();
return delegate.findOverdue(today);
}
@Override public Optional<Loan> findById(long id) { return delegate.findById(id); }
@Override public Optional<Loan> findActiveByIsbn(String isbn) { return delegate.findActiveByIsbn(isbn); }
@Override public Optional<Loan> findLatestByIsbn(String isbn) { return delegate.findLatestByIsbn(isbn); }
@Override public List<Loan> findActiveByMember(String memberId) { return delegate.findActiveByMember(memberId); }
@Override public long countActiveByMember(String memberId) { return delegate.countActiveByMember(memberId); }
@Override public long countByIsbn(String isbn) { return delegate.countByIsbn(isbn); }
}
update 同时被归还和续借调用。归还时 loan.returnedOn() != null,续借时为 null,所以只有归还会计入 libraryd.loans.returned。这个判断依赖领域对象的语义,不是靠调用方传标志位。
findOverdue 每次调用计一次 libraryd.overdue.scan。OverdueScanner 每 60 秒调用一次,所以这个计数器大致等于运行分钟数;如果扫描失败,LibraryService.overdueLoans 抛异常,计数器不会增加,可以用「扫描次数不涨」发现调度停了。
当前逾期借阅数是瞬时值,用 Gauge:
Gauge.builder("libraryd.loans.overdue", overdueCount, AtomicLong::doubleValue)
.description("Active loans past their due date")
.register(registry);
overdueCount 是 AtomicLong,OverdueScanner.scan 每次更新它。Gauge 不保存历史,只反映抓取瞬间的值;Prometheus 每隔一段时间抓一次,形成时间序列。
缓存命中率用 FunctionCounter 从 Caffeine 的统计派生,不需要在每次读写时手工计数:
FunctionCounter.builder("libraryd.cache.requests", cache, c -> c.stats().hitCount())
.description("Local cache lookups")
.tag("result", "hit")
.register(registry);
FunctionCounter.builder("libraryd.cache.requests", cache, c -> c.stats().missCount())
.description("Local cache lookups")
.tag("result", "miss")
.register(registry);
Gauge.builder("libraryd.cache.size", cache, c -> c.estimatedSize())
.description("Estimated local cache entries")
.register(registry);
本机抓取输出:
# HELP libraryd_cache_requests_total
# TYPE libraryd_cache_requests_total counter
libraryd_cache_requests_total{result="hit"} 1.0
libraryd_cache_requests_total{result="miss"} 1.0
# HELP libraryd_cache_size
# TYPE libraryd_cache_size gauge
libraryd_cache_size 1.0
FunctionCounter 的价值在于数据源是 Caffeine 自己的 CacheStats,不会出现「业务代码计数和缓存实际统计对不上」的问题。前提是 Caffeine 构建时开了 recordStats(),14.1 节会展开。
在 libraryd 里落地:把 meter 接进装配
LibraryApplication.start 里的装配顺序要调整:先创建 Telemetry,再用它的 registry 包装仓储。
telemetry = new Telemetry();
redis = RedisBookCache.connect(redisUrl, JsonSupport.objectMapper());
JdbcLoanRepository jdbcLoans = new JdbcLoanRepository(dataSource, transactions);
LoanRepository loans = new MeteredLoanRepository(jdbcLoans, telemetry.registry());
CachingBookRepository cachedBooks = new CachingBookRepository(jdbcBooks, redis);
Gauge.builder("libraryd.cache.size", cachedBooks, CachingBookRepository::localCacheSize)
.description("Estimated local cache entries")
.register(telemetry.registry());
LibraryService service = new LibraryService(
cachedBooks, members, loans, transactions, new FinePolicy());
MeteredLoanRepository 只包 LoanRepository,不改 LibraryService 的构造签名。LibraryService 仍然只依赖领域端口,不知道 Micrometer 的存在。这是装饰器模式在这里的最大好处:可观测性留在应用层,领域层保持干净。
CachingBookRepository 只暴露 localCacheSize(),容量用 Gauge 就够了。命中/未命中的 FunctionCounter 需要 Caffeine 的 CacheStats,前提是构建缓存时开了 recordStats();14.1 节会给出开了 recordStats() 的缓存实现,需要暴露命中率时,按本节前面的写法把 cache.stats().hitCount() 和 missCount() 注册成 FunctionCounter 即可。
装配完成后,一次借阅、归还、缓存命中之后抓取 /metrics,能看到:
libraryd_loans_borrowed_total 1.0
libraryd_loans_returned_total 1.0
libraryd_overdue_scan_total 1.0
libraryd_cache_requests_total{result="hit"} 2.0
libraryd_cache_requests_total{result="miss"} 1.0
这些数值来自本机用 MeteredLoanRepository 和 Caffeine 缓存跑的一组调用,不是线上流量。线上要把它们和 HTTP 指标放在同一张看板上:请求量、借阅量、归还量、逾期数、缓存命中率,五个曲线能覆盖大部分故障。
常见错误与边界
错误一:用 Counter 记录可升可降的值。 当前逾期数、缓存条目数、连接池活跃连接数都是瞬时值,应该用 Gauge。Counter 只能增,进程重启后归零,Prometheus 的 rate() 会把重启当成一次尖峰。判断方法:这个值能不能减少?能,就不是 Counter。
错误二:把高基数字段做成 tag。 path、isbn、memberId、requestId 都不能直接做 tag。每个不同的值都会生成一条时间序列,Prometheus 的内存按序列数增长。requestId 更是每请求一个,几乎没有聚合价值。需要按请求排查时用日志或 trace,不要用指标。
错误三:每次请求都 Counter.builder(...).register(registry)。 Micrometer 会按名字和 tag 查已有 meter,重复注册不会创建新对象,但每次构造 builder、拼 tag 仍有开销。正确做法是在构造函数或装配阶段把 Counter/Timer 存成字段,请求路径只调用 increment() 或 record()。MeteredLoanRepository 就是这么做的。
错误四:名字和单位不统一。 同一个概念在不同类里叫 libraryd.loans.borrowed、libraryd.loan.created、loans_borrowed,看板就没法复用。单位也一样:Timer 用秒,Gauge 如果要表达毫秒,名字里必须带 _ms,不要靠描述字段。libraryd 的约定是「点分隔、基础单位、Counter 不带 total」。
错误五:把 /metrics 暴露到公网。 指标里有 JVM 内存、线程数、请求量、错误率,能推断部署结构和流量。/metrics 应该只对 Prometheus 或内网开放,至少加来源 IP 限制或认证。libraryd 当前没有做鉴权,部署时要在反向代理层限制。
边界:summary 与 histogram 的取舍。 默认的 summary 只有 count/sum/max,算不出跨实例的 P95;histogram 有 bucket,可以聚合分位数,但序列更多。单实例排查用 summary 足够,多实例 SLO 需要 histogram。选择前先确认 Prometheus 的查询方式,不要两边都开。
边界:进程重启后的计数器。 Counter 在进程内累计,重启归零。Prometheus 的 increase() 和 rate() 能处理归零,但直接用当前值做减法会得到负数。看板查询要用 rate() 或 increase(),不要用 max - min。
小结
- Micrometer 用 Counter、Timer、Gauge、DistributionSummary、FunctionCounter 表达不同语义,导出到 Prometheus 时自动转换命名和单位。
libraryd的Telemetry持有PrometheusMeterRegistry,registry()注册 meter,scrape()生成 0.0.4 文本。- HTTP Timer 用
method、status两个 tag;需要路由维度时用matchedHttpEndpoint().path,不要用原始路径。 - 借阅、归还、扫描用 Counter,当前逾期数用 Gauge,缓存命中率用 Caffeine 的
FunctionCounter。 - 指标名、tag、单位一旦发布就是契约,改名会破坏看板和告警。
- 高基数字段进日志或 trace,不进指标 tag;
/metrics只对内网开放。
指标能告诉你「慢了」,但不能告诉你「慢在哪一段」。下一节用 OpenTelemetry 把一次请求拆成 span,并让 traceparent 在服务之间传递。
阅读导航:上一节:12.3 日志规范:脱敏、采样与成本 · 下一节:13.2 追踪:OpenTelemetry 与 W3C traceparent 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。