news 2026/10/6 4:31:54

从零构建OJ在线判题系统:判题核心、评测队列与题目数据管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建OJ在线判题系统:判题核心、评测队列与题目数据管理

做OJ(Online Judge)时间久了你会发现,真正磨人的往往不是算法本身,而是从代码提交到判题结果回传中间那条不可见的长链路。项目标题里的“133-135(oj)”,在我这边是仓库里的三张连续任务卡:编号133是判题核心与沙箱隔离,134是评测队列与并发调度,135是题面与测试数据管理。它们恰好构成了一条“用户提交代码 -> 系统判定 -> 输出结果”的完整主干。

这不是一个靠GitHub上抄一份开源项目就能跑通的东西。我前后改了三版架构,才把所有坑踩得差不多。这篇就用这三张任务卡当主线,把OJ在线判题系统的开发思路、方案取舍、核心实现和排查经验一次讲清楚。适合两类人看:一是想在简历上加一个完整项目的后端开发者,二是经常在OJ刷题、想搞清楚判题机制内部逻辑的同学。

1. 先说清楚:这三个编号到底在做什么

1.1 从刷题狂魔到OJ开发者的动机

打了几年OJ,从ACM校赛到各类在线平台,我算是各种判题系统最忠实的用户。但越刷越觉得不对劲:点击提交之后,后台到底发生了什么?为什么同样的代码在自己电脑上秒出结果,到OJ上就TLE?为什么有时候看着一模一样的输出,反而给你一个WA?

好奇心攒到一定程度就会变成动手欲。我开始尝试自己写一个OJ系统,目标很明确:不搞花哨功能,先把核心链路做扎实。项目从规划到第一版可用,大概花了两周业余时间,后面又持续迭代了一个多月。

很多人一开始想自己写OJ,第一反应是去GitHub找一个star多的项目直接跑起来。我不反对参考,但完全照抄有致命问题:你抄来的代码出了故障,你根本不知道从哪里排查。判题系统涉及进程管理、资源限制、安全管理、并发调度,任何一环出问题,用户侧表现都差不多:结果显示异常或一直排队。没有亲手实现一遍,出了问题就只能抓瞎。

1.2 一条提交的旅程:133、134、135分别管哪一段

如果把一次代码提交看成一趟交通工具的旅程,那么三个模块的分工非常清晰。

编号133管的是“引擎”:拿到一份用户代码之后,怎么把它编译成可执行文件、怎么在一个受控环境里跑起来、怎么限制它不许乱碰系统文件、不许联网、不许无限吃内存。这就是判题核心,也是整个系统里安全风险最高的地方。

编号134管的是“调度”:在线用户多的时候,如果一百个人同时点提交,判题机是不可能同时处理一百个进程的,必须排队。评测队列负责把请求按照先来后到的顺序送进判题机,并且保证消息不会丢、不会重复处理。

编号135管的是“数据”:每道题有一份题面、若干组测试数据、对应的标准输出文件,还有可能需要特殊判断的脚本。题目数据管理负责把这些东西组织好,并且让判题核心在运行用户代码时能准确拿到对应的测试点数据。

这三个任务卡串起来就是一次提交的完整生命周期。下面我会逐个拆开讲。

2. 前期设计与技术选型解析

2.1 为什么判题服务必须独立部署

第一版我图省事,把判题功能直接写在Web后端里:Spring Boot收到提交请求,当场调用编译命令,当场执行用户代码,当场返回结果。只适用于本地测试,因为用户量一大立刻暴露问题。

第一个问题是阻塞。Java编译和运行用户代码都是重量级操作,动辄几百毫秒到几秒。如果这些操作和登录、查题、提交等接口放在同一个应用进程里,一旦有多个判题任务同时进来,Tomcat线程池会被快速占满,整个网站都跟着卡死。像是把厨房和餐厅合并成同一个房间,有人炒菜的时候,所有人都得在旁边闻油烟。

第二个问题是安全。用户代码是不可信的。哪怕你只打算让系统支持Java语言,也不能直接在自己的服务器进程里执行用户代码。恶意代码可以删除文件、读取环境变量、甚至利用JVM漏洞尝试提权。判题服务必须和Web服务做隔离,所以我最后选择把判题器拆成一个独立的子服务,Web后端只负责把提交消息丢进队列,然后轮询结果。

2.2 三种判题沙箱方案的对比取舍

判题核心最棘手的是“安全地运行不可信代码”。我在实际对比过多种方案后,整理了这样一个表:

方案隔离强度资源控制精度实现难度适用场景
裸机ProcessBuilder极弱差低只敢跑自己写的代码,生产环境不可用
JVM SecurityManager中等中中只支持Java语言,JDK高版本已弱化
Linux容器(Docker)强好中高多语言支持,是目前主流方案
轻量级沙箱(seccomp/namespace)极强好高高并发判题平台,需要较强Linux功底

最终选Docker,核心原因有三个:一是容器隔离了文件系统和网络,用户代码默认看不到宿主机进程和网络;二是docker run自带--memory、--cpus、--pids-limit参数,可以直接限制内存、CPU和进程数量,省去自己解析cgroup的麻烦;三是镜像机制天然适合多语言环境,Java环境、Python环境、C++环境分开打镜像,需要哪个拉哪个。

代价是性能损耗。容器启动本身有开销,如果每条提交都现场创建容器,耗时不可接受。我最后用的是“容器复用”策略:判题机常驻一个容器,每次判题通过docker exec把代码塞进去执行。具体做法,下一节讲实现时细说。

2.3 队列与存储模型设计思路

队列方案没有太多纠结的空间。可选方案无非RabbitMQ、Kafka、Redis Stream。我的实际选择是RabbitMQ,原因很朴素:提交任务量级用不到Kafka那种吞吐量,而RabbitMQ能保证消息不丢失,且支持手动确认,配合业务字段做幂等就够用。Redis Stream也能做,但它更偏数据结构,持久化和ack机制没有RabbitMQ成熟,出了问题更难排查。

存储侧,用户提交的表结构反而比想象中简单。核心字段就几个:提交ID、题目ID、用户ID、代码内容、语言类型、判题状态、执行耗时、内存占用、错误信息。真正复杂的是结果判定,属于计算逻辑,不属于存储逻辑。

题目数据的存储也要提前设计好。我的方案是:数据库里只存题目元信息和数据文件路径,真正的测试数据放在判题机共享目录下,按problemId组织文件夹结构。这样判题时不需要走网络去数据库拉文件,本地文件读取最快最可靠。

3. 核心模块实现与实操细节

3.1 判题核心(133):编译、运行、限时限内存的实现

判题核心的输入是“用户代码 + 题目元信息”,输出是一组判定状态。Java语言标准判题流程分四步:编译、运行、比对、出结果。

编译这一步,最容易被忽视的是编译环境与运行环境的一致性。我踩过一个实际坑:本机编译用的OpenJDK 11,容器镜像里装的是OpenJDK 8,结果用户代码用了Java 11的语法,本地编译通过,上OJ编译失败。后来我把编译和运行统一放到同一个Java镜像里执行,一劳永逸。

用Java实现编译,本质上就是执行命令。下面是一段简化但完整的编译与运行代码:

