先到的不是告警,是账单

接入 OpenTelemetry 的前三个月一切美好:链路全通,排障顺手。第四个月开始,味道不对了:

  • Trace 后端的查询从 200ms 涨到 8s,span 列表页转圈;
  • 存储账单环比涨了 130%,增长曲线还在陡;
  • Grafana 上 spanmetrics 的序列数过了千万,Prometheus 内存吃紧。

最能解释问题的是一条真实的 span:

{
  "name": "GET /users/81273/orders?cursor=eyJwYWdlIjo...(全长 400+ 字符)",
  "attributes": {
    "user.id": "81273",
    "http.url": "https://api.example.com/users/81273/orders?cursor=eyJwYWdlIjo...",
    "error.message": "order 99183 not found for user 81273"
  }
}

一条 span 三个炸弹:span name 里带了用户 ID 和游标参数,URL 原样入库,异常消息把业务数据搬了进来。无论后端是 Jaeger/ClickHouse 这类给属性建索引的,还是 Tempo 这类存全量再检索的,高基数属性都会实打实拖垮查询——前者炸索引,后者炸块体积和扫描时间。

基数是一场乘法

指标后端的存储单位是时间序列,一条序列等于一组唯一的标签组合,序列数是各维度基数的乘积,不是加法:

span name:   480,000   (动态 URL 直接当 span name 的代价)
http.method:       4
status.code:       3
version:          12   (每次发布叠一个)
region:            3
----------------------------------------
480000 × 4 × 3 × 12 × 3 ≈ 2 亿条序列(理论上限)

即使其中九成的序列一小时才来一个点,后端也要为每条序列维护索引和存储。乘法的可怕之处在于:任何一个维度失控,整体就崩,其他维度治理得再好也没用。对照低基数版本的同一组维度:

span name:      180   (路由化之后)
http.method:       4
status.code:       3
version:          12
region:            3
----------------------------------------
180 × 4 × 3 × 12 × 3 = 77,760 条序列,差三个数量级

账单出来后的两个直觉方案都不行:把采样率调低,索引维度一个没少,账单只是涨得慢点,错误可见性先受损;把属性删光,索引是瘦了,trace 也失去了"定位那一个请求"的价值。正确路线是分层:trace 链路保检索、metrics 管道收敛维度,两层各自处理。

span 属性和 metric label 是两种身份

同一份数据在两个场景里的需求完全相反:

  • trace 场景:user.id 是定位"那个用户的那个请求"的钥匙,有价值,该留;
  • metrics 场景:span 经 spanmetrics 导出成 RED 指标后,属性变成 label,user.id 会让序列数等于用户数。

所以出路不是删光属性,而是在 spanmetrics 这条边界上做维度白名单。顺带一个少有人读的细节:spanmetrics connector 对每个 dimension 有 dimensions_cache_size(默认 1000)的截断保护,唯一值超过缓存后新值不再生成序列。这是防雪崩的保险丝,也是统计失真的来源——被截断的调用从指标里直接消失,错误率看起来"变好"了。保险丝的存在本身就说明默认行为不可持续。

第一层卫生:span name 就该是路由

SDK 侧两条纪律,成本最低、收益最大,改的是"生成"而非"补救":

// 错误:动态值进 span name,一天制造几十万个"不同操作"
// _, span := tracer.Start(ctx, "GET /users/81273/orders")
// _, span := tracer.Start(ctx, fmt.Sprintf("checkout failed: %s", err))

// 正确:span name 是稳定操作名,动态部分放属性
_, span := tracer.Start(ctx, "GET /users/{id}/orders")
defer span.End()

span.SetAttributes(
    // 低基数:枚举值,适合做聚合维度
    attribute.String("user.plan", plan), // free | pro | enterprise
    // 高基数:仅用于检索,绝不进 metrics 维度
    attribute.String("user.id", userID),
)

Web 框架基本都能拿到路由模板(Gin 的 c.FullPath(),HTTP 语义约定里的 http.route),别用原始 URL。异常消息不要拼进 name,放进 status description 或 event,并且入库前过一遍脱敏。

第二层卫生:Collector 统一收口

历史代码改不动时,Collector 是唯一的全局收口点。attributes processor 管删与哈希,transform processor(OTTL)管模式替换:

processors:
  attributes/hygiene:
    actions:
      - key: authorization        # 认证头整个删掉,日志和追踪都不该存
        action: delete
      - key: http.request.header.cookie
        action: delete
      - key: user.id              # 需要关联但不需要明文:一致性哈希后仍可等值检索
        action: hash

  transform/normalize:
    error_mode: ignore            # 单条语句失败不阻断整个 pipeline
    trace_statements:
      - context: span
        statements:
          # /users/12345 -> /users/{id}:收敛高基数路径
          - replace_pattern(attributes["url.path"], `^/users/\d+$`, "/users/{id}")
            where attributes["url.path"] != nil
          - replace_pattern(attributes["url.path"], `^/orders/[0-9a-f-]{36}$`, "/orders/{id}")
            where attributes["url.path"] != nil
          # span name 同步路由化,兜住不改代码的老服务
          - replace_pattern(name, `^GET /users/\d+/orders$`, "GET /users/{id}/orders")
          # 异常事件里的邮箱打码
          - replace_all_patterns(events, "event.attributes[\"error.message\"]",
                                 `[\w.+-]+@[\w.-]+`, "***")

