ESC
科技 2 分钟阅读

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

Cloudflare对其DNS缓存存储进行五项优化,将每个缓存条目的内存占用从953字节降至420字节,降幅达56%。全舰队合计释放约100TB内存,相当于130台Gen 13服务器的内存总量。同时缓存性能也得到提升:插入吞吐量提高43%,查找延迟降低19%。

来源:Hacker News

Big Pineapple是支撑1.1.1.1、Gateway DNS、DNS Firewall、AS112以及Cloudflare其他多项DNS服务的平台,它在任意时刻存储超过2500亿条DNS缓存条目。在这样的规模下,每个条目浪费1字节,整个舰队就会浪费超过250GB内存。

对缓存条目在内存中存储方式的五次连续改进,将每个条目的占用空间削减了50%以上。在我们的整个舰队中,这些改进释放了大约100TB内存,相当于130台Gen 13服务器的RAM总量。缓存也变得更快了。插入吞吐量提升了43%,查找延迟下降了19%,因为更少的分配和更好的内存局部性意味着我们并未用速度换取空间。

我们缓存什么

冷启动时,Big Pineapple从一个空缓存开始。随着DNS查询的到来,缓存逐渐填满,直到达到最大条目数,此时我们会驱逐较旧或不太受欢迎的条目以腾出空间。

具体的缓存大小因数据中心而异。当使用EDNS客户端子网(ECS)时,权威服务器会根据客户端网络返回不同的答案,因此我们会缓存同一查询的多个版本。这既增加了条目数量,也增加了每个条目消耗的内存,使得本文中的优化对ECS密集型位置尤其有效。

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

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

值存储DNS响应本身:答案、授权和附加记录部分,以及创建时间、命中计数器和生存时间(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存储三个字段:指向堆分配数据的指针、当前长度和总容量。当你推入一个项目时,Vec会检查长度是否超过容量,并在需要时重新分配。如果有空间,它只是追加项目并递增长度。

1.png

然而,一旦我们将DNS响应存储到缓存中,就再也不会修改它。容量字段毫无用处,但每个Vec仍要花费8字节。过度分配的堆空间也被浪费了,因为一个容量为8但只存储5个项目的Vec在堆上留下了3个未使用的槽位。

2.png

使用Box可以同时解决这两个问题。它在创建后无法增长,因此不需要容量字段,也不需要为未来元素预留空间。String也是如此,它也带有容量字段。Box将其丢弃。

每个缓存条目存储8个Vec和String字段。将它们替换为Box和Box,每个字段节省8字节,每个条目节省64字节。它还消除了Vec为未来增长预留的额外堆内存。综合节省在超过2500亿条缓存条目下总计超过15TB。

更少的列表,更少的指针

与其将答案、授权和附加部分存储在单独的列表中,我们可以存储一个包含每个部分起始偏移量的列表。由于每个部分的DNS记录数适合u16,我们可以为每个偏移量使用u16(2字节),而每个单独的Box则需要8字节指针和8字节长度。

3.png

这移除了两个列表,每个列表有8字节指针和8字节长度,并用两个2字节偏移量替换,每个条目节省28字节。

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

丢弃所有者

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

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

$ 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记录),我们存储完整名称。

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

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

4.png

实际上,大多数缓存的记录所有者与查询域名相同,因此大多数记录不需要为所有者字段进行堆分配。

枚举大小

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

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),
    // ...
}

但枚举总是和其最大变体一样大。在我们的例子中,最大的是NAPTR,136字节。它存储三个可变长度文本字段、一个域名和两个整数。因此,整个枚举(包括变体标签和填充)变成144字节。

5.png

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

对变体进行装箱

为了解决这个问题,我们可以对枚举中较大的变体进行装箱,将它们移动到单独的堆分配中。枚举随后存储一个8字节指针指向堆,而数据只占用其实际所需的大小。

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记录在实践中很少见,所以这种权衡是值得的。

6.png

但将较大的记录变体装箱也带来了自身的代价。

装箱的代价

装箱有两个代价。首先是分配器开销。每个装箱的变体都变成一次单独的堆分配,而分配器会向上取整到最接近的大小类别。Big Pineapple使用jemalloc,这是一个为多线程、重分配工作负载设计的分配器。jemalloc将相似大小的分配分组到固定大小的bin中。TXT记录请求32字节,正好放入32字节bin,不浪费任何内容,但MX记录请求40字节,向上取整到48字节,浪费8字节。

