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