《后端架构设计·高性能与高可用》10.1 订单链路的最小观测契约

在 arch-lab 订单链路上实现 trace_id、request_id、order_id、operation、outcome、error_code、duration 七字段契约、context 传播中间件与契约校验器,用 httptest 真跑创建、支付、失败三条链路验证父子 span,并给出采样率、日志与指标的分工口径。

先有契约,再选后端

链路追踪的通用模型、Span 与上下文传播已在 分布式追踪实践 和 分布式追踪架构 中讲过,本节不重复定义。这里只回答 arch-lab 的一个具体问题:一笔订单从创建到支付失败,究竟必须留下哪些字段,才能让值班的人在不问作者的情况下定位到是哪一步、因为什么失败。

先定契约再选后端,原因是后端会换:今天是日志文件加 Jaeger,明天可能是 OpenTelemetry Collector 加对象存储。字段和约束不随后端变化,校验器才能一直用。arch-lab 的契约只有七类字段,其中五个无条件必填,两个按结果条件必填。

七类字段与三条硬约束

字段必填条件取值约束用途
trace_id总是32 位小写十六进制串联整条链路
request_id总是1–64 字符对应用户的一次点击
order_id订单操作非空按业务聚合排障
operation总是契约内枚举区分入口、领域与外部调用
outcome总是ok 或 error成败口径唯一
error_codeoutcome=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 告警:从症状到行动的闭环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查