四、异常情况:挂了、掉了、迟到了怎么办 · znzryb
这是全文最重要的一节。中央仲裁器在一切正常时很好理解,难的是**出事的时候**:仲裁器自己崩了又起来、worker 掉线又回来、请求发了没收到回答、同一个结果交了两遍。
先把三个要回答的问题说清楚:
- **一致性**:任何时候,一条提交只能有一个「当前的主人」,数据库里记的状态是对的;
- **幂等**:同一个动作做两遍,结果跟做一遍一样(网络会让很多请求被重发);
- **不回退**:一条提交的结果只能往前走,不能被一份更旧的结果盖回去。
整节都靠三条原则撑着,先列出来,后面每个场景都会用到:
> **原则一:事实只放在数据库里。**仲裁器内存里的东西丢了也没关系,重启后从表里重建。
>
> **原则二:每一次写都带条件。**「把这条改成 AC」永远写成「如果它还是 `judging`、而且租约凭证是这一张,就改成 AC」。条件不满足就影响 0 行,什么都不发生。
>
> **原则三:凭证只增不减。**每次领活生成新的 `lease_token`、`lease_gen` 加一。旧凭证一旦作废,永远不会再变回有效。

这张总图把三条原则落成了一台状态机,后面九个场景都可以回到这张图上找位置:出事的是哪一条箭头,那条箭头的 `WHERE` 条件又是怎么把错误挡住的。
## 场景一:仲裁器在领活的半路崩了
分三种时刻:
| 崩在哪 | 数据库里是什么样 | 会怎样 |
|---|---|---|
| 事务还没 `COMMIT` | 事务被回滚,提交还是 `pending` | 什么都没发生,重启后照常领 |
| `COMMIT` 了,但回复还没发给 worker | 提交是 `judging`,租约属于某 worker,**但那个 worker 不知道** | 见下面 |
| 回复已经发出 | worker 拿到租约开始判 | 跟场景三一样 |
第二种是唯一麻烦的:数据库说「101 归 home-1」,home-1 却什么都没收到。最笨的结果是:60 秒后租约过期,被回收,重新排队。**不丢数据,只是多等一分钟。**
能做得更好吗?两个办法,打算都用:
1. **领活请求带 `request_id`**。worker 没收到回答会重发同一个 `request_id`,仲裁器在数据库里查「这个 worker 这个 `request_id` 是不是已经领过一条」,领过就把**同一张租约**再发一遍,而不是再领一条新的。这就是「领活」这个动作的幂等;
2. **先确认、再算数**。新发出的租约到期时间先设成 10 秒,worker 收到后的第一次心跳才把它续到 60 秒。没收到的那张租约 10 秒就会被收回,而不是 60 秒。
## 场景二:仲裁器崩了一段时间,worker 还在判
这是最容易想错的场景。
worker 判题**不需要**仲裁器:测试数据在它本地磁盘上,沙箱在它本地。所以仲裁器挂了,正在判的 worker 照样能判完。问题出在两处:
**问题 A:结果交不上去。**worker 把结果连同 `lease_token` **先写到本地磁盘**,然后带退避地重试上报(1 秒、2 秒、4 秒……封顶 30 秒)。仲裁器回来以后,结果照常交上去。worker 进程本身重启了也不怕:启动时先把磁盘上没交完的结果补交,再去领新活。
**问题 B:租约在仲裁器挂掉期间「过期」了。**仲裁器挂了 2 分钟,这期间所有心跳都发不进来,`lease_expires_at` 早就过了。仲裁器一起来,清洁工一扫:全部过期,全部放回 `pending`!可这些提交其实**都还有人在好好地判**,只是没法报平安。结果是每条都被判两遍,第一份结果交上来时被当成「迟到的旧结果」丢掉。
这不会让数据出错(原则二、三保证了),但会白白浪费一倍的评测量,还可能让 `judge_attempts` 冲到 3 判成 `system_error`。
修法:**仲裁器启动后有一段宽限期**。
```sql
-- 仲裁器启动时执行一次:给所有在途租约补足一个完整的租期
UPDATE submissions
SET lease_expires_at = greatest(lease_expires_at, now() + interval '60 seconds')
WHERE status = 'judging';
```

