← 文章 / 编程开发
Hacker News 4小时前 · 2026-08-28 19:04:19 · 3 阅读

优化 1.1.1.1 的 DNS 缓存,省下 100 TB 内存

Big Pineapple,即 1.1.1.1Gateway DNSDNS FirewallAS112 以及其他多个 Cloudflare DNS 服务背后的平台,任意时刻都存储着超过 2500 亿条 DNS 缓存条目。在这样的规模下,每条条目哪怕只浪费 1 个字节,整个集群就要多付出超过 250 GB 的内存代价。

对缓存条目在内存中存储方式的五次连续改造,把单条条目的内存占用削减了 50% 以上。在整个集群范围内,这些改造释放了大约 100 TB 内存,相当于我们 130 台 Gen 13 服务器的内存总量。缓存还变得更快了:插入吞吐量提升了 43%,查找延迟下降了 19%——更少的分配次数和更好的内存局部性意味着我们并没有用速度换空间。

我们缓存什么

冷启动时,Big Pineapple 从空缓存开始。随着 DNS 查询不断到来,缓存逐渐填满,直到达到最大条目数,此时我们会淘汰较旧或较冷门的条目来腾出空间。

缓存的具体大小因数据中心而异。当启用 EDNS Client Subnet(ECS)时,权威服务器会根据客户端所在网络返回不同的应答,因此我们会缓存同一查询的多个版本。这既增加了条目数量,也增加了每条条目消耗的内存,使得本文的优化对 ECS 流量占比高的节点尤其有效。

缓存中的每一项都是一个键值对。键标识被查询的内容:

pub struct CacheKey {
    qname: Name,
    qtype: Rtype,
    authenticated: bool,
    tag: Vec<u8>,
}

值存储 DNS 应答本身:answer、authority 和 additional 记录段,以及创建时间、命中计数器、生存时间(TTL)等元数据。

pub struct CacheEntry {
    timestamp: UnixTimeStamp,
    pub inception: Instant,
    pub ttl: Ttl,
    pub hits: u32,
    pub answers: Vec<Record>,
    pub authority: Vec<Record>,
    pub additional: Vec<Record>,
    pub errors: Vec<ExtendedError>,
    ...
}

这两个结构体都有改进空间。若干字段使用的类型带有额外开销,而条目一旦存入缓存,这些开销就不再需要。

内存占用基准测试

为了衡量每项改造的影响,我们用随机生成的条目填充缓存进行基准测试,这些条目大致匹配我们在生产环境看到的流量分布:56% 的 A 记录、25% 的 AAAA 和 19% 的 TXT。每条条目包含 1 到 4 条记录。

在基准测试中,TXT 记录充当所有非 A/AAAA 记录类型的替身。其大小在 64 到 224 字节之间随机取值,接近我们在变长记录类型上看到的平均响应大小。

我们用一个自定义分配器来跟踪内存占用:它包装 Rust 的 System 分配器,记录每条缓存条目的分配次数和大小。除内存之外,我们还在完整的缓存流程上测量插入吞吐量和查找延迟,确保节省内存不以牺牲性能为代价。

这些输入是对生产环境的近似而非精确复现。进程内存还取决于流量构成、缓存占用率、分配器状态以及缓存之外的内存用量。因此我们在灰度发布期间对生产实例的常驻内存进行了实测。

容量字段的代价

Vec<T> 存储三个字段:指向堆上数据的指针、当前长度和总容量。当你 push 一个元素时,Vec 会检查长度是否超出容量,必要时重新分配。如果还有空间,它只是追加元素并递增长度。

1.png

然而,DNS 应答一旦存入缓存就再也不会被修改。capacity 字段毫无用处,却仍让每个 Vec 付出 8 字节的代价。超额分配的堆空间同样被浪费——一个容量为 8 却只存了 5 个元素的 Vec,会在堆上留下 3 个闲置的槽位。

2.png

改用 Box<[T]> 能同时解决这两个问题。它在创建之后无法增长,因此既不需要 capacity 字段,也不为未来的元素预留空间。String 同理,它也携带 capacity 字段,而 Box<str> 把它去掉了。

每条缓存条目存储 8 个 VecString 字段。把它们替换为 Box<[T]>Box<str>,每个字段节省 8 字节,每条条目节省 64 字节。这还消除了 Vec 为未来增长而预留的多余堆内存。在超过 2500 亿条缓存条目的规模下,合计节省超过 15 TB。

更少的列表,更少的指针

与其把 answer、authority 和 additional 三段分别存进独立的列表,不如只存一个列表,并用偏移量标记各段的起始位置。由于每段的 DNS 记录数用一个 u16 就能容纳,每个偏移量只需 u16(2 字节),而每个独立的 Box<[T]> 需要 8 字节指针加 8 字节长度。

3.png

这样去掉了两个列表(各自带 8 字节指针和 8 字节长度),换成两个 2 字节偏移量,每条条目节省 28 字节。

