本节目标:把 trace_id 从「日志里的一个字段」变成「有父子关系、有耗时、有状态」的 span 树;能实现头部采样和尾部采样,能把 trace_id 作为 exemplar 关联到指标桶上,并清楚这套最小实现与 OpenTelemetry 的差距。
适用版本:JDK 25、gRPC-Java 1.76.0;本节不引入 OpenTelemetry SDK,所有结构由 JDK 与 gRPC 自带类型实现。
承接的问题
11.2 的日志能回答「这次请求的 trace_id 是什么」,但回答不了「时间花在网关、linkd 还是 libraryd」。一条 trace 由多个 span 组成,每个 span 记录一段有开始和结束的工作。父子关系表达「谁调用了谁」,耗时表达「慢在哪一跳」。
一个 span 至少要有这些字段:
| 字段 | 含义 | 来源 |
|---|---|---|
trace_id | 整条链路共享 | traceparent |
span_id | 本段工作的唯一 ID | 本服务生成 |
parent_span_id | 上一段工作的 ID | 收到的 traceparent 的 span-id |
name | 操作名,如 GetBook | 方法名 |
kind | SERVER / CLIENT / INTERNAL | 角色 |
start / end | 起止时间 | 本地时钟 |
status | OK / ERROR | gRPC 状态码 |
本节不引入 OpenTelemetry SDK,原因和 11.1 一样:书稿要展示机制,不是封装 API。下面的实现只覆盖「进程内生成 span、按父子关系组装、导出给 exporter」,不做 OTLP、不做跨线程上下文自动传播、不做 baggage。生产环境应当用成熟 SDK;本节的价值是让你知道 SDK 在每个位置替你做了什么。
核心机制一:span 的父子关系
服务端收到 traceparent 时,里面的 span-id 是调用方的 span,不是服务端自己的。服务端必须生成一个新的 span-id,把收到的 span-id 作为 parent_span_id。客户端发起调用时同理:生成新的客户端 span,把它的 span-id 写进 traceparent 发给下游。服务端与客户端各有一个 span,是因为它们测量的对象不同:客户端 span 测「从发出请求到收到响应」,服务端 span 测「从收到请求到写出响应」,两者之差就是网络与排队时间。
一条 GetBook 的链路:
gateway SERVER span A (trace T, parent = 上游)
└─ gateway CLIENT span B (parent A) ── traceparent: T-B-sampled
└─ linkd SERVER span C (parent B)
└─ linkd CLIENT span D (parent C) ── traceparent: T-D-sampled
└─ libraryd SERVER span E (parent D)
package com.plume.linkd.v3.core;
import io.grpc.Status;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
public final class Span {
public enum Kind { SERVER, CLIENT, INTERNAL }
private static final SecureRandom RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
public final String traceId;
public final String spanId;
public final String parentSpanId;
public final String name;
public final Kind kind;
public final Instant start;
public final Map<String, String> attributes = new LinkedHashMap<>();
private Instant end;
public Status.Code status = Status.Code.OK;
public Span(String traceId, String spanId, String parentSpanId,
String name, Kind kind) {
this.traceId = traceId;
this.spanId = spanId;
this.parentSpanId = parentSpanId;
this.name = name;
this.kind = kind;
this.start = Instant.now();
}
public static Span server(String method, TraceContext remote) {
return new Span(remote.traceId(), newSpanId(), remote.spanId(),
method, Kind.SERVER);
}
public static Span client(String method, Span parent) {
return new Span(parent.traceId, newSpanId(), parent.spanId,
method, Kind.CLIENT);
}
public static Span rootClient(String method) {
TraceContext root = TraceContext.newRoot(true);
return new Span(root.traceId(), newSpanId(), root.spanId(),
method, Kind.CLIENT);
}
public void finish(Status.Code code) {
this.end = Instant.now();
this.status = code;
}
public TraceContext outgoing(boolean sampled) {
return new TraceContext(traceId, spanId, sampled);
}
public long durationMicros() {
Instant finish = end == null ? Instant.now() : end;
return Duration.between(start, finish).toNanos() / 1_000;
}
private static String newSpanId() {
byte[] buffer = new byte[8];
RANDOM.nextBytes(buffer);
return HEX.formatHex(buffer);
}
}
outgoing 生成「本 span 作为父节点」的 traceparent:trace_id 不变,span-id 换成自己的。客户端拦截器把它放进 metadata,下游就会把本 span 认作父节点。finish 只允许调用一次;把它放在 ForwardingServerCall.close 里,是成本最低的约束。
核心机制二:头部采样与尾部采样
采样决定「哪些 trace 被保留」。两种策略的决策点不同:
| 维度 | 头部采样 | 尾部采样 |
|---|---|---|
| 决策时机 | 链路根节点,请求开始时 | trace 结束后,收集端 |
| 依据 | trace-id 哈希或固定比例 | 错误、耗时、业务属性 |
| 成本 | 极低 | 需要缓冲整条 trace |
| 风险 | 看不到未采样链路里的错误 | 内存与决策延迟 |
| 一致性 | sampled 标志沿链路传播 | 需要集中式收集器 |
头部采样适合绝大多数服务:在网关计算出 sampled,写进 traceparent 的 flags,后续服务只读取、不重新决策。尾部采样适合「错误全留、慢请求全留」的场景,但它要求收集端先缓冲所有 span,再按 trace 决定丢弃,成本和实现复杂度都高一个量级。
package com.plume.linkd.v3.core;
import io.grpc.Status;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public final class Sampling {
private Sampling() {
}
public static final class Head {
private final int keepBasisPoints;
public Head(double rate) {
if (rate < 0 || rate > 1) {
throw new IllegalArgumentException("rate must be in [0, 1]");
}
this.keepBasisPoints = (int) Math.round(rate * 10_000);
}
public boolean sample(String traceId) {
return keepBasisPoints >= 10_000
|| (keepBasisPoints > 0 && bucket(traceId) < keepBasisPoints);
}
private static int bucket(String traceId) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(traceId.getBytes(StandardCharsets.US_ASCII));
int value = ((hash[0] & 0xff) << 24) | ((hash[1] & 0xff) << 16)
| ((hash[2] & 0xff) << 8) | (hash[3] & 0xff);
return (int) (Integer.toUnsignedLong(value) % 10_000);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
public static final class Tail {
private final Duration delay;
private final long slowMicros;
private final Map<String, Buffer> buffers = new ConcurrentHashMap<>();
public Tail(Duration delay, long slowMicros) {
this.delay = delay;
this.slowMicros = slowMicros;
}
public void offer(Span span) {
Buffer buffer = buffers.computeIfAbsent(span.traceId,
id -> new Buffer(Instant.now().plus(delay)));
buffer.spans.add(span);
if (span.status == Status.Code.ERROR) {
buffer.keep = true;
}
}
public void sweep(SpanExporter exporter) {
Instant now = Instant.now();
buffers.forEach((traceId, buffer) -> {
boolean slow = buffer.spans.stream()
.anyMatch(span -> span.durationMicros() >= slowMicros);
if ((now.isAfter(buffer.deadline) || buffer.keep || slow)
&& buffers.remove(traceId, buffer)
&& (buffer.keep || slow)) {
buffer.spans.forEach(exporter::export);
}
});
}
private static final class Buffer {
private final List<Span> spans = new CopyOnWriteArrayList<>();
private final Instant deadline;
private volatile boolean keep;
private Buffer(Instant deadline) {
this.deadline = deadline;
}
}
}
@FunctionalInterface
public interface SpanExporter {
void export(Span span);
}
}
Tail.sweep 里「到期即决策」:到期时如果 buffer 被标记为 keep,或任一 span 超过慢阈值,就整条 trace 导出;否则丢弃。buffers.remove(key, buffer) 用双参数版本,避免两个线程同时决策同一条 trace 时重复导出。sweep 由外部定时器驱动;生产实现还要处理「trace 跨多实例、span 到达乱序、buffer 上限与背压」,这些不是几十行代码能覆盖的。
核心机制三:exemplar,指标到 trace 的那一条线
11.1 的直方图只有聚合值,看到「200 ms 桶有 1018 个样本」时,你不知道是哪次请求。exemplar 在桶上挂一个样本的 trace_id,让 histogram_quantile 与 trace 查询能对接。
Prometheus 的经典文本格式(0.0.4)不支持 exemplar,OpenMetrics 才支持。抓取时服务端返回 Content-Type: application/openmetrics-text,样本行长这样:
linkd_grpc_server_latency_ms_bucket{method="GetBook",le="200.0"} 1018 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 187.0
# {trace_id="..."} 187.0 是 exemplar:键值对是标签,最后是本次观测值(与指标同单位,这里是毫秒)。一个桶只保留最近的一个 exemplar,不能当成完整样本。
package com.plume.linkd.v3.core;
import java.util.concurrent.atomic.AtomicReference;
public final class ExemplarHistogram {
private final MetricsRegistry.Histogram histogram;
private final AtomicReference<String> last = new AtomicReference<>();
public ExemplarHistogram(MetricsRegistry.Histogram histogram) {
this.histogram = histogram;
}
public void observe(long nanos, String traceId, boolean sampled) {
histogram.observeNanos(nanos);
if (sampled) {
last.set(" # {trace_id=\"" + traceId + "\"} "
+ nanos / 1_000_000.0);
}
}
public String renderExemplar() {
return last.get() == null ? "" : last.get();
}
}
exemplar 只解决「指标到 trace 的关联」,不解决「这条 trace 是否被采样」。如果 trace 被头部采样丢弃,exemplar 指向的 trace_id 在追踪系统里查不到,反而制造误导。所以只在 sampled 为 true 时写 exemplar;尾部采样的场景更复杂,通常由收集端在决策后回填。
在 linkd-v3 里落地
服务端拦截器把收到的 traceparent 变成服务端 span,挂进 Context,并在 close 时结束:
package com.plume.linkd.v3.server;
import io.grpc.Context;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
public final class TracingServerInterceptor implements ServerInterceptor {
private final Tracer tracer;
public TracingServerInterceptor(Tracer tracer) {
this.tracer = tracer;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
TraceContext remote;
try {
remote = TraceContext.parse(
headers.get(TraceServerInterceptor.TRACEPARENT));
} catch (IllegalArgumentException e) {
remote = TraceContext.newRoot(true);
}
Span span = Span.server(
call.getMethodDescriptor().getBareMethodName(), remote);
boolean sampled = remote.sampled() && tracer.sampled(remote.traceId());
Context context = Context.current().withValue(Tracer.CURRENT, span);
ServerCall<ReqT, RespT> wrapped =
new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(Status status, Metadata trailers) {
span.finish(status.getCode());
if (sampled) {
tracer.export(span);
}
super.close(status, trailers);
}
};
return new ContextualListener<>(context,
context.call(() -> next.startCall(wrapped, headers)));
}
}
客户端侧反过来:从 Tracer.CURRENT 取父 span,创建客户端 span,把它的 traceparent 放进 metadata,在 onClose 时结束并导出:
Span parent = tracer.currentSpan();
Span span = parent == null
? Span.rootClient(method.getBareMethodName())
: Span.client(method.getBareMethodName(), parent);
headers.put(TraceServerInterceptor.TRACEPARENT,
span.outgoing(tracer.sampled(span.traceId)).toTraceparent());
Tracer 只做四件事:暴露 Context.Key<Span> CURRENT、用 sampled(traceId) 查询头部采样结果、用 export(span) 交给 exporter、用 currentSpan() 从上下文取父 span。它不持有线程、不启动定时器,也不做批量发送;这些都属于生产 SDK 的职责。
落地时的两个约束:服务端拦截器必须在认证、租户之后,这样才能把 tenant 作为低基数属性写进 span;客户端拦截器的 onClose 要用一个完整转发的 ClientCall.Listener,把 onHeaders、onMessage、onReady 都透传下去,只额外处理 onClose,否则响应会在客户端被吞掉。
常见错误与边界
错误一:服务端复用 traceparent 里的 span-id,或头部采样在每个服务各算一次。 前者让父子关系消失,后者让同一条 trace 在不同服务得到不同结论、链路被截断。服务端必须新生成 span-id;根节点决策 sampled,后续服务只读。错误二:尾部采样在所有实例各自缓冲。 同一条 trace 的 span 分散在多个进程,单实例看不到全貌,尾部采样需要集中式收集器。
错误三:exemplar 指向未采样的 trace,或把高基数属性放进 span。 前者会让指标上的 trace_id 在追踪系统里查不到;后者会让追踪后端按属性建索引时膨胀。只在 sampled 为 true 时写 exemplar,属性只放低基数、可枚举的值。
错误四:把本节实现当成 OpenTelemetry。 没有 OTLP、没有跨线程 Context 自动传播、没有 baggage、没有资源模型、没有批量导出与重试;它只覆盖「进程内生成 span 并交给 exporter」。另外,时钟不同步会让父子耗时对不上,采样率哈希桶在短窗口内也会波动。
小结
- span 由 trace_id、span_id、parent_span_id、name、kind、起止时间、属性和状态组成;服务端与客户端各生成一个 span,才能把网络与排队时间分离出来,且服务端必须新生成 span-id。
- 头部采样在根节点决策、沿链路传播,成本低;尾部采样在收集端按错误与耗时决策,能力更强但需要缓冲与集中式收集。
- exemplar 把 trace_id 挂到指标桶上,只在 OpenMetrics 文本格式里有效,且只应指向已采样的 trace;本节实现不是 OpenTelemetry。追踪数据只用于观测,不能作为授权或计费依据;高基数属性留在日志,不进 span。
到这里,可观测性三件套齐了:指标看聚合、日志看单条、追踪看链路。下一章转向版本演进,先回答「v1 和 v2 怎么并存」,再回答「怎么把一小部分流量切到新版本」。
阅读导航:上一节 11.2 结构化日志与 traceparent | 下一节 12.1 API 版本策略
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。