采样 · 头部还是尾部
追踪的成本随流量线性增长,所以必须采样。这一章讲清头部采样与尾部采样的根本取舍——前者便宜但会漏掉出问题的那条,后者能「只留有意思的」但代价高昂,以及为什么尾采样必须在 gateway 且必然有状态。
采样 · 头部还是尾部
第 0 部分讲成本模型时说过:追踪的数据量随流量线性增长。10 万 QPS 全量采,一天几百亿个 span——没人付得起。
所以必须采样。而“怎么采”是追踪工程里最重要的决策。
两种时机,两个世界
区别只有一个:在哪个时刻决定“这条 trace 要不要留”。
| 头部采样(head) | 尾部采样(tail) | |
|---|---|---|
| 决定时刻 | trace 一开始(第一个 span) | trace 结束后(所有 span 都到齐) |
| 依据 | 只有随机数 / trace_id | 整条 trace 的全貌(慢不慢?错没错?) |
| 成本 | 极低(没被采的根本不产生) | 高(所有 span 都要先收上来、缓存住) |
| 能“只留有意思的”吗 | ❌ 不能 | ✅ 能 |
| 在哪做 | SDK(应用里) | 必须在 gateway |
| 有状态吗 | 无状态 | 有状态(要缓存 span 等齐) |
这张表的每一行都是同一个根本矛盾的投影:你要在“决定得早(便宜)”和“决定得对(信息全)”之间选。
头部采样:便宜,但会漏掉重要的
网关收到请求 → 掷骰子:1% 概率 → 决定"采"
→ 把决定写进 traceparent 的[采样位](/manual/observability/traces-basics)
→ 下游所有服务照做(不采的连 span 都不产生)头部采样最大的优点是便宜**:没被采中的 trace,span 压根不会被创建——不占 CPU、不占内存、不占网络。这是“零成本”级别的便宜。**
但它有个致命缺陷:决定的时候,你还什么都不知道。
1% 采样率下:
- 100 个正常请求 → 采到 1 个 → 挺好
- 1 个超慢的请求(5 秒!)→ 99% 概率不采 → 你永远不知道它发生过而“那个出问题的请求”恰恰是你唯一想看的。 你付钱存下了 99 个“一切正常”的 trace,却漏掉了那个唯一有价值的。
这就是头部采样的根本问题:它对“有趣”和“无聊”一视同仁——因为在决定的那一刻,它还不知道这条 trace 会不会出问题。这和“未知的未知”是同一类困境:你没法预先知道该留什么。
缓解手段(都不完美):
- 对错误请求提高采样率 —— 但错误是在最后才知道的,而决定在最开始。有些 SDK 支持“事后补救”(先记录、延迟决定),但那已经在往尾采样靠了。
- 按路径分层采样 —— 重要接口 100%、健康检查 0.01%。这是最实用的头部采样优化,而且不需要任何额外基础设施。
尾部采样:能只留有意思的,但很贵
所有 span 都发到 Collector gateway
→ gateway 把同一 trace_id 的 span 缓存起来(等!)
→ 等到 trace "看起来完整了"(或超时)
→ 现在看全貌:有错误吗?超过 1s 吗?
→ 决定留还是丢尾部采样的价值是你可以按结果决定**——这是质的飞跃:**
# tail_sampling processor 的典型策略
policies:
- name: errors # 所有错误的 trace,100% 留
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow # 所有慢的,100% 留
type: latency
latency: { threshold_ms: 1000 }
- name: normal # 正常的,只留 1%
type: probabilistic
probabilistic: { sampling_percentage: 1 }“错的全留、慢的全留、正常的采样”——这几乎就是你想要的一切。 存储成本大降,而信息价值几乎没损失(那 99% 被丢掉的正常 trace,本来也没人看)。
代价极其现实,三条都很痛:
① 所有 span 都必须先发出来 —— 尾采样是在 gateway 决定的,所以应用必须先把 100% 的 span 发给它。你省的是“后端存储”,没省“应用的埋点开销 + 网络传输”。 这和头部采样“压根不产生”的便宜完全不是一回事。
② 必须缓存等齐,于是有状态 —— gateway 要把一条 trace 的所有 span 攒在内存里,直到它“完整”。这带来:
- 内存开销 —— 攒的是所有 trace(不只是最后会留下的那 1%)。
decision_wait的两难 —— 等多久算“完整”?等太短会把慢 trace 拦腰截断(讽刺的是:慢 trace 恰恰是你最想留的,却最可能因为超时被截断);等太长内存吃不消。- 有状态就不能随便扩副本 —— 这是最大的坑,见下。
③ 必须保证“同一 trace 的 span 落到同一个 gateway 实例”
这是尾采样部署时最容易搞砸的一点。如果 gateway 有 3 个副本、前面挂个普通负载均衡:
span A(trace=abc)→ 副本 1
span B(trace=abc)→ 副本 2 ← 灾难!
span C(trace=abc)→ 副本 3每个副本都只看到 trace 的一部分,谁也没法做出正确决定。 结果是:要么各自瞎决定(trace 被撕碎),要么全都留(等于没采样)。
解法:必须按 trace_id 做一致性路由——用 loadbalancing exporter(它按 trace_id 哈希路由到固定副本)组成两层 gateway:第一层只负责按 trace_id 路由,第二层做尾采样。
这就是上一部分说“尾采样必须在 gateway”的完整含义——不只是“要集中”,而是“要按 trace_id 一致性地集中”。很多团队第一次上尾采样都栽在这里:配好了策略,发现 trace 全是碎的。
怎么选
几条实战经验:
- 最好的采样是不采样 —— 如果你的流量不大,全量存。别为了“最佳实践”给自己加复杂度。采样是被成本逼出来的,不是美德。
- 头部采样 + 按路径分层,能解决 80% 的问题 —— 而且零基础设施成本。先做这个,不够了再上尾采样。
- 上尾采样前,先确认你真的需要它 —— 它带来一整套有状态服务的运维负担(扩缩容、一致性路由、内存调优)。如果你只是想“别漏掉错误的 trace”,很多 SDK 支持“错误请求提高采样率”,可能就够了。
- exemplar 是个被低估的中间道路 —— 下一章讲:它让你从指标曲线直接跳到那一刻的某条真实 trace,而不需要“存下所有 trace”。用聚合的便宜,换到了个案的入口。
小结与下一章
追踪成本随流量线性增长,必须采样。根本矛盾:决定得早(便宜) vs 决定得对(信息全)。 头部采样(trace 一开始就掷骰子,靠 traceparent 的采样位传播):极便宜(没采中的 span 压根不产生),但致命缺陷是决定时什么都不知道——1% 采样下,那个 5 秒的慢请求 99% 会被漏掉,而它恰恰是你唯一想看的;最实用的优化是按路径分层(重要接口 100%、健康检查 0.01%),零基础设施成本。尾部采样(等 trace 齐了看全貌再决定):能做到“错的全留、慢的全留、正常的 1%”——信息价值几乎无损。但三条代价:①所有 span 仍要先发出来(省的是存储,不是埋点和网络);②必须缓存等齐 → 有状态;decision_wait 两难(等太短会截断慢 trace,而慢 trace 恰恰最该留);③必须按 trace_id 一致性路由(否则副本各看一半,trace 全碎)——要用 loadbalancing exporter 做两层 gateway,很多团队第一次上尾采样都栽在这。选型:最好的采样是不采样(采样是被成本逼的,不是美德)→ 不够就头部+分层 → 再不够才上尾采样。
有没有办法“用聚合的便宜,换个案的入口”?有——下一章讲那座最巧妙的桥。👉 exemplar。
参考资料
- [1] OTel Collector contrib:
tailsamplingprocessor、loadbalancingexporter文档。 - [2] OpenTelemetry 官方文档:Sampling(head vs tail)。
- [3] 关联三大支柱的成本模型、agent/gateway 部署、上下文传播。