一、那五个凌晨发生了什么 · znzryb
> 这篇博客分五节,读完大约一小时。每节末尾有几道选择题,用来自查有没有读懂。
>
> 1. 那五个凌晨发生了什么(本节)
> 2. 现在的「端侧拉取」方案是怎么实现的
> 3. 中央仲裁器:换一种分活的办法
> 4. 异常情况:仲裁器挂了、评测机掉了、结果迟到了,怎么办
> 5. 别人家怎么做(HOJ / Hydro / DMOJ / QDUOJ),我们打算怎么做
## 先交代一个数字:不是三台评测机
平时口头上说「三台评测机」,准确地说是**两台机器、三个 worker**:
| 机器 | worker 个数 | 角色 | 怎么连数据库 |
|---|---|---|---|
| 生产机(腾讯云上海) | 1 | `server` | 同一台机器上直连 |
| 家用机(i5-7500,在家里) | 2 | `home` | 经一条 ssh 隧道,穿过家里宽带连到生产机的数据库 |
后面说的「worker」指的就是这三个干活的进程。一个 worker 一次判一条提交。
## 现象:连续五晚,01:00 一过就一条都判不了
监控里有一只「看门狗」,专门盯生产机的 worker 还有没有心跳。它在 9 月下旬连续五晚都报了同一条告警:
| 日期 | 告警(`judge_down`) | 恢复(`judge_down_cleared`) | 怎么恢复的 |
|---|---|---|---|
| 9/23 | 01:03:18 | 01:15:03 | 自己好了 |
| 9/24 | 01:02:58 | 01:15:13 | 自己好了 |
| 9/25 | 01:02:58 | 01:15:13 | 自己好了 |
| 9/28 | 01:03:09 | 01:15:09 | 自己好了 |
| 9/29 | 01:03:24 | 01:06:54 | 手动杀掉一个数据库会话 |
每晚都是 01:00 刚过开始,大约 12 分钟后自己恢复。**告警不是误报**:那段时间确实一条提交都判不了。
01:00 有什么特别的?家用机每天这个点**定时休眠**(这是计划内的,不是故障;10 月 1 日起改成周三 04:00、其余每天 01:30)。家用机睡了,按理说生产机应该接着干活,怎么反而连生产机也停了?

上面这张图先把一个凌晨摆开看:红色那一段就是全站一条都判不了的时间。为什么起点是 01:00、终点是 01:15 左右,读完这一节就清楚了。
## 9/29 凌晨的现场
那天 01:03 告警一响就上去看了。看到的东西分三层。
**面板**:队列 0 条、正在干活 0 件、生产机 worker 0 个、家用机 worker 0 个。生产机的 `oj-judge.service` 是 `active (running)`,但从 00:50 起日志一行都没有。CPU、内存、磁盘全正常。
**数据库里正在执行的会话**(`pg_stat_activity`):
| pid | 用户 | 状态 | 最后一句 / 在等什么 |
|---|---|---|---|
| 3113079 | `oj_judge`(家用机的数据库账号) | `idle in transaction` | `savepoint sp_5` |
| 3115279 | `oj`(生产机) | 等锁 `Lock/advisory` | `pg_advisory_xact_lock($1)` |
`idle in transaction` 的意思是:**这个会话开着一个事务,但客户端很久没发下一句了**。数据库只能干等。
**锁**(`pg_locks`):
- 3113079 已经拿到了一把 advisory lock,编号是 `0x4f4a5343484544`,翻成 ASCII 就是 `"OJSCHED"`,代码里叫 `claimAdvisoryLockID`,也就是**全站调度锁**;
- 3115279 在等**同一把**锁;
- 3113079 还锁着 `judge_dispatch_state` 表里唯一的那一行(轮到哪条队列的「轮次」)。
顺着 3113079 的客户端端口往下找,另一头是 `sshd: judgetun`,正是家用机的数据库隧道。生产机这边那条 TCP 连接上还有 120 字节一直发不出去:**家里那头已经没了,但生产机这边仍以为连着**。
到这里事情就清楚了:**家用机在握着全站调度锁的时候睡着了,生产机排在这把锁后面,一直等。**
01:06 手动执行了 `pg_terminate_backend(3113079)`。那个事务还没提交,杀掉它只会回滚,不丢任何数据。生产机立刻拿到锁,心跳恢复,01:06:54 告警解除。
## 四环因果链
把它拆成四环,每一环单独看都「不算错」,串起来就是全站停判。

