Cloudflare DNS 缓存优化工程复盘:五次数据结构手术省下 100TB 内存

Cloudflare 在 8 月下旬发布的这篇工程博客,主角是 1.1.1.1 背后的 DNS 服务平台 Big Pineapple。这个平台在任意时刻持有超过 2500 亿条 DNS 缓存条目,支撑着 1.1.1.1、Gateway DNS、DNS Firewall、AS112 等一系列服务。在这个量级上,每条缓存条目哪怕多浪费 1 个字节,全舰队加起来就是 250 GB 以上的内存。

团队对缓存条目的内存布局做了五次连续改造,把单条目的净占用从 953 字节压到 420 字节,降幅 56%。全舰队汇总后释放的内存约 100 TB,折算下来相当于 130 台 Cloudflare Gen 13 服务器的全部 RAM。改造同时让缓存的插入吞吐从每秒 62.5 万条升到 89.3 万条(+43%),查询延迟从 828 纳秒降到 670 纳秒(-19%):省内存没有以牺牲速度为代价。

从这一节起,按手术顺序逐刀拆解,每一步都带着它省下的字节数。

缓存里存的是什么

Big Pineapple 冷启动时缓存为空,随着 DNS 查询到来逐步填满,到达最大条目数后按新旧和热度逐出。条目是标准的键值对:键包含查询域名(qname)、记录类型(qtype)、是否经过 DNSSEC 验证和一个标签字段;值是完整的 DNS 响应,包括 answer、authority、additional 三个 record 段,外加创建时间、命中计数器和 TTL。

有一个细节让优化在这些节点上格外有效:启用 EDNS Client Subnet(ECS)时,权威服务器会按客户端网段返回不同答案,同一个查询要在缓存里存多个版本。ECS 流量占比高的数据中心,条目数量和单条体积同时膨胀,优化的收益也最大。

第零步:先造一把能测内存的尺子

这次改造有个容易被忽略的前置工作:内存优化怎么证明有效。团队的做法是按生产流量分布生成随机条目填满缓存——56% A 记录、25% AAAA、19% TXT(TXT 用来代表所有非 A/AAAA 的变长记录,长度在 64 到 224 字节间随机,接近真实均值)——然后用一个包着 Rust 系统 allocator 的自定义 GlobalAlloc,逐条记录每次分配的数量和大小。

benchmark 归 benchmark,文章同时保留了生产侧验证:改造在 5 月 18 日到 7 月 6 日之间分批 rollout,每批只含一两个优化点,各实例的常驻内存(resident memory)在滚动重启后按平台期逐级下台阶。benchmark 模拟不了真实的流量混合、缓存占用率和 allocator 状态,生产数据才是最终口径。

手术一:把可变容器的「容量税」退回去

第一刀砍在 Rust 的 Vec<T> 上。Vec 内部是三个 8 字节字段:数据指针、当前长度、总容量。容量字段的存在理由是支持追加元素时自动扩容——但 DNS 响应一旦写入缓存就再也不会修改,容量字段从此纯浪费。更糟的是堆上预留的空间:一个容量为 8、实际只存 5 个元素的 Vec,有 3 个槽位永远空着。

Vec 的内存布局:ptr、len、capacity 三个 8 字节字段,堆上分配 8 个槽位只用 5 个

替代方案是 Box<[T]>(boxed slice):创建后不可增长,头部只有指针加长度 16 字节,堆上按实际元素数精确分配。String 同理换成 Box<str>。每条缓存条目有 8 个 Vec/String 字段,每个省 8 字节头部,合计每条省 64 字节,外加收回全部堆上预留空间。按 2500 亿条目算,仅这一项就超过 15 TB。

手术二:三个列表合并成一个

第二个观察是:answer、authority、additional 三个 record 段各存一个列表,每个列表都带 8 字节指针加 8 字节长度。团队把三个列表合成一个大列表,用两个 2 字节的 u16 偏移量记录每段的起始位置——DNS 每段的记录数本来就用 u16 编码,两个偏移量完全够用。一进一出,每条省 28 字节。

这里顺带解释了 Rust 内存布局的一个普适现象:结构体的实际收缩量可以超过被删字段的大小。Rust 会对齐填充(padding),把结构体尺寸向上取整到对齐值的倍数。团队同时把多个布尔字段打包成一个 bitflag,周围 padding 跟着消失,结构体缩得比布尔字段本身还多。

手术三:不存重复的域名 owner

每条 DNS 记录都带一个 owner 字段,标明这条记录属于哪个域名。多数情况下 owner 与查询域名相同——查 example.com A 返回的两条 A 记录,owner 都是 example.com。差异只出现在 CNAME 链这类场景:example.com 的 CNAME 指向 cdn.example.com,后两条 A 记录的 owner 就是 cdn.example.com

DNS 线上格式早就解决过这个问题:RFC 1035 的名字压缩用 2 字节指针指向报文中已出现的域名。但缓存里不能直接用压缩指针——查询热路径上跳指针解析域名太慢,这里原本用「每条记录完整存一份 owner」换速度。

