《Java 服务端通信工程》11.1 指标口径与 Prometheus 文本

用 JDK 自己实现一套 gRPC 指标:LongAdder 计数器、固定桶并发直方图、gRPC 四个黄金信号,以及手写 renderPrometheus() 的 # HELP、# TYPE、le 标签格式;讲清标签基数风险和 9095 暴露口径。

本节目标:不引入 Micrometer,也不用 OpenTelemetry SDK,只用 JDK 的 LongAdder 和并发容器,做出一个能生成 Prometheus 文本的 MetricsRegistry,并把它接到 gRPC 拦截器与 9095 端口上。
适用版本:JDK 25、gRPC-Java 1.76.0;Prometheus 文本格式按 exposition format 0.0.4。

承接的问题

10.3 结束时,linkd-v3 能拒绝越权请求,但拒绝了多少、慢在哪里、有没有被打满,仍然只能靠人盯日志。指标最便宜:一条时间序列每秒采样一次,成本远低于一条日志或一个 span。

指标要先定口径,再谈实现。同一个「错误率」,分母是「所有请求」还是「所有非取消请求」,结论可能差一倍。linkd-v3 按四个黄金信号组织:

信号回答的问题指标形态名字
延迟请求有多慢直方图linkd_grpc_server_latency_ms
流量打了多少请求计数器linkd_grpc_server_requests_total
错误失败了多少带 code 标签的计数器同上
饱和度还有多少余量仪表linkd_grpc_server_inflight

只看 QPS 会漏掉「请求不多但每条都很大」的饱和问题,所以再加一个 linkd_grpc_server_response_bytes_total,让流量同时覆盖请求数与响应字节数。

核心机制一:三类指标的语义

  • Counter 只增不减,适合请求数、错误数、字节数,查询时用 rate() 或 increase(),不要直接看累计值。
  • Histogram 把观测值落进固定桶,输出 _bucket、_sum、_count;分位数由 histogram_quantile() 近似计算,桶边界决定精度。Gauge 可增可减,只反映采样瞬间的并发数、队列深度或连接数,不能用来算速率。

LongAdder 适合高并发自增:热点分散到多个 cell,sum() 时再合并。代价是 sum() 不是原子快照,多个计数器之间可能来自不同瞬间。监控可以接受;对账不能拿它当精确值。

直方图的桶要按 SLO 定。linkd-v3 的默认值是 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000(毫秒)。它覆盖了 800 ms 总 deadline 附近:1–50 ms 是缓存与本地查询,100–200 ms 是正常下游调用,500 ms 以上进入超时边缘。桶太密序列数膨胀,太疏会把 p99 变成「500 ms 或 1000 ms」的二选一。

核心机制二:MetricsRegistry 的实现

下面这个类只依赖 JDK。counter、histogram、renderPrometheus 是全书共享签名;gauge 是为饱和度补充的,向后兼容:

package com.plume.linkd.v3.core;

import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.LongSupplier;

public final class MetricsRegistry {

    private final Map<String, Counter> counters = new ConcurrentHashMap<>();
    private final Map<String, Histogram> histograms = new ConcurrentHashMap<>();
    private final Map<String, Gauge> gauges = new ConcurrentHashMap<>();

    public Counter counter(String name, Map<String, String> labels) {
        return counters.computeIfAbsent(key(name, labels), k -> new Counter(name, labels));
    }

    public Histogram histogram(String name, Map<String, String> labels, double[] bounds) {
        return histograms.computeIfAbsent(key(name, labels), k -> new Histogram(name, labels, bounds));
    }

    public Gauge gauge(String name, Map<String, String> labels, LongSupplier supplier) {
        return gauges.computeIfAbsent(key(name, labels), k -> new Gauge(name, labels, supplier));
    }

