本文是对Cloudflare How we saved 100 terabytes of memory by optimizing 1.1.1.1's DNS cache的整理与翻译

Big Pineapple 是 1.1.1.1、Gateway DNS、DNS Firewall、AS112 以及 Cloudflare 其他多项 DNS 服务背后的平台。在任意时刻,它都会存储超过 2500 亿条 DNS 缓存记录 。在这样的规模下,每条记录哪怕只浪费 1 个字节,放大到整个服务器集群,也意味着超过 250 GB 的内存浪费。
通过连续五次调整缓存条目在内存中的存储方式,我们将单条缓存记录的内存占用减少了 50% 以上 。这些改动在整个服务器集群中释放了大约 100 TB 内存 ,相当于我们 130 台 Gen 13 服务器 所配备的 RAM 总量。
缓存的速度也变得更快。由于减少了内存分配次数,并改善了内存局部性,缓存插入吞吐量提升了 43% ,查找延迟降低了 19%。也就是说,我们并没有为了节省空间而牺牲性能。
我们缓存了什么
在冷启动时,Big Pineapple 的缓存最初是空的。
随着 DNS 查询不断到来,缓存会逐渐被填满,直到达到允许的最大条目数量。此时,我们就会淘汰较旧或者不太常用的缓存项,为新的内容腾出空间。
具体的缓存大小会因数据中心而异。
当使用 EDNS Client Subnet(ECS) 时,权威 DNS 服务器会根据客户端所在的网络返回不同的响应,因此我们需要针对同一个查询缓存多个不同版本的结果。
这不仅增加了缓存条目的数量,也增加了每条记录需要消耗的内存。因此,对于大量使用 ECS 的数据中心来说,本文介绍的这些优化尤其重要。
缓存中的每一项都是一个键值对。
其中,键用于标识用户查询的内容:
rust
pub struct CacheKey {
qname: Name,
qtype: Rtype,
authenticated: bool,
tag: Vec<u8>,
}
值则存储 DNS 响应本身,包括 Answer、Authority 和 Additional 三个记录区域,同时还包括一些元数据,例如创建时间、命中计数器以及生存时间(Time-to-Live,TTL)。
rust
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> 内部存储了三个字段:
- 一个指向堆上数据的指针;
- 当前元素数量,也就是长度;
- 总容量。
当向 Vec 中加入一个元素时,Vec 会检查新的长度是否超过当前容量。
如果超过容量,它就需要重新分配内存。
如果当前仍然有足够的空间,就只需要追加新的元素,并增加长度即可。

然而,一旦我们将 DNS 响应存进缓存,之后就再也不会修改它了。
因此,capacity 容量字段实际上已经没有任何作用,但每一个 Vec 仍然需要为这个字段付出 8 字节的内存开销。
另外,Vec 在堆上预先多分配出来的空间同样会被浪费。
例如,一个容量可以容纳 8 个元素、但实际只存储了 5 个元素的 Vec,会在堆中留下 3 个没有使用的元素位置。

使用 Box<[T]> 可以同时解决这两个问题。
Box<[T]> 在创建之后无法继续增长,因此:
- 不需要容量字段;
- 也不需要为了未来可能加入的元素提前预留空间。
同样的问题也存在于 String 中,因为 String 同样携带一个容量字段。
将其替换成 Box<str> 后,就可以去掉这个容量字段。
每一个缓存条目一共存储了 8 个 Vec 和 String 字段。
将它们分别替换为 Box<[T]> 和 Box<str> 后,每个字段可以节省 8 字节,因此每条缓存记录可以节省:
8 × 8 = 64 字节。
除此之外,这还消除了 Vec 为未来增长而额外预留的堆内存。
在超过 2500 亿条缓存记录 的规模下,这两部分节省的内存加起来超过了 15 TB。
更少的列表,更少的指针
我们原本分别使用三个独立的列表存储:
- Answer 区域;
- Authority 区域;
- Additional 区域。
但实际上,我们可以把这三个区域中的所有记录存储到同一个列表中,再使用偏移量记录每个区域从哪里开始。
因为 DNS 每个区域中的记录数量都能够放进一个 u16 中,因此我们可以为每一个偏移量使用一个 2 字节的 u16。
相比之下,每一个独立的 Box<[T]> 都需要:
- 8 字节的指针;
- 8 字节的长度。

