先到的不是告警,是账单
接入 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 属性是用来回答"那一个请求发生了什么"的,不是业务数据库的镜像。