JSON-RPC 2.0 定义了严格的关联契约:携带 id 的请求必须收到恰好一个具有相同 id 的响应,而不带 id 的请求是通知,必须不收到任何响应。批量响应可能以任意顺序返回,因此假设响应顺序与请求顺序一致的客户端会静默地错误关联结果。正确的模式是分配唯一 id,维护 id 到 promise 的映射,并按 id 而非位置匹配响应。本文解释了该契约、id: null 边界情况、通知陷阱、以太坊订阅语义,以及一个可针对任何端点测试的可运行 Node.js 客户端。
JSON-RPC 2.0 id 关联契约
JSON-RPC 2.0 规范将请求定义为一个包含 jsonrpc、method、params 和可选 id 的对象。第 4 节规定,如果包含 id,服务器必须在响应中回复相同的值。第 5 节规定,不带 id 的请求是通知,服务器必须完全不回复。这就是完整的关联契约:id 是唯一将响应与其请求关联起来的字段。
由于 id 是唯一的关联键,它在给定客户端的在途请求中必须唯一。规范不要求 id 必须是整数,但建议客户端使用整数并避免小数部分。在实践中,单调递增的整数计数器是在无需协调的情况下保证唯一性的最简单方法。
这些规则的权威来源是 JSON-RPC 2.0 规范,它是协议语义的主要参考。以太坊 JSON-RPC 规范位于 ethereum.org,它在该基础契约之上叠加了方法名和参数形状,但没有改变 id 规则。
- 带 id 的请求:恰好一个响应,相同 id。
- 不带 id 的请求:通知,零响应。
- 批量:请求数组;响应可以任意顺序。
- 无效批量:单个错误对象,而非数组。
为什么简单客户端会错误匹配响应
手工批量处理中影响最大的 bug 是假设第 N 个响应属于第 N 个请求。规范第 6 节明确允许服务器以任意顺序返回批量响应,而真实服务器利用这种自由,因为它们并发处理请求。按索引将响应与请求配对的客户端会将错误的结果附加到错误的 promise 上,而且通常不会抛出错误。
这种失败是静默的,因为无论响应回答哪个请求,JSON-RPC 响应在结构上都是相同的。如果你在一个批量中发送 eth_blockNumber 和 eth_chainId,而服务器交换返回它们,你的代码会愉快地用链 ID 字符串解析区块号 promise。类型检查可能会捕获它,但许多方法返回重叠的类型(如十六进制字符串),因此损坏可能会传播。
修复方法是将 id 视为关联键,绝不依赖数组位置。这与 JSON-RPC 批量处理最佳实践 中描述的纪律相同,该文涵盖了何时批量处理;本文涵盖如何匹配返回的内容。
id 到 Promise 映射模式
正确的客户端模式是从 id 到待处理 promise 解析器的映射。当你发送请求时,分配下一个 id,将解析器存储在该 id 下,并将请求写入套接字或批量。当响应到达时,通过 response.id 查找解析器,解析它,并删除条目。顺序从不进入逻辑。
这种模式还为你提供了一个自然的位置来强制执行超时和检测未知 id 的响应,这通常表示服务器 bug 或来自未清理的先前连接的响应。将映射范围限定为单个连接可防止重连时的串扰。
对于重试,对重试的请求重用相同的 id,而不是发明新的 id。重用 id 保留了幂等性跟踪,并让服务器去重;权衡在 JSON-RPC 幂等性与重复请求 中有所涵盖。
// Minimal id-to-promise correlation client (Node.js 18+)
const pending = new Map();
let nextId = 1;
function send(ws, method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
});
}
function onMessage(raw) {
const msg = JSON.parse(raw);
const entry = pending.get(msg.id);
if (!entry) {
console.warn('response with unknown id', msg.id);
return;
}
pending.delete(msg.id);
if (msg.error) entry.reject(new Error(JSON.stringify(msg.error)));
else entry.resolve(msg.result);
}通知:无 id,无响应
不带 id 的请求是通知,规范禁止服务器回复。这对于即发即弃的写入(例如向接收器发送日志行或提交尽力而为的遥测事件)很有用,你不需要确认。客户端不得为通知分配 promise,因为永远不会有响应来解析它。
常见的陷阱是期望规范禁止的确认。如果你发送通知然后等待响应,你的队列将挂起直到超时触发,并且你可能将稍后的响应错误地归因于错误的请求。规则很简单:如果你需要结果,包含 id;如果不需要,省略它并且不要等待。
第二个陷阱是在同一批量中混合通知和请求,然后假设响应数组的长度与请求数组相同。它不会,因为通知不产生条目。在验证响应数组时,只计算携带 id 的请求。
id: null 边界情况
规范第 5 节保留 id: null 用于响应那些无法检测到 id 的请求,例如解析错误或无效请求。在这些情况下,服务器无法回显 id,因为它从未成功读取一个 id,所以它返回 null。这是协议级信号,不是应用程序值。
你不得将 null 用作正常的应用程序 id。如果你的客户端发送 id: null,你无法区分合法响应和因服务器无法解析你的请求而生成的错误响应。将 null 保留给服务器,并在客户端使用正整数。
一个具体示例:如果你发送格式错误的 JSON 正文,服务器返回一个带有 id: null 和错误代码 -32700(解析错误)的单个对象。你的客户端应将任何 id: null 的响应视为协议错误,而不是你发送的请求的答案。
// Server response to a malformed request body
{
"jsonrpc": "2.0",
"id": null,
"error": { "code": -32700, "message": "Parse error" }
}以太坊 eth_subscribe 与订阅通知
以太坊的 eth_subscribe 流程不同于普通的请求/响应对。subscribe 调用本身是一个带 id 的普通请求,并收到一个包含订阅 id 字符串的普通响应。之后,服务器推送 eth_subscription 通知,这些通知将订阅 id 携带在 params.subscription 中,而不是作为顶层请求 id。
这意味着你的关联逻辑需要两层。第一层通过请求 id 匹配 subscribe 响应。第二层通过 params.subscription 将传入的 eth_subscription 消息路由到为该订阅注册的处理程序。将订阅 id 视为请求 id 是行不通的,因为这些是通知,不是响应。
当你将订阅与普通调用组合在一个套接字上时,这种区别很重要。eth_subscribe 日志与 WebSocket 轮询 中的比较解释了推送模型何时值得额外的路由层。
// Subscribe response (normal id correlation)
{ "jsonrpc": "2.0", "id": 1, "result": "0x9cef478923ff08bf67fde6c64013158d" }
// Pushed notification (route by params.subscription, not id)
{
"jsonrpc": "2.0",
"method": "eth_subscription",
"params": {
"subscription": "0x9cef478923ff08bf67fde6c64013158d",
"result": { "number": "0x10d4f" }
}
}可运行的批量示例与乱序重新关联
下面的示例发送一个包含 id 1、2 和 3 的三元素批量,然后故意处理一个打乱的响应数组,以证明关联是按 id 而非按位置进行的。它打印每个请求及其匹配的响应,以便你可以明确看到关联。
针对任何支持 HTTP POST 的 JSON-RPC 端点运行它。将 URL 替换为你自己的端点;RPC 端点指南 解释了如何获取一个。代码仅使用 Node.js 标准库。
// node batch-correlate.mjs
const URL = process.env.RPC_URL || 'https://your-endpoint.example';
const requests = [
{ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] },
{ jsonrpc: '2.0', id: 2, method: 'eth_chainId', params: [] },
{ jsonrpc: '2.0', id: 3, method: 'net_version', params: [] }
];
const res = await fetch(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(requests)
});
const responses = await res.json();
// Simulate a server that returns responses out of order.
const shuffled = [...responses].reverse();
const byId = new Map(requests.map(r => [r.id, r]));
for (const resp of shuffled) {
const req = byId.get(resp.id);
console.log('request', req.method, 'id', req.id, '->', JSON.stringify(resp.result ?? resp.error));
}针对你自己的端点构建关联表
为了产生你的端点遵守 id 契约的可复现证据,运行上面的批量示例并将每个请求/响应对记录在表格中。随意重新运行;该表格是你自己的测量结果,不是供应商的声明。这将文档化的协议行为与观察到的提供商行为分开,后者可能有所不同。
每个请求填写一行。仅当响应 id 等于请求 id 时,匹配列才应为 yes。延迟列是该 id 从发送到接收的挂钟时间。错误列捕获任何 JSON-RPC 错误对象。如果任何行显示 matched = no,则你的客户端或端点违反了契约。
- 请求 id:你分配的整数。
- 方法:JSON-RPC 方法名。
- 响应 id:服务器回显的 id。
- 匹配:如果响应 id 等于请求 id,则为 yes。
- 延迟毫秒:测量的发送到接收时间。
- 错误:返回的任何错误对象,或没有。
连接多路复用、HTTP/2 与重试
当你在一个连接上多路复用许多调用时,响应确实可能乱序到达。HTTP/2 在单个 TCP 连接上交错流,而 WebSocket 帧按服务器写入的顺序到达,这不一定与你发送请求的顺序匹配。id 映射无需特殊逻辑即可处理这两种情况。
对于重试,重用原始请求的 id。每次重试发明新 id 会破坏幂等性跟踪,因为服务器看到两个不同的请求。安全重试行为的细节在 JSON-RPC 幂等性与重复请求 中。
连接重用和 keep-alive 设置会影响有多少在途请求共享一个套接字,进而影响服务器拥有的排序自由度。有关此图的传输侧,请参阅 RPC 连接重用与 HTTP/2 keep-alive。
id 与顺序失败的故障排除清单
当响应似乎不匹配或缺失时,使用此清单。每个项目都映射到规范的特定条款,因此你可以决定故障是在你的客户端还是在端点。
如果响应携带你从未发送的 id,将其视为协议错误并记录。如果一个批量元素没有响应,检查你是否意外省略了它的 id,使其成为通知。如果通知似乎挂起队列,你正在等待规范禁止的响应。如果你在一个批量中发送了重复 id,规范将其视为客户端错误,因此修复 id 分配器。
- 带有意外 id 的响应:记录并丢弃;检查过时的连接状态。
- 一个批量元素缺少响应:验证请求携带了 id。
- 通知挂起队列:移除 promise;通知永远不会解析。
- 一个批量中重复 id:客户端错误;强制执行唯一计数器。
- 单个错误对象而非数组:批量本身无效。
限制与权衡
规范没有限制批量大小,因此非常大的批量可能因协议之外的原因被提供商拒绝。当批量本身无效时,它也不强制每个批量一个响应:服务器返回一个错误对象,而不是数组。你的客户端必须处理数组形状和单对象形状。
提供商行为各不相同。某些端点可能作为实现细节按请求顺序返回响应,但你不能依赖它,因为规范允许任何顺序。文档化的行为是顺序不保证;任何更严格的行为都是提供商特定的,可能会改变。
最后,按 id 关联并不能解决应用程序级幂等性或副作用的顺序。两个具有不同 id 的请求仍可能以你未预期的顺序应用。对于写入方法,将 id 关联与上面链接的幂等性指南结合起来。
生产客户端的后续步骤
首先将任何基于索引的响应处理替换为 id 映射,然后将关联表测试添加到你的 CI 中,以便捕获回归。一旦关联稳固,使用 JSON-RPC 批量处理最佳实践 中的指导调整批量大小和连接重用。
如果你正在选择端点,请查看 RPC 定价 和 API 服务 概述,并浏览 OnFinality Learn 中心 以获取相关集成主题。对于以太坊特定方法,请参阅 以太坊网络页面。
将 JSON-RPC 2.0 规范 作为你的主要参考。当提供商的行为与其偏离时,将偏离视为已记录但可变,并使用上面的表格方法自行测试。