在线判题系统(OJ)架构解析:从Web端到判题端的部署与优化
简介:一套面向程序设计竞赛与算法训练的在线判题系统(OJ)完整项目,适合高校计算机相关专业学生用于课程设计、毕业设计或竞赛平台二次开发。资源覆盖Web管理端与判题端,前端采用Bootstrap4、Vue与Thymeleaf,后端使用Spring Boot、MyBatis、Spring Security、Spring Session,数据库为MySQL与Redis,并引入RocketMQ处理判题消息,配套项目源码与文档说明,代码经过测试运行成功,便于从零搭建和排查问题。压缩包共2000个文件,约15.9MB,以855个png截图、652个js前端逻辑、191个java后端实现、145个html页面、105个css样式为主,另含SQL脚本、Dockerfile等部署辅助文件,目录结构完整清晰。已有472人学习下载,可作为毕设选题、课设作业或OJ平台入门开发的学习样本。
1. 在线判题系统(OJ系统)到底在解决什么问题:从竞赛选手到判题端的完整闭环
一个程序设计竞赛选手在赛场上最怕的不是不会写题,而是代码交上去之后,等了几分钟还在队列里排队,最后判题端给你一个莫名其妙的 Runtime Error,连哪行出错都不告诉你。做过 OJ 系统的人都知道,这背后远不止“编译 + 跑一下”那么简单。一个能支撑程序设计竞赛的在线判题系统,必须把 Web 端的用户操作、判题端的资源隔离、以及两者之间的状态同步做得严丝合缝。这个标题里的“含 Web 端 + 判题端”不是营销话术,而是这套系统的本体:Web 端负责题目展示、代码提交、排行榜和比赛管理,判题端负责在受限沙箱里编译、运行、比对输出,再把结果回传给前端。这篇笔记会带你从零把这个 OJ 拆开,看懂它的数据流和核心参数,并且给出你能直接照做的本地部署步骤。无论你是想为校内竞赛搭一套可用系统,还是想学习服务端 + 定时任务 + 沙箱隔离的架构,这个项目都非常值得过一遍。
2. 拆解Web端与判题端:一条提交从浏览器到评测机的完整链路
2.1 Web端的核心模块:题目列表、提交页与状态轮询
Web 端在大多数 OJ 项目里其实就是一套常规的 MVC 应用,但有几个模块和普通管理系统不一样。首先是题目列表,它不只是展示标题和难度,还要处理样例输入输出、题目的数据范围、以及是否允许查看他人提交。其次就是提交页,用户在代码框里选择语言、粘贴代码、点击提交,这一瞬间 Web 端要做的事情比想象中多:把代码存进数据库或队列、生成一个 submission 记录、再通知判题端去取任务。
常见的做法是 Web 端不直接同步调用判题接口,而是把提交写进一张表,然后由判题端轮询数据库或通过消息队列来消费。这种异步设计的好处是提交操作能瞬间返回,“Pending” 状态先展示给用户,判题端处理完再更新状态。前端页面通常用定时器每隔 1~2 秒轮询一下当前 submission 的状态,或者用 WebSocket 推送状态变化。在这个项目里,Web 端我一般重点看两个文件:一个是提交相关的 Controller,另一个是状态查询的 Service。前者要检查用户是否登录、题目是否允许提交、代码长度是否超限,后者要能返回状态码和具体的判题详情。
状态轮询有一个容易被忽略的点:轮询接口要带版本号或更新时间,避免前端拿到旧状态覆盖新状态。如果网络抖动导致请求乱序,用户会看到已经 Accepted 又跳回 Running 的诡异现象。比较好的方案是在 submission 表里加一个 update_time 字段,查询接口强制按更新时间排序返回,前端只在拿到新的 update_time 时才刷新状态。这种细节项目源码里不一定做得很好,但你自己落地时要盯住。
2.2 判题端的设计:隔离沙箱与资源限制
判题端是整个 OJ 系统里最有技术含量的部分,也是标题里单独拿出来强调的原因。判题端要做三件事:从队列里取到提交记录、在隔离环境里编译和运行、比对输出结果。隔离沙箱是第一个关键点——你不能让用户提交的代码直接跑在服务器本身上,否则一段rm -rf /或者死循环就能搞垮整个 OJ。
常见的隔离方案有两种:一种是基于 Linux 系统调用层面的沙箱,比如使用 seccomp 或 rlimit 限制进程的资源;另一种是直接上 Docker 容器,每个提交跑在一个临时容器里,环境干净、销毁彻底。这个项目既然叫“判题端”,很可能自带了一套封装好的沙箱,但没有用容器。因为容器开销大,比赛高峰期几百个提交同时到达时,启动一个容器要几百毫秒,而进程级沙箱只需要 fork 一次。所以很多自研 OJ 都选择进程级隔离,配合 cgroup 限制 CPU 和内存,再用 seccomp 黑名单禁用危险系统调用。
资源限制是第二个关键点。判题端必须从数据库读到这道题配置好的时间限制和内存限制,然后传给沙箱执行器。时间限制不能用墙钟时间,因为系统负载会影响真实运行时间,一般都要用 CPU 时间或监控子进程的用户态加内核态时间。内存限制则要同时监控进程的虚拟内存和常驻内存,有些 C++ 程序会一次性申请大块内存但并不全部使用,如果不看驻留内存,就会出现误判 MLE(超出内存限制)。
2.3 提交状态机:从Pending到Accepted的流转
判题端处理一条提交时,状态会经过 Pending、Running、Compile Error、Accepted、Wrong Answer、Time Limit Exceeded、Memory Limit Exceeded、Runtime Error、Output Limit Exceeded 等几个状态。这里容易踩坑的是状态流转的幂等性——比如判题端更新状态时和 Web 端查询状态并发,可能导致最后回写的结果被更早的状态覆盖。
为了让状态流转可靠,最好把状态机固化在数据库里:submission 表里有一个 status 字段和一个 error_message 字段,判题端只负责从队列里消费,更新这两列,不做任何与 Web 端的直接通信。判题端每处理完一个阶段,比如编译完成,就更新 status 为 Running 并写入编译日志;运行完成后再更新为 Accepted 或对应的失败原因。Web 端查询时只读这两列,不参与写入,这样状态机就天然收敛了。
另外,判题端要支持“重新判题”操作。管理员修改了测试数据或者发现某题数据有问题时,会批量重置提交状态。这要求判题端能以 submission id 为键重新入队,并且要清理旧的判题结果。源码里如果没实现这个,你自己加也不难:写一个管理接口,把选中的 submission 的 status 改成 Pending,状态变更的监听器会自动把它送入判题队列。
3. 跑通本项目的最小步骤:从源码到本地可用的OJ环境
3.1 准备依赖:JDK、MySQL、Redis与判题端语言环境
拿到这套 OJ 项目源码之后,别急着打开 IDEA 就按 Ctrl+Shift+F10。先检查本机环境,避免出现“编译过了但启动就崩”的尴尬。大多数这类 OJ 项目都是 Java + Spring Boot 写的,Web 端和判题端是同一个 maven 工程里的两个模块。你要准备的东西有:JDK 8 或更高版本、Maven 3.6+、MySQL 5.7+、Redis(用于缓存和队列),以及判题端需要调用的各种编译器,比如 gcc、g++、python3 和 JDK。这里我建议你用 Docker 装 MySQL 和 Redis,省去本机环境变量和配置文件的折腾,编译器则必须在宿主机装好,因为判题端是直接调用系统命令的。
# 用 docker 启动 MySQL 和 Redis,避免污染本机环境 docker run --name oj-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -d mysql:5.7 docker run --name oj-redis -p 6379:6379 -d redis:6-alpine这段命令会拉取两个镜像并启动,-p表示把容器内的端口映射到宿主机,这样 Web 端代码里配置的连接地址 localhost:3306 和 localhost:6379 就能直接访问。如果你所在网络拉取镜像比较慢,就改用系统自带 MySQL 和 Redis,但要注意账号密码保持一致。这个项目里数据库配置一般写在 application.yml 里,你需要检查里面是否有额外的useSSL=false参数,否则连接时会报 SSL 警告。
编译器这里要额外说一句,判题端执行编译命令时用的是进程级别的ProcessBuilder,你需要确认g++ --version和python3 --version在启动判题端的用户下能正常输出。以前遇到很多次,root 用户装了编译器,但以服务用户启动判题端时 PATH 里没有对应目录,导致编译命令直接失败。所以建议在启动判题端之前,先用启动时用的用户身份执行一遍g++ --version和java -version验证环境变量。
3.2 导入数据库脚本并初始化账号
项目源码的 doc 目录下一般会放一个oj.sql或init.sql的数据库脚本,里面创建了库表并插入了管理员账号。你需要把这个脚本导入 MySQL,然后修改 Web 端的数据库连接配置。导入之前先建一个数据库,因为脚本里可能只写了 CREATE TABLE 没有 CREATE DATABASE。
CREATE DATABASE IF NOT EXISTS oj_system DEFAULT CHARACTER SET utf8mb4; USE oj_system; SOURCE /path/to/oj.sql;执行SOURCE命令时记得把路径替换成你本机实际的 sql 文件路径。导入后检查一下 user 表里有没有默认账号,通常管理员账号是 admin/admin123 或者 root/123456。如果脚本里没有插入任何账号,你需要手动 INSERT 一条,密码字段必须存 BCrypt 加密后的字符串,不能存明文。这个项目如果用了 Spring Security,登录的密码比对逻辑是走 BCrypt 的,你手动插明文会导致永远登录不上。
常见的坑是脚本里用了 utf8mb4 但你的 MySQL 默认排序规则是 utf8mb4_unicode_ci,这没问题。但如果你看到乱码,就要在连接字符串里加上characterEncoding=utf8,同时注意 MySQL 连接驱动版本。老项目用 com.mysql.jdbc.Driver,新版本用 com.mysql.cj.jdbc.Driver,导入后如果启动报找不到驱动,第一反应就是换驱动类。
3.3 启动Web端与判题端:两个进程的协作
环境配置好之后,最核心的一步是同时启动 Web 端和判题端,并确认它们之间的通讯正常。这个项目拆成了两个模块,通常是oj-web和oj-judge,在同一个 maven 工程下。你需要开两个终端分别启动,或者直接打包成两个 jar 分别运行。
# 打包整个工程,跳过测试 mvn clean package -DskipTests # 启动 Web 端,端口 8080 java -jar oj-web/target/oj-web.jar --server.port=8080 # 启动判题端,端口 8081(判题端不需要对外,但保留端口便于调试) java -jar oj-judge/target/oj-judge.jar --server.port=8081如果项目没有打包出独立的 jar,而是依赖内置 Tomcat,那就直接用 mvn spring-boot:run 分别启动两个模块。启动后观察日志:Web 端应该打印出数据库连接成功、Redis 连接成功;判题端应该打印出“等待任务...”或“Judge Queue Started”之类的提示。这时打开浏览器访问http://localhost:8080,能看到登录页就说明 Web 端活了;然后用 admin 账号登录,随便交一道题,看判题端日志是否打印出 compile 和 run 的步骤。如果判题端没有反应,优先检查 Redis 队列的 key 名称是否一致。
这里有一个特别容易翻车的点:Web 端和判题端如果不共用同一个数据库,判题端会找不到 submission 记录。很多项目默认它们连同一个 MySQL 数据库,但配置文件里写了不同的库名,导致 Web 端交题后,判题端轮询库里没有数据。你需要在两个模块的配置里确认连接的是同一个 oj_system 库。
4. 判题端核心参数与源码改造:3个必须调优的配置
4.1 时间限制与内存限制的传递方式
判题端拿到一条 submission 后,要找到对应题目的 time_limit 和 memory_limit,然后把它们传给沙箱执行器。源码里最可能写在JudgeTask或JudgeRunner这个类里。时间限制的单位通常是毫秒,内存限制的单位是 KB 或 MB,你必须要看代码里是直接透传,还是做了单位换算。很多项目踩过这个坑:数据库里存的时间限制是 1 秒,但判题端当成 1 毫秒来用,结果所有代码都超时。
正确的做法是定义统一的 javabean,比如JudgeLimit类,包含 cpuTimeLimit、realTimeLimit、memoryLimit 三个字段,在从数据库查询题目信息时直接构造好,避免一边用 long 一边用 int 导致溢出。realTimeLimit 通常设置成 cpuTimeLimit 的 2 倍或 3 倍,因为有些程序在做磁盘 IO 时 CPU 时间很少但墙钟时间超了,这时不应该判 TLE。如果你看到判题端只用了一个时间参数,建议你加上 realTimeLimit。
另一个容易被忽视的是内存限制的单位。Linux 的setrlimit里 RLIMIT_AS 以字节为单位,而 Java 的-Xmx以 MB 为单位。如果项目里直接把数据库的 256 当成兆字节传给沙箱,内部再转成字节,那没问题;但如果数据库里存的是 KB,你按 MB 传进去,程序申请超过 256KB 内存就被杀,这道题基本没人能过。我建议你在判题端的日志里打印出实际传给沙箱的字节数,看到 268435456(256MB)才放心。
4.2 沙箱隔离级别的选择:进程级还是容器级
源码里的判题端如果内置了一个 Sandbox 目录,大概率是进程级隔离,因为容器方案不需要自带 Sandbox,直接用 Docker SDK。进程级隔离的核心是 fork 子进程,然后调用setrlimit和seccomp。这里你要看它是否真正启用了 seccomp,有些项目写了过滤规则但没挂载,导致用户代码可以调用execve去执行系统命令。你可以做一个小测试:提交一道用 C++ 的system("ls")的题,如果判题端返回了结果而不是被阻断,说明 seccomp 规则没生效。
进程级隔离的优点是轻量,缺点是一旦用户代码有漏洞,可能逃逸到父进程。容器级隔离更安全,但需要 Docker 环境且启动开销高。如果你接手时项目自带的是进程级沙箱,并且打算上生产环境,我建议你至少加上以下安全措施:以 nobody 用户运行子进程、限制子进程文件写入路径到临时目录、禁用网络访问。网络禁用在进程级沙箱里很难做完善,但容器里用--network=none就很容易。所以如果你把判题端迁移到 Docker 内运行,建议直接把沙箱层也换成 Docker 创建一次性的评测容器。
改造沙箱有一个平滑过渡方案:先保留原有进程级沙箱,但把编译运行阶段换成 Docker 容器。判题端只需要调用 Docker API 创建一个临时容器,挂载一个包含用户代码和测试数据的临时目录,然后把结果输出取回来。这样改动面积小,且不破坏原有的编译日志和错误信息格式。不过要注意 Docker 容器的--memory参数和--cpus参数不能完美对应进程级沙箱的毫秒级精度,所以要在配置里增加一个偏差值。
4.3 判题结果回传的可靠性:失败重试与幂等性
判题端在运行过程中可能遇到各种异常:编译器闪退、进程被 OOM Killer 杀掉、网络断连导致 Redis 连接失败。如果源码里没有重试机制,一旦出现异常就丢题,用户那边永远卡在 Running 状态。这个项目里我注意到判题端是一个 while 循环里不断从队列 poll 任务,如果 poll 到的任务执行抛出了异常,循环可能直接中断,判题服务就挂了。所以第一步要做的是在循环里包一层 try-catch,保证单条判题异常不影响后续任务。
然后要考虑失败重试的次数和间隔。例如对于编译环境缺失这类问题,重试多少次都没用,应该直接标记为 System Error;而对于 Redis 连接超时这类瞬时错误,可以重试 3 次,指数退避。判断重试依据是异常的字段:如果异常里包含了 message "CompileError",就更新状态为 CE 并入库;如果是 RuntimeException,就重试。
幂等性问题在于:判题端更新 submission 状态时,如果写入数据库失败,它可能再次消费同一条消息,导致重复判题。为避免重复判题,你要在 submission 表里加一个judge_counter字段,入队时先置 0,判题端每次消费时加 1,如果大于 3 就丢弃。或者利用 Redis 的 setnx 命令,在消费前先占一个以 submissionId 为 key 的锁,成功才处理,处理完释放。这个方案比数据库字段更轻量,也更适合多实例并发判题。
5. 避坑与常见问题:本地部署OJ最容易翻车的5个地方
5.1 现象:启动 Web 端后访问 8080 端口报 404 或白屏
原因:项目里可能存在两个端共用一个端口,或者静态资源路径没有映射对。很多 OJ 的 Web 端用了 Thymeleaf 模板引擎,但静态资源放在/static下,如果你直接用@RestController返回字符串,就不会走模板引擎,导致页面找不到。另一个常见原因是 Web 端启动时没有加载到classpath:/static/下的 index.html。
解决:先看源码里有没有WebMvcConfigurer或WebConfig这样的类,确认它是否重写了addResourceHandlers。如果没重写,默认静态资源路径是classpath:/static/,你要检查 index.html 是否放错了位置。再试试直接访问http://localhost:8080/login,如果能显示登录页但 index 不行,就说明是根路径的跳转问题,需要在 Controller 里加一个@GetMapping("/")返回 redirect 到/index。
5.2 现象:提交代码后状态一直是 Pending,判题端没有日志
原因:判题端根本没收到消息,或者收到了但没匹配到任务。最常见的是 Web 端通过 Redis 的某个 List 左推消息,判题端却从另一个 key 右弹,或者判题端连了一个 Redis,Web 端连了另一个 Redis。还有就是判题端的 SQL 查询语句里 where 条件写死了一个状态,比如where status=1,但 Web 端提交时填错了状态值。
解决:先检查两个模块的application.yml里 redis 的 database 配置是否一致,默认 database 0 和 database 1 是不同的数据集。然后在 Redis 客户端执行keys *judge*看有没有队列 key 产生。如果队列有数据但判题端没消费,就手动在判题端里调用一下poll方法试试,再检查判题端的定时任务有没有被 Spring 扫到——很多项目用@Scheduled注解,但启动类忘了加@EnableScheduling。
5.3 现象:C++ 代码能编译通过,但运行时报 Runtime Error,本地却正常
原因:判题端的沙箱限制了栈大小或堆大小,导致用户代码中的递归或new超过限制。有些判题沙箱会默认把进程的虚拟地址空间限制在 256MB,而你的 C++ 程序里定义了一个全局大数组,这数组算在 BSS 段里,虚拟内存瞬间超过限制。还有一个原因是沙箱禁用了某些系统调用,比如getrusage或clock_gettime,导致标准库内的函数崩溃。
解决:先通过判题端日志确认退出码,如果是信号 9 或 11,大概率是被 OOM Killer 或段错误干掉了。这时你需要把题目内存限制调大,比如从 128MB 调到 256MB,再重新判题。如果本地能跑,检查你是否开了-static编译,动态链接在沙箱里找不到.so文件会报 Runtime Error,为比赛环境编译时最好用静态编译。
5.4 现象:判题结果 Accepted,但耗时显示为 0ms 或负数
原因:计时方式错误。判题端如果用System.currentTimeMillis()统计整个进程的墙钟时间,而且进程运行太快,就会得到 0ms。如果你在子进程结束时立刻取时间,可能拿到的时间差为负,因为系统时钟被 NTP 调整过。
解决:改用System.nanoTime()统计 CPU 时间,或者从/proc/<pid>/stat里读取进程的 utime 和 stime。更稳妥的方式是使用wait4系统调用或Process.waitFor()返回的 exit value 外加资源统计。对耗时统计的正确性,我的习惯是加一个最小限定值:实际耗时如果小于 0.1ms 就显示为 0ms,不要出现负数。
5.5 现象:多人同时提交时,判题端崩溃或卡死
原因:判题端是单线程消费队列,或者沙箱执行器没有做并发控制。一个典型的问题是判题端里用了全局的临时目录,多个评测任务同时写同一个文件,互相覆盖,编译输出就乱了。还有的是沙箱的进程 fork 数量过多,超过了系统 PID 限制。
解决:把判题端改成用线程池消费队列,但要给线程池设上限,比如Runtime.getRuntime().availableProcessors()个线程。同时每个判题任务用独立的临时目录,目录名用submissionId+时间戳生成,结束后递归删除。最后调整系统限制:ulimit -u和pids_max要足够大,不然并发一高就 fork 失败。
6. 进阶用法:给OJ加上竞赛模式与排行榜的实操思路
如果你想把这个 OJ 真正用到程序设计竞赛里,就要在现有功能上增加两个东西:竞赛模式和排行榜。竞赛模式的核心是比赛期间题目列表要倒计时显示,提交结果只在比赛结束后公开排名,以及封榜操作——比赛最后 1 小时显示实时排名但不显示具体提交细节。代码实现上不复杂,你要在数据库里增加contest表、contest_submission关联表,Web 端按比赛 ID 查询提交时要判断当前时间是否在开始和结束时间之间。
排行榜的算法可以参考 ICPC 赛制:解题数最多的排前面,解题数相同则总罚时少的排前面,罚时是每次错误提交罚 20 分钟,再加上第一次通过时距离比赛开始的时间。这个逻辑你可以直接用 SQL 查询出来,也可以在内存里计算,但要注意封榜后的可见性控制。我一般会在 Redis 里缓存排行榜,set 一个过期时间为比赛结束,这样比赛结束时自动失效,不需要定时任务去清理。
还有一个很实用的功能是比赛期间只允许查看自己提交的代码,防止抄袭。实现方法是给查询提交详情的接口加一个判断:如果当前用户不是管理员,且提交时间在某个比赛中,就要检查该提交是否属于当前登录用户,否则返回无权限。这个改造涉及权限校验,建议在 Service 层做,不要只在前端隐藏按钮,因为接口可以被直接调用。
做这些功能时,你会感觉到这个 OJ 项目的架构其实很清楚,但代码里可能缺少抽象,很多判断逻辑直接写在 Controller 里。我的习惯是先把比赛和普通的练习模式抽象成两种“场景”,在提交时传入 scene 类型,判题端不关心场景,只关心提交本身。这样一个判题端能同时支撑日常练习、正式比赛和虚拟比赛的评测,不会因为比赛封榜影响日常刷题状态。这个方案改动不大,但能让项目从“可用”变成“好用”。
做完这些之后,我建议你再用 Jmeter 或 wrk 对提交接口做个压测,看看在 100 个并发提交下判题端能不能扛住。因为比赛场景里经常出现“最后一分钟所有人同时交题”,如果判题端处理不过来,队列会越积越长。我当年第一次搞 OJ 就是没压测,结果校内赛 200 人同时交题,判题队列直接堆了 10 分钟,选手全在骂娘。后来我把判题端线程池从 4 调到 16,又给 MySQL 连接池加了上限,才算兜住。现在每次给别人调 OJ,我都会先问一句:你预计的最大并发是多少?然后照着这个数字去调线程池和队列长度,别再等翻车了补。希望你这次的 OJ 部署,能一次顺利跑通。
本文还有配套的精品资源,点击获取