在线判题系统设计

本文系统设计一个在线判题系统:需求澄清与量级估算、沙箱隔离与资源限制(namespace/cgroup/seccomp、CPU 与内存限额、超时与 OOM 判定)、评测队列与调度(拉模式、两步确认、判题机心跳与弹性伸缩)、测试用例与结果比对(特判 SPJ、浮点误差、交互题)、安全加固与水平扩展,并给出架构图、数据表与判题伪代码。

在线判题系统(OJ)是「不可信代码执行 + 高并发排队 + 结果精确比对」的组合题:用户提交的代码是完全不可信的,可能死循环、疯狂申请内存、fork 炸弹、甚至试图攻击判题机;同时一场比赛可能有几千人同时提交,评测队列要公平、快速地给出结果。它和普通任务系统的区别在于——任务本身是恶意的,隔离与安全是第一约束。本文按照系统设计面试的标准答题结构,设计一个生产级的在线判题系统。

一句话:OJ 的核心矛盾是「要执行不可信代码,又不能让这段代码碰到任何不该碰的东西」——沙箱隔离决定安全性,评测调度决定吞吐量。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个在线判题系统」后,先通过提问明确边界:

  • 评测类型:传统 ACM(标准输入输出)、Special Judge(特判)、交互题、函数式判题(LeetCode 风格)各支持哪些?题型越复杂,评测逻辑越难统一。
  • 语言支持:C++/Java/Python/Go/JavaScript 各支持哪些?不同语言的编译、运行、资源限额差异极大。
  • 资源限制:时间限制(如 1000ms)、内存限制(如 256MB)、输出限制(如 64MB)分别怎么设?
  • 评测规模:日常多少提交?比赛峰值多少?这决定判题机数量与队列设计。
  • 实时性:日常提交可排队几十秒;比赛提交必须尽快出结果,否则影响排名。
  • 安全等级:是否需要防「恶意代码逃逸」「挖矿」「攻击内网」?这是 OJ 最容易被忽视但最致命的点。
  • 结果详情:只给 AC/WA/TLE/MLE,还是给出具体测试点、耗时、内存?后者需要保存更多中间数据。

1.2 量级估算

以一个中等规模的 OJ(支持比赛)为例:

日常提交:          10 万次/天
比赛峰值:          5000 人 × 人均 20 次提交 / 2 小时 ≈ 14 提交/秒(瞬时峰值 ×3 ≈ 40/秒)
单次评测耗时:      编译 0.5~3 秒 + 运行(10~30 个测试点)1~10 秒,平均 5 秒
评测并发需求:      40 提交/秒 × 5 秒 ≈ 200 个并发评测任务(峰值)
判题机数量:        按单机并发 8 个评测算 → 需要 25~30 台(含冗余)
存储:              提交 10 万行/天;测试用例数十 GB,需分发到判题机

关键结论:评测是「重计算 + 强隔离」的任务,不能用普通线程池跑。单机并发数受 CPU 与内存限制(一个评测要独占若干 CPU 时间与内存),判题机的水平扩展是吞吐量的唯一出路。同时测试用例的分发是个隐性问题——判题机要能快速拿到题目数据,否则评测慢在 IO 上。

二、高层架构设计

  用户 ──▶ ┌────────────────┐
           │ Web/API 服务     │  提交代码 / 查看结果 / 榜单
           └───────┬────────┘
                   ▼
           ┌────────────────┐
           │ 提交服务         │  校验 / 去重 / 落库 → 生成评测任务
           └───────┬────────┘
                   ▼
           ┌────────────────┐
           │ 评测队列         │  优先级:比赛 > 日常;按语言分队列
           │ (Redis/MQ)      │
           └───────┬────────┘
        ┌──────────┼──────────┬──────────┐
        ▼          ▼          ▼          ▼
   ┌─────────┐┌─────────┐┌─────────┐┌─────────┐
   │ 判题机 1 ││ 判题机 2 ││ 判题机 3 ││ 判题机 N │  注册 / 心跳 / 拉任务
   │ 沙箱环境 ││ 沙箱环境 ││ 沙箱环境 ││ 沙箱环境 │
   └────┬────┘└────┬────┘└────┬────┘└────┬────┘
        └──────────┴──────────┴──────────┘
                   ▼
           ┌────────────────┐      ┌──────────────┐
           │ 结果收集 + 榜单  │◀────▶│ 测试用例存储  │
           │ (rank/score)    │      │ (对象存储+缓存)│
           └────────────────┘      └──────────────┘

