在单台服务器上节省几 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 适合演示,但不应直接作为对抗恶意输入的生产方案。实际系统可以这样改造:
- 使用带进程秘密种子的高质量哈希实现;
- 根据每秒事件数和窗口长度选择
u32或u64; - 按时间窗口轮换两组 sketch,避免计数无限累积;
- 给计数器溢出、估计误差和 RSS 建立监控;
- 若需要跨机器汇总,确认数据结构的合并语义与一致性要求。
在 Pingora 类服务中,先找“基数乘副本数”
Rust 能减少对象布局和内存生命周期上的不确定性,但语言本身不会自动带来 100TB 级节省。真正的杠杆通常来自数据模型:把无界的逐键状态,替换成有明确误差预算的固定大小结构。
评估一个候选状态时,可以依次询问:
- 业务真的需要精确值,还是只需要趋势、分位数、热点或阈值判断?
- 状态大小是否随用户、连接、域名或其他高基数维度增长?
- 它是否在线程、worker、进程和全球节点之间被重复保存?
- 允许多大的假阳性、假阴性或数值误差?
- 输入是否可能被攻击者控制?
- 内存节省是否会换来过高 CPU 成本或更复杂的运维模型?
适合近似化的通常是遥测、热点探测、缓存准入和预筛选;不适合的则包括计费、鉴权、强配额与审计记录。更稳妥的设计往往是分层方案:先用固定内存结构筛出少量候选键,再对候选键执行精确追踪。
落地时别只看对象大小
验证优化效果时,应测量真实工作负载下的进程 RSS、分配次数、CPU 时间、尾延迟和误差分布,而不是只比较 Rust 类型的 size_of。哈希表的峰值容量、窗口轮换时的双份状态以及重新分配产生的瞬时峰值,都可能改变最终结果。
这次 100TB 级优化最值得借鉴的地方,不是某个神奇的 Rust 技巧,而是一个工程判断:当规模足够大时,“必须精确保存所有状态”本身就应该被重新审视。先给误差设定预算,再给内存设定上限,往往比继续压缩每个对象更有力量。