然后是 spanmetrics 的维度白名单,这是 metrics 管道的闸门:

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [5ms, 25ms, 100ms, 500ms, 1s, 5s]   # 延迟桶按自家 SLO 定
    dimensions:
      - name: http.method
        default: "unknown"
      - name: http.route          # 只放路由化后的低基数字段进指标
        default: "unmatched"
    exemplars:
      enabled: true               # 指标带 exemplar,可一键跳回 trace
    dimensions_cache_size: 5000

service:
  pipelines:
    traces/ingest:                # 全量流:只做卫生处理,喂指标
      receivers: [otlp]
      processors: [attributes/hygiene, transform/normalize, batch]
      exporters: [spanmetrics]
    traces/store:                 # 采样流:决定哪些 trace 长期留存
      receivers: [otlp]
      processors: [attributes/hygiene, transform/normalize, tail_sampling, batch]
      exporters: [otlp/lts]
    metrics:
      receivers: [spanmetrics]    # connector 的 metrics 输出端
      exporters: [prometheus]

注意双管道的分工:指标从采样之前的全量流生成,留存从采样之后的流走。为什么必须这样拆,下一节见分晓。

采样:head 管成本,tail 保证据

  • head-based:请求入口按 trace ID 概率决定,决策早、零缓冲成本,但对结果一无所知——错误和慢请求与正常请求同比例被丢弃;
  • tail-based:等 trace 结束后按结果决定,能保证"错误全留、慢请求全留、其余低比例",代价是 Collector 要缓冲整条 trace,且一条 trace 的所有 span 必须到同一个 Collector 实例(用 load-balancing exporter 按 trace ID 分流),否则决策就是瞎子。
processors:
  tail_sampling:
    decision_wait: 10s        # 等后端 span 到齐再决策,按最慢链路调
    num_traces: 150000        # 内存中同时跟踪的 trace 上限,按内存预算调
    policies:
      - name: errors          # 错误 trace 100% 留存
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow            # 超 1.5s 的全留
        type: latency
        latency: { threshold_ms: 1500 }
      - name: baseline        # 其余 5% 保底,保留总体形状
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

head 侧只做一件事,且别画蛇添足:ParentBased 包住比例采样,根 span 按比例、子 span 一律跟随父决策。SDK 默认就是 ParentBased,手动拆掉它,trace 必断链。

// Go SDK:根 span 10% 采样,子 span 跟随父决策
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)))

采样与指标的口径陷阱

回到双管道的问题。tail 采样"错误全留、正常留 5%“之后,如果指标从留存样本生成,错误率会被放大二十倍——留存比例不是均匀的,样本就不是无偏的。三条纪律:

  • RED 指标一律在采样前的全量流上生成(上面的 traces/ingest),别从留存样本反推;
  • 所有 Collector 实例的采样比例必须同源管理、同一次下发。5% 和 20% 并存时,聚合出的错误率没有意义,而且从图表上几乎看不出来——这就是"配置漂移制造错误率假象”;
  • 采样配置变更当作观测性事件记录下来,指标口径在那一天前后不可比。

治理:新增属性按 schema 变更对待

工程手段之外,基数问题最终是流程问题:

  • 新增 span 属性或指标 label 走评审,只问三个问题:基数上限多少?进不进 metrics 维度?含不含个人信息?答不上来的不过;
  • 基数看板直接用 Prometheus 的 TSDB 状态接口,别靠等账单:
# 序列数最多的 metric 名,前 20——spanmetrics 榜上有名就该行动了
curl -s http://prometheus:9090/api/v1/status/tsdb \
  | jq -r '.data.seriesCountByMetricName[:20][] | "\(.value)\t\(.name)"'

# 单个 metric 内哪个维度在膨胀:按 span_name 数序列
curl -sG http://prometheus:9090/api/v1/query \
  --data-urlencode 'query=topk(10, count by (span_name) (traces_span_metrics_calls_total))'
  • 告警打在序列增长率上而不是绝对值上:某 metric 一天新增五万条序列,无论基线多少,都该有人被叫醒。

常见坑点

1. 动态值直接当 span name

URL、用户 ID、异常消息进 name,等于把第一聚合维度变成无限基数。这是所有后端共同的第一杀手。

2. 异常整段进属性

error.message 带着参数和用户数据入库,既是基数炸弹也是隐私泄漏。保留错误码级别的分类,消息打码或截断后入库。

3. 采样口径漂移

指标在采样后生成、或各实例采样比例不一致,错误率和延迟分布都是假象。双管道加配置同源,缺一个都算埋雷。

4. 把 dimensions_cache_size 截断当保护

保险丝烧断时被截断的值从指标里消失,某个路由的 QPS 会"神秘归零"、错误率"莫名变好"。看到这种曲线先查截断,再查应用。

5. 删属性只影响未来

Collector 层的 delete/hash 改不了已经入库的历史。合规要求清除历史数据时,得走后端自己的删除机制,收集器层的事后补救无效。

6. 脱敏依赖后端功能

后端的过滤规则随时会变,换后端更是常见。SDK 和 Collector 层的入口过滤是唯一跟着代码走、不依赖外部承诺的一层。

总结

  • 基数是乘法,一个失控维度足以拖垮整体,治理优先级永远先找最大的那个因子;
  • span 属性服务检索,metric label 服务聚合,两者在 spanmetrics 这条边界上收敛成白名单;
  • 规范化、采样、脱敏在 SDK 和 Collector 层闭环,不外包给后端;
  • 新增维度按 schema 变更对待,看板盯增长率而不是绝对值。

可观测性的查询速度和账单,在你往 span 里塞第三个高基数字段的那一刻就已经确定——只是三个月后才寄到。


span 属性是用来回答"那一个请求发生了什么"的,不是业务数据库的镜像。