多区域 RPC 故障转移结合了基于健康状态的故障转移、延迟感知路由和熔断器,使单个提供商、区域或节点故障只会增加延迟,而不会导致应用中断。其核心机制是一个客户端路由器,它持有多个独立端点,测量健康状态和往返时间(RTT),并将请求分发到最健康的端点,同时剔除故障端点。健康状态必须同时通过存活检查(eth_blockNumber/getHealth)和区块头滞后检查来验证,因为节点可能在线但数据陈旧。对正确性要求高的读取应固定承诺或最终性级别,新端点应通过影子/流量拆分方式切换引入。所有延迟和可用性数据均由读者自行测量或由提供商记录/因提供商而异;本文不主张 OnFinality 特定的区域或正常运行时间数据。
为什么单端点 RPC 在真实生产负载下会崩溃
单个 RPC 端点就是单点故障。当该端点的提供商、区域或节点性能下降时,你应用中的每个请求都会随之下降——钱包读取停滞、交易提交超时、索引器落后。多区域 RPC 故障转移的目标是让这种故障只增加延迟,而不会导致应用中断。
其机制是一个客户端路由器,它持有多个独立端点,并根据测量到的健康状态和往返时间(RTT)为每个请求选择一个端点。这与通用云和 API 架构中使用的模式相同:AWS 记录了多区域设计模式,Azure 记录了全局负载均衡和 Traffic Manager,两者都将基于健康状态和基于延迟的路由描述为一等概念。具体到 Web3 RPC,chainstack 工程参考文章“Plasma: RPC failover with eRPC”描述了一个位于多个 RPC 端点之前的故障转移层,按健康状态和延迟进行路由。
在设计之前,先确定哪种拓扑符合你的需求。单端点适合原型。带基于健康状态故障转移的主备模式是生产环境的最低要求。跨区域、基于延迟路由的双活模式适用于延迟敏感、高吞吐量的工作负载。一个持有多个独立端点并按测量到的健康状态和 RTT 进行选择的客户端路由器最为灵活,也是本文要构建的方案。
如果你还在选择提供商,请先阅读选择 RPC 提供商(RPC Assistant)和公共 RPC 端点与生产专用端点对比。如需具体链的示例,请参阅 Base 和 Base RPC 延迟:测量与优化。
- 单端点:最简单,无冗余,仅适用于原型。
- 主备:一个主端点,一个备用端点,故障时基于健康状态转移。
- 双活:多个区域同时服务流量,基于延迟路由。
- 客户端路由器:多个独立端点,按测量到的健康状态和 RTT 选择。
故障转移、负载均衡和对冲是不同的工具
故障转移对健康状态做出反应:当主端点未通过健康检查时,流量转移到备用端点。负载均衡为容量而分配:请求分散到多个端点,避免任何一个端点饱和。对冲为尾部延迟而竞争:同一请求发送到两个端点,先返回的响应胜出。生产系统通常结合使用这三种机制,因为它们解决不同的问题。
一个常见错误是将负载均衡当作故障转移。如果所有端点共享同一提供商或区域,轮询无济于事——它们会一起故障。反过来,当主端点健康但缓慢时,严格的主备故障转移也无济于事;你需要延迟感知路由或对冲来保护尾部延迟。
实用的组合是:基于健康状态的故障转移保障可用性,基于延迟的路由保障性能,可选的对冲保护最慢的百分位。RPC 节点监控、指标与故障转移一文涵盖了监控方面;本文侧重于路由和故障转移机制。
- 故障转移:对健康状态做出反应,故障时转移流量。
- 负载均衡:为容量而分配,避免饱和。
- 对冲:为尾部延迟而竞争,先返回的响应胜出。
- 三者结合:可用性 + 性能 + 尾部保护。
健康信号设计:被动结果与主动探测
健康信号有两种形式。被动健康观察真实请求结果:成功、错误、超时和延迟。主动健康按计划发送探测。被动方式成本低且反映真实流量;主动方式能在用户请求命中坏端点之前发现问题。两者都要用。
简单的存活探测是不够的。端点可能“在线”——它能响应 eth_blockNumber 或 getHealth——但数据陈旧,即其区块头落后于链尖。落后于链尖的节点能通过简单的存活检查,却返回陈旧状态,这是最危险的故障模式,因为它看起来是健康的。始终添加区块头滞后检查:将端点报告的区块号与参考值(另一个端点或已知良好来源)比较,将超过阈值的滞后视为不健康。
具体的探测集:用 eth_blockNumber 检查存活和区块头高度,在支持的地方使用 getHealth,并与池中观察到的最大高度进行区块头滞后比较。对于正确性要求高的读取,还要固定承诺或最终性级别(例如“finalized”或特定区块标签),这样稍微陈旧的端点也无法返回不同的答案。
RPC 延迟:原因、测量与修复一文解释了如何正确测量 RTT;在探测循环中复用该方法。
- 被动:观察真实请求的成功、错误、超时、延迟。
- 主动:按计划探测存活和区块头高度。
- 区块头滞后检查:将端点高度与池中最大值比较。
- 对正确性要求高的读取固定承诺/最终性级别。
验证独立性:同一提供商或区域不是冗余
同一提供商或同一区域的两个端点共享爆炸半径。如果该提供商发生故障或该区域发生网络事件,两个端点会一起失效。它们不是真正的冗余。独立性意味着不同的提供商、不同的区域,最好还有不同的底层基础设施。
一个实用的独立性检查:列出每个端点的提供商、区域和 ASN。如果两个端点共享其中任何一项,在规划时将它们视为同一个故障域。你的故障转移池应至少有两个独立故障域,最好有三个以支持基于延迟的路由。
这也是为什么持有多个独立端点的客户端路由器比单个托管负载均衡器更稳健:路由器可以强制独立性并直接测量健康状态。关于托管端点通常如何暴露,请参阅 API 服务。
本文描述的健康检查路由、熔断器与摘除行为与 Chainstack 在 Plasma:使用 eRPC 实现 RPC 故障转移 中记录的故障转移路由器模式一致;关于多区域拓扑的一般权衡,请参阅 AWS 多区域架构白皮书。两者均为概念参考,不代表背书,也不构成 OnFinality 的性能声明。
- 同一提供商或区域 = 共享爆炸半径,不是冗余。
- 独立性 = 不同的提供商、区域和基础设施。
- 规划至少两个独立故障域,最好三个。
- 客户端路由器可以强制独立性并测量健康状态。
基于延迟的路由机制与地理代理的注意事项
基于延迟的路由将每个请求发送到测量 RTT 最低的端点。其机制是一个小型探测循环,测量每个端点的 RTT,以及一个将 RTT 与健康状态结合的评分函数。这优于静态地理路由,因为地理只是真实 RTT 的粗略代理——网络路径、对等互联和拥塞会改变实际延迟。
Azure Traffic Manager 和 AWS Global Accelerator 都提供基于地理的路由,但文档将它们描述为粗略的路由方法。一个小型测量探测循环优于静态地理,因为它反映了请求时刻的真实路径。chainstack 的“Plasma: RPC failover with eRPC”参考文章描述了类似的 RPC 测量健康方法。
注意事项:从你的客户端测量 RTT 测量的是你客户端的路径,而不是每个用户的路径。如果你的用户分布在全球,请从边缘或代表性区域测量。对于大多数应用,从应用自身区域测量的客户端路由器就足够了,而且简单得多。
- 基于延迟的路由 = 测量 RTT 最低者胜出。
- 地理是粗略代理;测量 RTT 才是真实信号。
- 从应用所在区域或边缘测量,而不是单台笔记本电脑。
- 在评分函数中将 RTT 与健康状态结合。
熔断器与剔除:阈值、半开、迟滞
熔断器在连续错误达到阈值后剔除端点,然后定期允许半开试探以测试恢复。如果试探成功,端点返回池中;如果失败,熔断器保持打开。恢复迟滞(在完全重新进入前要求多次成功)可防止抖动。
过于激进的熔断器会抖动:它在单个瞬时错误后剔除,然后立即重新接纳,导致流量振荡。使用连续错误阈值(例如 3–5)、冷却期,以及要求成功的半开试探。具体数字由读者自行测量;根据你自己的错误特征进行调整。
剔除还应考虑区块头滞后:一个在线但陈旧的端点即使返回 HTTP 200 也应被剔除。将区块头滞后视为熔断器中的一等健康信号。
- 剔除前的连续错误阈值(例如 3–5)。
- 半开试探前的冷却期。
- 完全重新进入前要求成功的半开试探。
- 将区块头滞后视为一等健康信号。
链尖附近的状态一致性陷阱
不同端点,即使是同一链,也可能在链尖附近不一致。一个端点可能比另一个领先一两个区块,因此对“latest”的读取可能因哪个端点应答而返回不同状态。这不是 bug;这是分布式链尖的本质。
修复方法是为正确性要求高的读取固定承诺或最终性级别。例如,使用“finalized”或特定区块标签,并且不要跨池混合级别。如果你跨端点混合“latest”和“finalized”,同一逻辑查询可能得到不一致的答案。
对于非关键读取(仪表盘、估算),“latest”没问题,但要注意稍微陈旧的端点可能返回稍旧的区块头。健康探测中的区块头滞后检查正是将这种陈旧性限制在一定范围内的机制。
- 端点可能在链尖附近不一致;这是正常的。
- 对正确性要求高的读取固定承诺/最终性级别。
- 不要跨池混合级别。
- 区块头滞后检查限制非关键读取的陈旧性。
切换与流量拆分:安全地引入或替换端点
将新端点直接引入池中是有风险的。安全的模式是影子/流量拆分切换:双写到影子端点,将响应与当前主端点比较,然后转移一定百分比的流量。从小百分比开始,观察错误率和延迟,然后逐渐增加。
比较步骤很重要:对于同一请求,影子端点和主端点在相同承诺级别下是否返回相同结果?如果不是,在转移流量之前进行调查。这能捕获配置差异、链不匹配和陈旧节点。
一旦影子端点通过比较且小流量百分比健康,将其提升为全流量,并将旧端点降级为备用。这与通用 API 部署中使用的金丝雀模式相同,只是应用于 RPC 端点。
- 双写到影子端点,比较响应。
- 转移小百分比流量,观察错误率和延迟。
- 仅在比较通过后才提升为全流量。
- 将旧端点降级为备用,不要立即删除。
可运行的 TypeScript 路由器:健康探测、评分、熔断器、分发
以下 Node.js/TypeScript 示例实现了一个最小的多区域 RPC 路由器。它运行一个健康探测循环,测量每个端点的 RTT 和区块头滞后,对端点评分,维护熔断器状态,并将请求分发到最健康的端点,同时剔除故障端点。它仅使用内置 fetch,无外部依赖。
使用 Node 18+(具有全局 fetch)运行。将端点 URL 替换为你自己的独立端点。预期输出显示探测结果和示例请求选择的端点。
// rpc-router.ts — run with: npx tsx rpc-router.ts (Node 18+)
type Endpoint = {
url: string;
name: string;
rttMs: number;
head: number;
healthy: boolean;
consecutiveErrors: number;
breakerOpen: boolean;
lastProbe: number;
};
const endpoints: Endpoint[] = [
{ url: 'https://rpc-a.example.com', name: 'region-a', rttMs: 0, head: 0, healthy: true, consecutiveErrors: 0, breakerOpen: false, lastProbe: 0 },
{ url: 'https://rpc-b.example.com', name: 'region-b', rttMs: 0, head: 0, healthy: true, consecutiveErrors: 0, breakerOpen: false, lastProbe: 0 },
{ url: 'https://rpc-c.example.com', name: 'region-c', rttMs: 0, head: 0, healthy: true, consecutiveErrors: 0, breakerOpen: false, lastProbe: 0 },
];
const ERROR_THRESHOLD = 3;
const HEAD_LAG_THRESHOLD = 5;
const PROBE_INTERVAL_MS = 5000;
async function rpc(url: string, method: string, params: any[] = []): Promise<any> {
const start = Date.now();
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const rtt = Date.now() - start;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(json.error.message);
return { result: json.result, rtt };
}
async function probe(e: Endpoint): Promise<void> {
try {
const { result, rtt } = await rpc(e.url, 'eth_blockNumber');
e.rttMs = rtt;
e.head = parseInt(result, 16);
e.healthy = true;
e.consecutiveErrors = 0;
e.breakerOpen = false;
} catch (err) {
e.consecutiveErrors += 1;
e.healthy = false;
if (e.consecutiveErrors >= ERROR_THRESHOLD) e.breakerOpen = true;
}
e.lastProbe = Date.now();
}
function score(e: Endpoint, maxHead: number): number {
if (e.breakerOpen || !e.healthy) return -1;
const lag = maxHead - e.head;
if (lag > HEAD_LAG_THRESHOLD) return -1;
return 1000 - e.rttMs - lag * 10;
}
function pick(): Endpoint | null {
const maxHead = Math.max(...endpoints.map((e) => e.head));
const ranked = endpoints
.map((e) => ({ e, s: score(e, maxHead) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s);
return ranked.length ? ranked[0].e : null;
}
async function dispatch(method: string, params: any[] = []): Promise<any> {
const maxHead = Math.max(...endpoints.map((e) => e.head));
const ranked = endpoints
.map((e) => ({ e, s: score(e, maxHead) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s);
for (const { e } of ranked) {
try {
const { result } = await rpc(e.url, method, params);
return { endpoint: e.name, result };
} catch {
e.consecutiveErrors += 1;
if (e.consecutiveErrors >= ERROR_THRESHOLD) e.breakerOpen = true;
}
}
throw new Error('all endpoints failed');
}
async function main() {
setInterval(() => endpoints.forEach(probe), PROBE_INTERVAL_MS);
await Promise.all(endpoints.map(probe));
console.log('probe results:', endpoints.map((e) => ({ name: e.name, rttMs: e.rttMs, head: e.head, healthy: e.healthy, breakerOpen: e.breakerOpen })));
const chosen = pick();
console.log('chosen endpoint:', chosen?.name ?? 'none');
const out = await dispatch('eth_blockNumber');
console.log('dispatch result:', out);
}
main().catch(console.error);结果表:测量你自己的端点
使用下表记录你自己的测量结果。不要依赖供应商公布的数字;从你的应用所在区域进行测量。运行探测循环至少 10 分钟,记录每个端点的中位数和 p95 RTT、区块头滞后和错误率。
“记录/因提供商而异”的说明适用于任何提供商公布的延迟或正常运行时间数据。你测量到的值才是路由决策的关键。
- 端点名称 | 提供商 | 区域 | 中位 RTT (ms) | p95 RTT (ms) | 区块头滞后(区块) | 错误率 (%) | 备注
- region-a | provider-1 | us-east | (测量值) | (测量值) | (测量值) | (测量值) | 主端点
- region-b | provider-2 | eu-west | (测量值) | (测量值) | (测量值) | (测量值) | 备用
- region-c | provider-3 | ap-south | (测量值) | (测量值) | (测量值) | (测量值) | 备用
验证计划:注入故障并断言流量转移
验证不是可选项。注入一个故障端点,断言流量转移到健康端点,然后断言当端点恢复时流量恢复。使用受控故障:将一个端点 URL 指向不可路由地址,或从本地代理返回 HTTP 500。
步骤:(1) 用三个端点运行路由器;(2) 确认选择的端点是 RTT 最低的健康端点;(3) 破坏所选端点;(4) 确认路由器在连续错误阈值后将其剔除,并分发到下一个健康端点;(5) 恢复端点;(6) 确认半开试探在冷却后重新接纳它。
将观察到的行为记录在结果表中。如果路由器没有转移流量,检查健康探测间隔、错误阈值,以及熔断器是否卡在打开状态。
- 注入故障:不可路由 URL 或 HTTP 500 代理。
- 断言流量在连续错误阈值后转移。
- 断言冷却和半开试探后恢复。
- 将观察到的行为记录在结果表中。
常见故障与故障排除
最常见的故障是虚假冗余:同一提供商或区域的两个端点。检查每个端点的提供商、区域和 ASN。第二常见的是简单的存活检查遗漏了区块头滞后;添加区块头滞后比较。
其他故障:熔断器因阈值过低而抖动;跨池混合承诺级别导致读取不一致;探测循环从错误区域测量。对于每一种,修复都在健康信号设计和评分函数中。
如果你看到间歇性陈旧读取,检查端点是否落后于链尖,以及你的读取是否固定了最终性级别。如果你看到流量在端点之间振荡,增加连续错误阈值并添加恢复迟滞。
- 虚假冗余:同一提供商/区域/ASN。
- 简单存活检查:遗漏区块头滞后,返回陈旧状态。
- 熔断器抖动:阈值过低,无迟滞。
- 混合承诺级别:读取不一致。
- 从错误区域探测:RTT 误导。
局限性、权衡以及何时更新此设计
客户端路由器增加了复杂性:你必须维护探测循环、评分函数和熔断器状态。它还会为探测流量增加少量延迟。对于非常小的应用,托管负载均衡器或带备用端点的单一提供商可能更简单。
基于延迟的路由优化中位数,但如果 RTT 最低的端点也是负载最高的,则可能损害尾部。对冲可以帮助尾部,但会使请求量翻倍。没有免费的午餐;测量并调整。
当端点集变化、提供商区域变化或流量模式变化时,更新此设计。任何更改后重新运行验证计划。关于定价和套餐影响,请参阅 RPC 定价。
- 客户端路由器增加复杂性和探测流量。
- 延迟路由优化中位数,不总是尾部。
- 对冲帮助尾部,但使请求量翻倍。
- 任何端点或提供商更改后重新运行验证。
下一步:从路由器到生产部署
首先用结果表测量你当前的端点。然后在暂存环境中实现路由器,运行验证计划,最后才推广到生产。在切换期间将旧端点保留为备用。
如需更广泛的背景,请重温 RPC 节点监控、指标与故障转移 和 公共 RPC 端点与生产专用端点对比。如需特定链的延迟工作,请参阅 Base RPC 延迟:测量与优化 和 Base。
如果你正在评估提供商,请使用 选择 RPC 提供商(RPC Assistant) 和 RPC 定价。OnFinality Learn 中心 汇集了完整的性能与优化指南。
- 用结果表测量当前端点。
- 先在暂存环境中实现并验证路由器。
- 切换期间将旧端点保留为备用。
- 重温监控和提供商选择指南以获取背景。