ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于SpringBoot+Vue的在线评测系统:判题链路与资源隔离实践

2026/10/7 15:30:14 拓冰建站 浏览量
基于SpringBoot+Vue的在线评测系统:判题链路与资源隔离实践 简介一套基于Spring Boot与Vue前后端分离架构的在线代码评测系统源码面向计算机专业学生、Java全栈开发者及需要搭建OJ平台的技术人员。系统包含用户注册登录、个人信息管理、角色权限控制、题目创建编辑与分页查询、代码提交和自动评测等完整模块可作为课程设计、毕业设计或企业内训的参考项目。压缩包共120个文件以89个Java源码文件为主辅以8个XML配置和4个YAML配置另有13张PNG界面截图与1份Markdown说明文档整体体积仅2.13MB目录结构分层清晰。目前已有82人学习下载。研读后可掌握SpringBoot与Vue整合、判题服务核心流程、前后端接口交互及角色权限控制等关键实现同时通过目录划分快速理解用户服务、题目管理、提交评测等模块的组织方式借助截图和说明文档可迅速完成环境部署与功能验证是一套轻量但完整的在线评测系统参考实现。1. 在线代码评测系统功能不在“在线”而在评测链路怎么兜底一个在线评测系统最容易翻车的不是页面交互而是没法安全地运行用户提交的代码。这套基于 SpringBoot 和 Vue 的在线代码评测系统源码把“提交→判题→返回结果”做成了完整的工程链路Redis 队列承接提交、独立判题逻辑控制编译运行、测试用例比对结果最后通过接口和推送把状态回给前端。我拆它的过程中最强烈的感受是它已经把“代码在服务器上跑”这件事抽象成了资源限制和结果判定而不是简单丢进Runtime.exec里碰运气。适合两类人需要一套能答辩、能演示的 SpringBoot 全栈项目在校生以及想在公司内部复刻一个笔试/实训平台的后端开发。这篇文章按我拆包的顺序写从工程目录开始一直讲到判题进程的资源清理。2. 源码包结构与技术选型SpringBoot 后端和 Vue 前端怎么分工2.1 从压缩包到工程两个独立项目一份联调约定这个压缩包里的项目不是传统的单体内联页面而是前后端分离的两套工程。按我平时的习惯拿到压缩包第一件事就是把目录结构摸清oj-system/ ├── backend/ # SpringBoot 主工程Maven 构建 ├── frontend/ # Vue 3 Vite 前端工程 ├── sql/ # 数据库初始化脚本 └── README.md后端这一侧我建议先看pom.xml。这里有一个经常被忽略的坑SpringBoot 项目结构如果拆成多模块oj-common、oj-web、oj-judge之间的依赖关系就决定了你改完判题逻辑之后要不要重新打整个包。常见做法是把判题相关的类放在独立模块里这样以后想把判题服务拆出去单独部署只需要把oj-judge模块拎出来暴露一个消息队列订阅接口就能独立跑。Vue 前端这边用的是 Vite 而不是 Vue CLI这一点对构建速度影响很大。Vite 开发服务器冷启动比 Webpack 方案快很多依赖安装也更干净。如果你拿到源码后执行npm install遇到版本问题大概率是 Node 版本和 Vite 版本不匹配后面避坑章节我会具体展开。前后端联调时有一个约定要特别注意后端接口统一以/api开头前端所有请求都走这个前缀。这样在开发环境可以直接用 Vite 的 proxy 把/api指到后端端口到了生产环境再把打包后的静态文件放进 SpringBoot接口路径完全不用改。2.2 后端配置层数据源、Redis 队列和判题参数打开backend/src/main/resources/application.yml核心配置长这样server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/oj_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 database: 0 oj: judge: queue-key: oj:judge:queue result-key: oj:judge:result work-dir: /tmp/oj max-output-size: 1048576 default-time-limit: 3000 default-memory-limit: 256这里有几个参数值得单独说明。max-output-size是我认为最容易漏掉的配置如果用户代码里写了一个while(true) System.out.println(...)输出流不设上限会直接打满磁盘。default-time-limit单位是毫秒OJ 里一般说“时间限制 1s”指的就是 1000ms 的执行时间上限。default-memory-limit是判题 JVM 对用户代码进程的内存上限单位是 MB注意它和用户代码内部自己申请堆内存是两层概念。Redis 在这里的定位不只是缓存而是判题队列。提交评测时接口层把提交记录写入oj:judge:queue判题服务异步消费。这样做的直接好处是快速提交多个代码时接口不会因为某个用例运行慢而阻塞住。生产者消费者模式是这类系统里性价比最高的选型比直接引入 RabbitMQ 少维护一套中间件量级不大时完全够用。2.3 前端路由与登录态Vue 路由参数和 SpringBoot 的会话对齐前端路由采用 Vue Router 的 history 模式页面级路由设计得比较规整import { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const router createRouter({ history: createWebHistory(), routes: [ { path: /, name: ProblemList, component: () import(/views/ProblemList.vue) }, { path: /problem/:id, name: ProblemDetail, component: () import(/views/ProblemDetail.vue), props: true }, { path: /submit/:id, name: SubmitCode, component: () import(/views/SubmitCode.vue), props: true }, { path: /login, name: Login, component: () import(/views/Login.vue) } ] }) router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path ! /login !userStore.token) { next(/login) } else { next() } })这里值得抄作业的是props: true。开启后路由/problem/:id的id会自动作为组件的id属性传入组件内部就不再需要到处写$route.params.id代码一眼清爽。登录态用 token 存 Pinia每次请求在 axios 拦截器里加Authorization头。注意一点后端如果配置的是 SpringSecuritytoken 校验过滤器需要放行/api/auth/login和判题结果查询接口否则前端组件挂载后请求直接被 401 拦截。Vue 开发习惯相对独立这里按约定接后端接口即可具体拦截逻辑要对着源码里的后端过滤器链看一遍别直接套网上模板。3. 判题核心设计编译、资源限制与结果比对的实现细节3.1 一次提交的生命周期从 Submit 到 Accepted 中间发生了什么判题核心是一个后台运行的轮询任务它做的事可以抽象成“消费 Redis 队列里的提交 ID执行评测写回结果”。核心代码逻辑如下public void consumeJudgeQueue() { while (!Thread.currentThread().isInterrupted()) { try { String submitId redisTemplate.opsForList().leftPop(judgeConfig.getQueueKey()); if (submitId null) { Thread.sleep(200); continue; } JudgeTask task buildTask(submitId); JudgeResult result executeTask(task); saveResult(submitId, result); } catch (Exception e) { log.error(judge task failed, submitId{}, submitId, e); } } }这里的leftPop是移出并返回列表的左侧元素。判题服务启动后会一直循环队列空了就等 200ms 再试。有人会问为什么不用 Redis 的brpop阻塞模式原因很简单阻塞模式在连接被异常断开时不好恢复手动轮询反而更容易看清运行状态。判题本身不是高频低延迟场景200ms 的间隔对用户体验几乎没有影响。executeTask(task)内部大致有四步准备临时目录、把用户提交的代码写入文件、启动编译、执行并比对测试用例。每一步都需要独立的失败处理。比如编译阶段常见的错误是类名不一致用户代码写的是public class Main而判题端默认文件名也是Main.java这两者必须保持一致否则 Java 编译器会直接报错。3.2 编译与执行隔离为什么不能直接 Runtime.exec 一把梭很多人第一次写判题服务时习惯用Runtime.exec()直接跑编译命令然后拿到输出流就是一个 BufferedReader 慢慢读。这种做法在低并发下不会立刻出问题但一旦多个用户同时提交就会遇到两个隐蔽的坑输出流没人读导致管道写满、子进程僵死进程销毁不干净导致非常多的孤儿进程吃光 CPU。我在源码里看到的是用 ProcessBuilder 包了一层并且把错误流合并到了标准输出流public CompileResult compile(String workDir, String sourceName, int timeoutSeconds) { ProcessBuilder pb new ProcessBuilder(javac, sourceName); pb.directory(new File(workDir)); pb.redirectErrorStream(true); // 关键把 stderr 合并到 stdout避免管道死锁 Process process null; try { process pb.start(); StringBuilder output new StringBuilder(); try (BufferedReader reader new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null output.length() MAX_COMPILE_OUTPUT) { output.append(line).append(\n); } } boolean finished process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); return CompileResult.timeout(output.toString()); } return CompileResult.fromExitCode(process.exitValue(), output.toString()); } catch (IOException e) { return CompileResult.error(编译命令启动失败: e.getMessage()); } finally { if (process ! null process.isAlive()) { process.destroyForcibly(); } } }解释一下为什么redirectErrorStream(true)很重要。子进程往管道里写内容时如果父进程没有及时读取管道缓冲区满了之后子进程会阻塞住。而错误输出和标准输出共用一条管道后父进程只需要一个线程去读不会出现“标准输出没人读”的同时“错误输出阻塞”的死锁场景。waitFor(timeout, seconds)给编译设置了超时编译超过设定阈值就强杀。注意这里用的是destroyForcibly()对应 Linux 的kill -9比destroy()更彻底。但对于执行用户代码来说destroyForcibly()也未必能清干净因为用户代码可能自己 fork 了子进程。实务做法是用setsid把用户代码放到新的进程组里启动然后直接杀进程组kill -9 -PID。这个细节我在后面避坑章的“孤儿进程”一条里做实例说明。3.3 测试用例比对与 Special Judge判定标准不一致怎么办判题结果准确性的最后一道关是输出比对。源码里提供了一份基于行粒度的比较器public boolean compareOutput(String expected, String actual, boolean ignoreTrailingSpace, boolean ignoreLineOrder) { if (expected null || actual null) return false; String[] expectedLines expected.split(\n); String[] actualLines actual.split(\n); if (ignoreLineOrder) { Arrays.sort(expectedLines); Arrays.sort(actualLines); } if (expectedLines.length ! actualLines.length) return false; for (int i 0; i expectedLines.length; i) { String exp ignoreTrailingSpace ? expectedLines[i].trim() : expectedLines[i]; String act ignoreTrailingSpace ? actualLines[i].trim() : actualLines[i]; if (!exp.equals(act)) return false; } return true; }这里有两个参数可以根据题目类型调整。ignoreTrailingSpace控制是否忽略行尾空白OJ 里大多数题目建议开启否则用户代码多输出了一个空格就被判 WA容易引发“为什么本地跑是对的但提交就是不过”的玄学问题。ignoreLineOrder则比较危险它比较的是两行内容完全对称的题比如把一组数排序后输出任意顺序都算过——这类需求最好用独立的 isEqual 接口实现而不是开到所有题目上。当题目涉及浮点数比对时文本比对基本不可用因为3.14和3.14159在误差范围内应该判对但文本不等。常见做法是实现一个Special Judge允许配置误差阈值public boolean compareFloat(String expected, String actual, double epsilon) { try { double expVal Double.parseDouble(expected.trim()); double actVal Double.parseDouble(actual.trim()); return Math.abs(expVal - actVal) epsilon; } catch (NumberFormatException e) { return false; } }这类逻辑一般会做成一个可扩展接口每个特判题目在数据库里配置judge_typespecial和epsilon1e-6判题服务加载到对应测试用例时自动切到浮点比对。源码里这块的扩展点预留得不错二次开发时照着加一个JSONOutputComparator就能支持输出 JSON 字段比较的题目。4. 前端 Vue 工程与后端联调代理、路由与实时结果推送4.1 开发环境跨域vite.config.js 里的 proxy 配置开发阶段前后端分离部署端口不同跨域问题必须处理。Vite 的方案是代理在vite.config.js里这样配置import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置完成后浏览器页面里发起的/api/problem/list请求会被 Vite 开发服务器转发到http://localhost:8080/api/problem/list前后端联调时不需要后端开 CORS也不需要前端在 axios 里写完整域名。changeOrigin: true会让代理请求头中的 Host 变成目标地址很多后端框架校验 Host 时依赖这个字段。这里有一个需要看实际情况调整的点如果后端 SpringBoot 配置了server.servlet.context-path: /api那代理配置就不能再对路径做 rewrite转发后直接命中后端接口如果后端没有 context-path 而是通过 Controller 里的RequestMapping(/api/**)映射代理同样可以保持不变。千万别两层都加/api否则转发到后端会变成/api/api/xxx接口直接 404。4.2 评测状态实时刷新WebSocket 接入写法判题过程需要告诉用户当前处于“排队中”“判题中”“编译失败”还是“通过”最直接的做法是前端每隔几秒拉一次提交结果。但轮询有两个缺陷延迟高且如果提交记录多后端接口的压力明显。我在这套源码里看到的是用 WebSocket 做主动推送核心写法如下// 提交代码后建立连接监听该提交的评测状态 const status ref(QUEUE) const currentSubmitId ref(0) let ws null function watchJudgeStatus(submitId) { currentSubmitId.value submitId const protocol location.protocol https: ? wss: : ws: ws new WebSocket(${protocol}//${location.host}/api/ws/judge) ws.onopen () { ws.send(JSON.stringify({ type: bind, submitId: currentSubmitId.value })) } ws.onmessage (event) { const data JSON.parse(event.data) if (data.submitId currentSubmitId.value) { status.value data.status if (data.status ACCEPTED || data.status WRONG_ANSWER) { ws.close() } } } } onUnmounted(() { if (ws) ws.close() })WebSocket 路径/api/ws/judge由后端 SpringBoot 通过注册一个TextWebSocketHandler提供。注意前端必须在onUnmounted里关闭连接否则用户跳转页面后连接底下的线程不会被释放开发调试时你会看到浏览器控制台不断报断线重连。这里有一个体验细节后端收到bind消息后会把该 submitId 对应的状态全量推给前端而不是只推增量。判题中间态更新的频率不高全量推送反而能避免前端“错过了中间状态”导致界面卡在等待中。4.3 生产环境把 Vue 打包放进 SpringBoot 静态目录开发环境用代理生产环境更简单粗暴直接把前端构建产物放到 SpringBoot 的静态资源目录里让后端同时提供接口和页面。具体做法分两步。先构建前端npm run build # 产物输出到 frontend/dist/然后把dist里的文件整体复制到backend/src/main/resources/static/下cp -r frontend/dist/* backend/src/main/resources/static/最后还需要一个“兜底”的 Controller。因为 Vue 路由是createWebHistory()进入/problem/100后刷新页面时后端静态资源里并不存在名为problem/100的物理文件必须把所有非接口路径转发到index.htmlController public class FrontendController { RequestMapping(value {/, /problem/**, /submit/**, /login/**}) public String forward() { return forward:/index.html; } }比如访问/problem/101SpringBoot 会把请求转发给静态资源处理器由index.html接管前端路由。这里面最容易踩的坑是接口路径被这个 Controller 拦下来所以FrontendController的匹配路径必须只包含前端页面路由不能写/api/**否则所有接口请求都会被 forward 到index.html返回的不是 JSON 而是 HTML。要注意 Vue 打包出来的资源名带 hash 后缀static目录下的assets文件夹能省则省每次重新构建后把旧文件清掉再拷贝否则项目写久了static目录会越来越乱。5. 部署与运行的避坑记录版本冲突、路由 404 与孤儿进程部署这套源码时我遇到过不少看着奇怪、跑起来才发现是环境或设计导致的报错。这章按问题类型拆开讲每一条都是“现象→原因→解决”的实际排查记录。5.1 前端环境与构建类问题现象 1npm install后执行npm run build直接报错failed to load tsconfig vue/tsconfig/tsconfig.web.json: tsconfig not found构建流程当场中断。原因这是典型的依赖安装不完整。.npmrc或package.json里引用了vue/tsconfig作为 TS 配置基准包但npm install因网络或缓存问题没把该包装进node_modules。另外Node 版本太高或太低都可能让这个包里的配置语法解析失败。解决先删掉node_modules和package-lock.json重新npm install如果依然报错用nvm切到 Node 16 或 18 LTS 版本再装。装完后检查node_modules/vue/tsconfig目录是否存在并确认tsconfig.json里extends写的是vue/tsconfig/tsconfig.web.json而不是不存在的vue/tsconfig/tsconfig.json。我一般会直接搜索编译器路径如果tsc是从全局装的而项目本地没装 TypeScript也会报出“找不到”的假象。现象 2后端 Maven 打包时报spring-boot-maven-plugin找不到或版本解析失败换了一个高版本的 SpringBoot 后项目连启动都起不来Redis 自动配置直接失效。原因这就是大家常说“SpringBoot 版本太高”导致的问题。新版本对 JDK 版本有硬性要求比如 SpringBoot 3.x 必须跑在 JDK 17 以上如果本机是 JDK 8编译时能过但运行时ClassNotFoundException一个接一个。解决检查本机java -version确认 JDK 版本与pom.xml里声明的 SpringBoot 版本匹配。SpringBoot 2.7 用 JDK 8 或 11SpringBoot 3.x 用 JDK 17。如果项目里还引入了低版本的 MyBatis 或 Redis 客户端优先升到与 SpringBoot 对应兼容的版本再逐个排查配置类。5.2 后端配置与静态资源类问题现象 3前端开发环境代理访问接口一切正常但把dist放进 SpringBoot 静态目录后新窗口打开http://localhost:8080/problem/100出现白屏刷新一次后直接 404。原因Vue 路由用的是 history 模式非根路径在前端内存里能正常渲染但浏览器刷新时发起的请求到的是后端服务器后端没有对应路径的静态文件也不知道该让它走前端路由于是返回 404。解决用 4.3 节里说的FrontendController做 all-forward 处理。注意匹配顺序不要让/api/**被拦截接口请求和页面请求必须分清楚。如果要临时验证效果把 Vue 路由切成createWebHashHistory()再打包URL 会带#/problem/100后端无需任何配置也不会 404但这种方式在正式交付时一般不用URL 不够直观。现象 4MySQL 8 连接时抛Public Key Retrieval is not allowed错误数据库密码明明是错的还是报这个错。原因MySQL 8 的caching_sha2_password认证插件要求客户端首次连接时获取服务器的公钥来加密密码。很多驱动默认禁止这个行为所以即便是正确密码也过不了认证。解决在 JDBC URL 上追加allowPublicKeyRetrievaltruejdbc:mysql://localhost:3306/oj_system?allowPublicKeyRetrievaltrueuseSSLfalse。这个参数只在开发环境有效生产环境建议用useSSLtrue并配置正确的证书链路单纯关闭公钥获取不是安全做法。5.3 判题稳定性与资源清理类问题现象 5提交了一段死循环代码后前端状态一直卡在JUDGING后端日志刷出超时但过一会儿服务器负载持续升高ps -ef查出 Java 进程下挂了很多javac、java子进程父进程被杀后子进程依然存在。原因这是判题服务设计里最常见的一个缺口——进程销毁只杀了直接子进程没用进程组级别清理。用户代码调用Runtime.exec(bash -c java Main)时bash 可能再派生子进程只杀第一层根本清不干净。解决启动用户代码时用setsid开独立进程组超时或异常时直接杀整个进程组。核心代码可以这样调整// 把执行逻辑放在进程组里 ProcessBuilder pb new ProcessBuilder(bash, -c, setsid java -Xmx256m -cp . Main); pb.directory(new File(workDir)); Process process pb.start(); // 超时清理先杀进程组再杀子进程 if (!process.waitFor(timeLimit, TimeUnit.MILLISECONDS)) { String pid String.valueOf(process.pid()); Runtime.getRuntime().exec(new String[]{kill, -9, - pid}); return JudgeResult.timeout(); }代码里-加 pid 是 shell 的“负数 PID”语法表示向整个进程组发送信号。这是 Linux 下切孤儿进程最可靠的方法之一。我在本地压测时特意写了个“fork 炸弹”用例去试发现只调destroyForcibly()时系统会残留几十个僵尸进程序换成进程组 kill 后残留数量直接归零。现象 6同一个判题服务部署在 8 核 16G 的机器上并发提交 20 个代码时经常有提交卡在QUEUE状态很久不出结果。原因问题不在 Redis而在判题核心线程池的配置。默认空手道的写法是每消费一个提交就new Thread()起一个线程但执行用户代码是要占 CPU 的线程数超过 CPU 核数后上下文切换开销反而拖慢整体进度。解决用有界线程池控制并发度我一般给 2 倍 CPU 核心数。同时要控制每个判题任务的最大等待时间队列里积压超过上限就直接把提交标记为系统错误避免用户无限等待。这个参数应该在oj.judge.concurrency下配置具体看源码里的ThreadPoolTaskExecutor初始化部分。6. 从“能跑”到“能交付”并发评测与扩展的验证方法这套源码拿来本地跑通只是第一步真正的验证要模拟“多用户同时交代码、其中有恶意代码”的混合场景。我在递交之前通常强制走一遍完整测试流程先启动后端和前端准备至少三道题的数据每题配两个测试用例其中一个用例专门设计成死循环。第一项验证是并发提交。我用一个简单的脚本同时发 30 个提交请求覆盖“正确代码、答案错误、运行超时、编译失败”四类情况。随后观察 Redis 队列积压和判题日志# 每分钟采样一次队列积压深度 redis-cli llen oj:judge:queue # 观察判题服务线程池是否有堆积 jstack pid | grep -A 10 judge队列应该在提交全部进入后 10 秒内持续下降最终归零。如果积压不降说明线程池或消费循环设计有问题不是网速或数据库慢能掩盖的。第二项验证是资源隔离。提交一个while(true){ Thread.sleep(1000); }的 Java 代码时间限制设 1000ms。结束后执行ps -ef | grep java确认没有残留子进程。我习惯再检查/tmp/oj工作目录每轮判题结束后是否有文件残留。这个目录如果持续膨胀说明清理逻辑只删了父任务没删临时子目录。第三项验证是恢复能力。直接重启 Redis观察判题服务重新连接后消费是否继续再手动往oj:judge:queue塞一条测试消息确认不会因为连接中断导致整个消费者线程挂死。这套源码的日志打得比较清楚重启后能看到consumeQueue resumed之类的输出排查问题不用靠猜。如果你要把它改造成真正可交付的产品最值得扩展的方向是引入消息队列做异步削峰。当前 Redis 队列在提交量很低时完全够用但到了几千人同时笔试的场景Redis 队列的持久化弱项就暴露了Redis 重启会丢未消费的任务。我更推荐的方案是保留现有接口把 Redislist换成 RabbitMQ 或 ActiveMQ 的持久队列判题服务从消费 Redis 改成监听 MQ topic这样丢消息的概率大幅下降。判题 jvm 进程本身也可以考虑独立部署成 worker 节点每个 worker 单独配置 CPU 上限和内存上限。另外扩展语言支持时注意编译和执行的参数差异。现在源码主流程只写了 Java如果要接 Python执行命令建议改成python3 -c import py_compile; py_compile.compile(main.py)先做语法检查再timeout 5 python3 main.py限制执行时长。资源内存收紧方面Python 只能用cgroup或容器方案这和 Java 进程的-Xmx是两套体系这也会是二次开发时最费时间的部分。这套源码我拆下来最大的体会是在线评测系统最值钱的不是那层漂亮的前端页面而是在并发和异常代码的双重压力下还能稳定给出正确判定结果的判题服务。从那以后我每次拿到这类工程做交付都会强制走一遍“死循环 大内存 并发提交”的三连测确证进程组能干净撤下、队列不积压、Redis 重启不丢已收的任务。这套代码里很多边界处理都值得照着抄但抄完一定要自己做一轮资源清理的验证不然代码“写着能跑”和“上线能扛”就是两回事。希望帮到你。本文还有配套的精品资源点击获取