每次发起新的 JSON-RPC 调用时,如果打开全新的 TCP 和 TLS 连接,在发送请求的第一个字节之前就要付出多次往返开销。复用持久化的 HTTP/1.1 keep-alive 连接,或在单个 HTTP/2 连接上多路复用多个调用,可以消除大部分握手成本并稳定尾部延迟。HTTP/1.1 需要套接字池来处理并发,而 HTTP/2 使用单个连接并受流数量限制。负载均衡器或 NAT 的空闲超时可能在使用过程中终止连接,导致虚假的 ECONNRESET 或 socket hang up 错误。本文解释这些机制,提供一个可运行的 Node.js 测量与调优示例,并展示如何针对你自己的端点验证改进效果。
每次请求的成本模型:DNS、TCP、TLS,然后才是 JSON-RPC
通过 HTTPS 发起的 JSON-RPC 调用并不是单一的网络事件。在发送 HTTP 请求行之前,客户端必须解析主机名(DNS)、完成 TCP 三次握手并协商 TLS。这些步骤每一步都至少增加一次到服务器的往返,TLS 1.3 通常在 TCP 之后增加一次往返,而 TLS 1.2 增加两次。只有在安全通道建立之后,客户端才会发送包含 JSON-RPC 负载的 HTTP POST。
当连接被复用时,DNS 查询、TCP 握手和 TLS 协商会被完全跳过。客户端立即在现有套接字上发送请求。这就是为什么连接复用是 RPC 客户端中收益最高的延迟优化之一:它消除了与被调用方法无关的固定每次调用开销。
HTTP/1.1 规范(RFC 9112,已取代 RFC 7230)将持久连接定义为默认行为,RFC 9110 描述了连接管理语义。HTTP/2(RFC 9113)更进一步,在单个 TCP 连接上多路复用多个请求/响应流。这两种机制的存在都是为了摊销连接建立成本。
- DNS 解析:通常会被缓存,但冷启动客户端每遇到一个新主机名就要付出一次。
- TCP 握手:一次往返(SYN、SYN-ACK、ACK)。
- TLS 握手:TLS 1.3 为一次往返,TLS 1.2 为两次,外加证书验证。
- HTTP 请求/响应:实际的 JSON-RPC 调用,这才是你想测量的部分。
- 复用连接:从后续每次调用的关键路径中移除 DNS、TCP 和 TLS。
HTTP/1.1 keep-alive:每个连接一次请求,因此需要连接池
HTTP/1.1 keep-alive 允许单个 TCP 连接承载多个顺序的请求/响应对。然而,HTTP/1.1 不支持多路复用:一个连接上同一时间只能有一个请求在途。如果在第一个响应到达之前发送第二个请求,它必须等待,造成队头阻塞。为了实现并发,客户端维护一个套接字池,每个套接字一次处理一个请求。
连接池大小(通常称为 maxSockets)决定了在不排队的情况下可以发起多少并发 RPC 调用。如果你的应用发送 50 个并发调用,但连接池只有 10 个套接字,那么 40 个调用要等待空闲套接字。即使服务器很快,这段等待时间也会表现为延迟,并抬高 p95 和 p99。
Keep-alive 还取决于两端都同意保持连接打开。服务器或中间设备可能发送 Connection: close 头,或者只是在超时后关闭空闲连接。客户端必须优雅地处理这种情况,在需要时打开新连接,但频繁重连会重新引入握手成本。
- 每个连接一个在途请求;并发需要多个套接字。
- 连接池大小必须匹配预期的并发请求量,以避免排队。
- 空闲连接可能被服务器或负载均衡器关闭;客户端应检测并替换它们。
- HTTP/1.1 keep-alive 支持广泛,但在高并发下效率低于 HTTP/2。
HTTP/2 多路复用:单连接承载多个流,但有上限
HTTP/2 在单个 TCP 连接上多路复用多个请求/响应流。这消除了为并发而维护套接字池的需要:客户端可以在一个连接上并发发送多个 JSON-RPC 调用,响应交错到达。这减少了连接管理开销,并在负载下改善延迟,因为没有连接池排队。
然而,HTTP/2 并非没有限制。服务器会通告 SETTINGS_MAX_CONCURRENT_STREAMS,限制同时可以激活的流数量。如果超过该限制,额外的流会被客户端排队。此外,单个 TCP 连接是单一故障域:如果它断开,所有在途流都会失败。一些客户端通过维护少量 HTTP/2 连接来缓解这个问题,但这又引入了一些连接池的复杂性。
HTTP/2 在连接和流两个级别都有流量控制。如果流量控制窗口耗尽,慢速消费者或大响应可能会阻塞其他流。对于典型的 JSON-RPC 调用(体积很小),这很少成为问题,但如果你获取大负载,就值得监控。
- 一个连接承载多个并发流;并发不需要按套接字建池。
- SETTINGS_MAX_CONCURRENT_STREAMS 限制活动流数量;超出的流会排队。
- 单个连接是单点故障;考虑使用少量连接以实现冗余。
- 如果窗口耗尽,流量控制可能引入队头阻塞。
空闲超时与虚假重置:为什么 ECONNRESET 会在使用中发生
负载均衡器、NAT 网关和提供商边缘代理通常会在固定超时后关闭空闲的 TCP 连接(常见为 30–120 秒,但具体因提供商而异,并会记录/因提供商而异)。如果你的客户端认为连接仍然打开,并在中间设备关闭它的同时发送请求,请求就会以 ECONNRESET 或 socket hang up 失败。这不是提供商故障;而是你的发送与中间设备空闲超时之间的竞态。
TCP keep-alive 可以通过发送周期性探测来保持连接存活,但 TCP keep-alive 间隔通常太长(许多系统默认为 2 小时),必须调优。应用层心跳(例如轻量级 JSON-RPC 调用,如 eth_blockNumber 或 getHealth)更可靠,因为它们产生实际流量,重置所有中间设备上的空闲计时器。
对于 HTTP/2,服务器可能发送 GOAWAY 帧来优雅地关闭连接。客户端应通过完成在途流并为后续请求打开新连接来处理 GOAWAY。忽略 GOAWAY 会导致请求失败。
- 空闲超时在负载均衡器和 NAT 上很常见;它们会在不通知客户端的情况下关闭连接。
- TCP keep-alive 有帮助,但默认间隔通常太长;请调优或使用应用心跳。
- HTTP/2 GOAWAY 表示连接关闭;客户端必须重新连接。
- ECONNRESET 通常是空闲超时的症状,而不是提供商故障。
Keep-alive 与 WebSocket 订阅:空闲是敌人
WebSocket 订阅(例如 Solana accountSubscribe 或 Ethereum eth_subscribe)使用按设计处于空闲状态的长连接:服务器只在事件发生时推送数据。这使它们容易受到空闲超时的影响。与请求/响应式 HTTP 可以简单重试不同,订阅断开意味着在客户端重新连接并重新订阅之前会错过事件。
为了保持订阅存活,客户端应发送周期性 ping/pong 帧(WebSocket 协议级)或应用层心跳。许多 RPC 提供商会记录 WebSocket 连接的最大空闲时间;超过它会导致断开连接。始终实现带指数退避的重连逻辑,并在重连后重新订阅。
在大多数 JSON-RPC 设置中,HTTP/2 不用于 WebSocket 订阅;WebSocket 在 HTTP 升级后运行在自己的 TCP 连接上。因此,HTTP/2 的连接复用策略不适用于订阅。请将它们视为独立的问题。
本文描述的多路复用与并发流行为由 HTTP/2 规范 RFC 9113 定义;请查阅该规范以了解你的客户端必须遵循的 SETTINGS_MAX_CONCURRENT_STREAMS 与流量控制语义。
- WebSocket 订阅是长寿命且空闲的;空闲超时会导致断开连接。
- 使用 ping/pong 或应用心跳保持连接存活。
- 实现重连和重新订阅逻辑;错过的事件不会被重放。
- HTTP/2 多路复用不适用于 WebSocket 订阅。
Serverless 与每次请求新建客户端的反模式
Serverless 函数(AWS Lambda、Cloudflare Workers 等)通常每次调用都创建新的 HTTP 客户端,这意味着每次 RPC 调用都要进行新的 TCP 和 TLS 握手。这每次调用浪费数十到数百毫秒,并抬高 p95 延迟。一些运行时允许通过全局变量在调用之间复用客户端,但冷启动仍然要付出握手成本。
同样,在长时间运行的服务器中为每个请求创建新的客户端实例也是一种反模式。客户端应实例化一次并复用。如果你的框架默认每个请求创建新客户端,请覆盖该行为。连接复用是客户端的责任;如果你一直用新的握手敲门,服务器也无能为力。
对于 Serverless,考虑使用支持 HTTP/2 和 keep-alive 的提供商,并配置客户端在执行环境内复用连接。如果冷启动不可避免,请测量握手成本并将其计入延迟预算。
- 每次请求新建客户端 = 每次请求新建握手 = 浪费延迟。
- 在长时间运行的服务器中跨请求复用客户端实例。
- Serverless 冷启动要付出握手成本;尽可能跨调用复用客户端。
- 测量握手成本以了解其对 p95 的影响。
速率限制:限制的不是连接数,而是方法权重
RPC 提供商通常根据请求数或方法权重进行速率限制,而不是根据 TCP 连接数。连接池化可以降低延迟,但不会提高你的速率限制。如果超过限制,无论使用多少连接,你都会收到 429 响应。反过来,使用更少的连接也不会减少你的速率限制消耗。
一些提供商可能对并发连接或流有单独限制,但这些通常比请求限制宽松得多。关键要点:为了延迟优化连接复用,但不要指望它能提高吞吐量上限。对于吞吐量,考虑批处理(参见 JSON-RPC 批量请求与最佳实践)或升级套餐(参见 RPC 定价)。
如果你触达速率限制,解决方案是减少请求数(批处理、缓存)或升级套餐,而不是打开更多连接。
- 速率限制基于请求数或方法权重,而不是连接数。
- 连接池化改善延迟,但不增加速率限制余量。
- 批处理和缓存减少请求数,有助于保持在限制之下。
- 查看提供商文档以了解具体的速率限制语义。
可运行的 Node.js 示例:带 keep-alive 和测量循环的 undici Agent
以下 Node.js 脚本使用 undici(Node.js fetch 使用的 HTTP/1.1 客户端)创建一个启用了 keep-alive、设置了 maxSockets 连接池和 keepAliveTimeout 的 Agent。然后它测量第一个请求(要付出握手成本)与后续请求(复用连接)的延迟。将 RPC_URL 替换为你的端点。
该脚本还演示了如何设置较短的 keepAliveTimeout 以避免空闲超时,以及如何测量差异。使用 Node.js 18+(内置 undici)运行它。输出将显示第一个请求明显比稳态请求耗时更长,说明握手成本。
const { Agent, request } = require('undici');
const RPC_URL = 'https://your-rpc-endpoint.example.com';
const agent = new Agent({
keepAliveTimeout: 10_000, // 10 seconds; tune to be less than intermediary idle timeout
keepAliveMaxTimeout: 60_000,
maxSockets: 20, // pool size for HTTP/1.1 concurrency
pipelining: 1, // HTTP/1.1 pipelining is generally not recommended
});
async function rpcCall(method, params = []) {
const start = process.hrtime.bigint();
const { statusCode, body } = await request(RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
dispatcher: agent,
});
const text = await body.text();
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1e6;
return { statusCode, ms, text };
}
(async () => {
console.log('First request (pays handshake):');
const first = await rpcCall('eth_blockNumber');
console.log(` ${first.ms.toFixed(2)} ms, status ${first.statusCode}`);
console.log('Steady-state requests (reuse connection):');
for (let i = 0; i < 5; i++) {
const res = await rpcCall('eth_blockNumber');
console.log(` ${res.ms.toFixed(2)} ms, status ${res.statusCode}`);
}
await agent.close();
})();测量方法:首次请求与稳态延迟对比表
为了量化连接复用在你端点上的收益,运行一个测量循环,记录第一个请求(冷连接)和随后 N 个请求(热连接)的延迟。所有调用使用相同的方法和参数。多次运行测试以考虑网络波动。下表是模板;用你自己的测量值填充它。
记录热请求的中位数和 p95,并与冷请求比较。差值就是握手成本。如果该差值相对于你的延迟预算很大,那么连接复用就是高优先级优化。还要在并发下测量不同 maxSockets 值的效果,找到排队消失的临界点。
- 针对你的端点运行上述脚本。
- 记录第一个请求延迟(冷)和随后 20 个请求(热)。
- 计算热请求的中位数和 p95。
- 在 50 个并发请求的负载下,用不同的 maxSockets 值(例如 5、10、20、50)重复。
- 将结果记录在下表这样的表格中。
| Run | Cold request (ms) | Warm median (ms) | Warm p95 (ms) | maxSockets | Concurrent requests |
|-----|-------------------|------------------|---------------|------------|---------------------|
| 1 | | | | 10 | 50 |
| 2 | | | | 20 | 50 |
| 3 | | | | 50 | 50 |调优指南:HTTP/1.1 连接池 vs HTTP/2 多路复用
HTTP/1.1 和 HTTP/2 的调优参数不同。对于 HTTP/1.1,关键是连接池大小(maxSockets)和 keepAliveTimeout。对于 HTTP/2,关键是连接数(通常为 1–2)以及对 SETTINGS_MAX_CONCURRENT_STREAMS 的处理。下表总结了需要调优的内容。
对于 HTTP/1.1,将 maxSockets 设置为至少等于预期的并发请求数。将 keepAliveTimeout 设置为短于中间设备空闲超时的值(例如 10–30 秒)。对于 HTTP/2,如果服务器支持足够的并发流,则使用单个连接;否则使用少量连接。监控 GOAWAY 帧并按需重连。
- HTTP/1.1:调优 maxSockets(连接池大小)和 keepAliveTimeout。
- HTTP/2:调优连接数并遵守 SETTINGS_MAX_CONCURRENT_STREAMS。
- 两者:为 ECONNRESET 和 GOAWAY 实现带退避的重试。
- 两者:如果空闲超时很激进,使用应用层心跳。
| Protocol | Key parameter | Typical starting value | Notes |
|----------|------------------------|------------------------|--------------------------------------------|
| HTTP/1.1 | maxSockets | 20–50 | Match expected concurrency |
| HTTP/1.1 | keepAliveTimeout | 10–30 s | Less than intermediary idle timeout |
| HTTP/2 | connections | 1–2 | One connection multiplexes many streams |
| HTTP/2 | maxConcurrentStreams | Server-advertised | Client should not exceed |故障排查:常见故障及诊断方法
如果你看到 ECONNRESET 或 socket hang up,首先检查它是否与空闲时段相关。如果发生在不活动一段时间之后,很可能是空闲超时。增加心跳频率或减少 keepAliveTimeout。如果发生在负载下,检查是否超过了 maxSockets 并发生排队,或者服务器是否因速率限制而关闭连接。
如果你意外禁用了 keep-alive(例如在框架默认设置中设置了 Connection: close),你将在每个请求上付出握手成本。检查你的客户端配置。如果你使用的负载均衡器会重置空闲连接,确保你的 keepAliveTimeout 短于均衡器的空闲超时。如果你认为一个连接比有界连接池提供更高吞吐量,请测量:在 HTTP/1.1 下,一个连接会串行化请求,因此并发必须使用连接池。
将 ECONNRESET 误读为提供商故障很常见。在升级问题之前,用简单的 curl 或复用连接的脚本验证。如果错误在连接复用后消失,那就是空闲超时,而不是故障。关于超时的更多内容,参见 RPC 超时错误:原因与修复。
- 空闲期后出现 ECONNRESET → 空闲超时;添加心跳或减少 keepAliveTimeout。
- 负载下出现 ECONNRESET → 检查 maxSockets 和速率限制。
- 框架默认禁用 keep-alive → 检查客户端配置。
- 负载均衡器重置空闲连接 → 将 keepAliveTimeout 设置为短于均衡器空闲超时。
- 假设一个连接足以应对并发 → 测量并为 HTTP/1.1 使用连接池。
局限、权衡以及何时重新审视
连接复用不是银弹。它减少握手延迟,但不减少服务器处理时间或你与提供商之间的网络延迟。如果你的 RPC 调用因服务器端负载而缓慢,连接复用无法解决。此外,单个 HTTP/2 连接是单点故障;如果它断开,所有在途请求都会失败。一些客户端通过多个连接来缓解,但这增加了复杂性。
空闲超时因提供商而异,并且可能变化。如果提供商调整其基础设施,今天有效的方法明天可能无效。监控你的错误率和延迟,并准备好调整 keepAliveTimeout 和心跳间隔。对于 WebSocket 订阅,无论 keep-alive 设置如何,重连逻辑都是必需的。
当你更换提供商、看到新的错误模式或并发情况发生变化时,重新审视你的配置。使用上述测量方法验证你的设置仍能带来预期收益。关于延迟原因的更广泛概述,参见 RPC 延迟:原因、测量与修复。
- 连接复用不减少服务器处理或网络延迟。
- 单个 HTTP/2 连接是单点故障。
- 空闲超时因提供商而异,并且可能变化。
- 更换提供商或并发情况变化时重新审视配置。
- 始终为 WebSocket 订阅实现重连。
下一步:将连接复用应用到你的技术栈
首先使用上述脚本测量你端点上的握手成本。如果冷请求明显慢于热请求,启用 keep-alive 并调优连接池大小。对于 HTTP/1.1,将 maxSockets 设置为匹配你的并发量。对于 HTTP/2,使用单个连接并遵守流限制。为 WebSocket 订阅实现心跳。
如果你使用 OnFinality,请查看 RPC 端点指南 了解端点特定细节。关于 Solana 特定的延迟考虑,参见 Solana RPC 延迟:测量与优化。关于 OnFinality RPC 服务的总体概述,请访问 OnFinality 学习中心 和 API 服务。
最后,请记住连接复用是更广泛性能策略的一部分。将其与批处理、缓存和适当的速率限制管理结合起来。持续测量、调优和监控。
- 测量你端点上的握手成本。
- 启用 keep-alive 并调优连接池大小或 HTTP/2 连接数。
- 为 WebSocket 订阅实现心跳。
- 与批处理和缓存结合以获得最佳效果。
- 随着使用情况变化进行监控和重新审视。