DNS 不为人知的三件事:从根区文件到100万QPS的架构演化

DNS,你每天敲域名时它就在后台转。但有多少人真的拆开看过它的骨架?我指的不是课本上那套“递归-迭代”流程图——那种图看一百遍也搭不出能扛住100万QPS的解析集群。恰恰相反,真正的工程难点全藏在协议栈的缝隙里,藏在UDP重传的超时计算、缓存过期的雪崩概率,以及那该死的大小包分裂策略中。呃,就从一次生产事故说起吧。去年我们某个区域节点的解析延迟突然飙升到2秒以上,最后排查,根因你猜是什么——内核的conntrack表在UDP洪水下触发了提前回收,导致大量DNS请求被当作无关联包直接丢弃。没经历过这种诡异故障的人可能永远不知道,一个看似简单的无状态查询服务,能跟内核参数、交换机芯片的哈希极性扯上关系。

繁复的DNS查询流程图 递归与迭代混合路径
繁复的DNS查询流程图 递归与迭代混合路径

那之后我开始重新审视DNS的每个卡点。先聊个基础点的:UDP还是TCP? 教科书说,DNS主要用UDP 53,区域传送用TCP;消息超512字节时可截断,客户端用TC标志触发TCP重试。然而现实世界呢?Google的公共DNS早在2014年就给权威侧开启了TCP fast open,Cloudflare更激进,所有出站查询优先TCP——因为他们测过,在2%丢包率的公网上,UDP重试的尾部延迟比TCP长得多。我们自己做压测:同机房内,UDP当然快,avg 0.3ms;切到跨地域链路,丢包0.5%时,UDP的P99直接跳到800ms,而长连接复用的TCP维持P99在120ms左右。原因很简单,UDP超时重传算法太粗陋,而TCP有快速重传、SACK和更好的RTT估算。你看,这就是典型的“理论最优”与“工程现实”的错位。

所以如果你的服务还在迷信“UDP一定快”,醒醒吧。至少得把outbound DNS query over TCP作为默认策略,并且打开keepalive。不过话说回来,TCP也会引入新坑:文件描述符数量、端口范围耗尽、还有NAT网关的会话老化。我们早期踩过的最蠢的一个坑是:把递归解析器放在Kubernetes里,用ClusterIP做负载均衡,结果kube-proxy的conntrack条目在高峰时被打满,所有DNS请求直接超时。最后把解析器移到DaemonSet,挂hostNetwork,世界清净了。

缓存的艺术:从“命中率”到“负缓存”

DNS性能的命门是缓存。但大多数人对缓存的理解还停留在“加个Redis”或者“增大TTL”。TTL是蜜糖也是砒霜。 为了降低延迟,你巴不得把TTL设成一天;可一旦需要切流、故障转移,那些被长TTL粘住的客户端就成了噩梦。记得去年某大厂因根域故障导致业务中断四小时的事故吗?根源就是他们自建递归的缓存行为没有考虑负缓存(Negative Caching)上限 —— 权威返回SERVFAIL时,递归竟然缓存了整整5分钟。标准RFC 2308明确规定SERVFAIL不应缓存,但某些实现就是会贪这个“便利”。

我们后来设计了一套分级TTL策略:权威侧对于A记录设60秒,CNAME设300秒;递归侧针对不同类型的应答做自适应调整。比如对NXDOMAIN响应,强制缓存不超过15秒(比SOA中的min TTL更激进);而对于SERVFAIL,绝对禁止缓存,除非你明确知道这个失败是可预期的(比如后端健康检查主动返回)。

还有一个反常识的数据:加大缓存容量未必降低延迟。 我们用2亿条真实DNS记录做模拟,LRU置换算法下,1GB缓存命中率68%,8GB也只提到74%,边际效益骤降。真正立竿见影的是prefetch(预取)策略 —— 在缓存过期前预拉取热门域名。我们线上实测,启用预取后,P50延迟从12ms降到7ms,P99从200ms降到80ms。预取的窗口期要小心,太长浪费带宽,太短防不住并发击穿。我们的经验:按每分钟查询量排序,取Top 1%域名,在剩余TTL为原始TTL的30%时触发预取。这个值经一个月流量回放验证,缓存击穿率从0.3%压到0.02%。

