五、别人家怎么做,我们打算怎么做 · znzryb
做设计之前,先看看别人家怎么做。下面五个开源 OJ 都是**拉下源码当场读的**(2026-10-01 前后的默认分支),每条结论后面标了可信度:
- **源码确认**:读到了原文;
- **推断**:从代码推出来的,没实际跑过;
- **未核实**:没找到证据。
带着同一组问题去看每一家:
1. 谁决定这条提交给哪台评测机?走什么通道?
2. 排队中、评测中的状态存在哪?
3. 评测机判到一半掉线,这条提交怎么办?
4. 中心(派发的那个进程)自己崩了重启,在途的提交怎么办?
5. 一份迟到的旧结果能不能把新结果盖掉?
## QDUOJ(青岛大学 OnlineJudge)
**派发**:中心推。网页端把评测任务丢进 Redis(dramatiq 任务队列),后台 worker 取出任务,在数据库里锁住评测机表、挑当前任务数最少的一台,然后**同步 HTTP POST** 整份代码过去,一直等到评测结束拿回结果。(源码确认,`judge/dispatcher.py`)
```python
servers = JudgeServer.objects.select_for_update().filter(is_disabled=False).order_by("task_number")
```
**状态**:任务在 Redis;没有空闲评测机时放进另一个 Redis 列表 `waiting_queue`;每台评测机「正在跑几个」是数据库里的一个计数器。
**评测机掉线**:连接断了,提交直接改成 `SYSTEM_ERROR`,**不重新排队**。发请求那一句 `requests.post(...)` **没有设超时**;评测机挂住但 TCP 没断的话,会一直等到 dramatiq 的 1 小时上限,而且不重试。(源码确认)
**中心崩溃**:已经是 `JUDGING` 的提交没有任何地方捞回来;worker 崩了,计数器还会一直偏高。(推断)`waiting_queue` 只在评测机发心跳或者一次评测结束时才弹,而且每次只弹一个。GitHub 上 [#492「提交完一直显示等待评分」](https://github.com/QingdaoU/OnlineJudge/issues/492)、[#145](https://github.com/QingdaoU/OnlineJudge/issues/145)(评测机禁用期间交的题,启用后不会自动开始)跟这个机制吻合。
**迟到结果**:没有版本号,整行覆盖。好在是同步请求,结果只会从发请求的那个 worker 回来。
## DMOJ
**派发**:中心推,**TCP 长连接**。评测机主动连到一个叫 bridge 的中枢进程;网站把提交交给 bridge;bridge 在同档位里挑负载最小的空闲评测机推过去,每台评测机同时只判一条。评测过程中评测机一个包一个包地把进度推回来。(源码确认,`judge/bridge/judge_list.py`)
**状态**:等待队列**完全在 bridge 进程的内存里**(一个双向链表加两个字典),数据库只存每条提交的状态。
**评测机掉线**:连接一断,手上那条直接标成 `IE`(内部错误),**不自动重排**,要管理员手动重测:
```python
if self._working:
Submission.objects.filter(id=self._working).update(status='IE', result='IE', error='')
```
握手后连接 60 秒超时,bridge 每 10 秒 ping 一次。评测机那边发包出错就直接 `os._exit(1)`,源码注释自己说这是「靠 Docker 重启评测机的大锤式做法」。(源码确认)
一个有意思的发现:bridge 给每次派发建了一个「20 秒内没确认收到就断开」的计时器 `threading.Timer(20, ...)`,但整个文件里**没有调用 `.start()`**,这个保护从来没生效过。(源码确认)
**中心崩溃**:内存队列整个丢。bridge 启动时把所有还在排队、处理中、评测中的提交**一律标成 `IE`**:
```python
Submission.objects.filter(status__in=Submission.IN_PROGRESS_GRADING_STATUS).update(status='IE', ...)
```
也就是说:**不会卡住,但也不会恢复**,全部要重测。(源码确认,`judge/bridge/daemon.py`)
**迟到结果**:确认包会核对「你确认的是不是我派给你的那条」,对不上就两条都标 IE 并断开。但进度包和结束包只按提交 id 写库,不核对是谁在判、没有版本号;安全主要靠「一台评测机一条连接」「同名评测机重新注册时先踢掉旧连接」。(推断)[#1337「重测时 bridge 里的竞态」](https://github.com/DMOJ/online-judge/issues/1337) 报过评测机同时接了两条的情况,至今还开着。
## HydroOJ
**派发**:**评测机拉,但拉的动作由服务端代劳**。评测机用 WebSocket 连上来,报上自己的并发数、支持的语言;服务端为这条连接建一个「消费者」,按条件去 MongoDB 里抢任务,抢到就推给评测机:
```ts
const res = await coll.findOneAndDelete(q, { sort: { priority: -1 } });
```
(源码确认,`packages/hydrooj/src/model/task.ts`)
**状态**:待评任务在 MongoDB 的 `task` 集合。注意那个 `findOneAndDelete`:**任务一被取走就从库里删掉**,评测中的任务只存在这条连接对象的内存里。
**评测机掉线**:连接断开时,对每个在途任务调 `reset()`:把记录改回「等待中」,再把任务放回 MongoDB。**会自动重排**,这一点比 DMOJ、QDUOJ 都好。心跳:30 秒没消息发 ping,超过 80 秒没消息就断开连接,然后走重排。(源码确认)
**中心崩溃**:这是它的坑。任务取走时就删了,进程直接崩掉的话,内存里那份也没了,记录会一直停在「已取走 / 评测中」。我在源码里没找到启动时扫这些状态、放回队列的逻辑。(推断)[#60「HydroJudge 重启后第一个收到的提交将会永远处于 Fetch 状态」](https://github.com/hydro-dev/Hydro/issues/60) 是同一类问题(已关闭,修法未核实)。
**迟到结果**:结果只认当前这条连接上登记过的任务,断开的评测机交不进来。但写库本身没有版本号;评测途中被重测的话,旧那份结果可能写进新一轮。(推断)
## HOJ
**派发**:中心推。后端把任务推进 Redis 列表,一个异步线程取出来,每 2 秒尝试挑一次评测机,**最多 300 次**(约 10 分钟)。挑机器:从 Nacos(服务注册中心)拿健康实例,在 MySQL 里锁住评测机表,挑任务数最少的,然后同步 HTTP POST 过去,读超时 10 分钟。(源码确认)
**状态**:排队在 Redis;进行中的在后端 JVM 内存里;**评测机自己直接写 MySQL 的评测表**。最后这一点跟我们现在很像:评测机手里有数据库权限。
**评测机掉线**:HTTP 出异常,提交标成「提交失败,请重新提交」,**不重排**。(源码确认)
**中心崩溃**:已经从 Redis 弹出、正在调度线程里的任务会丢。兜底是一个**每 20 分钟跑一次的定时任务**,把超过 15 分钟还是 `PENDING` 的提交自动重测。但已经是 `COMPILING` / `JUDGING` 的(评测机已经开始写库了)不在兜底范围内,会一直卡着。(源码确认定时任务;卡住的部分是推断)
**迟到结果**:评测记录上有一个 `version` 字段,也注册了乐观锁拦截器,但这个字段**没加 `@Version` 注解,注释写着「废弃」**,起不到作用。最终结果按 id 直接覆盖,被重测过的旧评测晚到时可以盖掉新结果。(源码确认字段状态;覆盖是推断)[#55](https://github.com/HimitZH/HOJ/issues/55) 有人指出它的重试机制形同虚设。
## UOJ
**派发**:纯**评测机拉**,HTTP 轮询,每 2 秒问一次。服务端用一句带条件的 UPDATE 抢占:
```php
DB::update("update submissions set $set_q where id = {$submission['id']} and status = '$status'");
if (DB::affected_rows() != 1) { $submission = null; }
```
这跟我们第 4 节说的「每一次写都带条件、看影响行数」是同一个思路。(源码确认)
**状态**:就是 MySQL 里提交的状态字段,没有单独的队列。网站本身不存状态,重启无影响。交结果失败会每 2 秒一直重试。
**评测机掉线**:提交停在 `Judging`,我没找到按时间回收的逻辑。(未核实别处有没有)
**迟到结果**:只看状态是不是 `Judging`,不看是哪台评测机领的。
## 放在一张表里

上面这张图用颜色给每一格打了分,下面的表是每一格的原文依据:
| | 谁派活 | 评测中的状态在哪 | 评测机掉线 | 中心重启 | 防迟到结果 |
|---|---|---|---|---|---|
| QDUOJ | 中心推,同步 HTTP | Redis + 数据库计数 | 标系统错误,不重排 | 卡在评测中 | 无 |
| DMOJ | 中心推,TCP 长连接 | bridge 内存 | 标 IE,不重排 | 启动时全标 IE | 靠「一条连接」 |
| Hydro | 评测机拉(WebSocket) | 取走就删 + 连接内存 | **自动重排**,80 秒判死 | 可能卡住 | 按连接核对,无版本号 |
| HOJ | 中心推,HTTP 重试 | Redis + JVM 内存 | 标「提交失败」 | PENDING 超时重测,评测中卡住 | 版本字段已废弃 |
| UOJ | 评测机拉,HTTP 轮询 | 数据库状态字段 | 卡在评测中 | 无影响 | 只看状态 |
| **我们(现在)** | 评测机直连数据库拉 | 数据库 + `worker_id`/心跳 | 60 秒回收重排,最多 3 次 | 无中心 | `worker_id` 归属闸门 |
| **我们(仲裁器)** | 评测机长轮询拉 | 数据库 + 租约三件套 | 60 秒回收重排,不影响别人 | 宽限期后照常 | `lease_token` 防护令牌 |
## 从别人家学到什么
**一、五家都没有真正的「租约 + 代次」。**中心一崩,评测中的提交要么丢、要么卡、要么一刀切标成错误。这说明第 4 节那三条原则(事实在库里、写都带条件、凭证只增不减)不是过度设计,而是开源 OJ 普遍缺的那一块。我们的 Prepare 建包任务已经有了,把它推广到评测提交是顺理成章的。
**二、「取走就删」是 Hydro 最大的坑,我们不能学。**任务从队列里删掉、只留在内存里,中心一崩就没了。我们的做法是**行永远不删**,只改状态和租约,内存里只放判断不放事实。
**三、DMOJ 的「启动时全标 IE」是一种诚实但浪费的做法。**它保证了不会卡住,代价是每次重启都要人工重测一批。我们的宽限期是更进一步:不标错,给在途的评测机一个完整租期来报平安。

**四、「断线就重排」比「断线就标错」好,Hydro 做对了。**我们现在就是这么做的(60 秒回收、最多 3 次),仲裁器方案保持不变。
**五、UOJ 那句带条件的 UPDATE 是对的方向。**`WHERE status = '...'` 加上看影响行数,是最朴素的乐观并发控制。它缺的只是「哪一次领取」这个维度,我们用 `lease_token` 补上。
**六、长连接(DMOJ、Hydro)vs 长轮询(我们打算的)。**长连接能实时推进度,断线也能立刻发现;长轮询更简单,能穿过各种代理,每个请求都是独立的,断了就重发,不用维护连接状态。我们的评测机只有三个,进度更新 5 秒一次足够,**长轮询的简单更值钱**。以后评测机多了、要实时推每个测试点的进度,再考虑换 WebSocket,租约模型不用变。
**七、警惕「写了但没生效」的保护。**DMOJ 那个没 `.start()` 的计时器、HOJ 那个没加注解的 `version` 字段,都是「代码里有,实际没用」。我们的每一条保护都要有一个测试证明它真的会触发:比如故障报告里要求的「worker A 停在事务半路,6 秒后会话确实被断开」。
## 我们打算怎么做
把前面四节收拢成一张落地清单:
1. **数据库加列**:`lease_token`、`lease_gen`、`lease_expires_at`;评测历史表加 `(submission_id, lease_token)` 唯一约束;
2. **仲裁器进程**:跑在生产机,本机连数据库;启动先抢领导锁(会话级 advisory lock,抢不到就等,不阻塞任何人),抢到后执行一次宽限期,再开始派活和回收;
3. **三个接口**:`claim`(长轮询 25 秒,带 `request_id`)、`heartbeat`(带 `lease_token`,0 行回 410)、`result`(带 `lease_token`,重复交回同一结果);
4. **worker 改造**:去掉所有数据库代码;结果先落本地磁盘再上报,启动时补交;
5. **调度搬进内存**:家用机优先、三条队列轮转、IDE 运行插队、不健康判定;
6. **监控**:仲裁器心跳绑在派活循环上;「有人在等、有活、30 秒没配对」告警;
7. **分阶段切换**:加列 → 一个家用 worker 先切 → 家用机全切、删数据库账号 → 生产机 worker 切、删全局锁和共享轮次行;每一步都能改配置退回;
8. **测试**:每一个异常场景(第 4 节九个)写一条用真实 Postgres 跑的测试,证明保护真的会触发。

这份方案目前只写在这篇博客里,还没有设计文档和代码。读完觉得方向对,下一步就是把它整理成正式的设计文档,再开工。
## 本节要点
- QDUOJ、DMOJ、HOJ 是中心推;Hydro、UOJ 是评测机拉;
- 掉线后只有 Hydro 自动重排,其余要么标错要么卡住;
- 中心重启后:DMOJ 全标 IE,Hydro 因「取走就删」可能卡住,HOJ 只兜底 PENDING;
- 五家都没有「租约 + 代次」,我们的 Prepare 已经有,仲裁器把它推广到评测;
- 学 Hydro 的断线重排,不学它的取走就删;学 UOJ 的带条件更新,补上 `lease_token`;
- 每一条保护都要有测试证明它真的会触发。