首页 / 帮助文档 / Node.js http.globalAgent的maxSockets限制调优

Node.js http.globalAgent的maxSockets限制调优

当你开始用 Node.js 写爬虫或者调用大量第三方 API 时,经常会发现一个奇怪的现象:明明带宽和 CPU 都还绰绰有余,但请求的吞吐量就是上不去,甚至出现莫名其妙的延迟和排队。问题的根源,往往不在你的业务逻辑,而在于 Node.js 的 HTTP 全局连接池——http.globalAgent 的 maxSockets 限制。

Node.js 默认的 maxSockets 值是 Infinity,这听起来好像没有限制,但实际情况是,操作系统和底层 libuv 库对单个进程能打开的文件描述符和并发 TCP 连接数都有硬性限制。当你的并发请求量瞬间飙升时,http.globalAgent 会无节制地创建 socket,迅速耗尽系统资源,导致后续请求被阻塞在 TCP 握手阶段,甚至触发 EMFILE 错误。所以,调优 maxSockets 不是简单地把它改大或改小,而是要在吞吐量、系统资源和目标服务器的承载能力之间找到平衡点。

http.globalAgent 的工作机制

Node.js 的 http 模块在发起请求时,默认会使用一个全局的 Agent 实例——http.globalAgent。这个 Agent 负责管理 HTTP 连接的复用和创建。每个 Agent 内部维护一个连接池,连接池里的 socket 分为空闲和正在使用两种状态。当你发起一个请求时,Agent 会先检查连接池里是否有空闲的、可复用的 socket 指向同一个目标主机和端口。如果有,就复用它;如果没有,且当前已创建的 socket 数量还没达到 maxSockets 的上限,就新建一个;如果达到了上限,这个请求就会被放入等待队列,直到有 socket 被释放。

这里的关键点在于,maxSockets 是针对单个主机(host:port)的限制,而不是全局所有请求的总限制。也就是说,如果你同时向 api.example.com 和 cdn.example.com 发起请求,每个主机都会独立计算自己的 socket 数量上限。默认值 Infinity 意味着对单个主机没有主动限制,完全依赖操作系统层面的约束。这在开发环境或者低并发场景下没什么问题,但在高并发生产环境中,就是一颗定时炸弹。

为什么不能无脑调大 maxSockets

很多人遇到请求阻塞的第一反应是把 maxSockets 调到几千甚至上万,以为这样就能解决并发瓶颈。但这样做往往会带来更严重的问题。首先,每个 TCP 连接都会占用文件描述符,Linux 系统默认的单进程文件描述符上限通常是 1024 或 4096,你需要在系统层面调高 ulimit,但这只是治标不治本。其次,大量的并发连接会触发目标服务器的限流策略,轻则返回 429 状态码,重则直接封禁你的 IP。更隐蔽的问题是,过多的连接会导致 TCP 拥塞控制算法频繁触发,整体吞吐量反而下降。

从实际测试数据来看,对于大多数 RESTful API 场景,单个主机保持 20 到 50 个并发连接已经能达到最优的吞吐量。超过这个数量后,边际收益急剧下降,甚至变为负值。这是因为 HTTP/1.1 的队头阻塞问题在连接数过多时会被放大,而且服务端通常也会限制单个 IP 的连接数。

如何精准调优 maxSockets

调优的第一步是弄清楚你当前的实际并发情况。可以通过监听 Agent 的统计信息来观测连接池的使用状况:

const http = require('http');

// 每隔5秒打印连接池状态
setInterval(() => {
  const agent = http.globalAgent;
  const keys = Object.keys(agent.sockets);
  let totalSockets = 0;
  let totalRequests = 0;
  
  keys.forEach(key => {
    totalSockets += agent.sockets[key].length;
    totalRequests += agent.requests[key] ? agent.requests[key].length : 0;
  });
  
  console.log(`活跃socket: ${totalSockets}, 排队请求: ${totalRequests}`);
}, 5000);

这段代码能让你直观地看到当前有多少 socket 正在使用,有多少请求在排队等待。如果排队请求数持续大于零,说明当前的 maxSockets 限制已经成为了瓶颈。但不要急着调大,先检查一下你的请求是否开启了 keep-alive,以及是否正确复用了连接。

为特定场景创建独立的 Agent 实例

直接修改 http.globalAgent 的 maxSockets 会影响所有使用全局 Agent 的请求,这通常不是最佳实践。更推荐的做法是,根据不同的业务场景创建独立的 Agent 实例,并为每个实例设置合理的 maxSockets。比如,你的应用既需要访问一个高并发的内部微服务,又需要访问一个限流严格的外部 API,就应该为它们分别配置不同的 Agent:

const http = require('http');

// 内部微服务,可以承受较高并发
const internalAgent = new http.Agent({
  maxSockets: 100,
  keepAlive: true,
  keepAliveMsecs: 30000
});

// 外部API,对方限流严格,主动控制并发
const externalAgent = new http.Agent({
  maxSockets: 10,
  keepAlive: true,
  keepAliveMsecs: 60000
});

// 使用独立Agent发起请求
http.get({
  hostname: 'internal-api.local',
  port: 80,
  path: '/data',
  agent: internalAgent
}, (res) => {
  // 处理响应
});

