3.2 采样策略:全量与哈希采样

日均千万条日志里,真正有人看的不超过 1%:大部分 INFO 级的流水账,出事时根本不会翻。但「少存」不是随便丢:ERROR 必须一条不落,链路追踪必须能从头跟到尾,否则采样就等于自毁现场。采样的核心矛盾是——抽样样本既要小,又要足以重建任意一次请求的完整链路。

三段式:全量保底 + 稳态采样 + 峰值降级

实战中用得最多的是按级别分层,而不是一刀切:

层 范围 策略 依据
全量层 ERROR、FATAL、告警相关 一条不丢 出事靠它
稳态层 WARN、慢请求、首次出现的 INFO 固定比例或哈希采样 排障靠它
降噪层 高频重复 INFO(心跳、轮询) 按内容哈希聚合后存一条 几乎没人看

ERROR 全量是底线,哪怕峰值期也要保住;心跳类日志做内容指纹去重——同一句「health check ok」一小时内出现十万次,存一条带计数即可,排查时没人需要看第十万次。限流降级与峰值保护的思路可对照 PHP 秒杀实战 的分层兜底。

哈希采样:让同一条链路整进整出

按比例随机采样有个致命问题:请求链路跨十个服务,每个服务各采 10%,一次请求的完整链路被还原的概率只有百亿分之一。哈希采样解的就是这个:对请求 ID(或 trace ID)算一个哈希,哈希值低于阈值就全链路保留,高于就全链路丢。只要所有服务用同一哈希函数和同一阈值,同一条链路在任何节点上的去留都是一致的。

// 哈希采样:对 trace_id 取哈希,低于阈值整链路保留
fn sampled(trace_id: &str, rate: u32) -> bool {
    let h = murmur3::hash32(trace_id);   // 32 位无符号
    h < u32::MAX / rate                  // rate=10 即约 1/10
}
采样方式 链路完整性 实现复杂度 适用
固定比例随机 差 极低 单服务、无链路
哈希按 trace ID 好 低 多服务链路
尾部采样(按结果) 最好 高 错误链路必采

中间还有一档折中:按内容聚合采。对高频重复的 INFO 行先做指纹去重(同一句模板的日志聚成一条,附计数),再对聚合后的结果按比例采样。两道工序叠加,量最大的模板类日志实际留存率可以压到万分之一,而信息量几乎不损失——排查时你要看的是「这个模板出现了多少次、什么时候开始的」,而不是它的第一万零一次复制。指纹聚合与缓存思路相通,可对照 PHP SaaS 实战 的热点数据聚合设计。

再进一步是尾部采样:先在内存里攒一条链路的全部日志,等请求结束看到结果再决定存不存——错误链路全存、正常链路丢掉。效果好,代价是内存里要挂住整条链路,超时与容量控制非常烧脑。日志体量没到那个量级时,哈希采样加 ERROR 全量已经够用。链路追踪的完整做法可参考 Java 订单实战 里的分布式追踪章节。

哈希采样的数学直觉

为什么按 trace ID 哈希而不是按随机数?关键在于同一输入的哈希值稳定:同一个 trace ID 在任何节点算出来都一样,所以它的去留由所有节点共同判定、结论一致。而随机数每个节点独立,横竖对不齐。哈希阈值换算成采样率很直观:32 位哈希空间里,阈值取 u32::MAX / 10 就是 1/10 采样率,想动态调整只改阈值,不动哈希函数本身。

采样率 阈值(示意) 一千万条留存
100% u32::MAX 1000 万
20% u32::MAX / 5 200 万
10% u32::MAX / 10 100 万
1% u32::MAX / 100 10 万

注意留存条数换算必须配合上文说的采样率元数据,否则统计口径就乱了。采样精度还和窗口有关:稀疏的 trace ID 哈希分布均匀,直接到位;如果 trace ID 是自增整数( extremebad case),哈希分布会有聚集,此时换雪崩性更好的 murmur3 或 xxHash 即可。

