JSP+MySQL在线评测系统:判题核心与数据库设计实战
2026/9/23 10:02:43 网站建设 项目流程

简介:这是一套面向计算机专业学生与Java Web初学者、课程设计开发者的在线评测系统完整项目源码,基于JSP与MySQL实现,覆盖用户认证、题库管理、比赛组织、权限控制与实时评测反馈等核心模块,适合作为课程设计、毕业设计或Java Web综合练习的参考方案。压缩包共171个文件,约19.26MB,其中40个java源文件与40个class编译文件构成业务逻辑主体,62个jar提供依赖支持,12个jsp页面负责前后端交互,另含sql建库脚本、xml配置及少量图片与样式资源,目录结构清晰,便于按模块阅读与二次开发。目前已有112人学习下载。项目完整呈现了DAO层、Service层与Servlet层的分层设计,并包含登录过滤器、成绩统计、题目增删改查等实现细节,读者可据此理解JSP+MySQL项目的整体架构、数据库表设计与评测流程,快速搭建可运行的在线评测平台。

1. 在线评测系统的骨架:JSP+MySQL 到底在评什么

很多人第一次听到「在线评测系统」,脑子里浮现的是 LeetCode 那种页面,但真到自己动手用 JSP+MySQL 去搭,才发现核心根本不是前端长什么样,而是「用户提交的代码怎么被安全地跑起来、跑完怎么判、判完怎么把结果写回数据库」。这套系统要解决的是:学生或选手在浏览器里写一段 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

status字段是整个系统的状态机核心,Pending是待判,Judging是判题中(防止重复取任务),后面才是各种结果。idx_status索引很关键,判题线程每秒轮询一次WHERE status='Pending',没索引的话记录一多就是全表扫描。source_codeTEXT而不是VARCHAR,因为一段代码轻松超过 65535 字符的情况虽然少,但TEXT更省心。compile_info单独存,是为了 CE(编译错误)时能把 javac 的报错原样回显给用户,这是体验分水岭。

2.2 判题线程的取任务与状态抢占

判题线程不能简单地SELECT完就执行,因为如果你开了两个判题线程,可能同时取到同一条Pending记录,导致同一份代码被判两次。常见做法是用UPDATE ... WHERE status='Pending'的受影响行数来抢占:

// JudgeWorker.java 核心取任务逻辑 public Submission fetchTask() { String selectSql = "SELECT id FROM submission WHERE status='Pending' ORDER BY id LIMIT 1"; String updateSql = "UPDATE submission SET status='Judging' WHERE id=? AND status='Pending'"; 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)就把判题机干掉了。用ProcessBuilderjavacjava,并设置工作目录到临时沙箱:

// 编译阶段 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

is_sample区分样例和隐藏用例,前端只展示is_sample=1的。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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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让没通过任何题的用户也出现在列表里(solved=0)。ORDER BY solved DESC, u.id ASC里第二排序键是为了同分时按注册顺序稳定排列,不然每次刷新排名都在跳,用户会以为系统有 bug。

4. 避坑与排查:JSP+MySQL 判题系统最容易翻车的五个点

4.1 现象:提交后一直显示 Pending,永远不出结果

原因通常是判题线程根本没启动,或者启动后因为数据库连接失败静默退出了。JSP 应用里很多人把判题线程写在ServletContextListenercontextInitialized里,但异常被吞掉,Tomcat 日志里只有一行NullPointerException。解决:在contextInitialized里显式try-catch并打印完整堆栈,同时给判题线程加一个心跳日志,每轮轮询输出一次「本轮无任务」或「取到任务 id=X」。另外检查 MySQL 连接 URL 是否带了useSSL=false&serverTimezone=Asia/Shanghai,缺serverTimezone在 MySQL 8.0 上会直接抛时区异常导致连接失败。

4.2 现象:编译明明能过,判题却报 CE,错误信息是乱码

这是javac输出流的编码问题。Windows 上javac默认用 GBK 输出错误信息,而你的 JSP 页面是 UTF-8,读出来就是乱码。解决:在ProcessBuilder里设置环境变量JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8,或者读流时用new InputStreamReader(proc.getInputStream(), "GBK")按平台编码读。更稳的做法是判题机统一在 Linux 上跑,默认 UTF-8,省掉这类平台差异。如果非要在 Windows 开发,记得把compile_info落库前做一次编码转换。

4.3 现象:用户提交死循环代码,判题机 CPU 跑满,其他提交全部卡住

waitFor超时后你调了destroyForcibly(),但用户代码可能起了子线程,主进程杀了子线程还在。解决:运行用户代码时用-Xss限制栈、用-XX:ActiveProcessorCount=1限制可用核数,并且在 Linux 下用ulimit -u限制进程数。更彻底的是把判题进程放进独立的cgroup或容器里,但教学系统里至少要做到:超时后destroyForcibly()waitFor(2, SECONDS)确认进程真的死了,没死就记录一条告警日志,人工介入。别小看这个,一个死循环提交能让整个判题队列堵十分钟。

4.4 现象:测试用例输出比对总是 WA,但本地跑结果一样

八成是行尾符和末尾空白的问题。Windows 下测试用例文件是\r\n,用户程序输出是\n,直接equals就挂了。解决:比对前对两边都做归一化——去掉每行末尾空白、统一行尾为\n、去掉整个字符串末尾的换行。但注意别过度归一化,比如把中间的空格也去掉,那1 212就分不清了。常见做法是逐行trim后拼接再比,或者用split("\\s+")按 token 比。具体用哪种取决于题目是否对格式敏感,建议在题目描述里写清楚「忽略行末空格」。

4.5 现象:MySQL 连接池耗尽,页面报Too many connections

JSP 里每个页面都DriverManager.getConnection是最常见的翻车姿势。解决:用连接池,Tomcat 自带DBCP或者上HikariCP,在context.xml里配maxActive=20maxIdle=10maxWait=3000。判题线程和 Web 请求共用池子时,给判题线程单独配一个小池子(比如 3 个连接),避免判题把 Web 的连接抢光。另外记得在finally里关ConnectionStatementResultSet,用 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

判题时每跑完一个用例就插一条,全部跑完再汇总更新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,每次改完都跑一遍,确认三种状态都能正确落库。判题系统这东西,玄学问题多,但只要状态机是清晰的,排查就有抓手。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询