☰
OJ在线判题系统升级实战:从沙箱隔离到C++智能指针评测支持
2026/9/28 14:03:55 网站建设 项目流程

前阵子把学院的OJ平台升级到了3.3版本。这个版本号看起来平平无奇,却是我们团队花了三个星期才搞定的大工程。如果你在学校里维护过在线判题系统,或者只是天天在OJ上刷题被WA气到摔键盘,这篇东西都能给你一些参考。下面我把这次3.3 OJ的升级过程、评测原理、使用方法和踩坑记录一次说完。

1. 为什么要把OJ升级到3.3:在线判题系统的真实痛点

1.1 OJ不是“刷题工具”那么简单

在很多同学眼里,OJ就是“Online Judge”,一个给代码打分的地方。点开题目、写代码、提交、看结果,最多再刷个绿黄红。但如果你站在维护者的角度看,OJ其实是一个极其敏感的在线评测系统。它要在不到一秒的时间里把你的代码发到沙箱里,用测试数据去跑,然后把结果返回给你。这个过程涉及编译器调用、系统调用过滤、资源限制、并发队列、数据校验、结果判定,任何一个环节出问题都会变成“服务器又崩了”。

国内各高校的OJ平台,像江南OJ、郑州轻工业大学OJ、湘潭大学OJ、杭师大OJ,其实都是类似的架构。只不过有的用开源系统改的,有的从零写的。我们学院这个OJ最初也是搭在开源框架上的,用了两年之后,各种需求开始冒出来:判题速度太慢、比赛时经常排队、用户代码里那种恶意递归把宿主机搞挂过一次、C++题目里关于智能指针的用法判定不够准确。这些问题凑到一起,才催生了3.3 OJ。

1.2 3.3版本重点解决的三件事

这次升级,我没有贪多,只定了三个目标。

第一是判题稳定性。以前的判题服务用的是一个简单的多进程模型,每个评测任务起一个子进程,但资源限制经常失效。有一次某个同学提交了一段while(1);的代码,直接把整个评测节点CPU吃满,其他题目全部等待。3.3版本里我把核心评测迁移到了Linux沙箱(容器)里,用cgroup做内存和CPU限制,再加上系统调用过滤,至少从根上把“代码拖垮服务器”这条路堵死了。

第二是测评规则的可配置性。以前一道题只能做“全对全错”,没法支持部分分数。3.3加入了OI模式的子任务评测,一个测试点可以拆成多个子任务,每组数据独立判分,最后汇总。这样像“输出比标准答案多了一个空格”这种就不至于直接0分,按子任务比例扣分就行。

第三是C++智能指针相关题目的判定支持。热词里有个“西北农林科技大学C++ OJ智能指针”,听起来像是个具体的课程作业,其实背后是一个很普遍的需求:越来越多的数据结构课开始考unique_ptr、shared_ptr。以前OJ的编译参数不带-std=c++11,一用智能指针就编译失败;就算编译过了,我们也很难检测有没有内存泄漏。3.3版本把所有C++题目的编译标准升到C++17,同时在评测环境里加入了AddressSanitizer,能在运行阶段抓到明显的堆内存问题,这对教《数据结构与算法》的老师来说简直是救命。

1.3 和其它OJ平台相比,3.3有什么不同

国内OJ其实很多,大家熟知的江南OJ、华为OJ、郑州轻工业大学OJ、湘潭大学OJ、杭师大OJ,还有东方博宜OJ这类刷题站,各有各的定位。江南OJ和杭师大OJ偏向教学,题目跟课程强绑定;华为OJ更贴近企业机考,题目用来筛选工程师;东方博宜OJ更像题库站,大量收录了教材答案(比如“东方博宜OJ答案1065”这种搜索词特别多)。我们的3.3 OJ则偏“轻量级教学+比赛二合一”的定位,没必要做大而全,但要把教学场景里最痛的那几个问题解决掉。

平台类型代表平台典型用途3.3 OJ的侧重点
教学型江南OJ、杭师大OJ课程作业、期中期末机试课堂题目管理、多模式判分
竞赛型湘潭大学OJACM训练支持多评测节点、Special Judge
企业机考型华为OJ招聘筛选稳定判题、防作弊了一套
题库型东方博宜OJ课后刷题精简题库、成绩导出方便

说白了,3.3 OJ不是要跟这些平台比题目数量,而是把“课堂考试”这件事做顺。老师不想自己写评测脚本,学生不想提交半天没反馈,这两个核心诉求满足了,平台的价值就出来了。

2. 3.3 OJ的评测原理与核心设计

2.1 沙箱是怎么把危险代码关进笼子的

