
简介基于JavaMySQL实现的Web在线评测系统课程设计资源包适合Java Web学习者、课程设计或毕业设计学生参考。系统采用Spring MVC、Hibernate、MySQL、Lucene等主流技术Spring MVC掌管业务逻辑Hibernate完成ORM映射MySQL负责数据存储前端用JSP配合jQuery通过Ajax以JSON格式与后端交互功能覆盖用户自由选题、在线提交评测、结果与统计查询也包含学生加入课程、完成作业教师创建课程与题目、布置和批改作业、查看统计以及内部论坛等模块整体按角色与业务拆分扩展性较好。压缩包为zip格式共872个文件大小21.11MB其中191个JavaScript脚本用于前端交互161个Java源码实现后端业务59个XML配置负责映射与系统配置44个JSP页面展示视图另有class编译文件、图片资源以及文档、PDF和Astah模型文件等覆盖项目从建模到部署的主要层次。目前已有203人学习解压后可对照Astah模型梳理设计思路按目录顺序阅读源码和配置结合文档完善课程设计报告也可在现有功能基础上做二次开发提升对Java Web项目的综合把控能力。1. 为什么自己动手写“可扩展的程序在线评测系统”而不是直接套开源OJ设想一个很常见的场景Java课程选了120人期末上机题要求两小时内提交代码系统自动判题并返回每个用例的通过情况。人工挨个编译运行显然不现实直接拿开源OJ又常遇到“想加一种新题型得改源码”“判题只能单机跑”“想对接自己的账号体系非常痛苦”的连环问题。这也是最近java面试题和javaweb项目完整案例里反复出现“基于JavaMySQL实现Web可扩展的程序在线评测系统”的原因——用Java写Web管理端用MySQL存题目和提交记录把“判题”拆成独立进程再通过消息队列解耦。本文按“是什么→怎么做→坑在哪”的顺序把这条路径完整拆解适合做课程设计、毕业设计也适合想给团队搭内部训练平台的人。标题里真正值钱的不是“评测系统”四个字而是“可扩展”三个字怎么做实。2. 拆解系统边界与数据模型三张表和一个消息队列撑起整个评测系统2.1 系统模块怎么切把判题进程从Web服务里拆出来很多二手项目把提交和判题写在同一个Controller里用户一提交代码Web进程现场编译、现场运行。这种写法在10个人以内确实能跑但有两个硬伤一是判题是CPU密集操作用户提交一个死循环整个Web服务就卡死其他用户连登录都受影响二是单进程无法横向扩展加机器也没用因为判题逻辑和Web逻辑耦合在一起。常见做法是把系统拆成四个部分。Web端负责用户管理、题目展示、提交接收和结果查询判题Worker是独立进程专门拉取待判任务并执行编译运行消息队列用于在Web端和Worker之间传递提交IDMySQL加上文件存储负责持久化。Web端接收提交后只做一件事——把提交记录写进数据库并把提交ID丢进队列然后立刻返回“判题中”剩下的事情全部由Worker异步处理。这样Web端永远轻载判题压力再大也不会拖垮页面服务。如果不引入Redis或RabbitMQ用MySQL表模拟队列也能先把系统跑通提交记录表和队列表放在同一个事务里写入Worker定时轮询队列表。这个方案实现成本最低适合课程设计和单机部署但是水平扩展时会遇到队列表行锁竞争。第一章先讲清楚边界Web服务、判题进程、队列、存储四者各司其职这是后面所有扩展动作的前提。2.2 MySQL表结构题目、测试用例、提交记录三张核心表无论功能多花哨核心表就是三张题目表、测试用例表、提交记录表。用户表可以并入Web端自己的账号体系这里不展开。题目表负责描述一道题长什么样考什么时间和内存限制是多少测试用例表负责存放这道题的所有输入输出对提交记录表负责记录谁在什么时间交了哪段代码判题结果是什么。以Java技术栈惯用的数据库设计为例建表SQL如下-- 题目表 CREATE TABLE problem ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(128) NOT NULL, description TEXT NOT NULL, input_desc VARCHAR(255) NULL, output_desc VARCHAR(255) NULL, time_limit_ms INT NOT NULL DEFAULT 1000, memory_limit_mb INT NOT NULL DEFAULT 256, judge_type TINYINT NOT NULL DEFAULT 0 COMMENT 0IO题, 1Special Judge, spj_source TEXT NULL COMMENT SPJ程序的源码或路径, is_public TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_judge_type (judge_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 测试用例表 CREATE TABLE test_case ( id BIGINT NOT NULL AUTO_INCREMENT, problem_id BIGINT NOT NULL, case_index INT NOT NULL COMMENT 用例序号从1开始, input_path VARCHAR(255) NOT NULL COMMENT 输入文件路径, output_path VARCHAR(255) NOT NULL COMMENT 标准输出文件路径, PRIMARY KEY (id), UNIQUE KEY uk_problem_case (problem_id, case_index) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 提交记录表 CREATE TABLE submission ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, problem_id BIGINT NOT NULL, language VARCHAR(16) NOT NULL DEFAULT java, code_path VARCHAR(255) NOT NULL COMMENT 源码文件路径, status TINYINT NOT NULL DEFAULT 0 COMMENT 0排队, 1判题中, 2AC, 3WA, 4TLE, 5MLE, 6RE, 7CE, 8SE, judge_detail TEXT NULL COMMENT 错误详情或命中的用例序号, time_used_ms INT NULL, memory_used_kb INT NULL, submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_problem (user_id, problem_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这三张表的设计有几个关键点。第一测试用例不直接存大文本只存文件路径输入输出数据放到服务器磁盘或对象存储上这样用例文件再大也不会把MySQL拖垮第二submission表在“判题中”状态期间不允许出现中间态乱写status字段就是状态机的事实来源第三提交记录表一定要加idx_status索引因为Worker轮询时最频繁的SQL就是“查status0的记录”没有这个索引表数据过万后轮询会越来越慢。2.3 连接池与事务提交记录不许丢判题状态不许并发写乱Java Web项目连接MySQL几乎都会用连接池这是mysql数据库连接池被反复讨论的原因。常见选择是HikariCPSpring Boot默认集成。参数按单机小规模场景设置即可initialSize给5minIdle给5maxActive给50maxWait给1000毫秒连接空闲超时给30秒后回收再开启testWhileIdle保证空闲连接可用。maxActive不是越大越好MySQL默认max_connections通常是151连接池设100意味着一个Web服务就可能占掉三分之二的连接配额换来的只是并发线程争抢连接而不是更快。事务边界是这套系统最容易做错的地方。用户点提交后端要做两件事把完整代码写入存储把submission记录插入MySQL然后向队列投递一个“提交ID”。这两步必须在一个事务里完成或者通过“先写库、后发消息发失败则扫表补偿”的方式保证不丢。更稳妥的做法是用本地消息表插入submission后在同一事务里插入一条queue记录Worker扫描queue表并更新状态为处理中处理完再删除。这样即使队列中间件崩溃扫描逻辑也能把积压任务捞回来。注意submission的status从PENDING到JUDGING再到最终结果每一步都用UPDATE语句带上“当前状态”条件例如UPDATE submission SET status1 WHERE id? AND status0。这么做是为了利用InnoDB的行锁天然防并发重复判题后面第五章还会专门讲这个坑。3. 判题引擎设计从Pending到AC/WA/TLE的完整链路3.1 提交状态机一次提交的一生判题引擎的语义全部浓缩在状态机里。用户提交代码后记录初始状态是PENDINGWorker领取任务后置为JUDGING随后根据实际判题结果落到终端状态。终端状态有7种必须把每种的含义在系统里写清楚否则后期做统计报表时会把账算错。状态可以用下表定义状态值名称含义0PENDING已提交等待Worker领取1JUDGINGWorker正在编译或运行2AC所有用例通过3WA输出与标准答案不一致4TLE超过题目时限直接终止5MLE内存超限6RE程序运行时崩溃或非零退出7CE编译失败8SE系统错误通常是判题环境异常状态机只允许从0走到1从1走到2至8不允许从终端状态跳回去。如果出现“用户刷新看到AC再刷新变成WA”这种翻车现象说明代码里有两条路径在并发写status大概率是重复消费或消息重试导致。状态迁移要收敛到Worker的判题主流程里统一处理Web端只能读状态不能写状态。3.2 编译与执行沙箱隔离是底线判题引擎最核心的代码就是编译和运行用户程序。先看编译环节Java提交的源码通常命名为Main.javaWorker用ProcessBuilder调用javac。编译必须指定工作目录和编码否则换一台服务器就可能出现中文乱码或类文件找不到// 判题Worker编译用户提交的Java源码 ProcessBuilder pb new ProcessBuilder( javac, -encoding, UTF-8, Main.java); pb.directory(workDir); // 强制设置工作目录源码和编译产物都在这里 pb.redirectErrorStream(true); // 编译错误信息合并到stdout统一抓取 Process p pb.start(); // waitFor带超时防止javac本身卡死 boolean finished p.waitFor(15, TimeUnit.SECONDS); if (!finished) { p.destroyForcibly(); submission.setStatus(7); // CE编译超时按编译失败处理 submission.setJudgeDetail(Compile timeout.); return; } int exitCode p.exitValue(); if (exitCode ! 0) { String compilerOutput readStream(p.getInputStream()); submission.setStatus(7); // CE编译错误输出具体报错 submission.setJudgeDetail(compilerOutput); return; }编译这段有几个参数必须盯紧。-encoding UTF-8是硬要求Windows开发机上默认编码是GBK代码里写了中文注释不带这个参数在Linux判题机上直接编译失败或乱码waitFor(15, TimeUnit.SECONDS)是给javac一个上限编译一个简单程序正常不到1秒15秒足够宽松destroyForcibly()处理超时但要注意它只杀直接子进程如果编译命令是通过shell -c启动的孙进程可能残留后面第五章会详细说这个坑。运行用户程序的代码更关键因为这里涉及时间控制、内存控制和输出控制// 判题Worker运行用户程序并监控资源 ProcessBuilder pb new ProcessBuilder( java, -cp, workDir.getAbsolutePath(), Main); pb.directory(workDir); pb.redirectInput(inputFile); // 当前用例的输入文件作为stdin pb.redirectOutput(outputFile); // 用户程序原样输出到临时文件 pb.redirectError(errorFile); // stderr单独收集RE时排查看 long start System.nanoTime(); Process p pb.start(); // 等待进程结束超过timeLimitMs直接判TLE boolean finished p.waitFor(timeLimitMs, TimeUnit.MILLISECONDS); long elapsedMs (System.nanoTime() - start) / 1_000_000; if (!finished) { p.destroyForcibly(); submission.setStatus(4); // TLE submission.setJudgeDetail(Time limit exceeded at case caseIndex); return; } int exitCode p.exitValue(); if (exitCode ! 0) { submission.setStatus(6); // RE submission.setJudgeDetail(Runtime error, exit code exitCode); return; }运行环节有三个容易忽视的参数。第一个是输出文件大小用户程序可能打印几百MB垃圾数据Worker必须在写输出文件时就做限制常见做法是启动一个异步线程监控文件大小超过200MB直接kill进程第二个是内存限制单靠JVM参数不可靠因为用户提交的不一定是Java程序Linux下正规方案是用cgroup或容器限制次一档是ulimit -v但ulimit对JVM这类大内存进程效果不稳定第三个是waitFor超时后要立刻kill并且要等进程彻底退出再回收临时目录否则临时文件被占用Windows上还会报“文件被占用无法删除”的错。3.3 结果比对逐行比较与Special Judge的差异用户程序的输出写入临时文件后判题引擎要把这个文件和标准答案文件做比对。普通IO题采用逐行比较两个文件逐行读取每行做trim()去掉首尾空白然后比较字符串是否相等。注意不能直接FileUtils.contentEquals两个文件可能末尾多一个换行contentEquals会误判WA提前把答案判错。比对逻辑可以直接复用现成模板// 普通IO题逐行trim后比较 try (BufferedReader userReader new BufferedReader( new InputStreamReader(new FileInputStream(userOutFile), StandardCharsets.UTF_8)); BufferedReader answerReader new BufferedReader( new InputStreamReader(new FileInputStream(answerFile), StandardCharsets.UTF_8))) { String line1; String line2; while ((line1 userReader.readLine()) ! null (line2 answerReader.readLine()) ! null) { if (!line1.trim().equals(line2.trim())) { submission.setStatus(3); // WA return; } } // 一个文件读完另一个没读完也算WA if (line1 ! null || line2 ! null) { submission.setStatus(3); // WA return; } }Special Judge是IO题的扩展。比如输出要求“误差不超过1e-6”字符串比对永远判不对这时候要调用一个独立的SPJ程序来校验输出。常见做法是SPJ作为可执行文件放在题目目录里Worker把用户输出文件和标准答案文件的路径作为参数传给它SPJ返回0表示通过非0表示不通过。这个逻辑在第四章会作为扩展点详细展开。3.4 并发消费与防重判当判题Worker数量超过1个防重判就成了头号问题。两个Worker同时扫描queue表可能把同一条submission取走。解决办法不是用分布式锁而是利用MySQL行锁做状态抢占// 消费队列任务前先做状态抢占 String sql UPDATE submission SET status1 WHERE id? AND status0; int rows jdbcTemplate.update(sql, submissionId); if (rows 0) { // 说明这条提交已经被其他Worker领走直接跳过 return; }这段UPDATE利用了WHERE status0这个条件多个Worker同时执行时InnoDB的行锁保证只有一个UPDATE成功返回1另一个返回0。返回0的Worker什么都不做继续拉下一条。这是整个判题系统并发安全的地基比任何“队列消费确认机制”都可靠因为状态的事实来源始终在MySQL里。4. 可扩展性落地判题节点水平伸缩与题目类型插件化4.1 判题节点水平扩展无状态化设计可扩展的第一个含义是判题能力能水平扩容。判题Worker必须设计成无状态它不知道用户是谁不持有任何会话所有输入都来自队列和文件存储所有输出都写回MySQL和文件系统。满足这个前提后扩展判题节点就是部署一份相同的Worker代码然后让它启动消费队列。不需要改任何既有代码不需要重启Web服务。常见的做法是给每个Worker加一个唯一编号启动时在配置表里插入一条在线记录心跳续期下线时删除。Web端监控页面通过这张表展示当前在线Worker数量和判题吞吐。这里有个隐藏依赖多台Worker机器要能读到同一题目下的测试用例文件。如果所有Worker在同一台机器上直接用本地目录就行如果分布在多台机器就需要对象存储或共享文件系统把测试用例的分发做在“题目创建”时而不是“判题运行时”。4.2 题目类型扩展从IO题到SPJ的接口抽象可扩展的第二个含义是新增题目类型不需要重写判题主流程。判题主流程只关心“编译代码、运行代码、获取结果”至于结果怎么验证应该是一个可替换的策略。用Java写这个扩展点最自然是定义一个接口// 判题策略接口所有判题器都要实现这个接口 public interface JudgeStrategy { /** * 执行一次判题 * param context 包含提交记录、测试用例路径、程序运行结果 * return 判题结果 */ JudgeResult judge(JudgeContext context) throws Exception; }普通IO题和Special Judge分别实现该接口。主流程先编译、运行、采集资源数据然后根据题目的judge_type从工厂里取对应的JudgeStrategy调用judge方法得到最终结果。接题目的人不需要理解判题流程只需要写一个JudgeStrategy实现类然后工厂里注册一行映射。// 判题策略工厂按题型返回对应判题器 public class JudgeStrategyFactory { private static final MapInteger, JudgeStrategy STRATEGIES new HashMap(); static { STRATEGIES.put(0, new StandardJudgeStrategy()); // IO题 STRATEGIES.put(1, new SpecialJudgeStrategy()); // Special Judge } public static JudgeStrategy get(Integer judgeType) { JudgeStrategy strategy STRATEGIES.get(judgeType); if (strategy null) { throw new UnsupportedOperationException(Unsupported judge type: judgeType); } return strategy; } }通过这个工厂新增一种题型所做的改动是写一个新的Strategy实现类工厂里加一行put。判题Worker主流程一行不动这就是“可扩展”最直观的证据。比在Worker里写一堆if (judgeType 1) ... else if (judgeType 2) ...要干净得多而且每个Strategy可以单独做单元测试。4.3 扩展点落地判题资源预算与伸缩约束扩展不是无限度的要提前知道系统的瓶颈在哪里。判题吞吐量的上限由三个因素共同决定Worker所在机器的CPU核数、MySQL处理状态更新的能力、测试用例文件的读取带宽。最常见的情况是CPU最先打满尤其是Java用户程序占满单核时。因此每个Worker内部通常用信号量控制并发判题数比如机器是4核并发判题数设3留一核给编译和系统开销。// Worker内部限制并发判题任务数 private final Semaphore semaphore new Semaphore(3); // 4核机器最多同时跑3个判题任务 public void consumeSubmission(Long submissionId) { try { semaphore.acquire(); doJudge(submissionId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }并发数不是越高越好。并发任务超过CPU核数后每个用户程序得到的CPU时间片变少本来1秒能算完的循环可能拖到超时导致TLE误判率上升。这个参数要针对每台Worker的硬件单独调千万不要所有机器套同一个值。判题机的资源预算本质上是在“吞吐量”和“判题准确性”之间找平衡。5. 上线前的排查清单连接池、编码与并发判题的5个翻车现场5.1 现象系统跑几个小时就报“Too many connections”原因连接池maxActive配置过大而MySQL默认max_connections只有151或者代码里获取连接后没有释放连接泄漏。曾经在一个课程设计项目里看到maxActive设成200业务代码里new一个JdbcTemplate就开一批连接页面多刷新几次数据库直接拒绝连接。解决先把连接池maxActive压到50以内确认MySQL的max_connections然后排查是否每次操作都关闭了连接。用Spring的JdbcTemplate通常会自动管理连接但要注意在循环里手动创建连接必须用try-with-resources。跑一个查询就能验证泄漏SELECT COUNT(*) FROM information_schema.processlist WHERE commandSleep这个数字持续增长基本就是连接没关。5.2 现象用户提交死循环Worker所在机器CPU被打满正常提交全部排队原因TLE超时后调用了destroyForcibly但Java的ProcessBuilder在Linux下destroyForcibly只杀直接子进程。如果用户程序里又fork了子进程或者启动命令用了bash -c子进程会变成孤儿进程继续占CPU。解决超时杀进程必须连进程组一起杀。ProcessBuilder启动前设置pb.redirectErrorStream(true)不影响进程组需要用UnixProcess的destroyByProcessGroup或通过setsid启动命令让进程成为独立会话超时后对这个会话发送SIGKILL。更省心的方案是直接把用户程序丢进Docker容器用docker stop -t 1控制超时容器一停所有子进程全部回收。这是血泪经验本地Windows环境跑得好好的部署到Linux上一压测才暴露。5.3 现象代码里写了中文本地运行正常线上判题全是乱码或编译错误原因javac默认编码跟随系统区域设置。Windows默认GBKLinux默认UTF-8同一段带中文注释的代码在两台机器上编译结果不一样。MySQL也同理连接串不带字符集参数存中文题目描述可能变成问号。解决编译命令固定加-encoding UTF-8MySQL连接串固定加characterEncodingutf8建库指定utf8mb4。判题Worker在读取用例文件和题目描述时统一用UTF-8读取不要用平台默认编码。这套规则要在项目文档里写死凡是新接手的成员都要遵守因为这类问题没有报错只会表现为“这台机器能过那台机器全WA”。5.4 现象并发提交后同一条提交记录出现了两次判题结果或者状态从AC变成WA原因两个Worker同时消费同一条队列消息。一个Worker把它从PENDING更新成JUDGING后开始判题另一个Worker也读到了这条消息也把它更新成JUDGING两个进程同时跑谁后写完谁覆盖结果。解决判题前必须用UPDATE submission SET status1 WHERE id? AND status0做状态抢占受影响行数为0说明已被其他Worker领走直接跳过。判题过程中不要再UPDATE status只在最终结果出来后一次性写入终态。这一条是做并发判题的底线规则任何绕过它的“优化”都会在压测时原形毕露。5.5 现象Special Judge判题结果不稳定同一份输出有时候AC有时候WA原因SPJ程序里如果直接比较浮点数字符串或者使用了固定精度就会因为输出格式细微差别产生误判。还有一种情况是SPJ程序本身有状态没有每次判题都重新初始化。解决SPJ浮点数比较统一使用相对误差或绝对误差常见阈值是abs(user - answer) 1e-6或者1e-9。注意SPJ程序每次判题都要用新的进程运行禁止复用常驻进程防止上次判题的临时状态影响下一次结果。加一个自检脚本把已知的AC输出和WA输出分别喂给SPJ确认它稳定输出正确结果后再上线。6. 压测验证与进阶优化把“可扩展”变成能给人看的实验数据6.1 压测要盯三个数接口QPS、判题吞吐、队列积压给这套系统做压测别只盯着“Web接口每秒能接多少提交”。提交接口一辈子都轻载真正的瓶颈是判题吞吐和队列积压。写一个脚本循环调用提交接口用不同线程数压观察三个指标Web端提交接口的QPS、每分钟完成的判题数量、队列中积压的待判任务数。判题吞吐小于提交速度时积压数会线性增长这时候加Worker数量如果吞吐没有接近线性上升说明瓶颈在MySQL状态更新或测试用例读取而不在CPU。6.2 两个玩具实验验证“可扩展”实验一单Worker跑100道题记耗时T1再启动第二个Worker跑同样的100道题记耗时T2。如果T2接近T1的一半说明判题节点水平扩展有效如果T2只比T1少一点点先看两台Worker是不是共用同一个临时目录再看MySQL的锁竞争。实验二新加一种题型的模拟题目只写一个新的JudgeStrategy实现并注册到工厂验证判题主流程代码不需要改动。这两个实验是“可扩展”最直接的验收方式。6.3 我的两个固定习惯第一个习惯是判题Worker只信队列不知道用户是谁任何需要用户信息的地方都通过submissionId回表查询。第二个习惯是每次部署新Worker前先在测试机上跑一遍全集用例不是跑冒烟而是把测试用例、SPJ、编译参数完整验证一遍再上线。后台判题系统最怕的不是代码难写而是改一个环境变量导致全部误判。这套系统做完后最大教训是能让MySQL做状态事实来源的就不要引入第二个分布式状态组件能通过接口抽象扩展的就不要在判题主流程里堆if。希望帮到你。本文还有配套的精品资源点击获取