The Cloudflare Blog

How we saved 100 terabytes of memory by optimizing 1.1.1.1’s DNS cache

8.5内容质量

TL;DR · AI 摘要

Cloudflare通过五次DNS缓存优化节省100TB内存,提升43%插入吞吐量和19%查询速度。

核心要点

  • 五次优化减少50%内存占用,节省100TB内存。
  • 插入吞吐量提升43%,查询延迟降低19%。
  • ECS优化在高流量数据中心节省显著内存。

结构提纲

按章节快速跳转。

  1. 介绍CloudflareDNS缓存规模和优化目标。

  2. 解析DNS缓存条目的键值结构及内存占用问题。

  3. 五次优化方法减少内存占用并提升性能。

  4. 插入吞吐量提升43%,查询延迟降低19%。

  5. ECS影响

    ECS优化在高流量数据中心节省显著内存。

  6. 总结优化成果及对工程实践的启示。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • DNS缓存优化
    • 优化措施
      • 结构精简
      • 类型优化
      • 内存对齐
    • 性能提升
      • 吞吐量+43%
      • 延迟-19%
    • ECS影响
      • 多版本缓存优化

金句 / Highlights

值得收藏与分享的关键句。

#DNS优化#内存管理#Cloudflare#Rust#性能提升
打开原文

通过优化 1.1.1.1 的 DNS 缓存节省 100TB 内存 | Cloudflare 博客

post

工程

优化

性能

Rust

1.1.1.1

深度解析

DNS

2026年8月27日

通过优化 1.1.1.1 的 DNS 缓存节省 100TB 内存

Sebastiaan Neuteboom

12 分钟阅读

复制链接

支撑 1.1.1.1、Gateway DNS、DNS Firewall、AS112 以及多个 Cloudflare DNS 服务的 Big Pineapple 平台,在任意时刻存储着超过 2500 亿条 DNS 缓存条目。在如此庞大的规模下,每条记录浪费 1 字节内存,整个集群的内存消耗将超过 250GB。

通过五次连续改进缓存条目在内存中的存储方式,使每条记录的内存占用减少了超过 50%。在整个集群中,这些改进释放了约 100TB 内存,相当于 130 台第 13 代服务器的内存总量。缓存性能也得到提升,插入吞吐量提高 43%,查询延迟降低 19%,因为更少的内存分配和更好的内存局部性使我们无需在速度和空间之间做出权衡。

我们的缓存内容

Big Pineapple 在冷启动时从空缓存开始。随着 DNS 查询的到来,缓存逐渐填充,直到达到最大条目数限制,此时会淘汰较旧或不常用的条目以腾出空间。

缓存大小在不同数据中心有所差异。当使用 EDNS 客户端子网(ECS)时,权威服务器会根据客户端网络返回不同答案,因此我们需要缓存相同查询的多个版本。这既增加了条目数量,也增加了每个条目的内存消耗,使得本文的优化对 ECS 使用率高的地区影响尤为显著。

缓存中的每个条目都是一个键值对。键用于标识查询内容:

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

值存储 DNS 响应本身:回答、权威和附加记录部分,以及元数据如创建时间、命中计数器和生存时间(TTL)。

code
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 会检查长度是否超过容量,如果需要会重新分配内存。如果还有空间,它会直接追加元素并增加长度。

然而,一旦我们将 DNS 响应存储到缓存中,就再也不会修改它。容量字段没有实际用途,但仍会为每个 Vec 消耗 8 字节。过度分配的堆空间同样被浪费了,比如一个容量为 8 个元素但只存储了 5 个元素的 Vec,会在堆上留下三个未使用的空间。

使用 Box<[T]> 可以解决这两个问题。由于 Box<[T]> 在创建后无法增长,因此不需要容量字段或为未来元素预留空间。String 也是如此,它同样携带容量字段,而 Box<str> 则去掉了这个字段。

每个缓存条目存储 8 个 Vec 和 String 字段。将它们替换为 Box<[T]> 和 Box<str> 后,每个字段可节省 8 字节,每个条目可节省 64 字节。同时消除了 Vec 为未来增长预留的额外堆内存。这些节省的总和在超过 2500 亿个缓存条目时,可达到超过 15TB 的内存。

更少的列表,更少的指针

我们无需将答案、权威和附加部分分别存储在独立列表中,而是可以存储一个包含各部分起始位置偏移量的单一列表。由于每个部分的 DNS 记录数量可以放入 u16 中,我们可以用 2 字节的 u16 表示每个偏移量,而不是每个独立的 Box<[T]> 所需的 8 字节指针和 8 字节长度。