    public String renderPrometheus() {
        StringBuilder out = new StringBuilder(8192);
        String last = null;
        for (Counter c : new TreeMap<>(counters).values()) {
            if (!c.name.equals(last)) { header(out, c.name, "counter"); last = c.name; }
            out.append(c.name).append(labels(c.labels, null, null)).append(' ').append(c.value.sum()).append('\n');
        }
        last = null;
        for (Gauge g : new TreeMap<>(gauges).values()) {
            if (!g.name.equals(last)) { header(out, g.name, "gauge"); last = g.name; }
            out.append(g.name).append(labels(g.labels, null, null)).append(' ').append(g.supplier.getAsLong()).append('\n');
        }
        last = null;
        for (Histogram h : new TreeMap<>(histograms).values()) {
            if (!h.name.equals(last)) { header(out, h.name, "histogram"); last = h.name; }
            long cumulative = 0;
            for (int i = 0; i < h.bounds.length; i++) {
                cumulative += h.buckets[i].sum();
                out.append(h.name).append("_bucket").append(labels(h.labels, "le", Double.toString(h.bounds[i]))).append(' ').append(cumulative).append('\n');
            }
            cumulative += h.buckets[h.bounds.length].sum();
            out.append(h.name).append("_bucket").append(labels(h.labels, "le", "+Inf")).append(' ').append(cumulative).append('\n');
            out.append(h.name).append("_sum").append(labels(h.labels, null, null)).append(' ')
                    .append(String.format(Locale.ROOT, "%.3f", h.sumMicros.sum() / 1000.0)).append('\n');
            out.append(h.name).append("_count").append(labels(h.labels, null, null)).append(' ').append(h.count.sum()).append('\n');
        }
        return out.toString();
    }

    private static void header(StringBuilder out, String name, String type) {
        out.append("# HELP ").append(name).append(" gRPC server ").append(type).append(' ').append(name).append('\n');
        out.append("# TYPE ").append(name).append(' ').append(type).append('\n');
    }

    private static String key(String name, Map<String, String> labels) {
        StringBuilder out = new StringBuilder(name);
        for (Map.Entry<String, String> e : new TreeMap<>(labels).entrySet()) {
            out.append(',').append(e.getKey()).append('=').append(e.getValue());
        }
        return out.toString();
    }

    private static String labels(Map<String, String> labels, String extraKey, String extraValue) {
        if (labels.isEmpty() && extraKey == null) { return ""; }
        StringBuilder out = new StringBuilder("{");
        boolean first = true;
        for (Map.Entry<String, String> e : new TreeMap<>(labels).entrySet()) {
            if (!first) { out.append(','); }
            out.append(e.getKey()).append("=\"").append(escape(e.getValue())).append('"');
            first = false;
        }
        if (extraKey != null) {
            if (!first) { out.append(','); }
            out.append(extraKey).append("=\"").append(escape(extraValue)).append('"');
        }
        return out.append('}').toString();
    }

    private static String escape(String value) {
        return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
    }

    public static final class Counter {
        private final String name;
        private final Map<String, String> labels;
        private final LongAdder value = new LongAdder();

        Counter(String name, Map<String, String> labels) {
            this.name = name;
            this.labels = Map.copyOf(labels);
        }

        public void inc() { value.increment(); }
    }

    public static final class Gauge {
        private final String name;
        private final Map<String, String> labels;
        private final LongSupplier supplier;

        Gauge(String name, Map<String, String> labels, LongSupplier supplier) {
            this.name = name;
            this.labels = Map.copyOf(labels);
            this.supplier = supplier;
        }
    }

    public static final class Histogram {
        private final String name;
        private final Map<String, String> labels;
        private final double[] bounds;
        private final LongAdder[] buckets;
        private final LongAdder count = new LongAdder();
        private final LongAdder sumMicros = new LongAdder();

        Histogram(String name, Map<String, String> labels, double[] bounds) {
            this.name = name;
            this.labels = Map.copyOf(labels);
            this.bounds = bounds.clone();
            java.util.Arrays.sort(this.bounds);
            this.buckets = new LongAdder[this.bounds.length + 1];
            for (int i = 0; i < buckets.length; i++) { buckets[i] = new LongAdder(); }
        }

        public void observeNanos(long nanos) {
            double millis = nanos / 1_000_000.0;
            int index = bounds.length;
            for (int i = 0; i < bounds.length; i++) {
                if (millis <= bounds[i]) { index = i; break; }
            }
            buckets[index].increment();
            count.increment();
            sumMicros.add(Math.round(nanos / 1_000.0));
        }
    }
}

几个取舍:

  • 桶是「小于等于」语义,observeNanos 落进第一个 value <= bound 的桶,+Inf 兜底。顺序反了,histogram_quantile 会给出错误结果。
  • _sum 以毫秒输出:内部累计微秒,渲染时除以 1000,避免浮点累加误差随请求数增长。
  • 标签按 key 排序后再拼,保证同一组标签每次渲染的字符串一致;否则 Prometheus 会把它当成不同序列。
  • TreeMap<>(counters) 依赖 key() 已经把「名字 + 标签」拼成可排序的字符串,省掉了单独的排序代码。

