返回文章列表

Rust 微服务最容易写错的不是业务,是超时和取消

482·4 分钟阅读
Rust微服务超时控制取消机制重试

用户看到一个 504,以为请求已经结束了。服务端看起来也把错误返回了。但数据库连接还被占着,下游服务还在处理,后台任务还在跑,重试还在继续。

这类事故很烦,因为表面上每一层都“写了超时”。

网关有 3 秒超时,服务 A 有 2 秒超时,服务 B 查数据库也有超时。代码看起来很谨慎,压测时也没问题。真正上线后,一次下游抖动就把连接池打满,P99 飙升,日志里全是 deadline exceeded,但你根本不知道是谁还在继续烧资源。

我后来对 Rust 微服务里的超时有个很朴素的判断:

超时不是一个 timeout() 函数,而是一条请求生命周期的预算管理。

这篇不讲“什么是异步”,也不复述 Tokio 文档。前面已经写过 Tokio 那些没人讲透的事。这篇只讲一个真实问题:一个请求慢了以后,Rust 服务到底应该怎么停下来,怎么重试,怎么让日志说清楚发生了什么。

本文代码环境:

# Cargo.toml
[dependencies]
tokio = { version = "1.52", features = ["full"] }
tokio-util = "0.7"
tracing = "0.1"

错觉一:加了 timeout 就安全了

刚开始写服务间调用时,我也喜欢这么包一层:

use std::time::Duration;
use tokio::time;
 
async fn handle_request() -> Result<String, String> {
    match time::timeout(Duration::from_millis(300), call_downstream()).await {
        Ok(Ok(body)) => Ok(body),
        Ok(Err(_)) => Err("downstream error".into()),
        Err(_) => Err("gateway timeout".into()),
    }
}
 
async fn call_downstream() -> Result<String, String> {
    time::sleep(Duration::from_millis(500)).await;
    Ok("ok".to_string())
}

这段代码本身没错。timeout 到期后,它包住的 future 会被 drop,本进程不再等它。

但问题也在这里:你取消的是“本地等待”,不是整个世界。

如果 call_downstream() 里面只是普通 async 代码,被 drop 后通常就停了。但真实微服务里,里面可能是一次 HTTP 请求、一次数据库查询、一个已经提交给下游的任务,甚至是你自己 tokio::spawn 出去的后台任务。

远端服务已经收到请求了,它不会因为你本地 future 被 drop 就自动停止。数据库已经开始执行 SQL 了,它也未必立刻知道客户端不等了。

这就是很多超时事故的根:调用方已经放弃,执行方还在干活。


最容易漏掉的是 spawn 出去的任务

Rust 新手很容易以为,timeout 包住 JoinHandle,超时后任务就停了。

实际不是这样。

use std::time::Duration;
use tokio::time;
 
async fn wrong_way() -> Result<&'static str, &'static str> {
    let handle = tokio::spawn(async {
        time::sleep(Duration::from_secs(10)).await;
        "done"
    });
 
    time::timeout(Duration::from_millis(100), async {
        handle.await.map_err(|_| "join error")
    })
    .await
    .map_err(|_| "timeout")?
}

这段最迷惑的地方是:超时以后,handle 被包在里面一起 drop 了,但任务并不会因此停止。JoinHandle 被 drop,只是你不再关心结果,任务会继续在 runtime 里跑。

如果这个任务里面拿着数据库连接、文件句柄、锁,或者正在不断往 channel 里塞东西,你就制造了一个“用户看不到,但机器还在还债”的后台任务。

我现在更偏向这种写法:

use std::time::Duration;
use tokio::time;
 
async fn abort_on_timeout() -> Result<&'static str, &'static str> {
    let mut handle = tokio::spawn(async {
        time::sleep(Duration::from_secs(10)).await;
        "done"
    });
 
    match time::timeout(Duration::from_millis(100), &mut handle).await {
        Ok(Ok(value)) => Ok(value),
        Ok(Err(_)) => Err("join error"),
        Err(_) => {
            handle.abort();
            Err("timeout")
        }
    }
}

关键点不是 abort() 这个 API,而是所有权还在你手里

你把 &mut handle 交给 timeout 等待,超时后 handle 本体还在,所以你能明确告诉 runtime:这个任务不要了。

不过我也要说实话:abort() 是硬取消。它适合“这个任务没有复杂清理逻辑”的场景。如果任务需要释放业务资源、写失败状态、通知其他协程,硬取消就太粗暴了。