http.get({
  hostname: 'api.third-party.com',
  port: 443,
  path: '/v1/resource',
  agent: externalAgent
}, (res) => {
  // 处理响应
});

这种做法的好处是隔离性。外部 API 的慢响应不会阻塞内部微服务的连接池,反之亦然。同时,你还可以针对不同 Agent 设置不同的 keepAlive 超时时间,让连接复用策略更贴合实际场景。

keepAlive 与 maxSockets 的协同优化

maxSockets 调优不能脱离 keepAlive 单独进行。如果 keepAlive 没有开启,每个请求完成后 socket 都会被立即销毁,maxSockets 的限制就形同虚设,因为连接池里永远不会有空闲连接可供复用。开启 keepAlive 后,socket 在请求结束后会回到连接池的空闲队列中,等待下一次复用,这才让 maxSockets 的限制有了实际意义。

但 keepAlive 的超时时间设置也有讲究。keepAliveMsecs 参数控制一个空闲 socket 在被自动关闭前能存活多久。设置得太短,连接复用率低,频繁的 TCP 握手会消耗大量资源;设置得太长,又会占用过多的文件描述符,导致内存浪费。通常建议根据目标服务器的 keep-alive 超时策略来设置,一般 30 到 60 秒是比较合理的范围。如果你的应用有明显的请求波峰波谷,可以在波谷时适当缩短 keepAliveMsecs,主动释放空闲连接。

maxSockets 与 maxFreeSockets 的配合

Node.js 的 Agent 还有一个容易被忽略的参数:maxFreeSockets。它控制的是连接池中最多能保留多少个空闲 socket。默认值是 256,但在高并发场景下,这个值可能过大。想象一下,你的应用在高峰期创建了 200 个 socket,高峰期过后这些 socket 都变成了空闲状态,但它们仍然占用着文件描述符和内存。如果 maxFreeSockets 设置得过高,这些资源就得不到及时释放。

一个比较实用的策略是,让 maxFreeSockets 略低于 maxSockets,这样在连接使用率下降时,多余的 socket 会被逐步回收。例如,maxSockets 设置为 50,maxFreeSockets 可以设置为 20。这样既保证了高峰期的连接复用效率,又避免了低谷期的资源浪费。

const optimizedAgent = new http.Agent({
  maxSockets: 50,
  maxFreeSockets: 20,
  keepAlive: true,
  keepAliveMsecs: 30000,
  timeout: 10000
});
针对 HTTPS 的额外考量

HTTPS 请求使用的是 https.globalAgent,它的默认行为和 http.globalAgent 类似,但 SSL/TLS 握手带来的开销要大得多。如果你频繁向同一个 HTTPS 主机发起请求,复用 TLS 会话能显著降低延迟。Node.js 的 TLS 会话复用是自动进行的,但前提是你使用了同一个 Agent 实例,并且 keepAlive 已开启。因此,对于 HTTPS 场景,合理配置 maxSockets 和 keepAlive 的收益比 HTTP 更大。

另外,如果你的应用需要大量并发 HTTPS 请求,建议将 maxSockets 设置得比 HTTP 场景略低一些,因为 TLS 握手对 CPU 的消耗不容忽视。通常 HTTPS 的 maxSockets 设置为 20 到 30 就能达到较好的平衡。

监控与动态调整

调优不是一劳永逸的。业务量会变化,目标服务器的策略也会调整。你需要在生产环境中持续监控连接池的状态,并根据实际数据动态调整参数。除了前面提到的定时打印连接池状态,还可以将这些指标接入现有的监控系统,设置告警阈值。当排队请求数持续超过某个阈值时,自动触发扩容或者降级策略。

更进阶的做法是,在应用启动时通过压测确定当前环境下的最优 maxSockets 值。你可以编写一个简单的压测脚本,逐步增加并发数,观察吞吐量和延迟的变化曲线,找到那个拐点。这个拐点对应的并发数,就是你当前环境下的最优 maxSockets 设置。不同硬件配置、不同网络环境、不同目标服务的拐点都不一样,所以这个值没有银弹,必须实测。

常见误区与避坑指南

第一个误区是认为 maxSockets 越大越好。实际上,超过一定阈值后,再增加 maxSockets 只会增加上下文切换和 TCP 拥塞控制的负担,吞吐量不升反降。第二个误区是只调 maxSockets 不调 keepAlive,导致连接池形同虚设。第三个误区是忘记设置 timeout。如果一个请求因为网络问题长时间挂起,它会一直占用一个 socket,直到达到操作系统层面的超时。设置合理的 timeout 能让这些僵死连接及时释放,避免连接池被耗尽。

还有一个容易被忽视的细节是,Node.js 的 stream 管道如果没有正确关闭,会导致 socket 泄漏。即使你设置了 maxSockets,泄漏的 socket 也会绕过这个限制,最终拖垮整个进程。所以,务必确保每个响应流都被正确消费或销毁,尤其是在处理错误和超时逻辑时。

掌握了这些调优思路和实操方法,你就能让 Node.js 的 HTTP 连接池真正成为吞吐量的助推器,而不是性能瓶颈。关键在于理解底层机制,根据实际场景精细化配置,并建立持续的监控反馈闭环。