后台任务和重试队列设计:不要让异步任务悄悄失败
wxk1991 Lv5

后台任务和重试队列设计:不要让异步任务悄悄失败

很多系统一开始都是同步接口。

用户点按钮,后端立刻处理,然后返回结果。

但业务稍微复杂一点,就会出现不适合同步处理的事情:

  • 发邮件
  • 生成报表
  • 调用第三方接口
  • 处理图片和视频
  • 同步搜索索引
  • 发送消息通知
  • 执行耗时导入

这些任务如果都塞进 HTTP 请求里,接口会越来越慢,也越来越不稳定。

这时就需要后台任务。


一、什么任务适合异步

判断一个任务是否适合异步,可以看三点:

1
2
3

不稳定
不影响当前主结果

比如注册用户后发欢迎邮件。

用户注册成功是核心结果。

邮件发送失败不应该让注册接口失败。

更合理的是:

1
2
3
4
注册用户成功
写入发送邮件任务
接口返回
后台慢慢发

这能让主流程更快,也能让失败任务有机会重试。


二、不要只开一个 goroutine 或线程就完事

很多项目初期会这样:

1
go sendEmail(user.Email)

或者:

1
sendEmail(user.email).catch(console.error)

这在 demo 里可以。

但生产环境有几个问题:

  • 程序重启后任务丢失
  • 任务失败没有重试
  • 没有任务状态
  • 没有并发控制
  • 没有告警
  • 不知道积压了多少任务

后台任务不是“另起一个线程”这么简单。

它至少要回答:

1
2
3
4
5
任务在哪里存?
失败怎么办?
能不能重试?
会不会重复执行?
怎么观察状态?

三、任务表是最简单可靠的起点

小项目不一定一上来就用 RabbitMQ、Kafka、Redis Stream。

很多时候,一张数据库任务表就够了。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(32) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
run_at TIMESTAMP NOT NULL DEFAULT NOW(),
locked_at TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

状态可以简单设计为:

1
2
3
4
pending
running
succeeded
failed

后台 worker 定时拉取 pendingrun_at <= now() 的任务执行。

这套方案不酷,但足够透明。

你可以直接用 SQL 查任务状态,也容易排查问题。


四、任务领取要防止多个 worker 抢同一条

如果多个 worker 同时跑,不能让它们拿到同一条任务。

PostgreSQL 里可以用:

1
2
3
4
5
6
7
SELECT id
FROM jobs
WHERE status = 'pending'
AND run_at <= NOW()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10;

这可以让多个 worker 并发领取不同任务。

拿到任务后,在同一个事务里把状态改成 running

核心原则是:

1
领取任务和标记任务必须是原子过程。

否则并发 worker 很容易重复处理。


五、重试要有退避策略

失败后立刻重试,通常不是好主意。

如果第三方接口挂了,你立刻重试 100 次,只会让情况更糟。

更合理的是指数退避:

1
2
3
4
第 1 次失败:1 分钟后重试
第 2 次失败:5 分钟后重试
第 3 次失败:15 分钟后重试
第 4 次失败:1 小时后重试

可以简单实现:

1
next_run_at = now + min(2^attempts, 60) minutes

失败时更新:

1
2
3
4
5
6
UPDATE jobs
SET status = 'pending',
attempts = attempts + 1,
run_at = ?,
last_error = ?
WHERE id = ?;

超过最大次数后,标记为 failed

失败不是问题。

失败后不可见,才是问题。


六、任务必须考虑幂等

后台任务天然可能重复执行。

原因很多:

  • worker 执行到一半崩溃
  • 执行成功但状态更新失败
  • 锁超时后任务被重新领取
  • 消息队列至少一次投递
  • 人工重新触发失败任务

所以任务处理逻辑要尽量幂等。

比如发送优惠券任务:

1
2
3
不要直接 insert coupon
先用 user_id + campaign_id 做唯一约束
重复执行时返回已有记录

比如同步搜索索引:

1
2
用 document_id 覆盖写入
不要每次生成新 document

后台任务的正确心态是:

1
它可能会执行多次。

你接受这个事实,设计就会稳很多。


七、payload 不要塞太多动态数据

任务 payload 常见写法:

1
2
3
{
"user_id": 123
}

worker 执行时再查数据库。

不要轻易把用户完整信息塞进去:

1
2
3
4
5
6
7
8
{
"user": {
"id": 123,
"email": "[email protected]",
"name": "Alice",
"status": "active"
}
}

除非你明确需要任务创建时的快照。

否则 payload 太大、太旧,很容易造成数据不一致。

我的习惯是:

1
2
3
payload 里放最小业务标识
执行时读取最新数据
需要快照时明确命名 snapshot

这样任务更轻,也更容易兼容字段变化。


八、任务类型要有清晰边界

不要写一个万能任务:

1
2
type = "run_script"
payload = 任意参数

短期很灵活。

长期很难维护。

更推荐明确任务类型:

1
2
3
4
send_welcome_email
generate_monthly_report
sync_user_to_crm
resize_uploaded_image

每种任务有自己的 handler。

这样日志、重试、告警、统计都更清楚。

当任务类型多起来后,可以建立一个 handler registry:

1
job type -> handler

不要让一个巨大 switch 膨胀到几千行。


九、必须有可观察性

后台任务最怕悄悄失败。

至少要能看到:

  • pending 数量
  • running 数量
  • failed 数量
  • 重试次数
  • 最老 pending 等待时间
  • 每类任务执行耗时
  • 每类任务失败率

日志里至少要有:

1
2
3
4
5
job_id
job_type
attempts
duration
error

不要只打:

1
job failed

这种日志没有排查价值。

后台任务一旦不可观察,就会变成系统里的黑洞。


十、要有人工补偿入口

再好的重试也不能覆盖所有情况。

比如:

  • 第三方接口修复后,需要重跑失败任务
  • 某个任务 payload 错了,需要人工修正
  • 某批任务卡住了,需要重新置为 pending
  • 某些任务已经没有意义,需要取消

所以后台任务最好有管理入口。

最简单也可以是内部脚本:

1
2
3
job retry --id 123
job cancel --id 123
job retry-failed --type send_welcome_email

不要让运维操作只能靠手写 SQL。

SQL 可以应急,但不应该是常规操作界面。


十一、我的建议

后台任务可以按这个顺序演进:

1
2
3
4
小项目:数据库 jobs 表 + 定时 worker
任务变多:独立 worker 进程 + 指标监控
吞吐变大:Redis Stream / RabbitMQ / Kafka
链路复杂:工作流引擎或状态机

不要一开始就上最复杂的队列。

也不要一直停留在“开个线程跑一下”。

一个可靠的后台任务系统,至少要有:

  • 持久化
  • 状态
  • 重试
  • 幂等
  • 并发控制
  • 可观察性
  • 人工补偿

异步不是把问题丢到后台。

异步是把问题变成可以慢慢处理、可以重试、可以观察的任务。