先说个我实际遇到过的场景。有段时间我在维护一个高并发的网关服务,压测的时候QPS曲线总是锯齿状,每隔几十秒就掉一截,查了半天数据库、慢日志、GC,全都没问题。最后用perf一抓,发现大量线程卡在getaddrinfo上——说白了,DNS查询把整个Node.js线程池给拖住了。那次之后我把DNS缓存加上了,锯齿立刻消失,P99延迟直接降了一个量级。
这篇文章就围绕Node.js里的DNS解析提速来展开。我会从dns.lookup和dns.resolve的区别讲起,分析慢的根源,然后给出一套完整的缓存设计方案和可运行的代码。无论你是写爬虫、做微服务网关,还是维护自研RPC框架,这篇内容都可以直接参考。
1. 先搞清一个事实:Node.js的DNS查询并不都是异步的
很多Node.js开发者对DNS的理解就是"解析域名返回IP",但遇到性能问题就抓瞎了。原因是你可能根本不知道:Node.js里查DNS有两条完全不同的路,性能特征天差地别。
1.1 dns.lookup 与 dns.resolve:两条完全不同的路
先说dns.lookup。它是Node.js里最常用的DNS查询方式,比如net.connect、http.request默认走的都是它。这个函数调用的其实是操作系统层面的getaddrinfo,C语言标准库里那个老古董。因为是系统调用,Node.js没法直接用事件循环异步处理,只能丢给libuv的线程池去跑。
libuv线程池默认只有4个线程。想象一下:同时有4个请求在等DNS解析,线程池就满了。这时候其他依赖线程池的活儿——读文件、crypto运算、zlib压缩,全部排队等着。这就是为什么DNS解析慢的时候,整个服务都像死掉一样,而不是只有网络请求卡。
接着是dns.resolve。它不走操作系统,而是Node.js自己用c-ares这个C库直接发DNS请求,走UDP协议。这个查询是真正异步的,不占用线程池,只是功能上弱一些——拿不到系统hosts文件里的配置,也拿不到本地DNS缓存。
两者还有一个关键区别:dns.lookup默认只返回一个IP地址,而dns.resolve返回的是全部解析结果。对于负载均衡场景来说,后者反而更有价值。
1.2 慢的根源:不是解析本身,是调用链排队
那么DNS查询到底慢在哪?如果单纯看一次解析,走本机DNS服务器也就几毫秒到几十毫秒。但问题在于调用链排队。
你的请求打过来,需要先连上目标服务器。连上之前得先解析域名,而域名解析被丢进线程池排队。假设当前线程池有3个DNS任务在跑,你的任务就要等它们完成。等排到你了,DNS服务器响应慢了,比如超时,那这个连接请求就卡住了。更糟的是,Node.js内置的HTTP代理对同一hostname的socket复用是有数量上限的,连接一慢,后面的请求只能等空闲socket。这就是"一个DNS慢查询拖垮整个服务"的完整链路。
另一个容易被忽略的点是:DNS查询结果本身是带TTL的。系统层面有缓存,但Node.js进程每次新建连接还是会去查一次系统调用。如果你在短时间内大量新建连接(比如做服务发现、动态上游、短连接压测),dns.lookup就成了一个隐藏的全局锁。
用我当时的压测数据举个例子:1000并发请求,每次请求都要新建连接,每个DNS查询大约耗时15ms。线程池4个线程,每个线程每秒最多处理约66次查询,4个线程每秒撑死260次。也就是说,光DNS解析就把吞吐卡在每秒260个连接附近,数据量再大也上不去。
所以结论很直接:在高频新建连接的场景下,DNS必须做应用层缓存。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存方案设计不是set一下就完事
缓存谁都会写,但DNS缓存有个特殊性:它不是"存一个key取一个value"这么简单。你得想清楚存什么、存多久、多进程怎么处理、缓存过期瞬间会不会被打爆。这一节把设计决策讲清楚。
2.1 缓存对象:到底存IP还是存解析记录
我见过很多人用dns.lookup包装一层缓存,存的就是唯一一个IP。这能用,但信息量太少。你缓存的是"解析结果",但DNS解析结果并不是只有IP地址,它还包括TTL、解析类型(A还是AAAA)、多个IP的优先级等信息。
更好的做法是直接缓存dns.resolve的结果,也就是完整的解析记录数组。这样除了拿到IP列表,还能知道这条记录还剩多长时间到期,可以据此安排主动刷新。
还有一个决策点:缓存的key。我建议用family:hostname作为key,比如4:api.example.com和6:api.example.com分开缓存。因为IPv4和IPv6的解析结果可以独立变化,合并在一起容易互相污染。如果你不需要IPv6,直接只缓存IPv4,还能减少一半的解析请求。
2.2 TTL处理:DNS记录自带的ttl才是最准的基准
TTL是DNS缓存里最核心的参数。很多人在代码里写死一个60秒或者300秒,这其实不够严谨。正确的做法是:使用DNS解析记录自带的TTL,因为它是权威DNS服务器根据域名提供者的配置下发的,代表了这条记录的预期有效时长。
举个例子,你用dns.resolve4('example.com', { ttl: true })拿到一条记录:
javascript复制[
{ address: '93.184.216.34', ttl: 300 },
{ address: '93.184.216.35', ttl: 300 }
]
这里的ttl: 300代表这条记录最多缓存300秒。你设置缓存过期时间时,就应该以这个值为基准,再叠加一个安全边际,比如Math.min(recordTtl, maxCacheTtl)。为什么要设上限?因为有些DNS服务器给的TTL特别大(比如86400秒),一旦IP变更,你的服务
