Cloudflare 优化 1.1.1.1 DNS 缓存,节省 100TB 内存
做基础设施或高并发缓存优化的同学必看,这篇给出了从 Vec 到 Box、枚举压缩、位标志等可复用的内存布局技巧,还附了性能对比数据,直接能抄作业。
Big Pineapple,作为1.1.1.1、Gateway DNS、DNS Firewall、AS112以及其他多个Cloudflare DNS服务的平台,在任何时刻都存储着超过2500亿条DNS缓存条目。在这样的规模下,每个条目浪费一个字节,在整个集群中就会消耗超过250GB的内存。对缓存条目在内存中存储方式的五次连续改进,将每个条目的占用空间减少了超过50%。在整个集群中,这些改进释放了大约100TB的内存,相当于我们130台Gen 13服务器的内存总量。缓存也变得更快速。插入吞吐量提升了43%,查找延迟降低了19%,因为更少的分配和更好的内存局部性意味着我们并没有以速度换取空间。## 我们缓存什么 在冷启动时,Big Pineapple从一个空缓存开始。随着DNS查询的到来,缓存逐渐填满,直到达到最大条目数,此时我们会淘汰较旧或不太流行的条目以腾出空间。确切的缓存大小因数据中心而异。当使用EDNS客户端子网(ECS)时,权威服务器会根据客户端的网络返回不同的答案,因此我们缓存同一查询的多个版本。这增加了条目的数量和每个条目消耗的内存,使得本文中的优化对ECS密集型位置尤其有影响。缓存中的每个条目都是一个键值对。键标识了查询的内容:``` pub struct CacheKey { qname: Name, qtype: Rtype, authenticated: bool, tag: Vec, } ``` 值存储了DNS响应本身:答案、权威和附加记录部分,以及元数据,如创建时间、命中计数器和生存时间(TTL)。``` pub struct CacheEntry { timestamp: UnixTimeStamp, pub inception: Instant, pub ttl: Ttl, pub hits: u32, pub answers: Vec, pub authority: Vec, pub additional: Vec, pub errors: Vec, ... } ``` 这两个结构体都有改进的空间。几个字段使用的类型在条目存储后携带了我们不需要的开销。## 基准测试内存使用 为了衡量每次更改的影响,我们通过填充缓存来基准测试,使用随机生成的条目,大致匹配我们在生产中看到的流量分布:56%的A记录、25%的AAAA和19%的TXT。每个条目包含1到4条记录。TXT记录在基准测试中作为所有非A/AAAA记录类型的代表。它们的大小在64到224字节之间随机化,接近我们观察到的可变长度记录类型的平均响应大小。我们使用一个自定义分配器来跟踪内存使用情况,该分配器包装了Rust的System分配器,并记录每个缓存条目的分配数量和大小。除了内存,我们还测量了整个缓存流程中的插入吞吐量和查找延迟,以确保内存节省不会以牺牲性能为代价。这些输入近似于生产环境,而非完全复现。进程内存还取决于流量混合、缓存占用率、分配器状态以及缓存之外使用的内存。因此,我们在部署期间测量了生产实例的常驻内存。## 容量的代价 Vec存储三个字段:指向堆分配数据的指针、当前长度和总容量。当你推入一个项目时,Vec会检查长度是否超过容量,并在需要时重新分配。如果有空间,它只是追加项目并增加长度。然而,一旦我们将DNS响应存储在缓存中,就再也不会修改它。容量字段毫无用处,但每个Vec仍然占用8字节。过度分配的堆空间也被浪费了,因为一个容量为八个项目但只存储了五个的Vec在堆上留下了三个未使用的槽位。使用Box<[T]>解决了这两个问题。它在创建后无法增长,因此不需要容量字段或为未来元素预留空间。同样适用于String,它也带有容量字段。Box去掉了它。每个缓存条目存储8个Vec和String字段。将它们替换为Box<[T]>和Box,每个字段节省8字节,每个条目节省64字节。它还消除了Vec为未来增长预留的额外堆内存。综合节省超过15太字节,涉及超过2500亿个缓存条目。## 更少的列表,更少的指针 与其将答案、权威和附加部分存储在单独的列表中,我们可以存储一个带有指向每个部分起始位置偏移量的单一列表。由于每个部分的 DNS 记录数量适合 u16,我们可以为每个偏移使用 u16(2 字节),而每个单独的 Box<T> 需要 8 字节的指针和 8 字节的长度。这移除了两个列表,每个列表有 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<str>>, class: Class, ttl: Ttl, rtype: Rtype, data: RecordData, } ``` 当 owner 为 None 时,响应构建从缓存键恢复查询的域名,避免堆分配。这意味着记录不再自包含,但缓存键在每次查找时已经可用。当所有者不同时,Some 在堆上存储指向完整名称的指针。实际上,大多数缓存记录的所有者与查询的域名相同,因此大多数记录的所有者字段不需要堆分配。## 枚举大小 Rust 枚举是求和类型:每个变体可以携带不同的数据,但枚举的大小总是其最大变体的大小。``` pub enum Option { Some(T), None, } ``` Option 要么是 Some 并持有一个值,要么是 None 且不持有任何内容。两个变体占用相同的内存。枚举存储一个标签指示当前激活的变体,后面跟着足够容纳最大变体数据的空间。当变体为 None 时,该空间未被使用。对于记录数据,将每种 DNS 记录类型作为枚举变体存储似乎是自然的:
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力