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 秒的数据库查询。你应该快速失败,把资源留给还来得及完成的请求。
重试是放大器,不是补救药
超时后最常见的补丁是重试。
“失败了再试一次”听起来很合理,但在微服务里,重试经常是事故放大器。
下游已经慢了,你再打两次;连接池已经紧张了,你再排队;用户已经等不了,你还在后台补偿自己的焦虑。
我现在写重试有三个底线:
- 只重试幂等操作,比如 GET、按幂等键创建订单、可重复查询
- 只重试可恢复错误,比如超时、连接断开、503
- 每次重试都必须消耗同一个 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 好看。