ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

在线判题系统(OJ)从零实现:架构设计与沙箱隔离核心解析

在线判题系统(OJ)从零实现:架构设计与沙箱隔离核心解析 做OJOnline Judge在线判题系统这个项目最早是被学生逼出来的。当时带着一帮人备战校赛每人每天交代码让我肉眼判对错重复劳动不说关键是我一眼扫过去经常漏掉边界数据程序看着能跑一提交就崩。后来痛定思痛花了两周时间从零搭了一套OJ测试、判题、排名、补题全自动化整个人都解放了。这篇是这个系列的第一篇先把实现方案讲透——从整体架构、模块划分、技术选型到判题核心的沙箱隔离思路后续再一步步拆每个模块的落地细节。适合想自建OJ的在校学生、算法竞赛教练以及刚接触这个领域的后端开发者读完你至少能画出完整的系统全景图知道每个环节该做什么、避开哪些坑。1. 内容整体设计与思路拆解1.1 一个OJ到底在解决什么问题先说清楚OJ的本质它不是一个普通的CRUD系统而是一个自动裁判。用户提交一段代码系统需要把这段代码编译成可执行程序在一组预置的输入数据上运行它然后把输出和标准答案做比对给出AC通过、WA答案错误、TLE超时、MLE内存超限、RE运行时错误等结论。整个过程要快、要隔离、要稳因为一台服务器上可能同时跑着上百份用户代码任何一份出了差错都不能影响其他任务。这其实是把所有问题压缩到了三个核心矛盾上一是怎么让用户代码安全地运行沙箱二是怎么判定结果正确比对逻辑三是怎么调度大量判题任务队列与并发。这三块但凡有一个设计得不合理系统上线后就等着流量教做人了。1.2 整体架构选型为什么从单体起步我见过不少团队一上来就拆分微服务判题服务、用户服务、题目服务、评测队列服务各搞一套结果还没上线自己先被运维折磨疯了。对于OJ这类场景尤其是中等规模使用几千用户、几万提交量级单体应用完全够用而且部署、调试、备份都简单得多。我当时的方案是一个主服务处理Web请求一个独立的判题Worker进程跑沙箱判题通过消息队列把两边解耦。主服务负责用户管理、题库管理、提交记录、排名展示判题Worker负责编译、运行、比对。主服务和判题Worker可以部署在同一台机器上也可以分开部署因为中间有队列隔离水平扩展时只需要多挂几个Worker就行。这套架构的演进空间很舒服——等哪天真的需要拆微服务了只需要把主服务按业务边界切出来判题Worker本身已经独立了。2. 核心模块拆解与技术选型2.1 五大核心模块的职责边界一个可用的OJ最少由下面五个模块构成用户与权限模块。注册、登录、找回密码、用户角色管理员/普通用户。这块没什么花活但要注意密码存储务必用强哈希bcrypt或argon2别用MD5裸存我在渗透测试中见过的OJ漏洞相当比例是从弱口令和SQL注入开始的。题库管理模块。题目CRUD、数据组管理输入文件和输出文件、标签体系、题目标签与难度分级。这里有个容易忽略的点每道题至少要配两套数据——样例数据和测试数据。样例数据是题目里展示给用户的测试数据是判题时真正用来测的一定不能泄露。提交与判题模块。这是OJ的心脏。用户提交代码后系统要记录提交记录、将其推入判题队列、Worker取回并执行判题、更新提交状态和结果。整个流程要保证幂等即一个提交只能被正确判一次不能因为队列重试导致重复判题。代码沙箱模块。隔离用户代码的执行环境限制CPU时间、内存使用、输出大小、进程数量、文件操作权限。没有这一步你的服务器就是用户代码的后花园写个while(true) fork()就能把系统打崩。排行榜与统计模块。根据用户的AC题目数、总提交数、罚时等维度排名。这一块看似简单但能不能支撑上万人的实时排名刷新性能上要动点脑筋——我的做法是Redis缓存榜单热数据MySQL按周期落库。2.2 技术栈选型语言、判题沙箱与队列技术选型上不同团队会有不同偏好但总体要围绕判题性能、生态成熟度、团队熟悉度三个维度来权衡。下面是我测试过的组合供参考模块选型理由Web主服务Java Spring Boot / Go Gin生态成熟、并发处理能力强判题WorkerGo / C编译执行效率高Go跨平台部署方便沙箱Docker容器 Linux cgroups / seccomp或nsjailDocker隔离性够用cgroups精确控制资源队列RabbitMQ / Redis Stream小规模直接用Redis Stream就行少一个中间件数据库MySQL 8.x主库 Redis缓存提交记录量大Redis扛热读对象存储本地磁盘初期/ MinIO测试数据组文件存储我自己最终选了Go作为判题Worker的语言原因很简单Go对系统调用、进程管理和并发调度的支持很顺手编译用户代码时可以直接exec.Command(gcc, ...)接管进程写沙箱约束时也方便。Web服务用的Spring Boot因为之前团队最熟没必要为了新而新。这里要特别说明一下沙箱方案的选择。早期的OJ喜欢直接用沙箱库比如libsandbox、sandboxlib甚至有的直接裸用ulimit但对于现代OJ来说Docker容器方案是性价比最高的——每个判题任务动态起一个一次性容器镜像里预装好各语言的编译器和运行时通过--memory、--cpus等参数限制资源跑完之后容器直接销毁。隔离性和可复现性都好唯一要处理的问题是容器启动开销实测下来系统用Docker启动一个空容器大约需要100~200ms对于判题场景完全可以接受。3. 核心细节解析与判题状态机3.1 判题结果状态机从Pending到AC/WA/TLE判题结果的流转是系统的核心数据流我总结成一张状态机每一步都用数据库字段标记Pending排队中→Running判题中→Compiling编译中→CompileError|Running→Judging比对中→Accepted|WrongAnswer|TimeLimitExceeded|MemoryLimitExceeded|RuntimeError|OutputLimitExceeded设计状态机的意义在于运维人员能一眼看出判题卡在了哪个环节用户也能看到实时反馈——提交了怎么还没结果这种工单能减少一半。每个状态转换都要记录时间戳。我当时设计了一个submission_status_log表每次状态变更都追加一条记录方便后续做判题耗时分析和问题溯源这个设计在排查问题时帮了大忙。3.2 数据表设计没那么简单数据库层面除了常规的user和problem表最核心的是submission表。这张表的设计直接决定系统能扛多大访问量。我贴一下核心字段CREATE TABLE submission ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, problem_id bigint(20) NOT NULL COMMENT 题目ID, language varchar(20) NOT NULL COMMENT 代码语言 cpp/java/python, code longtext NOT NULL COMMENT 提交源码, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 判题状态: 0-pending 1-running 2-编译错误 3-AC 4-WA..., time_used int(11) DEFAULT NULL COMMENT 运行时间ms, memory_used int(11) DEFAULT NULL COMMENT 运行内存KB, compile_info text COMMENT 编译错误信息, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_problem (problem_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意几个容易忽略的细节code字段不能直接用text要用longtext因为用户可能有几千行的代码虽然正常比赛中不会出现但不能因为字段长度截断导致判题异常。查询submission表的时候尽量不要查code字段它太大会导致IO和网络开销剧增。查询列表页时只查列表进入详情页按id再查完整源码。idx_status索引一定要加因为状态过滤是最常见的查询条件。此外problem表需要额外存测试数据的存储路径和元信息比如数据组数量、时间限制、内存限制。时间限制和内存限制最好按语言做细微调整——同样是n10^5的题Python和C的耗时差异巨大如果限制一刀切要么C毫无挑战性要么Python永远过不了。我的做法是problem表存基准限制language_limits表存每种语言的系数C 1倍、Java 1.5倍、Python 2.5倍一个常规的折中方案够用且不复杂。4. 判题核心逻辑与沙箱实现4.1 判题流程全拆解一次完整判题从Worker拿到任务开始分这么几步第一步拉取题目数据组信息。通过题目ID读取测试数据的输入文件列表、输出文件列表、时间限制、内存限制。数据组通常有多组覆盖不同规模的数据——小数据、大数据、边界数据、极端数据。第二步创建沙箱。如果是Docker方案docker run一个预置好编译器的基础镜像挂载当前判题任务的工作目录。每个任务一个临时目录测试数据和工作文件放进去。第三步编译。根据提交的语言调用对应的编译器。比如C用g main.cpp -o main -O2 -static -stdc17Java用javac Main.javaPython不用编译但要做语法检查。编译阶段要打时间戳——很多新人在这容易踩坑编译超时和运行超时是两回事编译超时通常报编译错误不是判题超时。第四步逐组运行并比对输出。这是整个判题最耗时的环节。对每组输入数据启动用户程序传入输入文件捕获标准输出限制CPU时间和内存。一次运行完把输出文件和标准答案做比对。第五步汇总判题结果。多组数据按全对才算AC的原则汇总——只要有一组WA或TLE整体结果就是WA或TLE但记录时要保留第一个失败的数据编号方便调试。4.2 沙箱资源限制参数详解沙箱的隔离和资源限制是判题系统最核心、最考验功力的部分也是踩坑重灾区。以Docker cgroups为例几个关键限制的作用域如下限制项参数说明CPU时间--cpus1限制进程最多使用1个CPU核心的时间防止无限循环占满CPU内存--memory256m限制容器内存上限防止内存超分配输出大小--ulimit fsize64MB限制写文件大小防止程序疯狂输出撑爆磁盘进程数--pids-limit 64限制同时运行的进程数防止fork炸弹读写权限只读根文件系统 临时目录挂载防止用户代码篡改系统文件网络--network none最关键的一条禁止用户代码联网防止内网探测和外部数据交互系统调用seccomp profile禁用部分危险系统调用比如ptrace、mount、reboot我印象最深的一次事故就是忘了禁用网络有学生提交了一段Python代码在循环里不断请求外部API判题Worker连着跑了几十分钟把那道题的所有判题并发都拖垮了。加一行--network none之后这类问题彻底绝迹。这里还有个细节CPU时间限制和墙钟时间wall clock限制不一样。墙钟时间包括进程调度、IO等待等不能只看CPU时间。比如程序读了个超大文件CPU消耗很低但墙钟时间很长。我的做法是两者都限制比如CPU限制2秒墙钟限制4秒任何一个超了都算TLE。4.3 高手向使用seccomp做纵深防御光靠Docker的资源限制做隔离对于高安全要求的场景还不够因为容器内默认是共享宿主内核的。你需要做纵深防御Docker负责资源隔离seccomp负责系统调用过滤。看一眼seccomp的配置思路{ defaultAction: SCMP_ACT_ALLOW, architectures: [SCMP_ARCH_X86_64], syscalls: [ { names: [ptrace, mount, umount2, reboot, kexec_load], action: SCMP_ACT_ERRNO }, { names: [socket, connect, accept, bind, listen], action: SCMP_ACT_ERRNO } ] }上面这段的大致意思是默认放行所有系统调用但拦截ptrace调试器可能被用来读其他进程内存、mount挂载文件系统、socket/connect联网。这里面每一项都是我在实际中踩过或者研究过漏洞之后补上的——比如早期版本不禁ptrace有人跑了个侧信道探测好在发现得早。不过现在Docker原生就支持--security-opt seccompprofile.json把配置接入即可工作量不大。5. 实操过程从提交代码到返回AC5.1 提交判题的API流程设计下面这段是我实际在项目里落地的API交互流程把用户提交、队列转发、Worker判题、结果回写整个链路捋一遍。以用户提交C代码为例时序如下用户 POST /api/submission body: { problemId: 1024, language: cpp, code: ..., userId: 2024001 } 主服务: 1. 校验userId、problemId、language合法 2. 保存submission记录status0 (pending) 3. 把 { submissionId: 12345, problemId: 1024, language: cpp } 推入判题队列 4. 返回 { submissionId: 12345, status: pending } 判题Worker: 1. 监听判题队列消费到submissionId12345 2. 更新状态pending - running 3. 拉取题目信息、限制参数 4. 创建沙箱容器挂载测试数据 5. 编译用户代码超时则报编译错误并结束 6. 逐组运行测试数据比对输出 7. 汇总结果写回submission表更新status和耗时时长 8. 销毁容器释放资源 用户端: 轮询 GET /api/submission/{id} 直到 status 变为最终状态AC/WR/TLE...这个链路里最容易被忽视的是超时控制和失败重试。用户程序有可能陷入死循环Worker必须在墙钟超时后强杀进程如果Worker本身崩了队列里的消息不能被吞掉要设计重试机制。我当时用RabbitMQ手动ack模式只有在Worker完整处理完一个任务后才ack否则RabbitMQ会重新投递该消息避免任务丢失。5.2 判题Worker并发模型的现实选择并发模型上我用的不是传统的一个Worker一次判一个题而是一个Worker内部用goroutine池并发4~8个判题任务。因为判题过程中CPU主要耗在编译和用户程序运行上而文件IO和容器调度有大量等待时间单任务跑会浪费IO带宽并发稍微开大一点吞吐量就能翻倍。当然并发数的设置得谨慎。如果你用Docker沙箱一台2核4G的服务器上同时跑8个判题任务平均每个任务分到的CPU就会打折导致原本能通过的程序因为资源竞争被判TLE。我尝试过的合理值是2核机器并发4个判题任务4核机器并发8个。这个比例实测下来资源利用率和判题公平性比较平衡。5.3 比对逻辑特殊判题SPJ与精度问题常规判题就是字符串精确比对。把用户程序的输出和标准答案逐字对比完全一致才算AC。但真实场景没这么简单——如果题目要求输出浮点数标准的3.14和用户的3.140000结果不同但是数学上明明正确。再比如题目要求输出任意一种可行方案而有多种正确答案精确比对就会误判。所以需要一个**特殊判题Special Judge简称SPJ**机制。对这类题目你不用在数据组里存标准答案文件而是存一个校验器程序——它接收用户的输出文件自行判断答案是否正确。做法是在判题Worker里专门预留一个是否启用SPJ的开关{ problemId: 1024, outputType: special, spjExecutable: /data/problem/1024/spj_judge, runCommand: /usr/bin/time -v ./main }当outputTypespecial时Worker不执行逐字比对而运行SPJ程序对用户输出进行判断。浮点数题目内部会设置一个允许误差如abs(a-b) 1e-6方案题会检查输出是否满足所有约束条件。我之前有个血泪教训早期写SPJ时校验器本身的时间限制忘设了某个校验器遇到异常数据陷入了死循环结果判题Worker跟着卡死整队列堵了十来分钟。后来一律对SPJ程序套用和用户程序相同的沙箱限制问题才根治。6. 安全防护与反作弊实践6.1 九类典型攻击方式与对策OJ是公网服务天然暴露在各类攻击之下。我按照自己实际遇到的攻击方式整理了下面这张防护清单攻击方式具体表现对策恶意代码提交fork炸弹、死循环、内存耗尽沙箱资源限制 seccomp 禁网络代码注入在代码里写SQL注入、命令注入编译参数禁用危险库如-D_FORTIFY_SOURCE2Web攻击SQL注入、XSS、CSRF参数化查询、上下文转义、CSRF Token数据组泄露通过某接口下载测试数据数据组文件不设公网访问路径判题Worker走内部挂载提交记录偷看越权访问他人提交代码提交详情接口校验user_id为本人或管理员刷榜用小号提交、互抄答案排名按最高AC罚时封禁违规账号恶意编译参数通过语言/编译选项执行任意文件语言选项白名单校验禁止自定义编译参数穷举测试数据程序根据输入试探输出模式数据组加密hash校验SPJ动态限制重判次数拒绝服务批量提交/并发刷接口限流用户维度提交频率限制、IP维度限流其中最容易忽略的是限流。很多OJ刚上线时没做限流被脚本拿着几千个账号批量提交队列直接塞满正常用户体验瞬间变卡。我的做法是Redis里对每个用户设置滑动窗口比如1分钟最多提交10次超过则返回429 Too Many Requests并且对当前提交失败率高的用户降低配额——连续WA说明在试答案没必要让他刷那么快。6.2 反作弊相似代码检测的实用方案反作弊是竞赛OJ回避不了的需求。最实用的方案是MOSSMeasure of Software Similarity指纹检测的简化版对提交代码做词法规范化——去掉空格、换行、注释把变量名替换成固定占位符然后计算哈希。同一道题里哈希相同或高度相似的代码基本可以判断为抄袭。更精细的做法是计算两个字符串的最长公共子序列相似度超过阈值比如95%就标记可疑。不过这算法对改变了变量名但逻辑相同的抄袭会失效需要结合AST哈希或控制流分析更稳妥。我的建议是MVP阶段先用规范化哈希上线后用真实提交数据训练相似度阈值再迭代高级方案。反作弊不是一锤子买卖它是个持续对抗的过程。7. 常见问题与排查技巧实录7.1 判题TLE误判不是用户程序慢是沙箱资源没分够出现过好几次这样的问题用户程序在自己电脑上跑飞快提交到OJ被判TLE。一开始以为是算法复杂度问题后来发现沙箱里的CPU配额只有0.5核而本机是8核CPU单线程还好一旦程序开了多线程资源分配不均就会出现大幅波动。排查思路很直接先看这台机器上同时跑着多少个判题任务再看docker stats里容器的CPU和内存占用量。如果CPU占用率接近100%说明Worker并发配得太高需要降低并发数或给容器分配更多CPU。还有一次更隐蔽的用户在代码里用了clock()函数计时而clock()在Linux上统计的是CPU时间不是墙钟时间如果程序被调度延迟可能clock()没超时而实际执行时间超了。这类问题只能靠判题日志中的墙上时间来做二次判断不能只依赖CPU时间。7.2 格式化输出陷阱正常stdout换成文件这是新手OJ非常常见的问题用户代码明明输出正常判题却报WA。排查发现用户代码里用的是文件输出——fopen(out.txt, w)而不是标准输出stdout。判题系统捕获的是标准输出你往文件里写它自然比对不到。这个问题本质上是用户使用不当但OJ可以在题目描述里加输出到标准输出的醒目标注也可以在编译参数中强制开启警告比如GCC的-Wall配合提示。我甚至见过用#define fwrite这类宏把文件操作重定向到stdout的骚操作但这是万不得已的下策不推荐在正式OJ中做破坏用户代码稳定性。7.3 代码提交中文乱码与编码问题有段时间中文用户提交的代码里带中文注释结果显示乱码甚至编译错误。排查后发现是字符集问题用户代码保存时是GBK编码而判题服务器默认按UTF-8读取。解决方案提交接口在保存代码时统一转为UTF-8并且判题容器环境统一设置LANGC.UTF-8。同时要在前端做编码检测发现非法UTF-8字符串时提示用户重新提交或者自动转码。7.4 一个排查实战判题队列堆积有一次线上判题队列突然从0堆积到2000用户提交全部pending持续了十几分钟。排查顺序是这样的先看队列消费端有没有报错。日志显示Worker进程还在跑但处理速度非常慢。看系统负载。发现某台机器的load average飙到20但Worker的CPU占用正常。查内存。发现有一个容器的内存使用飙升到了近2GB导致整个宿主机swap开始疯狂换页。定位到是某个用户的Python代码开了大数组内存超限但没被及时杀掉容器一直处于半死状态持续消耗磁盘IO。这个案例说明两点一是判题队列和沙箱的监控必须配套不能只知道队列长度不知道沙箱里的异常容器二是内存超限的容器要立刻杀不能让其整到swap死循环的程度。后来我加了一个兜底逻辑判题Worker启动时先探测沙箱可用内存若低于阈值则暂时不再拉取新任务保护现有队列。7.5 常见问题速查表现象可能原因快速解法提交一直pending队列堆积/Worker挂了检查队列消费者、重启Worker、查看Redis队列长度编译时间特别长容器冷启动/资源不足预热镜像、加大资源配额同一代码提交两次结果不同并发资源竞争检查Worker并发数与CPU配额降低并发AC代码被判WA输出比对、浮点精度、SPJ没启用查输出格式、启用SPJ用户代码能访问公网沙箱没加--network none全局启用网络黑名单判题机器被拖慢恶意代码没限好类别增加process limit、检查cgroups CPU限制凡是出现异常行为但查不出代码问题时我的排查路径永远是沙箱限制-编译参数-数据组文件-比对逻辑。从外到内逐层缩小范围效率最高。8. 实测感悟与后续扩展方向整个OJ从方案落地到稳定运行我最大的体会是判题系统真正的核心竞争力不在Web功能而在沙箱和判题引擎的健壮性。我见过不少花了大工夫做界面、做排名的OJ却在沙箱隔离上偷了个懒结果一场校赛几百人同时提交服务器当场被代码打崩。OJ的门槛永远是能不能稳如老狗地自动判题界面再好看都是加分项不是根基。再分享一个后续很想做的扩展把判题引擎抽出来做成评测机集群支持多台机器通过队列横向扩容。现在单机的方案大概能扛每秒10~20次提交对绝大多数训练场景够用了。但如果以后要办线上邀请赛突然涌进来几千人同时交题单机跑不下来就需要上多Worker 结果汇总的集群方案——这个系列后面有时间再写。另外想对打算自建OJ的朋友说一句如果只是个人训练直接用现成的开源方案有的大学OJ就是直接搭的开源系统改改皮就上线的完全足够了真正值得自己动手的地方是判题沙箱和特殊判题这块那才是能做深、做出技术含量的突破口。
返回列表