用户上传一个视频、文档或一批图片后,最容易焦虑的不是等待本身,而是等待期间系统只显示一个转圈图标。页面刷新后看不到任务,进度停在 99%,点击取消没有反应,失败后又只剩“处理异常”。这些体验问题背后通常是同一个工程问题:系统没有把异步任务的真实状态和用户能理解的反馈分开设计。
本文使用脱敏任务PT-20261008-001:一份已通过校验的输入资产进入处理队列,Worker 依次完成准备、执行、结果验证和交付。示例中的时间、进度和文件均为教学数据;不公开真实视频、内部队列地址、模型服务或产品编排。
环境边界:Java 17、Spring Boot 风格服务层、Python 3.11 Worker、MySQL 8.x。本文讨论任务队列、进度语义与取消协作,不依赖某一种消息中间件;具体并发量和超时必须按实际资源评估。
目录
- 为什么“处理中”不能作为唯一状态
- 固定案例:用户到底应该看到什么
- 队列与领取:谁有资格开始执行任务
- 进度设计:阶段事实优先于虚假百分比
- 取消设计:请求取消不等于立即停止
- 服务层与 Worker:状态转换如何协作
- 预期输出与自动测试
- SQL 验证:如何发现卡死、重复领取和进度倒退
- 异常边界与上线验收
- 小结和延伸阅读
一、为什么“处理中”不能作为唯一状态
把QUEUED和RUNNING都翻译为“处理中”,会掩盖两个完全不同的问题:前者可能还在等待资源,后者已经被某个 Worker 领取。把READY、FAILED和CANCELLED也混进同一个模糊状态,用户不知道是否应继续等、重新提交还是下载结果,运维也无法判断瓶颈在队列、执行器还是产物验收。
对用户展示的状态可以保持简洁,但后台状态必须可验证。本文采用:QUEUED、RUNNING、CANCEL_REQUESTED、READY、FAILED、CANCELLED。其中QUEUED有等待位置,RUNNING有领取者和心跳,READY有正式结果,FAILED有错误类别,CANCEL_REQUESTED表示取消已被受理但尚未到达安全停止点。
图1:状态的价值不是增加术语,而是让用户和系统对“现在发生了什么”达成一致。
二、固定案例:用户到底应该看到什么
任务PT-20261008-001在用户侧只需要展示与下一步有关的信息:当前阶段、阶段说明、可否取消、可否下载、最近更新时间和失败后的可执行建议。它不应展示真实机器名、命令行、内部目录或堆栈。
| 后台状态 | 用户展示 | 必须具备的事实 | 用户可执行操作 |
|---|---|---|---|
QUEUED | 等待处理 | 已受理时间、队列位置或等待说明 | 查看、取消 |
RUNNING | 正在处理 | 当前阶段、最近心跳、阶段进度 | 查看、申请取消 |
CANCEL_REQUESTED | 正在停止 | 请求时间、安全点说明 | 查看,避免重复点击 |
READY | 处理完成 | 正式产物和验收摘要 | 预览、下载 |
FAILED | 未完成 | 脱敏错误类别、失败阶段 | 查看原因、按条件重试 |
CANCELLED | 已取消 | 停止时间、临时产物处置结果 | 重新提交 |
例如任务进入RUNNING后,不显示“67% 完成”这种没有来源的数字,而显示“正在验证输出文件,第 3/4 阶段”。如果 Worker 的第三阶段确实有可测的子单元,如已处理帧数或页数,才可以同时显示其确定进度和总数。
图2:状态反馈要帮助用户判断下一步,而不是暴露 Worker 的运行细节。
三、队列与领取:谁有资格开始执行任务
任务创建成功只代表可以进入队列,不能代表已经开始。无论使用数据库轮询、消息队列还是调度框架,都必须有一个原子“领取”动作:只有将状态从QUEUED条件更新为RUNNING的 Worker,才取得执行权。否则两台 Worker 恰好同时读取同一任务时,可能生成两份结果或竞争覆盖临时目录。
下面用 MySQL 的条件更新表达领取语义;影响行数为1才表示成功。生产系统还需要限制每个 Worker 的并发槽位,并记录worker_token和心跳时间,而不是长期依赖一个进程名称。
UPDATEtool_jobSETstatus='RUNNING',worker_token=:workerToken,started_at=NOW(),heartbeat_at=NOW(),stage_code='PREPARING'WHEREid=:jobIdANDstatus='QUEUED';若更新为 0 行,Worker 必须放弃执行:任务可能已经被其他执行者领取、已被取消,或者状态已被人工处理。这里不能采用“再查一下仍然执行”的补救逻辑,否则原子领取就失去意义。
图3:队列负责等待顺序,领取动作负责执行唯一性,心跳负责识别失联执行者。
四、进度设计:阶段事实优先于虚假百分比
进度必须能回答“数字从哪里来”。一个稳妥的做法是将任务拆成稳定阶段,并为每个阶段定义可验证的完成条件:
| 阶段代码 | 用户文案 | 进入条件 | 完成条件 | 是否适合展示百分比 |
|---|---|---|---|---|
PREPARING | 正在准备输入 | 已成功领取 | 输入资产和快照可读取 | 否 |
PROCESSING | 正在执行处理 | Worker 已启动处理单元 | 已处理单元数达到总数 | 仅总数已知时 |
VERIFYING | 正在验证结果 | 临时产物出现 | 摘要可读且验收通过 | 否 |
DELIVERING | 正在提交结果 | 验收通过 | 正式对象和下载记录已建立 | 否 |
对于视频逐帧处理,可以记录processedFrames / totalFrames;对于大模型生成或外部调用,常常只有“请求已发出、响应已收到”两个可靠节点,此时只显示阶段和最近更新时间。把未知耗时伪装成 88%、92%、99%,一旦发生排队、重试或外部限流,就会迅速损害信任。
defprogress_for_frames(processed:int,total:int|None)->tuple[str,int|None]:iftotalisNoneortotal<=0:return"PROCESSING",Nonereturn"PROCESSING",min(100,processed*100//total)defcan_publish_progress(stage:str,percent:int|None)->bool:returnstage=="PROCESSING"andpercentisnotNoneand0<=percent<=100阶段进度应连同updated_at一起保存。页面轮询、SSE 或 WebSocket 只是传输方式;没有可靠状态来源时,换成实时推送也只会更快地推送错误信息。
图4:用户宁可看到“正在验证结果”,也不需要一个没有计算依据的 99%。
五、取消设计:请求取消不等于立即停止
浏览器点击“取消”时,服务层通常无法安全地立刻杀死一段正在写文件或提交对象的工作。正确的语义是先把可取消任务改为CANCEL_REQUESTED,Worker 在阶段边界或安全检查点读取该标记:未开始执行则直接转CANCELLED;正在处理则停止后清理临时产物;已经进入正式提交阶段则根据事务结果继续完成或明确拒绝取消。
@TransactionalpublicvoidrequestCancel(LongjobId){intchanged=jobRepository.requestCancel(jobId);if(changed==0){thrownewIllegalStateException("JOB_NOT_CANCELLABLE");}}// UPDATE tool_job SET status = 'CANCEL_REQUESTED'// WHERE id = :jobId AND status IN ('QUEUED', 'RUNNING');Worker 每完成一个可中断单元,就查询或接收取消信号。不能在“写一半的正式结果”时直接删除对象并声称取消完成;临时目录与正式产物分离,正是为了让取消和交付边界清楚。
六、服务层与 Worker:状态转换如何协作
状态转换集中在少数入口,能减少“任意代码随手更新 status”的问题。服务层负责受理、查询和取消请求;Worker 负责领取、阶段推进、心跳、失败和交付完成。所有转换都携带前置状态,避免迟到的 Worker 把一个已取消任务重新写成READY。
publicvoidreportStage(LongjobId,Stringtoken,Stringstage,Integerpercent){intchanged=jobRepository.updateProgress(jobId,token,stage,percent);if(changed!=1){thrownewIllegalStateException("WORKER_LEASE_LOST");}}publicvoidcomplete(LongjobId,Stringtoken,ResultArtifactresult){validationService.requireDeliverable(result);intchanged=jobRepository.completeRunningJob(jobId,token);if(changed!=1){thrownewIllegalStateException("JOB_STATE_CHANGED");}artifactRepository.insert(result);}实际实现中,登记产物与任务转READY要在同一个短事务内完成;运行 Worker 的长时间过程不应持有这个事务。worker_token相当于一次短租约:如果系统确认 Worker 已失联,恢复流程会在下一篇讨论,不能简单把所有超时任务立刻重跑。
七、预期输出与自动测试
任务查询的输出应让用户和运营人员得到一致结论:
{"jobNo":"PT-20261008-001","status":"RUNNING","stage":"PROCESSING","progress":{"processedUnits":450,"totalUnits":900,"percent":50},"cancellable":true,"updatedAt":"2026-10-08T10:35:20"}测试既要检查重复领取被拒绝,也要检查取消后的 Worker 不能完成交付:
@TestvoidonlyOneWorkerCanClaimAQueuedJob(){ToolJobjob=jobRepository.save(queuedJob());assertThat(jobRepository.claim(job.getId(),"worker-a")).isEqualTo(1);assertThat(jobRepository.claim(job.getId(),"worker-b")).isEqualTo(0);assertThat(jobRepository.find(job.getId()).getWorkerToken()).isEqualTo("worker-a");}@TestvoidcancelledJobCannotBecomeReadyFromLateWorkerReport(){ToolJobjob=runningJob("worker-a");jobRepository.requestCancel(job.getId());jobRepository.markCancelled(job.getId(),"worker-a");assertThat(jobRepository.completeRunningJob(job.getId(),"worker-a")).isEqualTo(0);assertThat(jobRepository.find(job.getId()).getStatus()).isEqualTo("CANCELLED");}还应测试进度边界:未知总数不得返回伪百分比;已完成任务不得继续上报PROCESSING;updated_at长时间不变的RUNNING任务必须进入巡检列表。
八、SQL 验证:如何发现卡死、重复领取和进度倒退
上线后最需要主动发现的是“看起来在运行,实际上已经没人处理”的任务。以下 SQL 使用示例阈值,实际分钟数应按最长正常任务时长确定:
-- 预期结果:0 行。RUNNING 任务必须有领取者和最近心跳。SELECTjob_noFROMtool_jobWHEREstatus='RUNNING'AND(worker_tokenISNULLORheartbeat_atISNULL);-- 预期结果:巡检列表,而不是直接自动重跑。SELECTjob_no,stage_code,heartbeat_atFROMtool_jobWHEREstatusIN('RUNNING','CANCEL_REQUESTED')ANDheartbeat_at<NOW()-INTERVAL15MINUTE;-- 预期结果:0 行。非执行状态不应保留可继续更新的 Worker 租约。SELECTjob_no,status,worker_tokenFROMtool_jobWHEREstatusIN('READY','FAILED','CANCELLED')ANDworker_tokenISNOTNULL;第二条只生成待核实清单。网络抖动、长时间外部调用或重启恢复都可能造成心跳延迟;未经检查就直接把任务标为失败或重新投递,反而可能制造并发执行和重复结果。
九、异常边界与上线验收
| 场景 | 系统应做什么 | 不应做什么 |
|---|---|---|
| 队列积压 | 保持QUEUED,展示等待说明和提交时间 | 伪装成已在执行 |
| Worker 失联 | 通过心跳生成巡检或恢复候选 | 立即启动第二个 Worker 同时执行 |
| 未知总工作量 | 展示阶段和更新时间 | 虚构百分比或预计剩余时间 |
| 取消到达 | 先置为CANCEL_REQUESTED,在安全点停止 | 强行删除可能正在提交的正式结果 |
| 迟到状态上报 | 以状态和worker_token条件更新拒绝 | 覆盖已取消或已失败的最终状态 |
上线验收建议准备一个短任务和一个故意延迟的任务:确认第二个 Worker 无法重复领取;在执行中申请取消并检查临时产物未进入下载列表;暂停 Worker 模拟心跳中断,确认巡检只报警不盲目重跑;再完成一个正常任务,确认只有通过验收的结果才显示下载入口。
图5:用户看到的进度来自后端事实;任务完成必须经过结果验证,而不是停在“运行结束”。
十、小结和延伸阅读
长任务体验的关键不是把刷新频率调高,而是让每一个状态都有清晰含义:排队说明尚未领取,运行有阶段和心跳,进度有可验证依据,取消是协作过程,完成则必须有可下载产物。这样用户知道应该等待、取消、重试还是下载,系统也有证据定位卡在哪里。
下一篇将继续处理更棘手的一层:当 Worker 真正失败、进程中断或临时产物遗留时,怎样通过重试、幂等和检查点恢复,而不把同一任务重复交付。
参考资料:
- Spring Framework:事务管理参考
- MySQL 8.4:UPDATE 语句
- Python asyncio:任务取消