五条关键链路:

  1. 提交:用户提交代码,服务做语法预检、去重(同用户同题同代码短时间内只评一次)、落库。
  2. 入队:生成评测任务投递到队列,带优先级(比赛提交优先于日常)。
  3. 调度:判题机向队列拉取任务(拉模式,避免推模式的负载不均),拉取时携带自身能力(支持的语言、剩余并发槽)。
  4. 沙箱评测:判题机在隔离沙箱里编译、运行、比对,产出每个测试点的结果。
  5. 结果回写:汇总结果写库,更新榜单与用户提交状态。

三、核心组件设计

3.1 沙箱隔离与资源限制

沙箱是 OJ 的生命线。一段恶意代码能做到的事包括:读写判题机文件系统、读取其他用户代码、发起网络请求攻击内网、fork 炸弹耗尽进程数、申请超大内存拖垮机器、无限循环占用 CPU。沙箱必须逐条封堵,靠的是 Linux 的四大隔离机制:

1. Namespace:隔离「看得见什么」
   PID(看不到宿主机进程)/ Mount(只读挂载必要目录)/ Network(默认无网络)
   / User(沙箱内 root 映射为非特权用户)/ IPC、UTS

2. cgroup:限制「用多少」
   cpu.max(CPU 时间配额)/ memory.max(内存上限,超限触发 OOM)
   / pids.max(进程数上限,防 fork 炸弹)/ io.max(磁盘读写限速)

3. seccomp:限制「能调用什么系统调用」
   白名单只放 read/write/exit/mmap 等必要 syscall,
   拒绝 socket/connect/ptrace/mount/reboot 等危险调用

4. 文件系统权限:限制「能改什么」
   代码与测试数据只读挂载;/tmp 用容量受限的 tmpfs,评测后清空

沙箱的两种实现层次:

层次一:容器(Docker + runc + seccomp profile)
  优点:实现简单、生态成熟、镜像管理方便
  缺点:共享内核,内核漏洞可能逃逸;启动开销几十到几百毫秒
  适用:中小规模 OJ

层次二:专用判题沙箱(如 isolate,或内核级沙箱)
  优点:更严格的资源与 syscall 控制,启动更快
  缺点:需要内核支持与 root 权限,部署复杂
  适用:大规模竞赛平台

用 Docker 实现资源限制的示例:

docker run --rm \
  --network none \                       # 无网络
  --memory 256m --memory-swap 256m \     # 内存 256MB,禁用 swap
  --cpus 1.0 --pids-limit 64 \           # 1 核 CPU;最多 64 进程防 fork 炸弹
  --read-only --tmpfs /tmp:size=64m \    # 根文件系统只读;临时目录限 64MB
  --security-opt seccomp=judge.json \    # syscall 白名单
  --security-opt no-new-privileges \     # 禁止提权
  -v /data/problems/$PID:/problem:ro \   # 题目数据只读挂载
  judge-cpp:latest \
  /bin/sh -c "timeout 2 ./main < /problem/input.txt"

容器隔离的底层机制与 容器运行时实现 里讲的完全一致,区别是 OJ 把限制参数调得更严。若追求更强隔离,可把不可信代码编译成 WebAssembly 在 WASM 安全沙箱 里执行——WASM 的内存模型天然无指针越界、无系统调用能力,但性能与语言支持受限。

结果判定的四个维度:

TLE(超时):cgroup cpu.max 限制 CPU 时间,超时杀进程。
  注意区分「CPU 时间」与「墙钟时间」——sleep 不消耗 CPU 时间,
  所以墙钟也要有上限(如 3× 时间限制),防「sleep 攻击」占坑
MLE(超内存):cgroup memory.max 超限触发 OOM Killer,
  读 cgroup 的 memory.peak 得到实际峰值内存
OLE(超输出):限制输出文件大小(如 64MB),防「刷屏攻击」撑爆磁盘
RE(运行错误):非零退出码、信号终止(SIGSEGV/SIGFPE)、除零、栈溢出

超时判定的坑:直接用 timeout 杀进程会误判——机器负载高时正常程序也可能超时。改进做法是按机器基准分校准:先用一段标准程序测出该机器的基准耗时,再按比例调整时间限制。

3.2 评测队列与调度

拉模式而非推模式:判题机主动从队列拉任务。

推模式的问题:调度器要维护每台机器的负载状态;判题机卡住时仍可能继续推,
  导致任务积压;扩容时新机器要通知调度器,耦合高
