优化 1.1.1.1 DNS 缓存,释放 100TB 内存

大菠萝,后面的站台1.1.1.1,网关DNS,DNS防火墙,AS112以及其他几个Cloudflare DNS服务,在任何时刻都存储超过2500亿个DNS缓存条目。
在这种规模下,每个条目浪费一个字节,整个系统都会消耗超过250GB的内存。
连续五次对缓存条目存储方式的改变,使每个条目占用空间减少了50%以上。在我们整个车队中,这些变化释放了大约100TB的内存,相当于我们130个内存的RAM容量第13代服务器.缓存速度也加快了。
插入吞吐量提升了43%,查找延迟下降了19%,因为分配减少了内存,内存区域更优,我们没有用速度换取空间。
我们缓存的内容
冷启动时,Big Pineapple 的缓存是空的。当 DNS 查询到达时,缓存会被填满,直到达到最大条目数,这时我们会淘汰较旧或不那么受欢迎的项目以腾出空间。
具体缓存大小因数据中心而异。当EDNS客户端子网(ECS)在使用中时,权威服务器会根据客户端网络返回不同的答案,因此我们会缓存同一查询的多个版本。
这增加了条目数量和每个条目消耗的内存,使得本文的优化对ECS密集的地点尤为有效。
缓存中的每个项都是键值对。键表示被查询的内容:
该值存储DNS响应本身:答案、权限和额外记录部分,以及创建时间、命中计数器和存活时间(TTL)等元数据。
这两个结构都有改进空间。有几个字段使用类型,这些类型在条目存储后不需要。
内存使用基准测试
为了衡量每项变化的影响,我们通过随机生成的条目填充缓存,这些条目大致符合生产环境中我们看到的流量分布:56%A纪录,25%AAAA,以及19%TXT.每条记录包含一到四条记录。
TXT记录作为所有非A/AAAA基准测试中的记录类型。它们的大小在64到224字节之间随机分配,接近我们看到的可变长度记录类型的平均响应大小。
我们通过自定义分配器那是拉斯特的System分配器并记录每个缓存条目的分配数量和大小。除了内存外,我们还测量整个缓存流的插入吞吐量和查找延迟,以确保节省内存不会以性能为代价。
这些输入近似生产,而非完全复制。进程内存还依赖于流量组合、缓存占用率、配置器状态以及缓存外使用的内存。因此,我们在部署期间测量了生产实例间的常驻内存。
容量成本
Vec存储三个字段:指向堆分配数据的指针、当前长度和总容量。当你推送一个项目时,Vec检查长度是否超过容量,必要时重新分配。如果有空间,只需附加该项并递增长度。
然而,一旦我们把DNS响应存储在缓存中,就不会再修改它。容量字段没有任何作用,但仍然需要每个字段8字节Vec.过度分配的堆空间也会被浪费,因为Vec该堆可容纳八件物品,但仅存五件,堆栈上有三个空位。
使用Box<[T]>这解决了这两个问题。它在创建后无法生长,所以不需要容量场或为未来元素预留空间。同样的道理也适用于String,也携带一个容量场。Box放下了。
每个缓存条目存储8个Vec以及String字段。用Box<[T]>以及Box每个字段节省8字节,每个条目节省64字节。它还消除了多余的堆内存Vec为未来增长准备。
综合节省总量超过15TB,缓存条目超过2500亿条。
列表更少,指针更少
我们无需将答案、权威和附加部分分别存储在单独列表中,而是可以存储一个带有偏移量的单一列表,且偏移量为每个部分的开头。由于每个部分的DNS记录计数适用于u16,我们可以使用u16(2字节)对应每个偏移量,而它们分别分隔的8字节指针和8字节长度Box<[T]>需要。
这会移除两个列表,每个列表的指针为8字节,长度为8字节,并用两个2字节偏移量替代,每个条目节省28字节。
这些节省并不总是直接对应到从各个字段中移除的字节数。Rust 插入填充以满足对齐要求并将结构体大小四舍五入到其对齐的整数倍。因此,移除一个小字段可以消除额外的填充。
例如,我们还将多个布尔字段压缩到一个位旗.这减少了周围填充物,使结构体缩小超过单个布尔值的大小。
放弃老板
每个DNS记录都有一个所有者,即该记录所属的域名。在许多情况下,这个所有者与被查询的域名是完全相同的。例如,查询example.com A返回两条同一所有者的记录:
但当CNAME例如,记录所有者可能与查询的域名不同:
DNS线格式通过名称压缩处理重复拥有者,定义如下RFC 1035.与其重复编码同一域,后续出现会存储一个2字节的指针指向第一次出现。域www.example.com可以编码www后面是一个指向example.com已经出现在消息里了。
这在有线传输上效果不错,但在我们的缓存中,我们会在每条记录旁边存储完整的所有者名称。在缓存查找时遵循压缩指针在热路径上成本较高,因此我们用内存换取速度。
然而,大多数记录的所有者与查询的域名完全相同。对于这些记录,我们可以完全省略所有者,并在读取时推断出它。当所有者不同时,例如A幕后纪录CNAME,我们存储全名。
当owner是None响应结构会从缓存键恢复查询域,避免堆分配。这意味着记录不再是自包含的,但缓存键在每次查找时已经可用。当所有者不同时,Some存储指向堆上完整名称的指针。
实际上,大多数缓存记录的所有者与查询域相同,因此大多数缓存字段不需要堆分配。
枚举大小
锈核枚举是和类型:每个变体可以携带不同的数据,但枚举总是其最大变体的大小。
Option是Some并且具有一个值,或者None且无任何存储。两种变体占用相同的内存。枚举存储一个标签表示活动变体,后面存储足够大空间容纳最大变体的数据。
当变体为None,即空间未被使用。
对于记录数据,通常将每种DNS记录类型存储为枚举变体:
但枚举总是与其最大变体一样大。在我们的案例中,这是NAPTR136字节。它存储三个可变长度的文本字段、一个域名和两个整数。因此,完整的枚举,包括变体标签和填充,总共为144字节。
一个A记录只需要4字节,并且AAAA记录需要16字节。A以及AAAA它们占我们流量的80%以上,所以大多数记录在填充上浪费了超过120字节。
由于单个缓存条目可以存储大量记录,这会很快累积起来。
变体盒装
为解决这个问题,我们可以将更大的 enum 变体打包,移到单独的堆分配中。enum 随后存储一个 8 字节的指向堆的指针,数据只占其实际需要的大小。
对于A以及AAAA记录,这样每条记录节省了120字节。较小的变体类型如TXT以及CNAME同时也受益。它们仍然占用24字节的枚举,但堆分配是根据实际数据大小,而不是填充到144字节。NAPTR最大变体,实际上支付略高的费用。它现在增加了堆指针和分配开销的成本。但是NAPTR实际记录很少见,所以这种权衡是值得的。
但装箱大唱片变体本身也带来了成本。
拳击的代价
Boxing 有两个成本。第一个是分配器开销。每个盒装变体都变成一个独立的堆分配,分配者会向上取整到最接近的大小类别。Big Pineapple 使用杰马洛克,一个为多线程、分配量大的工作负载设计的分配器。
Jemalloc 将大小相近的分配分组到固定大小的 bin 中。ATXT记录请求32字节,恰好放入32字节的bin中,不浪费任何东西,但MX记录请求40字节,向上取整至48字节,浪费8字节。
第二个代价是内存局部性较差。没有装箱时,缓存条目的记录枚举值会集中在同一个连续分配中。而盒装则是每个盒装变体的数据都存在一个独立的堆区域。
读取数据需要跟随指针,当指针落点远离条目其他部分时,CPU 必须重新获取缓存行。对于数百万个缓存条目,盒装数据最终会分散在堆中,而不是打包在一起。
这两种成本单独来看都不是灾难性的,但正如下一节所展示的,消除它们能显著改善内存使用和查找延迟。
以线格式存储记录
显而易见的下一步是将完整的 DNS 响应存储为线格式,只补丁每个客户端字段,比如每次查询时的消息 ID。但这也有缺点。只有当客户端设置DO(DNSSEC OK)flag。
存储完整的线格式消息意味着要么缓存两个变体,一个带DNSSEC,一个不带DNSSEC,要么从已构建的消息中过滤掉它们。每次查找解析完整消息也会产生成本,而我们刚才描述的枚举方法通过存储已解析好的记录来避免了这一点。
作为折中方案,我们只将记录数据存储为原始字节,其余缓存条目则保留为结构化字段。我们不再列出解析后的枚举变体列表,而是将记录作为一个单独存储Box<[u8]>包含每个记录,以2字节长度的前缀和其原始字节编码。
这消除了之前优化中每个变体的枚举开销和盒装堆分配。数据也实现连续打包,从而提升了CPU缓存的本地性。权衡是记录不能再被随机索引。
我们必须顺序遍历缓冲区。这增加了一些复杂度,适用于像循环赛旋转A/AAAA记录,但由于每个条目记录数量较少,成本可以忽略不计。
在从缓存记录构建DNS响应时,大多数记录类型可以直接从缓冲区复制到发出消息中。此前,每个解析后的记录都必须逐字段序列化回DNS线格式。
新布局跳过了该格式A,AAAA,TXT,以及所有DNSSEC记录类型,通过直接复制其编码字节。仅包含域名的记录,例如CNAME,NS,MX, 和SOA仍然需要解析,以便应用DNS名称压缩。
由于支持直接复制的记录占据了我们流量的绝大多数,这一变化减少了查找路径的工作量。结合内存局部性的改进,这在我们的基准测试中将缓存查找延迟降低了5%。
为了构建记录数据缓冲区,我们写入一个可重复使用的暂存空间缓冲区,该缓冲区在缓存插入中持续存在。由于之前的写入已经扩展了缓冲区,缓冲区很少需要重新分配。
记录大小各异,因此在序列化之前我们无法确定具体缓冲区大小。一旦记录进入了缓冲区,我们会分配一个Box<[u8]>以及memcpy将数据输入其中。
这用一个分配替换了每个盒装记录的单独分配,而是所有记录数据的分配。这也避免了因Vec,分配者可能无法回收原始分配的未使用尾部。
在我们的基准测试中,仅这一变化就使缓存插入吞吐量提高了13%。
结果
生产测量显示了基准的每次条目节省如何转化为整进程驻留内存。下图显示了 Big Pineapple 实例中 p90、p98 和 p99 内存使用情况。
第一条虚线表示 2026 年 5 月 18 日部署开始,第二条线表示 2026 年 7 月 6 日所有服务的部署完成。每个版本都会引入上述一项或多项优化,因此内存使用是分阶段下降,而非一次性全部下降。
随着每次更新的推出,重启实例从缓存空开始,缓存填满后占用更多内存。因此,稳定平台期比初始的内存低谷更能代表稳态内存使用情况。
所有百分位的实例内存使用量均下降。在第99点,内存从9.3 GB降至5.3 GB,驻留内存减少了43%。在第90点,内存从6.5 GB降至3.8 GB,减少了42%。
缓存较满的实例实现了最大的绝对节省。
在我们的基准测试中,这五项优化将每条目内存占用从953字节减少到420字节,减少了56%。每个条目分配从1.1 KB降至461字节。生产环境中测量的减少较小,因为常驻内存包含缓存及所有进程数据。
部署稳定后,整个部署的总工作集内存减少了约100 TB。
性能也有所提升。缓存插入吞吐量提升了43%,查找延迟下降了19%。
我们计划将释放出来的内存重新投入到增加缓存容量上,同时不增加内存使用量,这样可以提高缓存命中率并减少上游查询量。我们还在探索对缓存本身的进一步优化。
想了解更多关于大菠萝的信息,请参见Rust 和 Wasm 如何驱动 Cloudflare 的 1.1.1.1.如果你在DNS或其他大型系统工作,分享一下你在Cloudflare 社区或者在Cloudflare 开发者 Discord.