Hotdry Blog

Article

从零构建 Rust DNS 解析器:手写 RFC 1035 协议与递归查询实战

深入探索完全依赖的 Rust DNS 解析器实现,手写 RFC 1035 协议解析、递归查询与 SRTT 智能路由的工程细节。

2026-04-02systems

当我们谈论网络协议实现时,DNS 解析器往往被视为「即插即用」的基础设施。然而,真正的工程价值恰恰隐藏在对这些看似简单的协议进行从零实现的过程中。Numa 项目展示了如何在 Rust 中完全不依赖任何外部 DNS 库,从.socket 层面手工实现完整的 RFC 1035 协议解析、递归查询与 DNSSEC 验证,这不仅是对协议本身的深度理解,更是对系统编程能力的极致考验。

为什么不直接使用成熟库?

在 Rust 生态中,trust-dns 是最成熟的 DNS 解决方案,它提供了完整的解析器、客户端和服务端实现。然而,选择从零构建的理由远超出「学习目的」的范畴。首先是零依赖的二进制体积 ——Numa 最终呈现为约 8MB 的单文件可执行文件,没有任何外部动态链接库,这对便携式工具和边缘计算场景具有直接价值。其次是极致性能:在热路径上实现零堆内存分配,让缓存查询延迟压至 691 纳秒,单机吞吐量达到每秒 200 万次查询,这种量级的优化空间在使用通用库时几乎无法触及。

更深层的动机在于对协议细节的完全掌控。当你手写 DNS 消息解析时,会被迫处理 RFC 1035 中那些容易被忽略的边界情况:域名压缩指针如何正确回溯、回答部分的多条资源记录如何顺序组织、以及 UDP 与 TCP 传输层切换的触发条件。这些细节在生产环境中往往决定了系统的健壮性,而非仅仅是「能工作」而已。

RFC 1035 协议解析:手写二进制解码

DNS 协议的核心在于其紧凑的二进制消息格式。一个标准的 DNS 查询报文由 12 字节的 Header 部分、变长的 Question 部分、Answer 部分、Authority 部分和 Additional 部分组成。在 Rust 中实现这个解析器,第一步是设计能够精确映射这些字段的数据结构。Header 中的每个字段都有明确的位宽:事务 ID 占 16 位、标志位占 16 位、问题计数、回答计数、授权计数和附加计数各占 16 位。

#[repr(C)]
struct DnsHeader {
    id: u16,
    flags: u16,
    questions: u16,
    answers: u16,
    authority: u16,
    additional: u16,
}

实际的挑战在于域名的解析方式。RFC 1035 使用一种称为「标签」的表示法,每个标签以长度字节开头,以零字节结束。整个域名本身也以尾部的零字节作为终止符。更关键的是,协议引入了「压缩指针」机制 —— 当解析器遇到一个最高两位被置为 11 的字节时,它会将其余 14 位解释为相对于消息起始位置的偏移量,允许后续引用之前出现过的域名片段。实现时需要维护一个偏移量到域名字符串的映射表,在解析过程中持续更新,以便在遇到压缩指针时能够正确回溯和组装完整域名。

Question 部分的解析同样需要严谨对待。每个 Question 包含查询名称、查询类型(QTYPE)和查询类(QCLASS)。类型字段支持从 A(IPv4 地址)到 AAAA(IPv6 地址)、CNAME、MX、TXT、NS 等数十种记录类型。实现者需要构建一个从数值到枚举的映射表,同时处理查询类型字段可能超出标准范围的情况 —— 某些 DNS 扩展会使用大于 512 的类型值来传递额外信息。

递归查询:从根服务器到目标域名的完整路径

完整的 DNS 递归解析是一个迭代式的信息收集过程。当本地缓存未命中时,解析器需要从根服务器开始,依次查询顶级域名服务器、权威服务器,最终获得目标记录。这个过程在 Numa 中通过状态机实现:初始状态指向硬编码的根服务器 IP 列表,向根服务器发送查询请求后,根据响应中的 Authority 部分的 NS 记录提取下一级服务器的域名,再通过该域名查询对应的 A 或 AAAA 记录获得服务器 IP,最后向新获得的服务器发起下一轮查询。