这次的选择更细:owner 与查询域名相同的记录(占绝大多数)直接不存 owner,读的时候从缓存键还原,零堆分配;owner 不同的才完整存一份。字段类型从 Box<Name> 改成 Option<Box<Name>>None 表示「从键里推」。记录不再自包含,但缓存键在每次查找时本来就在手上,这个约束可以接受。

手术四:枚举尺寸由最大的变体决定

第四刀最典型,对象是存记录数据的 Rust 枚举。Rust 枚举是和类型(sum type),整体尺寸等于最大变体的尺寸。把每种 DNS 记录类型做成一个变体后,最大的 NAPTR 变体有 136 字节(三个变长文本字段加一个域名加两个整数),整个枚举连 tag 带填充达到 144 字节。而占流量 80% 以上的 A 记录只要 4 字节、AAAA 只要 16 字节——绝大多数记录在为极少数 NAPTR 陪跑,每条白付 120 字节以上的填充。

解法是把大变体装箱(box):小而常见的 AAAAA 留在枚举内联,TXTNAPTRSvcb 等改成 Box<Txt> 这类堆指针,枚举缩到 24 字节,堆上按实际大小分配。

RecordData 枚举装箱前后的布局:A/AAAA 内联 24 字节,NAPTR 移到堆上按实际 136 字节分配

博客没有回避这个方案的代价,两条都很实在。一是 allocator 开销:Big Pineapple 用 jemalloc,按固定档位(size class)分配内存,32 字节的 TXT 请求正好落进 32 字节档位不浪费,40 字节的 MX 请求会被舍入到 48 字节档位,多付 8 字节。二是局部性变差:装箱前一条记录的枚举数据连续存放,装箱后每个大变体散落在堆的各处,读取要多追一次指针、多取一条 cache line。NAPTR 这类最大变体装箱后反而略贵(多付一个堆指针加分配开销),好在它在真实流量里极罕见,这笔交换是划算的。

手术五:记录数据直接存线上字节

终极方案没有走「整条响应按 wire format 缓存」的极端路线——那样要么为 DNSSEC 缓存两份变体(带 DO flag 和不带的),要么每次查找都在已构建的报文里过滤,还要在热路径上重新解析整个报文。

折中做法是:只把记录数据本身编码成原始字节,其余字段保持结构化。每个记录按「2 字节长度前缀 + 原始字节」依次写进一个可复用的 scratchspace 缓冲区,序列化完一次性分配一个 Box<[u8]> 并 memcpy 过去。原来每个装箱记录一次分配,现在全部数据合为一次分配,堆上完全连续。

效果出现在两个方向。内存上,枚举变体开销和装箱分配全部消失。速度上反而变快了:构建响应时,A、AAAA、TXT 和全部 DNSSEC 记录类型的字节可以直接从缓冲区拷进出站报文,跳过了原先逐字段序列化的步骤;只有 CNAME、NS、MX、SOA 这类含域名的记录仍需解析以施加名字压缩。直接可拷贝的类型占了流量的绝大多数,这一步让查询延迟再降 5%,插入吞吐在 benchmark 里提升 13%。代价是缓冲区只能顺序遍历、不能随机索引,对 A/AAAA 轮询(round-robin)这类功能要多写点逻辑,但单条记录数少,开销可以忽略。

生产数据:三级台阶

五步改造对应五次发布,生产环境常驻内存呈台阶状下降:

Big Pineapple 实例常驻内存 p90/p98/p99 曲线,5 月 18 日开始 rollout,7 月 6 日完成

指标改造前改造后变化
单条目净占用953 字节420 字节-56%
单条目分配量1.1 KB461 字节-58%
插入吞吐625,000 条/秒893,000 条/秒+43%
查询延迟828 ns670 ns-19%

生产侧的百分比略小于 benchmark:p99 常驻内存从 9.3 GB 降到 5.3 GB(-43%),p90 从 6.5 GB 降到 3.8 GB(-42%)。差额来自进程里缓存以外的数据(连接状态、运行时开销等)没有变小。缓存填得越满的实例,绝对节省越大。博客对曲线形态有一个诚实的说明:每次重启后缓存从零开始回填,初期 dip 低于稳态,看平台期才准。

这套方法可迁移的部分

省下来的 100 TB 有明确去处:在总内存不变的前提下扩大缓存容量,直接抬升命中率、减少向上游权威服务器的查询量。

对做大规模缓存服务的工程师,这篇博客的方法论可以按顺序复用:先造测量工具(按真实流量分布生成负载 + allocator 层逐条计量),再按「确定写入后不变的字段删掉、重复引用的字段去重、大变体拖累整体布局的装箱、最终形态连续存放」的次序动手,每一步都同时盯内存、吞吐、延迟三个数字。953 字节到 420 字节之间没有一步魔法,五刀全部砍在「为不需要的可变性、不需要的重复、不需要的对齐付费」这类结构性浪费上。

来源:How we saved 100 terabytes of memory by optimizing 1.1.1.1's DNS cache — Cloudflare Blog,作者 Sebastiaan Neuteboom