Cloudflare 对 1.1.1.1 DNS 缓存做了一次数据结构瘦身。完成迁移后,系统节省了约 100TB 内存。关键并不是更换硬件或引入复杂压缩算法,而是检查缓存条目的内存布局,删除不必要的状态,并把不需要扩容能力的数据从 Vec<T> 改成更紧凑的表示。
这类优化在普通服务里可能只省下几 MB,但 1.1.1.1 是全球规模最大的公共 DNS 解析器之一,缓存条目以亿计。到了这个量级,每条记录多一个机器字、一次额外分配或少量预留容量,最终都会变成庞大的常驻内存。
Vec<T> 不只保存元素
Rust 的 Vec<T> 通常包含三个机器字:
- 指向堆内存的指针;
- 当前元素数量
len; - 已分配容量
capacity。
在 64 位平台上,这个控制结构通常占 24 字节。Box<[T]> 是一个胖指针,只需要保存数据地址和切片长度,通常占 16 字节。
Vec<T> = pointer + length + capacity
Box<[T]> = pointer + length
如果一组 DNS 数据在写入缓存后不再追加元素,那么 capacity 就不再承载业务价值。继续使用 Vec<T>,相当于让每条缓存永久支付一个机器字的管理成本。此外,Vec 的容量可能大于长度,堆上还会留下未使用的预留空间。
这里需要区分两个收益:调用 shrink_to_fit() 可以尝试缩小堆分配,却不会移除 Vec 自身的 capacity 字段;转换成 Box<[T]> 则明确表达“长度固定”,同时缩小容器控制结构。
用一个可运行实验观察布局差异
可以这样实践:创建一个最小 Rust 程序,比较两种容器的栈上控制结构,并估算海量条目下的固定开销。实际结果取决于目标架构和 Rust ABI,不能把示例数字当成跨平台承诺。
mkdir rust-layout-demo
cd rust-layout-demo
cargo init --bin
cat > src/main.rs <<'RS'
use std::mem::size_of;
fn main() {
println!("Vec<u8>: {} bytes", size_of::<Vec<u8>>());
println!("Box<[u8]>: {} bytes", size_of::<Box<[u8]>>());
println!("Box<[u8; 4]>: {} bytes", size_of::<Box<[u8; 4]>>());
let records: u64 = 1_000_000_000;
let saved_per_record = size_of::<Vec<u8>>() - size_of::<Box<[u8]>>();
let total_gib = records as f64 * saved_per_record as f64
/ 1024.0
/ 1024.0
/ 1024.0;
println!("Fixed metadata saved per record: {saved_per_record} bytes");
println!("At {records} records: {total_gib:.2} GiB");
let mutable = vec![1_u8, 2, 3, 4];
let immutable: Box<[u8]> = mutable.into_boxed_slice();
assert_eq!(&*immutable, &[1, 2, 3, 4]);
}
RS
cargo run --release
在常见的 64 位环境中,程序通常会显示 Vec<u8> 为 24 字节、Box<[u8]> 为 16 字节。仅按 10 亿条记录计算,一个 8 字节差异就接近 7.45 GiB。真实 DNS 缓存还可能包含多个集合、索引、副本和进程实例,因此局部节省会被层层放大。不过,Cloudflare 公布的约 100TB 是整套数据结构优化和迁移后的结果,不能简单归因于这一处替换。
从动态构建切换到固定存储
实际代码不必放弃 Vec。更合理的生命周期是:构建阶段使用 Vec 收集数据,发布到缓存时再冻结为盒装切片。
#[derive(Debug)]
struct ResourceRecord {
ttl: u32,
payload: Box<[u8]>,
}
#[derive(Debug)]
struct CacheEntry {
records: Box<[ResourceRecord]>,
}
fn build_entry(raw_records: Vec<(u32, Vec<u8>)>) -> CacheEntry {
let records = raw_records
.into_iter()
.map(|(ttl, payload)| ResourceRecord {
ttl,
payload: payload.into_boxed_slice(),
})
.collect::<Vec<_>>()
.into_boxed_slice();
CacheEntry { records }
}
fn main() {
let entry = build_entry(vec![
(300, vec![192, 0, 2, 1]),
(300, vec![192, 0, 2, 2]),
]);
println!("{entry:#?}");
}
这个模式保留了构建时的便利性,同时让缓存中的稳定状态更紧凑。类型也建立了一条约束:发布后的记录集合不能再 push,更新操作必须创建新值或采用其他明确的替换机制。
如果元素数量在类型层面固定,例如 IPv4 地址始终占 4 字节,还可以考虑 [u8; 4] 或 Box<[u8; 4]>。固定数组不需要单独保存长度,但它只适合长度真正由协议或业务约束保证的场景。
真正值得删除的是“可推导状态”
从摘要披露的信息看,这次优化不止容器替换,还涉及删除字段。审查高基数缓存结构时,可以重点寻找以下对象:
- 能从其他字段计算出来的长度、类型或状态;
- 只在构建阶段使用,却被带入长期缓存的临时信息;
- 与键或外层索引重复保存的数据;
- 为少数异常路径准备、却让所有条目付费的可选字段;
- 默认预留容量明显高于常见数据规模的集合。
但不能仅凭 size_of::<T>() 决定改造。Rust 结构体还会受到字段对齐、填充、枚举布局和间接堆分配影响。删除一个字段后,结构体尺寸甚至可能因为对齐而保持不变。可以先用编译器和基准测试验证,再观察生产环境中的分配量、RSS、缓存命中率与延迟。
还要检查 CPU 代价。删除缓存字段后若需要在每次查询时重新计算,内存下降可能换来更高的查询延迟。把数据压得更紧也可能增加解码、复制或指针追踪成本。对于 DNS 解析器,尾延迟和缓存命中路径上的指令数通常与内存占用同样重要。
落地时按条目成本做账
采用这类优化前,可以按下面的顺序推进:
- 统计缓存条目数、各类条目的占比和真实容量分布。
- 同时测量容器控制结构、堆分配和分配器碎片,而不只看结构体大小。
- 找出写入后不再增长的
Vec、String和哈希集合。 - 优先删除重复字段与构建期字段,再评估紧凑表示。
- 用真实 DNS 响应分布做吞吐量和 P99 延迟基准。
- 分批迁移,持续比较 RSS、分配次数、命中率与查询延迟。
Cloudflare 的案例说明,超大规模系统的内存优化往往不是某个神奇算法,而是严肃对待每条记录的几个字节。Vec<T> 没有问题;问题在于,当数据已经不可变时,系统是否仍在为“未来可能扩容”持续付费。