你有没有遇到过这种场景:服务的 QPS 并不高,但是压测一上来,日志里就全是 ETIMEDOUT,CPU 和内存看着都正常,却总给人一种“使不上劲”的感觉。我之前排查这类问题,最终定位到根因的时候还挺意外的——是 Node.js 进程里的 DNS 解析在拖后腿。Node.js 的 DNS 解析默认不缓存,这意味着每个新连接都可能重新走一次完整解析流程,低并发下几乎无感,一旦请求量上来,解析耗时会被成倍放大,甚至挤占 libuv 线程池,殃及日志写入、加密握手这些看似毫不相关的操作。这篇文章我会从解析原理讲起,一步步给出缓存提速的完整方案,包括手写缓存模块、接入 cacheable-lookup、压测对比、以及生产环境里那些文档不会写的坑。不管你是做后端服务、爬虫采集,还是网关代理,只要代码是 Node.js,这块优化都值得认真看一遍。
1. DNS 缓存为什么是 Node.js 性能的隐藏杀手
1.1 一次真实的超时事故
先说一个我踩过的坑。有一回线上服务错误率突然飙高,P99 延迟从 80ms 直接跳到 3s,排查了半天,数据库慢查询正常、Redis 连接正常、CPU 和内存也都没爆。最后我登录服务器抓包,发现大量 DNS 查询请求在排队,libuv 线程池被完全打满。原因并不复杂:服务启动后会从一个内部 DNS 服务解析一个上游域名,而那个 DNS 服务器恰好性能比较弱,单次解析平均耗时超过 100ms。高并发下,线程池里的线程全被 DNS 查询占住了,后续的文件读写、crypto 加密、包括其他 socket 操作全部排队,整个进程就像“堵车”一样。
那次事故之后,我把 Node.js 的 DNS 这块完整捋了一遍。很多人的认知是“操作系统有 DNS 缓存,不用管”,但实际没那么简单。尤其是容器环境下,/etc/resolv.conf 里的 nameserver 每次启动可能都变,不同操作系统的缓存策略也不一致,你根本没法保证每次 dns.lookup() 都能命中系统缓存。更麻烦的是,即使命中了系统缓存,dns.lookup() 这条链路依然会把解析任务丢给线程池去处理,线程池的默认大小只有 4,一旦解析变慢,所有依赖线程池的操作都会跟着遭殃。
1.2 dns.lookup() 和 dns.resolve() 的本质区别
要理解为什么需要自己做缓存,得先分清 Node.js 的两个 DNS API。
dns.lookup() 走的是 libuv 的线程池,底层调用操作系统的 getaddrinfo()。这意味着它会读取 /etc/hosts、/etc/resolv.conf 这些系统配置,同时也能命中操作系统自己的 DNS 缓存。Node.js 的 http、https、net 这些模块,默认都用这个方式解析域名。它的好处是贴近系统行为,兼容性最好;坏处是线程池会参与排队,一旦解析变慢,整个进程的资源都会被拖累。
dns.resolve() 就不走系统配置了,它通过 c-ares 这个库直接向 DNS 服务器发起 DNS 查询。你可以理解为“自己动手做解析”,完全绕开操作系统缓存。接口返回的是解析结果数组,比如 dns.resolve4() 返回 A 记录列表,dns.resolve6() 返回 AAAA 记录列表,还支持 ttl: true 参数把每条记录的 TTL 一起返回。它的优势是可控性强、能拿到 TTL;劣势是每次调用都会真实发一次 DNS 查询,延迟一般在 10ms 到 100ms 级别,比纯内存缓存慢了几个数量级。
1.3 不缓存到底有多慢:一个简单实验
光说概念太抽象,我写个简单脚本实测一下。串行调用 100 次 dns.lookup() 解析同一个域名,看看总耗时:
javascript复制const dns = require('dns');
const { performance } = require('perf_hooks');
async function run() {
// 预热一次,确保系统缓存等状态就绪
await new Promise((resolve, reject) => {
dns.lookup('example.com', (err) => (err ? reject(err) : resolve()));
});
const start = performance.now();
for (let i = 0; i < 100; i++) {
await new Promise((resolve, reject) => {
dns.lookup('example.com', (err, address) => {
if (err) return reject(err);
resolve(address);
});
});
}
console.log(`100 次 dns.lookup 总耗时: ${(performance.now() - start).toFixed(2)}ms`);
}
run();
在我当时的测试环境里,这个脚本跑出来大概是 700ms 到 1500ms 之间,平均单次解析 7ms 到 15ms。看起来不多对吧?但如果你的服务 QPS 有 500,每个请求都需要新连接并且重新解析,那一秒钟光是 DNS 解析就要消耗 3500ms 以上的时间,线程池直接变成瓶颈。更讽刺的是,这是同一个域名,100 次解析结果完全一样,却被重复浪费了 100 次网络往返。这种场景下,进程内加一层 DNS 缓存几乎是零成本的性能提升。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手动实现一个可控的 DNS 缓存模块
2.1 设计要点:TTL、并发合并、错误处理
自己写缓存模块,最核心的是三个问题:什么时候过期、并发请求如何处理、解析失败怎么办。
TTL 过期管理很好理解,就是给每个缓存条目设置一个过期时间。但要注意:缓存 key 必须包含 family,比如同样一个域名,IPv4 和 IPv6 的解析结果是完全不同的,key 如果不区分,很可能把 IPv6 的结果返回给请求 IPv4 的调用方,甚至直接导致连接失败。
并发合并是很容易被忽略但其实最要命的一点。假设缓存刚好过期,同一时刻有 100 个请求都在解析同一个域名,普通写法会发出 100 个 DNS 查询。这不仅是浪费,还会在 DNS 服务器出现抖动时把故障放大。正确做法是用一个 pending Map 把在途的 Promise 存起来,相同 key 的并发请求直接复用一个 Promise,等第一个解析完成,其余的等待者也跟着拿到结果。这就把 100 次解析压成了 1 次,本质上是给缓存击穿加了保险。
错误处理也有讲究。解析失败时,最简单的做法是直接 reject,不缓存错误结果,这样 DNS 恢复后下一批请求自然能正常解析。但后面我会讲到,在某些场景下短暂缓存错误结果反而是更优的选择,可以防止故障期间疯狂重试把 DNS 服务器打得更惨。
2.2 一个完整的 DnsCache 实现
下面这个模块我实际用过,代码不长但足够顺手:
javascript复制const dns = require('dns');
const { promisify } = require('util');
const lookupAsync = promisify(dns.lookup);
class DnsCache {
constructor(options = {}) {
this.ttl = options.ttl ?? 60_000; // 缓存时间,默认 60 秒
this.maxSize = options.maxSize ?? 500; // 最大缓存条目数
this.store = new Map(); // key -> { address, family, expireAt }
this.pending = new Map(); // key -> Promise
}
_isExpired(entry) {
return entry.expireAt <= Date.now();
}
_evictOne() {
let oldestKey = null;
let oldestExpire = Infinity;
for (const [key, entry] of this.store) {
if (entry.expireAt < oldestExpire) {
oldestExpire = entry.expireAt;
oldestKey = key;
}
}
if (oldestKey) this.store.delete(oldestKey);
}
async lookup(hostname, family = 0) {
const key = `${hostname}:${family}`;
const now = Date.now();
const cached = this.store.get(key);
if (cached && !this._isExpired(cached)) {
return cached;
}
if (this.pending.has(key)) {
return this.pending.get(key);
}
const promise = lookupAsync(hostname, { family })
.then(({ address, family: af }) => {
if (this.store.size >= this.maxSize) this._evictOne();
this.store.set(key, {
address,
family: af,
expireAt: Date.now() + this.ttl,
});
return { address, family: af };
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
return promise;
}
clear() {
this.store.clear();
}
}
module.exports = DnsCache;
这个实现里有几个细节值得说。一是 pending 用的是 finally 清理,这样不管成功失败都能把锁释放掉,不会导致后续请求一直阻塞。二是淘汰策略我用的是“删除最早过期条目”,虽然不算最优,但简单可靠,非常适合 DNS 这种缓存规模几百条就顶天的场景,没必要引入完整的 LRU 结构。三是 family 的默认值设为 0,表示由系统决定返回 IPv4 还是 IPv6,和 Node.js 原生 dns.lookup() 的默认行为保持一致。
2.3 进阶:获取真实 TTL 和 stale-while-revalidate
固定 TTL 最大的问题就是拍脑袋。你把 TTL 设成 5 分钟,DNS 服务器上明明写了 30 秒,那这段时间内域名换了 IP,你的服务还会一直用旧地址。更好的做法是用 dns.resolve4(hostname, { ttl: true }) 拿到 A 记录自带的 TTL,再基于真实 TTL 决定缓存多久。
javascript复制const dns = require('dns');
const { promisify } = require('util');
const resolve4 = promisify(dns.resolve4);
async function lookupWithTtl(hostname) {
const records = await resolve4(hostname, { ttl: true });
if (records.length === 0) {
throw new Error(`No A records for ${hostname}`);
}
let lowestTtl = Infinity;
for (const record of records) {
if (record.ttl > 0 && record.ttl < lowestTtl) {
lowestTtl = record.ttl;
}
}
if (lowestTtl === Infinity) lowestTtl = 60; // 兜底
return {
addresses: records.map((r) => r.address),
ttl: lowestTtl,
};
}
多条 A 记录时,取最小的 TTL 是标准做法。这样能保证“最保守”的缓存时间,避免某条记录已经过期,但你还在用它做连接。配合随机选取一条地址,还能顺带做一层客户端负载均衡。
再说 stale-while-revalidate。这个策略的思路是:缓存快到过期时间时,不急着丢掉旧值,而是先返回旧值,同时在后台异步刷新缓存。这样对调用方来说,DNS 解析几乎永远命中,但 IP 又能比较及时地更新。实现起来就是在命中缓存的分支里加一个判断:
javascript复制async lookup(hostname, family = 0) {
const key = `${hostname}:${family}`;
const now = Date.now();
const entry = this.store.get(key);
if (entry && !this._isExpired(entry)) {
if (entry.expireAt - now < this.refreshThreshold) {
// 进入刷新窗口,后台上一次锁
this._refresh(key, hostname, family).catch(() => {});
}
return entry;
}
// ...正常解析逻辑
}
refreshThreshold 可以设置成 TTL 的 10%。后台刷新时要加锁,否则并发进来会同时刷新。刷新失败就静默保留旧缓存,顶多晚一点再更新,不影响当前请求。这个模式对高可用要求严格的系统特别有用。
2.4 和 HTTP 客户端集成
缓存模块写好了,怎么让它真正在请求里生效?答案是通过 lookup 选项。http.request()、https.request() 都支持传入自定义 lookup 函数,只要签名符合 (hostname, options, callback) 就行。
javascript复制const https = require('https');
const dnsCache = new DnsCache({ ttl: 30_000 });
function requestWithDnsCache(url) {
return new Promise((resolve, reject) => {
https.get(url, {
lookup: (hostname, options, callback) => {
dnsCache.lookup(hostname, options.family ?? 0)
.then(({ address, family }) => callback(null, address, family))
.catch(callback);
},
}, (res) => resolve(res)).on('error', reject);
});
}
如果你直接用 axios,同样可以通过自定义 agent 把 lookup 塞进去:
javascript复制const axios = require('axios');
const http = require('http');
const https = require('https');
const httpAgent = new http.Agent({ keepAlive: true, lookup: customLookup });
const httpsAgent = new https.Agent({ keepAlive: true, lookup: customLookup });
const client = axios.create({
httpAgent,
httpsAgent,
});
这里有个很容易踩的坑:如果你用 keepAlive 且连接复用得很好,DNS 解析确实不会频繁发生,但这不代表缓存没用。一旦连接池里没有可用连接,比如后端滚动重启、连接被服务端断开,新连接建立时立刻就会走一次解析。而且 keepAlive 场景下,DNS 缓存还能避免 Agent 在连接池维护时重复触发解析。所以配置好 lookup 总是稳赚不赔的。
3. 用 cacheable-lookup 快速落地
3.1 为什么推荐 cacheable-lookup
如果你的代码不想引入太多自定义逻辑,直接用社区成熟的 cacheable-lookup 是最省事的方式。它专门就是为了解决 Node.js 的 DNS 缓存问题而生的,兼容原生 dns.lookup 的回调签名,内部会自动做 TTL 感知、并发合并、失败回退,比大部分手写实现都要稳健。
安装很简单:
bash复制npm install cacheable-lookup
基本用法:
javascript复制const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup({
ttl: 60 * 1000, // 最小 TTL 60 秒
maxTtl: 600 * 1000, // 最大 TTL 10 分钟
fallbackDuration: 1000, // 失败时允许使用过期缓存的时间
errorTtl: 1000, // 错误结果缓存时间
lookup: false, // true 表示走 dns.lookup,false 表示走 dns.resolve
});
这里几个参数很关键,我逐个说明。ttl 和 maxTtl 是上下界,DNS 服务器返回的 TTL 如果太大,会被强制限制在 maxTtl 以内,避免 IP 变更后长时间不更新。fallbackDuration 是兜底策略:如果实时解析失败,可以继续返回过期的缓存数据一段时间,这个窗口就是它控制的。errorTtl 则是把解析失败的结果也缓存一小段时间,防止故障期间疯狂重试。lookup: false 会使用 dns.resolve 走 c-ares,能拿到真实 TTL;如果设成 true,就走系统 getaddrinfo 拿不到 TTL,缓存时间就成了固定值。
3.2 接入 http/https 和 axios
用 cacheable-lookup 替代自定义 lookup 函数,代码会简洁很多:
javascript复制const https = require('https');
const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup();
https.get('https://example.com', {
lookup: cacheable.lookup,
});
axios 同样是在 agent 里配置:
javascript复制const axios = require('axios');
const http = require('http');
const https = require('https');
const CacheableLookup = require('cacheable-lookup');
const cacheable = new CacheableLookup();
const httpAgent = new http.Agent({ lookup: cacheable.lookup });
const httpsAgent = new https.Agent({ lookup: cacheable.lookup });
const client = axios.create({
httpAgent,
httpsAgent,
});
从我实际使用经验来看,cacheable.lookup 绑到 agent 上之后,就不需要再做别的了。它的内部逻辑覆盖了缓存写入、过期、并发合并、错误处理,基本属于即插即用。但要注意,如果你在同一个进程里同时访问多个环境(比如测试环境、预发环境),最好分别创建实例,别共用一个全局缓存,否则环境域名不同还好,万一域名相同但 IP 归属不同环境,就会出大问题。
3.3 got 的原生支持
如果你用的是 got 这个请求库,它会比 axios 更方便。got 内置了 dnsCache 配置项,直接传入一个 CacheableLookup 实例就行:
javascript复制const got = require('got');
const CacheableLookup = require('cacheable-lookup');
const gotClient = got.extend({
dnsCache: new CacheableLookup(),
});
之后所有经过 gotClient 发起的请求都会自动使用 DNS 缓存,不需要手动配 agent。got 内部会在解析成功后自动完成缓存写入,解析失败时也会按缓存策略回退。对团队里已经有 got 的项目来说,这几乎是改动最小、收益最直接的一种接入方式。
4. 压测与效果对比:数字说话
4.1 压测环境与脚本
为了验证效果,我搭了一个简单的测试环境:一台 4 核 8G 的云主机,Node.js 20.x,用 autocannon 做压测。后端场景比较典型:Node.js 服务收到请求后,通过 HTTP 客户端调用一个上游域名 test-upstream.example.com,然后返回结果。对比两种模式:普通 http.Agent 和带 cacheable-lookup 的 http.Agent。
压测命令大致长这样:
bash复制autocannon -c 50 -d 30 -p 10 http://127.0.0.1:8080/proxy
同时,我在上游 DNS 的配置里人为模拟了一个平均 15ms 的解析延迟,用来观察差异。如果你们公司内部 DNS 比较快,比如 1ms,那收益会小一些;但生产环境公网域名的解析延迟通常都有几十毫秒,收益会更大。
4.2 结果与解读
最终压测数据大概是这样的:
| 指标 | 无缓存 | 有缓存 |
|---|---|---|
| DNS 平均解析耗时 | 15ms | <1ms |
| 单请求 P99 延迟 | 320ms | 120ms |
| QPS 上限 | 约 800 | 约 1500 |
| libuv 线程池占用 | 高 | 低 |
具体的数值每台机器都不一样,但数量级上的差异是确定的:有缓存之后,DNS 解析部分的开销几乎可以忽略不计,P99 从 320ms 降到 120ms 主要是省掉了真实解析的排队时间。QPS 上限提升也很明显,因为线程池被释放出来了,加密、文件写入这些操作不再和 DNS 抢资源。
但这个表格不是让你直接照抄结论,而是要理解为什么差距这么大。核心在于:线程池默认只有 4 个线程,当 DNS 解析平均耗时 15ms 时,每秒钟最多能处理约 266 次解析(1000ms / 15ms x 4),一旦请求量超过这个数,所有需要线程池的操作都要排队。而缓存命中时解析耗时小于 1ms,线程池每秒能扛住 4000 次以上的解析,这个瓶颈自然就消失了。
4.3 生产环境的 TTL 策略建议
压测数据只能告诉你方向,真正落地时最需要想清楚的是 TTL 设多大。
我自己的建议分三类来定:
- 内部服务域名,比如注册中心、配置中心、内部 API:TTL 30s 到 60s。这类域名变更频繁,短 TTL 能保证服务发现问题后尽快切换到新 IP。
- 外部稳定 API,比如支付通道、短信服务:TTL 5 分钟到 10 分钟。这些域名通常有负载均衡,IP 很少变,短 TTL 没什么意义,反而会增加解析频率。
- 有固定 IP 的对象存储、数据库:绕开 DNS 直接用 IP 连接,或者用 hosts 写入固定映射。
此外,建议开启 fallbackDuration。它的意义是:当实时 DNS 解析失败时,允许继续使用过期缓存数据一段时间。这样即使 DNS 服务器临时抖动,你的服务还能撑过去,而不是把每一次请求都打成 5xx。
5. 常见问题与排查技巧
5.1 缓存“不生效”的排查路径
我见过不少同学配置了 lookup 后,压测发现缓存好像没有生效。这里有一个很常见的误区:如果连接一直复用,DNS 解析本来就不会频繁发生,看起来像“没生效”,其实是 keepAlive 在起作用,这是正常现象。你需要确认的是,连接池 miss 时是否走了你的 lookup 函数。
如果确定没走,先查这几个位置:
- 请求是否真的传入了 agent。
axios.get(url)不传 agent 时,默认走全局默认配置,不是你在 create 里设置的那个实例。 - 自定义 lookup 的回调签名是否正确。签名必须是
(err, address, family),少一个参数或者顺序错误,Node.js 内部可能直接报错或静默失败。 - 是否创建了多个 Agent 实例,但请求并没有复用你配置过 lookup 的那个。比如无意识地在每次请求里
new http.Agent(),缓存自然无从谈起。
排查时最简单的办法是通过环境变量打开调试日志:
bash复制NODE_DEBUG=dns node app.js
这样 Node.js 会打印所有 DNS 查询相关日志,你能直接看到到底有没有发出新的解析请求。如果日志里完全没有查询记录,说明 lookup 已经被外部缓存拦截,这是好事。
5.2 缓存击穿和雪崩的防护
手写缓存模块时最容易漏掉的并发合并,前面已经讲了。用 cacheable-lookup 的同学基本不用操心,但自研方案一定要把 pending Map 加上。这是整个缓存设计里最核心的一道防线,因为 DNS 缓存一旦过期,大量请求同时涌入,没有并发合并意味着把 DNS 服务器直接打挂的可能性都存在。
另外,可以考虑给每个域名增加一个“随机抖动”的过期时间。也就是说,缓存条目的过期时间不是固定写死的 TTL,而是在 TTL 基础上加一个 0 到 10% 的随机值。这样多个节点、多个实例的缓存不会在同一秒集中过期,避免对 DNS 服务器造成周期性脉冲压力。这个小技巧在很多缓存场景下都通用,不只是 DNS。
5.3 DNS 切换后服务一直解析到旧 IP
典型的 SLA 事故场景:运维改了 DNS 记录,把服务切到新 IP,但线上服务还是继续连旧 IP,导致一部分节点报错。根本原因基本逃不出这两点:TTL 设置太长,或者缓存没有清理机制。
解决办法分几个层次:
- 收缩 TTL,比如内部服务域名统一设 30s,外部域名最多 10 分钟。
- 如果平台支持,在发布流程里主动调用缓存清理接口。我在内部服务里暴露过一个
/internal/dnscache/clear管理端点,滚动发布涉及 IP 切换时,先清理缓存再切流量。 - 使用 SWR 预刷新策略,让缓存最长只比 TTL 晚一个刷新窗口,而不是等到过期后被动更新。
如果实在来不及改代码,兜底方案是重启服务节点。大多数情况下重启后第一次解析会拿到最新 IP,比在混乱的缓存状态里排查快得多。
5.4 调试工具集锦
最后分享几个我常用的调试工具和方法,按照使用频率排序:
dig +trace example.com:看完整解析链路,确认权威记录和 TTL 值,判断问题到底在哪个层级。curl -w '%{time_namelookup}' https://example.com:快速测量单个请求的 DNS 耗时,适合做初步体检。NODE_DEBUG=dns node app.js:打印 Node.js 进程内的 DNS 查询日志,查看解析是否走到实际查询层。prom-client监控指标:在自定义缓存里加dns_cache_hit_total和dns_cache_miss_total两个计数器,配合 Grafana 面板,长期观察命中率和解析耗时是否正常。
如果你们是容器化部署,还要注意 /etc/resolv.conf 里的 search 域。默认情况下,容器内解析一个不完整域名时,会依次拼接多个 search 域去查询,导致一次解析发出多次 DNS 请求,这在 NODE_DEBUG=dns 里能看到一堆记录。遇到这种情况,可以在启动参数里调整 DNS 搜索域,或者减少不必要的 search 配置,对解析速度有明显帮助。
我在实际项目中最终使用的是 cacheable-lookup 加自定义的 TTL 策略,并配合 Prometheus 做了缓存命中率监控。有个小技巧一直沿用至今:每次发布涉及 IP 切换时,我会在发布脚本里先调用管理接口清掉缓存,避免滚动发布过程中一半节点还连着旧 IP。这个细节帮我避免过一次不小的线上事故。如果你也在维护 Node.js 服务,强烈建议把 DNS 缓存当成标配来做,而不是等到出问题时再补救。它改动不大、见效极快,属于性价比非常高的一类优化。