拉模式的优势:判题机自己控制拉取速率(有槽位才拉),天然背压;
  判题机挂了任务自动超时重入队;扩容时新机器直接开始拉,无需通知任何人

队列设计:

按优先级分队列:
  P0 比赛提交(要求低延迟,几十秒内出结果)
  P1 日常提交(可接受分钟级)
  P2 重测/批量重判(后台任务,可以慢慢跑)
按语言分队列(可选):编译型与解释型耗时差异大,分队列避免慢语言拖累快语言
出队:BRPOP queue:P0 queue:P1 queue:P2,或用 Lua 实现加权公平防 P2 饿死

判题机注册与心跳:

CREATE TABLE judge_node (
  node_id       VARCHAR(64) PRIMARY KEY,
  host          VARCHAR(128) NOT NULL,
  languages     VARCHAR(256) NOT NULL,   -- 支持的语言
  max_slots     INT NOT NULL,            -- 最大并发评测数
  used_slots    INT NOT NULL DEFAULT 0,
  status        TINYINT NOT NULL,        -- 1在线 0离线 2维护中
  last_heartbeat DATETIME NOT NULL,
  INDEX idx_status_heartbeat (status, last_heartbeat)
);

心跳超时(如 30 秒无心跳)的判题机标记离线,其上「已拉取但未确认」的任务重新入队。这要求任务出队时是「预占」而非「删除」:

可靠出队(两步确认):
  1. 判题机 RPOPLPUSH queue:P0 processing:<node_id>   # 移到处理中列表
  2. 评测完成后 LREM processing:<node_id> 该任务       # 确认删除
  3. 看门狗定时扫描 processing:*,超过 N 分钟未确认的任务重新入队

这套「可靠队列 + 看门狗重入队」的模式与 分布式任务调度系统设计 里的「租约 + 超时重试」完全同构。公平性方面,比赛期间要防「一个人提交 100 次占满队列」:单用户同时最多 1 个评测任务在跑,配合「同题同代码去重」。

3.3 测试用例与结果比对

测试用例的组织:

problems/{problem_id}/
  ├── config.json      # 时间/内存限制、测试点列表、评测类型
  ├── 1.in / 1.out     # 测试点 1 输入输出
  ├── 2.in / 2.out ...
  └── spj.cpp          # Special Judge 程序(可选)

config.json:{ "time_limit_ms": 1000, "memory_limit_mb": 256,
  "judge_type": "standard", "cases": [ {"input":"1.in","output":"1.out","score":10}, ... ] }

测试用例的分发:判题机要快速拿到题目数据。日常用「对象存储 + 本地 LRU 缓存」(判题机无状态、扩容简单),比赛用「赛前预分发到所有判题机」(评测零等待)。

结果比对:

标准比对:逐行比对(忽略行尾空格与末尾空行),完全一致才 AC。
  实现要点:不能读进内存比较(大输出会 OOM),要流式逐块比对
特判(SPJ):有些题答案不唯一(如「输出任意一种方案」),
  运行 spj.cpp,传入 input/user_output/answer,由它返回 AC/WA。
  SPJ 也要在沙箱里跑,且要防止它被恶意输入搞崩
浮点比对:允许误差(|a-b| < 1e-6 或相对误差),不能直接用 == 比较
交互题:用户程序与评测程序通过管道实时交互,
  判题机要同时管理两个进程的管道并防止死锁

流式比对(防大输出 OOM):

def compare_streaming(user_out_path, answer_path, tolerance=0):
    with open(user_out_path, 'rb') as u, open(answer_path, 'rb') as a:
        while True:
            ub, ab = u.read(65536), a.read(65536)
            if not ub and not ab:
                return "AC"
            if not ub or not ab:
                return "WA"                       # 长度不一致
            if tolerance == 0 and ub != ab:
                return "WA"
            if tolerance and not tokens_close(ub, ab, tolerance):
                return "WA"

测试点粒度的评分:ACM 赛制「全过才 AC」,OI 赛制「按通过的测试点累计得分」,后者要求结果表细化到测试点:

CREATE TABLE judge_result (
  submission_id BIGINT   NOT NULL,
  case_index    INT      NOT NULL,
  status        VARCHAR(8) NOT NULL,  -- AC/WA/TLE/MLE/RE/OLE
  time_ms       INT,
  memory_kb     INT,
  score         INT      NOT NULL DEFAULT 0,
  PRIMARY KEY (submission_id, case_index)
);

