Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
基础设施与运维阅读约 14 分钟

RPC 多提供商负载均衡与故障转移:避免过期读取

构建一个多提供商 RPC 调度层,区分传输、协议、语义和速率限制故障,并确保返回的区块头高度永不低于已提供过的最高值。

TL;DR

多提供商 RPC 池必须区分四类故障:传输、协议、语义(区块头过期)和速率限制。简单的 try/catch 只能捕获传输故障,导致过期读取和静默错误未被处理。核心安全规则是新鲜度门控:每个响应都标记该端点观测到的区块头,池拒绝从低于池高水位标记的端点满足“最新”读取。具有区块链特定剔除阈值的熔断器和使用真实低成本读取的半开探测可防止重复故障。本文提供一个可运行的 Node.js 调度层、用于自行测量的结果表,以及针对分叉区块头、缓存响应和惊群效应的故障排除方法。

RPC 池的故障分类

多提供商 RPC 池必须将故障分为四个不同的类别,因为每类需要不同的响应。传输故障(连接被拒绝、TLS 握手失败、DNS 解析失败)最容易检测,也是简单 try/catch 唯一能处理的。协议故障发生在端点返回有效的 JSON-RPC 2.0 响应,其中包含带有错误码的 error 对象时,如 JSON-RPC 2.0 规范 所定义。语义故障最为危险:端点返回 HTTP 200 和有效结果,但结果是过期的——例如 eth_blockNumber 返回的区块高度低于池的当前高水位标记。速率限制故障返回 HTTP 429,有时带有 Retry-After 头,必须等待处理而非立即重试。

以太坊 JSON-RPC 规范 定义了 eth_blockNumber 以及 latest、safe 和 finalized 等区块标签。这些是新鲜度的权威信号。将任何 HTTP 200 都视为成功的池会在部分故障期间提供过期读取,而这正是正确性最关键的时候。多端点一致性与区块头滞后 页面介绍了池存在后的读取纪律;本文定义池契约本身。

  • 传输故障:连接、TLS 或 DNS 错误——无 HTTP 响应。
  • 协议故障:带有错误码的 JSON-RPC error 对象(例如 -32603 内部错误)。
  • 语义故障:HTTP 200 但结果过期或与池的区块头不一致。
  • 速率限制故障:HTTP 429,可选带 Retry-After;需要退避,而非立即故障转移。

调度策略:加权、轮询和最少未完成请求

在存在每节点区块头滞后的链上,轮询调度对写后读工作负载是有害的。如果写入发送到提供商 A,而随后的读取被调度到落后几个区块的提供商 B,读取可能看不到写入。如果权重反映观测到的区块头新鲜度,加权调度可能有帮助,但静态权重会过时。最少未完成请求调度——发送到在途请求最少的端点——能平衡负载但忽略区块头高度。对于写后读,最安全的默认做法是将读取固定到服务写入的同一端点,直到写入确认,然后仅允许故障转移到区块头等于或高于池高水位标记的端点。

对于只读工作负载,最少未完成请求结合新鲜度门控效果良好。多区域 RPC 故障转移与延迟感知路由 页面介绍了区域和 RTT 评分;本文聚焦于使任何调度策略都安全的区块头高度门控。

  • 轮询:简单,但当节点滞后时对写后读不安全。
  • 加权:可纳入区块头新鲜度,但需要动态更新权重。
  • 最少未完成请求:适合只读负载,但必须结合新鲜度门控。
  • 固定:对于写后读,固定到写入端点直到确认。

新鲜度门控:区块头高水位标记

新鲜度门控是使故障转移安全的一条规则:每个调度的响应都标记该端点观测到的区块头(通过 eth_blockNumber),池拒绝从区块头低于池高水位标记的端点满足“最新”读取。高水位标记是所有健康端点中观测到的最大区块头,在每次成功采样区块头时更新。当读取请求到达时,调度器选择一个最后观测区块头等于或高于高水位标记的端点。如果不存在这样的端点,请求会等待或以明确的错误失败,而不是提供过期数据。

该门控防止了经典的过期读取故障:正在同步或滞后的提供商响应请求但返回旧数据。多端点一致性与区块头滞后 页面详细介绍了单调性和法定读取;这里的门控是池级别的强制执行。

// Freshness gate: only dispatch to endpoints at or above high-water mark
function selectEndpoint(pool, request) {
  const hwm = pool.highWaterMark;
  const eligible = pool.endpoints.filter(ep =>
    ep.state === 'closed' &&
    ep.lastHead >= hwm &&
    ep.inFlight < ep.maxInFlight
  );
  if (eligible.length === 0) {
    throw new Error('No fresh endpoint available; high-water mark=' + hwm);
  }
  // Least-outstanding among eligible
  return eligible.reduce((a, b) => a.inFlight <= b.inFlight ? a : b);
}

