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 本身没有规定限流的身份识别和计数范围。
路由时先判断能否接收,再比较优先级
一次选择可以分为以下步骤:
- 筛选链、网络、方法能力和数据新鲜度符合要求的节点。
- 排除仍在 Open 或配额冷却期间的候选项。
- 检查该请求的 CU 预算、并发名额及 HalfOpen 探测名额。
- 在符合条件的候选项中执行 RoundRobin、Priority、Weighted、LatencyBased 或 Sticky。
- 原子预留名额后发出请求;如果预留失败,重新选择。
在 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 的在途请求、熔断切换和重试次数。如果总需求持续超过所有可用提供商的合计容量,应有界排队或快速返回明确错误;继续切换和重试只会增加负载。
备注:内容仅供参考。