短路优化:ACM 赛制下某测试点失败即可判 WA,后续测试点不跑以节省算力;但要提供「跑完全部测试点」的选项,因为用户常希望看到所有失败点。

3.4 判题机水平扩展与安全加固

水平扩展的三个前提:

1. 判题机无状态(或弱状态):代码从队列取、题目从对象存储拉、结果写回数据库,
   本地只做临时缓存,重启不丢关键数据
2. 队列是唯一协调点:判题机之间不通信,全靠队列协调
3. 能力声明式:判题机启动时声明支持的语言与并发槽数,
   调度按能力匹配(不支持 Java 的机器不拉 Java 任务)

弹性伸缩:按队列积压长度自动扩缩容。

pending = LLEN(queue:P0) + LLEN(queue:P1)
pending > 500 持续 60 秒  → scale_up(2 台)
pending < 50  持续 300 秒 → scale_down(1 台)
比赛期间:预置固定容量,不依赖自动扩缩容(避免冷启动延迟)

安全加固清单(这是 OJ 最容易出事的地方):

1. 判题机与业务服务器网络隔离:只能访问对象存储与结果回写接口,
   不能访问数据库与内网服务;用独立子网 + 安全组,即使沙箱被逃逸也拿不到核心数据
2. 判题机不存敏感数据:用户密码、支付信息绝不出现在判题机上
3. 沙箱逃逸检测:监控异常 syscall、异常进程、异常网络连接,
   定期用已知逃逸手法做安全审计
4. 资源总量兜底:除 cgroup 单沙箱限制外,还要有宿主机级总量监控,
   防止多个沙箱同时打满拖垮整台机器
5. 编译产物不落盘:产物放 tmpfs,评测后立即清理,避免残留被下次利用
6. 输出重定向到受限文件:防止用户代码向 stdout 无限输出撑爆磁盘

判题机的版本管理:评测环境(编译器版本、系统库)必须版本化——同一份代码在不同版本编译器下可能结果不同。判题机镜像打标签,重测历史提交时要明确告知用户「环境已升级」。

判题的幂等性:同一提交可能被重测(判题机崩溃重入队、用户手动重测)。要求评测是确定性的(同一代码同一输入必然同一结果,除非用了随机数或时间),结果写入用「提交 ID + 测试点」为主键做 upsert,重测覆盖而非追加。

四、深入权衡

1. 隔离强度 vs 性能:容器隔离启动快(几十毫秒)但共享内核;微虚拟机(Firecracker)隔离强但启动慢(百毫秒级)。竞赛平台对隔离要求高,常选微虚拟机;普通 OJ 用容器即可。

2. 时间限制的严格程度:限制太松,慢算法也能过(失去区分度);太严,正常程序在负载高时被误判 TLE。折中是「时间限制 × 2 作为硬上限」,并在判题机上做基准校准。

3. 编译缓存的取舍:同一用户反复提交相似代码可缓存编译产物(按代码哈希)加速,但缓存会占磁盘且可能被滥用(大量不同代码撑爆缓存),需要 LRU + 容量上限。

4. 测试用例的保密性:测试用例是 OJ 的核心资产,泄露会被针对性打表。所以用例只挂在判题机上、不通过 API 暴露,且用户代码只能读当前测试点的输入。

5. 队列公平 vs 吞吐:严格公平(每人同时 1 个任务)会降低吞吐,但能防滥用。比赛期间更倾向公平,日常更倾向吞吐。

五、总结

在线判题系统的设计可以浓缩成四条主线:

  1. 沙箱是生命线:namespace 隔离可见性、cgroup 限制资源、seccomp 限制系统调用、只读文件系统限制写入,四层缺一不可。
  2. 拉模式队列是扩展的前提:判题机主动拉取、两步确认、看门狗重入队,让判题机可以随意增删而不丢任务。
  3. 比对要流式且支持特判:大输出不能读进内存,SPJ 处理多解题,浮点用容差,交互题管好管道。
  4. 安全加固贯穿始终:网络隔离、不存敏感数据、定期逃逸审计、资源总量兜底,任何一层松懈都可能导致判题机被攻陷。

延伸阅读:评测任务的可靠调度与超时重试见 分布式任务调度系统设计 ;队列公平性与优先级抢占见 分布式任务调度实践 ;容器隔离的底层机制见 容器运行时实现 ;更强隔离的替代方案见 WASM 安全沙箱 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个视频会议系统(WebRTC SFU)
  2. 设计一个 A/B 测试与实验平台
  3. 设计一个分布式锁服务