核心机制三:Prometheus 文本长什么样

renderPrometheus() 的预期输出如下(按上面的实现推导,未在本机启动服务抓取):

# HELP linkd_grpc_server_requests_total gRPC server counter linkd_grpc_server_requests_total
# TYPE linkd_grpc_server_requests_total counter
linkd_grpc_server_requests_total{code="OK",method="GetBook"} 1024
linkd_grpc_server_requests_total{code="UNAVAILABLE",method="GetBook"} 7

# HELP linkd_grpc_server_inflight gRPC server gauge linkd_grpc_server_inflight
# TYPE linkd_grpc_server_inflight gauge
linkd_grpc_server_inflight 3

# HELP linkd_grpc_server_latency_ms gRPC server histogram linkd_grpc_server_latency_ms
# TYPE linkd_grpc_server_latency_ms histogram
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="1.0"} 120
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="20.0"} 903
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="100.0"} 1002
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="500.0"} 1029
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="+Inf"} 1031
linkd_grpc_server_latency_ms_sum{method="GetBook"} 48213.700
linkd_grpc_server_latency_ms_count{method="GetBook"} 1031

读法:

  • _bucket 是累计值,必须单调不减。+Inf 与最高桶相同,说明没有超过最后一个边界的样本。
  • _count 是样本总数,与 requests_total 的各状态码之和接近;差值来自进行中的请求和取消。
  • le="+Inf" 必须有,否则 histogram_quantile 在最高桶之外无法给出结果。
  • _sum 的单位跟随指标名里的 _ms,不要混用秒和毫秒。

# HELP 的文本要稳定。上面的实现用统一模板生成,信息量不大但不会因为业务文案变化导致序列重建。需要更有意义的帮助文本时,给 counter 加一个 describe(name, help) 重载,把帮助文本存进 map。

核心机制四:拦截器与 9095 暴露

指标拦截器包在 ServerCall 外层,在 close 时记录状态码和耗时:

package com.plume.linkd.v3.server;

import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.LongAdder;

public final class MetricsServerInterceptor implements ServerInterceptor {

    private static final double[] LATENCY_BUCKETS =
            {1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000};

    private final MetricsRegistry metrics;
    private final LongAdder inFlight = new LongAdder();

    public MetricsServerInterceptor(MetricsRegistry metrics) {
        this.metrics = metrics;
        metrics.gauge("linkd_grpc_server_inflight", Map.of(), inFlight::sum);
    }

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        String method = call.getMethodDescriptor().getBareMethodName();
        Map<String, String> labels = Map.of("method", method);
        long start = System.nanoTime();
        inFlight.increment();
        ServerCall<ReqT, RespT> wrapped =
                new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
                    @Override
                    public void close(Status status, Metadata trailers) {
                        inFlight.decrement();
                        long nanos = System.nanoTime() - start;
                        metrics.counter("linkd_grpc_server_requests_total",
                                withCode(labels, status.getCode().name())).inc();
                        metrics.histogram("linkd_grpc_server_latency_ms",
                                labels, LATENCY_BUCKETS).observeNanos(nanos);
                        super.close(status, trailers);
                    }
                };
        try {
            return next.startCall(wrapped, headers);
        } catch (RuntimeException e) {
            inFlight.decrement();
            throw e;
        }
    }

    private static Map<String, String> withCode(Map<String, String> labels,
                                                String code) {
        Map<String, String> copy = new HashMap<>(labels);
        copy.put("code", code);
        return copy;
    }
}

getBareMethodName() 只返回 GetBook,不带服务名。linkd-v3 只有一个 Library 服务,够用;多服务共用一个进程时改用 getFullMethodName(),并把服务名拆成独立标签。

9095 只暴露指标,不暴露业务接口,绑定回环或内网:

HttpServer server = HttpServer.create(
        new InetSocketAddress("127.0.0.1", 9095), 0);
server.createContext("/metrics", exchange -> {
    byte[] body = registry.renderPrometheus().getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type",
            "text/plain; version=0.0.4; charset=utf-8");
    exchange.sendResponseHeaders(200, body.length);
    try (OutputStream out = exchange.getResponseBody()) {
        out.write(body);
    }
});
server.start();

