症状
EAI_AGAIN 的含义是“临时失败”,这解释了它为何从不像 EAI_NOTFOUND 那样孤立出现,而总是与负载相关:请求并发一升就成片闪现,流量回落后彻底消失。同主机上 getent hosts 在故障窗口内仍然成功,说明不是域名本身的问题。突发性、与流量尖峰吻合、系统工具正常——三者合起来指向 Node 进程内或容器解析路径的并发瓶颈。
诊断思路
分两层定位:并行 200 个 getent 测 OS 解析器,若同样退化则问题在解析器配置(如容器内单个 nameserver、默认 5 秒超时、musl 串行化);把 UV_THREADPOOL_SIZE 从默认 4 提到 16 重跑压测,失败率显著下降则证明 getaddrinfo 在与其他线程池工作(fs、crypto)排队。getaddrinfo 由 libuv 派发到线程池再调用系统解析器,两层各有各的饱和方式。
根因
getaddrinfo 在 libuv 线程池与系统解析器上执行。高并发下查询在少量线程后面排队,解析器本身也可能限速或超时。EAI_AGAIN 是“窗口内没拿到确定答案”的信号——并不存在持久的 DNS 故障,但请求确实失败了。没有任何 DNS 缓存时,每个出站请求都做一次全新解析,把尖峰直接灌进解析链路。
修复与验证
结构性修复是不再逐请求解析:给所有出站客户端套一层带 TTL 的查找缓存(http.Agent 的 lookup 选项接 cachedLookup,命中直接返回地址),启动时和尖峰前预热;线程池测量指向饱和就把 UV_THREADPOOL_SIZE 提到 16 作为部署默认值;容器里核对 resolv.conf 的 timeout 与 attempts,走本地缓存式 stub resolver(如 dnsmasq sidecar);Alpine 上确认 musl 串行化影响显著再考虑换 glibc 镜像。验证时重放之前制造 EAI_AGAIN 的尖峰负载:日志零 EAI_AGAIN;解析器侧查询速率保持平坦(缓存命中吸收尖峰);再做一次上游 IP 轮换演练,客户端必须在 TTL 窗口内拾取新地址,证明缓存没有钉死陈旧端点。缓存命中率导出为指标,持续观察解析压力趋势。
代码示例
Error: getaddrinfo EAI_AGAIN api.example.com
at GetAddrInfoReqWrap.oncomplete (node:dns:109:18)
import { LookupAddress } from 'node:dns';
const cache = new Map(); // host -> { addrs, expiresAt }
async function cachedLookup(hostname, options, callback) {
const hit = cache.get(hostname);
if (hit && hit.expiresAt > Date.now()) {
return callback(null, hit.addrs);
}
const addrs = await promisifiedLookup(hostname, { family: 4 });
cache.set(hostname, { addrs, expiresAt: Date.now() + 30_000 });
callback(null, addrs);
}
const agent = new http.Agent({ lookup: cachedLookup, keepAlive: true });