先说个我实际遇到过的场景。有段时间我在维护一个高并发的网关服务,压测的时候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变更,你的服务会一直连老地址,很长一段时间不可用。我的实践中,maxCacheTtl一般设在300到600秒之间,既保证缓存效率,又不会让失效收敛太慢。
还有一类情况要兜底:如果TTL返回0,或者解析记录里TTL缺失,那就不能无限期缓存,我会给一个默认保底值,通常30秒到60秒。这样即使碰到异常数据,也能在1分钟内自愈。
2.3 并发穿透:同一时刻几百个请求同时解析同一个域名
缓存有一个经典问题:缓存刚过期,瞬间来了100个请求,它们全部发现缓存miss,然后一起发起真实DNS查询。这样一来,一次过期引发的DB压力,在DNS场景下就是一次DNS服务器压力。虽然DNS服务器一般扛得住,但高并发下这依然会造成延迟尖刺。
解决手段叫请求合并(request coalescing),也叫single-flight。核心思想是:同一时刻对同一个key只发起一次真实查询,其他并发请求都复用这一个Promise。
具体实现可以这样:用一个Map记录正在进行的查询。缓存miss时先检查这个Map,如果已经有人在查了,就直接把那个查询的Promise返回给调用方;如果没人查,就自己发起查询,并把Promise放进Map。查询结束后,不管是成功还是失败,都要从Map里删掉,否则下次永远拿不到新结果。
2.4 过期策略:被动过期、主动刷新与容量上限
DNS缓存不能只靠"请求来了发现过期才删",因为如果某个域名请求频率很低,它的过期记录会一直留在Map里,时间长了就变成内存泄漏。所以要做定期清理:启动一个定时器,每隔一两分钟扫一遍缓存,把过期的key删掉。
但如果只是定时删除,还是有一个问题:热点域名在过期瞬间,还是会经历一次真实查询。要想彻底平滑,就得做主动刷新。具体做法是:缓存记录里除了保存IP列表和过期时间,还保存一个"刷新时间"。当收到请求时,如果发现已经过了刷新时间(比如TTL的80%),就异步发起一次DNS解析,同时仍然把旧的IP列表返回给调用方。这样用户无感知,流量也永远不需要等DNS解析。
缓存容量也要限制。Map无限增长在低峰期不显眼,但要是服务里域名特别多(比如缓存了所有上游节点的域名),迟早撑爆内存。我通常设置一个上限,比如一万条,超了就把过期的一批删掉,再不行就删最早过期的。
3. 一个可以直接抄的DNS缓存实现
理论讲完,上代码。下面的实现我用了很长时间,线上跑过不少流量,不敢说没有bug,但基础设计是经过验证的。你可以直接复制使用,然后根据自己的场景微调。
3.1 DnsCache核心代码
javascript复制const dns = require('dns');
const { EventEmitter } = require('events');
class DnsCache extends EventEmitter {
constructor(options = {}) {
super();
this.cache = new Map(); // 缓存主体
this.inflight = new Map(); // 正在解析中的Promise合并表
this.defaultTtl = options.defaultTtl || 60; // 记录没有TTL时兜底
this.maxTtl = options.maxTtl || 300; // 最大缓存时间,防止IP变更长时间不生效
this.refreshRate = options.refreshRate || 0.8; // 达到TTL的80%时后台刷新
this.maxSize = options.maxSize || 10000; // 缓存最大条数
this._cleanTimer = setInterval(() => this._cleanup(), 60 * 1000);
// 定时器不退出会导致进程挂住,配合测试和生命周期使用
this._cleanTimer.unref();
}
// 主要入口,family字段:4或6
async lookup(hostname, family = 4) {
const key = `${family}:${hostname}`;
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
// 到了后台刷新时间就异步刷新,不等结果
if (Date.now() > cached.refreshAt) {
this._refresh(key, hostname, family).catch(() => {});
}
return cached.addresses;
}
// 并发合并:同一个key同时只允许一个真实查询
if (this.inflight.has(key)) {
return this.inflight.get(key);
}
const promise = this._resolve(hostname, family)
.then(({ addresses, ttl }) => {
const finalTtl = ttl > 0 ? Math.min(ttl, this.maxTtl) : this.defaultTtl;
this.cache.set(key, {
addresses,
expiresAt: Date.now() + finalTtl * 1000,
refreshAt: Date.now() + finalTtl * 1000 * this.refreshRate,
});
// 超出容量时清掉过期项,再删最老的
if (this.cache.size > this.maxSize) {
this._cleanup();
}
return addresses;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return promise;
}
_resolve(hostname, family) {
return new Promise((resolve, reject) => {
const method = family === 6 ? dns.promises.resolve6 : dns.promises.resolve4;
method(hostname, { ttl: true })
.then(records => {
if (!records || records.length === 0) {
reject(new Error(`ENODATA: no ${family} records for ${hostname}`));
return;
}
// 把TTL取最小值,作为这条记录的保守过期时间
const ttl = Math.min(...records.map(r => r.ttl || 0));
const addresses = records.map(r => r.address);
resolve({ addresses, ttl });
})
.catch(reject);
});
}
_refresh(key, hostname, family) {
return this._resolve(hostname, family)
.then(({ addresses, ttl }) => {
const finalTtl = ttl > 0 ? Math.min(ttl, this.maxTtl) : this.defaultTtl;
this.cache.set(key, {
addresses,
expiresAt: Date.now() + finalTtl * 1000,
refreshAt: Date.now() + finalTtl * 1000 * this.refreshRate,
});
})
.catch(() => {
// 后台刷新失败不要紧,旧的还能继续用,到expiresAt再放弃
});
}
_cleanup() {
const now = Date.now();
for (const [key, value] of this.cache) {
if (value.expiresAt <= now) {
this.cache.delete(key);
}
}
// 如果删掉过期项后还是超容量,继续删最早过期的
if (this.cache.size > this.maxSize) {
const oldest = [...this.cache.entries()]
.sort((a, b) => a[1].expiresAt - b[1].expiresAt)[0];
if (oldest) this.cache.delete(oldest[0]);
}
}
}
module.exports = DnsCache;
这段代码有几个细节我要单独说明。_cleanTimer.unref()很多人会忽略,但如果不加,这个定时器会让Node.js进程在不需要的时候一直保持运行,在写命令行工具或测试时会很不方便。后台刷新失败的处理也有讲究:旧记录继续用,最多用到过期点,不会因为刷新失败就让线上请求突然变慢。
3.2 接入net.connect、http.Agent、axios和fetch
有了这个类,怎么接进项目?Node.js很多网络API都支持自定义lookup函数,这正好让缓存层无缝介入。
最底层的net.connect用法:
javascript复制const net = require('net');
const DnsCache = require('./dns-cache');
const dnsCache = new DnsCache();
function cachedLookup(hostname, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
const family = options.family || 4;
dnsCache.lookup(hostname, family)
.then(addresses => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
callback(null, address, family);
})
.catch(err => callback(err));
}
const socket = net.connect({
host: 'api.example.com',
port: 443,
lookup: cachedLookup,
});
http模块的Agent同样支持:
javascript复制const http = require('http');
const https = require('https');
const httpAgent = new http.Agent({ lookup: cachedLookup });
const httpsAgent = new https.Agent({ lookup: cachedLookup });
http.get('http://api.example.com/data', { agent: httpAgent }, res => {
// ...
});
如果你用axios,就通过httpAgent和httpsAgent传进去:
javascript复制const axios = require('axios');
const instance = axios.create({
httpAgent: new http.Agent({ keepAlive: true, lookup: cachedLookup }),
httpsAgent: new https.Agent({ keepAlive: true, lookup: cachedLookup }),
});
Node.js 18+内置的fetch基于undici,配置方式稍微不同:
javascript复制const { Agent } = require('undici');
const agent = new Agent({
connect: {
lookup: cachedLookup,
},
});
const response = await fetch('https://api.example.com/data', {
dispatcher: agent,
});
注意一个小坑:在cachedLookup里,我返回的是IP数组里的第一个地址。如果DNS记录里有多个IP(负载均衡场景),Node.js自带的连接逻辑默认也会逐个尝试,这里返回一个就够用了。但如果你想自己做多IP择优,可以看后面第5节。
3.3 实测参考值
我用模拟数据做个没有跑真实环境的估算,但量级是符合日常经验的。假设你的服务每秒要新建200个连接,每个连接做一次DNS查询,走dns.lookup大约耗时15ms。
- 不加缓存时:每秒200次真实查询,线程池4个线程,每个查询15ms,单线程每秒最多约66次,总共约264次/秒。也就是说,光解析就占了线程池75%的容量,其他文件读写的延迟会被明显拉高。
- 加缓存后:缓存命中率95%以上,每秒只有不到10次走真实查询,线程池几乎无感。你省下的不只是DMS查询本身的时间,还有线程池排队带来的连锁延迟。
从P99角度会更明显。缓存命中后,连接建立少了一次系统调用等待,整体握手耗时从30~50ms降到5ms以内。对高并发短连接场景,这是光速级的优化。
4. 踩坑记录:缓存上了线,问题才开始
任何缓存方案落到线上都会遇到"缓存一致性"的问题,DNS缓存尤其恶心,因为影响它失效的因素根本不在你手里:权威DNS服务器、CDN节点、运营商缓存,每一层都可能自作主张。下面这几个坑是我实际踩过并反复修正的。
4.1 迁移IP后流量打到老地址
最经典的故障。某天上游要迁移机房,提前通知了所有调用方"IP快变了"。结果服务切换后,老IP的机器已经下线,我的服务还在持续往老IP发请求,报错率飙升。
复盘原因:我当时的缓存TTL写死的是600秒,而DNS记录里的TTL是60秒。等于说,权威服务器已经告诉所有DNS缓存"10分钟后再来查",而我自作主张缓存了10分钟,导致新IP迟迟不被发现。
这个教训让我定下了规矩:TTL必须取自DNS记录本身,并且用Math.min兜底一个上限值。 你永远不应该把缓存的过期时间设置得比DNS记录自带的TTL还长,除非你明确知道为什么这么做。后来我在代码里加了maxTtl参数,默认300秒,就算上游记录的TTL写了86400,我也不会缓存超过5分钟。
4.2 cluster多进程缓存不共享
Node.js起多进程是常规操作,比如cluster模式,每个进程都有自己独立的缓存。这意味着什么?假设你有8个进程,同时收到一个域名解析请求,8个进程各自发现缓存miss,各自发起真实DNS查询——你的"缓存"并没有完全消除重复查询。
解决这个问题的方案有三种,按复杂度从低到高排列:
- 启动预热:让每个进程在启动阶段把核心域名预先解析一遍,先把缓存填上。这个方案最简单,但不能解决运行期新域名第一次出现的穿透。
- 本地缓存+共享缓存:进程内缓存负责热路径,外部缓存(比如Redis)负责兜底。进程内miss了再去查Redis,Redis会有一个跨进程的合并效果。
- 集中DNS代理:部署一个本地的DNS代理服务,所有进程都指向它,由它统一做缓存。这个方案最彻底,但需要额外部署组件。
大多数场景下,方案1已经能解决95%的问题。如果QPS真的高到需要方案2,那就得考虑分布式缓存了。
4.3 失败域名被"缓存假死"
这是我在实现早期遇到的最隐蔽的问题。一开始我的缓存逻辑是:缓存存的是"成功的解析结果",miss了就去查,查到就缓存,查不到就报错。看起来没问题,但有个死角——当DNS查询失败时,调用方会直接收到错误,这没问题;但如果上游DNS服务只是临时抖动,查询失败后没有缓存任何东西,下一次请求立刻又去查,如果抖动还没恢复,就会连续失败。
听起来合理对吧?但问题恰恰出在"立刻又去查"上。在高并发下,DNS抖动的那几秒里,可能同时涌入大量请求,它们全部走真实查询,全部失败。这不仅放大错误负载,还会加剧DNS服务器压力。有个老哥在群里分享过他的方案:失败结果也缓存,但TTL很短,比如5秒。 这样在DNS故障期间,其他请求会快速拿到"解析失败"结果,而不是再去打DNS服务器。
这个思路我后来也给缓存类加上了:
javascript复制async lookup(hostname, family = 4) {
const key = `${family}:${hostname}`;
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.addresses;
}
// 如果有失败缓存且在失败TTL内,直接抛错,避免打爆DNS
if (cached && cached.failed && Date.now() < cached.failedAt) {
const err = new Error(`DNS lookup failed for ${hostname}`);
err.code = cached.errorCode || 'ENOTFOUND';
throw err;
}
// ... 正常查询逻辑,失败时记录failedAt
}
当然,这个策略要谨慎使用。如果域名本来就不存在,你缓存失败结果没问题;如果域名存在但DNS临时抖动,失败缓存会让你在这几秒内所有请求都失败,而不是有的请求碰运气成功。所以失败TTL一定不能长,5到10秒是合理区间。
4.4 测了缓存命中率,才发现并发低峰期全是穿透
给缓存加监控指标是很重要的,不然后面这些坑你根本发现不了。我加了一个简单的计数器,统计每秒钟命中次数、穿透次数、过期刷新次数。上线一天后看数据,发现穿透次数在凌晨特别高——因为低峰期几乎没有请求,缓存全部自然过期,等早上流量起来,第一批请求全是穿透。
这个现象本身不可怕,真正的问题是:如果低峰期积累了几百个不同域名的过期缓存,早上高峰期到来时会同时刷新,等于人为制造了一波DNS查询高峰。所以后来我加了后台预热任务:定时把最近一小时内有请求的核心域名重新拉一遍,不等到过期才刷新。这样缓存永远不会全部清空,穿透率被压得很低。
监控指标里还有一个关键项:缓存记录的平均过期剩余时间。如果这个数字一直在低位徘徊,说明你的域名TTL很短,或者缓存策略太保守,可以适当调高maxTtl。
5. 还有几个可以玩的方向
加了缓存之后,解析延迟问题算是解决了。但既然手上有了一整套DNS缓存模块,后面还有很多可以顺手做的增强。
5.1 启动预热
服务启动时,把配置文件里列出的上游域名全部解析一遍。这一步的价值在于:规避"启动即穿透"的问题。不然服务刚上线,第一波流量还没进来,缓存是空的,所有请求都得走一次真实解析,造成启动阶段的高延迟。
预热代码很简单,在DnsCache类里加一个方法:
javascript复制async warmup(hostnames, family = 4) {
await Promise.allSettled(
hostnames.map(h => this.lookup(h, family))
);
}
5.2 多IP择优,顺带做一个应用层"DNS优选"
dns.resolve4返回的是多个IP。默认情况下,我上面的实现取了第一个。但第一个IP不代表最优——比如同一个域名解析出两个机房IP,你希望请求落到延迟更低的那一边。
一种做法是:缓存记录里保存每个IP的连接耗时,定期用TCP探测(TCP连接比HTTP快多了,只握手不发数据)测一下每个IP的RTT,把最快的排在前面。这就成了应用层的"DNS优选"。不过要注意,探测本身就是一种开销,不要每请求都做,最好是每30秒或者每分钟测一次。
另一种更简单的方案是:保留多个IP,每次随机选一个。这样至少能让流量在不同IP间分散开,避免全部打在同一个IP上。
5.3 外部缓存与分布式场景
如果服务规模上升到多机部署,每台机器的本地缓存依然会有重复解析。这时候可以引入外部缓存,比如Redis。流程是:进程内缓存miss后,先去Redis查,Redis也没有再去DNS服务器查,查完回填到Redis和本地。
但引入Redis也有代价:一次额外的网络RTT。如果DNS解析本身只要10ms,Redis请求要2ms,多一层反而可能拖慢。所以我的建议是:先做本地缓存,只有当本地缓存命中率低于90%时,才考虑做分布式DNS缓存。 大多数业务连本地缓存这一层都没做好,没必要一上来就搞分布式。
最后说个个人体会。做这套DNS缓存,最大的难点其实不是写代码,而是理解DNS在操作系统里的那一层。dns.lookup和dns.resolve的区别,很多做了几年Node.js的人也没搞清楚,但恰恰是这层理解决定了你写的缓存是"能跑"还是"扛得住"。如果你正准备给自己的服务加DNS缓存,我建议先从最简单的本地Map版本开始,加上监控,看清命中率和穿透率,再一步步往上加主动刷新、失败缓存这些高级特性。缓存不是越多越好,而是要把每一层的作用和代价都算清楚。
