先有契约,再选后端
链路追踪的通用模型、Span 与上下文传播已在 分布式追踪实践 和 分布式追踪架构 中讲过,本节不重复定义。这里只回答 arch-lab 的一个具体问题:一笔订单从创建到支付失败,究竟必须留下哪些字段,才能让值班的人在不问作者的情况下定位到是哪一步、因为什么失败。
先定契约再选后端,原因是后端会换:今天是日志文件加 Jaeger,明天可能是 OpenTelemetry Collector 加对象存储。字段和约束不随后端变化,校验器才能一直用。arch-lab 的契约只有七类字段,其中五个无条件必填,两个按结果条件必填。
七类字段与三条硬约束
| 字段 | 必填条件 | 取值约束 | 用途 |
|---|---|---|---|
trace_id | 总是 | 32 位小写十六进制 | 串联整条链路 |
request_id | 总是 | 1–64 字符 | 对应用户的一次点击 |
order_id | 订单操作 | 非空 | 按业务聚合排障 |
operation | 总是 | 契约内枚举 | 区分入口、领域与外部调用 |
outcome | 总是 | ok 或 error | 成败口径唯一 |
error_code | outcome=error | 九类领域码加 INTERNAL | 决定告警与重试策略 |
duration | 总是 | 大于 0 | 延迟分解与超时归因 |
三条硬约束决定了这份契约能不能长期用。第一,error_code 只能是枚举值,禁止把异常字符串直接塞进去,否则同一类失败会分裂成上千种取值。第二,标签只能是低基数键,order_id、trace_id、user_id 已经是一等字段,不允许再作为标签出现,标签值里出现六位以上连续数字、十六位以上十六进制串或 /、@ 时一律拒绝。第三,duration<=0 视为未闭合 span,必须拒绝而不是补一个零值放过。
中间件:一次请求只生成一个 trace
中间件只做四件事:复用或生成 trace_id、生成 request_id、把两个 ID 放进 context 与响应头、在请求结束时补齐 order_id、outcome、error_code 并发出根 span。order_id 由业务处理器解析出来后通过 SetOrderID 写回共享的 Trace,因此根 span 不需要在请求开始时猜测它。子 span 用 spanContext 记录当前 span ID,兄弟 span 不会互相覆盖父指针。
// file: internal/platform/obs/obs.go
// tier: L1
// verify: go test ./internal/platform/obs/
package obs
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strings"
"time"
)
const (
OutcomeOK = "ok"
OutcomeError = "error"
CodeInternal = "INTERNAL"
)
// Trace 是链路共享状态;SpanID 只属于当前 span,单独放在 spanContext。
type Trace struct {
TraceID, SpanID, RequestID, OrderID string
ErrorCode string
}
type (
traceKey struct{}
spanKey struct{}
)
type spanContext struct {
root *Trace
spanID string
}
func fromContext(ctx context.Context) (*Trace, bool) {
t, ok := ctx.Value(traceKey{}).(*Trace)
return t, ok
}
func SetOrderID(ctx context.Context, id string) {
if t, ok := fromContext(ctx); ok {
t.OrderID = id
}
}
func SetErrorCode(ctx context.Context, code string) {
if t, ok := fromContext(ctx); ok {
t.ErrorCode = code
}
}
type Span struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
ParentSpanID string `json:"parent_span_id,omitempty"`
RequestID string `json:"request_id"`
OrderID string `json:"order_id,omitempty"`
Operation string `json:"operation"`
Outcome string `json:"outcome"`
ErrorCode string `json:"error_code,omitempty"`
Start time.Time `json:"start"`
Duration time.Duration `json:"duration_ns"`
Tags map[string]string `json:"tags,omitempty"`
}
type Tracer struct {
Emit func(Span)
Now func() time.Time
}
func NewTracer(emit func(Span)) *Tracer { return &Tracer{Emit: emit, Now: time.Now} }
func newID(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Middleware 建立根 span,把 trace_id/request_id 放进 context 和响应头。
func (t *Tracer) Middleware(op func(*http.Request) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
if !ValidTraceID(traceID) {
traceID = newID(16)
}
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newID(8)
}
tr := &Trace{TraceID: traceID, SpanID: newID(8), RequestID: requestID}
span := Span{TraceID: traceID, SpanID: tr.SpanID, RequestID: requestID, Operation: op(r), Start: t.Now()}
ctx := context.WithValue(r.Context(), traceKey{}, tr)
ctx = context.WithValue(ctx, spanKey{}, &spanContext{root: tr, spanID: tr.SpanID})
w.Header().Set("X-Trace-ID", traceID)
w.Header().Set("X-Request-ID", requestID)
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r.WithContext(ctx))
span.OrderID = tr.OrderID
span.Tags = map[string]string{"http.status_class": fmt.Sprintf("%dxx", sw.Status()/100)}
code := tr.ErrorCode
if code == "" && sw.Status() >= 500 {
code = CodeInternal
}
t.finish(&span, code)
})
}
func (t *Tracer) Start(ctx context.Context, operation string) (context.Context, *Span) {
sc, ok := ctx.Value(spanKey{}).(*spanContext)
traceID, parentID, requestID, orderID := newID(16), "", newID(8), ""
if !ok {
span := &Span{TraceID: traceID, SpanID: newID(8), RequestID: requestID, Operation: operation, Start: t.Now()}
return ctx, span
}
traceID, parentID = sc.root.TraceID, sc.spanID
requestID, orderID = sc.root.RequestID, sc.root.OrderID
span := &Span{TraceID: traceID, SpanID: newID(8), ParentSpanID: parentID, RequestID: requestID,
OrderID: orderID, Operation: operation, Start: t.Now()}
return context.WithValue(ctx, spanKey{}, &spanContext{root: sc.root, spanID: span.SpanID}), span
}
func (t *Tracer) End(ctx context.Context, span *Span, errorCode string) {
if span == nil {
return
}
if tr, ok := fromContext(ctx); ok {
span.OrderID = tr.OrderID
}
t.finish(span, errorCode)
}
func (t *Tracer) finish(span *Span, errorCode string) {
span.Duration = t.Now().Sub(span.Start)
if errorCode != "" {
span.Outcome, span.ErrorCode = OutcomeError, errorCode
} else {
span.Outcome = OutcomeOK
}
if t.Emit != nil {
t.Emit(*span)
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Status() int {
if w.status == 0 {
return http.StatusOK
}
return w.status
}
var (
traceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
spanIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
digitRun = regexp.MustCompile(`[0-9]{6,}`)
hexRun = regexp.MustCompile(`[0-9a-f]{16,}`)
operations = map[string]bool{"http.create_order": true, "http.pay_order": true,
"http.fail_order": true, "order.create": true, "inventory.reserve": true, "payment.capture": true}
errorCodes = map[string]bool{"INVALID_STATE": true, "OUT_OF_STOCK": true, "PAYMENT_FAILED": true,
"VERSION_CONFLICT": true, "IDEMPOTENCY_CONFLICT": true, "RATE_LIMITED": true,
"CIRCUIT_OPEN": true, "TIMEOUT": true, "OVERLOADED": true, CodeInternal: true}
tagKeys = map[string]bool{"http.status_class": true, "order.status": true,
"payment.provider": true, "inventory.warehouse": true, "error.source": true, "sampling.reason": true}
forbiddenTags = map[string]bool{"trace_id": true, "span_id": true, "request_id": true,
"order_id": true, "user_id": true, "session_id": true}
)
func ValidTraceID(id string) bool { return traceIDPattern.MatchString(id) }
func highCardinality(v string) bool {
return len(v) > 40 || strings.ContainsAny(v, "/@") || digitRun.MatchString(v) || hexRun.MatchString(v)
}
func ValidateSpan(s Span) error {
var errs []string
add := func(f string, a ...any) { errs = append(errs, fmt.Sprintf(f, a...)) }
if !ValidTraceID(s.TraceID) {
add("trace_id %q is not 32 lowercase hex", s.TraceID)
}
if !spanIDPattern.MatchString(s.SpanID) || (s.ParentSpanID != "" && !spanIDPattern.MatchString(s.ParentSpanID)) {
add("span_id/parent_span_id is not 16 lowercase hex")
}
if strings.TrimSpace(s.RequestID) == "" || len(s.RequestID) > 64 {
add("request_id must be 1..64 chars")
}
if !operations[s.Operation] {
add("unknown operation %q", s.Operation)
}
if s.Duration <= 0 {
add("span %s is unclosed or has non-positive duration", s.SpanID)
}
if s.Outcome == OutcomeOK && s.ErrorCode != "" {
add("outcome=ok must not carry error_code")
}
if s.Outcome != OutcomeOK && s.Outcome != OutcomeError {
add("outcome must be ok or error")
}
if s.Outcome == OutcomeError && !errorCodes[s.ErrorCode] {
add("error outcome needs a known error_code, got %q", s.ErrorCode)
}
if operations[s.Operation] && s.OrderID == "" {
add("operation %q requires order_id", s.Operation)
}
for k, v := range s.Tags {
if forbiddenTags[k] || !tagKeys[k] {
add("tag key %q is not allowed", k)
} else if strings.TrimSpace(v) == "" || highCardinality(v) {
add("tag %q=%q is empty or high-cardinality", k, v)
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
// ValidateTrace 检查唯一根、父 span 存在,以及子 span 不超出父 span 的时间范围。
func ValidateTrace(spans []Span) error {
if len(spans) == 0 {
return fmt.Errorf("empty trace")
}
byID, roots := make(map[string]Span, len(spans)), 0
for _, s := range spans {
if err := ValidateSpan(s); err != nil {
return err
}
if _, dup := byID[s.SpanID]; dup {
return fmt.Errorf("duplicate span_id %s", s.SpanID)
}
byID[s.SpanID] = s
if s.ParentSpanID == "" {
roots++
}
}
if roots != 1 {
return fmt.Errorf("trace has %d roots, want 1", roots)
}
for _, s := range spans {
if s.ParentSpanID == "" {
continue
}
p, ok := byID[s.ParentSpanID]
if !ok {
return fmt.Errorf("span %s references missing parent %s", s.SpanID, s.ParentSpanID)
}
if s.Start.Before(p.Start) || s.Start.Add(s.Duration).After(p.Start.Add(p.Duration)) {
return fmt.Errorf("span %s is outside parent %s", s.SpanID, p.SpanID)
}
}
return nil
}
校验器拒绝什么
ValidateSpan 逐条检查七类字段,ValidateTrace 再检查链路完整性。前者拒绝:trace_id 不是 32 位小写十六进制;request_id 为空或超过 64 字符;operation 不在枚举内;duration<=0 表示 span 未闭合;outcome=ok 却带错误码;outcome=error 却没有已知错误码;订单操作缺 order_id;标签键不在白名单或值呈高基数。后者拒绝:一个 trace 有零个或多个根、父 span 不存在、子 span 的时间超出父 span。
高基数是日志系统最贵的错误。把 order_id 塞进标签,一天一百万单就会生成一百万条时间序列;把异常信息原样塞进标签,一次发布就能让索引基数翻十倍。校验器用“键白名单 + 值形态”两道闸门拦住它们,值里出现 provider-20261020123456 这种长数字串会直接报错,而不是等到账单出来才发现。
三条链路真跑
测试用 httptest 启动真实 HTTP 服务,路由分别是 /create、/pay、/fail。创建链路设置 order_id=o-1001 并产生一个 order.create 子 span;支付链路产生 inventory.reserve 与 payment.capture 两个兄弟子 span;失败链路设置 OUT_OF_STOCK 并返回 409。第一个请求显式带 X-Trace-ID 与 X-Request-ID,验证外部传入的 ID 会被复用;后两个请求不带,验证中间件会生成并在响应头回传。断言分三层:span 总数为 7、每个 trace 都能通过 ValidateTrace、失败链路的所有 span 都是 error 且错误码一致。
$ go test -v ./... -count=1
=== RUN TestThreeOrderChains
obs_test.go:83: traces=3 spans=7 create=2 pay=3 fail=2
obs_test.go:84: pay_child_json={"trace_id":"b970f994193800408c102687203f51ae","span_id":"471e8381b074f98b","parent_span_id":"4d97f3b2bbdb7c8b","request_id":"771e3e8489649133","order_id":"o-1002","operation":"inventory.reserve","outcome":"ok","start":"2026-11-03T00:34:09.839157+08:00","duration_ns":1088167}
--- PASS: TestThreeOrderChains (0.01s)
=== RUN TestContractRejects
obs_test.go:108: contract_self_check=ok rejections=4
--- PASS: TestContractRejects (0.00s)
PASS
ok archlab/internal/platform/obs 0.492s
以上为本机 macOS(darwin/arm64,Go 1.26.0)单次运行,命令 go test -v ./... -count=1,原始输出在 .local/reports/be-arch/be-arch-perf-10-1-order-trace-observability-contract-tests.txt。duration_ns=1088167 是这一条子 span 的实测值,约 1.09ms,主要来自测试里的 1ms 休眠,不代表线上延迟。七个 span 的父子关系由 ValidateTrace 断言:创建 2 个、支付 3 个、失败 2 个,三个 trace 各有一个根。
采样率、日志与指标怎么分工
三者不是互相替代,而是回答不同粒度的问题:
| 信号 | 粒度 | 保留策略 | 回答的问题 |
|---|---|---|---|
| 指标 | 聚合计数与直方图 | 全量、长期 | 错误率是否越线、P99 是否变慢 |
| 日志 | 单次事件 | 全量、短期 | 这次请求具体报了什么错 |
| 链路 | 因果路径 | 采样、中期 | 慢在哪一步、谁调用了谁 |
arch-lab 的起点是头部采样 10%:按 trace_id 哈希取模,保证同一 trace 在所有实例上结论一致;错误链路不看采样率,命中 outcome=error 的 trace 全量保留。指标只允许低基数标签(路由模板、状态码段、错误码),绝不能带 order_id;日志必须同时带 trace_id 与 request_id,否则无法从聚合图跳回单条记录。采样率提高会线性增加存储与 Collector 压力,10% 是示例目标值,必须按每日 trace 量和保留天数重新计算,不能直接抄。
常见错误与边界
第一,先接 SDK 再定字段。不同服务的字段名和错误码各写一套,最后只能靠正则清洗;契约应该先于后端存在。
第二,把 order_id 当标签。它天然高基数,正确位置是一等字段;标签只放能枚举的维度。
第三,用 HTTP 200 代表业务成功。支付失败可能返回 200 加业务错误体,中间件必须读取显式的错误码,而不是只看状态码。
第四,只校验单条 span。字段齐全但父 span 不存在、两个根、子 span 超出父 span 时间范围,同样无法排障;链路级校验不能省。
第五,采样率拍脑袋。错误全采、正常按比例采是常见组合,但比例要按容量算;采到 100% 往往不是“更安全”,而是把 Collector 先打挂。
小结
订单链路的最小观测契约是七类字段加三条硬约束:枚举化错误码、低基数标签、未闭合即拒绝。中间件负责一次请求只生成一个 trace 并完成 context 传播,校验器负责把坏数据挡在存储之前。三条链路真跑得到 7 个 span、3 个根,父子关系与错误码全部通过断言。下一节把这些信号变成可执行的告警:持续条件、抑制、去重与分级路由。
阅读导航:上一节:9.3 开关与灰度的数据兼容约束 · 下一节:10.2 告警:从症状到行动的闭环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。