《Java 服务端通信工程》11.3 追踪、采样与 exemplar

11.2 有了 trace_id,本节补上 span:定义客户端与服务端 span 的父子关系,实现头部采样与尾部采样两种策略,并把 trace_id 作为 exemplar 挂到延迟直方图上,同时说明这不是完整的 OpenTelemetry。

本节目标:把 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方法名
kindSERVER / CLIENT / INTERNAL角色
start / end起止时间本地时钟
statusOK / ERRORgRPC 状态码

本节不引入 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 版本策略

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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