采样率怎么定

采样率没有银弹,按级别与业务模块分档是常规操作(常见值,供参考):

级别 建议采样率 备注
ERROR/FATAL 100% 永远别省
WARN 100% 量小信息多
慢请求 100% 性能问题的现场
INFO 10%~20% 哈希按 trace ID
DEBUG 1% 或关 只在排障期开

一个容易忽略的规则:采样率必须可随负载动态调整。平时 20%,峰值期自动降到 5%,过了高峰再升回来——这正是第四篇热更新的用武之地。采样阈值放在 Redis 里做全局计数器,多实例之间共享一个口径,避免每台机器各采各的导致总量不可预期。

采样后必须留元数据

采掉的是日志,不能采掉可观测性。每一段采样后的数据都要带上:采样率(事后还原总量)、被丢弃的计数(监控丢弃率)、采样窗口的时间戳。否则容量规划时你连「今天到底有多少条」都答不出来。

// 采样元数据随段一起落盘
struct SampleMeta {
    rate: u32,            // 本段采样率,如 10
    dropped: u64,         // 本段丢弃条数
    window_secs: u64,     // 采样窗口
}
元数据 作用 丢了会怎样
采样率 事后估算总量 统计全错
丢弃计数 监控丢弃率 故障时没线索
窗口时间 对齐多实例 口径混乱

采样窗口与全局限流要联动:窗口太短(秒级),多个实例的采样率各自抖动,汇总后曲线锯齿严重;窗口太长(小时级),降级反应慢,高峰都过去了才把采样率降下来。实战取 10 秒到 1 分钟之间,配合 Redis 的过期 key 做全局窗口计数,多实例共享一份口径。窗口计数的具体实现可对照 Go 即时通讯实战 的在线计数设计。

踩坑提示

最经典的坑是哈希函数不一致:A 服务用 crc32、B 服务用 fnv,同一 trace ID 在 A 被采在 B 被丢,链路断成两截。解法是把哈希函数和阈值一起写进公共库,版本号跟着发版走。第二个坑是采样在错误后才开始:请求出错时才想开全量,但前半段日志已经被采掉了——尾部采样或保留「最近 N 秒滚动窗口」才能兜住。第三个坑是计数器漂移:采样率频繁切换时,每个窗口的计数要对齐时钟,否则多个实例汇总出来多算或少算一截。多实例计数对齐的做法可对照 Go 微服务实战 的分布式计数章节。

采样不是丢日志的借口

最后一道观念上的坎:采掉的日志等于在监控盲区里。任何采样方案上线前,先问自己三个问题:ERROR 真的一条不漏吗?采样率变更时有记录吗?被丢弃的日志有没有聚合统计(比如按错误码汇总丢弃条数)?三个都能打保票再上线。否则某天线上出了诡异故障,翻日志发现关键行被采掉了,采样省下的钱还不够一次故障复盘的工时。

自查项 不达标后果
ERROR 全量 故障现场丢失
采样率变更留痕 回溯时对不上口径
丢弃日志聚合计数 总量与趋势全靠猜

说到底,上线前把这三条写进发布清单,比事后补监控强一百倍。

小结

全量保 ERROR 与 WARN,INFO 用 trace ID 哈希采样,心跳类做内容指纹去重;采样率随负载动态调整并配上热更新;元数据与丢弃计数一起落盘,保证事后可还原总量。采样的本质是把存储花在「将来会看」的日志上,而不是均匀地撒在每一条上。一句话总结:保底层一条不落,链路层整进整出,噪音层聚合计数,元数据跟着段走。

延伸阅读

  • 采样率的配置热更新,见 4.3。
  • 丢弃率的监控告警,见 5.2。
  • 分布式追踪的完整链路,见 Java 订单实战。
  • 限流与降级兜底,见 PHP 秒杀实战。