
每年的3月15日前后我总会翻出那个被我命名为“3.15 OJ”的在线评测系统项目。这个名字并不是为了蹭消费者权益日的热度纯粹是因为项目在3月15日那天正式启动同事们懒得另起代号就一直“3.15”“3.15”地叫了下来。结果后来每次提到它都得额外解释一句“和打假没关系它是个判题平台”。但就是这个一度被当作课程作业的项目让我在测评系统设计、系统资源管控、部署测算这些事上踩出了不少真经验。如果你正准备给课程、实验课或者团队内部搭建一套类似判题测评系统本文会把整个项目的拆解过程、技术选型思路、评测沙箱的核心逻辑以及上线后那些容易被忽略的坑完整讲一遍。内容不局限于架构图更多是“当时为什么要这样选”“换了方案结果会怎样”的实操复盘。1. “3.15 OJ”立项时确定的定位不是刷题平台是教学辅助工具很多人一听到在线判题系统下意识想到的是竞赛刷题站、排行榜、每日一题这类玩法和界面。当初团队内部讨论时一开始也有声音说“做成一个对外开放的平台用户越多越好”。做了一段时间需求梳理后我们把这个定位推翻了。1.1 真实需求点出现在作业批改环节项目发起的原因是负责实验课的一位老师提了一个很具体的糟心事每学期收上来的编程作业不少是互相复制改个变量名就提交的手工批改只能靠肉眼比对面对一百多份代码效率很低。他想知道有没有办法让学生在提交代码时“当场得到反馈”同时又保留一份客观的评分记录。所以“3.15 OJ”的第一目标不是变得多好玩而是学生提交C/C、Python、Java代码后系统自动编译运行用预设的测试用例比对程序输出反馈“通过”或“不通过”不通过时能暴露是编译错、超时、运行时错误还是答案是错的教师端能看到每位学生的提交次数、用时、最高分对作业代码做基础相似度提醒重复率过高的打上标记供老师参考不自动判抄袭。把边界定清楚之后很多技术选型就容易做了。例如排行榜直接砍掉不提供排名功能因为教学场景下排名会给部分学生带来无意义的压力。比赛模式同样不做系统不要求支持实时封榜、滚榜这类功能。1.2 自建而非直接用现成开源项目的理由调研时我们确实看过不少在线评测系统的开源方案有些功能相当完整部署也不复杂。但最终选择自己搭一套精简版主要是因为这几点第一教学环境的评测逻辑和竞赛平台不完全一样。竞赛评测必须毫秒级并发、判题机集群分工而教学场景的峰值有限晚上交作业那段时间可能集中一点但整体量级不大没必要上整套分布式架构。第二课程要求评测到比较细的层次。比如老师希望记录“编译警告数”希望某个题目的测试点按权重给分希望同一道题可以设多个数据文件组。标准竞赛评测器通常只会给一个最终结果自定义扩展要改的代码量并不少。第三整个系统部署在某个高校实验室的服务器上硬件资源有限。我们最终方案是在一台普通服务器上完成了所有模块部署包括数据库、判题服务、网站服务。这对技术栈的轻量性有较高要求。实际上这套精简版后来不仅承担了每周作业评测还支撑了两届校内小型编程赛的判题工作一台普通服务器建议配置在16 GB内存、4核心CPU以上时大约可以满足80人同时在线提交的负载这是我实测的一个粗略参考。2. 判题核心模块的设计编译、运行、资源限制三件事在线评测系统的主流构成不复杂从业务上说就是“用户写代码—后台保存—判题机编译运行—比对输出—把结果写回”。真正决定系统可不可靠的是中间判题模块到底怎么处理编译与运行环节。2.1 进程隔离和资源限制用普通权限进程加检测而不是一上来就套容器看很多关于判题系统的教程第一反应就是用容器做隔离。我最初也试过这个思路部署了一套带基础镜像的容器方案每个提交动态起一个容器跑用户代码。但很快就遇到一个问题服务器内存和镜像管理成本超出了课程项目的预算而且容器创建和销毁的耗时会占据判题总时长的相当比例。后来选了更直接的方案使用多语言编译运行工具类库配合操作系统权限管理。核心是在一个受限用户下运行编译和测试程序对该用户做系统资源限制配合按进程实时监测数据。这个方案用在一台服务器上是足够稳妥的。操作系统层面的资源限制包括CPU时间限制按题目设为1秒或2秒防止死循环占满CPU内存限制按题目设为128 MB或256 MB超出直接判为MLE超出内存限制输出文件大小限制限制写入到标准输出的字节数防止程序狂刷数据把磁盘写满进程数限制限制派生进程的数量超时强杀机制到时间后自动终止整个进程树。举个实际判题例子一道排序题设置CPU时间2秒、内存128 MB某学生的程序写成了死循环那个评测实例会在2秒后被强杀并返回超出时间限制系统同时记录实际耗时约2000毫秒。整个评测过程不会影响服务器上的其他服务。对于容器方案不是不能用而是要看场景。如果目标是一次性承载高并发比赛用容器隔离更好如果只是课程作业提交普通进程隔离加上操作系统资源限制工具就能达到目的维护成本则低得多。2.2 特判程序Special Judge的需求判断“3.15 OJ”的评测不只是一刀切比对输出文件。教学中有一道题要求输出结果“误差不超过1e-6就算对”如果只用严格比对学生写得再对也过不了。所以系统需要支持特殊判题模式当题目配置为特判时判题机不再直接比对标准答案文件而是调用一个独立的校验程序读取学生输出和标准输出按照题目约束判断是否可接受。这个特判程序本质上就是一段独立代码由出题老师写好放在特定目录下判题机在评测时自动调用而不是单独人工跑一遍。设计时给特判程序的参数包括输入文件路径、学生输出文件路径、标准输出文件路径、输出文件路径。特判逻辑举个例子数值计算题的校验程序会读取学生输出中的double值计算与标准答案的绝对差或相对差误差在1e-6以内判定通过超出则返回不通过。由于每个题目的判定规则不同校验程序必须单独编写这也是出题环节老师最需要花时间的部分。2.3 多种语言的编译参数系统支持的语言主要有C、C、Python、Java。不同语言的编译和执行逻辑不太一样整体的处理方式是C使用GCC编译参数指定标准为gnu11开启Wall选项收集编译警告C使用G编译标准指定为gnu17同样开启WallPython不进行编译环节直接运行时用受限解释器执行并设置运行目录为临时副本避免脚本读到源码文件Java使用javac编译运行时设置最大堆内存然后执行主类。对编译警告系统并不是直接判错而是在评测结果里给一条提示“编译通过但存在警告信息建议检查”。教学场景下警告可能是未使用变量、类型不匹配等这些通常是代码质量问题的前兆提示比判错更能引导学生改进。3. 提交到出分的完整链路异步判题才是应对峰值的关键刚开始做原型时提交和判题是同步的用户提交代码后页面一直转圈等待判题机编译运行返回结果。在并发量很低时没问题但一旦多人同时提交页面会非常卡顿。后来把整条链路改成了异步轮询模型才算真正顺畅。3.1 数据库表结构与状态机设计系统涉及的数据表主要有用户表、题目表、提交表、测试点结果表。提交表是核心字段除了用户ID、题目ID、代码路径、语言类型还有一个状态字段。状态机的流转如下等待评测Pending提交已入库但判题进程还没取走评测中Judging判题进程正在处理各种终态编译通过、答案正确、答案错误、运行时错误、超出时间限制、超出内存限制、系统错误。测试点结果表用于记录单题多测试点的细分情况例如一道题有10个测试点其中7个通过、3个失败最终结果的确定逻辑是先判断状态再计算通过比例。如果所有测试点通过则判定为答案正确否则按通过数/总测试点数给出百分比分数。设计里还把代码文件路径和评测日志路径分开存避免文件过多时单目录性能下降。3.2 轮询与队列的选择异步判题最简单的实现方式是数据库轮询判题进程每隔几秒查一次提交表把等待评测的记录取出来处理。这个方案的好处是代码量少不依赖额外组件对于每秒个位数提交量的系统绰绰有余。不过我还是在项目里加了一层内存队列作为缓冲Web服务收到提交后只做保存操作写完数据库就把提交ID放进队列并立即返回“已提交”判题进程从队列取ID再到数据库读取详细信息进行评测。这样写代码时能很自然地区分“写入”和“判题”两个职责避免判断“谁先来谁后到”的并发干扰。队列用本地内存队列所以系统不依赖额外的消息队列服务部署简单很多。如果以后要拆成多机判题再把队列替换成独立的消息中间件就行这个演进路径是通的。3.3 判题超时与系统自保护判题进程自己也需要防呆。比如编译过程卡住、文件锁冲突、评测目录磁盘满了等情况如果没有任何保护判题进程会一直挂在那里后面的提交全部积压。我在判题循环里加了一个总超时阈值每个提交从取到ID开始整个评测流程最多不能超过3分钟超过则强制标记为系统错误同时写日志便于排查。磁盘空间监控也是一并做的每次开始评测前检查临时目录剩余空间低于某阈值直接判定为系统错误并触发提醒。这样能防止极端情况下评测进程写出海量数据把磁盘塞满。4. 部署与运维中踩过的一些坑动态库、乱码、评测机时钟这些坑不是一次性踩完的是随着使用逐步冒出来的。放在一起复盘可以帮助后来者少走弯路。4.1 动态库依赖导致评测环境不一致最初部署时评测机器上的GCC版本和编译环境是手动配置的。有一次我更新了某个基础库结果第二天有一批原本能过的题突然全部编译失败。排查了很久发现是某题目的测试数据生成脚本依赖一个旧版动态库而编译环境升级后这个库的版本变了导致生成标准答案的脚本运行时崩溃。标准答案本身错了学生自然怎么都过不了。这个问题带来的教训是标准答案生成和评测环境必须做到可复现。后来做法是给生成标准答案的脚本改用静态编译方式并把依赖的特定版本库文件放到固定目录不在系统目录里升级。与之相对学生代码的编译环境保持统一。4.2 中文输出的编码问题一道道题里有些题目要求学生输出中文字符串。Windows下写测试数据生成脚本默认以GBK编码保存而评测机是Linux环境读文件时默认按UTF-8解析结果就是比对总报错。学生程序里如果按题目要求直接输出中文在Linux下以UTF-8输出而标准答案文件是GBK等于两边永远对不上。解决办法很简单所有测试数据和标准答案统一在评测机上用脚本生成或转码强制都转换成UTF-8。如果学生数据来自Windows系统在保存代码和测试数据时会做编码探测发现非UTF-8编码会尝试转换转换不了的给出编码错误提示。4.3 时间统计口径的统一判题系统报告的时间到底是CPU时间还是墙钟时间这个口径一定要在文档里写清楚。项目中默认统计的是CPU时间也就是进程实际消耗的处理器时间。但有一个情况需要注意当程序调用系统级等待操作时墙钟时间会明显大于CPU时间比如使用了不该用的延时函数。课程作业阶段老师希望看到的是“程序因为逻辑或算法问题超时”而不是“程序故意睡了一段时间后超时”。因此评测时同时记录两者报告给用户的主要是CPU时间但系统会额外记录总耗时如果有明显的墙钟时间大于CPU时间的异常情况会在管理端给出提醒。遇到故意延时的情况管理员可以看到CPU时间很小但总耗时很大从而判断提交是否存在恶意行为。4.4 测试点数据命名与题目解耦刚开始出题时测试点文件命名比较随意比如随意命名为1.in、2.in对应答案文件为1.out、2.out。一旦需要修改某个测试点的顺序或增加数据就得同步改好几个地方。后来引入了一个配置文件将题目与测试点列表分开表述这样管理端调整题目时不需要改判题逻辑。测试点的配置文件记录每个测试点的输入文件名、期望输出文件名、权重、是否启用特判。判题机读取配置后按顺序逐个运行测试点并汇总结果。这一改出题老师就可以在不碰代码的情况下维护数据了。5. 性能压测和并发优化一台服务器能扛多少并发项目上线前我做了一轮简单的压测。压测不是用专业工具模拟复杂场景而是用脚本直接并发提交不同题目的代码模拟一个班三十多人同时交作业的情况。5.1 压测结果与瓶颈定位测试环境是4核CPU、16 GB内存的普通服务器数据库和判题服务部署在一起。结果大致如下10个并发提交响应时间稳定在800毫秒以内判题平均耗时约1.2秒30个并发提交Web服务响应变慢页面等待时间大约2秒左右判题排队时间增加80个并发提交内存占用明显上涨数据库连接池满部分提交返回超时提示。压测暴露出的瓶颈不在判题本身而在Web服务与数据库的连接处理。我们当时用的是同步阻塞式的Web框架每个请求占一个线程线程数有限并发一上来连接就排队了。5.2 优化方案连接池与静态资源分离第一步给数据库访问加上连接池配置把最大连接数从默认值上调。这解决了一部分连接被占用的问题。第二步题面、代码提交页面涉及的静态资源不多但为了减少不必要的请求负载把这些静态文件放进了独立的静态目录并由Web服务直接处理不再走动态接口。第三步判题进程从队列取任务时由原来的单线程改为双线程消费。对于这道题CPU时间1秒级别的任务两个并发判题可以减少排队时间同时又不至于把服务器CPU打满影响其他服务。经过优化80个并发的压测结果就正常了很多绝大多数提交能在3秒内得到结果服务器整体负载也稳住了。5.3 针对评测沙箱的压力测试还有一个需要压测的对象是沙箱本身因为这里容易出问题的地方不是性能而是资源限制的漏网。我专门写了一个循环分配内存、不释放内存的程序配合128 MB限制看系统能否准确判为超出内存限制。实际测试发现普通的内存统计存在延迟可能程序已经申请超过限制的堆内存但统计值还没同步。后来通过周期采样加最终校验的方式最终能准确判定。类似的还有输出超限测试。我写了一个会无限打印字符的程序输出重定向到临时文件。限定的输出字节数为16 MB程序跑到16 MB时被终止同时返回输出超限状态。系统不会直接展示输出超限这个状态给学生因为学生代码很少触发但对防止恶意提交很重要如果不限制一次提交就能把临时目录写满。6. 防止作弊与代码相似度检测的实际效果在线评测系统天然会有一些 “曲线救国”的办法直接输出标准答案、提交判断测试数据的代码、多账号互刷等。“3.15 OJ”没有做一个很重的防作弊系统只针对两种常见情况做了轻量级检测实测效果还不错。6.1 简单而实用的相似度检测项目选了一个常见的思想来计算代码相似度把代码按行拆开过滤掉空行和注释后做行级别的相似度对比再结合代码字符串的哈希特征值做二次校验。具体实现时对每个提交的代码计算一个由行内容构成的摘要值。如果两份不同用户提交的代码摘要值完全一致基本可以判定为相同代码。如果摘要不完全一样但行级别相似度超过某个阈值例如85%系统会在管理端标黄供老师决定是否进一步人工检查。这个方法不适合检测“大段注释不同但代码逻辑一致”的改写但对课程作业场景已经够用。更重要的是它的计算成本很低不会在每次提交时拖慢判题流程。6.2 针对测试数据套取的防护有一类作弊手段是程序里直接枚举所有可能的输入然后只输出对应标准答案。这在题目测试点固定且可猜测时尤其常见。防这种作弊不能靠判题机本身得靠出题侧的设计多组测试数据随机生成、部署时对测试点加密存储、评测目录使用随机子目录每次不同。这样学生很难提前猜到全部测试数据。加密存储测试点还有一个好处即使学生能从某种渠道拿到服务器文件权限也拿不到明文测试数据。实际项目中测试点明文只存在于判题进程读取的那一瞬间平时数据库里保存的是加密后的数据文件。6.3 提交次数异常与人工干预管理端会记录每个用户的提交次数。如果某个用户短时间内对同一道题提交超过一定次数比如10次系统会提示管理员。这既可能说明学生在反复尝试调试也可能说明在利用评测系统的反馈试探测试数据。管理员可以选择先不干预也可以选择临时限制该账号对该题目的提交频率。这个功能没有做成自动封禁因为误判成本高。只提供提醒由老师自行判断。7. 一个典型排错过程从“答案错误”定位到“标准答案生成脚本路径泄漏”实际使用中遇到一个印象很深的疑难杂症某道题目几乎所有学生提交都返回“答案错误”但老师本地跑学生代码结果是对的。这个现象本身就意味着问题不在学生代码而在评测链路。排查时我先拿老师的标准代码提交了一次结果居然也显示“答案错误”。这基本排除了学生代码的因素。接着我检查了测试点文件和标准答案文件发现一个特殊情况某个测试点的输入文件里包含了相对路径字符串标准答案生成脚本在读取该路径时因为工作目录不同读到了不同的文件导致生成的标准答案与用绝对路径读取时的结果不一致。这暴露了一个问题标准答案生成脚本依赖了运行时的工作目录而不是显式指定所有路径。修复方式是改脚本让它基于固定基路径拼接所有文件路径并在评测系统中统一设定工作目录。之后再提交标准代码就能正常判为答案正确。这类问题的共性排查思路可以归纳为用标准代码先测确认是否“标准答案本身不对”单测某个测试点逐文件比对输入输出检查脚本路径处理逻辑尤其是相对路径修正后重新生成标准答案再跑一遍完整提交。8. 现在回头看哪些地方当时设计充分哪些可以再优化任何一个项目做完后回头看总能找到改进空间。这里不列空泛的“未来展望”就具体说说现在让我重新做一次的话哪些地方我会改变做法。8.1 当时做得比较到位的地方一是状态机设计。提交状态的流转从一开始就理得很清楚后期加功能时几乎没有返工包括新增评测状态、增加管理员人工复审功能都只是在原有状态基础上扩展。二是配置与代码分离。题目配置、测试点配置、判题参数配置都放在外部配置文件中而不是写死在代码里。这让出题老师能自己维护数据不需要动代码。三是日志记录。每个提交从进入队列开始到最后判题结束每一步都会有日志包括取任务时间、编译耗时、各测试点运行耗时、资源使用量。这让排查问题时能精确定位到分钟级别。8.2 如果重做一次会改的地方第一会把判题进程改成支持动态扩展的架构。尽管当前单机方案够用但如果哪天要承接更大规模的比赛现状需要较大重构。如果当初设计时就预留一个独立判题队列的服务接口后续扩展会更平滑。第二会在最初就接入分布式文件存储。当前的文件存储就在本地磁盘随着题目和提交数量累积备份和迁移会比较麻烦。如果重做我可能会把代码文件、测试点文件托管到分布式对象存储里数据库只保存元数据。不过对于课程项目来说这个优先级并不高。第三会在学生端加入“代码格式提醒”和“常见编译错误”提示。教学中很多学生是第一次编程编译错误信息和改错能力是需要培养的。如果评测系统能在返回编译错误的同时给出对应的简单解释比如“未声明的标识符可能是变量名写错或缺少头文件”会更有教学价值。回到“3.15 OJ”这个项目我最深的体会是在线评测系统的核心不在于界面多花哨、功能多炫酷而在于评测结果的正确性、稳定性和可解释性。如果评测本身出了问题再多的排行榜、徽章、积分都只会放大错误。系统和教学场景紧密结合把每个反馈都做到让学生能看懂、老师能干预这才是在线判题系统真正有价值的地方。