这些节省并不总是与从单个字段移除的字节数直接对应。Rust 会插入填充以满足对齐要求,并把结构体大小向上取整到其对齐值的倍数。因此移除一个小字段可能连带消除额外的填充。例如,我们还把若干布尔字段打包进单个 bitflag,这减少了周边的填充,使结构体的缩小幅度超过了那些布尔值本身的尺寸。

去掉 owner 字段

每条 DNS 记录都有一个 owner,即该记录所属的域名。很多情况下,owner 与被查询的域名相同。例如查询 example.com A 会返回两条 owner 相同的记录:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    A        198.51.100.1
example.com.        300    IN    A        198.51.100.2

但当涉及 CNAME 时,记录的 owner 就可能与被查询的域名不同:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    CNAME    cdn.example.com.
cdn.example.com.    300    IN    A        198.51.100.1
cdn.example.com.    300    IN    A        198.51.100.2

DNS 线上格式使用名称压缩来处理重复的 owner,定义见 RFC 1035。同一个域名不必编码两次,后续出现的位置只存一个 2 字节指针,指向第一次出现的地方。像 www.example.com 这样的域名可以只编码 www,后面跟一个指针,指向 example.com 在报文中已经出现的位置。

这种方式在传输中很好用,但在缓存里,我们会在每条记录旁存储完整的 owner 名称。在缓存查找的热路径上追踪压缩指针开销很大,所以我们用内存换速度。

不过,大多数记录的 owner 与被查询的域名相同。对这类记录,我们可以完全去掉 owner,在读取时推断出来。当 owner 不同(例如 CNAME 背后的 A 记录)时,才存储完整名称。

pub struct Record {
    owner: Option<Box<Name>>,
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

ownerNone 时,构建应答时会从缓存键恢复被查询的域名,避免一次堆分配。这意味着记录不再自包含,但缓存键在每次查找时本来就可用了。当 owner 不同的时候,Some 存储一个指向堆上完整名称的指针。

4.png

实践中,大多数缓存记录的 owner 与被查询的域名相同,因此绝大多数记录的 owner 字段不需要堆分配。

枚举的大小

Rust 枚举是和类型(sum type):每个变体可以携带不同的数据,但枚举的大小总是等于其最大变体的大小。

pub enum Option<T> {
    Some(T),
    None,
}

Option 要么是持有值的 Some,要么是什么都不持有的 None。两个变体占用相同的内存。枚举存储一个标签来指示当前变体,后面跟着足以容纳最大变体数据的空间。当变体为 None 时,这块空间就被闲置了。

对于记录数据,很自然的做法是把每种 DNS 记录类型存为一个枚举变体:

pub enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Txt),
    Naptr(Naptr),
    Svcb(Svcb),
    // ...
}

但枚举的大小总是等于其最大变体。在我们的场景里,那是 136 字节的 NAPTR:它存储三个变长文本字段、一个域名和两个整数。于是整个枚举(包括变体标签和填充)达到了 144 字节。

5.png

而一条 A 记录只需要 4 字节,一条 AAAA 记录只需要 16 字节。AAAAA 占我们流量的 80% 以上,因此大多数记录在填充上浪费了 120 多字节。由于单条缓存条目可以存储多条记录,浪费会迅速累积。

把大变体装箱

为了解决这个问题,我们可以把枚举中较大的变体装箱,移到单独的堆分配中。枚举随后只存储一个指向堆的 8 字节指针,数据在堆上只占用它实际需要的空间。

pub enum RecordData {
    // Small and common variants are stored inline
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    // Large variants are stored on the heap
    Txt(Box<Txt>),
    Naptr(Box<Naptr>),
    Svcb(Box<Svcb>),
    // ...
}

AAAAA 记录,每条可节省 120 字节。TXTCNAME 等较小的变体类型同样受益:它们仍占用 24 字节的枚举,但其堆分配按实际数据大小分配,而不是填充到 144 字节。最大的变体 NAPTR 反而稍微多付出了代价——现在多了一次堆指针和分配开销。但 NAPTR 记录在实践中很罕见,这个取舍是值得的。

6.png

但对较大的记录变体装箱也引入了它自己的代价。

装箱的代价

装箱有两个代价。第一个是分配器开销。每个装箱的变体都成为一次独立的堆分配,而分配器会把请求向上取整到最近的尺寸等级。Big Pineapple 使用 jemalloc,一个为多线程、分配密集型负载设计的分配器。jemalloc 把大小相近的分配归入固定大小的 bin。TXT 记录请求 32 字节,恰好放进 32 字节的 bin,毫无浪费;而 MX 记录请求 40 字节,会被取整到 48,浪费 8 字节。

第二个代价是内存局部性差。不装箱时,一条缓存条目的各记录枚举值位于一次连续分配之中。装箱后,每个装箱变体的数据位于堆上独立的区域。读取它需要跟随指针,当指针落在离条目其他部分很远的地方时,CPU 就得取新的缓存行。在数百万条缓存条目的规模下,装箱数据最终散布在整个堆上,而不是紧凑地排在一起。

