Cloudflare 重新设计了 1.1.1.1 所使用的 Big Pineapple DNS 缓存的内存表示。结果不只是节省几个字段:单条缓存记录的内存占用下降 56%,整个服务器集群释放了约 100 TB 工作集内存,同时缓存写入吞吐量提高 43%,查询延迟降低 19%。
这组数字说明了一个容易被忽略的事实:对于拥有海量条目的内存服务,数据结构本身就是基础设施。每条记录少占几十字节,乘以节点数、缓存容量和副本数后,就可能变成数十乃至上百 TB。
优化对象不是业务数据,而是每条记录的固定成本
DNS 缓存条目通常不只有域名和响应内容。实现还要保存查询类型、过期时间、索引信息以及用于并发访问或内存管理的元数据。即使实际 DNS 响应很短,语言容器、指针、长度、容量、内存对齐和独立堆分配也可能带来可观的固定开销。
来源摘要没有公开 Cloudflare 生产结构体的具体字段,因此不能把下面这些方法视为其实现细节。不过,对类似的大规模内存缓存,可以重点检查:
- 是否为短字符串和短字节数组付出了完整动态容器的成本;
- 多个对象是否重复保存相同的域名、后缀或类型信息;
- 64 位指针和时间戳是否可以在明确边界下改成相对偏移量;
- 字段排列是否因为内存对齐产生了空洞;
- 一条记录是否触发多次独立堆分配;
- 读路径是否需要追逐多个指针才能得到最终响应。
优化后的收益也不仅体现在 RSS。更紧凑的数据可以让更多热点条目进入 CPU 缓存,减少指针跳转、分配器调用和内存带宽压力。这些因素通常有利于查询延迟和插入吞吐量,但具体贡献仍应通过性能分析和基准测试确认,不能仅凭结构体尺寸推断。
一个可运行的 Rust 紧凑布局实验
下面不是 Big Pineapple 的源码,而是一个可以直接运行的小实验:传统版本分别用 String 和 Vec<u8> 持有数据;紧凑版本把可变长度内容写入连续 arena,只在条目中保存 32 位偏移量和长度。
运行前只需要安装稳定版 Rust。复制整段命令即可创建项目:
mkdir -p dns-cache-layout-lab/src
cd dns-cache-layout-lab
cat > Cargo.toml <<'EOF'
[package]
name = "dns-cache-layout-lab"
version = "0.1.0"
edition = "2021"
EOF
cat > src/main.rs <<'EOF'
use std::mem::size_of;
#[allow(dead_code)]
struct NaiveEntry {
name: String,
rr_type: u16,
class: u16,
expires_at: u64,
value: Vec<u8>,
}
#[derive(Debug)]
struct SliceRef {
offset: u32,
len: u32,
}
#[allow(dead_code)]
struct CompactEntry {
name: SliceRef,
value: SliceRef,
rr_type: u16,
class: u16,
expires_after: u32,
}
fn append(arena: &mut Vec<u8>, data: &[u8]) -> SliceRef {
let offset = u32::try_from(arena.len()).expect("arena exceeds 4 GiB");
let len = u32::try_from(data.len()).expect("value is too large");
arena.extend_from_slice(data);
SliceRef { offset, len }
}
fn read<'a>(arena: &'a [u8], reference: &SliceRef) -> &'a [u8] {
let start = reference.offset as usize;
let end = start + reference.len as usize;
&arena[start..end]
}
fn main() {
let name = b"www.example.com";
let answer = [192, 0, 2, 10];
let mut arena = Vec::new();
let entry = CompactEntry {
name: append(&mut arena, name),
value: append(&mut arena, &answer),
rr_type: 1,
class: 1,
expires_after: 300,
};
assert_eq!(read(&arena, &entry.name), name);
assert_eq!(read(&arena, &entry.value), answer);
println!("NaiveEntry inline metadata: {} bytes", size_of::<NaiveEntry>());
println!("CompactEntry inline metadata: {} bytes", size_of::<CompactEntry>());
println!("Shared arena payload: {} bytes", arena.len());
}
EOF
cargo run --release
这个示例展示的是设计方向,而不是完整内存基准。size_of 只统计结构体的内联部分,不包括 String、Vec 的堆容量,也不包括分配器元数据。生产测试应同时测量进程 RSS、实际分配字节数、分配次数和碎片率。
32 位偏移量也不是免费的优化:单个 arena 必须限制在 4 GiB 内,删除记录后需要回收空间,压缩 arena 时还要更新或间接解析引用。expires_after 使用相对时间同样要求系统明确最大 TTL、时钟基准和溢出行为。如果这些边界无法被证明,就不应为了缩小字段而牺牲正确性。
为什么写入和读取可能同时变快
内存优化有时会以更多解码计算为代价,但 Cloudflare 此次改造同时获得了 43% 的插入吞吐量提升和 19% 的查询延迟下降。这意味着评估布局时不能只看“每条省了多少字节”,还要观察整条数据路径。
对于通用缓存实现,可以分别检查:
- 插入路径:哈希计算次数、堆分配次数、数据复制量以及锁持有时间。
- 查询路径:哈希表探测次数、指针跳转、边界检查以及热点字段是否连续存放。
- 淘汰路径:过期扫描是否触碰大量冷内存,删除后是否留下碎片。
- 并发路径:紧凑表示是否引入全局 arena 锁,或者让引用生命周期更难管理。
一个结构体在纸面上缩小 50%,并不保证服务就会快 50%。真实结果取决于命中率、对象大小分布、读写比例、NUMA 布局和并发模型。Cloudflare 公布的提升应理解为其实际系统改造结果,而不是可直接套用到任意缓存的固定比例。
上线前应同时守住容量、性能和正确性
可以按以下顺序推进类似优化:
- 记录优化前的每条目平均成本、RSS、缓存容量、命中率和淘汰率;
- 使用真实流量样本,而不是只生成等长键值;
- 对比插入吞吐量以及查询延迟的 p50、p95、p99;
- 验证 TTL、负缓存、不同 DNS 记录类型和异常数据;
- 对新旧表示做双写或离线逐条比对;
- 分批发布,并监控命中率、CPU、内存、错误率和尾延迟;
- 为偏移量溢出、arena 空间不足和回收失败准备降级路径。
这次改造最值得借鉴的,不是某个特定 Rust 技巧,而是把每条记录的表示成本当作一级性能指标。当缓存规模足够大时,内存布局、CPU 缓存局部性和分配行为会共同决定一项服务还能否继续扩容。Cloudflare 在不增加内存的情况下扩大缓存容量,正是这种系统级优化的直接价值。