返回文章列表

Rust 后台任务别只会 spawn,可靠重试才是硬骨头

290·3 分钟阅读
Rust微服务

接口返回成功,不代表后面的活真的做完了。发邮件、同步搜索索引、生成报表、推送消息,这些任务一旦丢了,用户不会关心你当时为什么超时。

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_untilheartbeat_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 语法,而是失败边界设计。