public JudgeResult judge(Submission submission, Problem problem) { String workspace = "/workspace/" + submission.getId(); String containerName = "judge-container"; // 第一步:编译用户代码 exec("docker exec " + containerName + " javac " + workspace + "/Main.java"); // 第二步:构造docker exec命令,限制资源并运行 String cmd = String.format( "docker exec %s /bin/sh -c \"cd %s && java -Xmx256m Main < %s\"", containerName, workspace, problem.getDataPath() + "/1.in" ); long start = System.currentTimeMillis(); Process process = Runtime.getRuntime().exec(cmd); long timeoutMs = problem.getTimeLimitMs(); boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS); // 第三步:根据结果分类 if (!finished) { process.destroyForcibly(); return JudgeResult.timeLimitExceeded(); } if (process.exitValue() != 0) { return JudgeResult.runtimeError(); } // 比对输出省略,在3.4展开 }

这段代码看起来简单,但有三个隐藏问题必须处理。

第一是Docker容器如何限制资源。容器安全参数没有体现在上面的exec里,因为容器在启动阶段就定好了配额。我的做法是创建容器时直接定义:

docker run -d --name judge-container \ --memory=512m --memory-swap=512m \ --cpus=1 \ --pids-limit=64 \ --network none \ --read-only \ judge-image:1.0

--pids-limit=64这个参数特别重要,它可以防止用户代码里fork炸弹。我在测试时故意扔了一个无限fork的Java程序进去,没有这个限制,容器直接卡到无法响应。

第二是输出文件比对之前,需要把容器里的标准输出重定向出来。上面的代码用了<重定向输入,但docker exec默认不把容器内进程的stdout直接传给宿主机Java进程,这里会踩到缓冲区满导致进程卡死的坑。稳妥做法是把输出写入容器内文件,再用docker cp拷出来,避免管道缓冲。

第三是超时控制。waitFor(timeout, unit)很关键,但要注意超时后必须destroyForcibly(),并再等几百毫秒确认进程真正结束。否则容器内进程会残留,长时间运行后系统里会堆积大量僵尸进程。

3.2 评测队列(134):消息驱动与幂等消费

评测队列的设计目标只有一句话:大量提交进来时,系统不能被压垮,也不能把某个提交丢了。

我的RabbitMQ配置是这样的:交换机用direct类型,队列设置为持久化,消费者的autoAck关闭,手动确认。每一条消息体内容包含提交ID、题目ID、语言类型和代码路径。Web后端把提交信息写入数据库之后,立刻发送消息到队列,然后返回给前端“判题中”的状态。

真正需要对消息做幂等处理的关键点是:消费者拿到消息后,如果判题中途宕机,消息会被重新投递;如果网络原因导致ack丢失,也会重复投递。如果不对重复投递做处理,同一条提交会被判两遍。解决办法很简单:在判题记录表里给submission_id加唯一索引,消费者处理消息前先尝试插入一条判题记录,插入成功才真正执行判题,插入失败说明这条消息已经处理过,直接确认丢弃。

排队时长预估值也很实用。我在队列消费者里做了计数自增,这样管理后台可以随时看到“队列积压数量”。一旦积压超过100,就说明判题吞吐跟不上提交量了,需要扩容判题机。

这里还要提一个相对隐蔽的调度问题:同一道题的多个提交,如果按队列顺序逐一判,前面一个大数据量提交可能会拖慢后面所有提交。我最后加了简单的优先级策略:根据目标题目的预计耗时给消息设置优先级,短题目先判。注意RabbitMQ的优先级队列是x-max-priority声明的,设置太大的优先级数字会额外消耗内存,我用0-10就够了。

3.3 题面数据管理(135):数据组织与特殊判题

题目数据这一块,新手经常理解成“数据库里存几行in/out就行了”,实际上完全不是。

我最终采用的目录结构是这样的:

/data/problems/133/ ├── question.md ├── config.json ├── data/ │ ├── 1.in │ ├── 1.out │ ├── 2.in │ ├── 2.out │ └── ... ├── spj/ │ └── checker.jar └── samples/ ├── sample1.in └── sample1.out

config.json里标注了时间限制、内存限制、是否允许Special Judge等信息。样例单独放在samples目录,不参与正式判题,只用来在题目展示页面给用户做自测。

特殊判题(SPJ)是另一个高频需求点。经典场景是输出浮点数,答案允许误差在1e-6以内,或者题目本身就允许多解。普通判题是比对文本完全一致,SPJ则需要先运行一个校验程序,由校验程序读取用户的输出和标准答案,自行决定对错。我配置SPJ的思路是:判题核心运行完用户程序后,发现config.json里spj=true,就转而去执行checker.jar,把用户输出文件路径和答案文件路径作为参数传进去,最后读checker进程的退出码决定用户这道题是AC还是WA。

3.4 “Perfect”判定与分析:我刷题时踩过的样例边界

说到“Perfect”这个判定,是不少OJ在用户通过所有测试点时给出的最高评价。实际开发中我发现,要真正达到“Perfect”,比对环节的细节极其苛刻。

文本比对不能直接用Java的String.equals(),因为不同系统对文件结尾换行符的处理不一样。标准输出的最后一行可能带换行,也可能不带。正确做法是逐行读取两个文件,去掉行尾的\r,再逐行比较字符串。

另一个真实案例是空格数量不一致问题。OJ用户经常在多个空格连续的地方输出单空格,这种差异肉眼很难发现,判定时却是WA。我在判题核心加了一个配置项,普通题目严格模式,逐字节比对;部分数学输出题用宽松模式,将连续空格压缩后再比对。这属于业务规则,必须在题目配置里单独指定,不能全局统一。

开发完判题核心之后,我自己拿平台上的热门题目做了一轮自测,用一段故意在最后一行多打一个空格的C++代码提交,结果精确地收到了WA。那一刻我反而很开心,说明比对逻辑真正在认真工作。

4. 链路打通:从提交到出分的完整流程

4.1 前端提交与后端入库

链路的第一环是前端拿到代码文本,提交到Web后端。我现在的实现是两步走。前端点击提交,后端先把代码入库,状态置为“待判题”,同时把代码文本写进判题机共享工作目录,最后发送MQ消息。

先入库再入队有个明显的好处:即使MQ队列故障或者判题机全部宕机,提交数据已经落在数据库里了,系统恢复后可以把状态为“待判题”且超过N分钟未更新的记录重新入队。这一步需要单独跑一个定时任务,属于兜底操作,但非常有必要。

代码写入共享目录时的文件名必须和语言匹配。Java要求公开类名和文件名一致,所以Java代码我统一写成Main.java,编译时会检查用户代码里是否有错误。C++统一用Main.cpp。这里容易踩的坑是:用户从自己IDE里复制代码时把文件名带过来,我保存时如果不做重命名,判题机就会编译失败。

4.2 判题机调用与结果回传

判题机拿到消息后,把判题结果写回数据库,再通过MQ给Web后端发一条结果通知。这个模式看起来绕,但异步解耦是关键。如果判题机直接调用Web后端的HTTP接口回传结果,一旦Web后端瞬时不可用,判题结果就会丢失。

Web后端收到结果通知后,更新提交记录的状态和耗时,同时更新用户的做题统计。这一环还有个经验:结果回传时不要只传“AC”或“WA”这种简写,我会把错误信息、退出码、执行耗时、内存峰值全部记录下来。用户看到WA时如果能顺便看到“Runtime Error: NullPointerException”这样的具体信息,体验完全不同。

4.3 “Perfect”背后的绩效标准

作为系统开发者,我给自己定的标准是:判题成功率要达到99.9%以上才敢给真实用户用。评判标准包括消息丢失率、判题机崩恢复率、僵尸进程清零率。在我的实测里,能稳定跑通的最快链路大概是:提交入库耗时80ms,入队到判题机消费耗时100ms,Java程序编译+运行+比对耗时600-1500ms。用户从点击提交到看到成绩,平均在2秒以内。

作为刷题用户,我给自己定的标准只有一个:提交时想的不是“这题会不会AC”,而是“这题我还有没有漏掉哪一类边界”——这是被OJ坑多了形成的反射。

5. 高频问题排查与避坑实战

5.1 判题机超时和泄漏:进程没有清理干净

运行一段时间后我发现判题机响应越来越慢,top一看全是僵尸进程。根源在于process.destroyForcibly()只杀了Java层面的子进程,Docker容器里实际运行的进程还活着。这个问题如果不处理,积攒一晚上,容器内进程数直接顶到--pids-limit上限,新任务全部失败。

解决方案分两层。第一,超时销毁后,额外执行一次docker exec judge-container pkill -9 -f 'java Main',确保容器内残留进程被清掉。第二,增加一个定时任务,每隔5分钟清理一次判题临时目录,删掉超过30分钟的workspace残留文件,这部分代码其实就十几行,但拯救了无数个夜晚。

5.2 队列消息重复消费:没有做幂等

上线第二天就遇到一个灵异现象:同一道题的同一个提交,用户报告成绩一会儿AC一会儿WA。查日志发现是消费者进程在判题过程中超时,RabbitMQ自动重新投递消息,导致同一提交被第二个消费者实例重新判了一遍。

解决方法是之前说的唯一索引。但这里还有一个小坑:判题记录表的插入和判题执行不在同一个事务里,需要把“插入记录”和“判题调用”放在同一个流程里,插入成功后才判题,这样即使判题失败,记录也在,后续重投检测到重复直接跳过。我最初把插入操作写在判题完成之后,结果重复投递照样执行了两遍,属于白踩的坑。

5.3 数据权限与防作弊的隐藏坑

题目数据和用户代码都是绝对不能错的东西。我最开始把数据目录放在和Web服务同一个用户下,某次跑用户代码时(当然是裸奔测试期)它把data目录里的测试数据读出来直接打印,直接把答案泄露了。

后续所有测试数据所在的目录必须挂载为--read-only,用户代码只能读不能写,且用户进程根本看不到数据目录路径。判题机工作目录也要单独建一个临时目录,每次判题前清空。这两条做好之后,防作弊和数据泄露的问题基本就被系统机制兜住了。

5.4 一个容易被忽略的极端场景:全角半角与行尾差异

做OJ开发前我以为字符比对就是字符串比较,做之后才发现文本比对的边角料能写一篇论文。数字输出题在全角半角上翻车是常事:用户从中文输入法切过来,在输出里带了个全角空格,肉眼是看不出来的,比对逻辑却明察秋毫。

我在实测里单独对这一项做了回归测试:构造一组标准输出,要求用户输出和它“看起来一样”但SHA256不同,系统判WA,然后把缓冲字符的差异写入日志。这个细节值得所有OJ开发者记到笔记里:判定结果不要只给用户一个WA,最好在非AC时把“第N行不一致”的定位信息还回去。

6. 最后再分享几个小事

开发OJ的这段时间,经常有人问我“你刷OJ吗”。我的答案永远是:刷。判题系统的每一个判定逻辑,都写着我自己做题时的切肤之痛。没有亲手被TLE教训过,就不会理解为什么timeout的判定要留几百毫秒的冗余;没有为一个样例WA到怀疑人生,就不会领悟到特殊判题和宽松比对的价值。

做项目也一样。现在你再看“133-135(oj)”这三个编号,应该能读懂:优质的OJ系统背后是无数个这样的小任务堆出来的,每个任务都有它存在的理由。如果你想自己动手做一套,我的建议是不要贪多,先把判题核心这几十行代码写透,把一次提交走通,再去考虑排行榜、讨论区、班级管理那些锦上添花的东西。核心链路稳定了,整个OJ项目就立住了一半。

最后一个小技巧:多备一份和线上环境完全一致的虚拟机来模拟判题机故障,断电、断网、磁盘满、进程被杀,全流程演练几遍。真正到了上线那个晚上,你会感谢之前每一次折腾。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 4:30:58

Agent-Reach:轻量级CLI驱动的LLM Agent协同调度框架

1. “Agent-Reach”不是新模型&#xff0c;而是一套轻量级CLI驱动的Agent协同调度框架你点开GitHub搜“Agent-Reach”&#xff0c;第一眼看到的很可能不是某个大厂发布的SOTA模型&#xff0c;而是一个星标刚过200、README里写着“CLI-first, API-native, Python-powered”的小仓…

作者头像 李华
网站建设 2026/10/6 4:30:54

10+10+10备考法:机考翻译单词三线并行冲刺指南

1. “101010”是什么&#xff1a;一场围绕机考核心的三线备考拆解我先直接说结论&#xff1a;这个“101010”并不是什么官方机构命名的考试项目&#xff0c;而是我自己在实际备考中反复验证过的一套压缩型训练结构——10天&#xff0c;每天围绕三个核心板块各投入一组高强度任务…

作者头像 李华
网站建设 2026/10/6 4:30:41

Windows 11 开始菜单改造:OpenShell 从安装到高级定制

1. 为什么 Windows 用户绕不开 OpenShell 这个选择打开 Windows 11 的设置&#xff0c;你大概率会对那个居中排列、图标扁平化的开始菜单皱眉头。微软这些年把开始菜单改来改去&#xff0c;从 Win8 的磁贴全屏&#xff0c;到 Win10 的混合布局&#xff0c;再到 Win11 的居中简化…

作者头像 李华
网站建设 2026/10/6 4:29:41

机器视觉镜头选型实战:从焦距计算到畸变标定的完整指南

简介&#xff1a;机器视觉系统之镜头篇PPT学习教案&#xff0c;是一份面向自动化、智能制造从业者与初学者的专业教学课件&#xff0c;系统讲解镜头在视觉系统中的核心作用与成像原理。资源共1个pptx文件&#xff0c;压缩包大小约639KB&#xff0c;内容紧凑、结构清晰。课件从图…

作者头像 李华
网站建设 2026/10/6 4:29:41

TIM数字孪生平台与STM32设备接入:系统集成商如何快速落地

2026年刚开年&#xff0c;我朋友圈里不少做系统集成的朋友都在转同一份资料——《孪图科技&#xff1a;TIM产品与服务合作面向系统集成商白皮书 2026》。有人把它当产品手册&#xff0c;有人当合作政策解读&#xff0c;也有人只看目录就转给了技术负责人。我花了一周时间把这份…

作者头像 李华
网站建设 2026/10/6 4:29:11

Agent-Reach:轻量级多智能体调度中枢设计与实践

1. 项目概述&#xff1a;Agent-Reach 是什么&#xff0c;它解决的不是“调用API”而是“调度智能体”的根本问题Agent-Reach 不是一个简单的命令行工具&#xff0c;也不是一个封装了 DeepSeek 或其他大模型 API 的 Python SDK。如果你把它当成“又一个 CLI 调用器”&#xff0c…

作者头像 李华