Node 20 下用 undici 连内网服务,连接池被我撑爆了

上周把一个老服务迁到 Node 20,它要往内网三个下游节点发请求,QPS 峰值大概 800,每个请求体 2KB 左右。压测到一半,下游报「too many connections」,本机 ss -s 一看 TIME_WAIT 两万多。当时用的是原生 fetch,没配任何 agent。

先是怀疑下游限流,抓包发现连接确实是我这边发起的,而且每个请求一条新连接。Node 20 的 fetch 底层是 undici,默认全局 dispatcher 其实有连接池,但它按 origin 分,而且默认 connections 是 null,也就是不限上限,高并发下会一路涨。我这个场景又都是短请求,连接还没复用就被下一波新请求挤满了。

做法很简单,给每个下游建独立的 Pool,明确连接数,再传给 fetch:

const { Pool, setGlobalDispatcher } = require('undici');

const pools = {
  a: new Pool('http://10.0.1.11:8080', { connections: 32, pipelining: 1 }),
  b: new Pool('http://10.0.1.12:8080', { connections: 32, pipelining: 1 }),
  c: new Pool('http://10.0.1.13:8080', { connections: 32, pipelining: 1 }),
};

async function call(node, path, body) {
  const res = await fetch(new URL(path, pools[node].origin), {
    method: 'POST',
    body: JSON.stringify(body),
    headers: { 'content-type': 'application/json' },
    dispatcher: pools[node],
  });
  if (!res.ok) throw new Error(`${node} ${res.status}`);
  return res.json();
}

关键是 dispatcher 这个参数,不传就落到全局 dispatcher 上,各下游混在一起,一个慢节点会把连接全占住。给每个下游单独 Pool 之后,慢的那个最多占 32 条,不会拖垮另外两个。

为什么是 32:下游单机 keep-alive 上限是 64,三台机器各 32 加起来正好压线,留点余量给健康检查。代价是并发超过 32 的请求会在 Pool 里排队,所以我把上游的接口超时从 3s 调到了 5s,排队时间得算进去。pipelining 保持 1,之前试过 10,下游有个老网关对 pipeline 支持不完整,偶发响应错位,这个不确定是不是普遍问题,但我不敢赌。

失效的地方:Pool 是按 origin 建的,路径不同不影响,但如果下游走域名且 DNS 轮询到多台,undici 只认 origin,所有请求会打到解析出的那一台上,这种场景得自己在前面加负载均衡。另外我用 AbortSignal.timeout 做超时,被中断的请求连接不会立刻回池,压测时看到 connections 短暂高于 32,这是正常的,别按这个数字去调小配置。

还有个坑是进程退出时 Pool 不会自动关,得在 SIGTERM 里手动 pool.close(),不然滚动发布时会挂住几十秒。

你们在内网多下游的场景里,是每个下游一个 Pool,还是共用全局 dispatcher 加 per-request 超时?我这边共用那版在慢节点上出过事,想看看别人怎么处理的。

0
赞同度 100% · 0 条评论 · 1 阅读

0 条评论

支持 Markdown · 回复会实时推送给对方

还没有评论,写下第一条。