Rust 后台任务别只会 spawn,可靠重试才是硬骨头
接口返回成功,不代表后面的活真的做完了。发邮件、同步搜索索引、生成报表、推送消息,这些任务一旦丢了,用户不会关心你当时为什么超时。
Rust 写后台任务很容易,代码形状通常是这样:
tokio::spawn(async move {
do_something().await;
});但这只是把工作挪到后台,不代表它可靠。进程重启会丢任务,任务失败没人知道,重试没有上限会打爆下游,重试没有幂等会重复发邮件、重复扣库存、重复通知用户。
后台任务不是 spawn 出来的协程,而是一条可恢复、可观测、可限速的业务流程。
本文代码环境:
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }tokio::spawn 适合并发,不适合持久化任务
spawn 没错,它解决的是“让一个 Future 并发执行”。
但它不解决这些问题:
- 进程退出后任务还在不在
- 执行失败后谁来重试
- 重试几次算失败
- 多个实例会不会重复执行
- 任务执行到一半崩了怎么恢复
用一个任务模型表示会更清楚:
use uuid::Uuid;
#[derive(Debug, Clone)]
struct Job {
id: Uuid,
kind: JobKind,
attempts: u32,
}
#[derive(Debug, Clone)]
enum JobKind {
SendEmail { user_id: u64 },
RebuildIndex { product_id: u64 },
}一旦你把任务当成数据,而不是当成一个临时协程,很多设计自然就会出现:
任务表
状态字段
重试次数
下次执行时间
错误原因这才是可靠后台任务的起点。
任务状态要能表达“正在发生什么”
后台任务至少需要这些状态:
#[derive(Debug, Clone, PartialEq, Eq)]
enum JobStatus {
Pending,
Running,
Succeeded,
Failed,
Dead,
}我不建议只用 done: bool。因为失败和完成不是一回事,临时失败和永久失败也不是一回事。
数据库表大概长这样:
create table jobs (
id uuid primary key,
kind text not null,
payload jsonb not null,
status text not null,
attempts int not null default 0,
run_after timestamptz not null default now(),
last_error text,
created_at timestamptz not null default now()
);run_after 很关键。它让任务系统有能力表达“现在不要跑,过一会儿再试”。
没有 run_after,退避重试就只能靠 sleep。worker 一 sleep,调度能力就差了;进程一重启,sleep 信息也没了。
领取任务要防止多个 worker 抢同一条
多个实例同时跑 worker 时,领取任务必须是原子的。
PostgreSQL 里常见写法是 for update skip locked:
select id
from jobs
where status = 'pending'
and run_after <= now()
order by created_at
limit 10
for update skip locked;选中以后,在同一个事务里改成 running。
Rust 侧可以把领取结果看成一个 lease:
use std::time::Instant;
use uuid::Uuid;
#[derive(Debug)]
struct JobLease {
job_id: Uuid,
leased_at: Instant,
}
fn lease_is_fresh(lease: &JobLease, now: Instant) -> bool {
now.duration_since(lease.leased_at).as_secs() < 60
}真实系统还会加 locked_until 或 heartbeat_at,避免 worker 崩溃后任务永远卡在 running。
我的偏好是:任务领取要靠数据库裁决,不要靠进程内 Mutex。
进程内锁只管一个实例,多实例部署时没意义。
重试要有退避,也要有上限
后台任务失败后,不应该立刻无限重试。
一个简单的退避函数:
use std::time::Duration;
fn retry_delay(attempts: u32) -> Duration {
let capped = attempts.min(6);
Duration::from_secs(2_u64.pow(capped))
}这个函数表达的是指数退避,最多退到 64 秒。真实项目里我还会加随机抖动,避免一批失败任务同时恢复后一起冲击下游。
重试策略应该区分错误:
#[derive(Debug, PartialEq, Eq)]
enum JobErrorKind {
Temporary,
Permanent,
}
fn can_retry(kind: JobErrorKind, attempts: u32) -> bool {
kind == JobErrorKind::Temporary && attempts < 8
}SMTP 暂时不可用、HTTP 503、数据库连接短暂失败,可以重试。
参数缺失、用户不存在、模板配置错误,通常不该重试。继续重试只是制造噪音。
幂等比重试更重要
后台任务一定要假设自己可能重复执行。
原因很多:
- worker 执行成功,但标记成功前崩溃
- 队列至少一次投递
- 运维手动补偿任务
- 任务超时后被另一个 worker 接管
所以任务处理函数要么天然幂等,要么显式记录处理结果。
#[derive(Debug)]
struct EmailCommand {
user_id: u64,
template: String,
dedupe_key: String,
}
fn email_dedupe_key(user_id: u64, template: &str) -> String {
format!("email:{user_id}:{template}")
}发邮件这类外部副作用很难回滚,dedupe_key 就很重要。你可以在本地表里记录“这个用户这个模板已经发送过”,重复执行时直接跳过。
不要把任务系统设计成“它最好不要重复”。要设计成“它重复了也没事”。
死信不是垃圾桶,是人工处理入口
超过重试次数的任务,不应该静默丢弃。
它应该进入 dead 状态,保留足够信息:
#[derive(Debug)]
struct DeadJob {
job: Job,
last_error: String,
}
fn dead_reason(job: &DeadJob) -> String {
format!("job {:?} failed: {}", job.job.kind, job.last_error)
}死信数据至少要能回答:
- 哪个任务失败了
- 参数是什么
- 失败了几次
- 最近一次错误是什么
- 是否可以人工重放
很多后台任务系统没有“死信查看和重放”入口,结果就是失败任务躺在数据库里没人知道,直到用户投诉。
没有死信处理的重试系统,只是把失败延后暴露。
观测指标要围绕队列健康
后台任务的指标不要只看成功率。
更关键的是:
- pending 数量
- running 数量
- dead 数量
- 最老 pending 等待时间
- 每类任务平均耗时
- 每类任务失败率
- 下游错误分类
一个任务系统最怕“还在消费,但已经追不上生产”。这时候成功率可能还不错,但队列延迟在不断变大。
#[derive(Debug)]
struct QueueSnapshot {
pending: u64,
running: u64,
dead: u64,
oldest_pending_seconds: u64,
}
fn is_queue_healthy(snapshot: &QueueSnapshot) -> bool {
snapshot.dead == 0 && snapshot.oldest_pending_seconds < 300
}这个判断很粗,但方向对:看队列健康,不只看单个任务成功失败。
结论
tokio::spawn 解决的是并发执行,不解决任务可靠性。真正的后台任务系统,需要把任务持久化、状态化、可重试、可观测。
任务要先变成数据;领取任务要有并发控制;重试要有退避和上限;处理函数必须幂等;死信必须能看、能处理、能重放。
Rust 的 async 很强,但可靠后台任务靠的不是 async 语法,而是失败边界设计。