这样一来,我们就可以删除两个列表。
原来的两个列表分别需要一个 8 字节指针和一个 8 字节长度,总共需要:
2 × 16 = 32 字节。
现在只需要两个 2 字节的偏移量,也就是 4 字节。
因此,每一个缓存条目可以节省:
32 - 4 = 28 字节。
不过,这里实际节省的内存,并不总是能够直接根据被删除字段自身的字节数来计算。
Rust 会插入填充字节以满足内存对齐要求,并且会把一个结构体的整体大小向上取整,使其成为结构体对齐大小的整数倍。
因此,删除一个很小的字段,有时候还会顺带消除额外的填充空间。
例如,我们还把多个布尔字段压缩进了同一个 bitflag 中。
这样做减少了这些字段周围所需要的填充空间,因此最终结构体缩小的大小,甚至超过了这些布尔字段自身大小的总和。
删除 owner
每一条 DNS 记录都有一个 owner,也就是这条记录所属的域名。
在很多情况下,这个 owner 与用户查询的域名完全相同。
例如,对 example.com A 发起查询,返回的两条记录拥有完全相同的 owner:
text
$ 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 就可能与被查询的域名不同。
例如:
text
$ 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,随后使用一个指针指向 DNS 消息中此前已经出现过的 example.com。
这种机制在线上传输的 DNS 数据中工作得很好。
但在我们的缓存中,我们会把完整的 owner 名称与每条记录一起保存。
原因是,在缓存查询这一高频热点路径中,如果每次都需要跟随 DNS 名称压缩指针进行解析,代价会比较高。
因此,我们选择使用更多内存来换取更快的处理速度。
然而,绝大多数 DNS 记录的 owner 实际上都与被查询的域名相同。
对于这些记录,我们可以完全不保存 owner,而是在读取缓存时重新推断出来。
只有当 owner 与查询域名不同时,例如 CNAME 后面的那些 A 记录,我们才保存完整名称。
rust
pub struct Record {
owner: Option<Box<Name>>,
class: Class,
ttl: Ttl,
rtype: Rtype,
data: RecordData,
}
当 owner 为 None 时,在构造 DNS 响应的过程中,我们会从缓存键中恢复用户原本查询的域名。
这一过程不需要进行堆内存分配。
这意味着记录本身不再是一个完全自包含的数据结构。
不过,每次缓存查询时,我们本来就已经能够访问缓存键,所以并不会带来问题。
当 owner 与查询域名不同时,Some 中则会存放一个指向堆上完整名称的指针。

实际运行中,大多数缓存 DNS 记录的 owner 都与被查询的域名完全相同。
因此,绝大多数记录的 owner 字段都不再需要进行堆内存分配。
枚举的大小
Rust 的枚举是和类型(sum type)。
枚举中的每一种变体都可以携带不同的数据,但是整个枚举类型本身的大小始终需要能够容纳其中最大的那个变体。
rust
pub enum Option<T> {
Some(T),
None,
}
Option 要么是 Some,并包含一个值;要么是 None,什么值都不包含。
但是,这两个变体在内存中占据的空间大小是一样的。
枚举需要存储一个标签,用来表示当前激活的是哪个变体,后面还需要预留足够大的空间,以便能够容纳最大变体中的数据。
当当前变体是 None 时,这部分空间实际上并没有被使用。
对于 DNS 记录数据来说,一个看起来非常自然的设计,就是让每一种 DNS 记录类型分别成为枚举中的一个变体:
rust
pub enum RecordData {
A(Ipv4Addr),
Aaaa(Ipv6Addr),
Txt(Txt),
Naptr(Naptr),
Svcb(Svcb),
// ...
}
问题在于,这个枚举始终需要和其中最大的变体一样大。
在我们的实现中,最大的变体是 NAPTR,它本身需要 136 字节。
它需要存储:
- 三个可变长度文本字段;
- 一个域名;
- 两个整数。
因此,把枚举变体标签以及内存填充计算在内之后,整个枚举最终达到了 144 字节。