DNS缓存预取与击穿预防机制架构图
DNS缓存预取与击穿预防机制架构图

我见过的3个致命陷阱,个个烧过上百万

陷阱一:Anycast与UDP的冲突。 我们都爱Anycast,一个IP撒遍全球,让用户就近接入。然而UDP是无状态的,当你的权威服务器用Anycast发布时,返回路径可能和请求路径不对称,导致客户端的源地址验证失效。更恐怖的是,如果配合了ECS(EDNS Client Subnet),递归转发过来的子网信息可能与Anycast选路后的实际拓扑矛盾——权威返回的地址可能不是最优,甚至不可达。我们的解法:不用Anycast做权威服务器入口,而是用健康检查+动态路由的BGP社区标记,把同一地域的递归固定引导到对应机房的权威;对外只保留少数IP用于根和顶级域的连接。

陷阱二:DNSSEC的“静默失败”。 签了DNSSEC后,你以为安全了,对不对?事实是,一旦递归侧的验证链路中任何一个环节的时钟不同步(哪怕差5分钟),整个验证链就会断裂,用户得到SERVFAIL。我们去年圣诞夜就因为一台解析器的NTP服务挂了,导致所有DNSSEC签名的域解析失败。监控面板上错误码没有异常,因为应用层只看到通用的“解析失败”。现在我们的策略是:所有递归解析器必须运行chronyd并监控时钟偏移;同时在实验环境用unbound的val-log-level:2 持续记录验证失败详情,告警精度要到具体域名

陷阱三:UDP包的大小与PMTUD黑洞。 这不是传说,我们真遇到了。EDNS0允许UDP报文大于512字节,但路径上的某些古董防火墙会静默丢弃超过1472字节的UDP包(以太网MTU 1500,减去IP头20、UDP头8,有效载荷1472)。于是,当DNSSEC的签名附加数据把响应撑大后,客户端收不到回复,开始疯狂重试,最终拖垮递归。解决之道:强制设置edns-udp-size为1232字节(遵循DNS Flag Day 2020建议),超出部分使用TCP回退。我们在全网推行此设置后,UDP分片引发的超时比例从3%下降到0.1%。

工程美学的极致:DPDK与内核旁路

如果你追求极致的DNS QPS,迟早要跟内核协议栈说再见。Linux内核的网络栈在处理小包时开销极大,中断、上下文切换、内存拷贝,单核顶多处理十万级QPS。用DPDK接管网卡后,我们在一台2U服务器(双路Xeon Gold, 4个MLX CX-5网口)上压到了1200万QPS,CPU占用率40%,延迟P99仅15μs。怎么做到的?核心是轮询模式、大页内存和用户态协议栈的零拷贝。但DPDK的坑也不浅:你得自己实现IP分片重组、ARP响应、TCP状态机……本质上是在写一个微型操作系统。我们调了半年,才把TCP重传率和内存泄漏完全驯服。最终上线那天,我盯着Grafana上那条平直的QPS曲线,忽然觉得——这大概就是架构师追求的所谓“工程美学”吧。

当然,DPDK不是银弹。如果你的场景里QPS不到50万,老实沿用内核加XDP offload就够了。别像我早期那样,为了炫技搞DPDK,结果维护成本是业务的十倍。但一旦你到了那个量级,那种“每个比特流都在掌控中”的快感,是任何云服务都给不了的。

零零碎碎写了这么多,只是想分享一些真实踩过的坑和实测数据。DNS很简单,简单到几十行Python就能搭个解析器;DNS也很难,难到需要你同时懂内核、网络、密码学和分布式系统。如果你也在折腾这块,强烈建议把rfc1034、rfc1035、rfc7871、rfc8906打印出来放在床头——哈哈,开玩笑的,电子版就够了。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:DNS 不为人知的三件事:从根区文件到100万QPS的架构演化
文章链接:https://www.lfdjt.com/info_23_7777.html