请求对冲会在短暂延迟后,将同一个只读 JSON-RPC 调用并行发送到两个或更多独立端点,取第一个成功响应并取消其余请求。这能压缩在扇出工作负载中主导用户可感知卡顿的 p99 尾部延迟,同时保持中位数不变。对冲不同于重试(失败后顺序恢复)和故障转移(路由到健康端点)。它必须仅限于幂等读取方法,并且会使请求权重翻倍,因此是用额外流量换取尾部削减。本文解释其机制,提供可运行的 TypeScript 实现,并展示如何针对你自己的端点测量 p99 下降。
为什么尾部延迟主导 RPC 扇出中用户可感知的卡顿
一次页面加载或交易模拟往往会触发数十次 JSON-RPC 读取:余额检查、nonce 查询、合约调用和区块查询。如果每次调用的中位延迟为 50 毫秒,但 p99 为 800 毫秒,中位页面加载看起来没问题,但大约每百次调用就有一次会卡顿近一秒。当你扇出到 20 次调用时,至少有一次命中尾部的概率会急剧上升,因此用户可感知的体验由最慢的调用决定,而不是平均值。
这是 Google 关于尾部延迟的开创性工作以及 gRPC“请求对冲”设计文档中描述的经典大规模尾部问题。用户感受到的指标不是中位数,而是 p99。将中位数从 50 毫秒降到 40 毫秒几乎察觉不到,但将 p99 从 800 毫秒降到 200 毫秒会彻底改变感知响应速度。
具体到区块链 RPC,尾部延迟来自许多来源:垃圾回收暂停、mempool 峰值、归档节点上的磁盘 I/O、网络抖动以及提供商侧限流。这些是瞬时的,并且在不同提供商之间相互独立,而这正是使对冲有效的条件。关于延迟原因的更广泛讨论,请参阅 RPC 延迟:原因、测量与修复。
- 中位延迟掩盖了用户实际会注意到的最慢 1% 调用。
- 扇出会放大尾部概率:20 次调用各自处于 p99 时,出现一次慢响应的概率会高得多。
- 不同提供商之间独立的瞬时原因是请求对冲能够发挥作用的前提。
对冲 vs 重试 vs 故障转移:决策表
这三种技术经常被混为一谈,但它们解决不同问题,并且组合方式也不同。重试是顺序的:你等待失败或超时,然后再次尝试,这会增加等于超时时间的延迟。故障转移是路由:你根据健康检查发送到健康端点,但仍然要等待该端点的响应。对冲是并行的:在你知道第一个请求是否会变慢之前就发送重复请求,并取第一个成功响应。
下表总结了每种技术适用的情况。仅对重复执行无害的幂等读取使用对冲。对任何方法的瞬时错误使用重试,但要注意非幂等写入。对端点健康和区域路由使用故障转移。在实践中,生产客户端会结合三者:故障转移选择主端点,对冲为读取竞速一个辅助端点,重试处理显式错误。
- 对冲:延迟后并行重复;降低 p99;使请求权重翻倍;仅限只读。
- 重试:失败后顺序执行;从错误中恢复;增加超时延迟;任何带幂等键的方法。
- 故障转移:路由到健康端点;处理中断;不会降低慢但存活端点的尾部延迟。
对冲机制:定时器、重复、首个成功、取消
如 gRPC“请求对冲”文档所述,典型的对冲请求工作方式如下。客户端立即将请求发送到端点 A。它设置一个定时器,延迟根据该方法观测到的 p95 延迟推导。如果响应在定时器触发前到达,客户端返回它,不发送重复请求。如果定时器先触发,客户端将同一请求发送到端点 B。第一个成功响应获胜;另一个进行中的请求通过中止信号取消。
延迟至关重要。如果太低,两个请求几乎同时发出,几乎每次调用都要付出双倍成本。如果太高,对冲很少触发,p99 仍然很高。一个常见的起点是该方法的延迟分布在滚动窗口上测得的 p95。你还可以限制每个方法未完成的对冲数量,以约束成本。
取消必须是协作式的。在 Node.js 中,AbortController 将中止信号传播到 fetch,从而关闭底层套接字。对于基于 HTTP 的 JSON-RPC,这对读取是安全的,因为服务器可能仍会处理请求,但客户端停止等待。绝不要对冲像 eth_sendRawTransaction 这样的写入方法,因为两个重复请求都可能被接受,导致重复提交。
这里的“先等待再复制”语义参考了 gRPC 请求对冲指南 所记录的客户端对冲模型;即使你的传输层是普通 HTTP JSON-RPC 而非 gRPC,这份指南仍是定时器与取消设计的一手参考。
- 立即发送到 A;在约 p95 延迟处设置定时器。
- 定时器触发时,将重复请求发送到 B;以第一个成功为准。
- 用 AbortController 取消落后者;限制未完成的对冲数量。
- 仅限于幂等读取:eth_call、eth_getBalance、getLatestBlockhash、getAccountInfo。
请求预算后果:为什么对冲会使方法权重翻倍
每个对冲请求都会在你的提供商套餐上消耗额外的请求权重。如果你对冲 10% 的调用,有效请求量会增加 10%;如果你用低定时器激进地对冲,开销可能接近 100%。这很重要,因为大多数 RPC 提供商按计算单元或请求数计量,并且限流按方法执行。关于权重通常如何计算,请参阅 RPC 定价。
预算影响因方法而异。一个 gas 限制很大的重型 eth_call 可能比轻量的 eth_blockNumber 消耗多得多的单元。对冲重型调用会使大成本翻倍;对冲轻量调用会使小成本翻倍。优先对冲既频繁又容易命中尾部的方法,避免对冲已经很快或很便宜的方法。
还要考虑 429 响应。如果提供商返回 429 Too Many Requests,这是退避的信号,而不是对冲的信号。对冲 429 会重复违反限流,并可能加剧节流。将 429 视为不可对冲的错误,并将其路由到故障转移或退避逻辑。关于减少调用次数的批处理策略,请参阅 JSON-RPC 批量请求与最佳实践。
- 对冲增加的请求量与对冲率成正比;相应做好预算。
- 重型方法每次重复成本更高;要有选择地对冲。
- 绝不对冲 429;改为退避或故障转移。
有效对冲的提供商选择要求
对冲只有在端点真正独立时才有效。如果两个端点共享同一提供商、区域或上游基础设施,单次中断或拥塞事件会同时拖慢两者,对冲就没有收益。gRPC 文档强调,对冲假设故障域相互独立。对于区块链 RPC,这意味着使用不同提供商,或至少不同区域和节点类型。
选择端点时,确认它们不在同一负载均衡器或 CDN 边缘之后。一个快速检查是比较端点之间的服务器 IP 或 TLS 证书颁发者。如果它们匹配,端点可能共享命运。关于评估提供商的指导,请参阅 选择 RPC 提供商(RPC Assistant)。
OnFinality 的 API 服务 提供多区域端点,但如果你混合使用提供商,仍应确认独立性。目标是让一个端点的瞬时减速不影响另一个端点。如果你在以太坊上构建,请参阅 以太坊网络端点 了解可用选项。
- 使用不同提供商或区域,以确保独立的故障域。
- 检查服务器 IP 和 TLS 颁发者,以发现共享基础设施。
- 如果单一提供商的冗余端点共享命运,避免对它们进行对冲。
可运行的 TypeScript 示例:使用 AbortController 实现 Hedge-to-N
以下 Node.js 示例实现了一个对冲 JSON-RPC 客户端。它将请求发送到第一个端点,在可配置的 p95 延迟处设置定时器,然后将重复请求发送到其他端点。第一个成功响应会解决 promise;所有其他进行中的请求都会被中止。它还限制每次调用未完成的对冲数量。
代码使用原生 fetch API 和 AbortController,适用于 Node.js 18+。它假设只读方法,并且不会在 429 时重试。你可以根据环境调整端点列表和延迟。示例会记录每次尝试的延迟,以便你根据经验推导 p95 定时器。
import { performance } from 'node:perf_hooks';
interface HedgeOptions {
endpoints: string[];
method: string;
params: any[];
p95DelayMs: number;
maxHedges?: number;
}
async function hedgedRpc({ endpoints, method, params, p95DelayMs, maxHedges = 2 }: HedgeOptions): Promise<any> {
const controllers: AbortController[] = [];
const attempts: Promise<any>[] = [];
let settled = false;
const send = (url: string, index: number) => {
const controller = new AbortController();
controllers.push(controller);
const start = performance.now();
return fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: index, method, params }),
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 429) throw new Error('rate-limited');
const json = await res.json();
if (json.error) throw new Error(json.error.message);
const elapsed = performance.now() - start;
console.log(`endpoint ${index} succeeded in ${elapsed.toFixed(1)} ms`);
return json.result;
})
.catch((err) => {
if (err.name === 'AbortError') return new Promise(() => {});
throw err;
});
};
// Send to first endpoint immediately
attempts.push(send(endpoints[0], 0));
// Arm timer for hedge
const timer = setTimeout(() => {
if (settled) return;
const remaining = Math.min(maxHedges, endpoints.length - 1);
for (let i = 1; i <= remaining; i++) {
attempts.push(send(endpoints[i], i));
}
}, p95DelayMs);
try {
const result = await Promise.race(attempts);
settled = true;
clearTimeout(timer);
controllers.forEach((c) => c.abort());
return result;
} catch (err) {
clearTimeout(timer);
throw err;
}
}
// Example usage
const endpoints = [
'https://eth-mainnet.example-provider-a.com',
'https://eth-mainnet.example-provider-b.com',
];
hedgedRpc({
endpoints,
method: 'eth_getBalance',
params: ['0x0000000000000000000000000000000000000000', 'latest'],
p95DelayMs: 150,
}).then(console.log).catch(console.error);根据每次请求延迟推导 p95 定时器
对冲延迟应基于你自己测得的延迟,而不是猜测的常数。为每次 RPC 调用记录开始和结束时间戳,记录方法名、端点和结果,并在滚动窗口上计算百分位数。成功调用的 p95 是合理的初始定时器:这意味着只有当主端点比 95% 的正常调用更慢时,对冲才会触发。
你可以在进程内计算百分位数,或将指标导出到监控系统。关于指标和故障转移的更深入讨论,请参阅 RPC 节点监控、指标与故障转移。从保守的定时器(例如 p95)开始,在观察请求量和成本的同时逐渐降低。
下表是你自己测量的模板。用你端点的数据填充它;不要依赖通用数字。目标是看到启用对冲后 p99 下降,同时确认中位数和请求量按预期变化。
- 记录每次调用的方法、端点、开始时间、结束时间和成功/失败。
- 在滚动窗口(例如 5 分钟)上计算 p50、p95 和 p99。
- 将对冲定时器设为成功调用的 p95,然后谨慎下调。
- 启用对冲后监控请求量和 429 率。
结果表:针对你自己的端点测量 p99 下降
使用下表记录前后测量结果。运行受控负载测试,针对你的端点集重复发出相同的读取方法(例如 10,000 次调用),先不启用对冲,然后启用对冲。捕获 p50、p95、p99 和总请求数。预期结果是 p99 显著下降,而 p50 大致不变,请求数上升。
不要将任何具体延迟数字视为通用值。延迟因提供商、区域、方法和一天中的时间而异。这项练习的价值在于你在自己环境中观察到的相对变化。如果 p99 没有改善,请检查你的端点是否真正独立,以及对冲定时器是否太高。
关于延迟测量的更广泛概述,请参阅 RPC 延迟:原因、测量与修复。关于超时特定问题,请参阅 RPC 超时错误:原因与修复。
- 指标 | 未启用对冲 | 启用对冲 | 备注
- p50 延迟 | 填写 | 填写 | 应大致相似
- p95 延迟 | 填写 | 填写 | 可能略有改善
- p99 延迟 | 填写 | 填写 | 主要目标
- 总请求数 | 填写 | 填写 | 预计增加
- 429 率 | 填写 | 填写 | 监控节流情况
常见故障与故障排查
最危险的故障是对冲非幂等写入。如果你对冲 eth_sendRawTransaction,两个端点都可能接受交易,导致重复提交或 nonce 冲突。始终将对冲限制为只读方法。如果你需要写入的可靠性,请使用带幂等键的重试或交易管理器。
另一个常见错误是对冲同一提供商共享基础设施的端点。如果两个端点都经过同一负载均衡器,单次拥塞事件会同时拖慢两者,对冲就毫无用处。通过检查服务器 IP 和 TLS 颁发者来验证独立性。
定时器设置得太低会导致几乎每次调用都发出两个请求,使成本翻倍而没有有意义的尾部削减。定时器设置得太高意味着对冲很少触发。监控对冲触发率并进行调整。最后,绝不对冲 429 响应;那是退避的信号,而不是重复的信号。如果你看到 429,请降低请求速率或切换到限制更高的提供商。
- 绝不对冲 eth_sendRawTransaction 或任何写入方法。
- 验证端点独立性;避免共享基础设施。
- 调整定时器:太低会使成本翻倍,太高会错过尾部。
- 不要对冲 429;改为退避或故障转移。
- 取消已经提交的请求对读取无害,但对写入很危险。
请求对冲的局限性与权衡
对冲无法修复系统性错误或滞后的节点。如果一个端点持续返回过期数据或不正确结果,将其与正确端点竞速时,如果过期响应先到达,仍可能返回错误答案。对冲减少的是延迟方差,而不是正确性错误。对于正确性,请使用区块高度检查或法定人数读取。
对冲用额外请求量换取尾部削减。如果你的工作负载已经在预算内,并且 p99 可接受,对冲会增加成本而没有收益。当一个端点已经快速可靠时,它也没有意义;对冲很少触发,增加的复杂性可能不值得。
最后,对冲不能替代适当的容量规划。如果你的提供商正在限流你,对冲会加剧问题。将对冲作为众多工具之一,与故障转移、批处理和缓存一起使用。要获得整体视图,请参阅 OnFinality Learn 中心。
- 不能修复过期或不正确的数据;使用法定人数或高度检查。
- 增加请求量;只有在 p99 重要且预算允许时才值得。
- 如果一个端点已经很快,则无用;增加复杂性。
- 不能解决限流;可能加剧 429。
后续步骤:将对冲集成到你的 RPC 栈中
首先对你的 RPC 调用进行埋点,以测量每个方法的 p50、p95 和 p99。找出对用户可感知尾部延迟贡献最大的读取方法。然后使用上面的 TypeScript 示例为这些方法实现对冲,采用保守的定时器并限制未完成的对冲数量。
运行受控负载测试并填写结果表。比较启用前后的 p99,并监控请求量和 429 率。如果 p99 改善且成本不过高,就逐步推广。如果没有改善,请重新审视端点独立性和定时器调优。
对于生产环境,考虑将对冲与故障转移和批处理结合。查看 RPC 定价 以了解成本影响,并探索 API 服务 以获取多区域端点。关于提供商选择,请参阅 选择 RPC 提供商(RPC Assistant)。
- 先埋点;只对容易命中尾部的读取方法进行对冲。
- 使用保守的 p95 定时器并限制对冲数量。
- 在全面推广前测量 p99 改善和成本影响。
- 与故障转移和批处理结合,形成完整策略。