采样采错地方,trace 全断链:OTel 尾部采样的代价账

采样采错地方,trace 全断链:OTel 尾部采样的代价账

上周四 14:23,支付回调失败率冲到 3%。你从错误日志里抄下一条 trace_id,打开 Jaeger 一搜------空白。换一条,还是空白。(构造典型案例,下同)

复盘翻到两周前的变更:为了省存储,四个服务的 SDK 各配了 10% 采样。事故链路一条都没留下。这篇把这笔账从头算清:head 采样为什么断链,tail 采样要付什么代价,processors 顺序错在哪,以及出口的队列与背压怎么兜底。

一、head 采样:独立掷骰子必断链

head 采样在 span 诞生那一刻做决定。SDK 在应用进程里掷骰子,10% 保留、90% 当场丢弃------被丢的 span 根本不离开进程。这是它便宜的原因:网络、CPU、存储在源头就省了,Collector 什么都看不见。

坑在「各自掷骰子」四个字。四个服务各自独立采 10%,一条贯穿四跳的 trace 完整到达后端的概率是 0.1 的四次方------万分之一。后端里不是「少了点数据」,而是躺满断链碎片:只有入口的、只有中段的,每段都引向一个搜不到的父 span 或子 span,就是连不成链。

修法是 ParentBased【从业者判断】:只有链路入口掷一次骰子,结果写进 trace 上下文(traceparent 头)随请求传递;下游服务读到 sampled 标志就跟随,不再自己掷。

链不断了,但决定权还留在第一跳------入口采 1%,意味着 99% 的错误 trace 从未存在过。不是丢了,是从没被记录过,日志里有 trace_id 也无处可搜。

独立采样断链,父子采样瞎丢------head 采样的两难躲不掉。 它丢的是「还不知道有没有用」的数据,且不可撤销。

二、tail 采样:决定后移,代价是等和存

想把「保链」和「保错误」同时要到手,只能把决定往后挪:等 trace 的 span 到齐了再判。这就是 tail sampling------Collector 把同一条 trace 的所有 span 攒在内存里,等齐(或等到超时)再整体决定去留:错误的整条留,正常的整条抽。

两笔代价,都实打实。

代价一:内存。未决 trace 全部挂在 Collector 进程内存里,挂多少条、每条多少 span,直接决定 gateway 的内存水位;吞吐翻倍,这个缓存近似翻倍【从业者判断】。

代价二:宕机丢失窗口。从 span 到达、到判完发出,这整段窗口里数据只活在内存里------Collector 一挂,窗口内所有未决 trace 全部蒸发。

注意这与 head 采样不同:head 丢的是「没进门的数据」,tail 丢的是「已经收下、正在打算盘的数据」。exporter 的 sending_queue 只能缓冲「判完待发」的,缓冲不了「还没判」的。

tail 采样用内存和宕机风险,换错误 100% 在场。

三、agent 与 gateway:谁近源,谁全局

tail 采样还有第三个约束:必须挂在能看到全量 span 的位置。这让部署形态从「随便选」变成「算位置」。

K8s 里规模上来了就是分层:应用 → 节点 agent(DaemonSet)→ 中心 gateway → 后端们。agent 干贴身的活(k8s 元数据富化、快速截断),gateway 干需要全貌的活(tail 采样、路由、限流)。

为什么 agent 上不能做 tail 采样:跨节点的 trace,一半 span 在节点 A 的 agent 内存里,另一半在节点 B,谁都只见半条链,谁都做不了整条决定。例外只有「全链路都在单节点」的旧应用。

模式 实例数 优点 代价 定位
sidecar 每 Pod 一个 同生命周期、强隔离、按应用配策略 资源 × Pod 数、运维面大 多租户、Serverless
节点 agent 每节点一个 省资源、能采节点面数据 单点=整节点、升级影响全节点 近源富化
中心 gateway 少量、可扩缩 统一策略、多后端收敛点 跨可用区流量、要做高可用与容量规划 全局采样

两层策略必须一起设计:gateway 做 tail 采样,SDK 侧就得高比例甚至全量 head 上报------span 在 SDK 门口被丢,gateway 就没得判。这句反过来就是账单:tail 采样的隐性成本,含着一份「全量上报」的网络流量。

采样点选在哪层,等于决定谁能看到多大的链路全貌。

四、processors 顺序:数组顺序就是执行顺序

Collector 的 processors 数组顺序就是执行顺序,顺序错了语义就错。traces 管道里三个经典坑:

memory_limiter 永远第一。它的语义是「在入口评估内存、拒收新数据」;若排在 batch 之后,数据已被 batch 持有,限流器看到的全是「已进来」的,闸门形同虚设,OOM 保护失效。