7.png

这两个代价单独看都不算致命,但正如下一节所示,同时消除两者,能同时带来内存占用和查找延迟两方面的可测改进。

以线上格式存储记录

显而易见的下一步,是把完整的 DNS 应答以线上格式(wire format)存储,每次查找时只修补消息 ID 这类随客户端而异的字段。但这有缺陷。DNSSEC 记录只有在客户端设置了 DO(DNSSEC OK)标志时才会包含。存储完整的线上格式报文,要么得缓存两个版本(一个带 DNSSEC、一个不带),要么得从已构建好的报文中把它们过滤掉。此外,每次查找都要解析完整报文也有开销,而我们刚才描述的枚举方案通过存储已解析的记录避开了这一点。

作为折中,我们只把记录数据存为原始字节,缓存条目的其余部分仍保留为结构化字段。取代解析好的枚举变体列表,我们把记录存储为单个 Box<[u8]>,其中每条记录编码为 2 字节长度前缀加原始字节。

8.png

这消除了每个变体的枚举开销,也消除了上一项优化中装箱带来的堆分配。数据还变得连续紧凑,改善了 CPU 缓存局部性。代价是记录不能再随机索引,必须顺序遍历缓冲区。这对 A/AAAA 记录的 round-robin 轮转之类的功能增加了一些复杂度,但由于每条条目的记录数很少,代价可以忽略不计。

从缓存记录构建 DNS 应答时,大多数记录类型可以从缓冲区直接复制进外发报文。此前,每条解析过的记录都得逐字段重新序列化回 DNS 线上格式。新布局对 AAAAATXT 和所有 DNSSEC 记录类型跳过了这一步,直接复制其编码字节。只有包含域名的记录(如 CNAMENSMXSOA)仍需解析,以便应用 DNS 名称压缩。由于支持直接复制的记录占我们流量的绝大多数,这一改动减少了查找路径上的工作量。叠加内存局部性的改善,基准测试中缓存查找延迟降低了 5%。

构建记录数据缓冲区时,我们写入一个跨缓存插入持续复用的暂存缓冲区(scratchspace buffer)。由于此前的写入已经把它撑大过,这个缓冲区很少需要重新分配。记录大小不一,所以在序列化完成之前我们并不知道缓冲区的确切大小。记录进入暂存缓冲区后,我们分配一个 Box<[u8]>,把数据 memcpy 进去。这把原来每个装箱记录各一次的独立分配,换成了全部记录数据共享的一次分配,还避免了收缩 Vec<u8> 时的浪费——分配器可能无法回收原分配末端未使用的部分。在我们的基准测试中,仅这一项改动就让缓存插入吞吐量提升了 13%。

结果

生产环境实测数据展示了基准测试中的单条节省如何转化为整个进程的常驻内存。下图显示了各 Big Pineapple 实例的 p90、p98 和 p99 内存占用。第一条虚线标记 2026 年 5 月 18 日灰度发布开始,第二条虚线标记 2026 年 7 月 6 日所有服务灰度完成。每次发布包含上述一项或多项优化,因此内存占用是逐级下降,而非一步到位。

每次发布 rollout 时,重启的实例都从空缓存开始,随着缓存填充逐渐消耗更多内存。因此,稳定的平台期比最初的低谷更能代表稳态内存占用。

所有百分位上的单实例内存占用都下降了。p99 下,内存从 9.3 GB 降到 5.3 GB,常驻内存减少 43%;p90 下,内存从 6.5 GB 降到 3.8 GB,减少 42%。缓存越满的实例,绝对节省越大。

在基准测试中,这五项优化把单条条目的内存占用从 953 字节降到 420 字节,减少 56%;单条条目的分配量从 1.1 KB 降到 461 字节。生产环境测得的降幅更小,因为常驻内存除了缓存,还包括进程的其他所有数据。灰度完成并稳定后,整个集群的总工作集内存比之前低了约 100 TB。

性能同样提升了:缓存插入吞吐量提高 43%,查找延迟下降 19%。

指标

改造前

改造后

变化

单条条目净占用

953 字节

420 字节

-56%

单条条目分配量

1.1 KB

461 字节

-58%

缓存插入吞吐量

625,000 条/秒

893,000 条/秒

+43%

缓存查找延迟

828 ns

670 ns

-19%

我们计划把释放出来的内存重新投入缓存扩容,在不增加内存用量的前提下提高缓存命中率、减少上游查询量。我们也在探索对缓存本身的进一步优化。

想进一步了解 Big Pineapple,请参阅 How Rust and Wasm power Cloudflare's 1.1.1.1。如果你从事 DNS 或其他大型系统的开发,欢迎在 Cloudflare CommunityCloudflare Developers Discord 上分享对你有效的优化经验。

原始来源: Hacker News

评论 (0)