一个 DNS 缓存条目如何省下 100 TB 内存:从 Rust 数据布局看规模效应

2026-08-28 37 预计阅读时间: 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.

预计阅读时间:9 分钟

Cloudflare 对 1.1.1.1 使用的 Big Pineapple DNS 缓存做了五项 Rust 层面的内存优化,将单条缓存记录的内存占用降低了 56%。当同一种数据结构在整个服务集群中被复制数十亿次时,这次看似局部的改造最终释放了约 100 TB 内存。

来源摘要没有列出五项优化的具体实现,因此下面不会猜测其内部字段或代码。我们更值得拆解的是这种优化为什么能形成如此大的收益,以及在 Rust 缓存、索引和常驻内存服务中,可以怎样实践同类分析。

真正的成本是“单条开销 × 条目数 × 副本数”

缓存结构的内存成本不能只看一条记录:

total_memory ≈ entry_count × bytes_per_entry × replica_count
             + hash_table_overhead
             + allocator_overhead
             + unused_capacity

假设某个条目从 160 字节缩减到 70 字节,每条只节省 90 字节。单看微不足道,但 10 亿条记录就是约 90 GB;再乘以进程、机器和区域副本,收益很快进入 TB 级。

这也是 56% 这个数字比“删除了几个字段”更重要的原因。它通常意味着团队审查了完整的内存布局,而不只是业务载荷:结构体填充、指针宽度、堆分配、容器容量和哈希表装载率都可能包含在账单里。

Rust 类型看起来很小,运行时布局未必小

Rust 会按照字段的对齐要求在结构体中插入 padding。与此同时,StringVec<T> 等类型本身还保存指针、长度和容量;在 64 位平台上,它们通常各占三个机器字,这还不包括堆上的内容。

对高基数 DNS 缓存,可以重点检查五类成本。以下是通用审计清单,并非对 Big Pineapple 五项改动的逐条复原:

  1. 字段宽度:过期时间是否真的需要 u64,状态值是否需要完整整数。
  2. 容器元数据:不可变数据是否仍在使用带额外 capacity 的 StringVec<T>
  3. 重复分配:一个条目是否包含多个独立堆对象,能否改成连续存储、切片或驻留数据。
  4. 枚举和可选值Option、标签和负缓存状态的组合是否产生了意外的 padding。
  5. 表级开销:哈希桶、低装载率、过度预留容量和分片元数据是否抵消了条目优化。

字段顺序也值得测量。把高对齐字段集中排列有时能减少结构体尾部或字段间的空洞,但编译器布局和类型语义都可能变化,不能依赖肉眼估算。

可以这样实践:先做一个可运行的布局实验

下面是一个简化示例,用于比较两种 DNS 缓存条目表示。它不代表 1.1.1.1 的实际数据结构,也没有覆盖完整 DNS 记录类型;它只演示字段宽度和容器选择如何影响结构体本体。

将代码保存为 layout.rs,然后直接用 Rust 编译器运行:

use std::mem::{align_of, size_of};

struct EntryWide {
    name: String,
    wire_answer: Vec<u8>,
    expires_at_unix_ms: u64,
    response_code: u32,
    authoritative: bool,
}

struct EntryCompact {
    name: Box<str>,
    wire_answer: Box<[u8]>,
    expires_after_s: u32,
    flags: u8,
}

fn report<T>(name: &str) {
    println!(
        "{name:>12}: size = {:>3} bytes, align = {} bytes",
        size_of::<T>(),
        align_of::<T>()
    );
}

fn main() {
    report::<EntryWide>("wide");
    report::<EntryCompact>("compact");

    let entries = 1_000_000_000_u64;
    let saved = size_of::<EntryWide>() - size_of::<EntryCompact>();
    let gib = saved as f64 * entries as f64 / 1024_f64.powi(3);

    println!("struct-only saving at {entries} entries: {gib:.2} GiB");
}
rustc -O layout.rs
./layout

Box<str>Box<[u8]> 没有可增长容器的 capacity 字段,因此结构体本体通常更小,也不会保留未使用容量。代价是更新时不能原地扩容,往往需要重新分配。对于写入后不再修改、读取频繁的缓存值,这种交换可能合理;对于持续追加或修改的数据,它可能增加复制成本。

这个程序只测量 size_of::<T>(),不包含字符串内容、响应字节、哈希表桶和分配器元数据。它适合发现布局问题,不足以证明生产环境节省了多少内存。

从类型实验走到生产测量

可靠的优化流程至少需要三层数据:

  • 类型层:使用 size_ofalign_of 和布局工具检查结构体、枚举与 padding。
  • 分配层:记录对象数量、分配大小分布、容器的 len/capacity 比值以及分配器驻留内存。
  • 服务层:观察缓存命中率、淘汰率、请求延迟、CPU 使用率和进程 RSS。

内存下降不等于优化完成。紧凑表示可能引入额外解码、整数溢出边界、时间范围限制或更多复制。例如,把毫秒时间戳改成 u32 相对秒数之前,必须定义时间基准、最大 TTL、回绕行为和升级兼容性。把多次分配合并为连续缓冲区,也可能让单字段更新变得昂贵。

测试时还应固定真实流量分布。DNS 名称长度、记录集合大小、负缓存比例和 TTL 分布都会改变平均条目成本。只用几个手写样例,很容易得到漂亮但无法外推的结果。

采用这类优化前的检查表

可以按以下顺序推进:

  1. 用生产采样计算每类条目的数量与实际分配成本。
  2. 优先处理高频类型中的固定开销,而不是只优化少数超大对象。
  3. 每次只改变一个布局决策,分别测量结构体、堆分配和整表 RSS。
  4. 为时间范围、长度截断、枚举编码和旧数据兼容性增加边界测试。
  5. 同时验证命中率、尾延迟和 CPU,避免用计算量换取不可接受的内存下降。
  6. 在少量实例上灰度,确认分配器释放的内存确实回到可用池或操作系统。

Big Pineapple 的结果说明了一条朴素但经常被忽略的工程规律:在超大规模常驻缓存里,数据布局就是系统架构。单条记录省下几十字节,乘上全网条目和副本后,就可能比一次大规模扩容更有价值。


相关推荐