Node.js基于dns.resolve的自定义lookup并行请求耗时陡增问题排查
耗时上涨核心原因
首先明确两个DNS实现的本质差异,性能劣化是多个因素叠加导致的:
- 你对系统DNS缓存的认知存在偏差:Linux内核本身确实没有内置DNS缓存,但绝大多数服务器发行版默认会启动
systemd-resolved、nscd这类本地DNS缓存服务,默认dns.lookup调用操作系统getaddrinfo系统调用时,会自动命中这些本地缓存,单查询耗时基本在1ms以内。 - 两个实现的请求链路完全不同:
- 默认
dns.lookup:调用系统getaddrinfo,遵循系统完整解析逻辑,读取/etc/hosts、/etc/nsswitch.conf配置,走系统解析栈的并发控制、超时重试、请求合并逻辑,优先命中本地缓存服务,实际对外发的DNS请求数量远低于300。 - 你使用的
dns.resolve:是Node.js在用户态自实现的DNS客户端,完全绕过系统解析栈,直接向配置的DNS服务器发UDP请求,不读hosts、不走系统缓存、没有内置并发控制,也不会做请求合并。
- 默认
- 高并发下触发限流/资源耗尽:你一次性发起300个DNS请求,会触发两个典型问题:
- 上游DNS服务器(包括本地缓存服务)对单IP的并发请求数有阈值,超过阈值直接丢包,Node.js内置DNS客户端默认超时为5秒,丢包后会等待超时再重试,直接拉高总耗时
- 短时间创建大量UDP socket会耗尽系统临时端口范围,部分请求排队等待端口释放,进一步增加耗时
- 你最初对libuv线程池瓶颈的判断不符合实际场景:libuv默认线程池大小为4,只有当单批次并发请求超过1000、且大量查询需要跨网递归解析无缓存时,线程池才会成为瓶颈,300并发的量级根本不会触发线程池拥堵。
关于DNS服务器的问题
两者读取DNS配置的来源都是/etc/resolv.conf,但实际请求链路并不完全一致:
- 默认
dns.lookup走系统解析栈,会优先访问本地监听的DNS缓存服务(比如systemd-resolved默认监听127.0.0.53),由缓存服务负责递归查询、缓存、重试 dns.resolve会直接读取resolv.conf里的nameserver地址发起请求,即使配置的是本地缓存服务地址,也绕过了系统解析栈的请求优化逻辑,不会触发同节点的请求合并、并发控制能力。
根因定位与修复方案
- 首先修复你自定义lookup的代码bug,当前实现未处理err返回,DNS查询失败时会直接访问undefined的length属性抛出未捕获异常:
const dns = require('dns'); const customLookup = (hostname, _options, cb) => { // 明确指定查询IPv4地址,减少不必要的解析开销 dns.resolve4(hostname, (err, records) => { if (err) return cb(err); if (!records?.length) return cb(new Error(`Resolve ${hostname} failed: no A record`)); // 随机选取IP,避免固定请求第一个节点 const targetIp = records[Math.floor(Math.random() * records.length)]; cb(null, targetIp, 4); }); };
- 做对照测试验证根因:
- 执行
resolvectl statistics或nscd -g查看本地DNS缓存服务的命中率,确认默认lookup是否大量命中本地缓存 - 给DNS查询加并发控制,把同时进行的DNS请求数限制在10-20,如果总耗时回落至1.5秒以内,即可确认是并发过高触发了DNS服务器限流或本地端口耗尽
- 执行
- 优化建议:
- 300并发的场景下默认lookup已经能达到1.4秒的耗时,完全没必要替换为自定义resolve实现,性能瓶颈不在libuv线程池
- 如果确实要绕过系统解析栈,需要给dns.resolve加并发控制,同时在业务层加一层带TTL的内存缓存,性能会远高于默认实现
内容的提问来源于stack exchange,提问作者Mary123
相关产品推荐
相关产品推荐