看到这里你可能会问:为什么不能让评测进程直接在服务器上跑?因为用户代码是不可信的。OJ上难免有人故意提交系统调用代码,比如fork()炸弹、rm -rf,甚至尝试读取/etc/passwd。如果评测进程没有隔离,整个服务器都可能被搞坏。

3.3 OJ的沙箱设计其实没那么玄乎,主要做三层事情:

  • 进程隔离:每个评测任务跑在一个独立的Linux容器里,容器之间互不可见,容器内只有必需的运行库,没有网络权限。这样即使代码真的执行了shutdown,也只是把自己那个容器关掉。
  • 系统调用过滤:用seccomp机制白名单放行代码运行时可能用到的系统调用。比如普通的计算题只需要read、write、mmap、brk这类,如果出现socket、clone、execve就立刻终止。
  • 资源限制:通过cgroup限制CPU时间、内存大小、进程数、文件体积。我一般设CPU 1秒、内存256MB、进程数4、输出文件1MB。一旦超限,评测程序直接给TLE或MLE,不会让服务器跟着遭殃。

这套机制也叫“评测沙箱”,很多在线判题系统都在用。3.3 OJ只是把之前“看起来有沙箱,其实只是用setrlimit简单限制”的半吊子方案换成了真正容器化的方案。实测下来,一个评测节点稳定跑学校期中考试的200人并发,没有出现一次“节点被打挂”的情况。

2.2 判分逻辑:不只是“对和错”

OJ最核心的是判分。很多人以为判分就是拿你的输出跟标准输出diff一下,全一样就AC,不一样就WA。其实3.3 OJ里“判分”被分成了好几种模式。

  • ACM模式:这是最传统的。一组测试数据,只有全部通过才算AC,否则按错误类型返回WA/TLE/MLE/RE。适合程序设计竞赛题。
  • OI模式:适合多次提交取最高分、部分得分。3.3支持把一组数据拆成多个子任务,每个子任务有自己的分值,全对得满分,部分对按百分比给分。比如输出格式差一个空格,很多老师希望只扣那一个小点而不是整个0分,就可以用这个模式。
  • Special Judge:输出不唯一,比如“任意一组合法答案即可”的构造题、或者精度误差允许在1e-6内的浮点题。普通diff没法判,得写一个特判程序。3.3 OJ把特判程序的接口做成了“和一个普通评测函数一样”,老师只需要写一个函数接收输入、标准答案、选手输出,返回一个分数就行。

这里面最容易被忽略的是“输出格式”的判定。Linux下文件结尾一般没有多余换行,但有的代码在最后多打了一个\n。3.3的默认比较器会忽略行尾空白差异,但对“两个答案之间必须有空格”这种严格的地方又能配置。我建议出题老师在题目说明里写清楚“行末无空格,文末有换行”之类的细节,能少很多争议。

2.3 为什么C++智能指针能成为OJ新考点

再说回“智能指针”。以前在OJ上写C++题,大家习惯用裸指针new/delete。但现在的课程都开始讲内存安全了,特别是一些高校的C++期中考试里,会让学生用unique_ptr管理一个动态分配的二叉树节点,或者用shared_ptr处理图结构。OJ要是编译环境太旧,这些代码直接编译报错,学生就会在讨论区发帖骂平台。

3.3 OJ做了一件很小但很重要的事:把所有C++代码都按C++17标准编译,同时把-Wall -Wextra打开,但不把警告当错误。这样学生既能用上现代C++的auto、lambda、智能指针,又不会因为局部for(int i=0; i<n; i++)这种传统写法被警告干扰。

更关键的是,3.3在评测容器的环境变量里加了一个地址消毒器(AddressSanitizer)的动态库,只在提交代码里出现明显堆内存问题时才生效。比如delete了同一个指针两次、访问越界,就能在运行阶段报出具体出错行号。很多同学第一次看到沙箱里打印出的heap-use-after-free时是懵的,但正是这种错误信息帮他们理解了内存生命周期。

说句实话,第三方OJ平台很少会专门为“智能指针”加判定支持。我们学院这个3.3版本之所以做了,是因为几位教C++的老师联名提的需求。如果你的OJ也想支持,建议至少把编译器换成GCC 8以上,并且确认沙箱里装了对应的运行时库。

3. 学生、老师、管理员:3.3 OJ的三类实操路径

3.1 学生端:5分钟上手刷题