这时候我会用 CancellationToken


真正靠谱的取消:让任务自己听得懂“停”

CancellationToken 的好处是,它不是从外面把任务一刀切掉,而是给任务一个信号:该收尾了。

use std::time::Duration;
use tokio::time;
use tokio_util::sync::CancellationToken;
 
async fn worker(cancel: CancellationToken) -> Result<(), &'static str> {
    loop {
        tokio::select! {
            _ = cancel.cancelled() => {
                return Err("cancelled");
            }
            _ = time::sleep(Duration::from_millis(50)) => {
                // 每一小段工作后都给取消信号一个机会
            }
        }
    }
}

这段代码的重点不在循环,而在“切片”。

如果你的任务一口气跑 30 秒,中间没有任何 .await,那取消信号根本没机会被处理。Tokio 是协作式调度,不是抢占式调度。任务要自己在合适的地方让出控制权。

所以长任务要拆成小段:

  • 每处理一批数据,检查一次取消信号
  • 每次外部 I/O,都放进 select!
  • 每次循环,都想清楚退出路径

我个人的经验是:只要一个任务会活过当前请求,它就应该有取消入口。

否则你迟早会在发布、超时、断连、用户取消这些场景里补课。


错觉二:每一层都设 3 秒就好了

很多系统的超时是这么配的:

网关:3 秒
服务 A:3 秒
服务 B:3 秒
数据库:3 秒

看起来每层都有保护,实际是每层都在骗自己。

用户请求到服务 B 的时候,可能已经过去 2.6 秒了。服务 B 如果再给数据库 3 秒,整个链路一定超过用户能接受的时间。最后的结果是:用户早就走了,后端还在认真查库。

更合理的做法是传递 deadline,而不是每层重新发一张 3 秒体验卡。

use std::{future::Future, time::Duration};
use tokio::time::{self, Instant};
 
#[derive(Clone, Copy)]
struct Deadline(Instant);
 
impl Deadline {
    fn after(duration: Duration) -> Self {
        Self(Instant::now() + duration)
    }
 
    fn remaining(self) -> Option<Duration> {
        // 已经过期返回 None,还没过期返回剩余时间
        self.0.checked_duration_since(Instant::now())
    }
}

有了 deadline,每一层都只花“剩下的钱”:

async fn with_deadline<T>(
    deadline: Deadline,
    future: impl Future<Output = T>,
) -> Result<T, &'static str> {
    let Some(left) = deadline.remaining() else {
        return Err("deadline exceeded");
    };
 
    time::timeout(left, future)
        .await
        .map_err(|_| "deadline exceeded")
}

调用方也不再拍脑袋给每个步骤独立超时:

async fn load_page() -> Result<String, &'static str> {
    let deadline = Deadline::after(Duration::from_millis(800));
 
    let user = with_deadline(deadline, fetch_user()).await?;
    let orders = with_deadline(deadline, fetch_orders()).await?;
 
    Ok(format!("{user}: {orders}"))
}
 
async fn fetch_user() -> String { "user".to_string() }
async fn fetch_orders() -> String { "orders".to_string() }

这个模型很朴素,但它改变了思路:

请求不是由很多个独立超时组成的,而是共享同一份时间预算。

如果入口只剩 100ms,下游就不该继续做一个最多 2 秒的数据库查询。你应该快速失败,把资源留给还来得及完成的请求。


重试是放大器,不是补救药

超时后最常见的补丁是重试。

“失败了再试一次”听起来很合理,但在微服务里,重试经常是事故放大器。

下游已经慢了,你再打两次;连接池已经紧张了,你再排队;用户已经等不了,你还在后台补偿自己的焦虑。

我现在写重试有三个底线:

  1. 只重试幂等操作,比如 GET、按幂等键创建订单、可重复查询
  2. 只重试可恢复错误,比如超时、连接断开、503
  3. 每次重试都必须消耗同一个 deadline

为了让例子能直接跑,先假设一次下游调用长这样:

async fn call_once() -> Result<String, &'static str> {
    Ok("ok".to_string())
}

真正的重试逻辑反而不复杂:

fn backoff(attempt: u32) -> std::time::Duration {
    // 指数退避 + 随机抖动,防止多个客户端同时重试时撞车
    // 实际项目可以用 full jitter 或 decorrelated jitter 算法
    let base = 50_u64.saturating_mul(1u64 << attempt.min(5));
    let jitter = (attempt as u64 * 17) % 31;
    std::time::Duration::from_millis(base + jitter)
}
 
