
做OJOnline Judge时间久了你会发现真正磨人的往往不是算法本身而是从代码提交到判题结果回传中间那条不可见的长链路。项目标题里的“133-135oj”在我这边是仓库里的三张连续任务卡编号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 \ --memory512m --memory-swap512m \ --cpus1 \ --pids-limit64 \ --network none \ --read-only \ judge-image:1.0--pids-limit64这个参数特别重要它可以防止用户代码里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.outconfig.json里标注了时间限制、内存限制、是否允许Special Judge等信息。样例单独放在samples目录不参与正式判题只用来在题目展示页面给用户做自测。特殊判题SPJ是另一个高频需求点。经典场景是输出浮点数答案允许误差在1e-6以内或者题目本身就允许多解。普通判题是比对文本完全一致SPJ则需要先运行一个校验程序由校验程序读取用户的输出和标准答案自行决定对错。我配置SPJ的思路是判题核心运行完用户程序后发现config.json里spjtrue就转而去执行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入队到判题机消费耗时100msJava程序编译运行比对耗时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-135oj”这三个编号应该能读懂优质的OJ系统背后是无数个这样的小任务堆出来的每个任务都有它存在的理由。如果你想自己动手做一套我的建议是不要贪多先把判题核心这几十行代码写透把一次提交走通再去考虑排行榜、讨论区、班级管理那些锦上添花的东西。核心链路稳定了整个OJ项目就立住了一半。最后一个小技巧多备一份和线上环境完全一致的虚拟机来模拟判题机故障断电、断网、磁盘满、进程被杀全流程演练几遍。真正到了上线那个晚上你会感谢之前每一次折腾。