优化 1.1.1.1 的 DNS 缓存,省下 100TB 内存
文章摘要
Cloudflare 工程师 Sebastiaan Neuteboom 在 2026 年 8 月 27 日发布的这篇工程博客,讲的是一件听起来枯燥、算起账来惊人的事:他们对 1.1.1.1 背后的 DNS 平台 Big Pineapple 做了五处缓存内存布局优化,把每条缓存条目的内存占用砍掉一半以上,全球机队因此释放了大约 100TB 内存——相当于 130 台 Gen 13 服务器的全部 RAM。更难得的是速度也变快了:插入吞吐提升 43%,查找延迟下降 19%,没有拿性能换空间。
背景是规模:Big Pineapple 支撑 1.1.1.1、Gateway DNS、DNS Firewall、AS112 等服务,任意时刻在机队中保存超过 2500 亿条 DNS 缓存条目。在这个量级下,每条浪费 1 个字节,全机队就要多付出 250GB 内存。启用 EDNS Client Subnet(ECS)的场景里,同一个查询会因客户端网段不同缓存出多个版本,条目数和单条内存占用都会放大,所以 ECS 密集的机房收益最明显。
缓存的 key 是 CacheKey(域名、查询类型、是否已认证、tag),value 是 CacheEntry(时间戳、TTL、命中计数,以及 answer / authority / additional 三段记录列表)。作者按顺序讲了五步优化:
第一步,砍掉 capacity 字段。 Rust 的 Vec<T> 存三个字段:堆指针、长度、容量。但 DNS 响应一旦写进缓存就再也不会被修改,容量字段毫无用处,却要占 8 字节;而且 Vec 为增长预留的多余堆空间也全是浪费。换成不可增长的 Box<[T]>(字符串换成 Box<str>)两个问题一起解决。每条条目有 8 个这样的字段,光结构体本身就省 64 字节,加上堆上的冗余,按 2500 亿条算超过 15TB。
第二步,三段列表合并成一段。 与其为 answer / authority / additional 各存一个列表(每个都是 8 字节指针 + 8 字节长度),不如存一个列表加上两个分段偏移量。DNS 每段的记录数放得进 u16,两个 2 字节偏移替换掉两组 16 字节,每条省 28 字节。作者顺带提醒:这类节省不能简单按字段大小相加,因为 Rust 会插入对齐 padding 并把结构体大小向上取整到对齐倍数——他们把几个 bool 打包成 bitflag 之后,结构体缩小的幅度反而超过了那几个布尔值本身的大小。
第三步,丢掉 owner。 每条 DNS 记录都有一个 owner(记录所属域名),绝大多数情况下它和被查询的域名完全一样,只有涉及 CNAME 时才会不同。DNS 线格式用 RFC 1035 的名称压缩指针来处理重复,但查找热路径上跟随压缩指针代价太高。他们的折中是把 owner 改成 Option<Box<Name>>:相同时存 None,读取时从 cache key 里还原,省掉一次堆分配;不同时才存完整名字。
第四步,给大的枚举变体装箱。 Rust 枚举的大小等于最大变体的大小。RecordData 里最大的是 NAPTR(136 字节),算上 tag 和 padding 整个枚举变成 144 字节——而 A 记录只需要 4 字节、AAAA 只需要 16 字节,这两类却占了超过 80% 的流量,也就是说大多数记录白白浪费 120 多字节。把大变体改成 Box<...>,枚举只存 8 字节指针,A/AAAA 每条省 120 字节。代价是罕见的 NAPTR 反而多付一点,但这笔交易划算。
第五步(也是最有意思的一步),记录数据直接以线格式存原始字节。 装箱本身带来两个新成本:一是分配器开销——Big Pineapple 用 jemalloc,它按固定尺寸的 bin 分组,TXT 请求 32 字节正好落进 32 字节 bin 不浪费,但 MX 请求 40 字节要向上取整到 48,白丢 8 字节;二是内存局部性变差,装箱后每条数据散落在堆各处,读取要跟指针、可能触发新的 cache line 加载。彻底存整条线格式响应也不行(DNSSEC 记录只在客户端设置 DO 标志时才返回,要么缓存两份要么每次过滤,且每次查找都要重新解析整条消息)。最终方案是中间路线:其余字段仍保持结构化,只把记录数据压成一个 Box<[u8]>,每条记录用「2 字节长度前缀 + 原始字节」编码。这样既消除了枚举 padding 和逐条堆分配,数据又连续排列改善了 CPU 缓存局部性。代价是不能随机索引、只能顺序遍历(影响 A/AAAA 的轮转),但每条条目的记录数很少,成本可以忽略。额外收获是构造响应时,A、AAAA、TXT 和所有 DNSSEC 记录可以直接把编码好的字节拷进出站消息,跳过逐字段重新序列化;只有 CNAME、NS、MX、SOA 这类含域名的记录还需要解析以应用名称压缩。仅这一项就让查找延迟降了 5%。写入侧则复用一个跨插入持久存在的 scratchspace 缓冲区,序列化完再一次性 memcpy 到 Box<[u8]>,单这一项把插入吞吐提高了 13%。
结果:基准测试中每条条目净占用从 953 字节降到 420 字节(-56%),每条分配量从 1.1KB 降到 461 字节(-58%),插入吞吐从 62.5 万条/秒升到 89.3 万条/秒(+43%),查找延迟从 828ns 降到 670ns(-19%)。生产环境上,rollout 从 2026 年 5 月 18 日开始、7 月 6 日在所有服务上完成,p99 单实例内存从 9.3GB 降到 5.3GB(-43%),p90 从 6.5GB 降到 3.8GB(-42%),全机队 working set 合计低了约 100TB。Cloudflare 表示接下来会把省出来的内存重新投入扩大缓存容量,从而提高命中率、减少对上游权威服务器的查询量。
HN 评论精华
这条帖子拿到 905 分、100 多条评论。讨论有三条主线:为什么这么明显的优化拖到 2026 年才做、Rust 在这类系统编程场景下的表现,以及一些同类经验分享。
- eviks 引用文中「响应写进缓存后再也不会修改,capacity 字段毫无用处却要占 8 字节」,直接问:系统搭建时难道没有设计评审来抓住这种琐碎问题吗?回复很热闹。micromacrofoot 一句话概括:「能跑,所以没人想去查。」scott_meyer 认为讨论琐碎优化本身就是浪费宝贵的设计时间,「你永远不会『忘掉』一个优化,跑起来的系统会在真正需要时提醒你」。r3trohack3r 搬出了 Rob Pike 的编程五规则(别猜瓶颈、先测量、n 通常很小、花哨算法更容易出 bug、数据结构比算法更重要)。lbriner 补充了实务视角:早期不值得优化,因为你不知道会有多火、会存多少条记录;等到有人质问那 100TB 内存时再回头改也可以,但那时你得设计迁移路径和回退机制。mhitza 点出了时代变量:现在内存价格涨了最多 10 倍,优化大内存占用的程序才重新变得值得。
- 反方阵营也不小。didgetmaster 反问:为什么大家总觉得优化是要等浪费掉几百 TB 内存后才处理的事?「好像设计阶段根本没人想过将来会怎样。这就是为什么那么多软件又臃肿又有 bug。」cristaloleg 也觉得奇怪:数据本来就都有,那种规模下降内存是必需项而不是加分项。sophacles 帮着算了笔账反驳:Cloudflare 号称在 300 多个城市有数据中心,每个至少几台服务器,省下的 130 台服务器的内存分摊下来其实是每台省几 GB,「在那个规模上这确实只是 nice-to-have」。yieldcrv 提了个当下很应景的猜测:可能是 agent 在扫技术债、找容易拿的胜利——每个部门都知道有预算就能做什么,只是预算从来批不下来,现在 agent 把这些瓶颈都打通了,「包括工程博客」。
- 得票最高的顶层评论来自 lpapez:「这才是交付软件的正确方式。先做出能用的产品,验证想法,稳住业务,开始盈利,然后再优化成本。事实上优化是整个流程里最简单的部分——看看这个 HN 帖子里有多少系统编程专家觉得这些优化很 trivial 就知道了。」这条底下的反驳堪称一场小型辩论:casey2 说「这个推理假设你有无限的跑道,你没有」;nine_k 指出这只对 VC 支持的企业或大公司的分支成立;aeonfox 更尖锐地翻旧账——Cloudflare 融资规模大、几乎立刻就盈利了,是有本钱烧钱的,「好家伙,他们一盈利就马上去做这件事了,而不是继续烧钱……哦,等等」;sandeepkd 反对「优化最简单」的说法,指出把 rollout 算进来的话,改动线上运行中的数据结构很难,改热路径上的缓存更是最难的一档,从文末的图看他们花了 4 个多月才推完;hinkley 写了整条讨论里最有共鸣的一段:真正毁掉性能工作的,是绝大多数开发者想从「make it work」直接跳到「make it fast」而跳过「make it right」,结果只能用脆弱的奇技淫巧,把生产环境搞得不稳定、每加一个功能都像在雷区里走路;等到真懂行的人来想修一条高速公路时,会发现沿途全是陷进泥里到门把手、已经水泥化了的废弃车辆,清路比修路还难,「所以我说『固执』是我性能工具箱里最重要的工具,远比机巧重要」。
- irdc 从 C 程序员的角度指出一个「显而易见但他们没做」的优化:把记录数据直接放在
CacheEntry成员之后连续存储,而不是单独分配——但也承认在 Rust 里可能不好做。mkeeter 确认这在 Rust 里技术上可以通过动态大小类型(DST)实现,但实践中很难用、跟语言其他部分配合不好,Rustonomicon 自己的结论是「自定义 DST 目前还是个半成品特性」。cakoose 猜测真正的障碍是泛型 HashMap——如果 V 是动态大小的,就不能有 V 的数组,这会排除掉一些哈希表实现。f311a 更直接:Rust 不适合这类技巧,这正是 Zig 的强项,「在 Rust 里你连像样的 arena 都用不上」,而 Cloudflare 最近确实开始在有内存约束的项目上用 Zig 了。jiggawatts 提了个更有想象力的方向:希望更多语言像数据库那样实现记录类型,把变长字段打包进连续内存——Cloudflare 这次是手工实现了一个笨拙版本,「让编译器像数据库引擎保存一行那样替你管理,不是很好吗?」 - vinkelhake 提出一个 Rust 安全性的观察:原来三个独立的
Vec时,Rust 保证你不会越界;现在合并成一个靠偏移量切分,你就可能索引到某个子切片范围之外而不 panic,文章居然没提这一点。afdbcreid 和 NIckGeek 都回应说这不难解决:把底层字段设为私有、提供返回切片的方法,或者写一个透明包装类型把偏移逻辑封装在安全接口后面,Rust 会把它编译成零开销。ratorx 认为这本质上是时间与代码量的取舍。 - OptionOfT 指出「2 字节长度前缀 + 原始字节」这个编码方式和 netlink 几乎一样,并提醒了一个 Rust 特有的坑:从裸流反序列化进
[u8; 4096]缓冲区时对齐只保证 1 字节,实践中是 4 字节但用 Miri 跑测试会报错,正确做法是用最大类型的对齐声明缓冲区(比如声明成[u32; 1024]再转成[u8; 4096])。pocksuppet 和 jandrewrogers 补充这就是网络协议里无处不在的 TLV(tag/length/value)编码,好处是能跳过不认识的 tag、接收方能提前估算资源需求,且通常故意不对齐。 - strenholme 分享了自己在 MaraDNS 上的同类经验:把黑名单条目从「每条一次 malloc()」改成「一次大 malloc() 再遍历内存块」,同一份黑名单的内存占用从 237MB 降到 9.5MB。Agentlien 说他职业生涯最自豪的时刻之一,是和三个同事把游戏 Wavetale 的内存负载从 20 多 GiB 压到 3 GiB 以下,好移植到 Switch,「100 TiB 这个数字几乎让我眩晕」。grep_it 用一个 Go 例子提醒最基础的那一课:同样的字段换个顺序让结构体对齐,24 字节能变成 16 字节。
- mannyv 提了个被反复纠正的问题:为什么要缓存?缓存这么大就不叫缓存了。bastawhiz 和 seiferteric 解释这是递归解析器,它前面不是什么内部数据集——数据集就是「世界上所有的 DNS 记录」,Cloudflare 事先并不知道,只能向权威服务器递归查询,且结果只在 TTL 内有效,不存在什么「全球 DNS 记录数据库」。toast0 补充数据源是第三方运营的权威名字服务器,响应时间从 1ms 到 2 秒不等,还有一些永远不回。otterley 从历史角度说:不缓存的话 DNS 流量会暴涨、压力全堆到本来设计得很小、早年还常常在带宽受限链路上的权威服务器上;顺带刺了一句——没人非得用 8.8.8.8 或 1.1.1.1,多数人用 ISP 或本地缓存也感觉不出差别。BowBun 直接反驳前提:缓存并不要求比源数据集小,CDN 就是一个大小通常与源数据相当的缓存。
- 一些短评:edflsafoiewq 总结出通用主题——语言的原生内存对象格式通常是为随机访问、统一性和可变性优化的(字段在固定偏移等),而网络/磁盘序列化格式则明确为紧凑设计,但你完全可以为自己需要的特性设计自己的内存表示。kccqzy 部分反对:新一代序列化格式的设计者已经意识到在现代 CPU 和网络上,做得更紧凑并没多大收益,Cap’n Proto(发明人 kentonv 也在 Cloudflare)和 flatbuffers 就是例子。9bot 觉得最有意思的结论是「更丰富的解析后表示未必更快」——如果热路径主要是「从缓存读出再序列化回 DNS」,那么前期全解析只为了之后再序列化回去,就是纯粹的无用功还伤局部性。pocksuppet 从流量分布里挑出一条:「56% A、25% AAAA、19% TXT」——「还有人说没人用 IPv6 呢。」varispeed 的玩笑最应景:「现在把这 100TB 内存还给市场吧,别囤 RAM 了。」xyst 则点破了时代背景:因为 AI 泡沫推高内存成本,这类工程终于变得「有利可图」了。superze 的吐槽也很妙:「这换算成欧元是多少?还是说我们现在用 RAM 来计价了?」