k8sattributes 排在 tail_sampling 之前【从业者判断】。采样策略常按属性过滤------比如生产 namespace 全保、某个服务 100%。富化排在采样后,策略读到的属性是空的,静默地一条都匹配不上:不报错,只是采样行为和你以为的完全不同。

batch 排在采样之后、管道末端。先采样再批量,批的是减量后的数据;batch 天生合并「即将导出」的数据,靠近末端。代价是至多 timeout 的延迟(默认 200ms,常配到 5s)。

yaml 复制代码
# [中心 gateway] 顺序即语义:processors 与 traces pipeline(receiver 与 exporter 定义见第六节,拼起来才是可跑配置)
processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512           # 软上限,约为容器 memory limit 的 75%
    spike_limit_mib: 128     # 突发缓冲,硬上限 = limit + spike
  k8sattributes:
    extract:
      metadata:
        - k8s.namespace.name
        - k8s.pod.name
        - k8s.deployment.name
  tail_sampling:
    decision_wait: 15s       # 等 span 到齐的窗口
    num_traces: 100000       # 内存里同时挂着的未决 trace 上限
    policies:
      - name: errors         # 错误 trace 100% 保留
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow           # 慢请求 100% 保留
        type: latency
        latency: {threshold_ms: 800}
      - name: baseline       # 正常流量 5% 兜底
        type: probabilistic
        probabilistic: {sampling_percentage: 5}
  batch:
    timeout: 5s
    send_batch_size: 1024

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, tail_sampling, batch]
      exporters: [otlp/jaeger]

(tail_sampling 的字段名与默认值随版本演进,以所装版本文档为准【从业者判断】。)

顺序口诀:限流在最前,富化在采样前,批量在最后。

五、组合策略:错误全保,正常抽样

上面配置的灵魂是策略组合:策略之间是「或」的关系,任何一条命中,整条 trace 保留【从业者判断】。于是错误 trace 100% 在场,慢请求 100% 在场,正常流量按 5% 兜底------排障时错误不缺证据,容量上又不会被打爆。

这笔账要算两边。省下的:95% 正常 trace 的存储与后端计算。花掉的:全量 span 涌向 gateway 的网络、内存里挂着的未决 trace 缓存、gateway 宕机时的丢失窗口。组合策略不是免费午餐,是把成本从后端存储搬进了管道内存。

验证方式很直观,给管道注入一条错误 trace:

bash 复制代码
# [任意节点] 注入一条 status=ERROR 的 trace(OTLP/HTTP 可直接收 JSON)
curl -s -X POST http://localhost:4318/v1/traces \
  -H 'Content-Type: application/json' \
  -d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"demo-api"}}]},"scopeSpans":[{"spans":[{"traceId":"5b8efff798038103d269b633813fc60c","spanId":"eee19b7ec3c1b174","name":"POST /callback","kind":2,"startTimeUnixNano":"1730000000000000000","endTimeUnixNano":"1730000000200000000","status":{"code":2,"message":"500"}}]}]}]}'
# 预期:返回 200 空响应。立刻去后端搜这个 traceId------搜不到,
# 它还压在 decision_wait 的等待窗口里;等 20 秒左右再搜
# (15s decision_wait + 至多 5s batch),100% 在场

「发完搜不到、等一会儿才出现」------这个延迟本身就是 tail 采样的工作证明。

六、出口三本账:sending_queue、retry、背压

采样解决「存多少」,出口配置解决「断不断」。「应用 → Collector」和「Collector → 后端」是两段独立缓冲:SDK 侧有自己的批量队列,Collector 侧靠 exporter 的 sending_queue。

yaml 复制代码
# [中心 gateway] exporter 级重试与排队
exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
    sending_queue:
      enabled: true
      num_consumers: 4
      queue_size: 4096       # 后端不可用时的缓冲容量
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 30s
      max_elapsed_time: 120s # 重试超过 2 分钟则放弃(丢弃)

queue_size 不是拍脑袋:峰值吞吐 × 后端最长可预期故障时长 ÷ send_batch_size。按 2000 spans/s、后端可能抖 120 秒算:2000×120÷1024 ≈ 235 批,取 512 留余量;配方里的 4096 是高吞吐起步值,落地前按公式重算。锚点是实测吞吐------看按小时分位的峰值,不看均值,更不看感觉【从业者判断】。

背压是一条完整的减压链:后端变慢 → exporter 队列上涨 → memory_limiter 触到软上限熔断拒收 → 拒收沿管道反压回入口(refused 计数上涨,SDK 会重试)→ SDK 队列在应用进程里堆积 → 堆满后丢弃,老数据让位新数据。链条末端是业务进程的内存------Collector 过载,最终由应用买单。

