OJ 评测系统 / 第 18 课
从 Bull 队列追到评测 Worker
看提交如何从数据库进入 Bull 队列,再由 Worker 与内部评测服务领取并执行,理解重试和旧尝试保护。
上一课中,创建接口把提交存成 PENDING。但 HTTP 请求不应该一直等到编译和所有测试结束:数据库保存业务记录,队列保存“稍后要做什么”的消息,Worker 在后台消费消息。你可以把它想成取号:号码牌说明轮到哪个工作,真正要处理的代码和题目资料仍从系统记录中读取。
派发的消息为什么很小
SubmissionService.dispatch 把任务名 internal-submission 和 { submissionId, attemptId } 加到 JUDGE_TX_QUEUE。Bull 的 jobId 由两者拼成,配置三次尝试和指数退避(初始延时 1000ms),完成后移除队列任务。消息不附带代码文本;Worker 后续从数据库读取 Submission、SubmissionMisc 和 Problem。这避免队列副本变成第二个代码来源。
create → 存 Submission + Misc → queue.add(jobName, {submissionId, attemptId})
↓
InternalJudgeWorker → InternalJudgeService.submission(job.data)
↓
取提交 / 题目 / 代码 → JudgeEngine → ReceiveService.finalize
Worker 用 @Processor(JUDGE_TX_QUEUE) 注册队列消费者,并以 @Process({ name: INTERNAL_SUBMISSION_JOB, concurrency: 1 }) 处理提交任务,再直接把 job.data 交给 InternalJudgeService.submission。这里的并发数是该处理器配置,不意味着每个 HTTP 请求创建一个 Worker。应用关闭时 Worker 暂停队列、要求评测服务停止活动任务,再关闭队列。
从消息到一次运行
评测服务先构造数据库筛选条件:提交 ID、内部 provider、相同 judgeAttempt,且状态属于 PENDING / COMPILING / JUDGING。如果找不到这次尝试对应的记录,就直接返回,不用旧任务对新记录写结果。如果查到的记录已经不是待处理状态,它会调用 finalize 修复可能遗漏的提交后通知,而不是重新执行代码。
对于有效任务,服务读取题目和代码、查语言运行时、计算语言时间/内存额度,然后把状态置为 COMPILING。随后调用 JudgeEngine.submission;进入用例运行前的回调会再把状态置为 JUDGING。成功结果或编译/基础设施结果最终都交给 ReceiveService.finalize。如果执行中断,JudgeCancelled 的取消情形不会伪造判题结论;队列可以按自身重试配置再次处理。
“至少一次”是理解队列的重要模型:失败重试可能让同一逻辑工作再次被调用,因此不能假定处理代码只运行一遍。Bull 的三次尝试是在同一队列 job 上重试;单条重判则会生成新的 judgeAttempt 并重置状态。尝试标识让旧 job 无法冒充新尝试。数据库更新还以当前状态为条件;重试发现结果已终结时走修复通知的分支。它们共同实现幂等保护,但不是“队列消息永不重复”的承诺。可以把三层职责分开记:数据库保留提交、代码与当前状态;Bull 保留可重试的工作通知;Worker/评测服务负责把通知转为实际执行,并在提交结果时校验任务身份。队列适合承接耗时工作,但它不是数据库记录的替代品,也不代表执行成功。调试时可先确认提交记录和 attemptId,再看 job 是否存在/重试,最后追 Worker 的状态更新与 finalize,而不是只盯 HTTP 返回码。
小练习
同一提交重判后,旧 Worker 延迟返回,为什么不能只凭 submissionId 更新?Bull 三次尝试是否等于三种新的 attemptId?
**答案:**重判创建新的 judgeAttempt,只用提交 ID 会让旧结果覆盖新结果;Worker 也校验尝试 ID 和当前状态。Bull 对同一个 job 的重试仍携带原消息身份;新 attemptId 属于重新发起的评测尝试,而不是队列自动重试。
去真实仓库看看
以下均固定到公开提交 de34b268e75f293e904d7734c194a4ff2467b7a8:
互动练习:把排队与执行分开
用户提交代码后,API 不应同步占住请求等待完整评测。按“接受任务到 worker 产出结果”的基本边界排列。
- 保存最终结果供查询
- 运行评测并收集判定
- worker 领取并确认可处理本轮
- 向队列投递提交标识
- 保存提交与初始排队状态
用 ↑ ↓ 调整顺序,再检查答案。也可以用 Tab 和回车操作。
队列消息只携带 submissionId 与 attemptId;Worker 调用内部服务读取数据库权威数据,尝试身份和当前状态共同阻止过期任务结算。