OpenTelemetry 采样别再全量开了:我把链路存储成本压到原来的 1/5

说实话,我一开始也觉得链路追踪这种东西,当然是全量开才安心。

直到账单出来。

那次我们把 OpenTelemetry Collector 接进生产后一周,Jaeger 的 ES 存储量从每天 180GB 直接冲到 900GB。更麻烦的是,查询速度也开始明显变慢,排查一次超时请求,点开 trace 列表要转十几秒,值班同学已经开始抱怨“这玩意儿比没接的时候还费劲”。

后来我才彻底想明白:很多团队不是不会做可观测性,而是把“采得越多越安全”当成默认前提。这个前提一旦不拆,链路系统迟早会先把自己拖垮。

这篇我就把当时那套采样改造过程讲清楚,包括我怎么判断该不该从全量追踪撤退,怎么落地 head sampling + tail sampling 的组合策略,以及最后怎么把存储成本压到原来的 1/5,同时保住关键问题的定位能力。

为什么全量追踪很容易把自己做废

全量追踪最诱人的地方,是心理上很踏实。出了问题,总觉得“反正 trace 都在,之后再查”。

但现实是,大多数请求根本不值得完整存档。健康检查、静态资源、内部低价值轮询、成功率接近 100% 的短链路接口,如果全部保留,留下来的不是洞察,而是噪声。

我们当时的问题有三个。

第一,存储成本失控。高峰期每秒 1.8 万请求,平均一个请求拆出 8 到 15 个 span。把日志和指标分开看还不明显,一旦 trace 量级飙起来,ES 磁盘和索引合并压力就上来了。

第二,查询体验变差。Jaeger UI 不是不能查,而是候选 trace 太多,真正有价值的异常请求被埋在大量 200 OK 的成功调用里。值班时找根因,反而要先和噪声打架。

第三,Collector 自己开始吃紧。批处理队列、导出器重试、后端写入限速,层层叠加后,链路系统本身成了新的不稳定因素。

当时我定了一个很朴素的判断标准:如果一套观测系统为了保住“可能有用”的数据,反过来拖慢了排障效率,那它就已经偏离目标了。

别急着砍采样,先把链路流量分层

我后来没有一刀切地把采样率直接砍到 10%,因为那样很容易误伤真正关键的流量。更稳妥的做法,是先给 trace 分层。

我把请求大致拆成四类。

第一类是必须保留的异常流量。只要状态码异常、span 标记 error、或者耗时超过阈值,就尽量全留。

第二类是高价值业务流量。比如支付、下单、登录、风控命中,这些链路哪怕成功,也值得保留更高比例。

第三类是普通在线请求。大部分查询、列表、配置拉取都在这里,适合做比例采样。

第四类是低价值噪声。健康检查、Prometheus 抓取、后台轮询、静态资源请求,原则上能不进追踪系统就别进。

这个分层动作很关键,因为它决定了后面的采样规则不再是“一把尺子量所有请求”,而是按业务价值分开处理。

我最后用的是两段式采样:入口先削峰,出口再挑重点

只用 head sampling 有个典型问题:请求一进来就决定采不采,后面即便变慢、报错,也可能早就被丢了。

只用 tail sampling 也有代价:你得先缓存一段时间的 trace,等整条链路结束后再决定保不保留,对 Collector 内存和队列配置要求更高。

所以我最后落的是组合方案。

第一层用 head sampling,先在入口挡掉明显低价值流量,避免所有数据都冲到后端。

第二层用 tail sampling,专门保护异常、慢请求和高价值业务,把真正值得看的链路兜住。

整体逻辑可以概括成一句话:先把洪水变成河流,再从河里留下金子。

第一步:在 SDK 或入口网关先做基础过滤

如果你的入口已经很清楚哪些请求天然不需要追踪,最好在最前面就拦掉。

比如健康检查和静态资源,我会直接在 SDK 层过滤:

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  filter/drop-noisy-spans:
    error_mode: ignore
    traces:
      span:
        - 'attributes["http.target"] == "/healthz"'
        - 'attributes["http.target"] == "/metrics"'
        - 'attributes["http.route"] == "/internal/ping"'
        - 'attributes["http.method"] == "OPTIONS"'

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [filter/drop-noisy-spans]

这一步不要想着做复杂策略,目标只有一个:把确定没价值的垃圾流量挡在门外。

我们当时只靠这一步,就把 trace 总量先打掉了大概 22%。

第二步:普通流量做 head sampling

剩下的在线请求,我在入口用了概率采样。比例不是拍脑袋定的,而是按照每个服务的 QPS 和后端写入预算反推。

配置大概是这样:

processors:
  probabilistic_sampler/default:
    hash_seed: 22
    sampling_percentage: 15

这里我踩过一个坑。很多人会给所有服务统一一个 10% 或 20% 的采样率,看起来简单,实际上很粗暴。高 QPS 的网关服务和低 QPS 的管理后台,本来就不该用同一把尺子。

我的做法是:

  • API 网关:10% 到 15%
  • 核心业务服务:20% 到 30%
  • 低频后台任务:50%
  • 支付、登录这类关键链路:不靠这一层降,交给后面的 tail sampling 兜底

这样做完以后,Collector 的入口流量先稳住了,后端写入峰值也跟着降了一大截。

真正保命的是 tail sampling,不是概率采样

真正让我觉得“这套改造值了”的,是 tail sampling 上线之后。

