一条只有半截的支付链路
大促压测时,arch-lab 的订单链路在追踪系统里经常只剩前半截:order-service 能看到 POST /orders/{id}/pay,inventory-service 的预占 span 偶尔出现,payment-service 的授权 span 几乎全丢。三个服务都声称自己开了 10% 采样,日志里也都能看到 trace ID,但拼起来就是一条断链。
排查发现两个独立问题。第一,三个服务各自用 hash(traceID) % 100 < 10 实现采样,哈希函数一个用 MD5、一个用 FNV、一个用字符串长度取模,同一个 trace ID 在三个服务得到三种结论。第二,order-service 在支付前把调用放进了一个新的 goroutine,context.Context 没有传进去,下游只能新建 trace,parent span ID 直接丢失。前者让采样结果不一致,后者让上下文根本没有过河。
成本则是另一面。按示例值估算:峰值 5000 请求/秒,每条 trace 平均 12 个 span,全量采集就是 6 万个 span/秒;每个 span 序列化后约 1.2 KB,约 72 MB/s,一天 6.2 TB。10% 采样把这个数字压到 7.2 MB/s,仍然需要算清保留策略。追踪不是“打开就有”,它是一条有单价的链路。分布式追踪 介绍过 span 模型与采集架构,本节只讨论采样决策和上下文传播这两个会直接改变成本与可诊断性的点。
头部、尾部与父级透传
| 方式 | 决策时刻 | 能看到什么 | 主要成本 | 适用场景 |
|---|---|---|---|---|
| 头部比例采样 | 请求进入根服务时 | 只有请求元数据 | 决策零延迟,可能丢错误 trace | 高吞吐、成本敏感 |
| 尾部采样 | trace 完成后在 collector | 完整 span、错误、时延 | 需要缓冲整条 trace | 错误与慢请求必须保留 |
| 父级透传 | 每个下游服务入口 | 上游 sampled 标志 | 依赖 header 正确传播 | 所有分布式调用 |
头部采样便宜,但它在看到结果之前就做了决定,错误请求可能恰好被丢。尾部采样能看到完整 trace,可以“错误全留、慢请求优先、其余按比例”,代价是 collector 要按 trace ID 缓冲,内存和决策延迟都更高。父级透传不是另一种采样算法,而是保证同一条 trace 在所有服务得到同一个结论的机制;没有它,前两种方式都会产生半截链路。
生产上常见的组合是:根服务做低比例头部采样,collector 再做尾部兜底。但尾部采样只能看到已经到达 collector 的 trace;如果根服务已经丢了,collector 无从补救。因此头部比例不能低到让错误样本绝迹,尾部策略也不能假设上游一定送来了完整数据。
比例采样必须对 trace ID 一致
一致性比例采样的做法是:取 trace ID 的固定 53 位,映射到 [0, 2^53),再和一个阈值比较。阈值等于 fraction × 2^53。只要所有服务用同一个 fraction、同一段比特,同一个 trace ID 就会得到同一个布尔结果,不需要任何中心协调。选 53 位是因为 float64 的尾数正好 53 位,阈值计算不会因精度丢失而让两个服务算出不同边界。
这比“随机数小于 0.1”强的地方在于可复现:同一个 trace 在任何机器、任何语言、任何时间都做同样决策。它也比“按服务各自随机”更适合排障,因为一条被采样的 trace 不会在第二个服务突然消失。代价是采样率是全局的,不能按服务单独调;要按服务调,应该改父级策略或在 collector 做尾部加权,而不是让每个服务各自换比例。
context 传播的三个断点
第一个断点是 goroutine。Go 的 context.Context 不会自动跨 goroutine 传递,go func() 里拿到的必须是显式传入的 ctx;用包级变量或重新 context.Background() 都会新建一条 trace。第二个断点是异步消息。HTTP header 能带 traceparent,消息体不会自动带;生产者在消息属性里写入,消费者从属性里提取,否则消息消费永远是新 trace。第三个断点是 header 解析。大小写、全零 ID、未定义 flag 位、未来版本都可能让解析失败;失败时应该记录并新建 trace,而不是把空上下文继续往下传。
baggage 是另一个容易被滥用的通道。它适合放租户 ID、灰度标记这类小值,不适合放用户信息或大对象。示例约束可以设为 8 个成员、单值 512 字节、总长 2048 字节;超限时按确定顺序丢弃并计数,而不是把整个 header 截断。baggage 会随每个请求复制到所有下游,体积和敏感度都要按“会传播”来评估。
可运行实现
下面的 obs 包实现严格版 W3C traceparent 解析、context 存取、注入/提取,以及一致性比例采样和父级采样。它不依赖第三方库;生产上可以替换为 OpenTelemetry SDK,但决策边界与传播规则不变。
// file: internal/platform/obs/sampling.go
// tier: L2
// verify: go test ./internal/platform/obs/ -count=1
package obs
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
// TraceContext is the W3C traceparent payload carried across services.
type TraceContext struct {
TraceID [16]byte
SpanID [8]byte
Flags byte
}
func (t TraceContext) Sampled() bool { return t.Flags&0x01 == 1 }
var (
ErrTraceparent = errors.New("obs: invalid traceparent")
ErrNoTraceContext = errors.New("obs: no trace context")
)
// ParseTraceparent accepts only the strict version-00 shape:
// 00-<32 lower hex>-<16 lower hex>-<2 lower hex>.
func ParseTraceparent(s string) (TraceContext, error) {
var tc TraceContext
parts := strings.Split(s, "-")
if len(parts) != 4 || parts[0] != "00" || len(parts[1]) != 32 || len(parts[2]) != 16 || len(parts[3]) != 2 {
return tc, fmt.Errorf("%w: shape or version", ErrTraceparent)
}
for _, part := range parts[1:] {
if !isLowerHex(part) {
return tc, fmt.Errorf("%w: non lower-case hex", ErrTraceparent)
}
}
if _, err := hex.Decode(tc.TraceID[:], []byte(parts[1])); err != nil {
return tc, fmt.Errorf("%w: trace id", ErrTraceparent)
}
if _, err := hex.Decode(tc.SpanID[:], []byte(parts[2])); err != nil {
return tc, fmt.Errorf("%w: span id", ErrTraceparent)
}
if tc.TraceID == [16]byte{} || tc.SpanID == [8]byte{} {
return tc, fmt.Errorf("%w: all-zero id", ErrTraceparent)
}
flags, err := strconv.ParseUint(parts[3], 16, 8)
if err != nil || flags > 1 {
return tc, fmt.Errorf("%w: undefined flag bits", ErrTraceparent)
}
tc.Flags = byte(flags)
return tc, nil
}
func isLowerHex(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return len(s) > 0
}
// FormatTraceparent emits the canonical 55-byte version-00 header.
func FormatTraceparent(tc TraceContext) string {
return fmt.Sprintf("00-%s-%s-%02x", hex.EncodeToString(tc.TraceID[:]), hex.EncodeToString(tc.SpanID[:]), tc.Flags)
}
// TextMapCarrier is the minimal header bag used by Inject and Extract.
type TextMapCarrier interface {
Get(key string) string
Set(key, value string)
}
type MapCarrier map[string]string
func (m MapCarrier) Get(key string) string { return m[key] }
func (m MapCarrier) Set(key, value string) { m[key] = value }
type ctxKey struct{}
func WithTraceContext(ctx context.Context, tc TraceContext) context.Context {
return context.WithValue(ctx, ctxKey{}, tc)
}
func TraceContextFrom(ctx context.Context) (TraceContext, bool) {
tc, ok := ctx.Value(ctxKey{}).(TraceContext)
if !ok || (tc.TraceID == [16]byte{} && tc.SpanID == [8]byte{}) {
return TraceContext{}, false
}
return tc, true
}
type Propagator struct{}
func (Propagator) Inject(ctx context.Context, c TextMapCarrier) error {
tc, ok := TraceContextFrom(ctx)
if !ok {
return ErrNoTraceContext
}
c.Set("traceparent", FormatTraceparent(tc))
return nil
}
func (Propagator) Extract(ctx context.Context, c TextMapCarrier) (context.Context, error) {
tc, err := ParseTraceparent(c.Get("traceparent"))
if err != nil {
return ctx, err
}
return WithTraceContext(ctx, tc), nil
}
// Decision is the local sampling outcome for one trace.
type Decision uint8
const (
Drop Decision = iota
Sample
)
// Sampler is the head-sampling contract; parent is meaningful only when
// hasParent is true.
type Sampler interface {
Decide(traceID [16]byte, parent Decision, hasParent bool) Decision
}
type ratioSampler struct{ threshold uint64 }
// ConsistentRatio samples a fixed fraction of trace IDs. Every service that
// uses the same fraction and ID reaches the same decision without coordination.
func ConsistentRatio(fraction float64) Sampler {
if fraction < 0 {
fraction = 0
}
if fraction > 1 {
fraction = 1
}
const space = uint64(1) << 53
return ratioSampler{threshold: uint64(fraction * float64(space))}
}
func (s ratioSampler) Decide(id [16]byte, _ Decision, _ bool) Decision {
// Top 53 bits avoid float64 precision loss and are stable across services.
if binary.BigEndian.Uint64(id[8:])>>11 < s.threshold {
return Sample
}
return Drop
}
type parentBased struct{ root Sampler }
// ParentBased honours the upstream sampled bit; only root services consult the
// root sampler. This keeps one trace all-or-nothing across services.
func ParentBased(root Sampler) Sampler { return parentBased{root: root} }
func (p parentBased) Decide(id [16]byte, parent Decision, hasParent bool) Decision {
if hasParent {
if parent == Sample {
return Sample
}
return Drop
}
return p.root.Decide(id, Drop, false)
}
测试覆盖四件事:合法与非法 traceparent、10 万 trace ID 的采样份额、父级 sampled 位优先于根采样器、以及 order → inventory → payment 三跳传播后 trace ID 与 sampled 位不变。
// file: internal/platform/obs/sampling_test.go
// tier: L2
// verify: go test ./internal/platform/obs/ -count=1
package obs
import (
"context"
"encoding/binary"
"testing"
)
const validTraceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
func TestTraceparent(t *testing.T) {
tc, err := ParseTraceparent(validTraceparent)
if err != nil || !tc.Sampled() || FormatTraceparent(tc) != validTraceparent {
t.Fatalf("round trip: %+v %v", tc, err)
}
bad := []string{
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7",
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01",
"ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03",
}
for _, in := range bad {
if _, err := ParseTraceparent(in); err == nil {
t.Fatalf("accepted %q", in)
}
}
t.Logf("valid=%s rejected=%d", FormatTraceparent(tc), len(bad))
}
func splitmix64(x uint64) uint64 {
x += 0x9e3779b97f4a7c15
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
return x ^ (x >> 31)
}
func traceID(seed uint64) [16]byte {
var id [16]byte
binary.BigEndian.PutUint64(id[:8], splitmix64(seed))
binary.BigEndian.PutUint64(id[8:], splitmix64(seed+1))
return id
}
func TestConsistentRatio(t *testing.T) {
s := ConsistentRatio(0.10)
hits := 0
for i := uint64(1); i <= 100000; i++ {
id := traceID(i)
if s.Decide(id, Drop, false) != s.Decide(id, Drop, false) {
t.Fatalf("trace %d flipped", i)
}
if s.Decide(id, Drop, false) == Sample {
hits++
}
}
share := float64(hits) / 100000
if share < 0.09 || share > 0.11 {
t.Fatalf("share = %.4f", share)
}
t.Logf("trace_ids=100000 fraction=0.10 sampled=%d share=%.4f", hits, share)
}
func TestParentBased(t *testing.T) {
s := ParentBased(ConsistentRatio(0.5))
id := traceID(42)
if s.Decide(id, Sample, true) != Sample || s.Decide(id, Drop, true) != Drop {
t.Fatalf("parent bit ignored")
}
if s.Decide(id, Drop, false) != ConsistentRatio(0.5).Decide(id, Drop, false) {
t.Fatalf("root decision differs")
}
t.Logf("parent_sample=sample parent_drop=drop root_sampled=%v", s.Decide(id, Drop, false) == Sample)
}
func TestPropagationChain(t *testing.T) {
tc, _ := ParseTraceparent(validTraceparent)
p := Propagator{}
carrier := MapCarrier{}
if err := p.Inject(WithTraceContext(context.Background(), tc), carrier); err != nil {
t.Fatalf("inject: %v", err)
}
var got TraceContext
for i, svc := range []string{"order", "inventory", "payment"} {
next, err := p.Extract(context.Background(), carrier)
if err != nil {
t.Fatalf("%s: %v", svc, err)
}
var ok bool
got, ok = TraceContextFrom(next)
if !ok || got.TraceID != tc.TraceID || !got.Sampled() {
t.Fatalf("%s lost trace context", svc)
}
child := got
for j := range child.SpanID {
child.SpanID[j] = byte(i*8 + j + 1)
}
carrier = MapCarrier{}
if err := p.Inject(WithTraceContext(context.Background(), child), carrier); err != nil {
t.Fatalf("%s inject: %v", svc, err)
}
}
t.Logf("hops=3 trace_id=%x sampled=%v header=%s", got.TraceID[8:], got.Sampled(), carrier.Get("traceparent"))
}
本机运行输出
命令、环境与原始输出如下,完整日志保存在 .local/reports/be-arch-gov/ch06-be-arch-gov-06-2-tracing-sampling-context-cost.txt。
$ go version
go version go1.26.0 darwin/arm64
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/obs/ -count=1 -v -run 'TestTraceparent|TestConsistentRatio|TestParentBased|TestPropagationChain'
=== RUN TestTraceparent
sampling_test.go:33: valid=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 rejected=7
--- PASS: TestTraceparent (0.00s)
=== RUN TestConsistentRatio
sampling_test.go:66: trace_ids=100000 fraction=0.10 sampled=10021 share=0.1002
--- PASS: TestConsistentRatio (0.00s)
=== RUN TestParentBased
sampling_test.go:78: parent_sample=sample parent_drop=drop root_sampled=false
--- PASS: TestParentBased (0.00s)
=== RUN TestPropagationChain
sampling_test.go:108: hops=3 trace_id=a3ce929d0e0e4736 sampled=true header=00-4bf92f3577b34da6a3ce929d0e0e4736-1112131415161718-01
--- PASS: TestPropagationChain (0.00s)
PASS
ok archlab/internal/platform/obs 0.237s
三组读数。解析组,7 种非法输入全部被拒,包括大写十六进制、全零 trace ID、全零 span ID、版本 ff、未来版本 01 和未定义 flag 位;合法 header 往返后逐字节一致。比例组,10 万个 trace ID 中采样 10021 条,份额 0.1002,与 10% 的偏差约 0.02 个百分点,且同一 ID 连续两次决策一致。传播组,三跳后 trace ID 仍是 ...a3ce929d0e0e4736,sampled 位保持 true,最后一跳的 header 中 span ID 已换成子 span,但 trace ID 没有变化。
边界要说明:这是单进程、合成 trace ID、固定 fraction 的测试,不代表生产网络中的 header 丢失率;0.237 秒只是本机运行时间。10.02% 是合成 ID 的统计结果,真实 trace ID 的分布需要用自己的样本重跑。尾部采样没有在本节的代码里实现,因为它的缓冲、按 trace 聚合和窗口预算属于 collector 侧;本节只固定“头部决策必须一致、上下文必须过河”这两个前提。
常见错误与边界
- 每个服务自己写采样算法。哈希函数、取模位数、随机种子任一不同,同一条 trace 就会在不同服务分叉;比例采样必须基于 trace ID 的同一段比特。
- 在 goroutine 里重新
context.Background()。这会新建 trace 并切断 parent 关系;所有异步调用都应显式传入上游 ctx。 - 只传播
traceparent,不处理解析失败。坏 header 应该被计数并新建 trace,而不是把空值继续传给下游,制造更多断链。 - 把 baggage 当通用存储。它随每个请求复制到所有下游,成员数、单值和总长都必须有上限,敏感信息不应放进去。
小结
追踪成本由“每秒 span 数 × 单 span 体积 × 保留时长”决定,采样是唯一能在入口处控制它的杠杆。头部比例采样便宜但可能丢错误,尾部采样能保留错误与慢请求但要缓冲完整 trace,父级透传则保证同一条 trace 不在中途分叉。三者不是互斥选项,而是不同位置的决策。
一致性比例采样把 trace ID 的固定 53 位映射到阈值,本机 10 万 ID 得到 10.02% 的份额,且跨服务无需协调。上下文传播则要守住 goroutine、异步消息和 header 解析三个断点。下一节把这些信号放回一次真实排障:当指标、日志和追踪各自指向不同服务时,如何按证据强度和时序排出候选原因。
阅读导航:上一节:6.1 日志、指标与追踪的统一语义约定 · 下一节:6.3 从信号到诊断:一次跨服务排障复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。