导读:本期聚焦于梦乃创作的《Node.js中如何利用dns.lookup缓存DNS查询结果提升性能?》,敬请观看详情。为什么每次HTTP请求都要重新解析一次域名,白白浪费几十毫秒?Node.js的dns.lookup底层依赖getaddrinfo,它本身不带回缓存,高频调用时开销相当明显。本文围绕这个问题展开,先分析dns.lookup与dns.resolve的本质区别,弄清为什么前者不能自建缓存机制,接着给出几种实用的缓存方案:手写LRU缓存、借助lookup-cache类库,以及在生产环境用Nginx或系统级nscd做前置缓存。文中还提供了完整可运行的代码示例,涵盖缓存过期策略、错误处理和并发去重等细节,帮助你把域名解析耗时降到最低,适合正在优化Node.js服务性能的开发者阅读参考。

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

Node.js中如何利用dns.lookup缓存DNS查询结果提升性能?

先弄清楚: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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260908/53033.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。