因为生产里最怕的不是少看几个正常请求,而是把真正出错的请求也一起采没了。tail sampling 的意义,就是等整条 trace 结束之后,再根据结果决定保留谁。

下面这份配置,是我后来稳定跑了挺久的一版思路:

processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 50000
    expected_new_traces_per_sec: 3000
    policies:
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]

      - name: keep-slow-requests
        type: latency
        latency:
          threshold_ms: 1500

      - name: keep-payment-service
        type: string_attribute
        string_attribute:
          key: service.name
          values: [payment-service, order-service, risk-service]

      - name: keep-important-http
        type: numeric_attribute
        numeric_attribute:
          key: http.response.status_code
          min_value: 500
          max_value: 599

      - name: sample-the-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

这套配置的核心很直接。

报错请求,全留。

超过 1.5 秒的慢请求,全留。

支付、下单、风控这些关键服务,高比例甚至全留。

其余普通请求,只保留 5%。

这种策略最舒服的地方,是值班时你不用再祈祷“希望这个故障请求刚好被采到”。因为真正要命的流量,本来就被规则重点保护了。

我怎么验证这套采样不是自欺欺人

采样方案最怕的是看起来很省钱,实际上把诊断能力一起省没了。

所以我没有只盯着“量降了多少”,而是同时看三组指标。

第一组是成本指标:每天 trace 写入量、存储占用、ES 索引增长速度、Collector 导出吞吐。

第二组是可用性指标:Jaeger 查询耗时、trace 搜索成功率、Collector 队列堆积、导出失败次数。

第三组是诊断有效性指标:最近一周的 P1/P2 故障里,关键 trace 是否都能找到;慢请求样本是否足够还原问题;高价值服务的关键事务链是否还完整。

我把改造前后的结果整理成了一张很粗暴但很好用的对比表:

指标 改造前 改造后
每日 trace 存储量 900GB 178GB
Jaeger 平均查询耗时 12.4s 3.1s
Collector 导出失败率 2.8% 0.3%
异常请求 trace 保留率 约 91% 99%+
慢请求样本覆盖率 约 54% 96%

这里最关键的其实不是“存储压到 1/5”,而是异常请求和慢请求的覆盖率反而上去了。

这说明我们删掉的大部分数据,本来就不值得留。

这几个坑我建议你提前绕开

1. decision_wait 设太短,tail sampling 会漏关键 span

一开始我把 decision_wait 设成了 3 秒,结果跨服务调用还没完全收齐,Collector 就已经提前做决定了。后来一些慢请求明明很关键,却因为尾部 span 晚到,被错误判成普通流量。

如果你的服务链路深、消息队列多、异步处理多,decision_wait 一定别太保守。宁可先给 8 到 15 秒,也别急着压太低。

2. num_traces 配太小,会把 Collector 内存和丢样一起搞崩

tail sampling 不是白来的,它需要在内存里暂存 trace。高峰期并发一上来,如果 num_traces 太小,新的 trace 会把旧的挤掉,最后你以为自己开了策略,实际关键链路根本留不住。

我的建议是先按峰值 RPS * decision_wait 粗算一个量级,再看 Collector 实际内存曲线去调。

3. 只盯错误,不盯慢请求,很多问题会漏

真实生产里,很多事故不是直接 500,而是先慢,再重试,再雪崩。你如果只保留 error trace,很容易看不见最早的退化阶段。

所以慢请求阈值一定要有,而且最好按服务层级分别设置,不要全站统一一个数字。

4. 采样规则没人维护,三个月后一定变脏

服务名会变,接口会扩,业务优先级会调整。采样规则不是一次性工程,它本质上是一份运行中的业务策略。

我后来把关键服务名单和阈值都放进配置仓,走变更评审,不让它继续野长。

一套更稳的落地顺序

如果你现在也在被 trace 成本压着打,我建议别一步到位,按下面这个顺序来:

  1. 先统计过去 7 天哪些流量最吵,明确低价值请求清单。
  2. 在入口过滤健康检查、静态资源、内部心跳这类噪声。
  3. 给普通在线请求加基础概率采样,先把总量降下来。
  4. 再补 tail sampling,保护 error、慢请求和关键业务服务。
  5. 用真实故障回放验证样本是否足够,而不是只看成本下降。
  6. 给 Collector 加自监控,盯住队列堆积、导出失败和内存占用。

这个顺序的好处是,每一步都能独立验证,不会因为一次大改把链路系统直接掀翻。

我现在怎么判断一套采样策略是不是靠谱

我现在看采样方案,不会先问“采样率多少”,而是先问三个问题。

第一,异常请求能不能稳定保留?

第二,关键业务链路是不是被单独照顾了?

第三,观测系统本身会不会因为保留太多无效样本而变慢?

这三个问题答不清楚,采样率写成 5% 还是 50%,本质上都只是运气。

链路追踪真正的价值,不在于把所有请求都留下来,而在于出事的时候,你能在最短时间里看到最该看的那部分。

写在最后

我后来挺少再说“全量追踪更安全”这种话了。

对大多数团队来说,更现实的答案是:关键链路全力保,普通流量按价值采,噪声流量尽早丢。这样你得到的不是缩水版的可观测性,而是一套终于能长期跑下去的可观测性。

如果你现在的 Jaeger、Tempo 或 ES 已经开始因为 trace 量太大变慢,别急着先扩盘。先看看你是不是把太多根本不会再看的请求,认真地存了下来。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