但实际上:
- 一条
A记录只需要 4 字节; - 一条
AAAA记录只需要 16 字节。
而 A 和 AAAA 记录占到了我们流量的 80% 以上。
因此,对于绝大多数记录来说,为了让枚举能够容纳最大的变体,我们实际上浪费了 120 多个字节作为填充空间。
由于单个缓存条目可能包含很多条 DNS 记录,这些浪费很快就会累积起来。
对枚举变体进行 Box 化
为了解决这个问题,我们可以将枚举中较大的变体放进 Box 中,将它们的数据移动到单独的堆内存分配中。
此时,枚举本身只需要保存一个指向堆的 8 字节指针,而真实数据在堆中只需要占据它实际所需要的空间。
rust
pub enum RecordData {
// 小且常见的变体直接内联存储
A(Ipv4Addr),
Aaaa(Ipv6Addr),
// 大型变体存储到堆上
Txt(Box<Txt>),
Naptr(Box<Naptr>),
Svcb(Box<Svcb>),
// ...
}
对于 A 和 AAAA 记录,这种方式可以为每条记录节省 120 字节。
像 TXT 和 CNAME 这样较小的变体类型同样能够从中受益。
它们仍然需要占据一个 24 字节枚举的位置,但是它们在堆上的内存分配只需要按照真实数据大小进行分配,而不再需要为了适应 144 字节的最大枚举大小进行填充。
最大的 NAPTR 变体反而会稍微多付出一些代价。
因为现在还需要增加:
- 一个堆指针;
- 一次额外内存分配带来的开销。
但是在实际流量中,NAPTR 记录非常少见,因此这种权衡是值得的。

不过,将较大的 DNS 记录变体放进 Box 本身也会引入新的成本。
Box 化的代价
Box 化主要有两方面的成本。
第一项成本来自内存分配器自身的开销。
每一个被 Box 化的变体都会变成一次独立的堆内存分配,而内存分配器通常会将请求的大小向上取整到最接近的 size class。
Big Pineapple 使用的是 jemalloc。
jemalloc 是一个专门针对多线程、高频内存分配工作负载设计的内存分配器。
jemalloc 会把大小相近的内存分配归入固定大小的 bin 中。
例如,一条 TXT 记录请求 32 字节的空间,它恰好可以放进一个 32 字节的 bin 中,因此不会浪费任何空间。
但是,一条 MX 记录请求的是 40 字节。
它会被向上取整并放入一个 48 字节 的 bin 中,因此其中有 8 字节被浪费。
第二项成本来自较差的内存局部性。
如果没有使用 Box,一个缓存条目中的所有记录枚举值都会位于同一块连续的内存分配中。
使用 Box 之后,每个被 Box 化的变体数据都会分别位于不同的堆内存区域。
读取这些数据时,需要先跟随一个指针。
如果这个指针指向的位置距离缓存条目的其他部分比较远,CPU 就需要加载一条新的缓存行。
当缓存中存在数百万条记录时,被 Box 化的数据最终会分散到整个堆的不同区域,而不是紧密地连续排列在一起。

这两项成本中的任何一项单独来看都不算灾难性问题。
但是,如果能同时消除这两个问题,就像下一节将介绍的那样,我们就可以在内存使用量和缓存查找延迟两个方面都获得可以实际测量出来的改进。
使用 DNS 线格式存储记录
一个很自然的下一步,是直接使用 DNS 线格式存储完整的 DNS 响应,然后在每次缓存查询时,只修改那些与具体客户端相关的字段,例如 DNS 消息 ID。
但是这种设计也存在一些缺点。
只有当客户端设置了 DO(DNSSEC OK) 标志时,DNSSEC 记录才应该被包含在响应中。
如果直接缓存一份完整的 DNS 线格式消息,那么我们就只能在以下两种方案中选择:
- 缓存两个版本,一个包含 DNSSEC,一个不包含 DNSSEC;
- 从已经构建完成的 DNS 消息中再次把不需要的 DNSSEC 记录过滤出去。
此外,如果每次查询缓存时都必须重新解析完整的 DNS 消息,同样存在性能成本。
前面介绍的枚举方案并不存在这个问题,因为其中存储的是已经解析完成的 DNS 记录。
因此,我们最终采用了一个折中的方案:
只把 DNS 记录的数据部分以原始字节形式保存,而缓存条目的其他部分仍然使用结构化字段。
我们不再保存一个由已经解析好的枚举变体构成的列表,而是将所有 DNS 记录存储到一个 Box<[u8]> 中。
每一条记录的编码形式为:
2 字节长度前缀 + 记录的原始字节。