图里四个编号就是下面四环,按编号对着读。
### 第一环:全站的认领只有一个入口,所有人排成一队
每个 worker 闲着的时候,每 500 毫秒去数据库「领一次活」。领活的三个入口开头都是同一句:
```sql
SELECT pg_advisory_xact_lock(claimAdvisoryLockID); -- "OJSCHED",全站只有这一把
```
`pg_advisory_xact_lock` 是 Postgres 提供的一种「自定义锁」:你给它一个数字,谁先拿到谁先过,别人排队,**等到事务结束自动释放**。
拿到锁之后还要再锁一次 `judge_dispatch_state` 那一行(全站也只有这一行)。于是:**所有 worker、所有队列(提交、建包、重测、IDE 运行)的认领,都被串成了一队,一次只能过一个。**
### 第二环:排在队头的人要来回跑好几趟网络
拿到锁以后,认领不是一句 SQL 就完事,而是按顺序做好几件事:先看「IDE 运行」队列,再读轮次,再轮流看「提交 → 建包 → 重测」三条队列(每一格开一个 savepoint),最后推进轮次、`COMMIT`。
每一句都是「客户端发过去 → 等数据库回答 → 再发下一句」。生产机跟数据库在同一台机器上,一个来回不到 1 毫秒;**家用机每个来回都要穿过家里宽带和 ssh 隧道**,几十毫秒起步。所以家用机握着这把全站锁的时间,比生产机长得多。

事发时它刚发完第 5 个 savepoint(`sp_5`),正停在轮流看三条队列的中间。
### 第三环:客户端死在半路,锁就一直不放
家里断网(机器休眠)时,这条 TCP 连接**不是正常关闭的**:没有人发出「我要走了」的信号。生产机这边的 sshd 只看到对方不说话,超时之前它认为连接还活着;数据库也就认为客户端还在,只是「暂时没发下一句」。
而数据库的 `idle_in_transaction_session_timeout` 当时是 `0`,意思是「永远等」。
那最后是谁把锁放掉的?是 sshd 自己的保活设置:`ClientAliveInterval 300`、`ClientAliveCountMax 2`,也就是每 300 秒问一次「你还在吗」,连续 2 次没回就断开。**300 × 2 = 600 秒 ≈ 10 分钟**,加上看门狗 2 分钟的判定时间、告警和恢复各自的检测间隔,刚好对上「01:03 报警、01:15 恢复」。
> 把锁放掉的是 sshd 的超时,而不是我们自己的任何设计。
### 第四环:生产机排在锁后面,连心跳也停了
生产机的 worker 是一个**单线程循环**:
```go
for ctx.Err() == nil {
worked, err := j.Step(ctx) // Step 里:清扫(每 10 秒顺带刷一次心跳) → 认领 → 评测
if !worked {
sleepCtx(ctx, interval) // 没领到活就睡 500 毫秒
}
}
```
`Step` 里的「认领」卡在等锁上,这一圈永远走不完,下一圈的「刷心跳」就永远走不到。120 秒没心跳,看门狗报警。
## 这把锁本来是为了什么?它需要是全局的吗?
这把锁是 9 月 10 日「Home 优先调度」那次加进来的。「Home 优先」是说:**家用机闲着的时候,生产机要把活让给家用机**(家用机更快,而且生产机还要跑网站本身)。代码注释写得很清楚:
```go
// advisory xact lock 把所有 worker 的调度判断串起来,避免两个 Server 同时看见
// 同一个空闲 Home 后作出互相矛盾的决定;具体任务行仍用 SKIP LOCKED 防重复认领。
```
这里有两件事必须分开:
- **同一条提交不能被两个 worker 同时领走**:这件事靠的是行锁 `FOR UPDATE SKIP LOCKED`(第 2 节细讲),**跟这把全局锁无关**;
- **生产机要不要给家用机让路**:全局锁只管这一件事。
那「让路」判断要是偶尔看错了会怎样?只有两种看错法:
| 看错成什么 | 结果 | 严重吗 |
|---|---|---|
| 家用机其实刚领了活,生产机以为它还闲 | 生产机让了路,这条提交等下一轮(500 毫秒后)再领 | 不严重:多等半秒 |
| 家用机其实刚闲下来,生产机以为它还忙 | 生产机没让路,自己判了 | 不严重:本来就是它能判的题 |
**为了防一个最多多等半秒的小偏差,把全站所有认领串成了一队;而这一队能被任何一个参与者的网络问题卡死,最容易出网络问题的恰恰是家用机。**这笔账不划算。
## 实际上线的修复(#155)
故障报告里提了三条修法,实际上线的是一个更保守的版本:
1. **家用机不再拿全站调度锁**,生产机照旧拿(生产机之间的「让路」判断还靠它保持一致);
2. **所有认领事务加上 10 秒的「干等上限」**:
```sql
BEGIN;
SET LOCAL idle_in_transaction_session_timeout = '10s'; -- 只对这一个认领事务生效
... 认领 ...
COMMIT;
```
客户端在事务半路 10 秒不说话,数据库自己把这个会话断掉、事务回滚、锁全放掉。`SET LOCAL` 只管当前这一个事务,不会误伤别的长事务(比如发布题目时中间要传图的那个事务)。
3. **心跳一行没动。**

