Cloudflare 刚刚节省了 100TB 的内存

BBetter Stack
Computing/SoftwareInternet Technology

Transcript

00:00:00一个字节究竟能花掉你多少钱?
00:00:02在 Cloudflare 的规模下,每个条目浪费一个字节
00:00:04就会导致他们整体集群消耗超过
00:00:06250 GB 的内存,
00:00:08而最近他们实际上将每个条目的内存减半了,
00:00:11释放了足足 100 TB 的内存。
00:00:13在如今的经济环境下,这是一大笔钱。
00:00:15他们其实是通过对缓存系统做出
00:00:17五个相当简单的改动来做到这一点的,
00:00:18同时还让 DNS 的速度变得更快了,
00:00:21所以让我们深入了解一下他们是怎么做到的。
00:00:27大家可能以前听说过 1.1.1.1。
00:00:30它是 Cloudflare 的公共 DNS 解析器,
00:00:32它的工作原理是获取你想访问的域名,
00:00:34比如 betterstack.com,
00:00:35然后查出该域名对应的真实 IP 地址到底是什么。
00:00:39而实际干这活的这个组件,
00:00:40叫做 Big Pineapple,它是用 Rust 编写的,
00:00:42正如你所能猜到的,
00:00:43这是一段业务极其繁忙的软件,
00:00:46在任何给定时间都要存储超过 2500 亿个 DNS 缓存条目。
00:00:50这确保了如果有人在另一个人访问完 betterstack.com 的
00:00:52两秒钟后也想访问它,
00:00:53系统就不必再次遍历整个 DNS 层次结构,
00:00:56而是直接把答案保留在内存中并立刻返回。
00:00:59但正如我在开场所提到的,
00:01:012500 亿个缓存条目意味着每个条目浪费一个字节
00:01:04就会消耗大约 250 GB 的内存,
00:01:07这就是为什么这五个改动能产生如此巨大的影响。
00:01:10首先,我们来看看容量的成本。
00:01:12以下是过去一个缓存条目的结构。
00:01:14时间戳、生存时间、命中计数器,
00:01:16然后是一堆向量。
00:01:17一个应答记录向量,
00:01:18一个权威记录向量,
00:01:20一个附加记录向量,
00:01:21一个错误向量,
00:01:22还有很多。
00:01:23对于不太熟悉 Rust 的朋友来说,
00:01:25向量其实就是可增长列表的首选数据类型,
00:01:27它在内存中包含三部分内容。
00:01:29一个指针、一个长度和一个容量。
00:01:31也就是为增长预留了多少空间,
00:01:32这样每次向它添加数据时就不必重新分配内存。
00:01:35如果对象真的会增长,这非常有用。
00:01:38但他们查看了实际的 DNS 缓存条目后发现,
00:01:40所有东西写入缓存后就再也没有被修改过。
00:01:42它只会被读取,
00:01:44所以这些向量其实从来没有增长过。
00:01:45这意味着堆空间被过度分配了,
00:01:47白白坐在那里被浪费掉。
00:01:49比如一个容量为八个项目的向量,
00:01:50却只存储了五个,
00:01:52留下三个槽位闲置不用,
00:01:53而容量字段本身是一个 usize,
00:01:55这占用了 8 个字节的内存,
00:01:57而对他们来说这根本不需要。
00:01:58解决这个问题的方法极其简单。
00:02:00只需把向量(Vec)换成装箱对象(Box),
00:02:02因为 Box 的大小在创建时就已经固定了,
00:02:04所以它不需要容量字段,
00:02:07并且它也只精确存储当前存在的内容。
00:02:08它不需要为未来的容量分配备用空间。
00:02:10他们还意识到,他们可以将完全相同的优化应用到
00:02:13字符串字段上,
00:02:15因为它们本质上也就是 U8 的向量,
00:02:16这样文本就可以增长或缩短,
00:02:18但如果你不需要这样做,
00:02:20你就可以直接使用装箱字符串切片(Box),
00:02:21也就是告诉系统这个字符串不会改变。
00:02:23总而言之,在每个缓存条目上,
00:02:26总而言之,在一个缓存条目中,
00:02:27有八个动态数组和字符串字段,
00:02:29因此用 Box 替换它们每个字段节省了 8 字节,
00:02:31每个条目节省 64 字节,
00:02:33同时也消除了动态数组为未来增长
00:02:35而预留的多余堆空间,
00:02:36这意味着当扩展到 2500 亿个缓存条目时,
00:02:39累积节省的内存超过了 15 太字节(TB)。
00:02:42这一切都源于一个简单的数据类型更改。
00:02:45不过对于下一个改动,
00:02:46他们实际上审视了其中一些列表,
00:02:48并直接问道:我们真的需要它们吗?
00:02:50一个 DNS 响应包含三个部分,
00:02:52应答、授权和附加信息,
00:02:54正如我们之前看到的,
00:02:55它们在缓存时是按原样存储的,
00:02:57也就是三个独立的列表,
00:02:58尽管我们在第一个改动中去掉了容量字段,
00:03:01但每个列表仍然包含一个指针和一个长度,
00:03:03也就是 8 字节加上 8 字节乘以 3,
00:03:05总共是 48 字节,
00:03:07并且涉及三个独立的堆分配。
00:03:09但当他们研究这些列表
00:03:10实际上是如何被使用的时候,
00:03:12他们意识到它们总是被一起读取、
00:03:13总是被一起写入,
00:03:14而且它们的顺序也完全固定。
00:03:16因此,与其使用三个列表,
00:03:17他们可以把它当作一个包含两个分隔符的列表来处理。
00:03:20于是这三个列表变成了一个记录列表,
00:03:22外加两个小偏移量,分别表示应答到此结束、
00:03:25授权到此结束,
00:03:26由于 DNS 响应永远不可能拥有 40 亿条记录,
00:03:2916 位无符号整数就足以表示这些偏移量了,
00:03:33每个偏移量只需 2 字节。
00:03:34这意味着总体而言,
00:03:35他们用两个 2 字节的数字替换了两个完整的 16 字节头部,
00:03:39每个条目节省了 28 字节,
00:03:41这也让 Rust 能够消除字段之间的一些额外间距,
00:03:44使得结构体比单纯移除字段本身变得更加紧凑。
00:03:47他们甚至将这一理念进一步推行,
00:03:49把多个布尔字段合并成了一个位标志。
00:03:52接下来看第三个改动,
00:03:53为什么我们不停止重复存储相同的名字呢?
00:03:55每条 DNS 记录都带有一个所有者,
00:03:57也就是该记录所属的域名。
00:04:00所以当你查询 betterstack.com 并取回记录时,
00:04:03每条记录上都写着 betterstack.com。
00:04:05但这些信息在某种程度上是冗余的。
00:04:08既然是你发出的查询,
00:04:09它就已经知道域名是什么了。
00:04:11而且 Cloudflare 已经把查询域名作为缓存键存储了,
00:04:14所以为什么还要把它作为所有者再次存储呢?
00:04:17嗯,它们不需要。
00:04:17因此 Cloudflare 简单地将这个字段改为了一个可选的 Box 名称,
00:04:20如果所有者与查询相匹配,
00:04:22就存储 None,
00:04:23但如果确实有所不同——
00:04:24在少数情况下(例如 CNAME 链)确实会如此——
00:04:27你就像以前一样存储所有者。
00:04:29通过这样做,在两者匹配的常规情况下,
00:04:32每条记录都能节省整整一个堆分配,
00:04:34所以这项节省其实很简单:审视缓存中拥有的数据,
00:04:37并意识到其中存在重复的值。
00:04:39对于第四项节省,我们来看看 144 字节的 IP 地址。
00:04:43它们的记录数据是一个 Rust 枚举,包含 A 记录、AAAA 记录、文本、
00:04:47SVCB 和 NAPTR,用一个类型涵盖了所有这些。
00:04:51但枚举的问题在于,
00:04:52它们的大小总是和其最大的变体一样大。
00:04:54该类型的每个值都要占用相同大小的空间,
00:04:57不管它是否需要。
00:04:58在这种情况下,NAPTR 是最大的值,占用 136 字节,
00:05:03而当你在枚举中加上标签和填充后,它就会变成 144 字节。
00:05:07如果你拿它与 A 记录的需求相比较,也就是一个简单的 IPv4 地址,
00:05:12那只需要四个字节。
00:05:13这意味着缓存中的每个 A 记录都塞在一个 144 字节的盒子中,
00:05:17却只用了其中的四个,
00:05:19而 A 记录和 4A 记录占了真实流量的大头。
00:05:22在 Cloudflare 自己的基准测试组合中,56% 是 A 记录,25% 是 4A 记录,因此缓存的大部分其实都是填充数据。
00:05:29解决这个问题的方法就是我们熟悉的 Box。
00:05:32他们把大的变体放入 Box 中,而让 A 和 4A 保持内联,因为它们又小又常见,
00:05:36所以现在 text、SVCB 和 NAPTR 都放在指针后面,
00:05:40这样枚举的大小只需和剩下的最大项一样大,也就是那个 16 字节的 IPv6 地址。
00:05:45总的来说,对于一个 A 或 4A 记录,每个记录节省了 120 字节。
00:05:50但这个改动其实也有代价。
00:05:52当你把一个变体 Box 化时,它的数据就不再呆在缓存条目内部了,
00:05:55而是移到了堆上完全不同的另一个区域,
00:05:58这就带来了两个新的成本。
00:06:00第一个是分配器。
00:06:02Cloudflare 使用的是 jemalloc,而 jemalloc 不会正好给你请求的字节数,
00:06:06它会将内存分配归入固定大小的桶中,并向上取整到最接近的桶。
00:06:10所以一个请求 32 字节的 text 记录会落入 32 字节的桶中,不会浪费任何空间,
00:06:15但一个 MX 记录请求 40 字节,会被向上取整到 48 字节,无声无息地浪费了 8 字节。
00:06:21第二个成本是局部性。
00:06:22在 Box 化之前,一个条目的所有记录数据都存放在一块连续的内存中,
00:06:27而 Box 化之后,每一项都住在别的地方,读取它意味着要跟随一个指针,
00:06:31如果这个指针指向远离条目其余部分的地方,
00:06:34你的 CPU 就必须去获取一个全新的缓存行才能读取它。
00:06:37所以,虽然 Box 化解决了填充问题,但也带来了它自己的问题,
00:06:41正是在这个时候,Cloudflare 想到:如果我们根本不把它们存为 Rust 类型会怎么样?
00:06:45这就是第五项改动。
00:06:46Cloudflare 将其描述为一种折中方案:缓存条目的其余部分保持正常的结构化
00:06:50字段,但将记录数据本身提取出来,以原始字节的形式存储。
00:06:54因此,不再为每个记录使用枚举或 Box,整个条目只有一个单独的 Boxed u8 数组,
00:07:00每个记录都写入一个 2 字节的长度前缀,后面跟着它的数据。
00:07:03这消除了我们刚才讨论的那两项成本。
00:07:06所有那些独立的 Boxed 分配合并为一个针对所有记录数据的分配,
00:07:10因此不再有每个记录向上取整到 jemalloc 桶的情况了,
00:07:13并且它们再次紧凑地打包在一起,从而找回了被 Box 化夺走的缓存局部性。
00:07:18作为额外的福利,它还使查找速度变得更快。
00:07:21在此之前,在每次缓存命中时,内存中都有一个已解析的记录,
00:07:25你必须将其逐个字段地序列化回 DNS 线路格式,然后才能将其发送到任何地方。
00:07:30但现在,它已经是 DNS 线路格式了,所以大多数记录类型只需直接从缓冲区复制
00:07:35到外发消息中即可。
00:07:37唯一仍需解析的是包含域名的记录,
00:07:40即 CNAME、NS、MX 和 SOA,
00:07:43而且这只是因为 DNS 名称压缩意味着你无论如何都必须重写这些名称。
00:07:47因此,这最后一项改动减少了内存,并从热路径中消除了大量工作。
00:07:51好了,这就是对缓存进行的 5 项底层改动,
00:07:54结合起来,它们将每个条目的占用空间从 953 字节降至 420 字节,
00:08:00缩减了 56%,而每个条目实际分配的内存从 1.1 KB
00:08:05降至 461 字节,减少了 58%。
00:08:09除此之外,它们的写入吞吐量也提高了 43%,
00:08:12从每秒 62.5 万个条目增加到 89.3 万个,
00:08:17查找速度也快了 19%,从 828 纳秒缩短至 670 纳秒。
00:08:23他们在今年将这些改动推向了生产环境,
00:08:25其 P99 常驻内存从 9.3 GB 降至 5.3 GB,
00:08:29在真实流量下减少了 43%,
00:08:32整个集群释放的内存大约有 100 TB,
00:08:35相当于 130 台第 13 代服务器的内存总量。
00:08:38现在他们计划利用这部分多出来的空间来建立更大的缓存,
00:08:41从而进一步提速。
00:08:43我真的很喜欢这篇博客文章,因为它凸显了一个我们也不得不做出的抉择。
00:08:46我们是否应该在构建任何东西之前坐下来,把所有的优化都想清楚,
00:08:50还是说那会属于过早优化?
00:08:52我猜在 2018 年构建这个系统的时候,
00:08:55内存对 Cloudflare 而言成本没有现在这么显著。
00:08:59这篇完整的文章写得很棒,我把它放在下方的链接里。
00:09:02欢迎在评论区告诉我你对这件事的看法,
00:09:03顺便点个订阅,
00:09:04我们一如既往,下期再见。
00:09:05下期再见。

