本节目标:让一条日志能回答「它属于哪个请求、哪个 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_id | traceparent | 跨服务串联 |
request_id | x-request-id | 单次业务请求的关联与幂等 |
tenant | x-tenant-id | 租户维度检索 |
method / code | gRPC 调用 | 聚合与筛选 |
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-id | traceparent |
|---|---|---|
| 谁生成 | 客户端(网关) | 链路根节点,通常是网关 |
| 唯一性范围 | 一次业务请求 | 一条分布式调用链 |
| 重试时 | 保持不变,配合幂等 | 可以复用同一 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。