第 1 条看着像是根治,其实不是。代码注释里自己承认了:
> Home 并没有因此完全不碰全站共享的锁:共享调度开着时,`judge_dispatch_state` 那一行的 `FOR UPDATE` 所有 worker 都要拿,Home 在握着它时掉线,Server 照样会卡在那一行上。能把这段等待封顶在 10 秒的是上面那个空闲超时,**不是**「Home 不拿调度锁」—— 所以超时不能删。
也就是说,**现在家用机掉线,最坏还是会让全站卡最多 10 秒**。从 12 分钟变成 10 秒是巨大的进步,但「一个参与者掉线,能卡住所有人」这个结构还在。这就是第 3 节要讲中央仲裁器的原因。
## 为什么不把心跳拆出去
有一个看起来很顺手的改法:把「刷心跳」交给一个独立的后台协程,每 10 秒刷一次,这样认领卡住心跳也不会停,看门狗就不会报警了。
**不应该这么改。**现在的心跳测的是「worker 能不能真的走到认领那一步」,不只是「进程还在不在」。正因为它跟干活走的是同一条路,这五晚它才一次不落地把问题报了出来。拆出去以后,凌晨队列本来就空,「队列有活却没人干」那条告警也不会响,**这五晚会一片安静,问题被彻底藏住**。
9 月 17 日就出过一次这样的事:家用机在监控上「在线、空闲」,实际上一件活都领不到,9 条提交挂了 44 分钟(第 2 节细讲)。
> 探针只能加强,不能削弱。
## 本节要点
- 看门狗报的是真故障:01:00 家用机休眠,正好握着全站调度锁,全站停判;
- 四环:全站只有一个认领入口 → 家用机握锁时间长 → 断线不是正常关闭、数据库永远等 → 生产机单线程循环卡住、心跳停;
- 恢复靠的是 sshd 的 300×2 秒保活,不是我们的设计;
- 防重复认领靠行锁,全局锁只保护「让不让路」这个无关紧要的判断;
- 现在的修复把最坏情况从 12 分钟压到 10 秒,但「一人掉线卡住所有人」的结构还在。