摘要

在线 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。这样判题服务可以独立扩容、独立限制资源,也不会把不可信代码执行逻辑混进用户业务服务。

一次代码提交的主链路

从用户视角看,一次提交就是“点提交,等结果”。但服务端链路会经过多个模块:

同步

异步

前端提交代码

oj-gateway 网关

oj-friend 用户服务

构造 JudgeSubmitDTO

提交模式

Feign 调用 oj-judge

RabbitMQ 投递判题任务

oj-judge JudgeConsumer

JudgeServiceImpl

SandboxPoolServiceImpl

Docker 容器编译运行

收集输出 / 耗时 / 内存

多测试用例比对

判断 AC / WA / TLE / MLE / CE / RE

保存 UserSubmit

前端查询结果

这条链路的关键是 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 container
start container
copy code
exec javac
exec java
remove 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 是增强能力,不是裁判。

项目难点总结

  1. 微服务拆分
    用户服务、管理服务、判题服务、公共模块拆开,判题逻辑不混在业务接口里。
  2. Docker 沙箱
    用容器隔离用户代码,并限制内存、CPU、网络和执行时间。
  3. RabbitMQ 异步判题
    提交接口快速返回,判题任务进入队列,避免业务线程长时间阻塞。
  4. Redis 缓存
    缓存题目、考试、用户、消息等热点数据,降低数据库压力。
  5. 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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