这消除了两个列表(每个列表包含 8 字节指针和 8 字节长度),并用两个 2 字节偏移量代替,每个条目节省 28 字节。

这些节省并不总是与移除单个字段的字节数直接对应。Rust 会插入填充字节以满足对齐要求,并将结构体的大小向上取整到对齐倍数。移除一个小型字段可能会消除额外的填充。例如,我们还将多个布尔字段打包到一个位标志中,这减少了周围的填充,使结构体整体缩小的幅度超过了单个布尔值的大小。

移除所有者字段

每个 DNS 记录都有一个所有者字段,即记录所属的域名。在许多情况下,这个所有者与查询的域名相同。例如,查询 example.com 的 A 记录会返回两个拥有相同所有者的记录:

code
$
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 记录时,记录所有者可能与查询域名不同:

code
$
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 协议通过 RFC 1035 定义的名称压缩处理重复的所有者字段。而不是重复编码相同域名,后续出现的域名会存储一个指向首次出现位置的 2 字节指针。例如 www.example.com 可以编码为 www 后接一个指向消息中已出现 example.com 的指针。

这种机制在协议传输中效果良好,但在我们的缓存中,我们为每个记录存储完整的所有者名称。在缓存查找过程中跟随压缩指针会带来较高的性能开销,因此我们选择用内存换取速度。

然而大多数记录的所有者与查询域名一致。对于这些记录,我们可以完全移除所有者字段,并在读取时推断出该字段。当所有者不同时(例如 CNAME 后面的 A 记录),我们才存储完整的域名。

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

当 owner 为 None 时,响应构建会从缓存键中恢复查询的域名,避免堆分配。这意味着记录不再自包含,但缓存键在每次查询时都已可用。当 owner 不同时,Some 会存储指向完整名称的堆指针。

实际上,大多数缓存记录的 owner 与查询的域名相同,因此大多数记录的 owner 字段无需堆分配。

枚举大小

Rust 枚举是求和类型:每个变体可以携带不同的数据,但枚举的大小始终等于其最大变体的大小。

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

Option 要么是 Some 并持有值,要么是 None 并不持有任何内容。两种变体占用相同的内存。枚举存储一个标记表示当前变体,后跟足够容纳最大变体数据的空间。当变体是 None 时,这部分空间未被使用。

对于记录数据,将每种 DNS 记录类型作为枚举变体存储似乎是自然的选择:

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

但枚举的大小始终等于其最大变体的大小。在我们的情况下,NAPTR 占用 136 字节。它存储三个可变长度的文本字段、一个域名和两个整数。因此,包括变体标记和填充在内的完整枚举大小变为 144 字节。

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

将变体放入堆中

为了解决这个问题,我们可以将枚举中较大的变体放入堆中,将其移动到单独的堆分配中。此时枚举只存储一个 8 字节的堆指针,数据在堆上只占用实际所需的空间。

code
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 记录很少见,因此这种权衡是值得的。

但将较大的记录变体放入堆中本身也引入了成本。

放入堆中的成本

放入堆中带来两种成本。第一种是分配器开销。每个放入堆的变体都会成为单独的堆分配,分配器会将大小对齐到最近的大小类。Big Pineapple 使用 jemalloc,这是为多线程、高分配负载设计的分配器。jemalloc 将相似大小的分配分组到固定大小的桶中。TXT 记录请求 32 字节并正好匹配 32 字节桶,没有浪费,但 MX 记录请求 40 字节并向上对齐到 48 字节,浪费了 8 字节。

第二个成本是内存局部性差。在不使用装箱的情况下,缓存条目对应的记录枚举值会存储在一块连续的内存区域中。而使用装箱后,每个装箱变体的数据会分散在堆的不同区域。读取这些数据需要通过指针跳转,当指针指向的位置远离条目其他部分时,CPU 就需要重新加载新的缓存行。当缓存条目数量达到数百万级时,装箱数据会分散在堆中,而非集中存储。

单独来看,这两种成本都不会造成灾难性影响,但如下一节所示,消除这两项成本后,内存使用量和查询延迟都能获得可衡量的提升。

以线格式存储记录

