跟着一次 AC 走一遍:判题机和经验值 · znzryb
## 提交:后端只管「写进表里」
你在题目页点「提交」,后端做的事很少:检查一下、把代码存起来,往 `submissions` 表插一行 `status = 'pending'`,然后就返回了。**判题不在这个请求里发生。**
## 判题机:从表里「抢」任务
`oj-judge` 是一个死循环,每一圈去 `submissions` 表里抢一条 `pending`(`backend/internal/judge/scheduling.go` 的 `claimInitialTx`):
```sql
SELECT s.id, s.problem_id
FROM submissions s
WHERE s.status = 'pending' AND ...
ORDER BY s.id
FOR UPDATE OF s SKIP LOCKED
LIMIT 1
```
关键在最后那句 **`FOR UPDATE SKIP LOCKED`**:
- `FOR UPDATE`:把选中的这一行锁住,别的事务动不了它;
- `SKIP LOCKED`:**已经被别人锁住的行直接跳过**,不排队等。
于是三台判题机同时来抢,会各自拿到不同的提交,谁也不会判重。**这就是「数据库当队列」**,不需要另外装 Redis 或消息队列。
判题机要是判到一半挂了怎么办?它判的时候会定时更新 `heartbeat_at`(心跳时间)。`reclaimStale` 每隔一段时间扫一遍:心跳太久没更新的,就改回 `pending`,让别人重判。
## AC 之后:一个事务里改好几张表
判出 AC 之后,判题机在**同一个事务**里做这些事(`backend/internal/judge/judge.go` 写结果的那一段):
```text
写 submission_results(每个测试点一行)
写 accepted_records(这道题我 AC 过了)
写 problem_xp_granted(这道题的经验发过了,ON CONFLICT DO NOTHING)
└─ 真插进去了(= 第一次)→ 调 gamify.OnFirstAC(ctx, tx, …)
├─ 写 xp_events(经验值流水)
├─ 算连击、每日打卡
├─ 看有没有章节因此通关
└─ 解锁成就
提交
```
`gamify.OnFirstAC` 就是前面说的「领域包」:它不开事务,而是**接过调用方的 `tx`**,在里面直接写 SQL。这样只要中间哪一步失败,整个事务回滚,不会出现「AC 记上了、经验没加上」这种半截状态。
## 「只发一次」靠的是主键
注意上面那张 `problem_xp_granted` 表:它的主键是 `(user_id, problem_id)`,插入时带 `ON CONFLICT DO NOTHING`。**第二次 AC 同一道题时插不进去,`RowsAffected()` 是 0,就不发经验。**
这个模式在项目里反复出现:章末测验(`user_chapter_quiz`)、读博客拿经验(`user_blog_reads`)都是一张「账本表」+ 主键 + `ON CONFLICT DO NOTHING`。用数据库的唯一约束来保证「只做一次」,比在代码里先查再写可靠 —— 两个请求同时到,也只有一个能插进去。