这样就消除了上一项优化中:
- 每一个枚举变体自身产生的额外内存开销;
- 每一个 Box 记录所产生的独立堆内存分配。
数据也重新变成了连续紧密排列的形式,从而改善 CPU 缓存局部性。
它所带来的代价是,我们不能再随机访问某一条 DNS 记录。
现在必须按照顺序遍历整个缓冲区。
对于一些功能来说,这会增加少量复杂度,例如对 A / AAAA 记录进行轮询(round-robin)旋转。
不过,由于每一个缓存条目中包含的记录数量本身就比较少,因此这部分成本几乎可以忽略不计。
当使用缓存记录构建 DNS 响应时,大多数记录类型现在都可以直接从这个缓冲区复制到最终要发送的消息中。
在之前的实现中,每一条已经解析好的 DNS 记录,都需要逐个字段重新序列化成 DNS 线格式。
新的内存布局跳过了这部分工作。
对于:
A;AAAA;TXT;- 所有 DNSSEC 记录类型;
都可以直接复制它们已经编码完成的字节。
只有那些数据中包含域名的记录类型,例如:
CNAME;NS;MX;SOA;
仍然需要重新解析,因为我们必须对其中的 DNS 名称应用 DNS 名称压缩。
由于能够直接复制的 DNS 记录占到了我们流量中的绝大多数,因此这一变化减少了缓存查询路径中的工作量。
再加上更好的内存局部性,在我们的基准测试中,这一项修改让缓存查询延迟降低了 5%。
为了构建 DNS 记录数据缓冲区,我们首先把数据写入一个可以重复使用的临时工作缓冲区。
这个缓冲区会在多次缓存插入操作之间持续存在。
由于之前的数据写入操作通常已经让它增长到了足够大的容量,因此它很少需要重新分配。
不同 DNS 记录的大小并不相同,因此在序列化完成之前,我们无法提前知道最终需要的确切缓冲区大小。
等所有记录都被写入临时缓冲区之后,我们再分配一个 Box<[u8]>,然后通过 memcpy 把数据复制进去。
这样就把:
每个被 Box 化的 DNS 记录分别进行一次内存分配
替换成了:
所有 DNS 记录数据只进行一次内存分配。
这同时也避免了缩小 Vec<u8> 时可能造成的内存浪费。
因为即使我们缩小一个 Vec<u8>,内存分配器也不一定能够回收原始内存分配末尾那部分已经不再使用的空间。
在我们的基准测试中,仅这一项变化,就让缓存插入吞吐量提升了 13%。
最终结果
生产环境中的测量结果展示了基准测试中观察到的单条缓存记录内存节省,最终如何转换成整个进程常驻内存的下降。
下面的图展示了所有 Big Pineapple 实例的:
- p90;
- p98;
- p99;
内存使用情况。
第一条虚线代表这些优化于 2026 年 5 月 18 日开始上线。
第二条虚线表示它们在 2026 年 7 月 6 日完成了所有服务的全面部署。
由于每一个发布版本都会引入本文介绍的一项或者多项优化,因此内存使用量是逐步、阶梯式下降的,而不是一次性全部下降。
随着每一个新版本逐步上线,重新启动的实例最初都会拥有空缓存。
随着缓存逐渐被填满,这些实例所占用的内存会不断增长。
因此,与刚刚重启后的短暂低谷相比,图中稳定的平台区域能够更准确地反映系统进入稳定状态之后的内存使用情况。

所有百分位上的单实例内存使用量都下降了。
在 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% |
我们计划把释放出来的这些内存重新用于增加缓存容量,而不会增加系统整体的内存使用量。
这样可以:
- 提高缓存命中率;
- 减少向上游 DNS 服务器发送的查询数量。
我们也正在继续探索针对缓存本身的进一步优化。
如果你想进一步了解 Big Pineapple,可以阅读:
《Rust 和 Wasm 如何为 Cloudflare 的 1.1.1.1 提供支持》
如果你也在开发 DNS 系统或者其他大规模系统,并且有一些行之有效的优化方法,也欢迎在 Cloudflare Community 或 Cloudflare Developers Discord 中分享。