Rust RPC 超时树:一个 deadline 要传到每个下游

📅 2026/7/5 20:05:45 👁️ 阅读次数 📝 编程学习
Rust RPC 超时树:一个 deadline 要传到每个下游

Rust RPC 超时树:一个 deadline 要传到每个下游

一、超时不能只写在入口网关

分布式 RPC 系统里,经常会在入口设置 3 秒超时。但服务内部继续调用下游时,如果不传递剩余时间,下游可能各自再等 3 秒。最终入口早就超时了,后端还在工作,资源被浪费,重试又叠上来。

超时树的核心是 deadline 传播。入口请求确定一个截止时间,所有下游调用都基于剩余时间分配预算。越靠后,预算越少。这样系统才能在压力下及时停止无意义工作。

二、把 deadline 当作请求上下文的一部分

每个 RPC 都应该携带 deadline 或 timeout header。服务端读取后,再传给下游。

flowchart TD A[入口请求 deadline=3s] --> B[服务 A] B --> C[下游 B 预算 800ms] B --> D[下游 C 预算 1200ms] C --> E[更下游预算 300ms] D --> F[返回聚合] E --> F

预算分配要留缓冲。不要把剩余时间全部给下游,否则聚合和序列化没有时间完成。

三、Rust 中用 Instant 表达绝对截止时间

相对 timeout 容易在多层调用中累积误差。绝对 deadline 更清楚。

use std::time::{Duration, Instant}; #[derive(Clone, Copy)] pub struct Deadline { at: Instant, } impl Deadline { pub fn after(d: Duration) -> Self { Self { at: Instant::now() + d } } pub fn remaining(&self) -> Option<Duration> { self.at.checked_duration_since(Instant::now()) } }

下游调用前先看remaining。如果已经没有时间,就不要发请求。

使用Instant表达 deadline 需注意分布式陷阱:Instant::now()在不同节点间不可比较。deadline 跨进程传播时,应用墙上时间戳配合误差边界,而非直接传Instant。入口在请求 header 放 UTC deadline(如X-Deadline: 2026-07-05T12:00:05Z),下游收到后用deadline_utc - Utc::now()计算剩余 duration,转成本地Instant::now() + duration。这里容易出现时钟偏移的两种错误:下游时钟慢于上游,计算剩余时间偏短;下游快于上游,可能误以为还有时间但实际已过期。缓解方案是在 header 中附带 creation timestamp,下游通过接收时间 - creation_timestamp - 实际耗时校准偏移,超过配置阈值时记录告警并采用保守策略。

四、超时要和重试一起设计

重试会消耗剩余 deadline。一次失败后,如果剩余时间不足,就不要继续重试。否则重试只会制造压力。

还要区分可重试和不可重试错误。超时、连接复位可能可重试;业务校验失败不应重试。幂等性也必须确认。没有幂等键的写请求,自动重试会制造重复副作用。

最后,日志里要记录 deadline。排查慢请求时,能看到每一层拿到多少预算、花了多少时间、在哪里超时。否则只能看到入口超时,无法知道内部时间被谁吃掉。

并发调用要预留聚合时间。两个下游同时调用,看似只消耗最长那个耗时,但结果合并、校验和序列化仍要时间。预算分配时可以给下游 80% 的剩余时间,保留 20% 给本层收尾。

队列等待也要算进 deadline。请求在本地 worker 队列里排了 500ms,下游预算就应减少 500ms。很多系统只在真正发 RPC 时开始计时,导致排队时间被隐藏。

客户端取消要向下游传播。入口连接断开后,如果服务内部继续等待下游,资源就会浪费。取消信号和 deadline 一样,都是请求上下文的一部分。

批量请求还要拆分预算。一个请求里包含多个子任务时,不能让第一个慢子任务吃掉全部 deadline。调度层应给每个子任务分配预算,并在剩余时间不足时跳过低优先级子任务。

五、总结

Rust RPC 超时树要把 deadline 作为请求上下文传播到每个下游。下游调用基于剩余时间分配预算,重试也必须受 deadline 约束。入口超时只是第一层防线,真正稳定的系统要让每一层都知道什么时候该停。