具有区块链特定剔除的熔断器状态

熔断器状态机——闭合、断开、半开——源自 Michael Nygard 的《Release It!》,并在 Opossum 等库中实现。在区块链 RPC 池中,剔除阈值必须是区块链特定的:单次语义故障(区块头过期)应立即剔除端点,而传输故障可以容忍几次重试。迟滞防止抖动:剔除后,端点在冷却期内保持断开,然后进入半开,并用真实的低成本读取(例如 eth_blockNumber)探测,而非 TCP ping。如果探测返回的区块头等于或高于高水位标记,端点恢复闭合;否则重新断开。

半开探测必须使用真实读取,因为 TCP ping 可能成功,而节点仍在同步并落后数万个区块。RPC 节点监控、指标与告警 页面介绍了这些状态的可观测性。

  • 闭合:正常运行;故障递增计数器。
  • 断开:端点被剔除;冷却期结束前无流量。
  • 半开:单个探测请求(eth_blockNumber)测试新鲜度;成功则闭合,失败则重新断开。
  • 剔除阈值:语义故障 = 立即剔除;传输故障 = 连续 3 次失败;速率限制 = 退避,而非剔除。

无法被欺骗的健康探测

仅检查 TCP 连通性或 HTTP 200 的健康探测很容易被正在同步的节点欺骗。正确的探测将每个端点的区块头与池最大值以及链的观测区块时间进行比较。如果端点的区块头落后池最大值超过几个区块时间,则视为过期并剔除。链的区块时间可以通过窗口内区块时间戳的差异估算;落后超过例如三个区块时间的端点很可能正在同步或停滞。

该探测必须定期运行(例如每几秒)并更新高水位标记。RPC 连接复用与 HTTP/2 keep-alive 页面介绍了这些探测的传输效率。

// Health probe: compare head against pool max and block time
async function probeEndpoint(ep, pool) {
  try {
    const headHex = await ep.call('eth_blockNumber', []);
    const head = parseInt(headHex, 16);
    ep.lastHead = head;
    ep.lastProbe = Date.now();
    const poolMax = Math.max(...pool.endpoints.map(e => e.lastHead || 0));
    const blockTimeMs = pool.estimatedBlockTimeMs || 12000;
    const lagBlocks = poolMax - head;
    const lagMs = lagBlocks * blockTimeMs;
    if (lagMs > 3 * blockTimeMs) {
      ep.state = 'open';
      ep.ejectUntil = Date.now() + pool.cooldownMs;
      return false;
    }
    if (ep.state === 'half-open') ep.state = 'closed';
    return true;
  } catch (err) {
    ep.state = 'open';
    ep.ejectUntil = Date.now() + pool.cooldownMs;
    return false;
  }
}

可运行的 Node.js 调度层

以下调度层实现了端点注册表、每端点状态、区块头高水位标记、剔除计时器、半开探测和感知 429 的等待。它使用 fetch 进行 HTTP 调用,并假设每个端点暴露 JSON-RPC 2.0 接口。调度器通过新鲜度门控选择合格端点,发送请求并分类响应。遇到 429 时,读取 Retry-After 并等待后重试同一端点;遇到语义故障时,剔除该端点并重试另一个。每次成功采样区块头时更新高水位标记。

此代码是起点;生产部署应添加指标、日志和持久状态。速率限制头与 Retry-After 处理 页面详细介绍了 429 语义。

class RpcPool {
  constructor(endpoints, opts = {}) {
    this.endpoints = endpoints.map(url => ({
      url, state: 'closed', lastHead: 0, inFlight: 0,
      maxInFlight: opts.maxInFlight || 10,
      ejectUntil: 0, failures: 0
    }));
    this.highWaterMark = 0;
    this.cooldownMs = opts.cooldownMs || 30000;
    this.estimatedBlockTimeMs = opts.blockTimeMs || 12000;
  }

  async call(method, params) {
    const ep = this.selectEndpoint();
    ep.inFlight++;
    try {
      const res = await fetch(ep.url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
      });
      if (res.status === 429) {
        const retryAfter = res.headers.get('Retry-After');
        const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000;
        await new Promise(r => setTimeout(r, waitMs));
        return this.call(method, params);
      }
      const json = await res.json();
      if (json.error) {
        ep.failures++;
        if (ep.failures >= 3) this.eject(ep);
        throw new Error('RPC error: ' + JSON.stringify(json.error));
      }
      if (method === 'eth_blockNumber') {
        const head = parseInt(json.result, 16);
        ep.lastHead = head;
        this.highWaterMark = Math.max(this.highWaterMark, head);
      }
      ep.failures = 0;
      return json.result;
    } catch (err) {
      ep.failures++;
      if (ep.failures >= 3) this.eject(ep);
      throw err;
    } finally {
      ep.inFlight--;
    }
  }

  selectEndpoint() {
    const now = Date.now();
    const eligible = this.endpoints.filter(ep =>
      ep.state === 'closed' && ep.lastHead >= this.highWaterMark &&
      ep.inFlight < ep.maxInFlight && now > ep.ejectUntil
    );
    if (eligible.length === 0) throw new Error('No fresh endpoint');
    return eligible.reduce((a, b) => a.inFlight <= b.inFlight ? a : b);
  }

  eject(ep) {
    ep.state = 'open';
    ep.ejectUntil = Date.now() + this.cooldownMs;
  }
}