对于大多数用户来说,3.3 OJ看起来和老的OJ没什么两样:登录、进题库、选题目、写代码、提交。但如果你第一次用这种系统,有几个细节值得注意。

  • 注册号:很多学校OJ会跟教务系统绑定,用学号直接登录。不要自己乱注册一个英文ID,不然老师后台导成绩的时候找不到你。
  • 语言选择:3.3支持C、C++(C++17)、Java、Python。我建议做算法题时优先用C++,因为判题环境对C++的优化最好,Python在极端数据下容易超时。
  • 提交前本地多测几组数据:OJ不提供这么多测试数据,但你可以自己构造边界。比如题目说“n在1到10000之间”,你至少要试一下n=1和n=10000的情况。
  • 看到WA别急着重新提交:先把代码里所有printf/cout调试输出删掉,很多人WA是因为把调试语句和正式输出混在了一起。

3.3 OJ的“提交记录”页面比老版本多了两个功能:一个是可以查看每次提交的编译参数,另一个是可以下载评测时用的标准输入样例(部分题目开放)。这对自测来说很有用。如果你在学校机房里用,建议把编辑器换掉,别再用系统自带的notepad了,Visual Studio Code或Dev-C++都行,关键是能看清楚空格和缩进。

3.2 老师端:创建一道题目的完整流程

我这一年里帮老师出了大概30道题,算是把“出题”这件事摸透了。在3.3 OJ上,老师创建一道题目的流程大致是这样:

  1. 准备好题目描述、输入输出样例、测试数据。测试数据分为输入文件(.in)和答案文件(.out),命名建议1.in、1.out这样一一对应。
  2. 在后台创建题目,填写标题、时间限制、内存限制、题目类型、难度标签。
  3. 上传测试数据,系统会自动统计每个测试点的大小和运行耗时。这里有个小坑:答案文件里不能有Windows行尾符(\r\n),最好在Linux环境下用dos2unix转一下。
  4. 如果题目答案不唯一,就选择Special Judge,并且把特判程序写成一个独立的C++函数上传。
  5. 设置可见性。3.3支持“隐藏题目”模式,考试时可以只让指定班级看到,防止提前泄题。

出题最花时间的是“踩边界”。我曾经出过一道排序题,自己觉得数据没问题,结果学生用快排会在某种特殊序列上栈溢出。后来我吸取教训:每道题至少要写一个暴力解法跑一遍测试数据,确保标准答案本身不是错的。3.3 OJ在后台上传数据时也会自动跑一个“标准程序”做验证,如果发现某个测试点标准程序都无法通过,会直接提示上传失败。这个功能帮我们挡掉了很多次线下考试翻车。

3.3 管理员端:从3.2升级到3.3的部署笔记

如果你要自己折腾OJ,可以看看这部分。这次我们是从一个3.2老版本升上来的,部署过程大致分五步:

  • 备份数据库和评测数据目录。用mysqldump把题目的所有内容导出来,评测数据目录直接打包。这一步一定不能省,我们升级时差一点把一道带Special Judge的题丢了。
  • 停掉老评测队列。用systemctl stop oj-judge之类的命令把正在跑的评测服务停下来,避免旧版还在写数据库。
  • 拉新代码,更新依赖。3.3的服务器端需要Node.js 16+、Redis 6+,数据库结构有变更,所以需要跑一下migrate脚本。
  • 重新构建评测沙箱镜像。这是最容易出错的一步。新版本的沙箱里多了AddressSanitizer的编译支持,基础镜像需要重新打。我们用的Dockerfile里,编译器从GCC 9升到GCC 11,并且加了libasan的包。
  • 上线后用“冒烟测试”验证核心功能:提交一道打印Hello的题,再提交一道死循环的题,确认前者AC、后者TLE,然后逐步开放用户队列。

整个升级过程我们花了一个周末,真正执行命令大概不到两小时,剩下的时间全在调沙箱镜像和权限。如果你们团队没有专门的运维,我建议多留出半天到一天的时间测试。

4. 3.3 OJ常见问题与排查实录

4.1 提交后卡在“等待中”

这是OJ最常见的问题。3.3版本里,如果提交后一直停在“等待中”超过十秒,原因通常是下面几个:

  • 评测队列塞满。检查Redis里队列的长度,或者登录管理后台看活跃评测任务数。如果很多任务卡住,可能是某个评测节点挂了。
  • 沙箱容器起不来。Docker镜像损坏或磁盘满了都会导致容器启动失败,这时候后台日志会报failed to create shim task。解决办法是重启Docker服务,清理/var/lib/docker里堆积的无用镜像。
  • 权限问题。评测进程没有权限执行编译器或写入临时目录。3.3里我遇到过/tmp被挂载成只读的情况,折腾好一阵才发现。

