如何重试带退避?

解读

面试官问“如何重试带退避”,并不是让你背诵“指数退避公式”,而是考察你能否在 Rust 的并发与错误处理模型 下,把“失败重试”做成线程安全、无阻塞、可组合、可配置的库级代码。国内大厂(阿里、字节、腾讯)的落地场景通常是:微服务调用下游 HTTP/gRPC、发送 Kafka、访问 Redis 集群,一旦遇到限流或网络抖动,必须自动重试且不能打爆对方。因此,答案必须体现三点:

  1. 退避策略可插拔(线性、指数、全抖动)
  2. 重试上限与超时受控,防止雪崩
  3. 异步无阻塞,不占用 Tokio 线程

知识点

  • std::future::Futuretokio::time::{sleep, timeout}
  • backoff crate 官方算法实现(ExponentialBackoff、Stop::MaxElapsedTime)
  • thiserror 自定义错误类型,区分 Transient(可重试)与 Permanent(不可重试)
  • pin-project 安全封装异步状态机
  • tower::Service 中间件思想,把重试做成 Layer,与业务解耦
  • Rust 异步取消安全(Cancel-Safe):sleep 返回的 Future 必须支持 tokio 取消,防止泄漏

答案

给出一个生产级异步重试函数,支持指数退避 + 全抖动 + 最大重试时间,编译通过即可上线

use std::time::Duration;
use tokio::time::{sleep, timeout, Instant};
use rand::Rng;

#[derive(Debug, thiserror::Error)]
pub enum RetryError<E> {
    #[error("permanent error: {0}")]
    Permanent(E),
    #[error("max elapsed time reached, last error: {0}")]
    MaxTime(E),
}

/// 退避策略
#[derive(Clone, Debug)]
pub struct Backoff {
    initial: Duration,
    max: Duration,
    factor: f64,
    jitter: bool,
}

impl Default for Backoff {
    fn default() -> Self {
        Backoff {
            initial: Duration::from_millis(100),
            max: Duration::from_secs(10),
            factor: 2.0,
            jitter: true,
        }
    }
}

impl Backoff {
    fn next(&self, n: u32) -> Duration {
        let base = self.initial.as_millis() as f64 * self.factor.powi(n as i32);
        let capped = base.min(self.max.as_millis() as f64);
        let with_jitter = if self.jitter {
            let jitter = rand::thread_rng().gen_range(0.0..1.0);
            capped * jitter
        } else {
            capped
        };
        Duration::from_millis(with_jitter as u64)
    }
}

/// 异步重试入口
pub async fn retry_with_backoff<F, Fut, T, E>(
    mut f: F,
    backoff: Backoff,
    max_elapsed: Duration,
) -> Result<T, RetryError<E>>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, E>>,
    E: std::fmt::Debug,
{
    let start = Instant::now();
    let mut n = 0;
    loop {
        match f().await {
            Ok(val) => return Ok(val),
            Err(e) => {
                if start.elapsed() + backoff.next(n) > max_elapsed {
                    return Err(RetryError::MaxTime(e));
                }
                sleep(backoff.next(n)).await;
                n += 1;
            }
        }
    }
}

使用示例(调用下游 HTTP):

let backoff = Backoff::default();
let resp = retry_with_backoff(
    || async { reqwest::get("https://api.xxx").await.map_err(|e| e) },
    backoff,
    Duration::from_secs(30),
).await?;

关键点

  • 错误类型区分:函数签名里 E 由调用者决定,业务方只需把限流、超时包装成 Err,永久错误直接 panic! 或返回 Permanent
  • 取消安全sleep 返回的 Future 支持 tokio 取消,不会泄漏资源
  • 无阻塞:全程 .await,不占用 OS 线程,QPS 万级并发无压力

拓展思考

  1. 与 tower-retry 集成:把上述逻辑封装成 tower::ServiceLayer,对 HTTP/gRPC/Tonic 通用,一次编写全服务复用
  2. 可观测性:在每次重试时注入 tracing::info! 记录 attempt_nobackoff_ms对接阿里云 SLS 或腾讯 CLS,方便排障。
  3. 退避算法升级:国内云厂商(如阿里云 MSE)推荐 “指数退避 + 全抖动 + 窗口限流”,可用 backoff crate 的 FullJittered 策略,把初始窗口从 100 ms 调到 20 ms降低 P99 延迟
  4. 与熔断器联动:当重试达到 “最大连续失败次数” 时,触发 state-machine 切换到 Open 状态直接拒绝请求,防止 雪崩穿透
  5. 嵌入式场景:在 no_std + alloc 环境下,移除 tokio,用 embassy-timeTimer::after() 实现异步退避,让 Rust 在 MCU 上也能优雅重试传感器 I²C 失败

掌握以上思路,面试时把“重试”讲到 库级别、框架级别、云原生级别offer 基本稳了