埋点哲学:粒度、采样、基数爆炸与成本模型
两年前一个周三的凌晨三点,我所在团队的值班 SRE
收到了一条奇怪的告警:Prometheus 的
prometheus_tsdb_head_series 指标在 40 分钟内从
120 万飙升到 810 万,紧接着 OOM Kill。告警升级到
PagerDuty,值班人爬起来一看——所有 Grafana Dashboard 全是
No data,全公司的服务(prod、staging、dev)全部进入可观测性盲区。
我们花了两个小时追查根因。不是攻击,不是流量暴涨,不是基础设施故障。起因是一个刚上线的
Python 微服务在它的 /metrics endpoint 中,把
HTTP 的 X-Request-Id header 写进了 Prometheus
Counter 的 label。一个 UUID 值就是一个全新的标签组合,在 300
QPS 的服务上每秒钟产生 1200 条新的时间序列。40
分钟后,Prometheus 被 690 万条新增 series 撑爆了内存。
这次事故最终用一笔账单收尾:Prometheus 的内存规格紧急从 64 GB 扩到 256 GB,加上那晚 on-call 的加班费、事后三天停产做 label 审计的成本,折算下来大约十五万人民币。事后复盘时,一个同事在 Confluence 上写了一句话:“埋点就是把枪。给一个没有任何指导的新人一把 AK47,他能在十分钟内把你的监控系统彻底打穿。”
这句话基本概括了”埋点哲学”试图解决的问题。埋点不是”多加几行 print”或者”把 OpenTelemetry SDK 集成进去就完了”——它是可观测性工程中最接近”系统设计”的一层决策。什么信号放在哪一层、用什么频率采集、哪些数据应该被立刻丢弃、哪些数据值得花十倍成本保留——这些决策的下游影响直达存储成本、查询延迟、故障恢复速度,以及值班工程师凌晨三点被叫醒时是花了五分钟还是五个小时找到根因。
更关键的是,埋点决策是不可撤回的。你今天因为”反正是全量采集”而打进去的高基数
label,要在几个月后才能通过重新发版去掉——但存储账单每个月都会准时抵达。你今天为了省成本把采样率压到
0.01%,当两周后一次生产事故发生时,你发现那条唯一的
status_code=500 的 Trace 正好落在这 99.99%
被丢弃的数据里——根因永远找不到了。
埋点哲学需要回答三个层层递进的问题。第一,信号应该放在哪一层——Trace Span 还是 Prometheus Metric?结构化日志还是 Event?Profile 还是 System Log?第二,放在这一层之后,如何控制它的成本——采样率多少?基数怎么限制?保留期多长?第三,怎么让团队在没有人盯着的时候也遵守这份规范——光写文档没用,必须有自动化检查。
一、可观测性的信号分层
埋点之前必须搞清楚的第一件事不是”用什么工具”,而是”信号放在哪一层”。把请求级别的信息打进 Metrics、把基础设施级别的信息写进 Span attribute、把业务指标用日志承载——这些错位带来的代价远不止账单。更致命的是排障时的”信噪比崩塌”:当几千条无关的信息淹没掉真正有价值的几条信号时,有可观测性比没有更糟——你不仅要定位根因,还要在一座信息垃圾山里挖洞。
信号分层的原则可以归结为一句话:每一层的设计都应该围绕该层的典型查询模式展开。你在排查”为什么 p99 延迟突然飙升”和”过去 24 小时内有多少用户注册”时,访问的数据类型、聚合粒度、时间窗口完全不同。信号分层就是提前把这些查询路径想清楚,而不是出了事再临时拼凑。
1.1 请求级(Request-level):每次请求一条命
请求级信号是每一个独立请求的完整上下文。Trace Span 是这一层最核心的载体——它不是”日志的一种”,而是一个携带精确时序信息、父子关系、属性上下文的分布式调用记录。一个 Span 的核心字段:
- 标识字段:
trace_id(32 hex 字符)、span_id(16 hex)、parent_span_id(根 Span 为空) - 操作字段:
name(如POST /api/checkout或redis.GET)、kind(SERVER/CLIENT/PRODUCER/CONSUMER/INTERNAL) - 时间字段:
start_time_unix_nano、end_time_unix_nano,精度到纳秒 - 状态字段:
status.code(OK/ERROR)、status.message - 上下文字段:
attributes(键值对 map)、events(带时间戳的离散事件)、links(跨 Trace 引用)
这里有一个容易被忽视的设计细节:Span 的
attributes 和 events
是两个独立概念。attributes 描述的是这个 Span
整个生命周期内都有效的元数据——“这个请求的 HTTP method 是
POST”、“目标服务是 checkout”。events 是 Span
时间线上的带时间戳的离散事件——“在第 342ms 时向 Redis 发起了
GET 操作”、“在第 891ms 时收到了 MySQL
响应”。把静态元数据写成 event(“每个 event 都写上
http.method=POST”)、把单次操作写成 attribute——这种混乱会让
Span 在 Jaeger 或 Tempo 的 UI 上变得难以阅读,也会让
attribute 数量膨胀。
请求级数据的量级正比于 QPS 和微服务调用深度。一个 5000 QPS 的 API Gateway,每个请求平均经过 6 个后端服务,每个服务产生约 4 个 Span(一个 SERVER + 三个 CLIENT 调用),总共是每秒 125000 个 Span,一天 108 亿个。没有任何存储系统能在不采样的情况下处理这个量级还保持秒级查询延迟。
请求级信号可以携带请求上下文中的任意字段,但这里的”可以”本身就是最大的坑。把
Response Body 全量打进 Span attribute——一次 JSON body 平均
50 KB,在 5000 QPS 下一天就是 21.6 TB 的额外 attribute
数据。而且 body 里可能包含用户名、银行卡尾号、身份证号——PII
泄露管道。正确做法是只保留几个经过审慎挑选的关键维度:http.method、http.status_code、endpoint、service.name、service.version,加上少数业务关键字段——比如订单
ID 的前 8
位哈希前缀(用于粗粒度过滤但不能反向推断原始值)。
Request Log 在 OpenTelemetry
生态中的角色正在被重新审视。在 OTel
普及之前,团队通常在每个请求的入口和出口各打一行结构化日志,携带
request_id 和业务字段。但当你已经为每个请求产出
Span 之后,Span Event
就是天然的带时间戳日志——它们在数据模型层面(携带
timestamp、name、attributes)和 Request Log
几乎等价,只是载体从日志管道切换到了 Trace 管道。OTel
社区的共识正在向”用 Span Event 替代 Request
Log”倾斜,但这需要满足一个前提:Trace
的采样率必须足够高——如果你头部采样率是
1%,而你要查的是一个”成功率 99.99%“的罕见错误,那 1%
的采样可能覆盖不到它。
1.2 基础设施级(Infrastructure-level):与请求脱钩的底层
基础设施级信号是所有与请求量解耦的底层指标:CPU 利用率、内存使用量、磁盘 I/O、网络吞吐、文件描述符数量、Socket 连接数、内核中断频率、OOM Kill 事件。这一层的典型载体是 Prometheus node_exporter + kube-state-metrics + cAdvisor,以及 syslog/journald。
以一个 500 节点的 K8s 集群为例做一次粗略的容量估算。node_exporter 每节点暴露约 500 个指标,乘以 500 节点是 25 万条 series。kube-state-metrics 跟踪约 2000 个 Pod,每个 Pod 约 10 种 label × 3 类资源指标,约 6 万条 series(前提是 Pod 数量稳定——滚动发布期间 churn 会暂时推高)。cAdvisor 为每个容器暴露约 50 个指标,总计约 10 万条。三者合计约 40 万条 series——在 Prometheus 单实例的经验安全区内(100 万 series 是默认上限)。前提是你别把 Pod name、Container ID 这类滚动发布就会变的标识符打成 label。这些字段应该放在日志或 K8s metadata API 中查询,因为它们的变化频率远远高于 Prometheus 的采集间隔。
基础设施信号的一个核心特征是它与业务语义无关。Prometheus
的 node_cpu_seconds_total
不知道、也不应该知道这个 CPU
周期是在处理支付请求还是在跑定时任务。当 node_exporter
展示某个节点 CPU 达到 92%
时,你不能直接得出”支付服务慢了”的结论——你只知道一个节点的
CPU 很忙,需要通过其他层次(应用级 Metrics 或
Traces)去确认是否影响了业务。这种”分层不越界”的纪律在告警设计中尤为重要——你不能用基础设施告警直接当业务告警用。
K8s Event 是基础设施级信号中一个特殊的类别:Pod
Scheduled、Image Pull Backoff、OOM Kill、Liveness Probe
Failed、Node Not Ready——它是纯事件流,不是时间序列。K8s
Event 的最佳归宿不是 Prometheus(它不是
metric),而是日志系统(Loki 或
Elasticsearch)或者事件总线(CloudEvents)。如果你只用
kubectl describe pod
去看事件,你实际上在做”手动可观测性”——每次故障都要等有人醒了登录集群才能看到关键事件。把
K8s Event 接入 Loki
意味着在凌晨三点告警触发的同一时刻,你能在 Grafana 上看到
“Pod checkout-xxx was OOMKilled at
03:14:22”——这几秒的延迟差,可能就是 10 分钟排障和 1
小时排障的区别。
1.3 应用级(Application-level):横跨业务与技术的中间层
应用级信号横跨业务与技术之间。它回答”从业务角度系统是否正常”——订单量同比/环比、支付成功率、推荐列表 CTR、用户注册速率、API 调用次数的 per-customer 分布——这些问题既不完全是底层基础设施的,也不完全是单个请求的。
应用级信号的本质特征是预聚合。你在应用代码里不是每次请求产出一条独立记录,而是先在内存中聚合——Counter
累加、Histogram 分桶、Gauge
更新当前值——然后以固定频率(15-30 秒)通过
/metrics endpoint 暴露给 Prometheus
scrape。这种模式将数据量与 QPS 完全脱钩:一个 10000 QPS
的服务和一个 10 QPS 的服务,如果暴露相同数量的
Counter/Histogram,它们的 Prometheus series
数量是相同的——这就是为什么 Metrics
是成本最低但信息密度最高的一层。
RED 方法论(Rate / Errors / Duration)和 Google 四黄金信号(Latency / Traffic / Errors / Saturation)构成了这一层的基线。但有一个在埋点场景下特别容易出错的问题:Histogram 的 bucket 设计。
Prometheus Histogram 的存储成本正比于
bucket 数量 × 标签组合数。如果你为每个 HTTP
endpoint 定义一个 50-bucket 的 latency
histogram,同时标签中有 method(5 种)×
status_code(约 10 种常见值)×
endpoint(可能 200 个),那么仅这一个 latency
histogram 的 storage footprint 就是
5 × 10 × 200 × 50 = 500000 条
series。而这只是一个服务的一个指标。如果 50
个微服务都这样设计,Prometheus
在没有任何”错误埋点”的情况下就会撑不住。
解决方法有三步。第一步,减少 bucket 数量——对大多数 HTTP
服务,15 到 20 个精心选择的 bucket 就能覆盖从 1ms 到 10s
的延迟范围。例如
[.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10]
这 11 个 bucket 已经覆盖了大多数 Web
服务的延迟分布。第二步,减少 label
维度——status_code 用聚合
status_class="2xx|4xx|5xx"
代替详细状态码,除非你真的需要区分 429(rate limit)和
503(服务不可用)的延迟差异。第三步,用 Recording Rules
把原始 histogram 预聚合为分位数查询结果——原始数据保留 7
天,聚合结果保留 90 天。
另一个常见陷阱是把应用级信号当请求级信号用。如果你在
handler 里每次请求都创建一个 Prometheus Counter 并打上
user_id label:
REQUEST_BY_USER = Counter('http_requests_by_user', '...', ['user_id'])
REQUEST_BY_USER.labels(user_id=str(current_user.id)).inc()这是双重灾难。Prometheus series 数在几分钟内正比于活跃用户数起飞——一个百万 DAU 的应用会产生数百万条 series。同时每个用户 ID 都暴露在了 Prometheus label 中,任何有 Grafana 访问权限的人都能在 PromQL 查询结果中看到 per-user 数据——在大多数公司这是一个 PII 合规红线。
正确做法:把 user_id 放在 Span attribute
或日志字段中(Trace/Log 系统承载高基数),Metrics
层面只保留聚合维度——user_type="premium|free"、region="us|eu|asia"。
1.4 剖析级(Profiling-level):不需埋点也能发现问题
剖析级信号是整个栈中最”重”但最有穿透力的一层:CPU Profile、Heap Profile、Goroutine Profile、Lock Contention Profile、Off-CPU Profile。它们的采集单位不是请求,而是时间采样——每隔 N 毫秒取一次当前调用栈快照——或者是事件触发——每次内存分配超过 N KB 时采样一次。
剖析级信号的独特价值在于它不需要开发者提前预判”哪里可能慢”。Profiler 在后台持续采样,当 CPU 热点从一个函数漂移到另一个函数时,这个变化自动出现在持续采集的火焰图中。这就是为什么 Continuous Profiling 能在不需要事先猜测的前提下发现性能退化:它不是”我怀疑这里有 bug 所以我来看”,而是”我在这里一直看着,出了变化我自然知道”。
各语言 Profiling 的生产开销实测数据:
| 语言 | Profiler | 频率 | CPU 开销 | 内存开销 |
|---|---|---|---|---|
| Go | pprof CPU | 100 Hz | 1%–3% | < 50 MB |
| Go | pprof heap | 全量 dump | STW 0.5–3s | 临时 ~2× heap |
| Java | async-profiler CPU | 100 Hz | < 1% | < 100 MB |
| Java | JFR default | 默认 | < 1% | < 50 MB |
| Python | py-spy | 100 Hz | 1%–5% | < 30 MB |
| Rust | pprof-rs | 99 Hz | < 1% | < 20 MB |
持续 CPU profiling 在 49–100 Hz 范围内对绝大多数生产环境是安全的(< 3% 开销)。Heap profiling 的开销更大,特别是全量 dump 的 STW 时间——因此一般建议以较低的采样间隔(如 Go 默认的每 512 KB 分配采样一次)来做,且决不在 QPS 峰值时段手动触发全量 heap dump。
1.5 分层的关键不是隔离,而是关联
把四层信号分清楚之后,最重要的一句话反而是:分层不是为了把数据装进不同的筒仓,而是为了在排障时能沿着一个 trace_id 从一层跳到另一层。
还原一条真实排障路径:凌晨 2:14,Grafana 上 checkout
服务的 p99 延迟从 120ms 飙升到 2.3s(应用级 RED Dashboard)→
点击时间线上的 exemplar 跳转到 Tempo 中这条慢 Trace
的详细视图(请求级 Trace)→ 发现 redis_get_cart
span 花了 850ms → 点击 Tempo “Related Logs” 跳转
Loki,看到同 trace_id 下一条日志
redis: connection pool exhausted, waiting 843ms(应用/基础设施日志)→
打开 Pyroscope 看过去 15 分钟的 heap
profile(剖析级),发现内存使用持续爬升,GC
频率增加(runtime.gcDrain 占用 15% CPU)→ 切回
Prometheus node_exporter(基础设施级),确认节点内存使用率
92%——最终根因是内存泄漏导致 GC 压力,GC 暂停拖慢了 Redis
连接池归还速度。
这条链条跨了 Metrics、Trace、Log、Profile、node_exporter——五种信号、三种存储后端(Mimir + Tempo + Loki + Pyroscope + Prometheus),每一跳都是无缝的。让它无缝的唯一前提是:trace_id 被一致注入到每一层。这个单个键的质量,决定了可观测性栈是”一个完整系统”还是”N 个互不说话的独立工具”。
而这个关联键的最脆弱环节不是存储,不是查询,是传播(Propagation)。当请求从
API Gateway → Auth → Order → Payment → Notification
这条链路上的每一跳,trace_id 都必须通过 HTTP
header(traceparent)、gRPC metadata
或者消息队列的 message attributes
被透传。任何一个环节如果用了
context.Background() 而不是传入的
ctx,或者消息队列 SDK 没有自动携带 trace
context——整条链路在这里断开。本章第六节会详细拆解这类断裂的真实场景和诊断方法。
1.6 埋点开销的量化模型
埋点决策最终都可以折算成金钱。把每一层的开销做一次粗略量化,有助于在”多埋一点”和”少花一点”之间做出有数据支撑的选择。以下模型基于一个中等规模的 SaaS 公司:200 个微服务、5000 个 Pod、总 QPS 约 50000、数据保留 30 天。
请求级(Trace)。全量采集的话,50000 QPS × 平均 10 Span/请求 × 86400 秒 = 每天 432 亿个 Span。按每个 Span 平均 1.5 KB(含 attribute、event、link 的 protobuf 编码),每天约 64.8 TB 的原始数据。即使压缩后(ZSTD 约 3:1),每天仍需 21.6 TB 对象存储。按 S3 标准存储 $0.023/GB/月计算,仅存储就 $15,000/月——还没算 Collector 的计算成本。如果合理采样(头部 1% + 尾部错误保留),数据量可以降到约 1/50:每天约 0.43 TB,存储成本降至 $300/月。
基础设施级(Metric)。5000 Pod 各暴露约 800 个指标(node_exporter + cAdvisor + kube-state-metrics + 应用指标),总计 400 万条 series。按每个 series 日增约 2 KB(15s 间隔 × 5760 数据点 × 每个数据点约 4 bytes),每天约 8 GB 新数据,30 天约 240 GB。Prometheus TSDB 经过 Gorilla 压缩后实际磁盘占用约 60–80 GB——成本约 $5–15/月。这就是为什么 Metrics 是最便宜的信号。
应用级(Custom
Metric)。如果每个服务合理控制基数(< 100 个
label 组合 × 20 个指标),200 个服务总计约 40 万条
series——在基础设施级之下是可控的。但如果某个服务不加控制(比如把
user_id 打成 label,使 10000 个活跃用户产生
10000 × 20 = 20 万条
series),这一个服务就比全公司所有基础设施指标的 series
总和还要多。
剖析级(Profile)。5000 Pod × 每 60 秒一条 CPU profile(约 50 KB 压缩后)× 86400/60 个数据点 = 每天约 3.6 GB。成本几乎可以忽略(< $3/月)。
从这四个数字中可以直接导出一个成本分配的启发式规则:把 85% 的存储预算花在 Trace 上(即使已经有采样),10% 花在 Logs 上,4% 花在 Metrics 上,1% 花在 Profile 上。如果你们公司的可观测性账单中 Metrics 或 Logs 占了 50% 以上——要么是基数失控,要么是采样率太高。Scenario A 的完整 worksheet 与 Scenario B 治理倍数见 存储与成本 §2——本文各节的采样率、label 约束直接对应那里的 \(N_{series}\)、\(V_{logs}\)、\(V_{traces}\) 输入项。
1.7 分层案例:checkout 服务的一次请求
用一条典型 checkout 请求说明”同一事实在不同层该长什么样”。假设路径:API Gateway → Auth → Cart → Inventory → Payment → Notification,QPS 5000,p99 目标 800ms。
| 事实 | 错误载体 | 正确载体 | 理由 |
|---|---|---|---|
| 本次请求总耗时 1.2s | Counter
checkout_latency_ms{request_id=...} |
Trace root span duration + RED histogram | request_id 作 label 基数无限;histogram 预聚合 |
支付 RPC 失败 code=INSUFFICIENT_FUNDS |
全量 INFO 日志每请求 3 行 | Span event + ERROR 级日志 1 行 | 日志按行计费;Span event 带 trace 上下文 |
| 节点 CPU 92% | Span attribute node_cpu=92 |
node_exporter node_cpu_seconds_total |
基础设施与请求解耦 |
| 过去 1h 支付成功率 99.2% | 逐请求 Trace 全量 | Counter payment_success_total /
payment_attempt_total |
业务 KPI 需预聚合 |
redis_get_cart 慢 850ms |
Metric label redis_key=cart:uuid-... |
Span redis.GET + 慢 Trace 尾部保留 |
key 高基数;慢路径用 Trace 定位 |
Request Log 与 Span Event 的取舍:若
Trace 头部采样 1% 且未部署尾部采样,checkout 的 INFO 级
access log 仍应保留(带 trace_id),否则 99%
成功请求的上下文在 Trace 侧不可见。部署”头 1% +
尾保错/慢”后,可将 access log 降为 WARN 以上 + 采样 INFO
10%,并用 trace_id 在 Tempo 与 Loki 间跳转——见
§1.5 排障链。
1.8 错层埋点的可观测症状
错层不会立刻 OOM,但会在 Dashboard 和排障中暴露固定模式:
| 症状 | 常见错层 | 验证 PromQL / 查询 |
|---|---|---|
Grafana rate() 查询 30s+ 超时 |
请求级细节打进 Counter/Histogram 且 label 过多 | count by (__name__) ({job="checkout"}) 单
metric > 10 万 series |
| Tempo 搜不到 ERROR trace,Loki 有大量 ERROR | 只开头部 0.1% 采样、无尾部保错 | 对比
rate(http_server_requests_total{status=~"5.."})
与 Tempo trace 计数 |
| 基础设施告警频繁,业务 SLO 仍绿 | 用 node CPU 告警代替 checkout 延迟 SLO | 见 SLO 工程 SLI 定义 |
| Jaeger UI 单 trace 上万 span | 循环内建 span(§6.1) | 按 service.name 统计 span/trace 比 |
| Loki ingester OOM | user_id 作 stream label |
sum by (user_id) (count_over_time({job="app"}[1h]))
唯一 label 值爆炸 |
错层修复优先级:先止血高基数 label(§2.5)→ 再调采样(§3)→ 最后补 recording rule / 尾部采样。
二、基数(Cardinality)为什么是头号杀手
如果把可观测性系统的存储和查询开销画饼图,至少 60% 的面积应该标记为”基数”。但在讨论基数技术细节之前,需要厘清一个几乎所有刚接触可观测性的人都会混淆的概念:数据量大等于基数高吗?不等于。
2.1 什么是基数、为什么它跟数据量不一样
“基数”在可观测性语境中特指标签(label / tag / attribute)组合的唯一组合数。以 Prometheus 为例:
{__name__="http_requests_total", method="GET", endpoint="/api/order", status="200"}
这是一条时间序列。如果下一轮 scrape
这组标签值再次出现,它仍是同一条 series——Prometheus
只追加新数据点,不创建新 series。但如果你加入
pod_name="checkout-7f8a9b-abc123",当 Pod
重新调度变为 checkout-8g9b0c-bcd234
时,你就有了两条时间序列——旧的那条还在(带着旧
pod_name),新的也已创建。如果把 Pod UID 或
request_id 打进去——每一个新
Pod、每一个新请求都产生一组全新标签——唯一组合数在几分钟内就会爆炸。
用一个更直观的对比来看”大”和”高”的区别。场景
A:低基数 + 海量数据。一个 CDN 日志服务每天产生 100
TB 压缩日志,每条日志 20
个固定字段(timestamp、host、method、uri、status、bytes_sent、country_code
等),这些字段的取值空间是确定的——HTTP method
只有几种,status code 几十种,country_code 不到 300 个。Loki
+ S3 月费大约 $800–$1200。
场景 B:高基数 +
中等数据量。一个用户行为追踪系统每天只产 500
GB,但每个事件携带
user_id(百万级唯一值)、session_id(千万级唯一值)、device_id(百万级唯一值)。如果这些字段全部被打成
ES 索引字段,ES 需要为每个唯一值建立和维护 posting
list。即使数据量只有场景 A 的 1/200,ES
的存储放大和内存消耗可能达到场景 A 的 10 倍以上,月费轻松过
$8000。
根本差异:低基数字段可以用基于标签的索引 + 高效压缩编码处理;高基数字段必须建立和维护昂贵的倒排索引结构,索引的存储成本和更新开销与基数成正比。这就是为什么 Prometheus TSDB 的内存消耗和 series 数量严格成正比。
2.2 Prometheus 的基数问题:一条 series 一条命
Prometheus TSDB(v2,自 2.0
起)对每条时间序列在内存中维护一个独立的
memSeries 结构,磁盘上对应一个独立的 chunk
文件。时间序列数量与唯一标签组合数严格 1:1
对应,没有任何压缩或共享。
具体数字:1 万 series ≈ 几十 MB 内存,完全无感。100 万 series ≈ 几个 GB(每个 memSeries 约 3–5 KB,加上 chunk overhead)——这是 Prometheus 单实例的默认经验上限。1000 万 series ≈ 内存 40+ GB,加上 scrape 期间的临时分配和 compaction 开销——大多数 Prometheus 实例在这个量级会 OOM Kill 或者每次 compaction 进入长时间 GC 停顿。
高基数 label 来源在 K8s
环境中有几个标准模式。pod:Pod name
每次滚动更新都会变(checkout-7f8a9b-abc123 →
checkout-8g9b0c-bcd234)。container_id:每次容器重建都会变。node:在
500+
节点集群中本身就有几百个值,物理机故障/扩缩容/内核升级都会导致变化。真正致命的是
uid / request_id /
session_id——基数实际上是无限的(每个请求/会话产生一个新值),打进去就是
Prometheus 的死刑宣判。
Churn(series 创建/销毁速率)比稳态 series 总量更危险。一个大型 K8s 集群,每次滚动发布涉及 200 个 Pod 重新调度,就会产生数千条新 series,而旧 series 要等 retention 到期才消失。Churn 高意味着更多的内存分配、WAL 写入和 compaction 开销。
解决高基数问题的工程手段按推荐优先级:
- 在采集端用
relabel_configs直接 drop 高基数 label。最彻底但最粗暴。如果你确定某个 label 没有查询价值(比如container_id),drop 掉立即见效。
relabel_configs:
- source_labels: [container_id]
regex: '.+'
action: labeldrop在应用暴露指标前做基数约束。把
user_id哈希取模映射到 100 个桶(user_bucket="0"到"99"),保留粗粒度分组能力但把基数从百万级压到 100。用 Recording Rules 做预聚合。原始指标保留 7 天(短 retention),Recording Rule 产出的聚合结果保留 30–90 天。
把高基数查询迁移到 Trace/Log 系统。“按 user_id 查这个用户过去 1 小时所有请求的延迟”——这种查询在 Prometheus 中不可行。在 Loki 中可以用
| json | user_id="xxx"搜索(扫描慢但不炸系统),在 Tempo 中可以把user_id作为 Span attribute(不索引但可存储)。
2.3 高基数在各支柱中的具体影响
不同支柱对基数的敏感程度差别巨大:
Prometheus——最敏感。一个唯一 label 组合 = 一个独立 series = 独立内存+磁盘结构。铁律:label 取值空间必须是小而固定的集合(< 100 个值),且不能随时间无限增长。
Loki——中等敏感,取决于 label vs
字段过滤的选择。Loki 设计哲学是”label
是索引,正文是扫描”。官方强烈建议 label 不超过 15 个且 label
值空间有界。如果你把 user_id(百万级)打成 Loki
label,index 大小会远超日志数据本身,ingester
内存和查询延迟都会恶化。正确方式:不把 user_id
当 label,而是放在日志 JSON 正文中,查询时用
| json | user_id="xxx" 顺序扫描。Loki 2.9+ 的
Bloom Filter 支持在 chunk 级别快速跳过不含目标字段的
chunk,显著减少扫描量。
Elasticsearch——同样敏感且更贵。ES
默认对每个字段建倒排索引。当 user_id 有 100
万唯一值时,索引大小可能比原始 JSON 大 2–3 倍。解决方案:在
ES mapping 中将高基数字段设为
"index": false(只保存不可搜索),或用
fielddata 限制。
Jaeger + ES——取决于后端。Span attribute
默认在 ES 中被索引,高基数 attribute
导致索引膨胀。缓解方式:在 ES mapping 中将高基数 tag 设为
"index": false。
Tempo——对高基数几乎免疫。Tempo
完全不索引 Span attribute——只索引
trace_id、时间窗口和少数 Resource
Attribute。代价是无法按 attribute 搜索 Trace(“所有
status=ERROR 的 trace”),除非用 Streaming Search
扫描对象存储或 TraceQL 加速索引。
Profiles(Pyroscope /
Parca)——最不敏感。数据按时间分块,标签基数影响聚合时的计算量但不影响存储效率。但用
user_id 做 profile label
仍然不合理——没有分析价值且可能违反 PII 合规。
2.4 基数治理:标签白名单与属性字典
基数不是纯技术问题,是技术 × 治理的交叉点。你需要三份清单:哪些字段可以作为 Prometheus label、哪些可以作为 Loki label、哪些可以作为 Span attribute。这三份清单由平台团队维护,服务团队可以申请新字段但必须经过审批和基数评估。
OpenTelemetry Semantic Conventions(v1.28+)提供了标准的属性清单,应该作为你的白名单基准:
| 属性命名空间 | 示例 | 基数评估 | 推荐载体 |
|---|---|---|---|
http.* |
http.method,
http.status_code |
低 (< 50) | Span attribute |
rpc.* |
rpc.service, rpc.method |
低-中 (< 500) | Span attribute |
db.* |
db.system, db.statement |
中 (< 1000) | Span attribute |
service.* |
service.name,
service.version |
低 (< 100) | Resource attribute |
deployment.* |
deployment.environment |
低 (< 10) | Resource attribute |
| 业务特有 | order.id, tenant.id |
高 (无上限) | Span attribute / 日志字段 |
| 用户标识 | user_id, session_id |
极高 (无上限) | 日志字段(不做 label) |
把高基数属性放到 Span attribute 而非 Metric label 是一条通用原则,但并非免费——如果你的 Trace 后端是 Jaeger + ES,高基数 attribute 仍会导致索引膨胀。Tempo 用户则可以几乎无代价地打高基数 attribute(反正不索引),但必须接受”不能按 attribute 搜索”的限制。
2.4.1 三份清单的维护方式
平台团队维护三份 YAML(或内部 Git 仓库),服务团队在 MR 中引用:
# attribute-dictionary.yaml(节选)
prometheus_labels:
allow:
- service
- method
- endpoint
- status_class
deny:
- request_id
- user_id
- pod
- container_id
loki_labels:
allow:
- job
- namespace
- app
- level
deny:
- trace_id # 应写在 log line,不作 stream label
span_attributes:
allow:
- http.*
- rpc.*
- db.system
require_approval:
- com.example.order.id
deny:
- http.request.body
- http.response.body审批门槛:新增 Prometheus label 需估算
|values| 上界;超过 500 需架构师签字。新增 Loki
label 需估算 stream 数(见 §2.10)。Span attribute 的
require_approval 类默认走 Tempo
存储路径,禁止同步索引到 ES。
2.4.2 CI 与生产双层 enforcement
| 层级 | 机制 | 失败后果 |
|---|---|---|
| CI | 扫描 /metrics 文本:regex 拒绝
request_id、uuid 形态 label |
MR 不可合并 |
| CI | OTel InMemoryExporter 断言无 deny 列表 attribute | MR 不可合并 |
| 采集 | Prometheus metric_relabel_configs drop deny
label |
生产止血,无需发版 |
| 采集 | Collector attributes processor delete
禁止键 |
统一拦截 PII |
| 运营 | Grafana
Dashboard:topk(10, count by (__name__) (...))
周环比 |
平台 on-call |
Deny 列表应与 存储与成本 §5 Metrics 基数章节保持一致——那边 worksheet 假设 \(N_{series} = 4 \times 10^6\);单服务突破 10 万 series 即触发 FinOps 复核。
2.5 Prometheus 基数诊断与修复的实战流程
当 prometheus_tsdb_head_series
曲线异常上升时,以下步骤可以快速定位和止血——不需要猜测,每一步都有明确的
PromQL 查询。
第一步:按 job 分解 series 分布。
count by (job) ({__name__=~".+"})
这个查询告诉你每个 scrape job 产生了多少 series。正常的 node_exporter job 通常稳定在几十万,应用 job 通常在几万。如果某个 job 的值是其他 job 的 10 倍以上——它就是嫌疑犯。
第二步:按 metric name 分解。
count by (__name__) ({job="suspicious-job"})
找出哪个 metric 贡献了最多的 series。通常罪魁祸首是单个
metric——比如 http_requests_total 因为 label
组合爆炸产生了 50 万 series,而其他 metric
都只有几百或几千。
第三步:看这个 metric 有哪些高基数 label。
count by (pod) ({__name__="suspicious-metric"})
如果 pod label 有 3000
个不同的值——这就是问题。正常的 pod label
应该只有几百个值(等于该服务的 running pod 数量)。
第四步:止血。 三种选择,按推荐顺序:
- 在采集侧用
metric_relabel_configs直接 drop 高基数 label(立即生效,无需重启应用):
metric_relabel_configs:
- source_labels: [pod]
regex: 'checkout-.*'
action: labeldrop- 如果高基数 label 仍有查询价值但不需要原始精度,用
relabel_configs做哈希分桶映射:
metric_relabel_configs:
- source_labels: [__name__, pod]
regex: 'http_requests_total;(.*)'
target_label: pod_bucket
replacement: '${1}'然后在应用层把 pod 值替换为 hash
取模后的值(如 pod_bucket="42")。
- 如果问题出在应用代码层面(比如把
request_id打成了 label),只能回滚或修复代码后重新部署。Collector 层的relabel_configs可以止损,但数据量已经产生的那部分需要手动清理 TSDB 或者等 retention 自然过期。
第五步:事后防护。 为
count by (job) ({__name__=~".+"})
设置告警—当任何 job 的 series count 同比或环比增长超过 50%
且绝对值 > 50000 时触发 Warning
级别通知。这不能防止基数爆炸,但能让你在”40 分钟炸掉
Prometheus”之前有 10–15 分钟的预警窗口。
2.6 VictoriaMetrics 的基数处理差异
如果你的团队用的是 VictoriaMetrics 而非原生
Prometheus,它在基数问题上有一个关键差异:VictoriaMetrics
不维持内存中的 per-series 索引。它把 label
对(key=value)压成一个整数 ID,series 由一组
label ID 组合而成。这使得它在同等内存下能处理的 series
数量约为 Prometheus 的 5–10
倍。但这不是无代价的——VictoriaMetrics 的标签值去重导致它在
label value churn 率高的场景下性能下降更快(因为每个新 label
value 都需要分配一个新的整数 ID 并写入倒排索引),而
Prometheus 的 TSDB 在 churn
率高的场景下主要表现为内存压力而非索引写入压力。
选择 Prometheus 还是 VictoriaMetrics 的决策与你的埋点哲学直接相关。如果你的组织成熟度高、label 治理做得好(每服务 < 50 个 label,值空间固定),Prometheus 足够好。如果你的组织已经积重难返——几百个服务、每个服务几十个 label、label 值空间不受控——VictoriaMetrics 可以多给你几个月的缓冲时间去逐步治理。
2.7 本机基数压测:prometheus_tsdb_head_series 观测
DEEPENING_PLAN 要求在本机 Prometheus 上构造高基数 label
并记录 prometheus_tsdb_head_series
变化。本环境(WSL2,Docker Hub 拉取超时)未能完成完整 Docker
Compose
压测;以下给出可复现步骤与基于
Prometheus TSDB 文档的推导数字(A 级:Prometheus
TSDB 源码与官方指标语义),部署前请在目标集群实测。
实验拓扑(Docker Compose 最小集):
# docker-compose.cardinality.yml(节选)
services:
prometheus:
image: prom/prometheus:v2.53.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports: ["9090:9090"]
exporter:
build: ./cardinality-exporter # 每 scrape 注入 50 个新 request_id label观测 PromQL(每 15s 记录一次,共 30 分钟):
prometheus_tsdb_head_series{job="prometheus"}
count by (__name__) ({job="cardinality-test"})
推导(非实测,假设 scrape_interval=5s,每 scrape 新增 50 个唯一 label 组合):
| 时间 | 新增 series/min | 累计 head_series(约) | 内存估算(3.5 KB/series) |
|---|---|---|---|
| T+0 | 0 | 基线 ~200 | < 1 MB |
| T+5min | 600 | ~3200 | ~11 MB |
| T+15min | 600 | ~9200 | ~32 MB |
| T+30min | 600 | ~18200 | ~64 MB |
| T+2h(持续) | 600 | ~72000 | ~252 MB |
若错误地把 request_id 打到 300 QPS
的生产服务(每请求一个新 UUID,15s scrape 内约 4500
个新组合/scrape):40 分钟内可从 120 万
series 增至数百万——与开篇事故叙事数量级一致。Prometheus 官方
prometheus_tsdb_head_series 定义见 Prometheus
TSDB stats。
止血验证(metric_relabel_configs,无需重启
exporter):
scrape_configs:
- job_name: cardinality-test
metric_relabel_configs:
- source_labels: [request_id]
regex: '.+'
action: labeldrop应用后 head_series 应在 1–2 个 scrape
周期内停止增长(旧 series 仍保留至 retention 过期)。
完整 docker-compose 与 exporter 源码(读者本地复现):
mkdir -p cardinality-lab && cd cardinality-labdocker-compose.cardinality.yml:
services:
prometheus:
image: prom/prometheus:v2.53.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=2h
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports: ["9090:9090"]
cardinality-exporter:
image: prom/node-exporter:v1.8.2
# 实际压测应使用自定义 exporter:每次 scrape 递增 request_id
# 见下方 Python 脚本 prometheus-cardinality-exporter.py
ports: ["9101:9100"]自定义 exporter(每 scrape 暴露 \(N\) 个带唯一
request_id label 的 counter):
#!/usr/bin/env python3
# prometheus-cardinality-exporter.py — 仅用于 lab,勿上生产
from prometheus_client import start_http_server, Counter
import uuid, time
REQUESTS = Counter(
"cardinality_test_requests_total",
"Lab only",
["request_id"],
)
BATCH = 50 # 每 5s scrape 新增 50 个 series
if __name__ == "__main__":
start_http_server(9101)
while True:
for _ in range(BATCH):
REQUESTS.labels(request_id=str(uuid.uuid4())).inc()
time.sleep(5)prometheus.yml scrape
cardinality-exporter:9101,scrape_interval: 5s。记录脚本:
while true; do
date -Is
curl -s 'http://localhost:9090/api/v1/query?query=prometheus_tsdb_head_series' \
| jq -r '.data.result[0].value[1]'
sleep 15
done推导校验:600 series/min = 10
series/s,与 BATCH=50、scrape 5s 一致。2
小时累计约 72000 series;内存约 72000 × 3.5 KB ≈ 252 MB——与
§2.7 表一致。若将 BATCH 提到 4500(模拟 300 QPS
每 scrape 周期新 UUID),40 分钟可逼近百万级
series——与开篇事故数量级一致,务必在隔离环境操作。
2.8 Recording Rule 与 exemplar 的基数边界
Exemplar(OpenMetrics 规范)把 trace_id 挂到
histogram 样本上——不增加 series 数,但增加
WAL 与 block 体积。单条 exemplar 约 32–48 bytes;若每个
histogram bucket 都挂 exemplar 且 QPS 极高,WAL
膨胀速度可超过 series 膨胀。Grafana Mimir 对 exemplar 有独立
limit:max_global_exemplars_per_user。
Recording rule 产出的新 series
仍受基数约束。常见错误:recording rule 保留
pod label——滚动发布时 churn 与原始 metric
一样严重。正确做法:聚合到 service /
deployment 粒度:
groups:
- name: checkout-slos
rules:
- record: checkout:http_requests:rate5m
expr: sum by (service, method, status_class) (rate(http_requests_total[5m]))2.9 VictoriaMetrics 与 Prometheus 的治理分叉
§2.6 已述 VM 的内存模型差异。治理上:Prometheus 适合”硬约束 label 白名单”文化;VictoriaMetrics 适合”已有技术债、需缓冲期”——但 VM 不能替代 label 治理,只是延迟 OOM。无论后端,以下 PromQL 应进入平台 Dashboard:
# 全局 head series(或 vm_cache_entries{type="storage/hour_metric_ids"} 视部署而定)
prometheus_tsdb_head_series
# Churn:1h 内新建 series 速率(Prometheus 2.45+)
rate(prometheus_tsdb_head_series_created_total[1h])
# Top 20 高 series metric
topk(20, count by (__name__) ({__name__=~".+"}))
告警建议:head_series > 1.5e6
Warning,> 2e6
Critical;rate(..._created_total[1h]) > 50000
Warning——具体阈值按实例内存调整。
2.10 Loki stream 基数:Metrics 的镜像问题
Loki 的 stream(唯一 label 组合)等价于 Prometheus 的
series——把 trace_id 或
user_id 作 Loki label 与把
request_id 作 Prometheus label
同样致命。Grafana 文档建议 stream
总数控制在可规划范围;高基数字段进 JSON line,查询用
LogQL:
{namespace="prod", app="checkout"} | json | user_id="u-123" | level="ERROR"
Bloom filter(Loki 2.9+)加速字段过滤,但不消除 ingest
成本——见 存储与成本
Logs 章节。Metrics 与 Logs 的 label
白名单应同表维护(§2.4.1),避免团队 A 在
Prometheus 用 user_bucket,团队 B 在 Loki 用
user_id 作 stream label。
三、采样策略
采样是可观测性成本模型中最大的单一杠杆。全量 Trace 存储成本是等量 Metrics 的 50–100 倍。把采样率从 100% 降到 1%,Trace 存储成本直接砍掉 99%。但这个杠杆的危险在于:砍掉的那 99% 数据里,可能恰好包含了你下一次事故的唯一线索。
在深入三种采样策略之前,先建立两个基础概念。第一,采样的最小单位是”一次
Trace”(一个 root span + 所有子 span),而不是单个
span。你不可能只”采样 span A 但不采样它的子 span
B”——要么整棵树保留,要么整棵树丢弃。这就是为什么
TraceIDRatioBased 要对 trace_id
哈希而不是随机数:确保同一个 trace_id 的所有
span
被跨服务一致性地决策。第二,采样的决策公式可以写成:should_sample = f(trace_id, trace_data, system_state)。区别只在于
f 能访问多少信息。头部采样只访问
trace_id(最小信息),尾部采样访问完整的
trace_data(中等信息),自适应采样还访问
system_state(最大信息但最高延迟)。
3.1 头部采样(Head-based Sampling)
头部采样在请求入口(第一个 Span 创建时)就做出保留/丢弃决策。OTel SDK 的标配实现:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/trace"
)
sampler := trace.ParentBased(
trace.TraceIDRatioBased(0.01), // 1% 概率
)
tp := trace.NewTracerProvider(trace.WithSampler(sampler))
otel.SetTracerProvider(tp)这段 6
行代码背后有两个精巧的工程细节。第一,TraceIDRatioBased
不是随机数,是对 trace_id
哈希值取模——这保证了同一个 trace_id
的所有跨服务 Span
被一致性地保留或丢弃,不会出现”只看到一半调用链”。第二,ParentBased
确保子 Span 跟随父 Span 的决策——如果父 Span 被采样了,子
Span 一定被采样;如果父 Span 没被采样,子 Span 按 root
sampler 规则决策(不独立随机)。
头部采样的优势是零额外开销——不需要 Span Buffer,不需要
Collector 参与,SDK 在第一个 Span
创建时就全搞定了。缺陷同样极端:决策不可逆。在 1%
采样率下,99% 的请求不产生任何 Trace。如果一条
status_code=500 的错误恰好属于这 99%——它的
Trace 就永远回不来了。在一个典型 HTTP 服务中,99.9%
的请求是成功的,只有 0.1% 是错误。1% 固定采样率平均每 24
小时能捕获约 86 条错误 Trace——对于调试一个低频但严重的
bug,这个数量可能不够。
速率限制(RateLimiting)是另一种思路:每秒最多保留
N 条 Trace。流量从 1000 QPS 飙升到 50000 QPS
时,概率采样会跟着产生更多
Trace(比例不变),而速率限制保持恒定——这在流量波动大的场景下更可预测,但在流量正常时可能严重”欠采”。
3.2 尾部采样(Tail-based Sampling)
尾部采样把决策推迟到所有 Span 到达 Collector 之后——此时你已知道这个 Trace 的完整状态(有没有错误?总延迟多大?涉及哪些服务?),可以做真正的智能决策:
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
- name: all-errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-checkout
type: and
and:
and_sub_policy:
- name: slow
type: latency
latency: {threshold_ms: 500}
- name: checkout-only
type: string_attribute
string_attribute:
key: service.name
values: [checkout]
- name: baseline
type: probabilistic
probabilistic: {sampling_percentage: 1}这个配置做三件事:所有 status_code=ERROR 的
Trace 全部保留;checkout 服务延迟超过 500ms 的 Trace
全部保留;其余按 1% 概率做 baseline 覆盖。效果是:99.9%
的正常请求只保留 1% 样本(成本极低),100%
的错误请求全部保留(调试能力最强)。
尾部采样的工程代价是 Span Buffer。假设总 QPS 为
20000,每个请求平均 20 个 Span,decision_wait
为 10 秒,Collector 需要在内存中缓存
20000 × 20 × 10 = 4000000 个 Span。每个 Span
平均 1 KB,就是约 4 GB 内存仅用于缓冲。缓解策略:缩短
decision_wait(如果微服务调用链最长时间通常在 2
秒内,设 3 秒而非 10 秒可以立刻减少 70%
缓冲量)、num_traces 硬限制(超过则丢弃最老
Trace 而不做决策——用概率丢失换确定性 OOM 保护)、按 trace_id
hash 分片到多个 Collector 实例。
头尾组合(头部 1% baseline + 尾部保留错误和慢请求)是目前生产环境的事实标准。
3.3 自适应采样(Adaptive Sampling)
自适应采样:系统根据当前错误率、延迟分布、流量特征自动调整采样率——正常时低采样(0.1%),异常时自动拉高到
10%–100%。Jaeger v2 的 Adaptive Sampling Processor
持续追踪每个 service + operation
的延迟分布和错误率来计算最优采样概率,使罕见错误的覆盖率比固定概率高几个数量级。
自适应采样的现实约束是反馈延迟:错误开始发生 → Collector 检测 → 更新策略 → SDK 拉取新策略 → 新 Trace 被保留 → 进入存储——这条链路至少有几十秒到几分钟延迟。对于事后回溯排障(你是在事故发生后去查),这个延迟可接受;但对于”错误刚发生我希望立刻看到 Trace”的实时调试,不如”always sample errors”简单可靠。目前自适应采样在弹性流量场景表现不错,但在稳定流量下相比”固定概率 + 尾部错误保留”没有显著优势。
3.4 日志的采样
日志采样与 Trace 采样有本质不同:Trace 采样的单位是”一个完整请求调用树”,日志采样的单位是”一行日志”。最基本的方式是按级别:ERROR 全量(铁律)、WARN 50%–100%、INFO 1%–10%、DEBUG 在生产直接关闭。
按 Trace 采样率联动是一种更精细的做法——如果某个请求的
Trace 被头部采样丢弃,它的 INFO 日志也可以丢弃。这需要在日志
Agent(Fluent Bit / Vector)层面解析日志中的
trace_id 字段并与 Trace
系统同步采样决策——当前还不是主流实践,但在高日志量团队中越来越受关注。
日志采样的关键风险是多行消息完整性。Java stacktrace、SQL
查询、Python traceback
都是多行的——对行做独立采样的后果是下游收到无法解析的半截消息。正确做法是在
Agent 层先用 multiline parser
把多行合并成一条逻辑记录,再按记录的字段做过滤——而不是在 raw
line 层做采样。
3.5 采样率选择:理论和实践的数字
几个实用的经验数字:
- 1%(0.01):在 1000 QPS 下每天约 86.4 万条 Trace。对大多数服务的延迟分析和 p50/p95/p99 估算已够用。这是最常见的”生产环境 baseline”。
- 0.1%(0.001):在 1000 QPS 下每天约 8.6 万条。对低频故障覆盖很差,但容量预警、流量突增检测等聚合层面够用。
- 10%(0.1):在 1000 QPS 下每天约 864 万条。接近”小型团队的全量”,需要认真评估存储成本。
采样率每提高一个数量级,存储成本基本也提高一个数量级(简化模型),但排障能力不是线性提升的——从 0.1% 提高到 1% 对排障的价值远大于从 1% 提高到 10%。因此大多数团队最终收敛到”1% baseline + errors always”模式。
3.6 头部 vs 尾部:存储量数量级(假设模型)
| 策略 | 5000 QPS,8 span/req,7 天保留 | 错误 Trace 覆盖率(错误率 0.1%) |
|---|---|---|
| 头部 1% | 压缩后 ~250 GB | 期望 ~600 条/7天(统计性) |
| 头 1% + 尾保错 | ~260 GB + Collector RAM 1–4 GB | 错误 100% |
| 头部 0.1% | ~25 GB | 错误 ~60 条/7天(不足排障) |
| 全量 | ~25 TB+ | 100%(成本不可接受) |
公式:\(V_{span/day} = QPS
\times 86400 \times spans \times bytes_{span} \times
rate_{sample} / ratio_{compress}\)。取 \(bytes_{span}=1KB\),\(ratio_{compress}=3\)(ZSTD)。勿将上表当作账单——用
Tempo tempo_ingester_bytes_received_total
实测校准。
3.7 OpenTelemetry Collector 尾部采样完整配置
以下配置在结构上对齐 tail_sampling processor 官方文档(本环境未部署 Collector 集群,语法以 v0.100+ 为准):
processors:
tail_sampling:
decision_wait: 5s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: errors-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: latency-policy
type: latency
latency: {threshold_ms: 500}
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 1}decision_wait 应设为端到端调用链 p99.9
延迟略大于实际值——过长浪费 buffer,过短导致子 span
未到达即决策。
3.8 三种策略的配置矩阵与 PromQL 验证
头部采样 — OTel 环境变量(K8s Deployment 片段):
env:
- name: OTEL_TRACES_SAMPLER
value: parentbased_traceidratio
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.01"
- name: OTEL_PROPAGATORS
value: tracecontext,baggage头部采样 — Java 等价:
-Dotel.traces.sampler=parentbased_traceidratio
-Dotel.traces.sampler.arg=0.01验证采样率是否接近配置(需应用暴露
otel_trace_sampled_total 或 Tempo
metrics):
# 若 SDK 导出 sampled vs not sampled counter
sum(rate(otel_traces_samples_total{sampled="true"}[5m]))
/
sum(rate(otel_traces_samples_total[5m]))
无 SDK counter 时,用 Tempo ingester 接收速率与 HTTP QPS 比值粗验:
sum(rate(tempo_distributor_spans_received_total[5m]))
/
sum(rate(http_requests_total[5m]))
比值应接近
采样率 × 平均每 trace span 数。偏离 > 2×
时检查:多服务采样不一致、ParentBased 断链、或 Collector
二次采样。
尾部采样 — 与头部 1% 组合的完整
otel-collector-config.yaml 骨架:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 4096
tail_sampling:
decision_wait: 5s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 500}
- name: baseline
type: probabilistic
probabilistic: {sampling_percentage: 1}
batch:
send_batch_size: 8192
timeout: 5s
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [otlp/tempo]注意:SDK 若已做头部 1% 采样,尾部
policy 的 probabilistic 1% 作用于已进入
Collector 的 trace——有效 baseline 约为 \(0.01 \times 0.01 =
0.01\%\)。常见做法是 SDK AlwaysOn 或
100% 发往 Collector,由 Collector 统一 tail sampling;或 SDK
100% 发送、Collector 仅 tail(内存压力大)。架构选型见 OpenTelemetry
栈。
自适应采样 — Jaeger v2 remote sampling 策略文件(B 级:Jaeger 文档):
{
"default_strategy": {
"type": "probabilistic",
"param": 0.01
},
"service_strategies": [
{
"service": "checkout",
"type": "probabilistic",
"param": 0.05
},
{
"service": "payment",
"type": "probabilistic",
"param": 1.0
}
]
}payment 低频高价值(§4 四象限)可设
param: 1.0;checkout 高频高价值用 5% +
Collector 尾保错。
PromQL:错误 Trace 覆盖率粗算(假设错误率 \(p_e\),头部采样率 \(p_h\),尾部保错 100%):
期望保留错误 trace 比例 \(\approx 1 - (1-p_h)^{E[\text{errors
per window}]}\) 在纯头部时;加尾部后 \(\approx 100\%\)(受 Collector
buffer 与 decision_wait 约束)。验证:
# 5xx 请求速率
sum(rate(http_requests_total{status=~"5.."}[1h]))
# Tempo 中 status=error 的 trace 速率(指标名因版本而异)
sum(rate(tempo_ingester_traces_created_total{status="error"}[1h]))
两者数量级应接近(考虑采样与批处理延迟)。若 HTTP 5xx 高但 Tempo error trace 低——尾部采样未生效或传播断裂(§6.3)。
3.9 日志采样:Collector filter + probabilistic
与 存储与成本 §3.2 对齐:
processors:
filter/logs_drop_debug:
logs:
exclude:
match_type: strict
record_attributes:
- key: severity
value: DEBUG
probabilistic_sampler/logs:
sampling_percentage: 10
hash_seed: 42
service:
pipelines:
logs:
receivers: [otlp]
processors: [filter/logs_drop_debug, probabilistic_sampler/logs, batch]
exporters: [otlphttp/loki]ERROR 应在 filter 之前单独 pipeline 全量导出,或通过
filter include severity=ERROR
bypass 采样——ERROR 100% 是 FinOps 与 SRE
的交集约束。
3.10 按需采样与故障窗口
平时 head 0.1%,Incident 期间临时提到 10%——实现路径:
- Feature Flag / 远程配置:动态改
OTEL_TRACES_SAMPLER_ARG(需 SDK 支持 reload 或滚动重启)。 - Collector
probabilisticpolicy 热更新:仅影响经 Collector 的 trace。 - Tail policy 增开
string_attribute:对deployment.environment=debug或X-Debug-Trace: 1header 100% 保留——通过transformprocessor 把 header 写入 span attribute 再匹配 tail policy。
按需采样不能替代日常尾部保错——Incident 窗口往往晚于首次错误发生 5–15 分钟。
四、埋点粒度决策框架
4.1 四象限决策树(Mermaid)
下图与文首 SVG 一致;Mermaid
便于在 Git 中 diff,SVG 便于站点排版。
flowchart TD
Start[新信号或新字段] --> L1{单次请求上下文?}
L1 -->|是| L2{QPS × 排障价值}
L1 -->|否| L3{基础设施或业务 KPI?}
L3 -->|基础设施| M1[Metric: node/cAdvisor/kube-state]
L3 -->|业务 KPI| M2[Counter/Histogram 预聚合]
L3 -->|代码热点| P1[Profile 持续采样]
L2 -->|高 QPS 低价值| M3[仅 RED Metric 无 Span]
L2 -->|高 QPS 高价值| T1[Trace: 头 1-10% + 尾 ERROR/慢]
L2 -->|低 QPS 低价值| G1[Log WARN+ 或定时 Metric]
L2 -->|低 QPS 高价值| T2[Trace+Log 全量或尾 100%]
T1 --> C1{label 基数}
T2 --> C1
M2 --> C1
M3 --> C1
C1 -->|有界 <500| OK[进入属性字典 allow]
C1 -->|无界| SA[Span attr 或 log 字段]
SA --> S1{含 PII?}
S1 -->|是| R[redaction 或禁止]
S1 -->|否| TS[Trace 尾部保留 + 禁止 metric label]
OK --> Cost[重算 storage-cost worksheet]
TS --> Cost
R --> Cost
4.2 决策案例 Walkthrough
案例 A:CDN 静态资源(高频低价值)
QPS 50000,排障只需 aggregate 4xx/5xx 率。决策:仅
http_requests_total +
histogram,不为每个 URL path 建 Span;path
基数用 status_class + cache_hit
两个 label。采样:Metric 全 scrape,Trace 关闭或 0.01%
baseline。
案例 B:支付回调(低频高价值)
QPS < 10,失败即资损。决策:Trace + Log
全量;Metric 记录
payment_callback_total{result}。禁止:order_id
作 Prometheus label;order_id 进 span attribute
与 log JSON。
案例 C:批处理 Job(低频低价值)
nightly ETL,失败可重跑。决策:Log 全量到 Loki(单 job
体积极小),Trace 可选仅 root
span;Metric:job_last_success_timestamp、job_duration_seconds
histogram。
案例 D:推荐排序(高频高价值)
QPS 8000,延迟回归难复现。决策:头 5% + 尾 p99>200ms +
ERROR 100%;Profile 1/min 持续;禁止把
item_id 作 metric label——用 trace exemplar 链到
Tempo(§2.8)。
4.3 与 metrics-methodology 的边界
Metrics 方法论 定义”该测什么 RED/USE”;本文定义”测出来的东西放哪一层、采样多少”。二者交集:Histogram bucket 设计与 recording rule——RED 所需指标必须在 worksheet 中标记为 SLO 不可删(见 SLO 工程)。
4.4 Per-service 采样策略表(模板)
| 服务 | QPS | 象限 | Trace 头 | Trace 尾 | Log INFO | Metric label 预算 |
|---|---|---|---|---|---|---|
| checkout | 5000 | 高频高 | 1% | ERROR+>500ms | 10% | ≤200 组合 |
| payment | 8 | 低频高 | 100% | — | 100% | ≤20 |
| cdn-edge | 50000 | 高频低 | 0.01% | ERROR only | 1% | ≤50 |
| report-api | 5 | 低频低 | 10% | ERROR | 50% | ≤30 |
策略表变更应同步 存储与成本 Scenario B 倍数估算。
信号分层、基数控制、采样策略最终需要收敛成可执行的决策框架。在落笔写埋点代码之前,每条信号都应该过五个问题:
- 这一层对吗? 这条信息是描述单次请求的(→ Trace Span/Request Log)、描述基础设施状态的(→ Metric/System Log)、描述业务聚合的(→ Custom Metric)、还是描述代码执行特性的(→ Profile)?
- 这个字段会导致高基数吗? 取值空间是否随时间无限增长?是 → 不能做 Prometheus label,不能做 Loki label,只能做 Span attribute 或日志字段。
- 包含 PII 吗? email、手机号、身份证号、银行卡号、IP 地址(GDPR 下)?是 → 要么不采集,要么 Collector 层哈希/redaction。
- 需要采样吗? 数据量与 QPS 成正比?是 → 需要采样策略。错误/慢请求全量 + 正常请求概率。
- 什么查询场景被使用? 按时序聚合(→ Metric)、按 trace_id 精确查找(→ Trace)、按关键字搜索(→ Log)、按时间对比(→ Profile)?
这五个问题构成埋点决策的 checklist。下面的四象限法是第 4 个问题(采样)的细化:
| 象限 | 特征 | 推荐策略 | 典型例子 |
|---|---|---|---|
| 高频低价值 | QPS 极高,单条价值低 | 聚合为 Metrics,不产出独立 Span | CDN 静态资源 → 只输出 http_requests_total +
latency histogram |
| 高频高价值 | QPS 高,每条请求都可能揭示问题 | 头部 1%–10% + 尾部保留错误+慢请求 | 订单创建 API |
| 低频低价值 | 频率低,出问题时影响小 | 全量 Log,不埋 Trace 或只埋基本 Span | 管理后台定时任务 |
| 低频高价值 | 频率低,一旦出问题就是事故 | 全量 Trace + 全量 Log,不采样 | 支付回调、对账任务 |
关键提醒:采样决策应该在服务级别做,不是全局统一。订单服务(QPS 5000)和报表服务(QPS 5)如果共用一套采样率,要么订单服务的成本被浪费在 99.8% 的无异常 Trace 上,要么报表服务的罕见错误完全捕捉不到。
五、埋点规范的工程落地
有了决策框架,下一步是让整个团队按照一致的规范行动。真正的难点不是写一份漂亮的规范文档——是在没有人盯着的时候规范依然被执行。
5.1 属性命名规范
OpenTelemetry Semantic
Conventions(v1.28+)是最完整、社区最活跃的跨语言属性命名标准。团队规范第一条应该是直接复用——Grafana、Jaeger、Tempo、Datadog
等所有主流 UI 都能自动识别 Semantic Conventions
标准属性并做特殊渲染(http.status_code 自动生成
RED 指标,db.system 自动关联到 Database
类型服务拓扑)。用自定义属性名(my_http_status
而非
http.status_code),这些自动能力全部失效。
团队特有属性必须遵循统一命名规范。OTel
推荐反向域名前缀(com.example.order.id)或至少建立组织级属性字典(Attribute
Dictionary)。两个团队对同一个概念用不同属性名(user_id
vs uid vs userId)的后果——在
Grafana 中按 user_id 过滤时,用
uid
的团队的数据全部被跳过。修复成本极高:不是改一个配置,而是改几十个微服务中数百处埋点代码。
5.2 必填 / 选填 / 禁止属性清单
一份可执行的埋点规范必须包含三个列表:
必填属性。每个服务在启动时必须通过
OTEL_RESOURCE_ATTRIBUTES 或 SDK 注入
service.name、service.namespace、service.version、deployment.environment。每个
HTTP Span 必须携带
http.method、http.status_code、http.route,每个
RPC Span 必须携带
rpc.service、rpc.method。如果
service.name 缺失或为
unknown_service(OTel SDK 默认值),这个服务在
Grafana
中就是一个黑洞——数据进了系统,但永远找不到它属于哪个服务。
选填属性。user.type(premium/free)、region、device.type、experiment.group——只在有值时写入,不用空字符串或
N/A 填充。过度的选填属性是属性膨胀的起点。
禁止属性。红线:高基数标识符不做
label(request_id、session_id、ip_address、container_id
原生格式),PII
不进任何可观测性信号(email、phone_number、id_card_number、password、token、secret),Request/Response
Body 不进 Span attribute(如需排障最多保留 truncated 前 256
字符并在 Collector 层做 PII 扫描)。
Collector 的 redaction processor
可在数据离开数据中心前做统一拦截:
processors:
redaction:
allow_all_keys: true
blocked_values:
- "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b"
- "\\b[0-9]{13,19}\\b"5.3 埋点质量的自动化检测
规范文档没有执行能力——代码是由人写的。三层检测让规范”自我执行”:
第一层:CI 检查。在 CI pipeline
中启动服务,通过 OTel InMemoryExporter 收集所有
Span,自动化验证:所有 Span 有
service.name/trace_id/span_id/duration,HTTP
Span 有
http.method/http.status_code,没有包含禁止列表中的属性名(检查
request_id/password/email
等关键词),没有单个 attribute value 超过 10
KB,关键业务路径至少产生了预期数量的
Span。这套检查不需要外部依赖,成本几乎为零。
第二层:生产抽检。OTel Collector 以极低概率(0.001%–0.01%)做完整 Span attribute 审计——检查是否出现新属性名、属性值是否包含 PII pattern、属性值平均大小是否异常。抽检后直接丢弃这些 Span,不进入存储。唯一目的是发现”有人上线了新版本引入了新的 attribute,但没经过属性字典审批”。
第三层:属性治理 Dashboard。Grafana Dashboard,利用 Tempo/Jaeger 的 API 每天拉取所有 Span attribute 清单,展示每个 attribute 的出现频率、跨服务覆盖和基数估计。新属性出现时自动通知平台团队——不能指望每个开发者都记住 200 个属性的规范清单。
六、真实坑点
以下坑点全部可以在凌晨三到四点之间的某个生产事故中找到原型。每个坑的背后都有一条合理的技术路径——“我觉得这样能解决问题”——但当它在生产环境炸开时,“合理”和”灾难”之间没有任何过渡地带。
6.1 在循环体内创建 Span
埋点事故频率最高的一种。完全无害的代码:
func (s *OrderService) BatchProcess(ctx context.Context, orders []Order) error {
for _, order := range orders {
ctx, span := s.tracer.Start(ctx, "process_order",
trace.WithAttributes(attribute.String("order.id", order.ID)),
)
if err := s.processSingleOrder(ctx, order); err != nil {
span.SetStatus(codes.Error, err.Error())
}
span.End()
}
return nil
}如果 orders 长度是 500,一批产生 500 个
Span。外部 API 以 100 QPS 调用时,每秒 50000 个 Span——任何
Tracing 后端在几分钟内被击穿。这些 Span 中的 99% 除了
order.id attribute 不同外完全相同。
三种替代方案:最好的是不创建独立 Span,只往父 Span 上添加
Event(时间戳 + 简述 + order.id)。次好的是循环前检查
span.IsRecording(),父 Span 未采样时跳过子 Span
创建。最差但仍可接受的是限制最大子 Span
数(if len(orders) > 100 { /* 只建聚合 Span */ })。
6.2 全量 Body 进 Span Attribute(The Full Body Problem)
无数团队在搭建 Tracing 初期都产生同一个”合理”想法:把 Request/Response Body 打进 Span attribute,在 Jaeger 里直接看到请求内容——排障太方便了。
方便是真的。账单也是。Response Body 平均 50 KB,5000 QPS 下一天 21.6 TB 额外 attribute 数据。而且是文本编码的——这些数据通过 Collector → 存储后端的每一步都被复制和处理。再加上 body 中可能包含用户姓名、银行卡尾号、身份证号——不仅是账单问题,是 GDPR 合规问题(罚款最高全球年收入 4%)。
唯一可接受的 Body 捕获是”按需截断”:在 Collector 层用 transform processor 保留前 256 字符,同时用正则或 DLP 策略检查 PII pattern。且不应该在生产环境长期开启——只在故障排查期间临时打开特定采样比例。
6.3 传播链断裂的四个经典场景
跨服务 trace_id 传播依赖 W3C TraceContext 标准
traceparent
header:00-{trace_id}-{parent_span_id}-01。以下四个场景中极易丢失:
OTEL_PROPAGATORS被设错。默认值tracecontext,baggage,helm chart 中误覆盖即全断。- 消息队列不自动传播。HTTP header
是自动的——但 Kafka/RabbitMQ/Pulsar 需要手动把
traceparent写入消息 header/metadata 并在 consumer 端恢复。大多数团队在做消息队列埋点时漏掉这一步。 - 异步代码用了空
Context。
go func() { tracer.Start(context.Background(), ...) }()——导致整个异步分支变成孤儿 Span。 - CDN / API Gateway / Service Mesh 剥离了 trace
header。较老 CDN 默认剥离非标准
header,
traceparent需加白名单。
诊断方法:在 Grafana 中对 Tempo/Jaeger 做”孤立 Span 占比”按服务分解的查询。某个服务的”无父 Span 比例”突然从 < 1% 跳到 > 30%→ 一次部署引入了传播断裂。
6.4 同一属性在不同服务中语义不一致
团队 A 的 user_id 是自增整数,团队 B 的是
UUID 字符串,团队 C 用了 uid。后果:在 Grafana
中按 user_id 过滤时团队 B 和 C
的数据全部被跳过。修复成本:几十个微服务数百处代码需要统一修改。这就是属性字典在服务数超过
10 个之后变成刚需的原因。
6.5 Prometheus Recording Rule 缺失导致查询超时
这不是”埋点”的问题,而是”埋了但没预聚合”的问题。一个常见场景:某团队在
counter 上打了三个 label(method ×
endpoint × status_code),产生约
5 × 200 × 10 = 10000 条 series。这在 Prometheus
内存层面完全可控。但他们每天在 Grafana 上跑了 20 个类似的
rate() + sum()
查询,每个查询都要扫描这 10000 条 series 的原始数据点——在 30
天 retention 下每次查询扫描约 1.08
亿个数据点。查询耗时从最初的 2 秒逐渐增长到 45 秒,最终
Grafana 前端超时。
解决方法是 Recording Rules:将高频查询预计算为新的时间序列,Grafana Dashboard 直接查询预计算结果而非每次都做全量扫描。代价是新 series 的存储——但 Recording Rule 产出的 series 通常远少于原始 series 组合。
6.6 在 CI 中跑可观测性但没有跑埋点验证
很多团队的 CI 会启动被测服务、跑集成测试、检查 HTTP 状态码,但从不检查 OTel Span 是否正常产出。后果是:某次重构中有人不小心把 tracer provider 的初始化顺序改坏了(比如在 HTTP middleware 注册之后才初始化 tracer),所有 HTTP handler 的 Span 全部丢失——但所有集成测试仍然通过(因为测试不检查 Span,只检查业务逻辑)。这个 bug 在生产环境跑了两个月,期间没有任何 HTTP Trace 数据被记录。
最简单的修复:在集成测试中加一个
TestSpanExport 辅助函数,用 OTel
InMemoryExporter 收集所有 Span
后断言至少存在一个 Span 且包含预期的 attribute。再加一个
TestPropagation 测试——A 服务发请求给 B 服务,B
服务确认收到的 traceparent header 与 A 的
trace_id 一致。这两个测试加起来不到 50
行代码,但能防止 90% 的埋点回归。
6.7 Histogram bucket 过多 + exemplar 全开
某团队在 200 个 endpoint 上各建 50-bucket
histogram,并开启每个 bucket 的 exemplar——series 数 \(200 \times 50 \times
label组合\) 已够大,WAL 因 exemplar 再涨
30%–50%。Grafana Mimir
max_global_exemplars_per_user 触发后 exemplar
被静默丢弃,Dashboard exemplar→Trace
跳转随机失效。修复:bucket 减到 11(§1.3)、exemplar 仅对
status_class=5xx 或采样 1% trace 挂载。
6.8 Double instrumentation:SDK + Mesh + Log 三份重复
Istio/Envoy 自动生成 span,应用又手动建 span,access log
再打印一遍 duration——Tempo 中同一跳出现
istio-proxy 与 checkout 双
span,日志量 ×3。规范:选一层为
canonical——通常应用 span 含业务 attribute,mesh
span 仅用于网络延迟;access log 降级为 WARN+ 或采样。
6.9
service.name 默认值污染
未设 OTEL_SERVICE_NAME 时 Go SDK 默认为
unknown_service:main——200
个服务可能都叫此名,Tail sampling 的
service.name=checkout policy
永远匹配不到。Prometheus target
上也无法按服务拆分。CI 必须断言
service.name != unknown_service。
6.10 Cardinality 在 Grafana 变量中的二次爆炸
Dashboard template 变量 $pod 从 Prometheus
label_values(pod) 拉取——当 pod label 合法但
churn 高时,变量下拉加载 5000 项,浏览器卡死。变量应改用
label_values(up{job="checkout"}, instance)
聚合到 deployment 级,或 recording rule 产出的
service label。
6.11 OTel Baggage 滥用传播业务字段
Baggage 会随 tracecontext
传播到所有下游——把 user_email
放入 baggage 等于全链路 PII 广播。Baggage 仅用于采样标记(如
sampling.force=true),业务字段走 span
attribute 且需 redaction。
七、OpenTelemetry Collector:埋点决策的最后一公里
谈论埋点哲学不能跳过 Collector。大多数埋点决策在 SDK 层做出(“这行代码要不要创建 Span”、“这个字段要不要打 attribute”),但 Collector 提供了对已产出数据的最后一次”把关”——它让你在不修改应用代码、不重新部署服务的前提下,对管道中的数据做过滤、增强、路由和采样。
7.1 Collector Pipeline 与埋点决策的对应关系
OTel Collector 内部由三个核心组件构成:Receiver(接收数据)、Processor(处理数据)、Exporter(导出数据)。从埋点决策的角度看,每个 Processor 类型解决的是不同层面的问题:
- Filter
Processor:扔掉不该存在的数据。如果你发现某个服务把
request_body打进了 Span attribute,在 Collector 层用filterprocessor 直接删除这个 attribute——5 分钟改配置 vs 几小时推代码+发布。这是应急止血的最快手段。 - Attributes
Processor:修正属性名不一致。团队 A 用
user_id,团队 B 用uid——Collector 层做uid→user_id的重命名,在属性字典统一之前提供过渡层。 - Resource Processor:注入或修正 Resource
Attribute。如果某个服务忘记设置
deployment.environment,Collector 可以根据来源 IP 或 K8s namespace 自动注入。 - Transform Processor:OTTL(OpenTelemetry Transformation Language)提供了图灵完备的数据变换能力——你可以用它做”从 span name 中提取 endpoint 并写入 attribute”、“把 http.url 中的 query string 部分截掉(降低基数)”、“对敏感字段做哈希”等所有 SDK 层不方便做或在 SDK 层已经太晚的操作。
- Batch Processor:不是作用于数据内容,而是作用于数据吞吐——把多个 Span/Metric/Log 批量打包发送,减少 Exporter 的网络往返次数。
- Memory Limiter Processor:OOM 保护——当 Collector 自身内存使用超过阈值时,开始丢弃数据或拒绝接收新数据,防止一个服务的埋点错误拖垮整个 Collector 实例。
7.2 Tail Sampling 在 Collector 中的正确部署模式
Tail Sampling Processor 的资源消耗取决于缓冲的 Trace 数量。以下经验法则是从多次生产事故中总结出来的:
- 每个 Trace ID 在
decision_wait期间平均占用约 1.5 KB Collector 内存(含 span 的 protobuf 表示和内部索引)。 - 给定 QPS 和 average span per trace,内存需求 =
QPS × decision_wait_seconds × 1.5 KB。例如 20000 QPS × 10s × 1.5 KB ≈ 300 MB——这是理想情况。实际中加上 Go runtime overhead 和 OTel Collector 自身的内部结构,这个数字通常要乘以 1.5–2。 - 如果 Collector
内存不够,最安全的做法不是加内存,而是分片:在
Collector 前面放一个 Layer 4 Load Balancer(如
iptables的statistic模块或 Envoy 的ring_hash),按 Span 的trace_idhash 将流量分到多个 Collector 实例。每个实例只缓冲自己那份 Trace——num_instances = total_memory_needed / per_instance_memory。 decision_wait的选择不是”越长越好”(不是”等越久越有可能收集完所有 span”),而是”等于 p99.9 端到端调用延迟”。如果你的服务链中最长的端到端延迟的 p99.9 是 2.8 秒,decision_wait设为 3 秒即可。设为 10 秒意味着 7 秒时间里每个 Trace 已经收集完所有 span,在空等。
7.3 Collector 的监控
收集器本身也是需要被监控的服务。至少应该对这些指标建立告警:
otelcol_processor_refused_spans:Tail Sampling buffer 满或 Memory Limiter 触发后的拒绝计数。如果这个指标非零,说明有 Trace 正在被丢弃——可能是因为 buffer 太小或流量突增。otelcol_exporter_send_failed_spans:Exporter 发送到后端(Tempo/Jaeger)失败。如果是持续的失败,可能后端不可用或限流——需要用磁盘 buffer 或死信队列兜底。otelcol_receiver_accepted_spans的速率:建立基线。速率突变(突然翻倍或突然归零)可能意味着上游部署变更或某服务埋点异常。otelcol_processor_batch_batch_send_size:批处理的实际发送大小。如果这个值很小(< 10),说明 Exporter 的发送频率太高、效率低。调整batchprocessor 的send_batch_max_size和timeout。
7.4 完整 Pipeline:Metrics + Logs + Traces 分流
生产级 Collector 通常按信号类型拆分 pipeline,并在 trace 路径上固定 processor 顺序:memory_limiter → tail_sampling → attributes/redaction → batch → exporter。
receivers:
otlp:
protocols:
grpc: {endpoint: 0.0.0.0:4317}
http: {endpoint: 0.0.0.0:4318}
prometheus:
config:
scrape_configs:
- job_name: otel-collector
static_configs:
- targets: [localhost:8888]
processors:
memory_limiter:
limit_mib: 8192
spike_limit_mib: 2048
check_interval: 1s
tail_sampling:
decision_wait: 5s
num_traces: 200000
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 500}
- name: baseline
type: probabilistic
probabilistic: {sampling_percentage: 1}
attributes/metrics:
actions:
- key: pod
action: delete
- key: container_id
action: delete
redaction:
allow_all_keys: true
blocked_values:
- "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b"
filter/drop_debug_logs:
logs:
exclude:
match_type: regexp
record_attributes:
- key: severity
value: DEBUG
batch:
send_batch_size: 8192
timeout: 5s
exporters:
prometheusremotewrite:
endpoint: http://mimir:9009/api/v1/push
otlp/tempo:
endpoint: tempo:4317
tls: {insecure: true}
otlphttp/loki:
endpoint: http://loki:3100/otlp
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, redaction, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, attributes/metrics, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, filter/drop_debug_logs, redaction, batch]
exporters: [otlphttp/loki]Processor 顺序原则:
| 顺序 | Processor | 原因 |
|---|---|---|
| 1 | memory_limiter | 先保护 Collector 自身 |
| 2 | tail_sampling(仅 traces) | 需完整 trace 窗口 |
| 3 | filter / attributes / transform | 在 batch 前减体积 |
| 4 | redaction | 出网前 PII 清洗 |
| 5 | batch | 最后打包发送 |
Metrics 路径不要
tail_sampling;高基数应在 attributes 或
Prometheus metric_relabel_configs
删除(§2.5)。
7.5 Gateway vs Agent 部署与埋点策略
| 模式 | 职责 | 采样决策位置 |
|---|---|---|
| Agent(DaemonSet) | 本机 OTLP 接收、batch、转发 | 可选本地 head sampling 减压 |
| Gateway(Deployment) | 集中 tail sampling、redaction、路由 | 尾部采样应在此 |
Agent 做 head 1% + Gateway 做 tail 是常见组合——Agent 减 egress,Gateway 统一 policy。注意 Agent head 采样会导致 Gateway 看不到未采样 trace 的子 span,尾部 ERROR 保留要求 SDK→Gateway 全量或 Agent 不采样。
7.6 与 11-opentelemetry 的交叉引用
SDK 初始化、Propagator 注册、Collector Helm values 的细节见 OpenTelemetry 栈。本文只固定决策结论:policy 在 Gateway 统一、PII 在 redaction、高基数 label 不进 Prometheus。
八、选型与落地清单
紧急(本周内)
短期(2–4 周)
中期(1–3 个月)
九、关键概念回顾
信号分层。请求级(Span + Request Log):正比于 QPS,必须采样。基础设施级(Metric + System Log):与 QPS 解耦,成本取决于节点数。应用级(Custom Metric):预聚合暴露,基数控制在固定范围。剖析级(Profile):时间/事件触发,开销可控,不需手动埋点。四层通过 trace_id 关联。
基数。唯一标签组合数,与数据量是不同概念。Prometheus 最敏感(一个组合 = 一个独立 series),Loki 中等(取决于 label vs 字段过滤),Tempo 几乎免疫(不索引 attribute)。Churn(series 创建/销毁速率)比稳态总数更危险。
采样。头部采样简单零开销不可逆,尾部采样保证错误+慢请求不丢但需 Span Buffer,自适应采样受反馈延迟限制。生产标准组合:头部 1% baseline + 尾部全量保留错误和慢请求。
治理。规范文档没有执行能力。必须三层自动化:CI 埋点检查 → Collector 层拦截 → Grafana 属性治理 Dashboard。属性字典是跨服务一致性的唯一保障。
十、常见误解
“全量采集总比采样好”。技术上正确,经济上不现实甚至有害。全量 Trace 成本是 Metrics 的 50–100 倍,且 TB/day 级别查询延迟升到分钟级——排障体验反而恶化。目标是”高价值数据的最高覆盖率 + 正常数据的统计性覆盖”。
“Prometheus 内存和 series 总数是简单线性关系”。每个 memSeries 约 3–5 KB 是线性的,但 churn 带来的瞬时分配、GC 和 compaction 开销是非线性的。稳态 80 万 series 可能比 churn 率很高的 40 万 series 更稳定。
“采样率越低越省钱”。到达底线前正确。底线是:能否在每次事故中稳定找到足够多异常 Trace 定位根因?过低 → 找不到 → 加更多日志补偿 → 成本更高 → 恶性循环。
“结构化日志和非结构化日志只是格式不同”。格式决定了管道能力上限。结构化日志(JSON)可被 Agent 字段解析/过滤/路由。非结构化日志每层需 regex 重新解析,CPU 成本高且规则维护噩梦。推动结构化是 ROI 最高的单一可观测性行动之一。
十一、与存储成本篇的联动
存储与成本 用 Scenario A/B 把字节换成钱(读者自填单价)。埋点哲学中的每个旋钮都映射到 worksheet 变量:
11.1 变量对照表
| 埋点决策(本文) | Worksheet 变量 | Scenario A 典型值 | 治理后(Scenario B) |
|---|---|---|---|
| Metric label 组合 / 服务 | \(N_{series}\) | \(4 \times 10^6\) | \(\times 0.5\) |
| Log 行/请求 × 采样 | \(V_{logs}\) | 全量 INFO ≈ 6.1 TiB/day | INFO 10% ≈ ×0.35 |
| Trace 头采样 × span 数 | \(V_{traces}\) | 100% head ≈ 32 TiB/day | 头 1%+尾 ≈ ×0.05–0.15 |
| Profile 频率 | \(V_{profile}\) | 1/min/pod | 通常不变 |
11.2 从埋点参数到公式
Metrics(见 storage-cost §2.2):
\[N_{samples/day} \approx N_{series} \times \frac{86400}{scrape\_interval}\]
减少 \(N_{series}\)
的手段:§2 label 白名单、§2.8 recording rule 聚合、禁止
pod/request_id label。每 halve
\(N_{series}\),Mimir/Prometheus
存储与查询成本近似 halve——与 Trace
采样杠杆同级,但常被忽视。
Logs:
\[V_{logs} = QPS \times 86400 \times lines/request \times bytes/line \times p_{log sample}\]
\(p_{log sample}\) 由 §3.9 的 filter + probabilistic 决定;ERROR 保持 \(p=1\)。
Traces:
\[V_{traces} = QPS \times 86400 \times spans/request \times bytes/span \times p_{trace}\]
\(p_{trace}\) 在纯 head 1% 时 \(\approx 0.01\);加 tail error 100% 后 \(p_{trace} \approx 0.01 + p_{error} \times (1 - 0.01)\),其中 \(p_{error}\) 为错误请求占比。错误率 0.1% 时 tail 增量可忽略;错误率 5% 时 storage 明显上升——应用埋点应减少 ERROR span 噪音(§6.1 循环 span)。
11.3 FinOps 工作流
- 用 Scenario A 填当前埋点参数 → 得 baseline 成本(自填 \(C_m, C_l, C_t\))。
- 按 §4.4 服务表改采样与 label 预算 → 填 Scenario B 倍数。
- 差值排序 ROI:通常 Trace 头采样 + Log INFO 采样 > Metrics 基数 > retention 缩短(非 SLO 数据)。
- 变更埋点规范时开 FinOps ticket,附 worksheet 截图——与 告警体系 中”成本异常告警”联动。
11.4 80/20 服务与埋点审计
storage-cost §1.3 的
topk(20, count by (job) (...)) 应对照 §4.4
策略表:Top 5 series 大户若无 documented 采样/label
例外,优先 audit。常见发现:一个内部 admin 服务把
path label 打到 2000 个 REST path——应 recording
聚合或删 path。
十二、PostgreSQL 与 DB 层 SLI 的埋点边界
PG
内核监控 在 DB 层采集
pg_stat_*、锁等待、复制延迟。这些指标不应打到应用
Trace 的 span attribute 里——DB 层 SLI 通过 federation 或
remote_write 进入 Prometheus,再与 checkout 等服务的 RED
指标在 Grafana 关联。把 pg_query 全文打进 span
attribute 是双重错误:高基数 + PII(SQL
可能含用户数据)。
推荐关联方式:
# 应用 p99(recording rule 产出)
histogram_quantile(0.99, sum by (le) (rate(checkout:http_duration_bucket[5m])))
# DB 层 active connections(federation)
pg_stat_activity_count{datname="checkout"}
Trace 中仅保留
db.system=postgresql、db.name、db.operation(Semantic
Conventions),db.statement
用参数化模板而非原始 SQL。
十三、下一步
理解了”记什么、怎么记、记多少”之后,下一个问题是这些数据在存储端长什么样。下一篇 数据模型:五大支柱的内部表达 拆解:Prometheus TSDB block 内部结构(chunk、index、WAL)、Loki 比 ES 省 5–10 倍存储的索引策略差异、Tempo”无索引 Trace 存储”的工程取舍、火焰图的本质是栈合并与压缩原理。
下一篇:数据模型:五大支柱的内部表达
参考资料
- OpenTelemetry, Semantic Conventions, v1.28+, https://opentelemetry.io/docs/specs/semconv/
- OpenTelemetry, SDK Specification — Sampling, https://opentelemetry.io/docs/specs/otel/trace/sdk/#sampling
- OpenTelemetry, Collector — Tail Sampling Processor, https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor
- W3C, Trace Context, W3C Recommendation, 2021-11-23, https://www.w3.org/TR/trace-context/
- Prometheus, TSDB Format, v2.45+, https://github.com/prometheus/prometheus/tree/main/tsdb
- Grafana Loki, Label Best Practices, https://grafana.com/docs/loki/latest/get-started/labels/
- Jaeger, Adaptive Sampling, v2, https://www.jaegertracing.io/docs/2.0/sampling/
- B. H. Sigelman et al., Dapper, a Large-Scale Distributed Systems Tracing Infrastructure, Google Technical Report, 2010
- C. Sridharan, Monitoring and Observability, Medium, 2017
- Google, Site Reliability Engineering, Chapter 6: Monitoring Distributed Systems, O’Reilly, 2016
- Grafana Tempo, Architecture, https://grafana.com/docs/tempo/latest/architecture/
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【可观测性工程】Traces 栈与采样:Jaeger、Tempo、Zipkin、SkyWalking
拆解 Jaeger、Tempo、SkyWalking 架构差异与采样策略(头部/尾部/自适应),给出 W3C TraceContext 传播、OpenTelemetry tail_sampling 配置与选型框架。
【可观测性工程】数据模型:时间序列、日志、Span、Profile 的内部表达
拆解 Metrics、Logs、Traces、Profiles、Events 五大支柱在磁盘和内存中的内部数据模型。字段级对照 Prometheus TSDB block、Loki chunk、Tempo block,给出带假设的存储成本估算公式,并解释索引策略如何决定账单与查询延迟。
【可观测性工程】存储与成本:采样、下采样、冷热分层、对象存储
可观测性数据量持续增长,存储成本常超过计算成本。拆解四大支柱的成本结构、采样与保留期策略、冷热分层架构,以及带显式假设的成本估算 worksheet。
可观测性工程
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。全 25 篇。