科技与工作流 · 2026-07-05

任务队列如何让长任务不丢进度

长任务要通过 job id、状态机、进度元数据、检查点、幂等和重试,把后台执行变成可追踪、可恢复、可运营的工作链路。

很多系统一开始都会把长任务塞进一次请求里:用户上传一个文件,接口开始解析;运营点一下生成报告,后端开始跑脚本;编辑提交一篇文章,服务立刻抓取素材、生成图片、写入数据库、通知群聊。只要任务三五秒结束,这种写法还能凑合。一旦任务变成几分钟、几十分钟,麻烦就出现了:浏览器断开、接口超时、进程重启、外部服务失败、用户刷新页面,然后大家开始问同一个问题:刚才跑到哪一步了?

任务队列要解决的不是“让任务永远不会失败”,而是把长任务拆成可记录的阶段。请求只负责提交任务并返回一个 job id,队列负责保存待处理任务,worker 负责执行,状态存储负责记录 queued、started、progress、retry、failed、finished 等状态,结果存储负责保存交付物或失败原因。这样,即使用户离开页面,任务仍然有可查询的身份;即使 worker 中途失败,系统也能知道它处在哪个阶段。

“不丢进度”在这里不是夸张口号,而是一个工程目标:每次重要状态变化都能被记录,每次失败都能被解释,每次重试都能知道从哪里继续,每次人工接手都能看到证据。

队列结构图

先把“提交”和“执行”分开

长任务最容易出问题的地方,是把提交和执行绑在一起。用户点按钮后,服务端如果直接开始处理大文件、调用多个外部接口、生成复杂结果,就会把用户连接、HTTP 超时、业务状态和后台执行绑成一团。任何一个环节断掉,前台看到的都是“失败了”,但后台可能已经跑了一半。

任务队列的第一步,是把这两个动作分开。提交阶段只做三件事:校验输入、创建 job、返回 job id。执行阶段由 worker 从队列取出 job,按步骤处理。用户之后查看进度,不是继续盯着原来的请求,而是用 job id 查询任务状态。

RQ 文档里的 job 生命周期很好理解:job 被放入队列后是 queued;worker 取到后变成 started;执行结束后进入 finished;遇到错误会进入 failed;依赖未完成时可以是 deferred;未来执行或重试间隔中可以是 scheduled;还有 stopped、canceled 等状态。这个状态表告诉我们,长任务不是只有“成功”和“失败”两个结果,它应该有一条可读的运行轨迹。

重试检查点流程图

进度不是日志,它要能回答“现在到哪了”

很多团队以为只要有日志,就等于有进度。日志当然重要,但日志常常是写给排障的人看的;进度是写给使用者和调度系统看的。一个好的进度字段,应该能回答三件事:当前阶段是什么、已经处理到哪里、下一步可能是什么。

Celery 文档给了一个直观例子:任务可以用 update_state 写入自定义状态,比如 PROGRESS,并在 meta 里放 currenttotal。这类元数据可以被前台用来展示进度条,也可以被运营后台用来判断任务是否卡住。

实际系统里,进度不一定非要是百分比。对长任务更有用的,往往是阶段和检查点:已接收文件、已完成格式校验、已处理第 230 行、已生成临时结果、等待人工确认、正在上传最终文件。百分比在某些任务里很难准确,因为每一步耗时不同;阶段与检查点更容易被复用,也更容易支持恢复。

检查点让重试不从头开始

队列本身只能帮你调度任务,不能替你设计业务恢复点。要让长任务重试后不从头开始,需要在任务内部留下检查点。检查点可以是已经处理的文件分片、最后一条记录 ID、已生成的中间文件、外部请求的幂等键、当前阶段的结果摘要。

举个例子,系统要处理一个十万行 CSV。如果每次失败都从第一行重读,用户会等得很难受,外部服务也会被重复调用。更好的做法是把任务拆成批次,处理完每一批就记录批次编号、成功数量、失败数量和错误样例。下次重试时,worker 可以从最近的安全位置继续,而不是把所有工作再做一遍。

这里要提醒一点:检查点不是越细越好。太粗会导致重复工作多,太细会让状态存储和业务逻辑变复杂。工程上常见的折中,是按“可独立提交的业务单元”记录,例如一页数据、一批图片、一个文件、一个章节、一个客户记录,而不是每执行一行代码都写状态。

任务状态机图

重试要配合幂等

失败重试是任务队列最常见的能力之一,但重试不是简单地“再跑一遍”。如果一次扣款、一次发信、一次发布、一次写入外部系统被重复执行,结果可能比失败更糟。Celery 文档提到,任务消息只有被 worker acknowledgment 后才会从队列中移除;如果任务是幂等的,可以设置 acks_late,让 worker 在任务返回后再确认消息,但这也意味着 worker 中途崩溃时任务可能被再次执行。文档也提醒,任务应该设计成幂等,避免同一参数多次执行带来意外影响。

幂等的意思可以说得很朴素:同一个 job 重试多次,最终不应该多发一封邮件、多扣一笔钱、多创建一份重复记录。实现方式可以是幂等键、唯一约束、外部请求去重、写入前检查、输出文件命名规则、阶段状态判断。任务队列负责“什么时候再试”,业务代码负责“再试时不会乱”。

