项目复盘:从 0 到上线一个 Spring Cloud 在线 OJ 判题系统
摘要
在线 OJ 系统看起来像“用户提交代码,系统返回结果”,但真正做起来会发现它不是普通 CRUD。
它至少要解决这些问题:
用户代码不可信,不能直接在宿主机运行;
判题过程耗时,不能一直阻塞业务线程;
多测试用例输出要准确比对;
TLE、MLE、CE、RE、WA、AC 等结果要结构化表达;
题目、考试、用户、提交记录等数据需要缓存和持久化;
管理端和用户端职责要拆开;
后续还要支持 AI 解释、RAG 学习建议和安全风控。
我的 OJ 项目采用 Spring Boot 3 + Spring Cloud Alibaba + MyBatis + MySQL + Redis + RabbitMQ + Docker + Vue 实现,对标 LeetCode 的在线代码评测平台。系统将判题能力拆成独立 oj-judge 服务,通过 Feign / RabbitMQ 和业务服务解耦,并基于 Docker 容器实现 Java 代码运行隔离。
本文从整体架构出发,复盘一次代码提交从进入系统到返回 AC 的完整链路。
项目定位
项目目标不是做一个简单题库,而是做一个能上线演示的在线 OJ:
C 端用户:注册登录、浏览题目、提交代码、查看结果、参加考试;
B 端管理:用户管理、题目管理、测试用例管理、考试管理;
判题服务:代码编译、Docker 沙箱执行、输出比对、资源限制;
异步能力:RabbitMQ 投递判题任务,避免阻塞提交接口;
缓存优化:Redis 缓存热点题目、考试、用户、消息等数据;
AI 增强:独立 Python FastAPI + LangGraph 服务做静态扫描、RAG、Memory、AI 点评。
一句话概括:
Java OJ 负责确定性判题,Docker 沙箱负责安全执行,RabbitMQ 负责异步解耦,AI 服务负责解释增强。
系统模块拆分
项目是 Spring Cloud Alibaba 多模块架构,核心模块如下:oj-gateway
网关入口、JWT 鉴权、路由转发
oj-api
Feign 接口、共享 DTO / VO
oj-common
Redis、安全、MyBatis、Swagger、文件、RabbitMQ、消息等公共能力
oj-modules/oj-system
B 端管理服务:用户、题目、考试管理
oj-modules/oj-friend
C 端用户服务:登录、题目浏览、代码提交、消息、考试
oj-modules/oj-judge
判题服务:Docker 沙箱、编译执行、结果比对、提交记录保存
oj-modules/oj-job
定时任务、考试和消息相关后台任务
online-judge-ai
Python FastAPI + LangGraph AI 判题增强服务
这套拆分的核心原则是:业务提交和代码执行分离。
用户提交代码发生在 oj-friend,真正执行代码发生在 oj-judge。这样判题服务可以独立扩容、独立限制资源,也不会把不可信代码执行逻辑混进用户业务服务。
一次代码提交的主链路
从用户视角看,一次提交就是“点提交,等结果”。但服务端链路会经过多个模块:
这条链路的关键是 JudgeSubmitDTO,它把判题所需信息统一封装起来:
用户 ID;
题目 ID;
考试 ID;
编程语言;
用户代码;
题目难度;
时间限制;
空间限制;
输入用例;
标准输出。
同步判题与异步判题
项目同时支持两种判题路径。
- 同步路径
同步路径适合调试或低并发场景:
oj-friend
-> RemoteJudgeService.doJudgeJavaCode
-> oj-judge
-> 返回判题结果
优点是调用简单,接口可以直接拿到结果。
缺点也明显:判题耗时会阻塞 HTTP 请求。如果用户代码执行慢,或者 Docker 沙箱排队,前端就要一直等。 - 异步路径
异步路径适合真实在线提交:
oj-friend
-> JudgeProducer 投递 RabbitMQ
-> 前端立即得到“判题中”
-> oj-judge 消费消息并判题
-> 前端轮询 / 推送获取最终结果
异步路径的优点是:
提交接口响应快;
判题任务可以排队;
判题服务和用户服务解耦;
高峰期可以削峰;
后续可以按语言、题目难度、优先级拆队列。
判题服务内部流程
判题核心在 JudgeServiceImpl,它负责:
调用 Docker 沙箱执行代码;
判断执行是否成功;
比对测试用例输出;
判断时间和内存限制;
保存提交记录。
核心流程可以概括为:
JudgeSubmitDTO
-> sandboxPoolService.exeJavaCode
-> SandBoxExecuteResult
-> resultCompare
-> assembleUserQuestionResultVO
-> saveUserSubmit
如果沙箱执行成功,进入输出比对:
String normalizedExpected = expectedOutput.replaceAll("\\s+", "");
String normalizedActual = actualOutput.replaceAll("\\s+", "");
if (normalizedActual.equals(normalizedExpected)) {
// 当前测试用例通过
} else {
allPassed = false;
}
这里对输出做了空白字符归一化,避免因为多余空格或换行导致误判。
然后判断资源限制:
if (sandBoxExecuteResult.getUseMemory() > judgeSubmitDTO.getSpaceLimit()) {
userQuestionResultVO.setExeMessage(CodeRunStatus.OUT_OF_MEMORY.getMsg());
return userQuestionResultVO;
}
if (sandBoxExecuteResult.getUseTime() > judgeSubmitDTO.getTimeLimit()) {
userQuestionResultVO.setExeMessage(CodeRunStatus.OUT_OF_TIME.getMsg());
return userQuestionResultVO;
}
最后保存提交记录。项目里按 userId + questionId + examId 维度保留最新提交记录:
同一用户
同一道题
同一场考试
保留最新一次提交
Docker 沙箱为什么是核心难点
OJ 和普通业务系统最大的区别是:它必须运行用户提交的代码。
用户代码可能包含:
while (true) {}
int[] arr = new int[Integer.MAX_VALUE];
Runtime.getRuntime().exec("...");
如果直接在宿主机执行,风险太高。
因此项目使用 Docker 沙箱:
镜像:openjdk:8-jdk-alpine
内存限制:约 256MB
CPU 限制:1 核
网络模式:none
执行超时:默认 5 秒
容器池:预热容器,通过 ArrayBlockingQueue 借还
Docker 沙箱负责隔离用户代码,Java 判题服务负责调度和结果比对。
容器池优化
如果每次提交都创建 Docker 容器,会有较大开销:create containerstart containercopy codeexec javacexec javaremove container
在高并发提交下,频繁创建和销毁容器会拖慢判题服务。
所以项目设计了 Docker 容器池:
服务启动时预热多个容器;
判题时从队列中借一个容器;
执行完成后清理工作目录;
正常容器归还;
超时或未知异常的容器丢弃并替换。
这个设计在安全和性能之间做了折中:既减少容器启动开销,又避免异常容器继续被复用。
Redis 在 OJ 中的作用
OJ 的高频查询场景很多:
题目列表;
题目详情;
考试信息;
用户提交记录;
消息通知;
排名或统计。
这些数据如果每次都查 MySQL,会给数据库造成压力。
项目中通过 Redis 缓存热点数据,典型模式是:
先查 Redis
-> 命中:直接返回
-> 未命中:查 MySQL / Elasticsearch
-> 回写 Redis
对于 OJ 来说,Redis 的价值不是炫技,而是减少高频读对数据库的冲击。
AI 增强为什么独立成 Python 服务
原 OJ 系统已经能完成确定性判题,但传统结果只告诉用户:
AC / WA / TLE / MLE / RE / CE
它不解释:
为什么错;
哪些 API 有风险;
复杂度是否可能有问题;
用户历史薄弱点是什么;
可以复习哪些知识点。
所以后续接入了 Python FastAPI + LangGraph AI 判题增强服务:
Java OJ:负责确定性判题
Python AI:负责分析、解释、RAG、Memory、安全风控
AI 服务不执行用户代码,也不替代最终判题。最终结果仍以 Java OJ + Docker 沙箱为准。
这个边界很重要:AI 是增强能力,不是裁判。
项目难点总结
- 微服务拆分
用户服务、管理服务、判题服务、公共模块拆开,判题逻辑不混在业务接口里。 - Docker 沙箱
用容器隔离用户代码,并限制内存、CPU、网络和执行时间。 - RabbitMQ 异步判题
提交接口快速返回,判题任务进入队列,避免业务线程长时间阻塞。 - Redis 缓存
缓存题目、考试、用户、消息等热点数据,降低数据库压力。 - AI 增强
用 LangGraph 多 Agent 做静态扫描、安全风控、RAG、用户记忆和 AI 点评,但不替代判题。
OJ 项目设计方案
我这个 OJ 项目对标 LeetCode,整体采用 Spring Boot 3 + Spring Cloud Alibaba 多模块架构。用户提交代码在 oj-friend 服务,真正执行代码在独立 oj-judge 服务,二者通过 Feign 或 RabbitMQ 解耦。同步模式适合调试,异步模式会把 JudgeSubmitDTO 投递到 RabbitMQ,前端先返回判题中,oj-judge 消费后调用 Docker 沙箱执行代码。
判题服务内部通过 SandboxPoolServiceImpl 从 Docker 容器池借容器,将用户代码复制到 openjdk 容器内编译执行,限制 256MB 内存、1 核 CPU、5 秒超时和 none 网络模式。执行后收集输出、耗时和内存,再由 JudgeServiceImpl 做多测试用例输出比对、TLE/MLE 判断和提交记录持久化。
后续我还增量接入了 Python FastAPI + LangGraph AI 判题增强服务,做静态扫描、安全风控、RAG 题解检索、用户记忆和 AI 点评。AI 不参与最终裁决,最终结果仍以 Java OJ + Docker 沙箱为准。
总结
这个 OJ 项目不是单纯题库 CRUD,而是一条完整工程链路:
提交代码
-> 网关鉴权
-> 用户服务构造判题请求
-> Feign / RabbitMQ 解耦
-> 判题服务消费任务
-> Docker 沙箱执行代码
-> 多测试用例比对
-> 时间 / 内存限制判断
-> 提交记录持久化
-> AI 增强解释
它能很好体现后端工程能力:微服务拆分、消息队列、Docker 沙箱、Redis 缓存、资源限制、异步处理和 AI Agent 工程化落地。
项目gitte源码:https://gitee.com/liu-jinhao-C/online-judge-30days
更多推荐



所有评论(0)