ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java+MySQL构建在线评测系统:判题队列与可扩展实践

Java+MySQL构建在线评测系统:判题队列与可扩展实践 简介这套基于Java与MySQL的Web在线评测系统面向计算机专业课程设计及毕业设计场景适合需要实现在线编程评测、课程作业管理或内部论坛的开发者参考。系统整合Spring MVC、Hibernate、MySQL与Lucene完整展示从前端JSP交互到后端业务逻辑、数据映射及全文检索的典型分层实现。资源包共872个文件约21.11MB其中包含161个Java源文件、191个JavaScript脚本、44个JSP页面、31个CSS样式及59个XML配置等覆盖前后端主要代码与静态资源也提供class文件便于直接运行或对比调试。包内文件类型多样还包含doc说明文档、properties配置文件及图片素材兼顾源码阅读与运行部署需求目录结构清晰适合按模块逐步拆解学习。目前已有204人学习下载无论是课后作业的在线提交、自动评测还是教师端的数据统计与讨论区功能均可从源码中获取完整实现思路并为二次开发提供可复用基础。1. 为什么“在线评测系统”比普通 Web 项目难一个量级在线评测系统OJ要做的核心事用户把 Java、C 或 Python 代码提交到 Web 页面后端把源码拉到判题节点编译、在受限环境里运行、喂测试用例、比对输出再把“AC/WA/TLE”结果回传给前端。真正难的从来不是 CRUD而是“让一段不可信代码在可控环境里运行”以及“提交量一上来判题延迟不能把整个站点拖垮”。用 JavaMySQL 实现一套可扩展的方案核心是找到一条中小团队能维护、以后能平滑加判题机x的落地路径。这篇笔记适合三类人做课设/毕设想做个像样 OJ 的、给内部训练平台加在线练习模块的、以及不想被 C 写死判题核心的 Java 工程师。先从最容易被低估的判题链路说起。2. 从提交到判题核心链路与 MySQL 表结构怎么设计2.1 判题链路编译、运行、比对三件事必须分开用户点击提交后Web 后端要干的活大致是把源码落盘根据语言调用编译器拿到字节码或可执行文件然后逐个测试用例执行捕获运行时间和内存最后把程序输出和标准输出做比对。这个流程里有三个必须拆开的环节。第一是编译。最容易翻车的地方是编译器版本不一致判题节点上的 JDK 版本和你本机不一样用户本地能编译的代码上去就 CE。第二是运行。用户代码可能死循环、占满内存、读写文件甚至调用系统命令这决定了判题环境必须和 Web 应用隔离。第三是比对。输出答案往往能容忍末尾换行差异但不能容忍多了一个空格所以 AC 判定绝不能简单地做字符串相等。常见做法是判题节点不跟 Web 主应用混在一起。即使一开始是单体部署也要把“判题”拆成独立 Service通过数据库队列表或消息队列传递任务。这样后续换语言版本、加判题机器都不用动 Web 主流程。我一般会坚持一条原则编译超时和运行超时分开设置。编译一个大型 Java 工程可能要 10 秒但单个测试用例的运行时限往往只有 1 到 2 秒混在一起设超时要么保守导致误判 TLE要么激进导致编译被砍掉。2.2 表结构设计用户、题目、提交记录与测试用例的最小建模既然是基于 JavaMySQL就用最熟悉的 Spring Boot MyBatis 作为 Web 层但表要先设计对。下面这套 MySQL 8 建表语句能支撑起课程设计到几千注册用户的量级生产环境再根据实际加权限和外键约束。CREATE DATABASE IF NOT EXISTS oj_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1普通用户 2管理员, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE problem ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(128) NOT NULL, description TEXT NOT NULL, time_limit INT NOT NULL DEFAULT 1000 COMMENT 运行时限单位毫秒, memory_limit INT NOT NULL DEFAULT 128 COMMENT 内存上限单位MB, difficulty TINYINT NOT NULL DEFAULT 1 COMMENT 1简单 2中等 3困难, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE submission ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, problem_id BIGINT UNSIGNED NOT NULL, lang VARCHAR(16) NOT NULL COMMENT java/cpp/python, source_code MEDIUMTEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT QUEUED COMMENT QUEUED/JUDGING/AC/WA/TLE/MLE/RE/CE, time_used INT NULL COMMENT 全部用例的运行时间取最大值单位毫秒, memory_used INT NULL COMMENT 全部用例的内存峰值取最大值单位KB, judge_detail JSON NULL COMMENT 每个用例的判题明细, judge_node VARCHAR(64) NULL COMMENT 处理该提交的判题节点标识, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_problem (problem_id), KEY idx_status (status), KEY idx_judge_node (judge_node), CONSTRAINT fk_sub_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_sub_problem FOREIGN KEY (problem_id) REFERENCES problem(id) ) ENGINEInnoDB; CREATE TABLE test_case ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, problem_id BIGINT UNSIGNED NOT NULL, input_data TEXT NOT NULL, output_data TEXT NOT NULL, is_sample TINYINT NOT NULL DEFAULT 0 COMMENT 是否样例用于前端展示, score INT NOT NULL DEFAULT 0 COMMENT 该用例分值按比例给部分分, KEY idx_problem (problem_id) ) ENGINEInnoDB;给这些表做几个关键说明。字符集用 utf8mb4是考虑到题目描述里可能会贴 Emoji、数学符号或中文引号只靠 utf8 会出现插入异常。status字段用字符串而不是数字枚举是为了让日志和排查的人一眼看懂代价是每个提交多占几个字节这个规模完全无所谓。submission表最关键的索引是idx_status因为队列消费者要反复按status捞QUEUED记录没有这个索引业务量上来后一次简单的SELECT ... WHERE statusQUEUED就会拖垮 InnoDB。updated_at用ON UPDATE CURRENT_TIMESTAMP自动维护后面做“卡死任务重置”时就要靠它判断多久没心跳。judge_detail直接设计成 JSON 类型比单独建一张明细表省事也够后续前端展示“第几个用例 WA、耗时多少”这类信息。2.3 判题结果状态机别让状态回滚变成无限重判submission.status的状态流转要定死QUEUED - JUDGING - AC/WA/TLE/MLE/RE/CE。其中JUDGING只能由判题节点进入不能由 Web 请求直接改因为用户提交后应该立刻返回真正的状态推进发生在异步队列里。这里有一个新手常犯的错判题机崩溃后把JUDGING的任务重置回QUEUED时要区分“从没开始跑的”和“已经跑到一半的”。如果任务已经进入JUDGING但用户代码在第一个用例就崩溃了再重置回去会造成无限重判。所以重置脚本必须带条件只允许把“长时间没有心跳更新”的JUDGING任务捞回来。对了time_limit和memory_limit是题目级参数判题节点执行时要把它作为每个用例的运行上限。内存上限建议以操作系统统计的进程峰值 RSS 为准而不是只看 Java 虚拟机的堆内存否则 GC 和 Metaspace 会漏算。这个坑到第 5 章再展开。3. 用 Spring Boot 分层把判题核心设计成可插拔模块3.1 为什么模块拆分比一个大类搞定更重要很多入门项目会把判题逻辑全写在一个OnlineJudgeService里从解析源码到执行命令再到比对输出全部用 if-else 堆在同一个方法里。前期能跑但每加一种语言就要改一次主流程测试用例也没办法单独验证。这就是典型的“可扩展性”失败的开始。我一般会把后端拆成四层Controller 只管收参数和返回结果Service 管事务、状态流转和队列落库判题层面向接口JudgeService编程内部再细分语言处理器基础设施层封装文件读写、命令执行和输出比对。这样做的直接好处是判题出错时能单独重跑判题逻辑而不影响 Web 主流程新语言进来只是加一个处理器不碰 Controller单测也能通过给判题层传一个假的命令执行器把“判题策略”和“真实执行”解耦。下面给出一个我在项目里常用的语言处理器设计。它不依赖具体框架Spring Boot 可以原样使用原生 Java 也能直接抄。3.2 语言处理器与工厂用接口挡住“又要加一种语言”的麻烦先定义一个LanguageHandler接口所有语言判题差异都收敛到编译命令和运行命令里。public interface LanguageHandler { boolean supports(String lang); String compileCommand(String sourcePath, String workDir); String runCommand(String sourcePath, String workDir); long defaultTimeLimit(); }Component public class JavaHandler implements LanguageHandler { Override public boolean supports(String lang) { return java.equals(lang); } Override public String compileCommand(String sourcePath, String workDir) { return javac -encoding UTF-8 -d workDir sourcePath; } Override public String runCommand(String sourcePath, String workDir) { return java -Xmx512m -Dfile.encodingUTF-8 -cp workDir Main; } Override public long defaultTimeLimit() { return 2000L; } }逻辑说明supports让工厂能根据提交语言找到对应处理器compileCommand和runCommand把命令差异封装起来defaultTimeLimit是语言级兜底题目没设置时限时用它。JavaHandler里把运行主类写死为Main这是 OJ 的常见约定前端提交模板要提示用户public class Main否则编译过了也找不到主类。参数说明workDir是判题节点上的临时工作目录源码和编译产物都放这里判完必须清空。sourcePath建议用 UUID 命名防止两个用户在同一个workDir下产生同名类文件冲突。-Xmx512m是 JVM 堆上限但实际进程内存可能更高真正判定 MLE 要以进程整体峰值内存为准第 5 章会细说。光有处理器还不够需要一个工厂把 Spring 扫描到的所有处理器收集起来。Component public class LanguageHandlerFactory { private final MapString, LanguageHandler handlerMap; public LanguageHandlerFactory(ListLanguageHandler handlers) { handlerMap handlers.stream() .collect(Collectors.toMap(this::extractLang, handler - handler)); } private String extractLang(LanguageHandler handler) { if (handler.supports(java)) return java; if (handler.supports(cpp)) return cpp; if (handler.supports(python)) return python; throw new IllegalStateException(unknown handler); } public LanguageHandler getHandler(String lang) { LanguageHandler handler handlerMap.get(lang); if (handler null) { throw new UnsupportedOperationException(unsupported lang: lang); } return handler; } }这段代码有一点取巧extractLang靠supports反推语言名实际工程里我更推荐直接给LanguageHandler增加一个String languageName()方法工厂按它做 Map key。这里的重点是 Spring 会把所有LanguageHandler实现类自动注入到ListLanguageHandler新语言只需要新增一个带Component的类不用改工厂不用改 Controller。这就是“可扩展”最朴素落地的样子扩展点靠接口注册靠容器发现而不是靠 if-else 排列组合。3.3 提交处理 Service别在 Controller 里跑 ProcessBuilder用户的提交动作应该是轻量的落库、返回提交 ID剩下的交给判题队列。下面这个SubmissionService只做事务插入。Service public class SubmissionService { private final SubmissionMapper submissionMapper; public SubmissionService(SubmissionMapper submissionMapper) { this.submissionMapper submissionMapper; } Transactional public Long createSubmission(Long userId, Long problemId, String lang, String sourceCode) { Submission submission new Submission(); submission.setUserId(userId); submission.setProblemId(problemId); submission.setLang(lang); submission.setSourceCode(sourceCode); submission.setStatus(QUEUED); submissionMapper.insert(submission); return submission.getId(); } }逻辑说明事务只覆盖插入不碰编译。如果在这里直接调用ProcessBuilderWeb 请求线程会被卡住好几秒Tomcat 线程被占满后其他接口全部超时。而且判题逻辑一旦抛异常整个事务回滚用户的提交记录就丢了。参数说明status默认QUEUED表示任务已经进入待判队列。这里不需要设置judge_node等判题节点真正取走任务时才写入。Controller 层拿到返回的submissionId后前端就靠轮询这个 ID 的status字段刷新结果页面。4. 并发判题先用 MySQL 当队列别急着上消息中间件4.1 同步判题为什么扛不住在线评测系统最容易被忽略的是判题是 CPU 密集且耗时不可控的。一个 Java 提交可能 0.1 秒也可能死循环跑满 2 秒。如果在 HTTP 请求里同步等结果Tomcat 默认 200 个线程10 个并发提交就能把站点拖到不可用。所以必须把“接收提交”和“执行判题”解耦。常见的解耦方式有两种引入 RabbitMQ/Kafka或者直接用 MySQL 表当队列。我的建议是中小系统先用数据库队列理由有三条。第一不引入额外中间件运维成本低特别适合 JavaMySQL 技术栈的团队。第二事务一致性最好提交记录和任务状态在同一库里更新原子性容易保证。第三数据可追溯出问题时直接SELECT看卡在哪个状态即可。数据库队列的缺点是吞吐上限取决于 MySQL 的并发能力但对课程设计、内部练习平台、几千注册用户的场景完全够用。真到需要横向大规模扩展时再迁移到消息队列或者独立的判题中间件也不迟核心的“任务状态机”设计可以原样保留。4.2 用 submission 表当任务队列乐观锁与 SKIP LOCKED不需要单独建一张 task 表直接用submission表当队列靠status字段驱动。取任务的核心是解决“多个判题节点并发抢单”。MySQL 8.0 上干净的写法是FOR UPDATE SKIP LOCKED示例 SQL 如下。BEGIN; SELECT id FROM submission WHERE status QUEUED ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;拿到id之后再执行状态更新。UPDATE submission SET status JUDGING, judge_node node-01 WHERE id ?; COMMIT;逻辑说明FOR UPDATE SKIP LOCKED会跳过已经被其他事务锁住的行多个判题节点同时跑这段 SQL 时不会取到同一个id。两步操作必须放在同一个事务里事务提交前如果节点崩溃行锁会自动释放任务仍然停留在QUEUED可以由其他节点继续领取。参数说明ORDER BY id是让任务按提交先后顺序处理避免后提交的插队。judge_node写入具体节点名排查问题时能看出是哪台机器处理过这条提交。如果你的 MySQL 版本不支持SKIP LOCKED退而求其次可以用UPDATE ... WHERE statusQUEUED原子更新的写法靠“更新影响行数为 1”来判断抢单成功。用 MyBatis 做取任务的 Java 代码如下。Transactional public Submission pollSubmission(String nodeId) { Long id submissionMapper.selectQueuedIdWithSkipLocked(); if (id null) { return null; } submissionMapper.markJudging(id, nodeId); return submissionMapper.selectById(id); }逻辑说明selectQueuedIdWithSkipLocked映射到上面的SELECT ... FOR UPDATE SKIP LOCKEDmarkJudging映射到UPDATE submission SET statusJUDGING, judge_node? WHERE id?。整个方法在 Spring 事务注解下执行事务提交时锁才释放保证同一时刻只有一台判题节点处理这条提交。参数说明nodeId可以从配置中心或环境变量注入每台判题节点部署时指定不同的值。pollSubmission返回值是完整的Submission里面带着sourceCode和problemId判题节点接下来就要根据这些信息去加载题目测试用例。4.3 死信任务判题节点崩溃后的兜底重置即使有了乐观锁判题节点仍然可能处理到一半宕机、被 OOM Killer 杀掉、或者断电。这时记录滞留在JUDGING前端一直显示“判题中”这是最让用户恼火的体验。解决办法是加一个定时清理任务把长时间没有心跳更新的JUDGING任务重置回队列。我常用的重置 SQL 是这样的。UPDATE submission SET status QUEUED, judge_node NULL WHERE status JUDGING AND updated_at NOW() - INTERVAL 5 MINUTE;逻辑说明updated_at在每次状态更新时会被自动刷新判题节点只要还在干活updated_at就会不断变化超过 5 分钟没有变化说明这个任务大概率已经变成死信。重置时把judge_node清空下一次轮询就能重新领走。参数说明5 分钟阈值要大于最慢用例的执行时间。如果你的题库里有需要跑 3 分钟的长时用例这个值就要调到 5 分钟以上否则会把正在正常判题的长任务误重置。更稳妥的做法是维护一张独立的心跳表判题节点每隔 30 秒上报一次心跳重置任务只针对“心跳过期且任务仍在JUDGING”的数据。这个思路比单纯靠updated_at更可靠代价是多一张表和一个心跳接口。5. 可扩展的边界在哪里五个常见的翻车现场与排查清单5.1 从单机到多判题机无状态设计是关键可扩展不是玄学核心是把判题节点做成无状态。判题节点除了连接数据库不持有任何应用级状态所有临时文件都在本地workDir结束后清空任务从submission表领取结果写回数据库。做到这三点加机器就是部署一个 Spring Boot 实例加上对应语言的编译环境。需要特别注意两件事。第一多节点同时拉任务时FOR UPDATE SKIP LOCKED或者乐观更新必须放在事务里否则必然出现重复判题。第二判题机要限制自身进程的资源占用Java 判题节点本身别跟着用户代码一起被 OOM 拖死。还有一个容易被忽略的点文件落在各节点本地所以跨节点看日志时找不到源码文件必须在日志里打印workDir和节点名方便定位是哪台机器处理的。5.2 我在实际项目里踩过的五个坑第一个坑提交量一大出现重复判题同一条submission被两个节点同时处理。现象是WA和AC在结果页随机跳动。原因是取任务时用了“先SELECT再UPDATE”两条 SQL中间没有事务或锁保护两个节点同时选中同一行。解决方法是改成FOR UPDATE SKIP LOCKED或者在UPDATE语句里带WHERE statusQUEUED用更新行数来判断是否抢单成功。第二个坑Java 程序在判题机上跑内存统计不准甚至触发整机 OOM。现象是-Xmx512m设置了但进程实际吃掉 1.5 GB。原因很简单JVM 的堆上限管不住 Metaspace、线程栈和 GC 开销而且 ProcessBuilder 启动的进程没有被操作系统的 cgroup 限制。解决方法是同时在容器层面设置内存上限再给 JVM 设置-Xmx。两层限制缺一不可只设一个都拦不住。第三个坑样例能过、提交全是 WA输出明明“看起来一样”。现象是编辑器比对时肉眼看不出差异但程序比对就是不等。原因是对输出比较太严格最后的换行符、行尾空格不一致都会被算成 WA。解决方法是先做规范化把\r\n统一成\n每行trim()去掉末尾空行再逐行比较。这个操作务必放在判题核心逻辑里不要只在前端展示时做。第四个坑用户代码死循环后判题节点 CPU 飙升Process.destroy()杀不掉进程。现象是超时后节点负载下不来。原因是ProcessBuilder通过sh -c启动命令时实际运行的是子进程destroy()只杀了 shell子进程变成孤儿继续占用 CPU。解决方法是先waitFor带超时时间超过后再调用destroyForcibly()。最稳妥的做法是把每个判题任务放进 Docker 容器或 cgroup 里面超时直接销毁整个容器进程树问题一并解决。第五个坑测试环境一切正常生产环境跑一会儿就报“Connection is not available, request timed out”。现象是判题节点频繁报数据库连接超时。原因是判题节点线程多、任务紧密每次判题都要写数据库连接池里的空闲连接被 MySQL 的wait_timeout断开后连接池没有及时剔除坏连接。解决方法是给判题节点单独配置数据源maximumPoolSize调大同时把连接池的validationTimeout调得比 MySQLwait_timeout小保证拿到的连接一定可用。Web 应用和判题节点用同一套连接池配置也是这个问题的常见根源。6. 上线前必做三项检查压测、超时控制与结果校验先做一次最粗糙的压测确认“接收提交”接口不会被判题拖死。写一个简单的循环脚本用 curl 造 50 个提交后台再开一个监控脚本数数据库里QUEUED的数量看队列堆积是否在预期时间内清空。for i in $(seq 1 50); do curl -X POST http://localhost:8080/api/submission \ -H Content-Type: application/json \ -d {problemId:1,lang:java,sourceCode:public class Main { public static void main(String[] args) { System.out.println(\ok\); } }} sleep 0.1 done这个脚本不需要看返回体只看接口是否快速响应。压测重点不是 CPU而是确认 Web 请求线程没有被同步判题占住。如果接口平均响应时间超过 1 秒先回第 4 章检查队列逻辑。然后是超时控制。编译超时和运行超时分开配编译一个大工程可以放宽到 15 秒运行单个用例按题目time_limit走通常 1 到 2 秒。注意Process.waitFor必须带超时时间Java 里用waitFor(long, TimeUnit)不要调无参版本否则死循环代码永远叫不醒判题节点。最后是结果校验。拿一组已知答案的提交做回归一道 AC 题、一道 WA 题、一道 TLE 题、一道 MLE 题、一道 CE 题确认状态判定都正确。这一步能筛掉绝大多数学步项目里的“假判题”尤其是输出比对不规范、内存统计只看堆这两个隐蔽问题。我早期的教训就是没做死信清理凌晨一台判题节点挂掉后所有提交卡在JUDGING用户以为系统死了。后来加了心跳表和定时重置任务才真正睡得着觉。希望帮到你。本文还有配套的精品资源点击获取
返回列表