Key Takeaway

Cloudflare 通过对 Rust 缓存系统进行五项底层数据结构改动,将每个 DNS 缓存条目的内存占用缩减 56%,在集群中释放了 100 TB 内存。

Highlights

  • Cloudflare 将每个 DNS 缓存条目的内存减半,在其整体集群中释放了超过 100 TB 的内存。

  • 公共 DNS 解析器 Big Pineapple 在任何给定时间存储超过 2500 亿个 DNS 缓存条目。

  • 将 Rust 动态数组(Vec)替换为装箱对象(Box)消除了未使用的堆空间分配,单此一项节省了超过 15 TB 的内存。

  • 通过将三个独立的 DNS 响应列表合并为一个带偏移量的单个记录列表,每个条目节省了 28 字节。

  • 将记录数据提取为原始字节格式并存储在单个 Boxed 数组中,使写入吞吐量提高了 43%,查找速度快了 19%。

Timeline

DNS 缓存系统与内存成本背景

  • Cloudflare 的公共 DNS 解析器 Big Pineapple 处理超过 2500 亿个缓存条目。
  • 每个条目浪费一个字节就会导致整体集群消耗超过 250 GB 的内存。
  • 对缓存系统进行的五个简单改动使每个条目的内存减半,并释放了 100 TB 的内存。