SRTT(平滑往返时间)算法在这个过程中扮演着性能优化的关键角色。每次与某个 Nameserver 通信时,解析器都会记录查询的往返延迟,并通过加权移动平均更新该服务器的 SRTT 值。当存在多个可用的 Nameserver 时,算法倾向于选择历史响应最快的服务器,这使得递归查询在预热后的平均延迟从初始的数秒级下降至约 237 毫秒,相比简单的轮询策略有 12 倍的提升。

递归解析还需要处理各种异常情况。如果某个 Nameserver 在超时时间内未响应,需要切换到备用服务器并重试;如果最终无法获得有效响应,应该向客户端返回 SERVFAIL 而不是默默丢弃请求。DNSSEC 验证是更复杂的扩展:当启用时,解析器需要沿着信任链逐级验证 —— 从根密钥到 TLD 密钥,再到域名密钥,每一级都需要验证 RRSIG 签名、DNSKEY 记录和 DS 记录的完整性,这涉及 RSA、ECDSA P-256、Ed25519 等多种密码学算法的实现。

缓存机制与性能优化

DNS 解析器的缓存设计直接影响整体性能。Numa 采用内存缓存结构,以域名和查询类型组成的元组作为键,存储解析得到的资源记录。每条缓存记录都关联一个 TTL(生存时间)值,这是从 DNS 响应中的记录本身携带的。缓存过期时采用惰性删除策略:只有当查询命中某条缓存且发现其已过期时,才会触发重新查询,而非后台守护进程主动清理。

热路径零堆分配是 Numa 性能优化的核心策略之一。在处理大量并发查询时,每次分配堆内存都会引入不可忽略的延迟。Numa 通过预分配固定大小的缓冲区、使用栈分配而非堆分配来存储解析过程中的临时数据、以及采用无锁数据结构来管理缓存访问,从而在每秒百万级查询的压力下保持稳定的低延迟表现。性能测试表明,缓存查询可以做到 691 纳秒的端到端延迟,这已经接近现代 CPU 缓存层次的物理极限。

另一个关键优化是查询去重。当多个客户端几乎同时请求同一个未缓存的域名时,解析器只会向外发起一次递归查询,其他请求则等待该查询的结果。这避免了「惊群效应」导致的重复网络请求和资源浪费。实现上通常使用带超时机制的互斥锁或并发哈希表来实现请求的合并与结果分发。

工程落地的关键参数

如果你计划在自己的项目中实现类似的 DNS 解析器,以下是经过验证的关键参数。UDP 缓冲区建议设置为 4096 字节,这是大多数网络栈能够无碎片接收的最大单元;超过这个大小的响应会自动切换到 TCP 53 端口。查询超时建议设置为 5 秒,这是 RFC 1035 推荐的上限值。Nameserver 列表至少应包含 3 个根服务器的 IP,并定期通过完整的递归查询流程验证其可达性。

对于递归查询的重试策略,建议采用指数退避:首次重试间隔 1 秒,第二次 2 秒,第三次 4 秒,总重试次数不超过 3 次。缓存 TTL 的下限不应低于 60 秒,以避免向权威服务器发起过于频繁的刷新请求。SRTT 权重的配置推荐使用 0.75 的衰减因子,这意味着新观测值占最终 SRTT 值的 25%,历史数据占 75%,这在稳定性和响应性之间取得了较好平衡。

DNSSEC 验证的启用需要谨慎考虑性能开销。ECDSA P-256 签名的验证时间约为 174 纳秒,虽然绝对值不大,但在高并发场景下会成为不可忽视的 CPU 负载。如果你的使用场景不涉及对域名真实性的严格验证,可以将 DNSSEC 作为可选功能默认关闭。

小结

从零实现一个 DNS 解析器远不止「解析 UDP 数据包」那么简单。它要求开发者深入理解 RFC 1035 的二进制编码细节、递归查询的状态机逻辑、缓存一致性的维护策略,以及在极限压力下的性能优化技巧。Numa 项目展示了 Rust 在系统编程中的独特优势 —— 通过零成本抽象和精细的内存控制,我们可以在不牺牲可读性的前提下实现接近硬件极限的性能。对于任何希望深入理解网络协议栈的工程师而言,这都是一个值得深入研究的优秀范本。

资料来源:Numa GitHub 仓库(https://github.com/razvandimescu/numa)

systems