L.Leverage Learn从第一行代码,到读懂真实后端查看项目源码

OJ 评测系统 / 第 22 课

结果结算与重新评测

追踪评测结果如何在 SQL 事务中落库、避免重复统计,并在重判后修复排名。

◷ 预计 18 分钟 · 面向初学者

Worker 得到 AC、WA 或错误状态后,还不能直接把结果当作已经保存。ReceiveService.finalize 是评测结果的结算入口:它拒绝未完成、状态不是整数或仍处于 PENDING、JUDGING、COMPILING 的结果;随后开启数据库事务并对提交记录加写锁。这样并发到达的结果不会同时根据同一条旧状态重复增加统计。

事务中先核对 provider 和 attemptId。队列里的任务可能已经过期,例如管理员重判后旧 Worker 才结束;旧 attempt 不匹配时,finalize 不修改新评测的数据。若提交已经是终态,也不再重复执行统计更新。首次结算会更新提交状态、评测用时/内存、判题结果和编译信息,并根据之前保存的 judgedStatus 计算提交数、AC 数的增量。重判前的状态因此很重要:例如旧结果是 AC,新结果是 WA,统计需要减去原先的 AC,而不是只累加一次 WA。

事务开始 → 锁提交 → 校验 provider/attempt/当前状态
         → 调整 SQL 统计与结果 → COMMIT
         → 发布 Redis 排名

数据库事务成功后才发布竞赛或课程排名到 Redis。SQL 持有提交状态和统计的权威值,Redis 是排名读取路径,二者不是一个原子事务。如果 Redis 发布失败,SQL 结果仍已提交,排名可能暂时落后;相同结算被重试时,终态检查会跳过 SQL 统计更新,但仍会重新发布排名。这让通知失败可恢复,而不会把提交数或 AC 数重复加一。它不意味着 SQL 与 Redis 瞬时一致,排查时应分别查看数据库终态、统计字段和 Redis 排名更新时间。

重新评测先由 SubmissionService.rejudge 开启 SQL 事务,锁定提交,保存旧状态、用时、内存和评测信息到 RejudgeLog,生成新的 judgeAttempt,将状态重置为 PENDING 并清空旧结果。事务提交后才派发新队列任务。attempt 标识隔离新旧 Worker;如果旧任务随后回报,它不能覆盖新一轮评测。重判的 SQL 统计不会在“重置为 PENDING”时随意清零,最终由新终态与 judgedStatus 的差值更新。

练习排错时不要只看 Redis 榜单判断结算是否成功。先用 submission ID 找 SQL 行及当前 attempt,再查队列任务身份、RejudgeLog、评测结果字段;若 SQL 已终态而排名没更新,检查事务后的发布错误和重试路径。若旧结果覆盖了新结果,检查结算是否在锁内匹配了 attempt;若统计翻倍,检查终态短路是否发生在任何计数更新之前。

用“增量”而非“总量重算”解释这套逻辑会更清楚。设上一终态为 old、本次终态为 new,AC 数变化可以看成 是否 new 是 AC - 是否 old 是 AC;由 WA 重判成 AC 加一,由 AC 重判成 WA 减一,AC 重判成 AC 则不变。不同比赛、课程和全局范围还可能各自维护统计,不能把一个榜单字段随意复制到另一个范围。并发锁与对其他已 AC 提交的检查,避免重复奖励同一用户;Redis 排名发布则读取事务后的聚合值。做数据修复时先确认统计口径和受影响范围,避免一次手工重放又制造新的重复计数。

小练习

一次结果已写入 SQL,但 Redis 发布时失败。队列重试 finalize 后,应该重复增加 AC 数吗?为什么?

**答案:**不应该。记录已处于终态,重试跳过事务里的重复统计更新,但可以再次发布 Redis 排名。若旧 attempt 与当前记录不符,则应直接拒绝旧结果的修改。

去真实仓库看看

链接固定在公开提交 de34b268e75f293e904d7734c194a4ff2467b7a8:

互动练习:防止旧评测覆盖重测结果

提交 S 已有第 1 轮结果。管理员发起第 2 轮重测,第 1 轮 worker 此时才迟到上报。按安全处理顺序排列。

  1. 只接受当前轮次,忽略迟到的第 1 轮结果
  2. 写回时核对结果所属轮次
  3. 把第 2 轮标识交给 worker
  4. 为重测创建第 2 轮标识

用 ↑ ↓ 调整顺序,再检查答案。也可以用 Tab 和回车操作。

✓
这一课,你已经能……

当前 attempt 的终态结果只结算一次;SQL 是提交与统计的权威状态,Redis 排名在事务提交后发布并可重试。