BullMQ 的重试文档也说明,失败任务可以通过 attempts 和 backoff 配置重试次数与等待策略;backoff 可以是固定间隔,也可以是指数退避。这个设计的意义,是给短暂失败一点恢复时间,同时避免任务失败后立刻高频冲击同一个外部服务。

stalled job 说明“worker 活着”也要被检查

长任务还有一种尴尬状态:任务没有明确失败,但也没有继续推进。BullMQ 文档把这类情况称为 stalled job。比如 CPU 很忙,worker 来不及续约锁,系统可能会认为 job stalled;stalled job 会被移回等待状态,由其他 worker 处理,或者在超过允许次数后进入失败集合。文档还提醒,worker 要经常把控制权还给 Node.js event loop,避免 CPU 密集任务让锁续约失败。

这对所有队列系统都有启发:只记录开始和结束还不够,系统还要知道 worker 是否仍在推进。可以用 heartbeat、更新时间、阶段超时、worker 名称、最近一条进度记录来判断任务是不是卡住了。对用户来说,“正在处理”如果半小时都不变,其实等于没有进度;对系统来说,这也应该进入待检查状态。

运维清单

状态机比一串布尔字段更清楚

很多业务表一开始会用几个布尔字段描述长任务:is_runningis_donehas_error。写着写着就会出现矛盾:既 done 又 error,或者 running 为 false 但没有结果。更稳的方式,是给任务设计一张状态机。

状态机可以很轻:submitted、queued、started、progress、waiting_for_review、retrying、failed、completed、canceled。每个状态都要回答三个问题:谁可以进入这个状态,进入时要写哪些字段,下一步能去哪里。比如 failed 需要错误类型、可重试标记、最后一次尝试时间;completed 需要结果路径、完成时间、校验摘要;waiting_for_review 需要接手人和待确认事项。

Temporal 的 Workflow Execution 文档给了更高阶的参照:Workflow Execution 被描述为 durable、reliable、scalable 的函数执行;失败后可以从最近记录的事件继续;运行状态可以是 Open 或 Closed,Open 中的 Running 既可能在推进,也可能在等待某个结果。Temporal 不等同于普通任务队列,但它把“长流程要记录状态、等待和恢复”这件事讲得很清楚。

前台查询进度,后台留下证据

长任务如果只在后台跑,用户体验会很差;如果只把进度推给前台,排障又会很困难。比较实用的结构是两层:前台有一个轻量 status API,后台保留更详细的证据记录。

status API 可以返回 job id、标题、状态、当前阶段、已处理数量、总量或阶段数、最近更新时间、可选动作和结果链接。后台证据可以记录输入摘要、worker、尝试次数、错误堆栈、外部请求 ID、检查点、输出路径和人工操作记录。前者给用户和业务同事看,后者给工程团队排查。

这也是为什么 BullMQ 的 worker 文档会提到 progress 事件、completed 事件和 failed 事件;这些事件不仅能驱动前台展示,也能接入告警、仪表盘和审计记录。队列不是把任务藏到后台,而是把后台处理变得可见。

哪些任务适合进队列

并不是所有动作都要进队列。适合放进队列的任务,通常有几个特征:耗时超过一次请求的舒适范围;需要调用不稳定的外部服务;可以拆成阶段;用户不需要马上拿到完整结果;失败后可以重试或人工接手。比如大文件解析、批量导入、图片转码、长报告生成、通知批量发送、数据同步、模型推理、素材归档,都适合用队列处理。

反过来,有些动作不适合简单丢进队列后不管。比如用户登录校验、即时库存判断、强一致的支付确认、必须同步返回的权限判断,通常需要更严格的事务与同步反馈。队列可以参与后续处理,但不应该遮住用户当下必须知道的结果。这个区分很重要:队列不是逃避复杂性的地方,而是把可异步、可追踪、可恢复的工作放到更合适的位置。

一个可落地的长任务清单

如果你正在设计一个长任务系统,可以按下面的清单开始。

第一,提交时只返回 job id,不让用户等待完整处理。第二,为每个 job 记录输入摘要和幂等键。第三,设计状态机,不用多个布尔字段拼状态。第四,任务执行时写阶段与检查点,而不是只写日志。第五,对外部接口设置超时和重试间隔,不让 worker 无限等待。第六,把可以重复执行的动作做成幂等。第七,把大任务拆成可独立提交的批次。第八,给 stalled、超时、失败、取消、人工确认都设计状态。第九,提供 status API 和后台证据页面。第十,定期清理过期结果和失败记录,避免结果存储无限膨胀。

这些动作听起来比“直接跑脚本”麻烦,但它们换来的是可运营性。用户知道任务在哪里,工程师知道失败发生在哪一步,系统知道是否应该重试,业务负责人知道是否需要人工接手。

小结

任务队列让长任务不容易丢进度,靠的不是某个单点能力,而是一组配合:job id 让任务有身份,状态机让过程可读,进度元数据让用户知道当前阶段,检查点让重试有起点,幂等让重复执行可控,stalled 检测让卡住的任务被发现,结果存储让交付物可追溯。

所以,判断一个长任务系统是否成熟,不要只问“有没有队列”。更应该问:任务能不能查到,状态能不能解释,失败能不能复盘,重试会不会造成副作用,进度能不能被接手的人读懂。能回答这些问题,长任务才从一段后台代码,变成一条可以交付的工作链路。