《Java 服务端通信工程》11.2 结构化日志与 traceparent

把单个请求串起来:按 W3C 规范解析与生成 traceparent,区分 request-id 与 trace_id,用 JDK 输出可检索的结构化 JSON 日志,并给出按 trace 一致采样的完整可编译实现、字段设计与日志脱敏边界。

本节目标:让一条日志能回答「它属于哪个请求、哪个 trace、哪个租户」:能严格解析和生成 W3C traceparent,能把 x-request-id 与 trace_id 分清楚,能用 JDK 拼出合法 JSON 日志,并按 trace 做一致采样。
适用版本:JDK 25、gRPC-Java 1.76.0;traceparent 按 W3C Trace Context Level 1 的 00 版本处理。

承接的问题

11.1 的指标告诉你「p99 涨了」,但给不出「是哪一次请求、在哪一跳慢的」。日志能给出单条记录,代价是量大、格式杂。要让日志可检索,必须固定字段;要让跨服务日志能串起来,必须共用同一个 trace 标识。

linkd-v3 的一条访问日志至少要有这些字段:

字段来源用途
trace_id / span_idtraceparent跨服务串联
request_idx-request-id单次业务请求的关联与幂等
tenantx-tenant-id租户维度检索
method / codegRPC 调用聚合与筛选
latency_ms本地计时单请求耗时

核心机制一:traceparent 的结构与校验

W3C 的 traceparent 是四段,用连字符分隔:

00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│  │                                │                │
│  │                                │                └─ flags:01 表示 sampled
│  │                                └─ parent-id:16 位小写十六进制
│  └─ trace-id:32 位小写十六进制,不能全 0
└─ version:当前为 00,ff 非法

固定长度是 55 个字符。规范要求小写十六进制;trace-id 与 parent-id 都不能全零。flags 的第 0 位是 sampled,其余位保留。版本不是 00 时,解析规则可能变化;本实现只接受 00,遇到其他版本按非法处理并另起一条新 trace,而不是让请求失败。

package com.plume.linkd.v3.core;

import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.regex.Pattern;

public record TraceContext(String traceId, String spanId, boolean sampled) {

    private static final Pattern TRACE_ID = Pattern.compile("[0-9a-f]{32}");
    private static final Pattern SPAN_ID = Pattern.compile("[0-9a-f]{16}");
    private static final Pattern FLAGS = Pattern.compile("[0-9a-f]{2}");
    private static final String ZERO_TRACE = "0".repeat(32);
    private static final String ZERO_SPAN = "0".repeat(16);
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final HexFormat HEX = HexFormat.of();

    public TraceContext {
        if (traceId == null || !TRACE_ID.matcher(traceId).matches()
                || traceId.equals(ZERO_TRACE)) {
            throw new IllegalArgumentException("invalid trace id");
        }
        if (spanId == null || !SPAN_ID.matcher(spanId).matches()
                || spanId.equals(ZERO_SPAN)) {
            throw new IllegalArgumentException("invalid span id");
        }
    }

    public String toTraceparent() {
        return "00-" + traceId + "-" + spanId + "-" + (sampled ? "01" : "00");
    }

    public static TraceContext parse(String value) {
        if (value == null) {
            throw new IllegalArgumentException("missing traceparent");
        }
        if (!value.equals(value.strip())) {
            throw new IllegalArgumentException("traceparent has whitespace");
        }
        String[] parts = value.split("-", -1);
        if (parts.length != 4) {
            throw new IllegalArgumentException("traceparent must have 4 parts");
        }
        if (!parts[0].equals("00")) {
            throw new IllegalArgumentException("unsupported version");
        }
        if (!FLAGS.matcher(parts[3]).matches()) {
            throw new IllegalArgumentException("invalid flags");
        }
        int flags = Integer.parseInt(parts[3], 16);
        return new TraceContext(parts[1], parts[2], (flags & 0x01) != 0);
    }

    public static TraceContext newRoot(boolean sampled) {
        return new TraceContext(randomHex(16, ZERO_TRACE),
                randomHex(8, ZERO_SPAN), sampled);
    }

    private static String randomHex(int bytes, String zero) {
        byte[] buffer = new byte[bytes];
        String hex;
        do {
            RANDOM.nextBytes(buffer);
            hex = HEX.formatHex(buffer);
        } while (hex.equals(zero));
        return hex;
    }
}

几个决定:

  • 构造器做全部校验,parse 只负责拆段。这样任何来源(header、消息字段、测试构造)产生的 TraceContext 都是合法的。
  • 用 record 的紧凑构造器,校验写在里面,外部无法绕过。traceId 与 spanId 是不可变字符串,线程安全。
  • newRoot 用 SecureRandom,并循环到非全零。0 的概率可以忽略,但校验器不认「可以忽略」,所以循环是必要的。
  • 只保留 sampled 这一位,toTraceparent() 输出 00 或 01。规范里其余 flag 位本实现不生成,这是有意的简化,跨实现时不会因此误解 sampled 语义。

核心机制二:request-id 与 trace_id 的区别

两个标识解决不同问题:

