《Java 编程实战》13.1 Micrometer:计数器、计时器与 Prometheus

日志告诉你发生了什么,指标告诉你发生得有多频繁。本节用 Micrometer 1.17.2 与 Prometheus registry 给 libraryd 接上计数器、计时器和仪表,讲清命名、tag 基数、SimpleMeterRegistry 测试写法,以及 /metrics 的真实抓取输出。

本节目标:让 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发