在线判题系统(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 就按 CtrlShiftF10。先检查本机环境避免出现“编译过了但启动就崩”的尴尬。大多数这类 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_PASSWORD123456 -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 里你需要检查里面是否有额外的useSSLfalse参数否则连接时会报 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这没问题。但如果你看到乱码就要在连接字符串里加上characterEncodingutf8同时注意 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.port8080 # 启动判题端端口 8081判题端不需要对外但保留端口便于调试 java -jar oj-judge/target/oj-judge.jar --server.port8081如果项目没有打包出独立的 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 内存就被杀这道题基本没人能过。我建议你在判题端的日志里打印出实际传给沙箱的字节数看到 268435456256MB才放心。4.2 沙箱隔离级别的选择进程级还是容器级源码里的判题端如果内置了一个 Sandbox 目录大概率是进程级隔离因为容器方案不需要自带 Sandbox直接用 Docker SDK。进程级隔离的核心是 fork 子进程然后调用setrlimit和seccomp。这里你要看它是否真正启用了 seccomp有些项目写了过滤规则但没挂载导致用户代码可以调用execve去执行系统命令。你可以做一个小测试提交一道用 C 的system(ls)的题如果判题端返回了结果而不是被阻断说明 seccomp 规则没生效。进程级隔离的优点是轻量缺点是一旦用户代码有漏洞可能逃逸到父进程。容器级隔离更安全但需要 Docker 环境且启动开销高。如果你接手时项目自带的是进程级沙箱并且打算上生产环境我建议你至少加上以下安全措施以 nobody 用户运行子进程、限制子进程文件写入路径到临时目录、禁用网络访问。网络禁用在进程级沙箱里很难做完善但容器里用--networknone就很容易。所以如果你把判题端迁移到 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 右弹或者判题端连了一个 RedisWeb 端连了另一个 Redis。还有就是判题端的 SQL 查询语句里 where 条件写死了一个状态比如where status1但 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 部署能一次顺利跑通。本文还有配套的精品资源点击获取