如何采样日志?

解读

在国内 Rust 后端/中间件面试中,“采样日志”不是问“怎么打日志”,而是考察在高并发、低延迟场景下如何既保留排障能力,又避免日志打爆磁盘、阻塞业务线程。面试官想听你给出可落地的 Rust 方案,包括:

  1. 采样策略(随机、计数、自适应、尾采样)
  2. 线程间无锁协作
  3. 与异步运行时(Tokio/async-std)集成
  4. 线上动态开关与回捞机制
  5. 对性能指标(QPS、P99、CPU)的量化控制

一句话:让日志“少而精”,且编译器帮你保证线程安全

知识点

  1. 采样算法:随机采样、计数采样、漏桶/令牌桶、自适应采样(如 Google 的“每秒前 N 个”)、尾采样(Tail-Based Sampling)
  2. Rust 并发原语:AtomicU64、crossbeam::atomic::AtomicCell、parking_lot::Mutex、tokio::sync::mpsc、once_cell::sync::Lazy
  3. 异步日志通道:tokio::spawn_blocking + tracing-appender,或异步无锁队列(spsc/bounded)
  4. 动态配置:etcd/apollo 推送 → watch 任务 → Arc<AtomicU64> 热更新采样率
  5. 观测性指标:prometheus 的 rust crate 暴露 log_dropped_total、log_sampled_total,配合 Grafana 告警
  6. 内存与零拷贝:使用 bytes::Bytes 避免每次分配,tracing 的 Value::Debug 延迟序列化
  7. 编译期保证:Rust 的所有权模型保证“日志缓冲区跨线程传递时无悬垂指针”,无需 GC 停顿

答案

下面给出一个生产级、可热更、异步无阻塞的 Rust 采样日志骨架,可直接嵌入 Tokio 服务:

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use once_cell::sync::Lazy;
use tokio::sync::mpsc;
use tracing::{info, warn};

/// 全局采样率,0..=100,热更新
static SAMPLE_RATE: Lazy<Arc<AtomicU64>> = Lazy::new(|| Arc::new(AtomicU64::new(1)));

/// 每秒计数器,线程本地避免 false sharing
thread_local! {
    static COUNTER: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}

/// 快速路径:决定是否采样
#[inline]
fn should_sample() -> bool {
    let rate = SAMPLE_RATE.load(Ordering::Relaxed);
    if rate == 0 { return false; }
    if rate == 100 { return true; }
    COUNTER.with(|c| {
        let v = c.get().wrapping_add(1);
        c.set(v);
        v % (100 / rate) == 0
    })
}

/// 异步日志任务,批量写盘
async fn logger_task(mut rx: mpsc::Receiver<String>) {
    const BATCH: usize = 1024;
    let mut buf = Vec::with_capacity(BATCH);
    while let Some(line) = rx.recv().await {
        buf.push(line);
        if buf.len() >= BATCH {
            flush(&buf).await;
            buf.clear();
        }
    }
    if !buf.is_empty() { flush(&buf).await; }
}

async fn flush(batch: &[String]) {
    // 零拷贝写盘,或发送到 Kafka/Pulsar
    tokio::fs::write("/tmp/app.log", batch.join("\n")).await.unwrap();
}

/// 业务代码中使用
#[tokio::main]
async fn main() {
    let (tx, rx) = mpsc::channel(10_000);
    tokio::spawn(logger_task(rx));

    for i in 0..1_000_000 {
        if should_sample() {
            let _ = tx.send(format!("request_id={} latency=120µs", i)).await;
        }
        // 模拟业务
        tokio::task::consume_budget().await;
    }
}

关键点

  1. should_sample无锁、线程本地的快速路径,单条判断 <10 ns,对 P99 无影响
  2. SAMPLE_RATE 可通过 HTTP 接口或配置中心热更新:
    SAMPLE_RATE.store(new_rate, Ordering::Relaxed);
  3. 通道背压保护:mpsc 容量 10k,满则主动丢弃(可计数告警),避免阻塞主流程
  4. 批量 flush 减少 syscall,磁盘 IO 与业务线程彻底解耦
  5. 若需尾采样(只保留异常链路),可在 tracing span 关闭时统一判断状态码,再决定是否落盘

拓展思考

  1. 自适应采样:结合 prometheus 的 log_rate 指标,用 PID 控制器动态调整 SAMPLE_RATE,使日志条数恒定在“每秒 1k 条”以内
  2. 多维度采样:对错误码=500 的请求采样率 100%,正常 200 请求采样率 1%,用分层采样实现“重要信息不丢,普通信息压缩”
  3. eBPF 加速:在内核态用 Rust aya 框架做 kprobe,把采样逻辑下沉到网卡驱动,用户态零开销
  4. 合规与回捞:国内金融场景要求“日志留痕 6 个月”,可先把采样日志写本地 SSD,再异步压缩上传到 OSS;当故障发生时,通过 trace_id 回捞全量链路,10 分钟内完成
  5. 与 OpenTelemetry 集成:使用 opentelemetry-rust 的 BatchSpanProcessor,把采样决策推迟到 collector 端,实现跨服务尾采样,同时保持 Rust 侧无锁高速埋点