async fn retry_query() -> Result<String, &'static str> {
    for attempt in 0..3 {
        match call_once().await {
            Ok(value) => return Ok(value),
            Err("timeout" | "busy") if attempt < 2 => {
                tokio::time::sleep(backoff(attempt)).await;
            }
            Err(err) => return Err(err),
        }
    }
    unreachable!()
}

这段代码故意没有写得很高级。

真实项目里你可以用库来做 retry policy,但原则别丢:重试不是为了把失败藏起来,而是给短暂抖动一次恢复机会。

如果下游真的挂了,重试应该尽快停手,把压力交给熔断和降级,而不是把本来 1 次失败变成 3 次失败。


日志里必须看得到:谁超时,重试了几次,还剩多少预算

没有观测,超时就是玄学。

我见过太多日志只写一句:

request failed: timeout

这跟没写差不多。你不知道是入口超时、下游超时、数据库超时,还是重试把时间花完了。

如果你已经在用 tracing,可以把 deadline 和重试信息挂在 span 上:

use tracing::instrument;
 
#[instrument(skip(deadline), fields(deadline_ms, retry_count = 0_u64))]
async fn load_order(deadline: Deadline) -> Result<String, &'static str> {
    if let Some(left) = deadline.remaining() {
        tracing::Span::current().record("deadline_ms", left.as_millis() as u64);
    }
 
    let order = with_deadline(deadline, fetch_order()).await?;
    Ok(order)
}
 
async fn fetch_order() -> String {
    "order".to_string()
}

如果重试逻辑也写在这个函数里,可以在每次重试后更新 span:

#[instrument(skip(deadline), fields(deadline_ms, retry_count = 0_u64))]
async fn load_order_with_retry(deadline: Deadline) -> Result<String, &'static str> {
    for attempt in 0..3u64 {
        tracing::Span::current().record("retry_count", attempt);
 
        if let Some(left) = deadline.remaining() {
            tracing::Span::current().record("deadline_ms", left.as_millis() as u64);
        }
 
        match with_deadline(deadline, fetch_order()).await {
            Ok(order) => return Ok(order),
            Err("deadline exceeded") => return Err("deadline exceeded"),
            Err(_) if attempt < 2 => {
                tokio::time::sleep(backoff(attempt as u32)).await;
            }
            Err(e) => return Err(e),
        }
    }
    Err("all retries exhausted")
}

这段代码的目的不是“多打一行日志”,而是让一次请求的上下文完整:

request_id=8f3a route=/orders/42 deadline_ms=742 retry_count=1 error=deadline_exceeded

排查时你最想知道的不是“有没有超时”,而是:

  • 进入当前服务时还剩多少时间
  • 第几次重试后失败
  • 是哪个下游耗掉了预算
  • 超时后有没有取消后台任务

这些信息平时看起来啰嗦,出事故时就是救命绳。


建议默认写法

如果让我重新写一个 Rust 微服务的调用链,我会把规则定得很死:

问题 我会怎么做
单次外部调用 必须包 timeout 或 deadline
请求级预算 入口生成 deadline,向下传递
后台任务 JoinHandle 就要考虑 abort 或等待收尾
长生命周期任务 CancellationToken,不要只靠 drop
重试 只重试幂等和可恢复错误,必须带 backoff
日志 记录 deadline、retry_count、downstream、timeout_reason

这里面最重要的是 deadline。

超时、取消、重试、熔断、日志都应该围绕它展开。没有 deadline,你的系统就是一堆局部合理的 timeout。每一层都觉得自己没错,最后用户等了 10 秒,机器干了 30 秒。


结论

Rust 写微服务,最容易让人上头的是性能。

很多人一上来就讨论 axum 快不快、tonic 性能怎么样、serde 要不要换 simd-json。但真正把服务拖垮的,往往不是框架慢,而是这些不显眼的生命周期问题:

  • 请求已经超时,后台任务还在跑
  • 调用方放弃了,下游还在处理
  • 重试没有预算,越失败越用力
  • 日志只写 timeout,不写上下文

Rust 的好处是,它会逼你面对所有权和生命周期。但异步世界里的生命周期不只是在内存里,也在请求链路里。

一个请求什么时候开始,什么时候应该结束,谁负责通知其他任务停下来,这些都要设计。

把超时写对,比把接口性能从 2ms 优化到 1ms 重要得多。前者决定系统会不会在下游抖动时活下来,后者大多数时候只是 benchmark 好看。