Cloudflare 重构 1.1.1.1 DNS 缓存:单条目缩小 56%,释放约 100 TB 内存

2026-09-23 25 预计阅读时间: 1 分钟
来源: infoq.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 缓存的内存表示。结果不只是节省几个字段:单条缓存记录的内存占用下降 56%,整个服务器集群释放了约 100 TB 工作集内存,同时缓存写入吞吐量提高 43%,查询延迟降低 19%。

这组数字说明了一个容易被忽略的事实:对于拥有海量条目的内存服务,数据结构本身就是基础设施。每条记录少占几十字节,乘以节点数、缓存容量和副本数后,就可能变成数十乃至上百 TB。

优化对象不是业务数据,而是每条记录的固定成本

DNS 缓存条目通常不只有域名和响应内容。实现还要保存查询类型、过期时间、索引信息以及用于并发访问或内存管理的元数据。即使实际 DNS 响应很短,语言容器、指针、长度、容量、内存对齐和独立堆分配也可能带来可观的固定开销。

来源摘要没有公开 Cloudflare 生产结构体的具体字段,因此不能把下面这些方法视为其实现细节。不过,对类似的大规模内存缓存,可以重点检查:

  • 是否为短字符串和短字节数组付出了完整动态容器的成本;
  • 多个对象是否重复保存相同的域名、后缀或类型信息;
  • 64 位指针和时间戳是否可以在明确边界下改成相对偏移量;
  • 字段排列是否因为内存对齐产生了空洞;
  • 一条记录是否触发多次独立堆分配;
  • 读路径是否需要追逐多个指针才能得到最终响应。

优化后的收益也不仅体现在 RSS。更紧凑的数据可以让更多热点条目进入 CPU 缓存,减少指针跳转、分配器调用和内存带宽压力。这些因素通常有利于查询延迟和插入吞吐量,但具体贡献仍应通过性能分析和基准测试确认,不能仅凭结构体尺寸推断。

一个可运行的 Rust 紧凑布局实验

下面不是 Big Pineapple 的源码,而是一个可以直接运行的小实验:传统版本分别用 StringVec<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 只统计结构体的内联部分,不包括 StringVec 的堆容量,也不包括分配器元数据。生产测试应同时测量进程 RSS、实际分配字节数、分配次数和碎片率。

32 位偏移量也不是免费的优化:单个 arena 必须限制在 4 GiB 内,删除记录后需要回收空间,压缩 arena 时还要更新或间接解析引用。expires_after 使用相对时间同样要求系统明确最大 TTL、时钟基准和溢出行为。如果这些边界无法被证明,就不应为了缩小字段而牺牲正确性。

为什么写入和读取可能同时变快

内存优化有时会以更多解码计算为代价,但 Cloudflare 此次改造同时获得了 43% 的插入吞吐量提升和 19% 的查询延迟下降。这意味着评估布局时不能只看“每条省了多少字节”,还要观察整条数据路径。

对于通用缓存实现,可以分别检查:

  1. 插入路径:哈希计算次数、堆分配次数、数据复制量以及锁持有时间。
  2. 查询路径:哈希表探测次数、指针跳转、边界检查以及热点字段是否连续存放。
  3. 淘汰路径:过期扫描是否触碰大量冷内存,删除后是否留下碎片。
  4. 并发路径:紧凑表示是否引入全局 arena 锁,或者让引用生命周期更难管理。

一个结构体在纸面上缩小 50%,并不保证服务就会快 50%。真实结果取决于命中率、对象大小分布、读写比例、NUMA 布局和并发模型。Cloudflare 公布的提升应理解为其实际系统改造结果,而不是可直接套用到任意缓存的固定比例。

上线前应同时守住容量、性能和正确性

可以按以下顺序推进类似优化:

  • 记录优化前的每条目平均成本、RSS、缓存容量、命中率和淘汰率;
  • 使用真实流量样本,而不是只生成等长键值;
  • 对比插入吞吐量以及查询延迟的 p50、p95、p99;
  • 验证 TTL、负缓存、不同 DNS 记录类型和异常数据;
  • 对新旧表示做双写或离线逐条比对;
  • 分批发布,并监控命中率、CPU、内存、错误率和尾延迟;
  • 为偏移量溢出、arena 空间不足和回收失败准备降级路径。

这次改造最值得借鉴的,不是某个特定 Rust 技巧,而是把每条记录的表示成本当作一级性能指标。当缓存规模足够大时,内存布局、CPU 缓存局部性和分配行为会共同决定一项服务还能否继续扩容。Cloudflare 在不增加内存的情况下扩大缓存容量,正是这种系统级优化的直接价值。


相关推荐