PAYATHON 2026

Rust 区块链 RPC 多提供商故障转移:熔断与重试如何配合以避免振荡?

数据阿明

建议保留 circuit breaker,并配合受预算约束的重试、按配额作用域限流和渐进恢复。现有的 cooldown weight 可以作为 slow start 的基础,但也需要限制恢复节点的实际请求速率,否则仍可能再次超过配额。

为什么只重试或只调整权重都不够?

当前的 LatencyBased 策略会把流量全部交给最快节点。节点恢复后,延迟排名重新生效,流量立即回流;一旦超过容量,流量又会被切走。这种振荡来自路由策略与容量限制之间的反馈。

重试处理单次调用的暂时失败,熔断则暂时避开持续异常的上游。两者都不能直接保证请求量处于服务商配额以内。

渐进恢复可以参考 Envoy 的 slow start,它会在一段时间内逐步提高 endpoint 的负载均衡权重。权重决定的是相对份额:如果池中只剩一个可用节点,即使它的权重很低,也可能接收全部请求。因此还需设置绝对速率和并发上限。

先将健康状态与配额状态分开

建议至少维护两类状态:

  • endpoint 健康状态:连接失败、timeout、持续 503、熔断状态和探测名额。
  • quota group 状态:共享配额的 API key、账户或项目对应的令牌、冷却截止时间和恢复速率。

题述场景按 API key 限流,多个 endpoint 使用同一 key 时,必须共享配额状态。不要通过切换 URL 绕回同一份已耗尽的预算。如果服务商按账户或项目汇总额度,还需要扩大共享范围。

情况建议处理
429冷却对应 quota group;请求满足重试条件时,尝试具有独立剩余额度的提供商
单次 timeout根据调用语义、剩余 deadline 和重试预算决定是否重试,不立即判定整个提供商失效
持续 503、连接失败达到配置的失败阈值后熔断,之后限制性探测
参数错误、执行 revert通常直接返回,不计入基础设施故障
方法不支持、日志范围超限更新能力判断,或调整请求;不直接把整个提供商判为不健康

429 可能携带 Retry-After。存在有效值时,应据此确定该配额作用域的等待时间;没有有效值时,再使用有上限的退避和 jitter。RFC 6585 说明,429 本身没有规定限流的身份识别和计数范围。

路由时先判断能否接收,再比较优先级

一次选择可以分为以下步骤:

  1. 筛选链、网络、方法能力和数据新鲜度符合要求的节点。
  2. 排除仍在 Open 或配额冷却期间的候选项。
  3. 检查该请求的 CU 预算、并发名额及 HalfOpen 探测名额。
  4. 在符合条件的候选项中执行 RoundRobin、Priority、Weighted、LatencyBased 或 Sticky。
  5. 原子预留名额后发出请求;如果预留失败,重新选择。

在 Rust 异步代码中,检查和占用探测名额时要避免竞态,否则多个任务可能同时看到 HalfOpen 可用,一起发起探测。请求取消或结束时也要释放并发名额。

LatencyBased 可以改为根据平滑延迟进行加权随机选择,或随机抽取两个候选节点,比较它们的延迟和在途请求数后再选择。这样可以避免每次都选择全局最快节点。不同方法的原始延迟也不宜直接混在一起比较。

其他策略同样需要遵守容量约束:Priority 在首选节点达到预算后向后溢出;Weighted 的配置权重不代表容量保证;Sticky 在绑定节点不可用时,需要明确是返回错误还是允许迁移。

恢复时同时控制探测、速率和增长速度

Open 的等待时间结束后,只允许有限的 HalfOpen 探测。根据配置的观察窗口和成功条件确认恢复,再逐步增加业务流量。一次轻量的 eth_blockNumber 成功,只能说明该请求可用,不能证明昂贵查询的容量已经恢复。

恢复期间可以提高 CU/s 预算上限,同时保留服务商配额和并发限制。下面的 Rust 示例仅计算渐进恢复的速率上限,不包含令牌桶、调度器或熔断实现:

use std::time::Duration;

// Preconditions: cap_cu_per_sec is finite and positive;
// floor_fraction is finite and in (0.0, 1.0].
fn recovery_rate(
    cap_cu_per_sec: f64,
    elapsed: Duration,
    warmup: Duration,
    floor_fraction: f64,
) -> f64 {
    let progress = if warmup.is_zero() {
        1.0
    } else {
        (elapsed.as_secs_f64() / warmup.as_secs_f64()).min(1.0)
    };
    cap_cu_per_sec * progress.max(floor_fraction)
}

将返回值用于限制令牌补充速率,并约束令牌桶的突发容量。恢复时不要直接填满整个桶,否则第一批请求仍会形成尖峰。

对于反复限流的节点,可以采用加性增加、乘性减少:持续稳定时缓慢增加允许速率,出现新的限流信号时明显降低速率。增加速率应有观察窗口,必要时加入 jitter,避免多个实例同步回流。速率上限始终受已知套餐额度约束,不能无限增长。

持续故障时也可以延长隔离时间,并为隔离时间设置上限。Envoy 的 outlier detection 包含重复剔除后延长隔离时间的机制。题述的 60 秒可以作为配置示例,不能据此推断适合所有提供商。

重试必须按方法语义决定

为一个逻辑请求设置总 deadline、最大尝试次数和重试成本预算。切换提供商也计入同一预算,避免各层独立重试导致请求放大。

只读方法通常允许重试,但相同参数不保证得到相同结果。使用 latest 或 pending 时,不同节点可能看到不同状态。需要一致视图时,应在方法支持的范围内固定区块引用,并检查节点同步进度。

eth_sendRawTransaction 需要进一步区分:对 Ethereum/EVM 链重广播完全相同的已签名交易,与重新生成并签名一笔交易不同。相同交易的重广播不会因此变成第二笔独立交易,但 RPC 响应可能不同,因此不能简单当作普通只读重试。

建议保留已签名交易和交易 hash。提交 timeout 后,将状态记为“结果未知”,按 hash 查询,再按策略重广播原始交易;即使查不到交易,也不能立即证明之前没有提交成功。不要因为网络错误就自动换 nonce 或重签。eth_sendRawTransaction 与按 hash 查询的接口定义可参见 Ethereum JSON-RPC 文档。其他链家族应分别定义提交、过期和重试语义。

Sticky 也不能单独保证 nonce 一致性。应用侧仍需协调同一账户的 nonce 分配,并处理 pending 状态与替换交易。

如何接入现有 middleware stack?

现有结构可以继续使用:

DedupTransport → CacheTransport → BackpressureTransport → ProviderPool → HttpRpcClient

建议让 BackpressureTransport 管理全局队列、并发和 deadline;ProviderPool 管理实际尝试、候选节点、熔断与共享配额;HttpRpcClient 返回可分类的 HTTP 和 JSON-RPC 错误。

每次真实的上游尝试都要占用对应提供商的预算,重试、探测和 batch 也不能漏算。调用发出后,不要未经服务商规则确认就因失败而退还 CU。多实例共享一个 API key 时,需要协调额度,或为实例分配总和不超过全局预算的份额。

题述的 75 CU 和 10 CU 应作为示例成本,实际使用时应维护按提供商、方法及必要参数区分的成本配置。Dedup 和 Cache 也应按方法启用,尤其要谨慎处理 pending、交易提交及不同区块引用。

排查时重点记录每个 quota group 的 CU/s、429 和恢复速率,以及各 endpoint 的在途请求、熔断切换和重试次数。如果总需求持续超过所有可用提供商的合计容量,应有界排队或快速返回明确错误;继续切换和重试只会增加负载。

备注:内容仅供参考。