用统计学给 Pingora 服务瘦身:Rust 如何把全网内存再降 100TB

2026-09-19 17 预计阅读时间: 1 分钟
来源: blog.cloudflare.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

在单台服务器上节省几 MB 内存,通常不值得专门写一篇文章;但当同一项服务部署到庞大的全球网络中,每个进程、每个请求或每个键多占用一点状态,最终都会被副本数放大。Cloudflare 这次针对一个基于 Pingora 的服务,用统计方法替代部分高成本状态,将全网 RAM 用量进一步削减了约 100TB。

来源摘要没有披露具体采用了哪一种统计结构,因此下面不会猜测其内部实现,而是分析这类优化背后的工程方法,并用一个可运行的 Rust 示例演示:如何把随键数量增长的精确计数,改造成内存固定的近似计数。

真正昂贵的往往不是计算,而是“记住一切”

高吞吐代理或边缘服务经常需要记录类似状态:

  • 每个客户端、租户或资源的请求次数;
  • 某类事件是否已经出现;
  • 流量分布、热点键和异常频率;
  • 一段时间窗口内的限流或观测数据。

最直接的实现是为每个键维护一个哈希表条目:

HashMap<Key, Counter>

这种方案精确而直观,但内存会随基数增长。实际成本也不只是 Key + Counter:哈希桶、负载因子、对齐、分配器元数据以及键本身的所有权都会增加常驻内存。若状态还要按线程、CPU 核或 worker 复制,成本会继续放大。

全网节省量可以粗略理解为:

全网节省内存 ≈ 单进程节省量 × 进程数 × 每进程副本数

这不是精确容量模型,却揭示了为什么边缘基础设施如此重视“小优化”。每实例节省几十 MB,在足够多的机器和服务副本上就可能变成 TB 级结果。

关键问题不是“怎样让 HashMap 再快一点”,而是业务是否真的需要保存每个键的精确答案。如果需求只是判断热点、估算频率或触发近似阈值,那么统计结构通常更合适。

用有界误差换取有界内存

以 Count-Min Sketch 为例,它不为每个键保存独立记录,而是维护若干行固定宽度的计数器。更新一个键时,使用多个哈希函数分别定位计数器;查询时取这些计数器中的最小值。

它具有几个适合大规模服务的特征:

  • 内存由宽度和深度决定,与不同键的数量无关;
  • 更新和查询时间固定;
  • 估计值通常只会高估,不会低估,前提是计数器没有溢出;
  • 哈希碰撞会引入误差,宽度越大,误差通常越小。

在标准假设下,宽度 w 和深度 d 可对应大致的误差与失败概率:

ε ≈ e / w
δ ≈ e^(-d)

这类结构不是免费的午餐。它不能列出全部键,也不适合账单、配额扣减、权限判断等必须精确的场景。对于面对不可信输入的边缘服务,还必须考虑攻击者构造碰撞的风险,应使用带秘密种子的稳健哈希,并定期轮换状态。

可运行的 Rust 示例:固定 1 MiB 的近似计数器

下面是一个独立示例,不代表 Cloudflare 的具体实现。它同时维护精确 HashMap 和 Count-Min Sketch,用前者验证误差。生产环境若决定使用近似计数,就不会再保留这个完整哈希表。

将以下内容保存为 main.rs

use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::mem::size_of;

struct CountMinSketch {
    width: usize,
    rows: Vec<Vec<u32>>,
    seeds: [u64; 4],
}

impl CountMinSketch {
    fn new(width: usize) -> Self {
        Self {
            width,
            rows: vec![vec![0; width]; 4],
            seeds: [
                0x9e3779b97f4a7c15,
                0xbf58476d1ce4e5b9,
                0x94d049bb133111eb,
                0xd6e8feb86659fd93,
            ],
        }
    }

    fn index<T: Hash>(&self, key: &T, seed: u64) -> usize {
        let mut hasher = DefaultHasher::new();
        hasher.write_u64(seed);
        key.hash(&mut hasher);
        (hasher.finish() as usize) % self.width
    }

    fn add<T: Hash>(&mut self, key: &T) {
        for row in 0..self.rows.len() {
            let index = self.index(key, self.seeds[row]);
            self.rows[row][index] = self.rows[row][index].saturating_add(1);
        }
    }