维度x-request-idtraceparent
谁生成客户端(网关)链路根节点,通常是网关
唯一性范围一次业务请求一条分布式调用链
重试时保持不变,配合幂等可以复用同一 trace,新 span
用途业务关联、幂等键、客诉定位跨服务时序、延迟归因
格式任意 ASCII,建议 UUID固定 55 字符
是否可信仅作关联,不参与授权仅作关联,不参与授权

一条经验规则:用户报障时给的是 request-id,工程师排障时看的是 trace_id。request-id 要回显给客户端,方便客服按单号查;trace_id 可以不回显,留在日志与追踪系统里。

重试会放大两者的差异。3.3 的幂等键在一次逻辑请求的多次尝试之间保持不变,x-request-id 也应保持不变;每次尝试可以生成新的 span,但 trace_id 不变。如果每次重试都换 request-id,幂等层就无法判断「这是不是同一个请求」。

x-request-id 缺失时,服务端生成一个 UUID 并回显;traceparent 缺失或非法时,服务端新起一条 trace,但不要因此拒绝请求。追踪是观测手段,不能成为可用性的单点。

核心机制三:结构化 JSON 日志

不引入日志框架,用 JDK 拼 JSON。关键是转义和字段稳定:

package com.plume.linkd.v3.core;

import java.time.Instant;

public final class JsonLog {

    private JsonLog() {
    }

    public static String access(String service, String method,
                                TraceContext trace, String requestId,
                                String tenant, String code, long latencyMs) {
        StringBuilder out = new StringBuilder(256).append('{');
        string(out, "ts", Instant.now().toString());
        string(out, "level", "INFO");
        string(out, "service", service);
        string(out, "method", method);
        string(out, "trace_id", trace.traceId());
        string(out, "span_id", trace.spanId());
        string(out, "request_id", requestId);
        string(out, "tenant", tenant);
        string(out, "code", code);
        out.append(",\"latency_ms\":").append(latencyMs);
        return out.append('}').toString();
    }

    private static void string(StringBuilder out, String key, String value) {
        if (out.length() > 1) {
            out.append(',');
        }
        out.append('"').append(key).append("\":\"")
                .append(escape(value)).append('"');
    }

    private static String escape(String value) {
        StringBuilder out = new StringBuilder(value.length() + 8);
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            switch (c) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> out.append(c < 0x20
                        ? String.format("\\u%04x", (int) c) : String.valueOf(c));
            }
        }
        return out.toString();
    }
}

预期的一条日志(字段按上面的顺序生成,时间与 ID 为示例):

{"ts":"2026-10-28T11:34:00.123Z","level":"INFO","service":"linkd-v3","method":"GetBook","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","request_id":"6f1c2a9e-7d54-4a0e-9f2b-3c1d5e7a9b0c","tenant":"acme","code":"OK","latency_ms":12}

字段顺序固定有两个好处:人眼扫得快,日志管道做前缀匹配时不会因为顺序变化而漏。JSON 对象本身无序,但生成端有序是零成本的。

不要把异常栈直接拼进 JSON 字符串。栈里的换行会把一行日志拆成多行,采集器按行切分时后半段就丢了。要么把栈转义成 \n 放进 error.stack 字段,要么让日志框架单独处理多行;本节的手写实现选择转义,保证一条记录始终是一行。

核心机制四:按 trace 一致的日志采样

全量日志在高峰期会打满磁盘。采样要满足两个条件:同一个 trace 的日志要么都留、要么都不留;错误日志永远保留。

package com.plume.linkd.v3.core;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public final class LogSampler {

    private final int keepBasisPoints;

    public LogSampler(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 keep(String traceId) {
        return keepBasisPoints >= 10_000
                || (keepBasisPoints > 0 && bucket(traceId) < keepBasisPoints);
    }

    static int bucket(String traceId) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.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);
        }
    }
}

调用点写成 code != "OK" || sampler.keep(traceId):错误不采样,正常请求按比例留。用 SHA-256 而不是 String.hashCode(),是因为后者分布不够均匀,且很容易被构造出同桶的 ID;采样桶只要求稳定和均匀,不要求密码学强度,但 SHA-256 的实现成本一样低。

采样的粒度要提前说清楚:5% 的日志采样意味着「5% 的 trace 有完整日志」,不是「每条日志独立丢 95%」。后者会让同一次请求只剩半条链路,排障时比没有更糟。

在 linkd-v3 里落地

追踪拦截器放在认证、租户之后,指标之前。它做四件事:解析或新建 trace、生成或透传 request-id、把 RequestContext 放进 Context、在 close 时写访问日志并回显 request-id:

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;

import java.util.UUID;

public final class TraceServerInterceptor implements ServerInterceptor {

