二、现在的端侧拉取是怎么实现的 · znzryb
这一节把现在的方案从头到尾走一遍:一条提交从「交上来」到「出结果」,中间谁碰了它、怎么碰的、碰坏了怎么办。
## 一句话:数据库就是队列,谁闲谁去拿

我们没有单独的消息队列(没有 Redis、RabbitMQ)。`submissions` 表本身就是队列:`status = 'pending'` 的行就是「排着队的活」。三个 worker **各自**直接连数据库,闲下来就去表里挑一条领走。这就是「端侧拉取」:**分活这件事是在每个 worker 自己那一侧完成的,没有一个中心在发号施令。**
这套办法的好处很实在:
- **一行调度代码都不用写**。谁闲谁领,家用机两个 worker 对生产机一个 worker,积压时自然落在大约 7:3;
- **睡着的机器天然不领活**。家用机休眠,它就不来拿,不用任何「别把活派给睡着的机器」的逻辑;
- **少一个要运维的组件**。数据库本来就要有。
## 领活:`FOR UPDATE SKIP LOCKED`
领一条提交的核心是这两句,在同一个事务里:
```sql
SELECT id, problem_id FROM submissions
WHERE status = 'pending' AND <让路条件>
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
UPDATE submissions
SET status = 'judging', worker_id = $2, heartbeat_at = now(),
judge_attempts = judge_attempts + 1
WHERE id = $1 AND status = 'pending';
```
`FOR UPDATE` 是给选中的那一行加锁,`SKIP LOCKED` 是说**别人锁着的行我直接跳过,不等**。两个 worker 同时来领,第一个锁住 101 号,第二个看到 101 被锁就去拿 102,谁也不等谁。这就是「防同一条提交被领两次」的真正机制,跟第 1 节那把全局锁无关。
`UPDATE` 那句把这条提交标成「我在判」:`status = 'judging'`,写上自己的名字 `worker_id`,记下时间 `heartbeat_at`,尝试次数加一。
## 租约:`worker_id + heartbeat_at`
「我在判」不能是永久的。worker 可能判到一半死掉(断电、断网、被杀),这条提交就永远卡在 `judging`。所以这个「我在判」其实是一份**租约**:你说你在判,那你得一直证明你还活着。
| 参数 | 数值 | 意思 |
|---|---|---|
| 心跳间隔 | 5 秒 | 评测期间每 5 秒把 `heartbeat_at` 刷成 `now()` |
| 判死时间 | 60 秒 | 60 秒没刷心跳,就认为这个 worker 没了 |
| 尝试上限 | 3 次 | 同一条提交最多被领 3 次,防止一条「能稳定把 worker 打死」的提交无限循环 |
| 回收间隔 | 10 秒 | 每个 worker 每 10 秒顺手扫一次有没有过期的租约 |

图上每一条箭头,对应下面要讲的一段代码:领走、心跳、交结果、过期回收、主动归还。
为什么判死是 60 秒,不是更短?因为家用机的 ssh 隧道正常也会抖:autossh 每 15 秒问一次、连续 3 次不通才重连(45 秒),重连前再等 5 秒,一次正常的隧道重连可能就有约 55 秒听不到心跳。**误回收比晚回收贵得多**:把一条其实还在判的提交收回来,会让两个 worker 同时判同一条。
所有时间都用**数据库的 `now()`** 来判,不用各台机器自己的时钟,所以两台机器时间对不齐也没关系。
心跳只在租约还归我时才刷得上:
```sql
UPDATE submissions SET heartbeat_at = now(), ...
WHERE id = $1 AND status = 'judging' AND worker_id = $2;
```
## 回收:没有专门的清洁工,人人顺手扫
过期的租约谁来收?没有单独的进程,**每个 worker 每 10 秒顺手扫一次**:
```sql
WITH stale AS (
SELECT id FROM submissions
WHERE status = 'judging'
AND coalesce(heartbeat_at, created_at) < now() - interval '60 seconds'
FOR UPDATE SKIP LOCKED)
UPDATE submissions s
SET status = CASE WHEN s.judge_attempts < 3 THEN 'pending' ELSE 'system_error' END,
worker_id = NULL, heartbeat_at = NULL
FROM stale WHERE s.id = stale.id;
```
过期的放回 `pending` 让别人重领;已经领过 3 次的直接判 `system_error`,不再折腾。
另外还有**主动归还**:worker 收到停止信号(比如家用机要休眠,systemd 先停 worker)时,会把手上的活放回队列,并且把这次尝试次数退回去(不算它失败)。systemd 按「先停 worker、后停隧道」的顺序关,保证这句归还的 `UPDATE` 还能穿过隧道发出去。
## 交结果:归属闸门
判完以后,`finalize` 在**一个事务**里做完所有事:
1. **先确认这条提交还归我**:
```sql
SELECT status, worker_id FROM submissions WHERE id = $1 FOR UPDATE;
```
如果它已经不是 `judging`,或者 `worker_id` 不是我了,**这份结果直接丢掉**,而且返回「成功」而不是报错;
2. 删掉这条提交旧的逐个测试点结果,再整批写入新的;
3. 更新 `submissions` 的最终状态、用时、内存;
4. 在评测历史里追加一条;
5. 如果是 AC:写「首次通过」记录和经验值,都用 `INSERT ... ON CONFLICT DO NOTHING`,**重复写也只算一次**;如果不是 AC 而原来是 AC(重测改判),撤销通过记录。
第 1 步为什么必须有?设想这条时间线:

按时间顺序读:家用机 0 秒领到 101,10 秒起隧道卡住;70 秒生产机发现过期,收回来自己重判,80 秒判出 AC;90 秒家用机那份 WA 才终于到。
没有归属闸门,90 秒那份迟到的 WA 就会把 80 秒那份正确的 AC 盖掉。有了闸门,家用机一看 `worker_id` 已经是生产机了,丢掉自己的结果。
> 记住这个问题,第 4 节会再回来:**「`worker_id` 是不是我」够不够判断「这是不是最新的那一次」?**
## 家用机优先:让路条件
前面 `SELECT` 里的 `<让路条件>` 是:
- 如果我是**生产机**:要是存在一个家用 worker,它**在线**(60 秒内露过面)、**健康**、**能判这道题**(有测试数据)、**手上没活**,那我就不碰这条,让给它;
- 如果我是**家用机**:只跳过自己判不了的题。
正是为了让这个判断在多个生产 worker 之间保持一致,才加了第 1 节那把全局锁。
## 家用机怎么连到数据库
家用机没有公网入口,它**主动**往外连:
```bash
autossh -L 127.0.0.1:15432:127.0.0.1:5432 judgetun@生产机
```
把生产机的 5432(数据库)映射到家里的 15432。家用 worker 的 `DATABASE_URL` 指向 `127.0.0.1:15432`,用一个受限账号 `oj_judge` 登录。
这意味着:**家用机手里拿着一份能直接改生产数据库的凭据**,而且它的每一句 SQL 都要穿过家里宽带。
## 前车之鉴:9 月 17 日,「在线空闲」却判不了
在 9/29 之前,端侧拉取已经出过一次大事:
- IDE 模式上线时新建了一张表 `run_jobs`,**忘了给家用机的数据库账号授权**;
- 家用机每次认领都要先看一眼 `run_jobs`,于是每秒两次 `permission denied`,一件活都领不到;
- 但它的心跳是另一段代码,照常刷着。在生产机眼里它是:**在线、健康、手上没活**,完全满足让路条件;
- 于是生产机对每一条提交都让路,让给一个根本领不了活的人。**两层评测同时停摆,9 条提交挂了 44 分钟**,三个服务全是 `active`,监控上没有一处是红的。

故障报告里有一句话说得很准:
> 「我不行,你来」这句话,是由那个已经不行了的人负责说出口的。他哑了,接力棒就永远递不出去。
修复是:每个认领格子单独开 savepoint(一格出错不连累别的格子);连续 3 轮认领失败就把自己标成 `unhealthy`,让路条件里加上「健康」;新表默认自动授权。
## 端侧拉取的结构性弱点
两次故障放在一起看,问题不在某一行代码,而在结构:
1. **每个参与者都在数据库里握着事务和锁。**谁的网最差,谁就能把锁握得最久,掉线时就能卡住所有人。9/29 是这个。
2. **「能不能干活」由干活的人自己汇报。**一个坏掉的 worker 恰好会表现得「最闲」,调度判断被它骗了。9/17 是这个。
3. **生产数据库的凭据放在别人家的机器上。**现在是一台自己的机器,还能接受;以后要是接入陌生人的机器,这条就不行了。
4. **调度逻辑散在每个 worker 里。**「让路」「轮次」「公平」都要靠大家在数据库里通过锁达成一致,而这恰恰是第 1 条的来源。
2026-08-24 接入家用机时,设计文档里其实就考虑过另一种做法,「HTTP 认领 API」,当时的结论是:
> 它是唯一不用把 DB 凭据交出去的方案,长期正确;……等评测机不再是「一台朋友的机器」而是「若干台陌生机器」时再做——那时补的 `worker_id` / `heartbeat_at` 正好是它的数据基础。
下一节就是把这个「长期正确」的方案展开。
## 本节要点
- `submissions` 表就是队列,每个 worker 自己去拿,`FOR UPDATE SKIP LOCKED` 防重复领;
- `worker_id + heartbeat_at` 就是租约:5 秒心跳、60 秒判死、最多 3 次;
- 没有专门的清洁工,每个 worker 顺手回收过期租约;
- `finalize` 先确认「还归我」再写结果,迟到的结果丢掉;AC 落账 `ON CONFLICT DO NOTHING`;
- 弱点是结构性的:人人握锁、自己报健康、凭据外放、调度逻辑分散。