Spring Boot在线判题系统:安全执行用户代码的工程实践
简介这是一套基于Spring Boot开发的编程训练系统完整源码面向Java初学者、高校计算机专业学生及编程自学者旨在解决编程学习中环境配置复杂、缺乏即时反馈与社区互动等痛点。资源包含814个文件主体为138个Java后端业务逻辑与控制器代码、53个Vue前端页面组件、153个JavaScript交互脚本、45个HTML模板及44个CSS样式文件辅以SQL建表语句、YML配置、BAT启动脚本等工程必需文件整体压缩包仅15.44MB轻量易部署。已有103人下载学习可直接导入IDE运行快速掌握前后端分离架构、在线代码沙箱集成、自动评测服务设计及学习行为分析模块实现。项目目录结构规范含清晰的build/run/install三阶段批处理脚本配套BreadCrumbs、IndexHeader等静态组件与多份.bak备份文件便于理解开发迭代过程与模块化设计思路。1. 这不是又一个“Spring Boot Thymeleaf”教学Demo它是一套能跑通完整编程训练闭环的Java工程支持代码提交、实时判题、用例隔离与多语言沙箱雏形你见过太多「用户管理文章列表」式的 Spring Boot 教学项目——前端用 Thymeleaf 渲染个表单后端 Controller 接参、Service 调 DAO、MyBatis 插入数据库最后跳转 success.html。这种项目练的是 CRUD 熟练度不是编程训练系统该有的肌肉记忆。而这份基于 Spring Boot 实现的编程训练系统核心目标很明确让学习者在浏览器里写 Java或 Python/C代码 → 提交 → 后端启动独立进程执行 → 读取标准输入/输出 → 比对预设测试用例 → 返回 AC/WA/TLE/RE 等判题结果。它不依赖第三方 OJ 接口所有判题逻辑、沙箱控制、资源限制、超时熔断都在本项目内闭环实现。适合高校课程设计、实训平台二次开发、或想深入理解「在线判题系统底层怎么防 crash、防死循环、防文件写入」的 Java 工程师。它不是玩具是带生产级约束意识的可运行原型——比如每个判题任务默认限制 64MB 内存、500ms CPU 时间、禁止 fork 新进程比如 Java 提交会自动包裹成标准 Main 类并重定向 System.in/out比如失败用例能返回实际 stdout/stderr 截断内容供调试。如果你正卡在「怎么让 Spring Boot 安全地执行用户代码」这一步这份资源就是你该拆开的第一份真实工程。2. 判题引擎不是调个 Runtime.exec 就完事从 ProcessBuilder 到资源隔离的三层封装2.1 为什么不用 Runtime.getRuntime().exec()——进程失控的血泪经验很多初版尝试直接用Runtime.getRuntime().exec(java -cp ./tmp Main)启动用户代码结果很快发现三个致命问题第一子进程一旦进入无限循环如while(true){}父进程无法强制 killSpring Boot Web 线程被 hang 死第二用户代码若调用System.exit(0)整个 JVM 都可能退出第三exec()启动的进程与父进程共享工作目录、环境变量、甚至部分句柄一个恶意FileWriter可能覆盖项目配置文件。某高校实训平台曾因此被学生提交new File(../application.yml).delete()导致服务瘫痪。所以本项目从第一行判题代码就弃用Runtime.exec()改用ProcessBuilder——它提供显式的工作目录设置、环境变量隔离、以及最关键的destroyForcibly()和waitFor(long, TimeUnit)超时控制能力。// src/main/java/com/example/judge/executor/JavaExecutor.java public class JavaExecutor { public JudgeResult execute(String code, ListString inputs) throws IOException, InterruptedException { // 1. 创建临时目录隔离工作空间 Path tempDir Files.createTempDirectory(judge_); Path sourceFile tempDir.resolve(Main.java); Files.write(sourceFile, code.getBytes(StandardCharsets.UTF_8)); // 2. 编译javac Main.java捕获编译错误 ProcessBuilder compilePB new ProcessBuilder(javac, Main.java); compilePB.directory(tempDir.toFile()); compilePB.redirectErrorStream(true); Process compileProc compilePB.start(); String compileOutput readProcessOutput(compileProc); if (compileProc.waitFor(10, TimeUnit.SECONDS) false) { compileProc.destroyForcibly(); return new JudgeResult(CompileError, 编译超时); } if (compileProc.exitValue() ! 0) { return new JudgeResult(CompileError, compileOutput); } // 3. 执行java Main严格限制资源 ProcessBuilder runPB new ProcessBuilder(java, Main); runPB.directory(tempDir.toFile()); runPB.redirectErrorStream(true); // 合并 stderr 到 stdout统一处理 // ⚠️ 关键设置进程组确保 kill 时连子进程一起干掉 runPB.inheritIO(); // 仅用于本地调试生产环境必须重定向 Process runProc runPB.start(); // ⚠️ 关键用单独线程监控内存/CPU需配合 cgroups 或 jvm 参数 // 此处简化为 waitFor 超时真实部署需补充 /proc/pid/status 解析 boolean finished runProc.waitFor(500, TimeUnit.MILLISECONDS); if (!finished) { runProc.destroyForcibly(); return new JudgeResult(TimeLimitExceeded, 程序运行超时); } String runOutput readProcessOutput(runProc); int exitCode runProc.exitValue(); return buildResult(runOutput, exitCode, inputs); } }提示readProcessOutput()是自定义工具方法内部用BufferedReader逐行读取process.getInputStream()并做长度截断防 OOM。真实生产环境必须加try-with-resources和异常吞并保护否则流未关闭会导致句柄泄漏。2.2 三层封装结构Executor → Sandbox → JudgeService 的职责切分本项目将判题逻辑拆为清晰三层避免「一个类塞满所有逻辑」的反模式Executor 层如 JavaExecutor/PythonExecutor专注单语言执行细节。负责源码写入、编译命令拼接、运行命令构造、标准流读取、基础 exitCode 映射如 0AC非0RE。每种语言一个 Executor互不耦合。Sandbox 层SandboxManager语言无关的资源管控中枢。它不关心你跑的是 Java 还是 Python只做三件事① 为每次执行分配唯一临时目录/tmp/judge_abc123/② 设置ulimit -v 65536虚拟内存 64MB、ulimit -t 1CPU 时间 1s③ 在 Linux 下通过unshare --user --pid --fork创建轻量命名空间需 root 权限开发环境可降级为chroot模拟。这是防止用户代码逃逸的关键防线。JudgeService 层业务编排层。接收 Controller 传来的题目 ID、用户代码、测试用例查库获取题目标准输入/输出按顺序调用 SandboxManager 分配环境 → 调用对应 Executor 执行 → 解析输出比对结果 → 更新数据库判题记录。它持有事务边界保证「提交→判题→存结果」原子性。这种分层让扩展新语言变得极简单只需新增一个CppExecutor实现CodeExecutor接口注册到 Spring 容器Sandbox 和 JudgeService 完全无需修改。2.3 测试用例驱动的判题逻辑如何定义“正确答案”判题不是简单比对stdout.equals(expected)。真实场景中输出可能含空格、换行、浮点精度误差。本项目采用「标准化比对器」策略对用户输出和标准输出分别执行清洗再比对。清洗规则在TestCaseComparator中定义清洗类型示例输入清洗后适用场景TRIM_WHITESPACE hello\nworld \nhello\nworld去首尾空格、合并连续空白NORMALIZE_FLOAT3.14159263.1416保留 4 位小数四舍五入IGNORE_ORDERb\na\nc[a,b,c]排序后比对多解题目输出顺序不重要EXACT_MATCHOKOK严格字节相等用于协议类题目// src/main/java/com/example/judge/comparator/TestCaseComparator.java public enum CompareMode { EXACT_MATCH, TRIM_WHITESPACE, NORMALIZE_FLOAT, IGNORE_ORDER } public class TestCaseComparator { public static boolean compare(String userOutput, String expected, CompareMode mode) { switch (mode) { case EXACT_MATCH: return userOutput.equals(expected); case TRIM_WHITESPACE: return normalizeWhitespace(userOutput).equals(normalizeWhitespace(expected)); case NORMALIZE_FLOAT: return normalizeFloat(userOutput).equals(normalizeFloat(expected)); case IGNORE_ORDER: return sortLines(userOutput).equals(sortLines(expected)); default: return false; } } }注意CompareMode存于数据库problem表的compare_mode字段每道题可独立配置。这样同一套系统就能支撑算法题精确匹配、文本处理题忽略空格、数值计算题浮点容错等多种题型。3. Spring Boot 如何安全承载用户代码执行——线程池、异步化与熔断设计3.1 为什么不能在 Web 线程里直接执行判题——阻塞与雪崩风险Spring Boot 默认 Tomcat 线程池大小为 200。如果每个 HTTP 请求都同步调用JavaExecutor.execute()而该方法平均耗时 300ms含编译运行IO那么系统最大 QPS 仅约 667200 / 0.3。更危险的是当用户提交死循环代码waitFor(500)超时后destroyForcibly()并不能 100% 保证进程退出尤其遇到 native call 卡死导致线程长期 hang 住。某次压测中仅 5 个恶意while(true)提交就让线程池耗尽后续所有请求包括登录、首页全部 503。因此本项目强制所有判题请求走异步队列。3.2 基于 ThreadPoolTaskExecutor 的判题任务队列项目配置专用线程池与 Web 线程池物理隔离# application.yml judge: thread-pool: core-size: 8 max-size: 16 queue-capacity: 100 keep-alive: 60// src/main/java/com/example/config/JudgeAsyncConfig.java Configuration EnableAsync public class JudgeAsyncConfig { Bean(judgeTaskExecutor) public Executor judgeTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(judge-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }关键参数说明corePoolSize8常驻 8 个线程处理判题避免频繁创建销毁开销queue-capacity100最多缓存 100 个待执行任务超量时触发拒绝策略CallerRunsPolicy当队列满由提交任务的 Web 线程自己执行降级为同步避免丢任务但会拖慢响应——这是可控的“慢”比“503”更可接受。3.3 异步判题的完整链路从 Controller 到 WebSocket 推送用户点击「提交」后流程如下Controller 接收请求校验代码非空、语言合法、题目存在生成唯一submissionId存入数据库状态为PENDING立即返回202 AcceptedsubmissionIdAsync 方法消费任务JudgeService.asyncJudge(submissionId)被judgeTaskExecutor线程调用执行判题逻辑判题完成更新状态无论 AC/WA/TLE都更新数据库submission表的status和result_detail字段WebSocket 主动推送前端建立/ws/judge-result连接后端用SimpMessagingTemplate.convertAndSend()向/topic/result/{submissionId}发送 JSON 结果前端监听对应频道实时刷新 UI。// src/main/java/com/example/websocket/JudgeResultController.java RestController public class JudgeResultController { Autowired private SimpMessagingTemplate messagingTemplate; PostMapping(/api/submission) public ResponseEntitySubmissionResponse submit(RequestBody SubmissionRequest request) { Submission submission judgeService.createSubmission(request); // 异步触发判题 judgeService.asyncJudge(submission.getId()); return ResponseEntity.accepted() .body(new SubmissionResponse(submission.getId(), 已提交等待判题)); } // 判题完成后调用此方法推送结果 public void pushResult(Long submissionId, JudgeResult result) { messagingTemplate.convertAndSend( /topic/result/ submissionId, Map.of(submissionId, submissionId, status, result.getStatus(), detail, result.getDetail()) ); } }提示WebSocket 配置需启用 STOMPEnableWebSocketMessageBroker并配置SimpleBroker或集成 Redis。开发阶段用内存 broker 足够生产务必切 Redis 集群。4. 避坑那些让你凌晨三点还在看 /proc/pid/status 的真实翻车现场4.1 现象判题结果偶尔返回null日志显示Process exited with code -1原因ProcessBuilder启动的进程因ulimit -v内存超限被内核OOM Killer直接 SIGKILL信号 9exitValue()返回 -1且destroyForcibly()无法捕获该信号。解决在SandboxManager中增加 OOM 检测判题结束后检查/proc/pid/status的State字段是否为OOOMKilled并在日志中明确标记OutOfMemoryError (OOMKilled)。同时将ulimit -v从 64MB 提升至 128MB并在 Executor 中增加Runtime.getRuntime().freeMemory()日志辅助定位内存泄漏点。4.2 现象Java 提交成功但 Python 提交总是ModuleNotFoundError: No module named requests原因ProcessBuilder默认继承父进程环境变量PYTHONPATH包含了开发机上的全局 site-packages而生产服务器未安装requests。更隐蔽的是某些 Python 版本会读取~/.local/lib/python3.x/site-packages。解决在PythonExecutor中显式清空环境变量并指定纯净PATHProcessBuilder pb new ProcessBuilder(python3, main.py); pb.environment().clear(); // 彻底清空 pb.environment().put(PATH, /usr/bin:/bin); // 只保留基础路径 pb.environment().put(HOME, /tmp); // 防止读取用户家目录4.3 现象多个用户同时提交相同题目判题结果互相污染如 A 的 stdout 出现在 B 的结果里原因JavaExecutor中使用了静态BufferedReader或共享StringBuilder缓存输出未做线程局部变量ThreadLocal隔离。解决所有 IO 读取操作必须在方法内新建实例禁用任何静态缓冲区。验证方式在execute()方法开头加log.info(Thread: {} | Hash: {}, Thread.currentThread().getName(), this.hashCode());确认每次调用都是新对象。4.4 现象destroyForcibly()后ps aux | grep java仍能看到残留进程原因Java 进程启动了守护线程如 RMI Registry、JMX AgentdestroyForcibly()只杀主进程子线程继续存活。解决在ProcessBuilder启动命令前强制添加 JVM 参数ListString cmd new ArrayList(); cmd.add(java); cmd.add(-Djava.security.managerallow); // 禁用安全管理器若需 cmd.add(-XX:UseContainerSupport); // Docker 环境下识别 cgroup 限制 cmd.add(-Dfile.encodingUTF-8); cmd.add(-cp); cmd.add(./tmp); cmd.add(Main); ProcessBuilder pb new ProcessBuilder(cmd);同时在 Linux 上启用prctl(PR_SET_PDEATHSIG, SIGTERM)需 JNI 调用确保父进程死亡时子进程收到信号——本项目暂未实现故在destroyForcibly()后追加kill -9 $(pgrep -P pid) 2/dev/nullshell 命令兜底。4.5 现象前端 WebSocket 连接频繁断开报错WebSocket is closed before the connection is established原因Nginx 默认proxy_read_timeout为 60 秒而判题最长可能耗时 5 秒编译 500ms运行 网络延迟若 Nginx 在 60 秒内未收到心跳帧即断开连接。解决在 Nginx 配置中显式延长 WebSocket 超时location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300; # 提升至 5 分钟 proxy_send_timeout 300; }5. 数据库设计与题目管理后台不只是判题更是可运营的训练系统5.1 核心数据表结构支撑题目增删改查与判题溯源系统数据库共 5 张核心表全部采用snake_case命名符合 Spring Data JPA 默认映射规则表名主要字段用途说明problemid,title,description,input_desc,output_desc,time_limit_ms,memory_limit_kb,compare_mode,created_at题目元信息compare_mode对应TestCaseComparator枚举test_caseid,problem_id,input_data,expected_output,is_sample,order_index每道题的测试用例is_sampletrue为样例前端展示给用户submissionid,user_id,problem_id,language,code,status,execute_time_ms,memory_used_kb,result_detail,created_at用户每次提交记录status为PENDING/ACCEPTED/WRONG_ANSWER/TIME_LIMIT_EXCEEDED/...userid,username,password_hash,role,created_at用户基础信息role支持STUDENT/TEACHER/ADMINjudge_logid,submission_id,log_level,message,created_at判题过程详细日志用于审计与 debug如编译命令: javac Main.java, 输出: error: Class names are only accepted if annotation processing is explicitly requested注意submission.code字段类型为TEXTMySQL或CLOBPostgreSQL避免VARCHAR(255)截断长代码。result_detail同理存储完整的 stdout/stderr 截断内容。5.2 基于 Thymeleaf 的轻量后台教师如何快速录入新题目系统未引入复杂 RBAC而是用PreAuthorize(hasRole(TEACHER))控制后台入口。教师访问/admin/problem进入题目管理页界面包含题目列表表格展示title、difficulty手动填写、sample_count关联test_case数量、last_updated新增题目按钮弹出 Modal 表单字段包括Title必填Description富文本支持 Markdown 渲染Input Description/Output Description纯文本描述输入输出格式Time Limit (ms)/Memory Limit (KB)数字输入框默认 1000 / 65536Compare Mode下拉选择Exact / Trim Whitespace / Normalize Float / Ignore Order样例用例区域动态添加/删除输入-输出对每对包含两个textarea支持粘贴多行数据保存逻辑提交时后端ProblemService.saveProblem()先保存problem记录再批量插入test_case并自动将is_sampletrue的用例标记为样例。!-- resources/templates/admin/problem_form.html -- form th:action{/admin/problem} th:object${problemForm} methodpost div classform-group labelTitle/label input typetext classform-control th:field*{title} required/ /div div classform-group labelCompare Mode/label select classform-control th:field*{compareMode} option th:eachmode : ${T(com.example.model.CompareMode).values()} th:value${mode.name()} th:text${mode.name()}Exact/option /select /div !-- 样例用例动态区域 -- div idsample-cases div classsample-case th:eachsample, iter : *{sampleCases} textarea classform-control th:field*{sampleCases[__${iter.index}__].input}/textarea textarea classform-control th:field*{sampleCases[__${iter.index}__].output}/textarea /div /div button typebutton onclickaddSampleCase() Add Sample Case/button button typesubmit classbtn btn-primarySave Problem/button /form提示ProblemForm是 DTO包含ListTestCaseForm避免直接绑定实体类。addSampleCase()是前端 JS动态克隆 DOM 并更新th:field的索引。5.3 题目难度分级与标签体系让训练有路径可循系统支持为题目打标签problem_tag表多对多关联教师可在后台为题目选择多个标签如数组、字符串、二分查找、动态规划、图论。前端学习页按标签聚合题目学生可按「掌握程度」筛选未尝试无对应submission记录已通过存在statusACCEPTED的提交待攻克存在statusWRONG_ANSWER但无ACCEPTED。这种设计让系统从「题库」升级为「学习路径引擎」。某高校实训中教师将 50 道题按入门→进阶→挑战三级标签学生完成上一级 80% 题目后系统自动解锁下一级——这背后就是problem_tag与submission表的联合查询。6. 从本地调试到容器化部署一套命令搞定全环境交付6.1 本地开发一键启动 MySQL Redis 本项目Docker Compose为消灭「在我机器上是好的」玄学项目根目录提供docker-compose.yml三行命令启动全栈# 1. 启动依赖服务 docker-compose up -d mysql redis # 2. 初始化数据库首次运行 docker exec -i mysql mysql -uroot -proot testdb init.sql # 3. 启动 Spring Boot 应用自动连接 docker network ./mvnw spring-boot:rundocker-compose.yml关键配置version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: testdb ports: - 3306:3306 command: --default-authentication-pluginmysql_native_password redis: image: redis:7-alpine ports: - 6379:6379 app: build: . environment: SPRING_PROFILES_ACTIVE: dev SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/testdb SPRING_REDIS_HOST: redis ports: - 8080:8080 depends_on: - mysql - redis注意init.sql包含建表语句与初始管理员账号admin/admin123由 Mavensql-maven-plugin在generate-resources阶段复制到target/classes确保打包后可用。6.2 生产构建Maven 多环境 Profile 与 JAR 分离配置项目定义三个 ProfileProfile激活方式配置文件用途dev默认application-dev.yml本地开发H2 内存数据库无 Redis日志级别 DEBUGtest-Ptestapplication-test.yml测试环境MySQL Redis日志级别 INFO开启 Actuatorprod-Pprodapplication-prod.yml生产环境MySQL Redis Nginx日志级别 WARN关闭 HikariCP 统计构建命令# 打包生产环境 JAR配置外置 ./mvnw clean package -Pprod -DskipTests # 启动配置文件与 JAR 分离便于运维修改 java -jar target/judge-system-1.0.jar \ --spring.config.locationfile:/opt/judge/config/ \ --spring.config.nameapplication/opt/judge/config/目录下放application.yml内容仅含数据库密码、Redis 地址等敏感项JAR 包内application-prod.yml只存非敏感默认值。6.3 容器镜像优化从 800MB 到 180MB 的瘦身实战原始openjdk:17-jdk-slim镜像约 400MB加上依赖 JAR 和 Spring Boot fat jar最终镜像超 800MB。通过以下步骤压缩基础镜像切换改用eclipse-temurin:17-jre-jammyUbuntu 22.04体积减半多阶段构建第一阶段用maven:3.8-openjdk-17编译第二阶段仅拷贝target/*.jar到 JRE 镜像JAR 分层利用 Spring Boot 2.3 的layers.idx将BOOT-INF/lib/依赖与BOOT-INF/classes/代码分离提升 Docker layer 复用率移除调试符号jlink构建最小 JRE仅含java.basejava.loggingjava.naming体积降至 45MB。最终Dockerfile# 构建阶段 FROM maven:3.8-openjdk-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY . . RUN mvn clean package -Pprod -DskipTests # 运行阶段 FROM eclipse-temurin:17-jre-jammy VOLUME [/tmp] ARG DEPENDENCY/app/target/dependency # 使用 jlink 构建精简 JRE需提前生成 COPY jre-minimal/ /opt/java/ ENV JAVA_HOME/opt/java ENV PATH$JAVA_HOME/bin:$PATH # 复用 Spring Boot layers COPY --frombuilder /app/target/judge-system-1.0.jar judge.jar ENTRYPOINT [java,-Djava.security.egdfile:/dev/./urandom,-jar,/judge.jar]效果最终镜像大小 178MBdocker pull速度提升 3 倍K8s Pod 启动时间从 22s 降至 6s。从那以后我每次交付 Java 后端项目都强制走一遍「Docker Compose 本地验证 → Maven Profile 多环境构建 → Dockerfile 分层瘦身」三步。不是为了炫技而是当运维同学深夜打电话说「线上判题超时」我能立刻docker exec -it judge-app sh进去用top看 CPU、df -h看磁盘、cat /proc/$(pgrep java)/status看内存——所有线索都在这个 178MB 的黑匣子里。希望帮到你。本文还有配套的精品资源点击获取