第二个代价是内存局部性差。没有装箱时,缓存条目的记录枚举值位于单个连续分配中。有了装箱,每个装箱变体的数据位于单独的堆区域。读取它需要跟随指针,当该指针落在条目其余部分的远处时,CPU必须获取新的缓存行。由于有数百万个缓存条目,装箱数据最终散落在整个堆中,而不是打包在一起。

7.png

这两项代价单独来看都不是灾难性的,但正如下一节所示,消除两者会在内存使用和查找延迟方面带来可衡量的改进。

以线格式存储记录

一个明显的下一步是将完整的DNS响应以线格式存储,每次查找时只修补每客户端字段(如消息ID)。但这有缺点。DNSSEC记录仅在客户端设置DO(DNSSEC OK)标志时才包含。存储完整的线格式消息意味着要么缓存两个变体(一个带DNSSEC,一个不带),要么从已构建的消息中过滤掉它们。此外,每次查找解析完整消息也有成本,而我们刚才描述的枚举方法通过存储已解析的记录避免了这一成本。

作为折中方案,我们仅将记录数据存储为原始字节,同时保持缓存条目的其余部分为结构化字段。我们不是存储解析后的枚举变体列表,而是将记录存储为单个Box,每个记录编码为2字节长度前缀后跟原始字节。

8.png

这消除了先前优化中每个变体的枚举开销和装箱的堆分配。数据也变得连续打包,从而改善了CPU缓存局部性。代价是记录不能再被随机索引。我们必须顺序遍历缓冲区。这为诸如A/AAAA记录的轮询旋转等功能增加了一些复杂性,但由于每个条目的记录数很少,成本可以忽略不计。

从缓存记录构建DNS响应时,大多数记录类型可以直接从缓冲区复制到传出消息中。以前,每条解析后的记录必须逐字段序列化回DNS线格式。新布局通过直接复制编码字节,跳过了A、AAAA、TXT以及所有DNSSEC记录类型的这项工作。只有包含域名的记录(如CNAME、NS、MX和SOA)仍然需要解析,以便我们应用DNS名称压缩。由于支持直接复制的记录占我们流量的绝大多数,这一变化减少了查找路径上的工作。结合改进的内存局部性,这在我们的基准测试中将缓存查找延迟降低了5%。

为了构建记录数据缓冲区,我们写入一个可重用的scratchspace缓冲区,该缓冲区在缓存插入之间持续存在。由于之前的写入已经扩展了它,缓冲区很少需要重新分配。记录大小不一,因此在它们被序列化之前我们不知道确切缓冲区大小。一旦记录在scratchspace缓冲区中,我们分配一个Box并将数据memcpy进去。这用一次所有记录数据的分配取代了之前每个装箱记录的单独分配。它还避免了收缩Vec时的浪费,因为分配器可能无法回收原始分配中未使用的尾部。在我们的基准测试中,仅这一项改动就将缓存插入吞吐量提高了13%。

结果

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

随着每个版本的推出,重启的实例从空缓存开始,随着缓存填充而消耗更多内存。因此,稳定的平台期比最初的下降更能代表稳态内存使用。

9.png

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

在我们的基准测试中,这五项优化将每个条目的内存占用从953字节降至420字节,减少56%。每个条目的分配从1.1KB降至461字节。生产环境中测得的减少较小,因为常驻内存包括缓存和所有其他进程数据。部署稳定后,整个舰队的聚合工作集内存大约降低了100TB。

性能也有所改善。缓存插入吞吐量提高了43%,而查找延迟下降了19%。

指标优化前优化后变化
每条目净占用953字节420字节-56%
每条目分配1.1KB461字节-58%
缓存插入吞吐量625,000条目/秒893,000条目/秒+43%
缓存查找延迟828纳秒670纳秒-19%

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

要了解更多关于Big Pineapple的信息,请参阅How Rust and Wasm power Cloudflare’s 1.1.1.1。如果你从事DNS或其他大型系统的工作,请在Cloudflare Community或Cloudflare Developers Discord上分享对你有用的优化。

相关标签

1.1.1.1Deep DiveDNSEngineeringOptimizationPerformanceRust

在社交媒体上关注

  • CloudflareCloudflare

div:not(:last-child)]:dashed-btm lg:[&>div:not(:last-child)]:dashed-inline-end">

搜索暂时不可用。

登录在新标签页中打开仪表板在新标签页中打开联系销售在新标签页中打开开始构建在新标签页中打开

在新标签页中打开在新标签页中打开在新标签页中打开

所有类别

英语