Big Pineapple 使用 Rust 编写,在任何给定时间存储数千亿个条目,以避免频繁遍历 DNS 层次结构。在这样大规模的集群中,微小的内存浪费会累积成巨大的集群资源消耗。最近实施的五个改动显著降低了内存成本,同时提升了 DNS 的运行速度。

用 Box 替换动态数组与字符串

  • 写入缓存的 DNS 条目只会被读取而从不修改,导致动态数组的容量字段和预留空间被白白浪费。
  • 将动态数组(Vec)和字符串字段替换为固定大小的装箱对象(Box),消除了不必要的容量字段。
  • 此项数据类型更改在 2500 亿个缓存条目上累计节省了超过 15 TB 的内存。

Rust 中的向量包含指针、长度和容量,专为可增长的数据类型设计。由于缓存条目写入后保持不变,预留的堆空间和 8 字节的容量字段完全冗余。改用 Box 后,系统仅存储当前实际存在的内容,去除了多余的分配。

合并响应列表与消除冗余域名

  • DNS 响应的三个独立列表被合并为一个记录列表加上两个 2 字节的小偏移量,每个条目节省 28 字节。
  • 每条 DNS 记录中重复存储的所属域名被改为可选的 Box 名称,仅在少数情况下存储。
  • 当记录所有者与查询相匹配时,常规情况省去了整整一个堆分配。

