三、中央仲裁器:换一种分活的办法 · znzryb
> 说明:仓库里此前**没有**中央仲裁器的设计文档,这一节是第一次把它写出来,是方案,不是已经上线的东西。名字里的「仲裁器」是我们自己起的,外面一般叫 dispatcher(派发器)或 judge server。
## 核心想法:只让一个人碰队列
第 2 节最后总结的四个弱点,根子都是同一件事:**三个 worker 都直接在数据库里抢活**,于是锁、轮次、让路判断全靠它们在数据库里互相协调,谁的网差谁就能卡住大家。
中央仲裁器把这件事收回来:

- **仲裁器**是跑在生产机上的一个小进程,跟数据库在同一台机器上,一次来回不到 1 毫秒;
- 它是**唯一**一个去 `submissions` 表里领活、改状态的人;
- 评测机(worker)**不再连数据库**,只跟仲裁器说三句话:**「给我活」「我还在判」「我判完了,结果是这个」**;
- 认领还是在数据库的一个短事务里完成,但这个事务**由仲裁器在本机发起、本机提交**,永远不会因为家里断网而卡在半路。
一个参与者掉线,只会影响它自己手里那几条租约,不可能再握着任何全站的锁。

这张图是整个方案的要害:上半张里那两条紫色虚线,就是第 1 节出事的地方 —— 一个开着的数据库事务挂在家宽另一头。下半张里,网络另一头只剩一句句独立的 HTTP 请求,断了就重发,没有什么「开着的东西」能卡在半路。
## 三句话分别长什么样
先看一条提交从头到尾怎么走,再逐句拆开:

### 「给我活」:长轮询
```text
POST /judge/v1/claim
{ "worker": "home-1/7f3c...", "request_id": "c-000183", "problems_ready": [...] }
```
仲裁器收到后:
- 有活:立刻在数据库里领一条,把租约返回;
- 没活:**先不回答**,把这个请求挂起,最多挂 25 秒。这期间来了新提交,马上交给它;25 秒还没有就回一句「暂时没活」,worker 再问一次。
这叫**长轮询**(long polling)。它仍然是「worker 来要」(拉模式),但不是每 500 毫秒问一次,而是问了就等着,有活秒回。
为什么保留拉模式,而不是仲裁器主动往 worker 推?
- **睡着的机器天然不领活**:家用机休眠了就不来问,仲裁器不需要知道它的作息表;
- **「闲」的定义变得诚实了**:一个 worker 正挂着一个「给我活」的请求,才算闲。9/17 那种「心跳照常、实际领不了活」的 worker,**根本发不出这个请求**,仲裁器自然不会把活给它。「我闲着」不再是自己报的状态,而是一个正在发生的动作;
- 推模式要求仲裁器能主动连到 worker,家用机没有公网入口,还得再打一条隧道。
### 「我还在判」:续租
```text
POST /judge/v1/leases/{lease_token}/heartbeat
{ "phase": "running", "testcase": 7 }
```
每 5 秒一次。仲裁器收到后在数据库里把这条租约的到期时间往后推:
```sql
UPDATE submissions
SET lease_expires_at = now() + interval '60 seconds',
judging_phase = $3, current_testcase_ordinal = $4
WHERE id = $1 AND lease_token = $2 AND status = 'judging';
```
影响 0 行,说明这条租约已经不归它了(被回收、被重测),仲裁器回 `410 Gone`,worker 立刻停手、丢掉这条。
### 「我判完了」:交结果
```text
POST /judge/v1/leases/{lease_token}/result
{ "verdict": "WA", "time_ms": 312, "memory_kb": 10240, "cases": [...] }
```
仲裁器在一个事务里做完第 2 节 `finalize` 那几步,但**归属检查从「`worker_id` 是不是我」换成「`lease_token` 对不对」**(为什么要换,第 4 节细讲)。
## 租约:三个字段
在 `submissions` 上加(或改造)三列:
| 列 | 是什么 | 作用 |
|---|---|---|
| `lease_token` | 每次领活新生成的 UUID | 这一次领取的「凭证」,心跳和交结果都必须带着它 |
| `lease_gen` | 整数,每次领活 +1 | 第几次被领;只增不减,用来判断「谁更新」 |
| `lease_expires_at` | 时间 | 租约到期时间,由数据库的 `now()` 算 |
领活的事务:
```sql
BEGIN;
SELECT id FROM submissions
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED LIMIT 1;
UPDATE submissions
SET status = 'judging',
worker_id = $worker,
lease_token = gen_random_uuid(),
lease_gen = lease_gen + 1,
lease_expires_at = now() + interval '60 seconds',
judge_attempts = judge_attempts + 1
WHERE id = $id AND status = 'pending'
RETURNING lease_token, lease_gen;
COMMIT;
```
注意这个事务**从头到尾都在仲裁器本机**,几毫秒就提交,中间不经过任何外网。
这套租约在我们仓库里不是新东西:**出题工作台(Prepare)的建包任务已经在用「租约 UUID + 代次」**,心跳和交结果都核对代次,60 秒失联重新排队,最多三次。中央仲裁器相当于把评测提交也搬到同一套模型上。
## 调度判断搬进仲裁器的内存
「家用机优先」「三条队列轮流」「IDE 运行插队」这些判断,现在要靠多个 worker 在数据库里抢锁达成一致。换成仲裁器以后,只有它一个人做决定,这些判断都可以在它**内存里**做:
- **家用机优先**:新提交到了,先看有没有挂着请求的家用 worker,有就给它;没有再给生产机。不需要全局锁,也不需要猜「家用机现在闲不闲」:**挂着请求就是闲**;
- **队列轮转**:轮次指针放在内存里,不用再锁 `judge_dispatch_state` 那一行;
- **不健康的 worker**:连续几次交上来 `system_error`、或者心跳正常但很久没来要活,仲裁器自己就能看出来,不用等它自己说「我不行」。
这里有一条**铁律**:内存里只放「怎么分活比较好」的判断,**不放「这条提交归谁」的事实**。归属、状态、租约全部以数据库为准。这样仲裁器随时重启都不会丢东西,下一节会反复用到这一点。
## 回收:只有一个清洁工
现在是每个 worker 每 10 秒顺手扫一遍过期租约。换成仲裁器后,**只有仲裁器扫**:
```sql
UPDATE submissions
SET status = CASE WHEN judge_attempts < 3 THEN 'pending' ELSE 'system_error' END,
worker_id = NULL, lease_token = NULL, lease_expires_at = NULL
WHERE status = 'judging' AND lease_expires_at < now();
```
一个人扫,就不用再 `SKIP LOCKED` 避让别的清洁工,也不会出现「清洁工自己卡住」连累别人。
## 安全上的收益
家用机不再需要数据库账号。它手里只有一个**评测机令牌**,能做的事只有那三句话。万一这台机器被人拿走,能造成的损失是「交几份假结果」,而不是「整个生产数据库」。以后要接入陌生人的机器(比如学校机房),这一条是前提。
顺带,9/17 那种「新建一张表忘了给家用机授权」的故障,也就不存在了:家用机根本不碰表。
## 代价
不是没有代价:
| 代价 | 说明 |
|---|---|
| 多一个组件 | 仲裁器是一个新进程,要部署、要监控 |
| 单点 | 仲裁器挂了,所有 worker 都领不到活。但它跟数据库、网站在同一台机器上,那台机器挂了本来就全站挂;仲裁器自己挂了,systemd 几秒内拉起来 |
| worker 的数据库代码全要改成 HTTP | 认领、心跳、交结果、释放、报告缺数据,都要改 |
| 结果要过一次 HTTP | 测试点结果多的提交,上报体积变大;仲裁器要限制大小 |
第 4 节要回答的,正是这个「单点」挂掉、恢复、网络断断续续的时候,怎么保证数据不乱。
## 本节要点
- 仲裁器是唯一碰队列表的人,worker 只通过 HTTP 说三句话,永远不握数据库事务;
- 保留拉模式(长轮询):睡着的机器不来要;「挂着请求」就是诚实的「闲」;
- 租约 = `lease_token` + `lease_gen` + `lease_expires_at`,仓库里 Prepare 已经在用同一套;
- 调度判断搬进仲裁器内存,不再需要全局锁和共享轮次行;内存里只放判断,不放事实;
- 回收只有仲裁器一个人做;家用机不再持有数据库凭据。