用 undici Agent 限制连接池并区分分层超时
问题场景
Node.js 服务调用下游时,常见两类“慢”:TCP 连接迟迟建不起来,或者连接已建立但响应头/响应体迟迟不返回。如果不加区分,只配一个总超时,排查时只能看到“请求失败”,也很难判断是网络不通、下游过载,还是下游处理慢。更麻烦的是,慢请求会长期占用 undici 连接池中的连接,后续请求排队,表现为“连接池被打满”。
undici 的 Agent 可以按 origin 维护连接池。通过 connections 限制并发连接数,通过连接建立超时、headersTimeout、bodyTimeout 把不同阶段拆开,可以让失败原因更清晰,并在超时后释放连接,避免慢下游无限期占用资源。
配置要点
connections:每个 origin 的连接上限。示例配合pipelining: 1,让一条连接同一时间只跑一个请求,便于观察池占用。- 连接建立超时:控制 TCP 连接建立阶段。不同 undici 版本可使用
connectTimeout,或写成connect: { timeout: ... };以所用版本文档为准。 headersTimeout:等待响应头的上限。它管的是“请求已发出,响应头还没来”,不是整个响应耗时。bodyTimeout:响应体接收阶段的超时。流式响应、大文件下载要单独评估。
建议让 headersTimeout 明显大于连接建立超时,避免连接还没建好就被误判为响应头超时。错误码通常可区分为 UND_ERR_CONNECT_TIMEOUT、UND_ERR_HEADERS_TIMEOUT、UND_ERR_BODY_TIMEOUT,但应以实际运行结果为准。
最小可运行示例
准备一个本地 HTTP 服务:/fast 立即返回;/slow-headers 延迟 3 秒才写响应头;/slow-body 先写响应头,再延迟 3 秒结束响应体。客户端把连接数限制为 2,并设置较短的连接、响应头、响应体超时。
const http = require('node:http');
const { Agent, request } = require('undici');
const agent = new Agent({
connections: 2,
pipelining: 1,
connectTimeout: 500, // 若版本不支持,可改为 connect: { timeout: 500 }
headersTimeout: 800,
bodyTimeout: 800,
});
const server = http.createServer((req, res) => {
if (req.url === '/fast') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ok');
return;
}
if (req.url.startsWith('/slow-headers')) {
const timer = setTimeout(() => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('late headers');
}, 3000);
res.on('close', () => clearTimeout(timer));
return;
}
if (req.url === '/slow-body') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.write('part-');
const timer = setTimeout(() => res.end('late body'), 3000);
res.on('close', () => clearTimeout(timer));
return;
}
res.writeHead(404);
res.end('not found');
});
async function call(base, path) {
const start = Date.now();
try {
const res = await request(base + path, { dispatcher: agent });
const text = await res.body.text();
console.log('[ok]', path, res.statusCode, text, `${Date.now() - start}ms`);
} catch (err) {
console.log('[err]', path, err.name, err.code, err.message, `${Date.now() - start}ms`);
}
}
(async () => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const slow = [];
for (let i = 0; i < 4; i++) {
slow.push(call(base, `/slow-headers?i=${i}`));
}
await new Promise((resolve) => setTimeout(resolve, 1200));
await call(base, '/fast');
await Promise.allSettled(slow);
await call(base, '/slow-body');
await agent.close();
await new Promise((resolve) => server.close(resolve));
})();
运行:
npm init -y
npm i undici
node demo.js
预期能看到前 4 个 /slow-headers 请求以 UND_ERR_HEADERS_TIMEOUT 失败(或相近错误码),随后 /fast 能完成,说明池中的连接在超时后被释放,排队请求继续执行;/slow-body 则用于观察响应体阶段超时。若本地环境或 undici 版本对 connectTimeout 字段处理不同,先改写成 connect: { timeout: 500 } 再运行。连接建立超时可在隔离测试环境里指向一个不可达地址验证,但不同网络可能直接返回不可达错误,不一定是 UND_ERR_CONNECT_TIMEOUT。
边界与限制
- 超时不是扩容手段。它只能让慢请求更早释放资源,不能让慢下游变快;如果所有请求都慢,吞吐仍受下游能力限制。
connections不是越大越好。过大可能放大下游压力,并增加本机文件描述符和内存开销;过小会造成排队。应结合下游承载能力和业务可接受的排队时间设置。headersTimeout过小会把“慢但正常”的响应判失败;bodyTimeout过小会影响流式响应和大文件下载;连接建立超时过小会在跨地域、弱网环境下误判。Agent的连接池通常按 origin 隔离。多下游、多域名场景要分别评估,不要把一个域名的结论直接套到另一个域名。- DNS、TLS 握手、代理等阶段的耗时是否计入某个超时,受 undici 版本和配置影响,需要用错误码和日志实测确认。
- 生产环境应复用同一个
Agent,避免每个请求新建实例;进程退出或配置变更时调用agent.close(),并处理未完成请求。
- 点赞
- 收藏
- 关注作者
评论(0)