原本分离的应答、授权和附加信息列表占用多个独立的堆分配和头部空间。通过分析其实际使用方式,系统将其打包成一个连续结构,并利用 16 位整数记录边界。同时,由于查询域名已经作为缓存键存储,记录中重复的域名也被移除。

优化 IP 地址枚举与原始字节存储

  • 大型枚举类型导致常见的 A 记录和 AAAA 记录被迫占用 144 字节的固定空间。
  • 将大的枚举变体放入 Box 中使常见的简短 IP 地址能够保持内联。
  • 最终方案将所有记录数据提取为原始字节存储在单个 Boxed 数组中,消除了 jemalloc 内存桶对齐浪费并恢复了缓存局部性。

Rust 枚举的大小取决于其最大的变体,这导致大量占用空间的填充数据存在于频繁使用的 IPv4 和 IPv6 记录中。虽然最初通过 Box 化解决了填充问题,但也引入了内存分配桶浪费和 CPU 缓存局部性下降的新成本。最终的原始字节格式设计彻底解决了这些问题,并将解析开销降到最低。

改动带来的整体性能提升

  • 五项底层改动将每个条目的占用空间从 953 字节降至 420 字节,缩减了 56%。
  • 写入吞吐量从每秒 62.5 万个条目增加到 89.3 万个,查找速度从 828 纳秒缩短至 670 纳秒。
  • 生产环境中的 P99 常驻内存减少了 43%,释放了相当于 130 台服务器总量的 100 TB 内存。

这五项底层优化在今年全面投入生产环境,彻底改变了集群的资源利用效率。内存消耗的大幅缩减使 Cloudflare 能够将释放出的空间转化为更大的缓存容量,为未来的进一步提速奠定了基础。

Community Posts

No posts yet. Be the first to write about this video!

Write about this video