在Node.js里发起HTTP请求时,如果直接传入域名,底层会调用libuv线程池中的getaddrinfo函数完成域名解析。这个过程不会缓存结果,也就是说你的服务每秒请求100次外部接口,就可能触发100次DNS查询。DNS查询通常耗时几毫秒到几十毫秒,在内网或DNS服务器响应慢的情况下,甚至会达到数百毫秒,这对高并发服务来说是一笔不小的开销。本文详细讲解如何围绕dns.lookup构建一层缓存,把这部分重复开销省掉。

先弄清楚:dns.lookup和dns.resolve的区别
Node.js官方提供了两组DNS相关的API,很多初学者容易把它们混为一谈。dns.lookup是对操作系统getaddrinfo的薄封装,它会读取/etc/hosts文件,遵循系统的解析规则,并且运行在libuv的线程池中。而dns.resolve系列函数是纯粹的网络请求实现,直接与DNS服务器通信,不经过操作系统。
这个区别非常关键:Node.js的http模块、axios、node-fetch等绝大多数库在做域名解析时,走的都是dns.lookup这条路径。所以我们要优化的正是它。还有一个隐藏的坑需要注意,libuv的线程池默认大小是4,如果大量并发的dns.lookup调用堆积在线程池里,会连带影响文件读写等其他依赖线程池的操作,导致整个进程出现难以排查的卡顿。
看看两者在行为上的差异:
const dns = require('dns');
// lookup:走系统getaddrinfo,遵循/etc/hosts,可返回IPv6优先结果
dns.lookup('www.baidu.com', (err, address, family) => {
console.log('lookup结果:', address, 'IPv' + family);
});
// resolve4:直接向DNS服务器发A记录查询,只返回IPv4地址
dns.resolve4('www.baidu.com', (err, addresses) => {
console.log('resolve4结果:', addresses);
});两者的缓存策略也不同:dns.resolve的结果由DNS响应中的TTL字段决定,你可以拿到TTL自行决定缓存多久;而dns.lookup拿不到TTL信息,只能自己设定固定的过期时间。理解了这些,下面就可以动手实现缓存了。
方案一:自定义Lookup缓存函数
最直接的办法是自己维护一个Map,把域名和解析结果存起来,并给每个条目设置过期时间。同时要处理一个经典问题:并发去重。假设同一时刻有50个请求都要解析同一个尚未缓存的域名,如果不去重,会触发50次真实的DNS查询,缓存的意义就大打折扣。解决办法是用一个"进行中"的映射表,把同一域名的并发回调合并到同一个Promise上。
下面是完整的实现,可以直接复制到项目中使用:
const dns = require('dns');
class DnsCache {
constructor(ttlMs = 30000) {
this.ttl = ttlMs; // 缓存有效期,默认30秒
this.cache = new Map(); // 已完成的缓存
this.pending = new Map(); // 进行中的请求,用于并发去重
}
lookup(hostname) {
const hit = this.cache.get(hostname);
if (hit && Date.now() - hit.time < this.ttl) {
return Promise.resolve(hit.address); // 命中缓存直接返回
}
// 并发去重:同一域名同时多次调用只发起一次真实查询
if (this.pending.has(hostname)) {
return this.pending.get(hostname);
}
const task = new Promise((resolve, reject) => {
dns.lookup(hostname, (err, address) => {
this.pending.delete(hostname);
if (err) return reject(err);
this.cache.set(hostname, { address, time: Date.now() });
resolve(address);
});
});
this.pending.set(hostname, task);
return task;
}
}
module.exports = DnsCache;使用方式也很简单,在发起请求前先查缓存,拿到IP后直接用IP访问,同时通过Host头保留原域名,保证HTTP虚拟主机和TLS的SNI扩展正常工作:
const http = require('http');
const DnsCache = require('./dns-cache');
const cache = new DnsCache(60000);
async function requestWithCache(hostname, path) {
const ip = await cache.lookup(hostname);
return new Promise((resolve, reject) => {
http.get({
host: ip, // 用IP直连
path: path,
headers: { Host: hostname }, // 保留原始域名
}, resolve).on('error', reject);
});
}这种方案的优点是零依赖、逻辑透明、完全可控;缺点是需要侵入请求代码,而且如果使用https模块,还要设置servername选项来保证证书校验通过,否则HTTPS请求会报证书错误。
方案二:修改http.Agent,对业务代码零侵入
p>手动传IP的方式对现有代码改动太大。更优雅的做法是利用Node.js的http.Agent提供的createConnection钩子,在这个钩子里做DNS缓存,业务代码完全不用改,只要换个全局agent即可。
const http = require('http');
const dns = require('dns');
const cache = new Map();
const cachedAgent = new http.Agent({
keepAlive: true,
});
cachedAgent.createConnection = function (options, callback) {
const hostname = options.host;
const hit = cache.get(hostname);
if (hit && Date.now() - hit.time < 30000) {
options.host = hit.address;
options.servername = hostname; // HTTPS证书校验需要
return http.Agent.prototype.createConnection.call(this, options, callback);
}
dns.lookup(hostname, (err, address) => {
if (err) return callback(err);
cache.set(hostname, { address, time: Date.now() });
options.host = address;
options.servername = hostname;
http.Agent.prototype.createConnection.call(this, options, callback);
});
};
// 使用时只需指定agent,业务代码不变
http.get({ host: 'api.ipipp.com', path: '/', agent: cachedAgent }, (res) => {
res.on('data', chunk => process.stdout.write(chunk));
});这个方案的好处显而易见:业务代码保持干净,所有走这个agent的请求都自动享受缓存。配合keepAlive: true,连接复用和DNS缓存双重生效,效果叠加。需要注意的是,如果你的请求库默认使用全局http.globalAgent,可以直接替换它的createConnection方法,但建议评估影响范围,避免误伤其他业务。
缓存过期与容错:TTL怎么定才合理
TTL的设置是一门权衡的艺术。设得太短,缓存命中率低,优化效果有限;设得太长,一旦目标域名变更解析记录,你的服务会在过期时间内持续访问旧IP,可能把正常故障切换变成大面积超时。一般建议:
- 内网服务或解析记录稳定的域名,可以设5到10分钟;
- 公网API域名,特别是有CDN或多机房轮询的,建议30秒到2分钟;
- 绝对不要设置永久缓存,除非你完全确定该域名IP不变。
容错方面还有一个实用技巧:DNS查询失败时,不要把失败结果缓存住,但可以考虑使用"过期缓存"。也就是说查询失败时,如果缓存里有一条刚过期的旧记录,宁可先用旧IP试一次,也比直接抛错强。这种策略在DNS服务器抖动的场景下能显著提升服务可用性。
dns.lookup(hostname, (err, address) => {
this.pending.delete(hostname);
if (err) {
const stale = this.cache.get(hostname); // 查询失败,尝试用过期缓存兜底
if (stale) return resolve(stale.address);
return reject(err);
}
this.cache.set(hostname, { address, time: Date.now() });
resolve(address);
});其他值得考虑的做法
除了在应用层做缓存,还有一些补充手段。第一,Node.js从v18开始支持--dns-result-order启动参数,可以控制返回顺序,配合IPv4优先能减少不必要的解析尝试。第二,操作系统层面的缓存,比如Linux上的nscd或systemd-resolved,能够让getaddrinfo直接命中本机缓存,对应用完全透明,但需要运维配合修改机器配置。第三,如果服务前面有Nginx做反向代理,Nginx自带的resolver缓存指令也能消化掉大部分DNS压力。
最后提醒一点:做任何DNS缓存优化前,先确认这真的是你的瓶颈。可以用perf_hooks测量一次请求中域名解析占用的真实时间。如果DNS只占请求总耗时的百分之一,那优化收益有限,不如把精力放在连接复用或数据库查询上。先量化,再优化,这是性能调优的基本原则。
Node.jsdns.lookupDNS缓存修改时间:2026-09-08 23:05:23