排查思路很简单:先去管理后台看评测任务状态,再去日志里搜task id,最后看沙箱容器是否正常。别一上来就重启服务器,那是下策。

4.2 超时和内存超限的定位技巧

很多同学在OJ上最崩溃的就是“明明本地跑得飞快,提交上去就TLE”。3.3 OJ判题的CPU时间限制比真实机器的耗时要严格,因为评测节点往往同时跑很多任务,而且CPU频率可能会被限制。所以本地1秒,线上可能只有0.3秒。

定位TLE,我一般教学生用三板斧:第一,不要开O2优化就盲目自信,把循环里所有能提公因式的运算提到外面;第二,把endl换成'\n',因为endl每次刷新缓冲区;第三,如果用了STL容器,考虑是不是频繁构造析构导致时间爆炸。还是不行的话,用二分答案或者调整算法复杂度,别硬刚。

MLE稍微好查一点。3.3的评测结果里会给出实际使用的内存峰值。如果超限,优先检查是不是在循环里反复new却没有释放。以前很多学生写链表题直接new一个节点不delete,100万次循环下来内存就爆了。现在用智能指针可以省点事,但也要注意shared_ptr的循环引用问题。

4.3 智能指针与内存泄漏的经典坑

既然3.3 OJ加入了AddressSanitizer,那这块就多说一点。我在后台见过几千份学生提交,其中关于智能指针的典型错误大概有三类。

  • 用shared_ptr管理栈上的对象。比如std::shared_ptr<int> p(&a);,离开作用域时,智能指针会对栈地址执行delete,直接把栈破坏掉。正确做法是不要这样写,或者用自定义删除器。
  • 循环引用。两个对象互持shared_ptr,会导致引用计数永远不为0,内存泄漏。解决方法是把其中一个改成weak_ptr。
  • 用unique_ptr但没有std::move就尝试拷贝。编译器会报错说你调用了删除的函数。这种报错虽然麻烦,但总比运行时崩溃好。

AddressSanitizer能抓到的通常是第一种和裸指针的越界访问,它会在评测结果里额外打印出出错行号。很多学生第一次看到那一大段报错时会慌,其实只要去找#0那一行就明白了。

4.4 比赛高峰期的性能优化

3.3 OJ上线之后,第一次大考就是期中机试,300人同时在线提交。我们原以为评测节点能扛住,结果开考二十分钟后队列堵了。后来查下来,问题不在判题,而在数据库连接池不够用。每个提交都在写库,连接池默认10个根本不够。

解决方案有三个:把Redis的队列改成优先级队列,高峰时优先判短题;数据库连接池从10调到50;评测节点从1个临时扩到3个。从那以后,再也没有出现“提交长时间等待”的情况。如果你也要办线上比赛,建议提前压测一下,至少用脚本模拟100人同时提交,观察后端CPU、内存和数据库QPS三个指标。

再补充一个小技巧:把“编译”和“运行”分成两个阶段。编译阶段消耗CPU高但时间短,运行阶段消耗内存多。3.3的调度器会根据资源类型动态分配,比如编译密集的任务轮流放到不同节点,运行密集的任务则考虑节点剩余内存。这样整体吞吐量比单队列高了不少。

4.5 部署时的几个高频坑

部署时还有几个高频坑,如果你是自己维护OJ,一定会遇到。第一个是时区问题,默认镜像的UTC时间会导致评测记录显示和本地差8小时,记得在启动容器时挂载/etc/localtime。第二个是文件编码问题,上传的数据和程序如果是从Windows编辑的,很容易带上BOM头,C++编译器会直接报错,用sed -i 's/\r$//'清一下。第三个是磁盘空间,评测容器的日志如果不做切割,一个月能把根分区写满,我建议用logrotate定期处理。

5. 踩过坑之后的一些个人体会

这次3.3 OJ的升级,我最大的体会是:永远要对用户代码抱有“恶意”。哪怕是一道打印“Hello World”的题,也会有人写出system("reboot")。你做得再完善,用户总能找到新的花样。所以沙箱和权限设计一定不能妥协。

另一个体会是,对OJ来说,“好用”比“强大”更重要。3.3版本一开始加了很多花哨功能,比如代码高亮主题、排行榜皮肤,结果学生根本不关心。真正让学生感激的是:编译报错时能看到合理的错误行号,TLE时能直接看到运行时间,WA时能知道自己错在哪组数据上。这些朴素的反馈才是一个OJ存在的意义。

如果你正在维护自己的OJ,或者刚开始刷题,希望这篇东西能给你一点参考。3.3 OJ只是一个普通版本,但背后那些关于评测、沙箱和用户心理的坑,却一点也不普通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询