    fn estimate<T: Hash>(&self, key: &T) -> u32 {
        self.rows
            .iter()
            .enumerate()
            .map(|(row, counters)| counters[self.index(key, self.seeds[row])])
            .min()
            .unwrap_or(0)
    }

    fn allocated_bytes(&self) -> usize {
        self.rows.len() * self.width * size_of::<u32>()
    }
}

fn main() {
    let events = 2_000_000u64;
    let unique_keys = 1_000_000u64;

    // 4 行 × 65,536 列 × 4 字节 = 1 MiB。
    let mut sketch = CountMinSketch::new(65_536);
    let mut exact: HashMap<u64, u32> = HashMap::new();

    for i in 0..events {
        let key = i % unique_keys;
        *exact.entry(key).or_insert(0) += 1;
        sketch.add(&key);

        // 制造一个明显的热点键。
        if i % 10 == 0 {
            *exact.entry(42).or_insert(0) += 1;
            sketch.add(&42);
        }
    }

    for key in [42u64, 7, 999_999] {
        let actual = exact.get(&key).copied().unwrap_or(0);
        let estimated = sketch.estimate(&key);
        println!(
            "key={key}, actual={actual}, estimated={estimated}, overestimate={}",
            estimated.saturating_sub(actual)
        );
    }

    let payload_lower_bound = exact.len()
        * (size_of::<u64>() + size_of::<u32>());

    println!("unique keys: {}", exact.len());
    println!("sketch counters: {} bytes", sketch.allocated_bytes());
    println!(
        "exact map key/value payload lower bound: {} bytes",
        payload_lower_bound
    );
    println!("note: HashMap buckets and allocator overhead are not included");
}

直接编译运行:

rustc -O main.rs -o cms_demo
./cms_demo

示例中的 sketch 始终只占用约 1 MiB 计数器空间,不会因为唯一键从 10 万增长到 100 万而扩容。相比之下,精确哈希表至少要保存全部键和值,而程序打印的还只是键值载荷下界,没有计算桶数组和分配器开销。

DefaultHasher 适合演示,但不应直接作为对抗恶意输入的生产方案。实际系统可以这样改造:

  • 使用带进程秘密种子的高质量哈希实现;
  • 根据每秒事件数和窗口长度选择 u32u64
  • 按时间窗口轮换两组 sketch,避免计数无限累积;
  • 给计数器溢出、估计误差和 RSS 建立监控;
  • 若需要跨机器汇总,确认数据结构的合并语义与一致性要求。

在 Pingora 类服务中,先找“基数乘副本数”

Rust 能减少对象布局和内存生命周期上的不确定性,但语言本身不会自动带来 100TB 级节省。真正的杠杆通常来自数据模型:把无界的逐键状态,替换成有明确误差预算的固定大小结构。

评估一个候选状态时,可以依次询问:

  1. 业务真的需要精确值,还是只需要趋势、分位数、热点或阈值判断?
  2. 状态大小是否随用户、连接、域名或其他高基数维度增长?
  3. 它是否在线程、worker、进程和全球节点之间被重复保存?
  4. 允许多大的假阳性、假阴性或数值误差?
  5. 输入是否可能被攻击者控制?
  6. 内存节省是否会换来过高 CPU 成本或更复杂的运维模型?

适合近似化的通常是遥测、热点探测、缓存准入和预筛选;不适合的则包括计费、鉴权、强配额与审计记录。更稳妥的设计往往是分层方案:先用固定内存结构筛出少量候选键,再对候选键执行精确追踪。

落地时别只看对象大小

验证优化效果时,应测量真实工作负载下的进程 RSS、分配次数、CPU 时间、尾延迟和误差分布,而不是只比较 Rust 类型的 size_of。哈希表的峰值容量、窗口轮换时的双份状态以及重新分配产生的瞬时峰值,都可能改变最终结果。

这次 100TB 级优化最值得借鉴的地方,不是某个神奇的 Rust 技巧,而是一个工程判断:当规模足够大时,“必须精确保存所有状态”本身就应该被重新审视。先给误差设定预算,再给内存设定上限,往往比继续压缩每个对象更有力量。


相关推荐