
简介这是一套面向计算机专业学生与Java Web初学者、课程设计开发者的在线评测系统完整项目源码基于JSP与MySQL实现覆盖用户认证、题库管理、比赛组织、权限控制与实时评测反馈等核心模块适合作为课程设计、毕业设计或Java Web综合练习的参考方案。压缩包共171个文件约19.26MB其中40个java源文件与40个class编译文件构成业务逻辑主体62个jar提供依赖支持12个jsp页面负责前后端交互另含sql建库脚本、xml配置及少量图片与样式资源目录结构清晰便于按模块阅读与二次开发。目前已有112人学习下载。项目完整呈现了DAO层、Service层与Servlet层的分层设计并包含登录过滤器、成绩统计、题目增删改查等实现细节读者可据此理解JSPMySQL项目的整体架构、数据库表设计与评测流程快速搭建可运行的在线评测平台。1. 在线评测系统的骨架JSPMySQL 到底在评什么很多人第一次听到「在线评测系统」脑子里浮现的是 LeetCode 那种页面但真到自己动手用 JSPMySQL 去搭才发现核心根本不是前端长什么样而是「用户提交的代码怎么被安全地跑起来、跑完怎么判、判完怎么把结果写回数据库」。这套系统要解决的是学生或选手在浏览器里写一段 Java 代码点提交后台编译执行拿测试用例比对输出最后把 AC、WA、TLE 这些状态连同耗时内存一起落库展示。适合谁做课程设计、毕设、JavaWeb 实训里需要一套能跑通闭环的选题尤其是那些「基于 JSP 的毕设选题」被翻来覆去讲、但真正把判题逻辑讲清楚的不多。JSP 在这里的角色是视图层和控制器入口MySQL 负责题目、用户、提交记录、测试用例的持久化。别指望 JSP 扛高并发它的价值在于把 Servlet 容器里那套请求-响应-会话机制用最直白的方式串起来让你看清一个评测请求从submit.jsp到判题结果回显的完整链路。这一章先把边界划清楚我们做的是单机可复现的教学级 OJ不是分布式判题集群。2. 判题核心从提交到落库的完整链路怎么搭2.1 为什么判题不能直接在 JSP 页面里写 Java 代码新手最容易翻车的地方就是在submit.jsp里直接Runtime.exec(javac ...)。JSP 本质是 Servlet运行在 Tomcat 的线程池里你在这里起进程等于让 Web 容器的线程去等一个外部进程并发一上来线程池直接被打满整个应用假死。正确做法是把「接收提交」和「执行判题」拆开JSP/Servlet 只负责把代码文本、题目 ID、用户 ID 写进 MySQL 的submission表状态置为Pending另起一个后台判题线程或独立 Java 进程轮询这张表取到Pending记录后执行编译运行再把结果更新回去。这个「数据库当队列」的模式虽然土但在单机教学场景里足够稳也方便你观察每一步状态流转。-- 提交记录表判题状态机的载体 CREATE TABLE submission ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, problem_id INT NOT NULL, language VARCHAR(20) DEFAULT java, source_code TEXT NOT NULL, status VARCHAR(20) DEFAULT Pending, -- Pending/Judging/AC/WA/TLE/MLE/RE/CE time_used INT DEFAULT 0, -- 毫秒 memory_used INT DEFAULT 0, -- KB compile_info TEXT, -- 编译错误信息 submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_status (status), INDEX idx_user_problem (user_id, problem_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status字段是整个系统的状态机核心Pending是待判Judging是判题中防止重复取任务后面才是各种结果。idx_status索引很关键判题线程每秒轮询一次WHERE statusPending没索引的话记录一多就是全表扫描。source_code用TEXT而不是VARCHAR因为一段代码轻松超过 65535 字符的情况虽然少但TEXT更省心。compile_info单独存是为了 CE编译错误时能把 javac 的报错原样回显给用户这是体验分水岭。2.2 判题线程的取任务与状态抢占判题线程不能简单地SELECT完就执行因为如果你开了两个判题线程可能同时取到同一条Pending记录导致同一份代码被判两次。常见做法是用UPDATE ... WHERE statusPending的受影响行数来抢占// JudgeWorker.java 核心取任务逻辑 public Submission fetchTask() { String selectSql SELECT id FROM submission WHERE statusPending ORDER BY id LIMIT 1; String updateSql UPDATE submission SET statusJudging WHERE id? AND statusPending; try (Connection conn DriverManager.getConnection(URL, USER, PWD)) { conn.setAutoCommit(false); int id; try (PreparedStatement ps conn.prepareStatement(selectSql); ResultSet rs ps.executeQuery()) { if (!rs.next()) { conn.rollback(); return null; } id rs.getInt(id); } try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, id); int affected ps.executeUpdate(); // 只有仍为 Pending 才会返回 1 if (affected 0) { conn.rollback(); return null; } } conn.commit(); return loadSubmission(id); } catch (SQLException e) { e.printStackTrace(); return null; } }这里的事务不是为了保证原子性有多复杂而是让「查 ID」和「改状态」在一个连接里完成减少竞态窗口。affected 0说明这条记录已经被别的线程抢走了直接放弃本轮。ORDER BY id保证先提交先判符合直觉。注意setAutoCommit(false)之后任何异常路径都要rollback否则连接归还池子时事务还挂着这是 MySQL 连接池使用里很隐蔽的坑。2.3 编译与运行用独立进程隔离用户代码用户代码必须跑在独立 JVM 进程里不能反射调用否则用户一个System.exit(0)就把判题机干掉了。用ProcessBuilder起javac和java并设置工作目录到临时沙箱// 编译阶段 ProcessBuilder compilePb new ProcessBuilder(javac, Main.java); compilePb.directory(new File(sandboxDir)); compilePb.redirectErrorStream(true); Process compileProc compilePb.start(); String compileOutput readStream(compileProc.getInputStream()); boolean compileOk compileProc.waitFor(10, TimeUnit.SECONDS) compileProc.exitValue() 0; if (!compileOk) { updateStatus(subId, CE, compileOutput); // 编译错误直接落库 return; } // 运行阶段每个测试用例单独起进程带超时 ProcessBuilder runPb new ProcessBuilder(java, -Xmx256m, Main); runPb.directory(new File(sandboxDir)); runPb.redirectInput(new File(testCaseInputPath)); Process runProc runPb.start(); boolean finished runProc.waitFor(timeLimitMs, TimeUnit.MILLISECONDS); if (!finished) { runProc.destroyForcibly(); updateStatus(subId, TLE, null); return; }-Xmx256m限制堆内存配合waitFor的超时实现 TLE 判定。redirectInput把测试用例文件直接接到进程标准输入省去手动写流的麻烦。编译超时给 10 秒运行超时按题目配置通常 1000~2000ms。destroyForcibly()是必须的否则超时的进程会一直挂着吃 CPU。这里有个血泪经验javac编译大文件时可能超过 10 秒如果你的题目允许较长代码编译超时要单独放宽别和运行超时共用一个值。3. 数据库设计题目、用例、用户三张表怎么定3.1 题目表与测试用例表的拆分理由题目信息和测试用例必须分表。题目表存标题、描述、时间限制、内存限制用例表存输入输出对一个题目对应多条用例。合在一起的话加一个用例就要更新题目行锁竞争不说用例多了单行数据也臃肿。CREATE TABLE problem ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, time_limit INT DEFAULT 1000, -- 毫秒 memory_limit INT DEFAULT 256, -- MB difficulty VARCHAR(10) DEFAULT Easy, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE test_case ( id INT PRIMARY KEY AUTO_INCREMENT, problem_id INT NOT NULL, input_data TEXT, expected_output TEXT, is_sample TINYINT DEFAULT 0, -- 1 表示是展示给用户的样例 score INT DEFAULT 10, FOREIGN KEY (problem_id) REFERENCES problem(id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;is_sample区分样例和隐藏用例前端只展示is_sample1的。ON DELETE CASCADE让删题目时用例自动清理省得留孤儿数据。score字段为后面按用例给分留口子虽然教学系统常用全对才 AC但按点给分更接近真实 OJ。3.2 用户表与密码存储的底线用户表别存明文密码这是底线。用SHA-256加盐或者直接上BCrypt。JSP 里做注册登录很多人图省事MD5一把梭现在彩虹表随便查。CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(100) NOT NULL, salt VARCHAR(32) NOT NULL, email VARCHAR(100), role VARCHAR(10) DEFAULT user, -- user/admin register_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;username加唯一索引注册时靠数据库约束兜底别只靠 Java 层查重并发注册会漏。role字段控制谁能进后台加题目。salt每个用户独立生成password_hash SHA256(password salt)登录时用同样方式算一遍比对。3.3 判题结果回写与排名统计判题完成后回写submission表同时更新用户的通过数。排名统计可以实时算也可以定时刷。教学系统数据量小直接GROUP BY实时查就行-- 用户通过题目数排名 SELECT u.username, COUNT(DISTINCT s.problem_id) AS solved FROM user u LEFT JOIN submission s ON u.id s.user_id AND s.status AC GROUP BY u.id, u.username ORDER BY solved DESC, u.id ASC LIMIT 50;COUNT(DISTINCT s.problem_id)保证同一题多次 AC 只算一次。LEFT JOIN让没通过任何题的用户也出现在列表里solved0。ORDER BY solved DESC, u.id ASC里第二排序键是为了同分时按注册顺序稳定排列不然每次刷新排名都在跳用户会以为系统有 bug。4. 避坑与排查JSPMySQL 判题系统最容易翻车的五个点4.1 现象提交后一直显示 Pending永远不出结果原因通常是判题线程根本没启动或者启动后因为数据库连接失败静默退出了。JSP 应用里很多人把判题线程写在ServletContextListener的contextInitialized里但异常被吞掉Tomcat 日志里只有一行NullPointerException。解决在contextInitialized里显式try-catch并打印完整堆栈同时给判题线程加一个心跳日志每轮轮询输出一次「本轮无任务」或「取到任务 idX」。另外检查 MySQL 连接 URL 是否带了useSSLfalseserverTimezoneAsia/Shanghai缺serverTimezone在 MySQL 8.0 上会直接抛时区异常导致连接失败。4.2 现象编译明明能过判题却报 CE错误信息是乱码这是javac输出流的编码问题。Windows 上javac默认用 GBK 输出错误信息而你的 JSP 页面是 UTF-8读出来就是乱码。解决在ProcessBuilder里设置环境变量JAVA_TOOL_OPTIONS-Dfile.encodingUTF-8或者读流时用new InputStreamReader(proc.getInputStream(), GBK)按平台编码读。更稳的做法是判题机统一在 Linux 上跑默认 UTF-8省掉这类平台差异。如果非要在 Windows 开发记得把compile_info落库前做一次编码转换。4.3 现象用户提交死循环代码判题机 CPU 跑满其他提交全部卡住waitFor超时后你调了destroyForcibly()但用户代码可能起了子线程主进程杀了子线程还在。解决运行用户代码时用-Xss限制栈、用-XX:ActiveProcessorCount1限制可用核数并且在 Linux 下用ulimit -u限制进程数。更彻底的是把判题进程放进独立的cgroup或容器里但教学系统里至少要做到超时后destroyForcibly()再waitFor(2, SECONDS)确认进程真的死了没死就记录一条告警日志人工介入。别小看这个一个死循环提交能让整个判题队列堵十分钟。4.4 现象测试用例输出比对总是 WA但本地跑结果一样八成是行尾符和末尾空白的问题。Windows 下测试用例文件是\r\n用户程序输出是\n直接equals就挂了。解决比对前对两边都做归一化——去掉每行末尾空白、统一行尾为\n、去掉整个字符串末尾的换行。但注意别过度归一化比如把中间的空格也去掉那1 2和12就分不清了。常见做法是逐行trim后拼接再比或者用split(\\s)按 token 比。具体用哪种取决于题目是否对格式敏感建议在题目描述里写清楚「忽略行末空格」。4.5 现象MySQL 连接池耗尽页面报Too many connectionsJSP 里每个页面都DriverManager.getConnection是最常见的翻车姿势。解决用连接池Tomcat 自带DBCP或者上HikariCP在context.xml里配maxActive20、maxIdle10、maxWait3000。判题线程和 Web 请求共用池子时给判题线程单独配一个小池子比如 3 个连接避免判题把 Web 的连接抢光。另外记得在finally里关Connection、Statement、ResultSet用 try-with-resources 最省心。MySQL 的max_connections默认 151教学系统够用但连接泄漏的话 151 也撑不过一上午。5. 进阶技巧把判题结果做成可回放的调试面板5.1 用「逐用例结果」替代单一状态基础版只存一个最终状态用户看到 WA 不知道错在哪。进阶做法是加一张submission_case_result表每个测试用例一条记录存用例 ID、实际输出、状态、耗时。这样用户点开提交详情能看到「第 3 个用例 WA你的输出是 X期望是 Y」。这对教学场景价值极大学生能自己定位问题。CREATE TABLE submission_case_result ( id INT PRIMARY KEY AUTO_INCREMENT, submission_id INT NOT NULL, case_id INT NOT NULL, status VARCHAR(20), actual_output TEXT, time_used INT, FOREIGN KEY (submission_id) REFERENCES submission(id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;判题时每跑完一个用例就插一条全部跑完再汇总更新submission.status。注意actual_output要截断用户输出可能巨大比如死循环打印存之前substring(0, 2000)不然数据库分分钟被撑爆。5.2 判题沙箱的目录隔离与清理每个提交用一个独立临时目录命名用submission_id跑完就删。别用固定目录否则两个判题线程会互相覆盖Main.java。File sandbox new File(System.getProperty(java.io.tmpdir), oj_ submissionId); sandbox.mkdirs(); try { // 写 Main.java、编译、运行... } finally { deleteRecursively(sandbox); // 无论成功失败都清理 }deleteRecursively要自己写File.delete()只能删空目录。清理放在finally里判题异常也不能留垃圾文件。如果判题机磁盘小临时目录建议挂到/tmp并配tmpwatch定时清理超过 1 小时的残留目录防止某次判题进程崩溃导致目录没删掉。5.3 一个我常用的验证习惯每次改完判题逻辑我不会直接开页面点提交而是先写一个JudgeTest.java的 main 方法手动构造一条submission记录插库然后单独调JudgeWorker.fetchTask()和判题方法看状态流转对不对。这样能把「Web 层」和「判题层」的问题分开不然页面一报错你根本不知道是 JSP 传参错了还是判题逻辑错了。这个习惯帮我省了无数次在 Tomcat 日志里大海捞针的时间。另外测试用例至少准备三组一组正常 AC、一组故意 WA、一组死循环测 TLE每次改完都跑一遍确认三种状态都能正确落库。判题系统这东西玄学问题多但只要状态机是清晰的排查就有抓手。希望帮到你。本文还有配套的精品资源点击获取