道理是:**租约过期的意思是「我们 60 秒没听到你」,而仲裁器自己不在的那段时间,不是 worker 的错,不能算进去。**起来以后先给大家一个完整的租期报平安,真死了的 60 秒后照常回收。
> 这一点在第 5 节看别人家的做法时会再遇到:「重启以后,在途的任务怎么算」是各家最容易出问题的地方。
## 场景三:worker 判到一半掉线
跟现在一样:心跳断了,60 秒后仲裁器回收,放回 `pending`,别人重领。区别只在于:
- **它掉线不会卡住任何别人**。它没握数据库事务、没握任何锁,手上只有一张租约;
- 现在掉线最坏卡全站 10 秒,仲裁器方案下是 0 秒。
## 场景四:同一份结果交了两遍
worker 交结果,仲裁器写库成功,回复却在路上丢了。worker 以为没交上,带同一个 `lease_token` 再交一遍。
第一遍的写库条件是「`status = 'judging'` 且 `lease_token` 是这张」,满足,改成 WA。第二遍来时状态已经是 WA,条件不满足,影响 0 行。
**这时候仲裁器不能回「失败」**,否则 worker 会一直重试,或者报警。它要再查一步:「这条提交上一次是不是就是用这张 `lease_token` 定的结果?」是的话,回「已收到」,跟第一次一模一样。所以每次定结果时,要把**定结果用的那张凭证**记下来:
```sql
-- 评测历史里每一行记下是哪张租约定的结果,并且不许重复
CREATE UNIQUE INDEX ON submission_judgings (submission_id, lease_token);
```
这样「交结果」就是幂等的:交一遍和交十遍,数据库里都只有一条记录,worker 收到的回答也都一样。
AC 带来的**通过记录和经验值**,跟现在一样用 `INSERT ... ON CONFLICT DO NOTHING`:同一个人同一道题的首次通过只记一次,无论结果交了几遍、重测了几次。
## 场景五:迟到的旧结果(防回退的关键)
回到第 2 节留下的问题:**「`worker_id` 是不是我」够不够判断「这是不是最新的那一次」?**
不够。看这条时间线:

按时间顺序:home-1 第 1 次领到 101(凭证 A),10 秒起网络卡住;70 秒过期回收;71 秒它又来要活,又领到 101(凭证 B);75 秒,卡在路上的第 1 次结果才到。
75 秒那份结果是第 1 次的,但 `worker_id` 还是 home-1、状态也还是 `judging`。只看 `worker_id` 的话,它会被当成第 2 次的结果收下。
(现在的单线程 worker 恰好不会这样:它必须先处理完第 1 次才去领第 2 次。但仲裁器方案下有 HTTP 重试和本地补交,「同一个 worker 的两次领取交错到达」就真的会发生。)
**`lease_token` 能分清**:第 1 次和第 2 次是两张不同的凭证,第 1 次那张在 70 秒回收时就作废了。75 秒那份结果拿的是作废的凭证,写库条件不满足,0 行,丢掉,回 `410 Gone`。
这个思路在分布式系统里叫**防护令牌**(fencing token):每次换主人就发一个更大的号码,所有写操作都核对号码,旧号码的人无论何时到达都写不进去。我们的 `lease_gen` 就是这个号码。
「不回退」还有另一层:**结果只能往前走**。把一条提交的状态看成一台只许前进的机器:
(就是本节开头那张总图:每一条箭头都带条件,终态往回走只有「重测」一条路,而且要开新的一代。)
- 每一条箭头都是一句带条件的 `UPDATE`:从哪个状态来、拿哪张凭证,都写在 `WHERE` 里;
- 终态之后**只有重测**能把它拉回 `pending`,而且重测会让 `lease_gen` 加一,之前所有凭证全部作废;
- 评测历史(`submission_judgings`)只追加、不修改,`seq` 只增不减。所以就算哪里出了错,历史也能看出来每一代的结果是什么、谁定的。
## 场景六:两个仲裁器同时活着
什么时候会有两个?部署新版本时新的起来了、旧的还没退;或者旧的卡住了(比如跟数据库的连接断了又自己连上),systemd 以为它死了又拉起来一个。
**第一道防线:领导锁。**仲裁器启动后,先用一条专用连接抢一把**会话级**的 advisory lock(`pg_try_advisory_lock`)。抢到的才干活,抢不到的就等着、每隔几秒再试。这条连接一断,数据库自动把锁放掉,另一个就能接手。
**但是,领导锁不能是正确性的唯一保证。**旧仲裁器可能还没发现自己的连接断了,手里还拿着一个「我是领导」的念头,继续处理请求。所以:
**第二道防线才是真正的保证:原则二。**所有写操作都带条件、都在数据库里核对凭证。就算两个仲裁器同时在工作:
- 它们同时领活:`FOR UPDATE SKIP LOCKED` 保证同一条提交只会被其中一个领走;
- 一个想回收、一个在续租:都是带条件的 `UPDATE`,数据库按顺序执行,结果是确定的;
- 结果交到了旧的那个:它写库时照样核对 `lease_token`,对就收、不对就丢。
两个仲裁器同时活着,最坏只是**调度判断变差**(家用机优先可能失灵一会儿、轮转不公平),**不会让数据出错**。

