本节目标:不引入 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 的标签预算:
| 标签 | 取值数量 | 是否允许 |
|---|---|---|
method | 4 个方法 | 允许 |
code | gRPC 状态码约 17 个 | 允许 |
tenant_tier | 3 档(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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。