后台任务和重试队列设计:不要让异步任务悄悄失败
很多系统一开始都是同步接口。
用户点按钮,后端立刻处理,然后返回结果。
但业务稍微复杂一点,就会出现不适合同步处理的事情:
- 发邮件
- 生成报表
- 调用第三方接口
- 处理图片和视频
- 同步搜索索引
- 发送消息通知
- 执行耗时导入
这些任务如果都塞进 HTTP 请求里,接口会越来越慢,也越来越不稳定。
这时就需要后台任务。
一、什么任务适合异步
判断一个任务是否适合异步,可以看三点:
1 | 慢 |
比如注册用户后发欢迎邮件。
用户注册成功是核心结果。
邮件发送失败不应该让注册接口失败。
更合理的是:
1 | 注册用户成功 |
这能让主流程更快,也能让失败任务有机会重试。
二、不要只开一个 goroutine 或线程就完事
很多项目初期会这样:
1 | go sendEmail(user.Email) |
或者:
1 | sendEmail(user.email).catch(console.error) |
这在 demo 里可以。
但生产环境有几个问题:
- 程序重启后任务丢失
- 任务失败没有重试
- 没有任务状态
- 没有并发控制
- 没有告警
- 不知道积压了多少任务
后台任务不是“另起一个线程”这么简单。
它至少要回答:
1 | 任务在哪里存? |
三、任务表是最简单可靠的起点
小项目不一定一上来就用 RabbitMQ、Kafka、Redis Stream。
很多时候,一张数据库任务表就够了。
示例:
1 | CREATE TABLE jobs ( |
状态可以简单设计为:
1 | pending |
后台 worker 定时拉取 pending 且 run_at <= now() 的任务执行。
这套方案不酷,但足够透明。
你可以直接用 SQL 查任务状态,也容易排查问题。
四、任务领取要防止多个 worker 抢同一条
如果多个 worker 同时跑,不能让它们拿到同一条任务。
PostgreSQL 里可以用:
1 | SELECT id |
这可以让多个 worker 并发领取不同任务。
拿到任务后,在同一个事务里把状态改成 running。
核心原则是:
1 | 领取任务和标记任务必须是原子过程。 |
否则并发 worker 很容易重复处理。
五、重试要有退避策略
失败后立刻重试,通常不是好主意。
如果第三方接口挂了,你立刻重试 100 次,只会让情况更糟。
更合理的是指数退避:
1 | 第 1 次失败:1 分钟后重试 |
可以简单实现:
1 | next_run_at = now + min(2^attempts, 60) minutes |
失败时更新:
1 | UPDATE jobs |
超过最大次数后,标记为 failed。
失败不是问题。
失败后不可见,才是问题。
六、任务必须考虑幂等
后台任务天然可能重复执行。
原因很多:
- worker 执行到一半崩溃
- 执行成功但状态更新失败
- 锁超时后任务被重新领取
- 消息队列至少一次投递
- 人工重新触发失败任务
所以任务处理逻辑要尽量幂等。
比如发送优惠券任务:
1 | 不要直接 insert coupon |
比如同步搜索索引:
1 | 用 document_id 覆盖写入 |
后台任务的正确心态是:
1 | 它可能会执行多次。 |
你接受这个事实,设计就会稳很多。
七、payload 不要塞太多动态数据
任务 payload 常见写法:
1 | { |
worker 执行时再查数据库。
不要轻易把用户完整信息塞进去:
1 | { |
除非你明确需要任务创建时的快照。
否则 payload 太大、太旧,很容易造成数据不一致。
我的习惯是:
1 | payload 里放最小业务标识 |
这样任务更轻,也更容易兼容字段变化。
八、任务类型要有清晰边界
不要写一个万能任务:
1 | type = "run_script" |
短期很灵活。
长期很难维护。
更推荐明确任务类型:
1 | send_welcome_email |
每种任务有自己的 handler。
这样日志、重试、告警、统计都更清楚。
当任务类型多起来后,可以建立一个 handler registry:
1 | job type -> handler |
不要让一个巨大 switch 膨胀到几千行。
九、必须有可观察性
后台任务最怕悄悄失败。
至少要能看到:
- pending 数量
- running 数量
- failed 数量
- 重试次数
- 最老 pending 等待时间
- 每类任务执行耗时
- 每类任务失败率
日志里至少要有:
1 | job_id |
不要只打:
1 | job failed |
这种日志没有排查价值。
后台任务一旦不可观察,就会变成系统里的黑洞。
十、要有人工补偿入口
再好的重试也不能覆盖所有情况。
比如:
- 第三方接口修复后,需要重跑失败任务
- 某个任务 payload 错了,需要人工修正
- 某批任务卡住了,需要重新置为 pending
- 某些任务已经没有意义,需要取消
所以后台任务最好有管理入口。
最简单也可以是内部脚本:
1 | job retry --id 123 |
不要让运维操作只能靠手写 SQL。
SQL 可以应急,但不应该是常规操作界面。
十一、我的建议
后台任务可以按这个顺序演进:
1 | 小项目:数据库 jobs 表 + 定时 worker |
不要一开始就上最复杂的队列。
也不要一直停留在“开个线程跑一下”。
一个可靠的后台任务系统,至少要有:
- 持久化
- 状态
- 重试
- 幂等
- 并发控制
- 可观察性
- 人工补偿
异步不是把问题丢到后台。
异步是把问题变成可以慢慢处理、可以重试、可以观察的任务。