结果表:针对你自己的提供商进行测量

使用下表记录你自己提供商的观测结果。针对你的端点运行调度层至少 24 小时,每 10 秒采样一次 eth_blockNumber。用你的测量值填写每一列。这是一种由读者验证的方法;此处不提供基准数字,因为提供商行为各不相同。

列:提供商 URL、观测区块头滞后(区块数)、传输故障(次数)、协议故障(次数)、语义故障(次数)、429 响应(次数)、平均延迟(毫秒)、剔除事件(次数)。

  • 提供商 URL:被测端点。
  • 观测区块头滞后:提供商区块头与池高水位标记之间的差值。
  • 传输故障:连接/TLS/DNS 错误。
  • 协议故障:JSON-RPC error 对象。
  • 语义故障:HTTP 200 但区块头低于高水位标记。
  • 429 响应:速率限制命中次数。
  • 平均延迟:eth_blockNumber 的平均往返时间。
  • 剔除事件:熔断器断开的次数。

故障模式与故障排除

当两个提供商报告不同的区块头且池的高水位标记未一致更新时,会出现提供商之间的分叉区块头。如果区块头采样不频繁,或提供商的区块头因重组而跳跃,就可能发生这种情况。修复方法是频繁采样区块头,并使用观测到的最大区块头作为高水位标记,同时通过比较区块哈希检测重组。静默提供缓存响应的提供商会通过简单的健康检查;新鲜度门控会捕获它,因为缓存的区块头将低于高水位标记。在故障期间通过半开探测可能发生在探测使用缓存或过期响应时;始终使用真实的 eth_blockNumber 调用并与池最大值比较。简单全端点重试产生的惊群效应可以通过在重试延迟中添加抖动并限制并发重试次数来缓解。

RPC 请求对冲以降低尾部延迟 页面介绍了对冲,这是一种不同于故障转移的技术。多区域 RPC 故障转移与延迟感知路由 页面介绍了区域级故障转移。

  • 分叉区块头:频繁采样区块头;通过区块哈希检测重组。
  • 缓存响应:新鲜度门控拒绝低于高水位标记的区块头。
  • 故障期间半开探测通过:使用真实 eth_blockNumber,而非 TCP ping。
  • 惊群效应:为重试添加抖动;限制并发重试。

局限性与权衡

多提供商池会增加开销:用于区块头采样的额外请求、将读取固定到单个端点的成本,以及维护每端点状态的复杂性。区块头采样消耗配额并增加调度路径的延迟。将读取固定到写入端点会降低负载均衡效果,如果该端点缓慢还可能增加延迟。池无法修复意图问题:如果应用逻辑需要特定的区块标签(例如 finalized),池必须尊重该标签,不能替换为 latest。RPC 定价 页面可帮助估算额外请求的成本。

如果链本身正在重组,池也无法保证跨提供商的一致性;它只能确保池的视图是单调的。对于需要强一致性的应用,考虑使用带有专用节点的单一提供商,如 API 服务 页面所述。

  • 额外请求:区块头采样消耗配额。
  • 固定成本:降低负载均衡效果,可能增加延迟。
  • 意图不匹配:池必须尊重区块标签,不能覆盖它们。
  • 重组:池确保单调视图,而非跨提供商一致性。

后续步骤与相关指南

要深入了解多提供商 RPC 运维,请浏览 OnFinality Learn 中心 获取相关文章。多区域 RPC 故障转移与延迟感知路由 页面介绍了区域和 RTT 评分。RPC 请求对冲以降低尾部延迟 页面解释了如何竞速重复的只读调用。多端点一致性与区块头滞后 页面详细介绍了读取纪律。RPC 节点监控、指标与告警 页面介绍了可观测性。关于跨多条链的端点选择,请参阅 多链 RPC 端点指南(RPC Assistant)

关于以太坊特定端点,请访问 以太坊网络页面。要估算额外区块头采样请求的成本,请参阅 RPC 定价。关于专用节点选项,请参阅 API 服务 页面。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始