每条缓存少几个字节,Cloudflare 如何从 1.1.1.1 省出约 100TB 内存

2026-08-31 30 预计阅读时间: 1 分钟
来源: oschina.net 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.

预计阅读时间:9 分钟

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 解析器,尾延迟和缓存命中路径上的指令数通常与内存占用同样重要。

落地时按条目成本做账

采用这类优化前,可以按下面的顺序推进:

  1. 统计缓存条目数、各类条目的占比和真实容量分布。
  2. 同时测量容器控制结构、堆分配和分配器碎片,而不只看结构体大小。
  3. 找出写入后不再增长的 VecString 和哈希集合。
  4. 优先删除重复字段与构建期字段,再评估紧凑表示。
  5. 用真实 DNS 响应分布做吞吐量和 P99 延迟基准。
  6. 分批迁移,持续比较 RSS、分配次数、命中率与查询延迟。

Cloudflare 的案例说明,超大规模系统的内存优化往往不是某个神奇算法,而是严肃对待每条记录的几个字节。Vec<T> 没有问题;问题在于,当数据已经不可变时,系统是否仍在为“未来可能扩容”持续付费。


相关推荐