    public static final Metadata.Key<String> TRACEPARENT =
            Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER);
    public static final Metadata.Key<String> REQUEST_ID =
            Metadata.Key.of("x-request-id", Metadata.ASCII_STRING_MARSHALLER);
    public static final Context.Key<RequestContext> REQUEST =
            Context.key("linkd.request");

    private static final System.Logger LOG =
            System.getLogger("linkd.access");

    private static final String SERVICE = "linkd-v3";

    private final LogSampler sampler;

    public TraceServerInterceptor(LogSampler sampler) {
        this.sampler = sampler;
    }

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        TraceContext trace;
        try {
            trace = TraceContext.parse(headers.get(TRACEPARENT));
        } catch (IllegalArgumentException e) {
            trace = TraceContext.newRoot(true);
        }
        String requestId = headers.get(REQUEST_ID);
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }
        RequestContext requestContext = new RequestContext(requestId,
                TenantInterceptor.TENANT_ID.get(), trace,
                Context.current().getDeadline());
        Context context = Context.current().withValue(REQUEST, requestContext);
        String finalRequestId = requestId;
        TraceContext finalTrace = trace;
        long start = System.nanoTime();
        ServerCall<ReqT, RespT> wrapped =
                new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
                    @Override
                    public void sendHeaders(Metadata responseHeaders) {
                        responseHeaders.put(REQUEST_ID, finalRequestId);
                        super.sendHeaders(responseHeaders);
                    }

                    @Override
                    public void close(Status status, Metadata trailers) {
                        long latencyMs = (System.nanoTime() - start) / 1_000_000;
                        if (status.getCode() != Status.Code.OK
                                || sampler.keep(finalTrace.traceId())) {
                            LOG.log(System.Logger.Level.INFO, JsonLog.access(
                                    SERVICE,
                                    call.getMethodDescriptor().getBareMethodName(),
                                    finalTrace, finalRequestId,
                                    TenantInterceptor.TENANT_ID.get(),
                                    status.getCode().name(), latencyMs));
                        }
                        super.close(status, trailers);
                    }
                };
        ServerCall.Listener<ReqT> delegate =
                context.call(() -> next.startCall(wrapped, headers));
        return new ContextualListener<>(context, delegate);
    }
}

两个细节:

  • 服务端必须生成新的 span_id,不能把 traceparent 里的 parent-id 当成自己的 span。traceparent 传进来的是调用方的 span,服务端的 span 是它的子节点。11.3 会把这一步显式建模。
  • sendHeaders 里回显 request-id,客户端用 Metadata 的 trailer 或 header 都能读到。回显不是为了好看:客诉只给单号时,服务端日志要靠它定位。

访问日志用 System.Logger 输出到 stderr,容器采集后按 JSON 解析。生产环境可以换成日志框架的 JSON encoder,但字段名与转义规则要保持一致。

常见错误与边界

错误一:把 request-id 当成 trace_id。 一次请求可能跨多个服务,request-id 只在业务层唯一;一次重试可能共享 request-id 但产生多个 span。两者都记,不要互相替代。

错误二:接受大写或全零的 ID,或对非法 traceparent 直接拒绝请求。 W3C 规范要求小写十六进制,全零 trace-id/span-id 非法;但追踪头是观测数据,不是授权凭据,解析失败应当新起一条 trace,而不是返回 INVALID_ARGUMENT。

错误三:服务端复用 parent-id 作为自己的 span-id。 这会让调用链出现两个相同 span,追踪系统无法区分父子。服务端必须生成新的 16 位 ID。

错误四:把异常栈原样塞进 JSON,或逐条日志独立采样。 换行会破坏「一行一条」的约定;逐条采样会让同一条 trace 的日志被随机丢弃、链路断成碎片。转义异常,并按 trace-id 做确定性哈希采样,错误日志不采样。

边界一:traceparent 可以被伪造,采样标志也不是安全边界。 任何调用方都能塞任意 trace-id;sampled=01 只表示调用方希望采样,服务端可以覆盖。它们只用于关联,不能用于授权、计费或「证明调用来自某服务」,这些判断靠 mTLS 与 token。

边界二:日志里的租户与用户字段仍是敏感数据。 tenant、sub 可以记,token、完整 claims、请求体不行。日志管道要有字段级脱敏,不能只依赖应用层自觉。

边界三:System.Logger 不是结构化日志框架。 它把整条 JSON 当消息输出,没有字段级索引;要按字段检索,仍然需要采集端解析 JSON。本节的目标是让每行合法,而不是替代日志平台。

小结

  • traceparent 是 00-<32 hex>-<16 hex>-<2 hex>,55 字符,小写,trace-id 与 span-id 不能全零;解析失败时新起 trace,不拒绝请求。x-request-id 标识一次业务请求,重试时保持不变;trace_id 标识调用链,重试可以复用 trace 但每次尝试是新的 span。
  • 结构化日志用固定字段与严格转义,保证一条记录一行;token 与完整 claims 永不入日志,采样按 trace-id 做确定性哈希,错误日志始终保留。
  • 服务端在 sendHeaders 回显 request-id,在 close 记录状态码与耗时,span-id 必须新生成;追踪头只用于关联,不能作为授权或身份凭据。11.3 再补齐 span 父子关系、头部/尾部采样与 exemplar 关联。

阅读导航:上一节 11.1 指标口径与 Prometheus 文本 | 下一节 11.3 追踪、采样与 exemplar

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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