> **一句话:领导锁是为了效率,正确性靠数据库里的条件更新。**
这跟第 1 节那把全局锁正好形成对比:那把锁是为了效率(让路判断),却被当成了必须等的东西,结果一个人掉线卡住所有人。这里我们明确让它「抢不到就不干活」,而不是「抢不到就一直等」,而且旧领导的连接断了数据库会立刻放锁。
## 场景七:仲裁器还活着,但数据库连不上
仲裁器对所有请求回 `503`,worker 带退避重试。
- 正在判的 worker 继续判,结果存本地;
- 没有任何租约会在这期间被回收,因为回收这句 SQL 本身也发不出去;
- 数据库恢复后,先执行场景二那个宽限期,再开始回收。
这里有一个设计选择:**租约到期由数据库的 `now()` 判,而不是仲裁器的时钟。**仲裁器的机器时钟跳了、或者两个仲裁器时间不一致,都不影响判断。
## 场景八:仲裁器没崩,但卡住了
最阴险的一种:进程在、端口在、systemd 说 `active`,但它就是不派活(死锁了、某个 goroutine 卡在一个慢查询上)。这正是第 1 节和 9/17 那两次故障的样子。
监控要沿用第 1 节的原则:**探针要走真实的那条路**。
- 仲裁器的心跳不是「进程活着」,而是「派活循环这一圈走完了」:每次真正执行完一轮「看队列 → 配对 → 写库」,才刷一次心跳;
- 再加一条告警:**有可领的提交、有 worker 挂着请求在等,却超过 30 秒没配上对**。在仲裁器方案下这条判断特别准,因为「有人在等」是一个真实的、正挂着的请求,不是猜的。
## 场景九:切换期间出了问题,要退回去
仲裁器上线不是一刀切:
1. 先加列(`lease_token`、`lease_gen`、`lease_expires_at`),旧 worker 不用它们,不受影响;
2. 仲裁器上线,但只接**一个**家用 worker;另一个家用 worker 和生产机 worker 还直连数据库。两种 worker 同时存在时,领活都是 `FOR UPDATE SKIP LOCKED`,不会抢同一条。这一阶段旧方案的全局锁、`judge_dispatch_state` 还在,仲裁器领活时**也照旧规矩来**,不去破坏它们;
3. 跑一周没问题,把家用机全切过去,家用机的数据库账号删掉;
4. 最后切生产机 worker,删掉全局锁和共享轮次行。
任何一步出问题,把 worker 的配置改回「直连数据库」就退回去了。数据库里的状态两种方案都认得。
## 九个场景对照表
| 场景 | 靠什么保证不出错 | 最坏的代价 |
|---|---|---|
| 仲裁器在领活半路崩了 | 事务原子性;`request_id` 幂等;未确认的租约 10 秒收回 | 一条提交晚 10 秒 |
| 仲裁器崩了一段时间 | 结果先落 worker 本地磁盘;重启宽限期 | 仲裁器停多久,出结果就晚多久 |
| worker 判到一半掉线 | 租约过期回收 | 晚 60 秒,不影响别人 |
| 同一份结果交两遍 | `(submission_id, lease_token)` 唯一;AC 落账 `ON CONFLICT DO NOTHING` | 无 |
| 迟到的旧结果 | `lease_token` / `lease_gen` 防护令牌;状态只许前进 | 无 |
| 两个仲裁器同时活着 | 领导锁(效率)+ 带条件的写(正确性) | 调度暂时变差 |
| 数据库连不上 | 一切写操作都发不出去;恢复后宽限期 | 停多久晚多久 |
| 仲裁器卡住 | 探针走真实派活路径;「有人等却没配对」告警 | 30 秒后叫人 |
| 切换出问题 | 分阶段、新旧方案共存、配置一改就回退 | 无 |
## 本节要点
- 三条原则:事实只在数据库里;每一次写都带条件;凭证只增不减;
- 幂等靠「带凭证、记下凭证」:领活带 `request_id`,交结果带 `lease_token`,AC 落账 `ON CONFLICT DO NOTHING`;
- 不回退靠防护令牌:`lease_token` / `lease_gen` 区分每一次领取,旧凭证永远写不进去;状态机只许前进,只有重测能开新一代;
- 仲裁器重启后要给在途租约一个宽限期,自己不在的时间不能算到 worker 头上;
- 领导锁只为效率,正确性靠数据库里的条件更新;
- 监控沿用「探针走真实路径」的原则。