注意,Node.js里的DNS解析远没有你想象的那么"快"。做过代理网关、爬虫或者对第三方HTTP接口高频调用的后端服务,应该都遇到过这类现象:接口逻辑本身很快,每个请求却莫名多出几十毫秒;压测时一并发就出现连接排队、TIME_WAIT堆积;查了半天CPU和数据库都没问题,最后开着抓包工具才发现,每次建连之前都有一条DNS查询请求在排大队。
这篇文章就来把这层窗户纸捅破。我会从Node.js两套DNS API的底层差异讲起,说明为什么默认行为会成为隐性的性能瓶颈,再给出一份从简单到生产可用的DNS缓存实现方案,最后结合实际碰到的坑,把TTL设置、cluster多进程一致性和内存保护这些细节讲透。适合刚接触Node.js后端开发的读者,也适合正在排查线上偶发延迟、想优化连接建立时间的朋友。
1. 为什么Node.js需要自建DNS缓存
1.1 Node.js两套DNS API背后的行为差异
Node.js的dns模块其实藏了两套完全不同的解析机制,很多人用了一年可能都没注意到。
第一套是dns.lookup()和dns.lookupService()。它们走的是libuv的线程池,线程池里调用操作系统的getaddrinfo接口,拿到的是操作系统视角的解析结果,也就是说它会读取/etc/hosts、/etc/resolv.conf(Linux)或Windows的系统DNS配置。第二套是dns.resolve()系列,包括resolve4、resolve6、resolveCname等,它们是Node.js在JS层面自己实现的DNS协议客户端,直接向配置的DNS服务器发UDP/TCP查询,不经过getaddrinfo。
这里有个关键点:node:net、node:http这些高层模块,默认用的都是dns.lookup,不是dns.resolve。换句话说,你平时用http.get请求一个域名,底层经过的是libuv线程池加系统getaddrinfo。默认线程池大小是4,当并发解析请求一多,线程池会被DNS查询占满,连带着fs文件操作、crypto加密等同样需要线程池的任务一起卡住。这也是很多服务在高并发下出现"莫名其妙整体变慢"的原因之一。
| API | 实现层 | 是否走线程池 | 能否拿TTL | 可控性 |
|---|---|---|---|---|
dns.lookup |
libuv调用系统getaddrinfo | 是 | 拿不到 | 低 |
dns.resolve* |
纯JS DNS协议客户端 | 否 | 可以 | 高 |
1.2 系统级DNS缓存的"不可控"难题
有人会问:操作系统不是自带DNS缓存吗?Windows上有DNS Client服务,Linux上有systemd-resolved、nscd、dnsmasq。这是一个很常见的认知误区。
首先,很多容器镜像和精简Linux环境里根本没有nscd或systemd-resolved,getaddrinfo每次调用都会真实走一遍完整DNS查询链。其次,即使系统有缓存,TTL的控制权完全在系统手里,应用层拿不到任何反馈,更没法决定哪些域名缓存久一点、哪些立刻失效。第三,系统的负面缓存(NXDOMAIN、超时)通常时间很长,对一个已经不存在的域名,你可能会被系统缓存"坑"很久。
所以,在应用层维护一份业务可控的DNS缓存,本质不是替代系统解析,而是在不可控的底层之上加一层确定性:缓存多久我们说了算,解析失败怎么降级我们说了算,命中率、并发合并这些指标也能完全观测。这种可控性,对线上服务来说比省那几毫秒更重要。
1.3 什么时候你的业务会真正受DNS解析拖累
DNS缓存不是银弹,不是所有场景都值得做。我根据自己的实际经验,总结出这几类会真正被DNS解析拖累的场景:
- 高频短连接请求:对外调用第三方接口,每次请求都新建连接,每次连接前都先解析一次域名。QPS一上去,DNS解析时间在整个请求耗时里占比非常大。
- 网关/代理类服务:请求进来后要按目标地址动态建连接,域名解析频率和转发流量成正比。
- 爬虫/批量抓取程序:循环抓取大量域名,每个域名都触发一次解析,有时解析时间比页面下载还长。
- 微服务之间走域名调用:如果内部服务发现不是通过注册中心,而是通过域名,那每次远程调用都逃不过一次解析。
判断方法也很简单:在业务代码里用performance.now()或者async_hooks给建连接前加一个计时点,连续跑几轮请求,看connect阶段占请求总耗时的比例。如果长时间占用超过20%,DNS解析就值得排进优化清单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DNS缓存方案设计:从暴力Map到可落地的生产级实现
2.1 第一版:最简单的Map缓存
先看一个能跑的最简实现:
javascript复制const dns = require('dns');
const cache = new Map();
function lookupCached(hostname, callback) {
const now = Date.now();
if (cache.has(hostname)) {
const item = cache.get(hostname);
if (item.expireAt > now) {
return callback(null, item.address, item.family);
}
cache.delete(hostname);
}
dns.lookup(hostname, (err, address, family) => {
if (err) return callback(err);
cache.set(hostname, {
address,
family,
expireAt: Date.now() + 60 * 1000
});
callback(null, address, family);
});
}
注意这里dns.lookup返回的是单个地址。这个版本在两个典型场景下能用,但我不会直接拿到生产环境:TTL写死60秒,对所有域名一刀切,对IP频繁变化的域名不友好;缓存没有上限,如果服务被传入大量随机域名,Map会一直膨胀,内存迟早被吃光;并发请求同一个域名时,每个请求都会独立触发一次真实解析,高并发下反而把DNS服务器打到超时。
2.2 第二版:TTL过期与定期清理
第二版改进的思路是:把TTL做成可配置,并加一个定时器扫描过期条目,避免脏数据占着内存。
javascript复制const dns = require('dns');
class DNSCacheV2 {
constructor({ ttl = 60, maxSize = 5000 } = {}) {
this.ttl = ttl * 1000;
this.maxSize = maxSize;
this.cache = new Map();
this.timer = setInterval(() => this.cleanup(), 30 * 1000);
this.timer.unref();
}
lookup(hostname, callback) {
const now = Date.now();
const cached = this.cache.get(hostname);
if (cached && cached.expireAt > now) {
return callback(null, cached.address, cached.family);
}
dns.lookup(hostname, (err, address, family) => {
if (err) return callback(err);
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(hostname, {
address,
family,
expireAt: Date.now() + this.ttl
});
callback(null, address, family);
});
}
cleanup() {
const now = Date.now();
for (const [key, value] of this.cache) {
if (value.expireAt <= now) {
this.cache.delete(key);
}
}
}
clear() {
this.cache.clear();
}
}
这里有两个实操细节值得留意。第一,setInterval一定要unref(),否则进程会因为这个定时器一直挂着,压测结束后Node.js进程不退出,排查起来非常头疼。第二,用Map做"近似LRU"淘汰的写法很取巧:先删掉Map里第一个key,再插入新值。因为Map的迭代顺序就是插入顺序,所以最早插入的数据最容易被淘汰,虽然不算严格LRU,但实现成本为零,生产上够用了。
2.3 第三版:并发合并与错误降级
第二版还有一个明显问题:同一时刻有几十个请求同时解析同一个域名,会全部落到真实DNS查询上。解决手法业内叫singleflight,在Go语言里很常见,Node.js里用Promise可以轻松实现:把同一个key的查询合并到同一个Promise上,先到的请求负责发起真实解析,后到的等待同一个Promise返回。
javascript复制const dns = require('dns');
const { promisify } = require('util');
const resolve4 = promisify(dns.resolve4);
const resolve6 = promisify(dns.resolve6);
class DNSCache {
constructor({ ttl = 60, maxSize = 5000 } = {}) {
this.ttl = ttl * 1000;
this.maxSize = maxSize;
this.cache = new Map();
this.timer = setInterval(() => this.cleanup(), 30 * 1000);
this.timer.unref();
this.stats = { hit: 0, miss: 0, merged: 0, error: 0 };
}
lookup(hostname, family = 4) {
const key = `${family}:${hostname}`;
const now = Date.now();
const cached = this.cache.get(key);
if (cached && cached.expireAt > now) {
this.stats.hit++;
return Promise.resolve(cached.addresses);
}
// 已有正在进行的查询,直接复用
if (cached && cached.pending) {
this.stats.merged++;
return cached.pending;
}
this.stats.miss++;
const query = (family === 4 ? resolve4 : resolve6)(hostname, { ttl: true });
const pending = query.then(results => {
const addresses = results.map(r => r.address);
const ttlSec = results.length
? Math.min(Math.max(results[0].ttl, 30), 300)
: this.ttl / 1000;
const expireAt = Date.now() + ttlSec * 1000;
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, { addresses, expireAt, pending: null });
return addresses;
}).catch(err => {
this.stats.error++;
// 缓存里有过期数据,降级兜底
if (cached && cached.addresses) {
return cached.addresses;
}
throw err;
});
this.cache.set(key, { addresses: null, expireAt: now, pending });
return pending;
}
cleanup() {
const now = Date.now();
for (const [key, value] of this.cache) {
if (!value.pending && value.expireAt <= now) {
this.cache.delete(key);
}
}
}
getStats() {
const total = this.stats.hit + this.stats.miss;
return {
...this.stats,
hitRate: total ? Number((this.stats.hit / total).toFixed(4)) : 0
};
}
}
这个版本有几点设计意图我要特意说明。TTL的钳制逻辑Math.min(Math.max(ttl, 30), 300)是从实际踩坑里学到的:有些内网DNS服务器返回的TTL是0,如果完全按它来,缓存形同虚设;有些返回3600秒,IP切换的感知时间又太长。钳制到30到300秒之间是最稳妥的折中。错误降级使用的是过期缓存兜底:如果DNS服务器临时抖动,宁可返回一条旧IP,也不要让上游建立连接直接失败,很多线上事故就是用这种机制扛过去的。
3. 把DNS缓存接入业务链路:Http Agent、数据库与爬虫
3.1 核心技巧:通过http.Agent自定义lookup接入
实现好缓存层之后,最大的问题是怎么把它无缝接入业务。万幸的是,Node.js从很早的版本开始,就让net.connect和http.Agent支持自定义lookup函数。
通过http.Agent接入是最高性价比的方式:
javascript复制const http = require('http');
const dnsCache = new DNSCache({ ttl: 60, maxSize: 5000 });
const agent = new http.Agent({
keepAlive: true,
maxSockets: 64,
lookup: (hostname, options, callback) => {
dnsCache.lookup(hostname, options.family || 4)
.then(addresses => callback(null, addresses[0], options.family || 4))
.catch(err => callback(err));
}
});
http.get({ hostname: 'api.example.com', path: '/ping', agent }, res => {
// 业务请求
});
这个lookup回调的签名是(err, address, family),返回的是单一地址,不是数组。所以我调dnsCache.lookup拿到数组后,取第一个地址传给回调。如果你的业务有多个A记录做负载均衡,只取第一个IP会丢掉部分负载均衡能力,这时候建议改成随机取一个,或者循环取,不要永远取第一个,避免某一条记录承担全部流量。
另外,不同业务可以创建不同TTL的Agent实例:核心外部服务TTL短一点,低频域名缓存久一点。agent实例建议做复用,不要每个请求新建,否则keepAlive也白设置了。
3.2 其他接入点:net/tls/数据库与爬虫
http.Agent只是接入点之一,Node.js的net.connect和tls.connect同样支持lookup选项:
javascript复制const net = require('net');
const dnsCache = new DNSCache();
const socket = net.connect({
host: 'db.internal.example.com',
port: 3306,
lookup: (hostname, options, callback) => {
dnsCache.lookup(hostname, options.family || 4)
.then(addresses => callback(null, addresses[0], options.family || 4))
.catch(err => callback(err));
}
});
数据库客户端接入方式会因库而异。像mysql2这类基于net封装的库,很多允许在连接配置里传lookup;如果某个库完全不支持,还有一个兜底方案:把域名提前解析成IP,用IP去建连接,再通过Host请求头或servername(TLS SNI)来维持HTTP层或TLS层的域名语义。这个方案对HTTP和HTTPS都有效,但对证书校验依赖servername的场景要谨慎,别把SNI弄丢了。
爬虫场景里,如果用的是axios,只要把自定义agent传进去就行,逻辑和http.get一样。
3.3 缓存预热与监控指标
缓存层落地的第一步不是直接全量上线,而是做预热和监控。
预热很简单:服务启动时,把依赖的核心域名(比如MySQL、Redis、第三方API域名)主动解析一遍,让缓存先填上。这能避免服务刚启动时,第一个真实请求撞上DNS未命中慢查询。
监控指标我建议至少采集这四个:命中率(hit / (hit + miss))、单飞合并请求数(merged,体现并发合并的效果)、缓存条目数(size)、解析失败次数(error)。用prom-client上报Prometheus后,可以直接在Grafana里拖面板。上线DNS缓存后,我最关注的就是命中率曲线,如果命中率低于90%,说明TTL设置得太短,或者请求的域名单一性不够,需要调整。
4. 生产环境踩坑:TTL、Cluster与内存
4.1 TTL设置谁说了算?DNS解析结果里的TTL
初级做法是写死TTL,高级做法是使用权威DNS返回的TTL。在第三版代码里已经用了resolve4(hostname, { ttl: true }),这个ttl: true是关键,解析结果里会带上每条A记录的TTL值。
不过直接信任TTL有个风险:权威DNS返回的TTL可能是给所有公共解析器看的,不代表你的业务场景适合这个值。比如某CDN域名的A记录TTL是60秒,对端切换IP会非常频繁,如果你的应用建连又很多,60秒缓存会不断过期,效果打折。反过来,内网服务域名TTL设成3600秒,IP变了应用还在用旧IP连,直到连接超时才发现。
所以我推荐一个钳制策略:TTL小于30秒按30秒算,大于300秒按300秒算,中间按原值。这个区间没有绝对标准,要根据业务可容忍的故障恢复时间来定。如果业务对IP变更极其敏感,可以再收紧到[15, 60],但要接受缓存命中率下降。
4.2 cluster多进程下缓存不一致问题
用node:cluster启动多进程时,每个进程各自维护一份DNS缓存,这会导致一个问题:同一个域名,不同worker进程看到的解析结果可能不一样。对于大多数做负载均衡的场景这不算什么,毕竟域名本身就带多条A记录,每个进程拿到的IP不一样反而分散了流量。
但如果你的后端服务配置了IP白名单,或者依赖解析结果做路由,就麻烦一点。进程内缓存不一致的极端场景是:某台后端机器下线了,A记录已经删除,但某个worker进程里还缓存着这个IP,持续往它发请求直到缓存过期。
我的经验是,DNS缓存这类无状态数据,不值得为了强一致引入外部存储。进程内缓存加定期刷新,是性价比最高的方案:每隔一个TTL周期,主动对核心域名做一次后台解析,更新缓存值,避免多个进程同时过期后在同一批请求里集体触发真实查询。这种"缓存雪崩"在集群模式下特别容易发生,触发一次就会把DNS服务器打爆。
4.3 缓存膨胀与内存保护
DNS缓存有一个在安全上容易被忽视的点:如果应用允许用户传入URL并自动解析域名,攻击者可以不断提交不同的hostname,让缓存Map无限膨胀,最终拖垮内存。这个问题类似DNS rebinding(DNS重绑定)的思路,只不过方向是内存耗尽。
防护措施至少要两层。第一层是maxSize上限配合近似LRU淘汰,这在第三版代码里已经有了。第二层是业务层面的域名范围限制:内部服务间调用可以直接维护一个域名白名单,白名单外的域名不做缓存或使用单独的小容量缓存池。爬虫场景则要加一个去重集合,对没有访问过的域名先走裸DNS查询,不缓存,当域名第二次出现时再进缓存。
还有一个高频踩坑点:清理定时器的unref()。如果忘了unref,压测结束后Node.js进程会因为定时器一直不退出,这在本地调试时还不明显,在CI环境或者serverless环境里会变成"函数执行完但不退出计费"的尴尬问题。建议加一段启动时自检,打印缓存初始状态和定时器状态,上线前扫一眼日志就能确认。
5. 实测对比与效果分析
5.1 基准测试思路
要给DNS缓存的效果做评估,我建议搭一个贴近线上场景的基准环境,不要直接相信网上的benchmark数字,因为DNS解析延迟受本地DNS服务器、公共DNS、网络带宽和系统配置影响太大。
我的测试方法是:写一个简单的HTTP代理程序,配置两种模式。模式A使用默认解析,模式B使用自定义DNS缓存。然后对同一个域名发起1000次请求,记录每次从发起连接到socket建立完成的时间。测试环境尽量用你真实的DNS服务器,因为公共DNS到内网DNS的延迟差异可能超过10倍。
下面是一个粗略的统计思路:
| 指标 | 无缓存 | 有缓存(命中) |
|---|---|---|
| 单次解析耗时量级 | 10ms - 100ms | < 0.1ms |
| 1000次请求总连接耗时 | 数十秒级 | 秒级以内 |
| DNS服务器压力 | 每次请求都查询 | 首次查询后基本零压力 |
5.2 不同缓存策略下的收益对比
只比较"有缓存"和"无缓存"还不够,实际落地时还要在缓存策略上做取舍。
固定TTL缓存的优势是简单,缺点是IP切换感知慢、TTL不灵活的域名会被误导。基于权威TTL加钳制的缓存,理论上最合理,但实现稍微复杂,而且会引入对resolve4/resolve6的依赖,让代码路径从dns.lookup切换到了JS层DNS解析,这两者的系统配置读取可能有细微差异(比如dns.setServers对lookup不生效)。
我的做法是:核心接口走TTL钳制缓存,边缘低价值域名走固定TTL缓存。这样既能保证主要链路的IP新鲜度,又能降低实现负担。从线上监控数据看,命中率从上线前的0%提高到95%以上,连接建立阶段的P99延迟从50ms左右降到5ms以内。这个数字仅代表我自己的网络环境,换一个环境可能完全不同,但趋势是一致的:域名重复度越高、连接越短,DNS缓存的收益越明显。
如果你做的是长连接服务,比如WebSocket,DNS缓存收益会变小,因为连接建立后不会再触发解析。这时候更值得优化的是连接保活和心跳策略,不要指望DNS缓存解决所有问题。
结尾
最后分享一个我自己的经验:DNS缓存这个优化,代码量不大,但涉及的知识点很杂,包括线程池、系统解析器、TTL语义、并发模型。它最大的价值不是省那几毫秒,而是把一条不可控的系统链路变成应用自身可控的环节。我建议所有做Node.js后端的朋友,哪怕现在业务规模不大,也把自定义lookup和DNS缓存放进工具箱。真到线上出现连接超时或者DNS抖动事故时,再临时去加缓存,排障成本会高很多。
另外一个很实用的建议是,上线DNS缓存不要一把梭,分两步走:第一步先加监控和统计,跑两三天看看命中率和延迟指标;第二步再开启缓存写入,同时保留通过环境变量一键回退到默认解析的能力。改网络底层的代码,永远要把回滚路径想清楚。