一个显而易见的改进方向是将完整的 DNS 响应以线格式存储,仅在每次查询时修改每个客户端特有的字段(如消息 ID)。但这种方法存在弊端:只有当客户端设置 DO(DNSSEC OK)标志时才会包含 DNSSEC 记录。存储完整线格式消息意味着需要缓存两种变体(一种包含 DNSSEC,一种不包含),或从已构建的消息中过滤掉 DNSSEC 记录。此外,每次查询都需要解析完整消息,而我们之前描述的枚举方案通过存储已解析的记录避免了这一开销。

作为折中方案,我们仅将记录数据以原始字节形式存储,而将缓存条目的其余部分保留为结构化字段。不再使用解析后的枚举变体列表,而是将记录存储为一个 Box<[u8]>,其中每个记录以 2 字节长度前缀开头,后接其原始字节。

这种方案消除了每种变体的枚举开销,以及之前优化方案中的装箱堆分配。数据也变得连续存储,从而提升了 CPU 缓存局部性。代价是记录无法再随机索引,必须顺序遍历缓冲区。这为 A/AAAA 记录的轮询等功能增加了复杂度,但由于每条目记录数量较少,开销可以忽略不计。

从缓存记录构建 DNS 响应时,大多数记录类型可以直接从缓冲区复制到传出消息中。此前,每个解析后的记录都需要逐字段重新序列化为 DNS 线格式。新布局通过直接复制已编码字节,跳过了 A、AAAA、TXT 和所有 DNSSEC 记录类型的序列化过程。只有包含域名的记录(如 CNAME、NS、MX 和 SOA)仍需解析,以便应用 DNS 域名压缩。由于支持直接复制的记录占我们流量的绝大多数,这一改进显著降低了查询路径的处理开销。结合改进的内存局部性,我们的基准测试显示缓存查询延迟降低了 5%。

构建记录数据缓冲区时,我们向一个跨缓存插入操作复用的临时缓冲区写入数据。由于之前的写入已扩展了缓冲区大小,因此很少需要重新分配内存。记录大小不一,因此必须等到序列化完成才能确定缓冲区的确切大小。一旦记录写入临时缓冲区,我们就会分配一个 Box<[u8]> 并通过 memcpy 将数据复制进去。这将原本每个装箱记录单独分配的内存,合并为所有记录数据的一次性分配。同时避免了 Vec<u8> 缩小时的内存浪费问题(分配器可能无法回收原始分配的未使用尾部)。在我们的基准测试中,仅这一改进就使缓存插入吞吐量提升了 13%。

结果

生产环境的测量数据展示了每项基准节省如何转化为整个进程的驻留内存使用情况。下图显示了Big Pineapple实例的p90、p98和p99内存使用情况。第一条虚线标记了2026年5月18日 rollout 的开始,第二条虚线标记了2026年7月6日所有服务 rollout 的完成。每次发布引入了一个或多个上述优化,因此内存使用量是逐步下降而非一次性下降。

随着每次发布 rollout,重启的实例会从空缓存开始,缓存填充时会消耗更多内存。因此,稳定的平台区域比初始下降值更能代表稳态内存使用情况。

所有百分位的单实例内存使用量均下降。在p99,内存从9.3 GB降至5.3 GB,驻留内存减少了43%。在p90,内存从6.5 GB降至3.8 GB,减少了42%。缓存更满的实例实现了最大的绝对节省。

在我们的基准测试中,这五项优化将每项的内存占用从953字节减少到420字节,减少了56%。每项分配的内存从1.1 KB降至461字节。由于驻留内存包含缓存和其他所有进程数据,生产环境中测量到的减少幅度较小。 rollout 稳定后,整个舰队的聚合工作集内存降低了约100太字节。

性能也有所提升。缓存插入吞吐量增加了43%,而查找延迟降低了19%。

指标

发布前

发布后

变化

每项净内存占用

953字节

420字节

-56%

每项分配内存

1.1 KB

461字节

-58%

缓存插入吞吐量

625,000条目/秒

893,000条目/秒

+43%

缓存查找延迟

828纳秒

670纳秒

-19%

我们计划将释放的内存重新投入,增加缓存容量而不增加内存使用量,这将提高缓存命中率并减少上游查询量。我们也在探索对缓存本身的进一步优化。

要了解更多关于Big Pineapple的信息,请参阅《How Rust and Wasm power Cloudflare's 1.1.1.1》。如果您从事DNS或其他大型系统开发,请在Cloudflare社区或Cloudflare开发者Discord上分享对您有效的优化方案。

目录列

讨论列

相关标签和社交媒体链接

相关标签

关注社交媒体

  • Cloudflare

电子邮件订阅