Content-Type 里的 version=0.0.4 是 Prometheus 判断 exposition format 的依据。响应通常只有几十 KB;如果序列数失控,先治理标签,不要靠截断响应掩盖问题。

标签基数:指标系统真正的成本

一条时间序列由「指标名 + 一组标签值」唯一确定。每增加一个组合,Prometheus 就多存一条序列、多一份索引和内存。linkd-v3 的标签预算:

标签取值数量是否允许
method4 个方法允许
codegRPC 状态码约 17 个允许
tenant_tier3 档(free/pro/enterprise)允许
tenant_id可能上万禁止,改 tenant_tier
isbn、sub、request_id、trace_id无上限禁止

粗算:4 个方法 × 17 个状态码 = 68 条请求计数序列;12 个桶 × 4 个方法 = 48 条延迟序列;总量在几百条。给每个租户打标签,序列数会随租户数线性增长,最终把 Prometheus 打爆。标签值还要控制长度:method 来自 descriptor,长度可控;用户输入要先做枚举映射或哈希前缀并限制字符集,renderPrometheus() 的转义不能解决基数问题。

在 linkd-v3 里落地

MetricsRegistry metrics = new MetricsRegistry();
NettyServerBuilder.forPort(9093, TlsServer.credentials(tlsDir))
        .addService(new LibraryServiceImpl(repository, new Authorizer()))
        .intercept(new AuthInterceptor(verifier))
        .intercept(new TenantInterceptor())
        .intercept(new MetricsServerInterceptor(metrics))
        .build()
        .start();
MetricsEndpoint.start(metrics, 9095);

拦截器顺序是认证、租户、指标。指标放最后,UNAUTHENTICATED 和 PERMISSION_DENIED 也会被计数;如果指标放最前,认证失败的请求仍会触发 close,但拿不到租户维度。linkd-v3 选择放最后,避免未认证请求污染租户标签。

常见错误与边界

错误一:桶边界没排序。 Prometheus 要求 le 严格递增。实现里 Arrays.sort 兜底,但重复边界排序后仍然重复,histogram_quantile 会得到不可预测的结果。构造时应去重或直接拒绝。

错误二:把 _sum 当成平均值,或给每个请求打唯一标签。 _sum / _count 才是平均值,且会被长尾拉高;request_id、trace_id、isbn 进标签会让序列数随流量无限增长。分位数用 histogram_quantile,唯一标识留给日志或追踪。

错误三:计数器重置后直接比较累计值,或把 9095 暴露到公网。 进程重启后计数器归零,rate() 能处理重置,手写告警规则不能;指标里有方法名、状态码分布和租户档位,9095 只绑回环或内网。

边界一:LongAdder.sum() 不是原子快照。 并发自增时,requests_total 与 latency_count 可能来自不同瞬间,差几个计数正常,不要用它们做精确对账。直方图分位数也是近似值:histogram_quantile(0.99, ...) 在桶边界之间做线性插值,桶越疏误差越大;p99 落在 500–1000 ms 之间时,报告值可能明显偏离真实值。

边界二:本节不是 Micrometer,也不是 OpenTelemetry。 没有注册表、没有 exporter、没有 exemplar(11.3 补文本格式)。它只解决「用 JDK 生成 Prometheus 能抓的文本」。

小结

  • 指标先定口径:延迟用直方图、流量与错误用计数器、饱和度用仪表;LongAdder 适合高并发计数,但 sum() 不是原子快照。
  • 直方图桶按 SLO 设,le 必须递增且带 +Inf;Prometheus 文本由 # HELP、# TYPE、样本行组成,标签按 key 排序、值做转义。
  • 标签基数是指标系统的主要成本:方法、状态码、租户档位可以打,租户 ID、ISBN、请求 ID、trace ID 不能打。
  • 拦截器在 close 时记录状态码与耗时,9095 只在内网暴露 /metrics;本节实现不是 Micrometer,不要把它当成通用观测 SDK。

指标给出的是聚合视角。下一节处理单个请求:怎么生成和解析 W3C traceparent,怎么把 request-id 与 trace_id 分开,以及怎么让结构化日志既能被检索又不泄露 token。

阅读导航:上一节 10.3 授权模型与租户隔离 | 下一节 11.2 结构化日志与 traceparent

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收