采样率从 100% 降到 1% 之后,我们靠这 4 种策略保住了排查精度
采样率降到 1%,精度还能稳住,靠的不是玄学,是四种工程策略的组合——动态采样、分层保留、上下文补全、以及查询时重建。
这听起来反直觉。100% 采样意味着每条请求链路都在手边,查问题跟翻书一样。1% 意味着 99% 的数据直接扔了,出了故障去哪找线索?但现实是,当 QPS 从几万涨到几百万,100% 采样产生的日志量会让存储成本失控,查询速度也崩掉。我们当时算过一笔账:QPS 峰值 120 万的业务,全量 Trace 每天产生 14TB 数据,用 Elasticsearch 存 7 天就吃掉近 100TB 磁盘,还不算索引开销。降到 1% 之后,日增量压到了 140GB,存储成本从每月 2 万多美元砍到 300 美元出头。关键问题是,那 99% 没采到的请求里,藏着慢查询、偶发错误、异常超时——排查精度怎么保?
动态采样:让采样率跟着请求“重要性”浮动
固定 1% 采样是最粗暴的做法,它假设所有请求同等重要。但实际上一台机器的 CPU 使用率 99% 时产生的慢请求,远比空闲时的普通请求值得保留。动态采样的核心思路是:给每个请求算一个“得分”,得分高的保留概率大,得分低的可以少采甚至不采。
我们在网关层实现了一套规则引擎,判断逻辑大概长这样:
type SamplingRule struct {
Condition string // 匹配条件表达式
SampleRate float64 // 0.0 ~ 1.0
}
var rules = []SamplingRule{
{Condition: "status_code >= 500", SampleRate: 1.0}, // 错误全留
{Condition: "latency_ms > 1000", SampleRate: 1.0}, // 慢请求全留
{Condition: "endpoint == '/api/payment'", SampleRate: 0.5}, // 核心接口高采
{Condition: "is_new_user == true", SampleRate: 0.3}, // 新用户路径高采
{Condition: "true", SampleRate: 0.01}, // 兜底:1%
}
规则是优先级匹配的,命中第一条就不再往下走。这样,错误请求和慢请求 100% 保留,核心支付接口保留 50%,普通请求按 1% 随机采样。实际线上跑下来,整体采样率在 1.2% 左右,但异常请求的捕获率是 100%。这个“整体 1%”不是均匀撒胡椒面,而是把采样预算集中花在了刀刃上。
实现细节上,随机采样不用 math/rand 那种简单随机,因为 1% 的低概率下,可能出现连续好几秒一条都不采的情况,导致时间轴上出现空洞。我们用确定性采样,基于 TraceID 的哈希值判断。OpenTelemetry 的 TraceIDRatioBased 就是这个思路:
if hash(trace_id) % 10000 < sample_rate * 10000 {
record()
}
这样同一个 TraceID 的所有 Span 要么全留要么全丢,不会出现断链。而且分布均匀,不会产生时间窗口的空洞。
分层保留:不存全量,但分层存关键信息
采样决定了哪些请求被记录,但即使是被“丢弃”的 99% 请求,也不是完全消失。分层保留的做法是:不同粒度的数据,存活时间不一样。
我们设计了三层结构:
| 层级 | 内容 | 保留时长 | 存储量级(日增) |
|---|---|---|---|
| 热层(Hot) | 被采样到的完整 Trace + Span | 7 天 | 140GB |
| 温层(Warm) | 全量请求的 RED 指标(Rate/Error/Duration),按 endpoint + 分钟聚合 | 30 天 | 2GB |
| 冷层(Cold) | 全量请求的摘要:TraceID + endpoint + status_code + latency_ms + 时间戳 | 90 天 | 8GB |
热层用 Elasticsearch,存完整的 Span 数据,字段包括 tags、logs、events,排查单条链路时直接查这个,但只覆盖 1% 的请求。温层是预聚合的时序指标,存进 VictoriaMetrics,每分钟每个接口的请求量、错误数、P50/P95/P99 延迟都算好。冷层最轻量,用 ClickHouse 存,每行记录只有 5 个字段加一个时间戳,压缩后一条请求不到 40 字节。
这套分层的好处是:大部分告警和趋势分析根本不需要碰热层。收到“/api/checkout 的 P99 延迟从 200ms 跳到了 1.2s”这种告警,先看温层的时序指标,确认异常时间窗口是 14:32 到 14:35。然后去冷层查这个窗口内该接口的所有请求摘要,按延迟降序排列,找到最慢的那几条的 TraceID。最后用 TraceID 去热层看完整链路——如果这几条恰好被采到了,直接定位;如果没被采到,TraceID 本身也能关联到上游的 Nginx 日志和下游的 RPC 调用日志,通过上下文补全来重建。
上下文补全:丢掉的 Span,从别处找回来
Trace 采样最大的痛点是断链。上游采了、下游没采,或者反过来,导致调用链在中间断开,排查时只能看到半截。上下文补全的思路是:不要求整条链路都在 Trace 系统里完整记录,而是利用其他系统里必然存在的日志来补齐缺失的部分。
我们强制所有服务在发起 RPC 调用时,把 TraceID 透传到下游的日志里。这不是可选项,是基础规范。Nginx 的 access log 里打上 TraceID,应用日志里也打上,数据库的慢查询日志通过注释方式带上 TraceID。这样即使某段 Span 没被采样,它的 TraceID 仍然出现在上下游的日志文件里。
排查时,如果发现热层里某条 Trace 缺了中间几段 Span,直接用 TraceID 去 grep 那几台机器的应用日志:
grep "trace_id=4bf92f3577b34da6" /var/log/app/*.log | jq '.'
日志里通常有请求入参、下游调用耗时、返回码,把这些信息拼回去,虽然不如完整 Span 那样结构化,但足以定位绝大多数问题。我们甚至写了个小工具,输入 TraceID,自动从 ES、ClickHouse、以及各机器的日志文件里拉取所有带这个 TraceID 的记录,按时间排序拼成一条“半结构化”的链路视图。对于没被采样的 99% 请求,这个视图不如原生 Trace 漂亮,但排查能力没有实质性下降。
这套机制能跑通的前提是 TraceID 透传率必须接近 100%。我们通过静态代码检查 + 中间件强制注入来保证——所有内部 RPC 框架在发请求时自动从 context 里提取 TraceID 并写入 metadata,新服务接入时不需要业务开发手动处理。唯一容易遗漏的是异步任务和定时任务,这类场景需要显式地从源头生成或传递 TraceID,我们在 Code Review 时专门有一条 checklist 项。
查询时重建:用轻量索引反推完整现场
有时候,问题排查的起点不是 TraceID,而是一个模糊的条件:某个用户在某个时间段操作失败、某台机器在某个时间点 CPU 飙高、某个错误码在 5 分钟内集中出现。如果这些请求没被采样,热层里根本没有它们的记录。
查询时重建依赖冷层里的全量摘要数据。前面提到冷层在 ClickHouse 里存了每一条请求的 TraceID + endpoint + status_code + latency_ms + 时间戳。ClickHouse 的列存储在百万级 QPS 的全量数据上做过滤聚合,速度非常快。
举个例子,用户反馈昨天下午 3 点左右支付失败。我们不知道 TraceID,只知道用户 ID。先在 Nginx 日志里用用户 ID 找到对应时间窗口的请求,拿到 TraceID 列表。如果这些 TraceID 不在热层,就去 ClickHouse 查:
SELECT trace_id, endpoint, status_code, latency_ms, timestamp
FROM request_summary
WHERE trace_id IN ('id1', 'id2', 'id3')
AND timestamp BETWEEN '2025-01-15 14:55:00' AND '2025-01-15 15:05:00'
ORDER BY timestamp DESC
返回的摘要已经能看出请求走到了哪个接口、返回了什么状态码、耗时多少。如果需要更详细的上下文,再用这些 TraceID 走上一节的上下文补全流程,去应用日志里拉完整记录。
另一个典型场景是故障范围评估。某个服务发布后,错误率从 0.01% 升到 0.5%。热层里能抓到一些错误 Trace,但我们需要知道到底影响了多少用户、集中在哪些接口。这时候直接查 ClickHouse:
SELECT endpoint, count() as errors
FROM request_summary
WHERE status_code >= 500
AND timestamp BETWEEN '2025-01-15 16:00:00' AND '2025-01-15 16:10:00'
GROUP BY endpoint
ORDER BY errors DESC
几秒内拿到全量数据,不需要依赖采样。这就是冷层的价值——没有存细节,但存了骨架,需要时再用骨架去别的系统里把肉补回来。
几种策略怎么配合
这四种策略不是各自为战,而是有明确的协作关系。动态采样决定了哪些请求进入热层,保证异常和核心链路高概率保留。分层保留让不同粒度的数据有不同的生命周期和存储成本,热层存细节、温层存聚合、冷层存骨架。上下文补全利用 TraceID 的透传机制,从日志系统里找回采样中丢失的 Span 细节。查询时重建利用冷层的全量摘要快速定位目标 TraceID,再走上下文补全恢复完整现场。
实际效果上,我们在 QPS 120 万、日请求量 100 亿次的线上环境跑了一年多。整体采样率 1.2%,存储成本下降 98%。同时,P0 故障的平均定位时间(MTTD)从 12 分钟变成了 14 分钟,多了 2 分钟,主要花在上下文补全的日志查询上。这个代价可以接受。P1 和 P2 问题的排查没有明显劣化,因为大部分场景根本不需要完整链路,温层的时序指标和冷层的摘要足够定位。
有一个值得注意的陷阱:动态采样的规则会随着业务变化而过期。半年前设置的“核心接口高采”规则,可能接口已经边缘化了,但采样率还是 50%,白白浪费存储。我们每季度 Review 一次采样规则,根据实际的接口调用量和业务重要性调整。这个维护成本不高,但容易被忽略。
常见问题
1% 采样率会不会漏掉偶发的罕见 bug?
会的,如果这个 bug 既不产生错误状态码、延迟也不超标、又不在核心接口上,动态采样规则可能完全漏掉它。实际中这种情况极少,因为真正的 bug 最终会以错误、超时、或用户投诉的形式暴露出来,而这三条路径都有对应的保留机制。如果确实有极端情况,可以通过冷层的全量摘要发现异常模式——比如某个接口的 P99 延迟正常但 P999 出现尖刺,温层的指标会触发告警。
为什么不用尾部采样(Tail Sampling)?
尾部采样是等请求完成后再决定是否保留,可以基于完整信息做判断,理论上比头部采样更精准。但它要求先把所有 Span 缓存在内存里等请求结束,在高 QPS 下内存开销巨大。我们的动态采样在网关层做决定,不需要缓存,实时判断,延迟不超过 0.1ms。对于需要尾部采样的场景(比如基于完整调用链的拓扑判断),我们用了 OpenTelemetry Collector 的 Tail Sampling Processor,但只对已经通过头部采样的 1% 请求做二次判断,不是全量。
上下文补全依赖日志,日志本身不也是全量存储吗,成本不是又回来了?
应用日志确实全量打,但日志的存储成本远低于 Trace。应用日志通常每个请求打 1-3 行,每行几百字节,而且日志系统(比如 Loki + S3)的存储成本是 Elasticsearch 的 1/10 到 1/20。我们全量日志存 30 天,日增 300GB,用 Loki 存在 S3 上,每月成本不到 200 美元。热层 Trace 存 7 天就要 140GB 的 ES 存储,成本高一个数量级。所以把细节从 Trace 挪到日志,总成本是下降的。