三种「丢法」必须分清:refused 是入口拒绝,数据还在 SDK 队列里、会重试;dropped 是进了管道却被丢;send_failed 是重试用尽后丢弃。后两种是永久丢失。排查「后端少数据」,先看三个计数器谁在涨,再决定是扩 Collector 还是修后端。

还有一本重启账:sending_queue 默认在内存,Collector 重启即清零------连重启都不许丢的场景,把 sending_queue.storage 指到 file_storage extension,让队列落盘。

queue 满了依然会丢;容量预算是给后端故障时长上保险。

七、Collector 自己也要被监控

采集层自己瞎了,全集群的可观测性一起瞎。Collector 默认在 8888 端口暴露 otelcol_* 内部指标,指标名随版本演进,以自己 /metrics 端点的实际输出为准:

指标(示例名) 回答的问题
otelcol_receiver_refused_spans 入口拒收量,非 0 = Collector 过载
otelcol_processor_dropped_spans 进了管道却被丢的量
otelcol_exporter_queue_size / capacity 发送队列水位,涨向 capacity 是「后端跟不上」最早信号
otelcol_exporter_send_failed_spans 重试后仍失败(出口方向丢失)
otelcol_process_memory_rss 内存水位,对照容器 limit 与 limit_mib

两条查询直接进大盘:

promql 复制代码
# 队列水位:持续大于 0.8 就是「即将丢数据」
otelcol_exporter_queue_size / otelcol_exporter_queue_capacity

# 入口拒收:非 0 说明 SDK 正在重试堆积,减压链已启动
otelcol_receiver_refused_spans
bash 复制代码
# [任意节点] 快速看一眼三种丢法谁在涨
curl -s http://localhost:8888/metrics | grep -E 'receiver_refused|processor_dropped|send_failed'
# 预期:过滤出各计数器的当前值;隔几秒抓两次对比,谁在涨,丢数据的位置就在哪一跳

一个反直觉告诫:别用「Collector 抓自己再转发」做主路径------后端故障时这条路跟着一起死,自我观测必须落在被观测对象之外。

观测管道的第一课:先观测观测者。

八、反方:不是所有链路都值得 tail 采样

拆台时间。

其一,tail 采样的前提是全量上报。低流量系统全量不采样,存储未必比「gateway 内存 + 高可用改造」贵【从业者判断】。

其二,decision_wait 既加排障延迟,又造丢失窗口。为了「错误全保」引入「gateway 一挂未决全丢」的新单点,在部分可用性要求下是负收益【从业者判断】。

其三,穷人的替代品存在:错误 100% 进日志、日志 100% 保留,trace 只负责还原细节现场------head 采 1% 也许就够,因为你手里已有 100% 的「错误发生记录」【从业者判断】。

采样策略的正确问法:错误发生时,你手里有什么。

现在就能做的三件事

第一,把那两条查询贴进 Prometheus,看队列水位与 refused 此刻是多少------多数人第一次看都会愣住【从业者判断】。

第二,翻开 Collector 配置检查 processors 数组:memory_limiter 是不是第一个?k8sattributes 是不是排在采样前?

第三,用公式算一遍 queue_size:峰值吞吐 × 后端最长可预期故障时长 ÷ batch size,对一下现值------差一个数量级的,今晚就改。

命令、配置与容量公式的底稿,都在我的学习仓库:GitHub 搜 sre-learning-hub,OTel 模块的 Collector 章节有完整可跑的配方。

评论区报个数:你现在 head 还是 tail?采样率多少?出过「日志里有 trace_id、后端里搜不到」的事故吗------那次最后背锅的是谁?

相关推荐
匆匆那年随风41 分钟前
零依赖 C# Office 库到底怎么用——四个场景讲透 Wisdom.Office
后端
今天早睡41 分钟前
用子图封装流程,设计清晰的父子状态接口
后端
Lyy41 分钟前
DevOps平台 — 第十七篇:测试管理的设计与实现
后端·devops
用户EasyAdminBlazor44 分钟前
用 EasyAdminBlazor + OpenCode 开发进销存:实际做了什么,花了多久
后端
ByteRock44 分钟前
同样是 Decimal,为什么 MySQL 和 ClickHouse 会写成两套世界
后端
leo在掘金44 分钟前
一天155万亿Token,DeepInfer省在哪
后端·架构
2601_954811821 小时前
人工智能机器视觉实验箱选型:昇腾算力与工业数据集架构对比
人工智能·架构
阿良别做梦1 小时前
LangGraph 学习之路:让工作流返工,也让它及时停下
后端·开源
我的div丢了肿么办1 小时前
go语言抽离路由文件以及对路由进行分组
后端·go