简介:基于Java+MySQL实现的Web程序在线评测系统课程设计资源包,面向需要学习Spring、Hibernate、Lucene等主流框架整合开发的Java学习者,也可作为毕业设计或课设项目的参考蓝本。系统覆盖用户选题解答、在线评测、统计信息查看,并完整支持教师建课布题、布置作业,学生选课提交作业,以及内部论坛讨论等教学闭环,模块划分清晰,便于按需扩展与二次开发。压缩包共872个文件,大小约21.11MB,文件类型以js、java、class、xml、jsp、css为主,分别对应前端交互脚本、后端业务代码、编译产物、ORM映射与页面模板,另含PDF说明文档和设计模型文件,可帮助理解从架构设计到部署运行的全过程。已有204人学习下载,适合希望快速搭建可运行OJ系统并深入理解Java Web分层开发的读者。
1. 程序在线评测系统:为什么说“可扩展”比“能跑通”更重要
如果你要做一个基于Java+MySQL实现(Web)可扩展的程序在线评测系统,最容易被低估的其实是“判题”这两个字。你很快会发现,把题目和提交记录存进数据库很简单,真正让人睡不着的是用户代码怎么安全地编译运行、怎么限制它不把服务器拖垮、怎么给出不冤枉人的判定结果。这个标题里的“可扩展”也不是口号:题库会加题型,比赛会加语言,参赛规模会翻倍,表结构和判题流程一开始没留扩展点,后面每次加功能都要动老代码。这套系统能解决教学OJ、竞赛平台、企业内部coding测验的完整闭环,适合正在做毕业设计、想给社团搭比赛平台,或者要把在线编程考试落到内网环境的人。先把“判题”这条主链路想清楚,剩下的功能都是围绕它长出来的叶子。
2. 从提交到出分:可扩展 OJ 的整体架构与选型理由
2.1 一次提交的七个环节:从题目到出分的链路
抛开花哨的界面,OJ 系统的核心链路可以拆成七个环节:题目与测试用例管理、用户提交代码、编译(或解释执行)、沙箱运行、输出比对、判题结果回写、结果展示。这七个环节里,真正决定系统天花板的是第 3 到第 6 步。很多项目挂在“能提交、能出分”的假象上,用户代码一多或者测试数据一大,就开始超时、卡死、误判。
把每个环节单独建模,是我做这套系统时坚持的第一原则。题目管理负责把题目、测试用例、判定规则存进 MySQL;提交服务只做一件事——接收代码、生成 submission 记录、放进待判队列;编译阶段把用户代码变成可执行文件;沙箱阶段负责限制资源;输出比对根据题型决定是精确匹配还是走 Special Judge;回写阶段把结果集中更新回数据库;最后前端展示。每个环节都留接口,后面加题型、加语言、加比赛模式才不用推倒重来。
扩展点具体落在哪里,用下面这个表可以看得很清楚:
| 环节 | 技术承载 | 扩展方向 |
|---|---|---|
| 题目管理 | MySQL 表 + JSON 配置字段 | 新题型、Special Judge、数据分组 |
| 提交服务 | Java Web Controller + Service | 多语言、模板代码、代码查重 |
| 编译 | Java ProcessBuilder 调编译器 | 新增语言只需注册新编译命令 |
| 沙箱运行 | 独立进程 + 资源限制 | 更严隔离可替换为容器化执行 |
| 输出比对 | 文本规范化 + 可选 SPJ | 多解判定、浮点误差判定 |
| 结果回写 | 批量 SQL 更新 + 状态机 | 队列化、分布式判题扩展 |
| 结果展示 | Web 前端读数据库 | 排名、统计、图表扩展 |
这七个环节,缺任何一个都会在真实比赛里暴露问题。比如很多教学项目不区分“编译错误”和“运行错误”,用户代码一崩就统一显示“答案错误”,学生根本不知道是语法问题还是算法问题。所以环节拆分本质上是为状态机服务的,后面第 4 章会详细讲。
2.2 选型理由:Java 管调度和进程,MySQL 管状态和数据
为什么标题里是 Java + MySQL,而不是 Python + SQLite 或者其他组合?我的判断是:Java 在进程管理和工程化生态上有现成优势。判题必须把用户代码放到独立进程里跑,Java 的 ProcessBuilder、ProcessHandle、destroyForcibly 这套 API 能在一个进程内完成“启动子进程、限时等待、收集输出、强制清理”的全流程,不用引入额外的进程管理工具。至于并发,Java 的多线程模型配合线程池,处理几百个同时提交的压力是足够稳的,真正要担心的是数据库连接池而不是线程池。
MySQL 在这里承担的是“权威状态源”。提交记录、判题结果、题目的测试用例都要求强一致,MySQL 的事务和行锁比内存缓存更可靠。有人问为什么不直接上 Redis 做队列和存储,我一般会回答:单机教学环境里 MySQL 足够,等到需要集群判题时再加 Redis 或 Pika 做队列,把 MySQL 留作最终落库。这也是“可扩展”的体现——不是一开始就上分布式,而是留下替换边界。
工程化上,我建议用 Spring Boot 搭 Web 层、MyBatis 做持久层。这不是噱头,而是因为 MyBatis 可以把复杂的判题结果批量更新 SQL 写得清晰,写好的 XML 映射也是团队协作时最容易对齐的部分。JDBC 手写当然也行,但遇到批量 insert、批量 update、JSON 字段读写时,手写容易漏掉事务边界。这里有一个从标题里延伸出来的判断:可扩展的起点不在框架,在于每个环节是否独立、每个表是否留了扩展字段。
3. 用 MySQL 建出可扩展的评测数据模型:五张核心表与关键字段
3.1 用户、题目、测试用例三张基础表:字段设计与 JSON 扩展位
下表的 schema 我做过多轮调整,核心原则是:基础字段稳定,扩展字段用 JSON 留口子。先看用户表:
CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL COMMENT '登录名,唯一', `password_hash` VARCHAR(128) NOT NULL COMMENT 'BCrypt 哈希,不要存明文', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0-普通用户 1-管理员', `school_no` VARCHAR(32) DEFAULT '' COMMENT '学号/工号,可按场景扩展', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';password_hash 用 BCrypt,不要用 MD5,这个没有太多讨论余地。role 用 TINYINT 而不是字符串,是为了索引效率和后续加角色时不用改表结构,加权限系统时再拆一张角色表即可。
接下来是题目表。题目表是扩展性设计的关键,因为题型变化就发生在这里:
CREATE TABLE `problem` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `description` MEDIUMTEXT NOT NULL COMMENT '题目描述,按需允许 HTML', `input_spec` TEXT COMMENT '输入说明', `output_spec` TEXT COMMENT '输出说明', `difficulty` TINYINT NOT NULL DEFAULT 1 COMMENT '1-简单 2-中等 3-困难', `time_limit_ms` INT NOT NULL DEFAULT 1000 COMMENT '单测试用例时间限制,毫秒', `memory_limit_kb` INT NOT NULL DEFAULT 262144 COMMENT '内存限制,单位KB,默认256MB', `judge_type` VARCHAR(32) NOT NULL DEFAULT 'exact' COMMENT 'exact-精确比 ou-浮点 spj-特判', `extension_config` JSON DEFAULT NULL COMMENT '扩展配置:SPJ路径、测试点分数等', `is_visible` TINYINT NOT NULL DEFAULT 1, `created_by` BIGINT NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_visible_diff` (`is_visible`, `difficulty`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';参数说明:time_limit_ms 放在题目表是“默认值”,每个测试用例还可以覆盖它;memory_limit_kb 用 KB 而不是 MB,是为了兼容一些需要精细控制内存的题目,默认 262144 KB 恰好是 256 MB。judge_type 字段是“可扩展”的第一个开关——当题目需要多解判定时,改成spj并配置 extension_config 里的 spj 程序路径。逻辑说明:把判题类型放在题目层而不是测试用例层,是因为一个题目的所有测试用例共享一种判定策略,放在用例层会造成配置分散,后面做数据迁移时很难对齐。
测试用例表要特别注意:题目可以有很多组测试数据,而且测试数据一旦变大,就不适合全部塞进数据库:
CREATE TABLE `test_case` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `problem_id` BIGINT NOT NULL, `test_group` INT NOT NULL DEFAULT 1 COMMENT '分组,一个测试点一组,或按子任务分组', `input_file_path` VARCHAR(255) NOT NULL COMMENT '输入文件绝对路径或相对存储根路径', `output_file_path` VARCHAR(255) NOT NULL COMMENT '期望输出文件路径', `time_limit_ms` INT DEFAULT NULL COMMENT '为空时继承题目的 time_limit_ms', `memory_limit_kb` INT DEFAULT NULL, `score` INT NOT NULL DEFAULT 0 COMMENT '该测试点分值,用于部分得分', `is_sample` TINYINT NOT NULL DEFAULT 0 COMMENT '是否样例,用于前端展示', `sort_order` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_problem_group` (`problem_id`, `test_group`, `sort_order`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测试用例表';逻辑说明:input_file_path 和 output_file_path 存的是文件路径而非 TEXT 内容,这是很多从 Excel 式设计转过来的人最容易翻车的地方。判题时 IO 读写文件比读数据库字段快一个数量级,而且 MySQL 单包大小有限,往库里塞几 MB 的输入数据会影响整库性能。score 字段支持部分得分,比如 5 组测试点每组 20 分,学生只跑过 3 组就是 60 分,这对教学赛非常重要。
3.2 提交表和判题结果表:状态机与幂等设计
提交表是整个系统最热的一张表,设计时要同时照顾写入频率和查询场景:
CREATE TABLE `submission` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `submission_token` VARCHAR(64) NOT NULL COMMENT '幂等键,防止重复提交', `user_id` BIGINT NOT NULL, `problem_id` BIGINT NOT NULL, `language` VARCHAR(32) NOT NULL COMMENT 'java/cpp/python2/python3/go', `source_code` MEDIUMTEXT NOT NULL COMMENT '代码正文,或存文件路径', `code_file_path` VARCHAR(255) DEFAULT NULL COMMENT '代码落盘路径,供编译阶段读取', `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态机见 4.1', `score` INT NOT NULL DEFAULT 0, `used_time_ms` INT DEFAULT NULL COMMENT '所有测试点最大耗时', `used_memory_kb` INT DEFAULT NULL COMMENT '所有测试点最大内存', `error_message` TEXT COMMENT '编译错误或运行时错误摘要', `judge_type` VARCHAR(32) NOT NULL DEFAULT 'exact', `judged_at` DATETIME DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_token` (`submission_token`), KEY `idx_user_problem` (`user_id`, `problem_id`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='提交记录表';参数说明:submission_token 是我强烈建议加的字段。前端提交或者判题回写时,如果网络抖动导致客户端重试,没有幂等键就会出现同一份代码被判两次、成绩被覆盖的问题。idx_status_created索引用于判题 worker 扫描待判队列,这条查询非常高频,没有索引会造成全表扫描。status 用 VARCHAR 是为了在日志里直接可读,配合代码里的枚举映射,比数字更直观。
判题结果表存的是“一次提交对应每个测试用例的明细”,它让“AC/WA/TLE/MLE”这种判定变得可解释:
CREATE TABLE `judge_result_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `submission_id` BIGINT NOT NULL, `test_case_id` BIGINT NOT NULL, `test_group` INT NOT NULL, `status` VARCHAR(20) NOT NULL COMMENT '该测试点状态', `used_time_ms` INT DEFAULT NULL, `used_memory_kb` INT DEFAULT NULL, `output_tail` TEXT COMMENT '实际输出尾部,便于前端展示差异', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_submission` (`submission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='判题明细表';有这张表,前端展示“哪个测试点 WA、哪个测试点 TLE”就很容易。output_tail 只存尾部而不是全部输出,是因为完整输出可能几 MB,存数据库会撑爆表,只存最后 2KB 足够定位问题。这套五表模型(用户、题目、测试用例、提交、判题明细)基本覆盖了教学 OJ 的全部数据需求,也为后面接排行榜、统计报表留好了 join 的入口。
4. 用 Java 实现判题服务:编译、沙箱运行、状态机与结果回写
4.1 判题状态机:从 PENDING 到 ACCEPTED 的九个状态
判题服务是 OJ 的心脏,状态机则是心脏的节拍。我常用的状态集合如下:
中间态:PENDING(排队中)、JUDGING(编译/运行中)。终态:ACCEPTED(通过)、WRONG_ANSWER(答案错误)、COMPILE_ERROR(编译错误)、RUNTIME_ERROR(运行崩溃,含段错误、非零退出)、TIME_LIMIT_EXCEEDED(超时)、MEMORY_LIMIT_EXCEEDED(内存超限)、SYSTEM_ERROR(判题器自身出错)。
为什么单独留 SYSTEM_ERROR?因为判题服务自身也可能崩——比如编译进程被系统杀、临时目录满了,这时不能怪用户代码。状态机流转顺序是:PENDING -> JUDGING -> 每个测试点依次判定 -> 汇总终态。需要注意的是,多个测试点里只要有一个 TLE,这次提交的最终状态就是 TLE,不需要继续跑后面的测试点,能省大量 CPU。但要注意部分得分场景,如果题目配了 groups 按子任务计分,则需要全部跑完并按分组统计。这个差异我在 5.2 节展开讲。
4.2 用 ProcessBuilder 跑用户代码:一个最小可用的沙箱
Java 里最朴素的沙箱就是独立进程。看这段核心判题代码,它能编译并运行一份用户提交的 C++ 代码:
public class JudgeRunner { public JudgeResult runSingleTest(String execPath, String inputPath, long timeLimitMs, long maxOutputBytes) throws IOException, InterruptedException { ProcessBuilder pb = new ProcessBuilder(execPath); pb.redirectInput(new File(inputPath)); // 测试输入从文件读 pb.redirectErrorStream(false); // 分离 stdout 和 stderr ByteArrayOutputStream stdoutBuf = new ByteArrayOutputStream(); ByteArrayOutputStream stderrBuf = new ByteArrayOutputStream(); Process process = pb.start(); Thread outThread = new Thread(() -> copyWithLimit(process.getInputStream(), stdoutBuf, maxOutputBytes)); Thread errThread = new Thread(() -> copyWithLimit(process.getErrorStream(), stderrBuf, maxOutputBytes)); outThread.start(); errThread.start(); long start = System.nanoTime(); boolean finished = process.waitFor(timeLimitMs, TimeUnit.MILLISECONDS); long elapsedMs = (System.nanoTime() - start) / 1_000_000; if (!finished) { // 超时:杀掉子进程及其后代 process.descendants().forEach(ph -> ph.destroyForcibly()); process.destroyForcibly(); return JudgeResult.timeLimitExceeded(elapsedMs, stdoutBuf.toString()); } outThread.join(2000); errThread.join(2000); int exitCode = process.exitValue(); if (exitCode != 0) { return JudgeResult.runtimeError(exitCode, stderrBuf.toString(), elapsedMs); } return JudgeResult.ok(stdoutBuf.toString(), elapsedMs); } }逻辑说明:redirectInput把测试用例的输入文件直接接到子进程的标准输入,避免用 OutputStream 往子进程里写大文本造成阻塞。redirectErrorStream(false)是为了分别取 stdout 和 stderr——用户代码往 stderr 打印调试信息,不能污染答案输出,但出现运行错误时这些信息又是排查线索。参数说明:waitFor 的第一个参数timeLimitMs直接取题目表配置,一般 1000ms 到 3000ms 之间;maxOutputBytes建议 1MB 到 2MB,防止用户代码死循环刷屏把磁盘写满。
内存限制在这个最小实现里没有做,因为 Java 层读子进程内存得轮询/proc/<pid>/status,代码会变得很长。我一般单独写一个 MemoryWatcher 线程,循环读取/proc/{pid}/status里的 VmRSS,超过memoryLimitKb就主动destroyForcibly()。这里有一个重要提示:ProcessBuilder 只适合教研环境或低风险内网,真正的生产 OJ 必须加容器隔离、seccomp 限制系统调用,否则用户代码可以读到服务器上其他文件。标题里的“可扩展”指的是业务扩展性,不是安全边界,安全隔离必须另做一层。
4.3 判题结果回写:批量更新与幂等消费
每个测试用例判完后,不要立刻 update 数据库。正确做法是把本轮所有测试点结果先存在内存列表,全部跑完后一次性更新submission和judge_result_detail。一个完整判题流程的 Java 伪代码如下:
@Transactional public void judge(Submission submission, List<TestCase> testCases) { int totalScore = 0; String finalStatus = "ACCEPTED"; List<JudgeResultDetail> details = new ArrayList<>(); for (TestCase tc : testCases) { JudgeResult r = runner.runSingleTest(execPath, tc.getInputPath(), tc.getTimeLimitMs(), MAX_OUTPUT_BYTES); r.setStatus(normalizeStatus(r, tc)); // 将输出比对结果转为判定状态 details.add(toDetail(tc, r)); if (!r.isAccepted()) { finalStatus = r.getStatus(); if (!supportsPartialScore(submission.getJudgeType())) break; } totalScore += r.isAccepted() ? tc.getScore() : 0; } submissionMapper.updateResult(submission.getId(), finalStatus, totalScore, details.stream().mapToInt(JudgeResultDetail::getUsedTimeMs).max().orElse(0)); detailMapper.batchInsert(details); }逻辑说明:@Transactional保证提交表更新和明细插入是同生共死的,避免出现“明细表有新记录,提交表还是 PENDING”的数据不一致。break逻辑前面讲过:不是部分得分题型时,遇到 WA/TLE 直接终止,节省判题资源。参数说明:finalStatus 的优先级是 TLE/MLE 最高、WA 次之、RUNTIME_ERROR 再次、AC 最低,因为运行到一半超时说明代码逻辑可能没跑完,不能给出正确性判断。
批量更新也不建议一次更新几百条明细,MySQL 单条 insert 语句长度有限制。比较稳的做法是按照 50 到 100 条一批执行 batch insert,如果失败就回滚整个事务。注意:判题 worker 必须支持“重复消费同一 submission_token 时直接跳过”的幂等判断,否则网络回写失败重试时会把成绩覆盖成第二次的脏数据。
5. 可扩展设计落地与判题避坑指南:新题型、新语言怎么低成本接入
5.1 用策略模式接新语言和新题型:扩展点选在哪
“可扩展”不只在表结构里留 JSON 字段,更要在 Java 代码里留接口。我常用的做法是定义一个 JudgeHandler 接口,每种语言实现一个 Bean:
public interface JudgeHandler { String language(); // 返回 cpp/java/python3... CompileResult compile(Submission sub) throws Exception; RunResult run(TestCase tc, String execPath) throws Exception; }@Component public class CppJudgeHandler implements JudgeHandler { @Override public String language() { return "cpp"; } @Override public CompileResult compile(Submission sub) throws Exception { Path src = Paths.get(sub.getCodeFilePath()); Path exe = src.resolveSibling("a.out"); ProcessBuilder pb = new ProcessBuilder("g++", src.toString(), "-o", exe.toString(), "-O2", "-std=c++17"); Process p = pb.start(); boolean ok = p.waitFor(10, TimeUnit.SECONDS); String err = new String(p.getErrorStream().readAllBytes()); return new CompileResult(ok, exe.toString(), err); } @Override public RunResult run(TestCase tc, String execPath) { // 调用 4.2 节 JudgeRunner,上面已经有实现 return judgeRunner.runSingleTest(execPath, tc.getInputFilePath(), tc.getTimeLimitMs(), MAX_OUTPUT_BYTES); } }逻辑说明:Spring 启动时会把所有 JudgeHandler 实现类收集进一个Map<String, JudgeHandler>,key 就是 language() 的返回值。新接入一种语言,只需要新增一个实现类,不需要改判题主流程。参数说明:-O2 -std=c++17是 C++ 判题常用编译参数;编译超时设置为 10 秒,比运行超时长很多,因为编译是可控的本地行为,不应轻易限制。Java 语言同理,用javac编译,入口类名要按规则从代码里解析出来,这里有一个容易踩的坑我会在 5.2 讲。
题型扩展的做法是:在判题主流程里只认judgeType,精确匹配、浮点误差、Special Judge 各实现一个JudgeComparator。新增“输出忽略大小写”这类题型时,加一个 Comparator 实现就够了。我一般把特判程序也做成可执行文件,判题时把它和用户输出、期望输出一起传进去,由特判程序决定 0/1/分数,这样自由度最高。
5.2 判题踩坑记录:三个高发症状与修复方法
踩坑一:编译进程跑完了却不退出,整个判题线程卡死。
现象:submission 状态一直停在 PENDING 或 JUDGING,服务器进程列表里能看到残留的a.out或者java进程。原因:用户代码里开了线程池或守护线程没有退出,ProcessBuilder 只杀了直接子进程,子进程的子进程还活着。解决:超时后不要只调用 destroyForcibly,要递归处理进程树——先process.descendants().forEach(ph -> ph.destroyForcibly()),再杀主进程,必要时在 Linux 下对整个进程组执行kill -9 -pid。这段逻辑建议封装成destroyProcessTree(Process p),判题结束的 finally 块里统一调用,别等到卡死才处理。
踩坑二:本机编译运行都正常,提交上去就是 WRONG_ANSWER。
现象:同一份代码本地跑样例全过,OJ 上 WA。原因一般有三个:输出比对太严格,末尾换行或空格差异被误判;Windows 下编写的代码把\r\n带进了输出;浮点数输出精度不一致。解决:比对前做规范化——去掉输出字符串末尾所有空白字符,把\r\n统一替换成\n,浮点比对用Double.parseDouble后按 1e-6 精度比较。这里有个血泪经验:WA 不一定是逻辑错,也可能是输出全角半角标点问题,规范化函数要写进判题框架的基础工具类里,所有题型共用。
踩坑三:判题高峰期数据库连接被占满,MySQL 报 Too many connections。
现象:比赛开始时大量用户同时提交,Tomcat 日志开始刷连接池超时。原因:判题 worker 每判一个测试点就 update 一次数据库,几百个提交同时跑就把连接池打满。解决:所有测试点判完再批量写库,连接池最大连接数调到 50 到 100,同时把判题服务的数据库连接和 Web 服务连接池分开。另一个优化是把judge_result_detail的 insert 改成批量,不要一条条写。
5.3 部署阶段的排查项:JDK 环境、临时目录、日志可读性
部署时最常见的坑是 Tomcat 启动后判题编译报错,但现象很迷惑。如果你在 Servlet 容器里调 g++ 或 javac,一定先确认外部进程能读到环境变量。我遇到过JAVA_HOME没配导致 javac 找不到,但容器日志里只显示“编译失败”,错误信息被吞了。解决:在启动脚本里显式 export JAVA_HOME 和 PATH,并且把编译错误流完整写进 error_message 字段,不要只存布尔值。
另一个高频问题是/tmp目录空间被写满。用户代码和可执行文件如果都放系统 /tmp,长时间运行会被系统清理任务删除,或者被磁盘占满。解决:判题服务使用独立的工作目录,比如/data/oj/tmp/{submissionId},每个提交跑完立刻清理该目录。这一步还要处理“清理失败”的情况,定期写一个定时任务扫超过 2 小时没动的目录直接删掉。日志方面,我必须强调在日志里带上submissionId、judgeType、language,否则线上排查时你根本不知道这条日志对应哪个提交。这不算技巧,算教训。
6. 进阶:判题集群化之前,先做一轮实验验证,再走这三步
把单机 OJ 扩展成集群判题,我建议不要急着上 Kubernetes,先把三件事做扎实。第一步是压测:用 JMeter 或者 ab 压POST /submit接口,每个线程模拟一个用户提交同一道题,观察 PENDING 到终态的耗时分布,重点看 95 分位而不是平均值。压测时要故意混入超时代码和死循环代码,看沙箱能不能把进程清理干净,这一步能暴露大部分进程残留问题。第二步是验证幂等:构造一个提交,手动触发两次判题,确认结果一致且 judge_result_detail 不会翻倍。第三步是看 MySQL 慢查询日志,把submission和judge_result_detail的更新频率和索引使用情况整理成清单。
实验验证通过后,再走集群化的三步:把判题服务从 Web 进程里拆成独立 Worker 进程,Web 端只负责写 submission 记录;引入一个简单的消息队列(单机用 Redis 或 Pika 就够)把 submissionId 推给多个 Worker 消费;Worker 判完后调用 Web 端的回写接口更新结果,回写接口必须做幂等。这三步走完,你的标题里的“可扩展”就从表结构扩展到了部署架构扩展。我自己做这套改造时,最深的体会是:先别追求并发数,先把“杀进程”“写结果”两个动作做可靠,集群只是把同一个可靠动作并行跑而已。希望帮到你。
本文还有配套的精品资源,点击获取