
1. 项目概述从“游戏套装”到“一站式在线评测平台”的蜕变最近在整理手头的项目资料翻到了几年前做的一个小玩意儿当时给它起了个名字叫“xtuoj.游戏套装”。现在回头看这个名字挺有意思的也容易让人误解。乍一听可能以为是个什么游戏外设或者桌游合集。但实际上它和传统意义上的“游戏”关系不大它是一个面向编程竞赛和算法学习场景的在线评测系统。之所以叫“游戏套装”更多是源于我们团队内部的一种调侃和愿景希望算法学习和解题的过程能像玩游戏一样有明确的关卡题目、即时的反馈评测结果、清晰的成长路径积分与排名从而变得更有趣、更上瘾。这个项目的核心就是构建一个类似LeetCode、Codeforces的在线平台但更侧重于轻量化、可定制和教学场景。用户通常是学生、编程爱好者或培训讲师可以在上面发布题目、编写代码、提交答案系统会自动编译、运行用户的代码并用预设的测试数据验证其正确性、时间与空间效率最后给出“Accepted”、“Wrong Answer”、“Time Limit Exceeded”等结果。听起来是不是很像一个裁判没错它的本质就是一个自动化的、公正的代码裁判系统。那么这个“套装”具体包含什么它不是一个单一的应用而是一套完整的解决方案涵盖了前端用户界面、后端评测核心、题目与用户管理系统、以及一整套部署和运维的脚本与配置。从用户注册登录、浏览题库、提交代码到后台的沙箱安全运行、资源监控、结果返回再到管理员的数据统计、题目管理都需要被考虑进来。做这样一个系统远不止是写一个能跑代码的程序那么简单它涉及到Web开发、系统安全、并发处理、资源隔离、判题策略设计等多个技术领域的交叉。这个项目适合谁来看呢如果你是一名计算机相关专业的学生想深入了解一个中型Web应用的全栈开发尤其是其中涉及系统编程和安全的复杂部分如果你是一位算法竞赛的教练或老师希望搭建一个私有的、题目可控的练习平台或者你单纯对“如何安全、高效地自动运行他人代码”这个技术挑战感兴趣那么这篇分享或许能给你带来一些启发。接下来我会拆解这个“游戏套装”的核心设计与实现分享我们踩过的坑和积累的经验。2. 核心架构设计与技术选型思路当我们决定要造一个在线评测系统Online Judge, OJ时面临的第一个问题就是架构设计。市面上有开源的OJ系统比如HUSTOJ、QDUOJ等但我们希望有更高的定制自由度并且能深入理解每一个环节所以选择了从核心组件开始自研。整个架构可以清晰地分为三层表现层、业务逻辑层和评测核心层。2.1 前后端分离与技术栈选择我们采用了经典的前后端分离架构。这样做的好处是职责清晰前后端可以并行开发且前端用户体验更灵活。前端的选择上我们使用了Vue.js框架。对于OJ这类交互复杂但业务逻辑相对固定的应用Vue的组件化开发非常合适。比如题目描述展示、代码编辑器、提交历史列表、实时判题状态轮询都可以封装成独立的组件。我们集成了Monaco EditorVS Code使用的编辑器作为代码编辑组件它提供了代码高亮、自动补全、多语言支持等强大功能能极大提升用户的编码体验。前端通过RESTful API与后端通信所有动态数据题目列表、提交结果、排名信息都通过异步请求获取和更新。后端则选择了Python的Django框架。Django以其“开箱即用”和强大的ORM对象关系映射著称能快速构建稳健的后台管理系统。用户认证、权限控制普通用户、题目管理员、超级管理员、题目与测试数据的CRUD增删改查、提交记录的管理这些功能用Django Admin可以快速搭建原型。但Django本身是同步框架对于判题这种可能耗时的IO密集型任务直接处理会阻塞请求。因此我们引入了Celery作为分布式任务队列。当用户提交一份代码时后端API只负责接收请求、验证数据、生成一个唯一的提交记录然后将实际的判题任务编译、运行、比对异步地扔给Celery去处理。这样Web服务器可以立即响应用户告知“提交已接收正在评测”用户体验非常流畅。注意这里有一个关键决策点。为什么不直接用Django的异步视图ASGI因为在项目启动时几年前Django对异步的支持还不像现在这么成熟和完整特别是与ORM的配合。而Celery是一个久经考验的、专门处理后台任务的方案其可靠性、可监控性配合Flower和与Django的集成度都非常高。对于判题这种需要严格保证任务不丢失、且可能需重试的场景Celery是更稳妥的选择。2.2 评测核心安全性与隔离性设计这是整个系统的“心脏”也是最复杂、最需要谨慎对待的部分。核心需求就两个1. 正确运行用户代码2. 绝对保证系统安全。用户代码可能是恶意的可能包含无限循环、疯狂占用内存、尝试读取系统文件、甚至调用fork炸弹。因此评测核心必须在一个高度隔离的“沙箱”环境中运行用户代码。我们调研了几种方案Docker容器这是目前最主流和推荐的方式。每个判题任务启动一个全新的、资源受限的Docker容器任务结束后立即销毁。安全性高隔离性好环境干净。但开销相对较大频繁创建销毁容器对性能有影响。系统调用沙箱如seccomp, ptrace通过Linux内核机制限制进程的系统调用。更轻量性能好但配置复杂需要深入理解系统调用且仍有被绕过风险如利用内核漏洞。虚拟化KVM/QEMU完全虚拟化安全性最高但开销巨大启动慢不适合高并发评测。基于易用性、安全性和社区支持度的权衡我们最终选择了Docker方案。虽然性能不是最优但对于一个教学或中小型竞赛平台完全够用并且大大降低了开发和安全维护的心智负担。具体到判题流程评测核心是一个独立的服务用Python编写它从Celery接收判题任务然后执行以下步骤准备阶段根据题目要求的编程语言如C、Python、Java拉取或使用预置的对应语言Docker镜像。将用户提交的源代码、题目的标准输入测试数据写入临时目录。编译阶段对于编译型语言在Docker容器内执行编译命令如g -O2 -stdc11 -o main main.cpp捕获编译输出。如果编译错误则直接返回“Compilation Error”并附上错误信息。运行阶段限制容器资源CPU时间、实际运行时间、内存、线程数。例如通过Docker的--ulimit限制CPU时间通过-m限制内存。然后运行编译好的程序或直接解释执行Python代码将测试数据作为标准输入stdin喂给程序同时捕获其标准输出stdout和标准错误stderr。比对阶段将程序输出与题目的标准答案通常也是文件进行比对。比对不是简单的字符串相等需要考虑特殊裁判Special Judge, SPJ的情况。比如浮点数允许误差、多解输出任意一个即可等。这就需要编写SPJ程序评测核心会调用SPJ来判断用户输出是否正确。清理与结果返回无论成功与否都强制销毁本次判题使用的Docker容器清理临时文件。将判题结果状态、用时、耗内存、可能的错误信息写回数据库并通知前端更新状态。这个过程中每一步都有坑。比如如何精确测量程序运行时间不能只用time命令因为我们要区分CPU时间和实际墙钟时间。一个程序可能因为等待IO而实际运行很久但CPU时间很短也可能陷入死循环CPU时间爆表。通常我们设置两个限制CPU时间限制如2秒和实际时间限制如5秒任一超限都判为“Time Limit Exceeded”。这需要利用Linux的setrlimit系统调用或在Docker中配置ulimit来实现。3. 关键模块实现与实操细节有了顶层设计我们来看看几个关键模块是如何落地实现的。这里会包含具体的代码片段和配置示例你可以直接参考。3.1 异步任务处理Celery与Redis的配置后端使用Django我们需要配置Celery来处理异步判题任务。首先安装必要的包celery,redis我们选用Redis作为Celery的消息代理和结果后端。proj/celery.py配置文件示例import os from celery import Celery # 设置Django的默认配置模块 os.environ.setdefault(DJANGO_SETTINGS_MODULE, xtuoj.settings) app Celery(xtuoj) # 从Django的settings.py中读取以CELERY_开头的配置 app.config_from_object(django.conf:settings, namespaceCELERY) # 自动从所有已注册的Django app中发现任务tasks.py app.autodiscover_tasks() app.task(bindTrue, ignore_resultTrue) def debug_task(self): print(fRequest: {self.request!r})proj/__init__.py需要初始化Celery应用from .celery import app as celery_app __all__ (celery_app,)在Django的settings.py中配置# Celery配置 CELERY_BROKER_URL redis://localhost:6379/0 # 消息代理使用Redis的0号数据库 CELERY_RESULT_BACKEND redis://localhost:6379/0 # 结果后端 CELERY_ACCEPT_CONTENT [json] CELERY_TASK_SERIALIZER json CELERY_RESULT_SERIALIZER json CELERY_TIMEZONE Asia/Shanghai # 非常重要设置任务超时时间防止任务悬挂 CELERY_TASK_TIME_LIMIT 30 * 60 # 30分钟 CELERY_TASK_SOFT_TIME_LIMIT 25 * 60 # 25分钟判题任务本身定义在一个单独的tasks.py文件中from celery import shared_task from judge.core import judge_submission # 这是我们的评测核心函数 shared_task(bindTrue, max_retries3) def judge_task(self, submission_id): 异步判题任务 :param submission_id: 提交记录的主键ID try: result judge_submission(submission_id) # 更新数据库中的提交状态 # ... (省略数据库更新代码) return result except Exception as exc: # 任务失败重试 raise self.retry(excexc, countdown60) # 60秒后重试当用户提交代码时视图函数只需调用judge_task.delay(submission_id)任务就会被放入Redis队列由Celery worker取出执行。实操心得Celery worker建议以supervisor或systemd托管保证其常驻运行。另外一定要设置合理的time_limit和soft_time_limit防止某个判题任务卡死比如用户程序死循环而Docker隔离又没完全生效导致worker被拖住。我们曾遇到过因为一个恶意程序导致整个判题队列堵塞的情况就是靠这个超时设置来自动终结任务并标记为系统错误。3.2 Docker沙箱的实现与安全加固评测核心函数judge_submission中与Docker交互的部分是关键。我们使用docker-py这个官方Python库来操作Docker。一个简化的判题流程代码示例import docker import os import tempfile from pathlib import Path client docker.from_env() def run_in_docker(code, input_data, time_limit, memory_limit): # 创建临时目录存放代码和输入数据 with tempfile.TemporaryDirectory() as tmpdir: code_path Path(tmpdir) / main.cpp code_path.write_text(code) input_path Path(tmpdir) / input.txt input_path.write_text(input_data) # 1. 编译以C为例 # 使用一个预构建的包含g的镜像例如 gcc:latest compile_container client.containers.run( imagegcc:latest, commandfg -O2 -stdc11 -o /tmp/main /tmp/main.cpp, volumes{tmpdir: {bind: /tmp, mode: rw}}, working_dir/tmp, removeTrue, # 运行后自动删除容器 stdoutTrue, stderrTrue ) if compile_container.exit_code ! 0: return {status: CE, message: compile_container.stderr.decode()} # 2. 运行 # 运行需要更严格的限制 run_container client.containers.run( imagegcc:latest, commandftimeout {time_limit1} /tmp/main, # 使用timeout做第二重保险 volumes{tmpdir: {bind: /tmp, mode: ro}}, # 只读挂载 working_dir/tmp, stdin_openTrue, # 开启标准输入用于喂数据 mem_limitf{memory_limit}m, # 内存限制例如 256m # CPU限制通过cpu_period和cpu_quota实现例如限制为单核的50% cpu_period100000, cpu_quota50000, # 安全配置以非root用户运行限制能力 usernobody, cap_drop[ALL], # 丢弃所有特权能力 security_opt[no-new-privileges:true], removeFalse, # 先不删除我们要获取详细状态 detachTrue # 后台运行 ) # 将输入数据写入容器的标准输入 socket run_container.attach_socket(params{stdin: 1, stream: 1}) socket.send(input_data.encode()) socket.close() # 等待容器运行结束或超时 try: result run_container.wait(timeouttime_limit2) # 等待时间稍长 exit_code result[StatusCode] # 获取输出 stdout run_container.logs(stdoutTrue, stderrFalse).decode() stderr run_container.logs(stdoutFalse, stderrTrue).decode() except Exception as e: # 超时或其他异常强制终止 run_container.stop() return {status: TLE, message: Real Time Limit Exceeded} finally: run_container.remove() # 最终清理容器 # 3. 比对输出此处简化假设简单比对 expected_output ... # 从数据库读取 if stdout.strip() expected_output.strip(): return {status: AC, time_used: ...} # 需要从容器状态中解析实际用时 else: return {status: WA, output: stdout}这段代码只是一个高度简化的示例真实系统要复杂得多。需要处理多种语言Java的类路径、Python的模块、精确的资源统计通过Docker API获取容器实际的CPU和内存使用峰值、以及完善的错误处理。安全加固要点用户身份务必使用usernobody或创建一个专用的无特权用户来运行容器内的进程。能力丢弃cap_drop[ALL]丢弃所有Linux能力这是防止容器内进程进行特权操作的关键。禁止权限提升security_opt[no-new-privileges:true]防止进程通过SUID等方式提升权限。只读挂载运行阶段将代码目录以只读模式挂载防止程序修改自身代码或写入额外文件。网络隔离默认情况下Docker容器有网络。对于判题应该使用network_modenone完全禁用网络防止用户程序进行网络通信如发起DDoS攻击或传输数据。系统调用过滤可以结合seccomp安全配置文件进一步限制容器内可用的系统调用例如禁止clone,fork,kill等。Docker有一个默认的seccomp配置我们可以基于它进行更严格的定制。3.3 特殊裁判SPJ与多测试点支持不是所有题目都只有唯一的标准答案。比如一道求“数列最大值”的题输出最大值即可但一道“输出任意一个可行解”的题就需要SPJ来判断。SPJ本身也是一个程序它接收三个参数输入文件、用户输出文件、标准答案文件然后通过退出码0表示AC非0表示WA或标准输出返回更详细的判断信息。我们的系统支持SPJ。在题目后台管理员可以上传一个SPJ程序通常是C或Python编写。在判题流程的比对阶段评测核心会启动一个受信任的SPJ容器与运行用户代码的容器隔离将输入、用户输出、答案文件传递给它并运行它。根据SPJ的返回结果来决定最终判题状态。多测试点则是另一个必备功能。一道题目通常包含多组测试数据输入/输出对用于全面检验程序的正确性和鲁棒性。评测核心需要依次运行用户程序对每一组测试数据进行评测。这里的设计有两种模式全对通过只有所有测试点都通过AC题目才算通过。这是最常见的方式。部分分每个测试点有独立的分值根据通过情况计算总分。这常用于OI信息学奥林匹克赛制。我们需要在数据库中设计Problem,TestCase模型并在判题时循环遍历所有关联的测试用例。判题结果也需要细化到每个测试点方便用户查看是哪个点出了错Wrong Answer on test 5。4. 数据库模型设计与业务逻辑一个OJ系统的数据模型相对复杂。核心的实体包括User用户、Problem题目、Submission提交、TestCase测试用例。此外还有Contest比赛、Announcement公告等扩展实体。核心模型关系简述User用户信息包含用户名、密码哈希、邮箱、角色、积分、解题数等。Problem题目信息包含标题、描述、输入输出说明、时间/内存限制、是否公开、关联的测试用例等。一个题目有多个测试用例TestCase。TestCase测试用例包含输入数据、输出数据或SPJ答案、是否样例、分值等。属于一个Problem。Submission提交记录这是最活跃的表。字段包括关联的用户、关联的题目、提交的代码、使用的语言、提交时间、判题状态Pending/Running/AC/WA...、耗时、耗内存、判题结果详情JSON格式存储每个测试点的结果等。Contest比赛包含开始时间、结束时间、规则ICPC/OI、题目列表。Contest与Problem是多对多关系比赛中的题目可能来自公共题库也可能是比赛专用题。业务逻辑的难点在于并发和状态一致性。例如在比赛期间提交频率可能极高。我们的Celery队列可能堆积大量判题任务。如何保证判题结果的顺序实际上我们不需要保证全局顺序只需要保证同一个用户的连续提交按顺序判题或者同一题目的提交公平判题即可。更关键的是要防止用户通过快速提交来“探测”测试数据比如通过不同的输出猜测数据。一种常见的防护措施是在比赛期间对于错误的提交只返回“Wrong Answer”或“Runtime Error”等概括性状态而不返回具体是哪个测试点错了也不返回程序的输出内容。这需要在判题逻辑和结果返回API上做控制。另一个业务逻辑是排名系统。对于ICPC赛制排名首先按解题数再按总罚时。罚时 首次正确提交的时间 错误提交次数 * 20分钟。这需要在每次提交状态更新为AC时触发一个排名计算任务。这个计算可以实时也可以定时如每分钟批量计算取决于对实时性的要求。我们采用了异步事件驱动的方式当Submission状态更新为AC时发送一个信号Signal触发一个异步任务来重新计算相关比赛或用户的排名数据并缓存起来。前端请求排名时直接读取缓存性能很好。5. 前端用户体验与交互优化前端的目标是让用户专注于解题操作流畅无阻。我们重点优化了以下几个点实时判题状态更新用户提交后页面不能刷新。我们通过WebSocket或更简单的长轮询Long Polling来实时获取判题状态。当提交状态从Pending变为Running再变为AC/WA...时前端需要动态更新状态图标和文字。我们使用了WebSocket通过Django Channels实现在判题结果写入数据库后后端主动向前端推送消息实现真正的实时更新。代码编辑器体验语言切换切换编程语言时代码编辑器的语法高亮、自动补全规则要随之改变。本地保存使用localStorage自动保存用户正在编写的代码即使不小心关闭页面代码也不会丢失。自定义模板允许用户为每种语言设置代码模板如C的#include bits/stdc.h提高编码效率。题目与比赛列表的筛选与搜索题目数量多了之后筛选功能至关重要。我们提供了按难度、标签、状态已解决/未解决筛选以及关键词搜索。这里涉及前后端配合前端传递筛选参数后端构造复杂的数据库查询Q对象并做好分页。响应式设计考虑到用户可能在电脑、平板或手机上访问前端需要做基本的响应式适配确保代码编辑和题目阅读的基本体验。6. 部署、运维与性能调优将“游戏套装”部署到生产环境又是另一番挑战。我们采用的技术栈是Nginx Gunicorn Django Celery Redis PostgreSQL Docker全部部署在一台或多台Linux服务器上。部署步骤简述环境准备服务器安装Docker、Docker Compose、Python、Node.js等基础环境。代码部署使用Git拉取代码配置生产环境设置DEBUGFalse, 设置正确的数据库和Redis连接配置静态文件收集等。数据库迁移运行python manage.py migrate创建或更新数据库表结构。静态文件收集运行python manage.py collectstatic将静态文件收集到Nginx服务的目录。服务启动用Gunicorn启动Django应用gunicorn --workers 4 --bind 0.0.0.0:8000 xtuoj.wsgi:application启动Celery workercelery -A xtuoj worker --loglevelinfo --concurrency4并发数根据CPU核心数调整启动Celery beat如果需要定时任务celery -A xtuoj beat --loglevelinfo配置Nginx反向代理将80/443端口的请求转发到Gunicorn并处理静态文件。使用进程管理使用supervisor来管理Gunicorn和Celery进程保证它们意外退出后能自动重启。性能调优经验数据库优化Submission表会快速增长需要定期归档旧数据或者按时间分表。为频繁查询的字段建立索引如Submission表的user_id,problem_id,status,create_time。使用select_related和prefetch_related来减少ORM查询的N1问题。缓存策略使用Redis缓存不常变但频繁访问的数据如题目列表过滤后、用户排名、网站公告等。对判题结果也可以进行短时间缓存避免短时间内同一份代码重复判题虽然概率低。评测队列优化根据服务器性能调整Celery worker的并发数。判题任务是CPU和IO密集型Docker容器启动并发数不宜过高通常设置为CPU核心数的1-2倍。可以设置多个队列将不同优先级或不同类型的任务分开。例如高优先级的比赛提交一个队列普通练习提交另一个队列。Docker镜像预热判题时拉取Docker镜像会带来延迟。我们预先将常用的语言镜像gcc, python, openjdk等拉取到服务器本地并在Docker配置中设置合适的镜像清理策略避免磁盘被占满。7. 常见问题排查与安全防护实录在开发和运营过程中我们遇到了形形色色的问题。这里记录几个典型场景和解决方法。问题一判题服务“卡死”提交一直Pending。排查首先检查Celery worker是否在运行supervisorctl status。然后查看Celery日志看是否有任务抛出未处理的异常。接着检查Redis服务是否正常队列是否堆积。最后检查Docker daemon是否响应。根因最常见的是用户程序含有死循环而Docker的资源限制CPU时间未能及时生效或者生效后容器僵死。也可能是某个判题任务消耗了异常多的内存导致整个worker进程被OS杀死OOM Killer。解决确保设置了Celery任务的time_limit和Docker的ulimitCPU time。在判题逻辑中对运行容器的操作加上全局超时控制如使用signal.alarm或multiprocessing的timeout。监控服务器内存和Docker容器状态设置报警。我们写了一个监控脚本定期检查运行时间过长的容器并强制清理。问题二用户提交恶意代码导致系统负载飙升。现象服务器CPU或内存使用率突然达到100%系统响应缓慢。排查通过docker stats命令快速查看哪个容器在消耗资源。通过查看最近提交记录定位到可疑用户和题目。根因用户代码可能包含计算密集型死循环、内存泄漏、或试图创建海量线程/进程fork bomb。解决强化Docker资源限制除了内存和CPU还可以限制进程数pids-limit。对于fork bombpids-limit非常有效。系统调用过滤使用定制的seccomp配置文件禁止fork,clone,kill等危险系统调用。运行前静态分析可选对于某些语言如C/C可以尝试用简单的静态分析工具或规则在编译前检查代码中是否包含明显的恶意函数调用如fork()但这种方法误报率高且容易被绕过只能作为辅助。问题三SPJ程序本身有Bug导致判题不公。现象用户反馈明明正确的代码被判错或者错误的代码被判对。排查这是最严重的问题之一。需要复核题目测试数据和SPJ程序。可以手动运行SPJ用有争议的输入输出进行测试。根因SPJ程序逻辑错误、边界条件处理不当、浮点数精度比较有问题等。解决SPJ测试在管理员后台增加SPJ测试功能允许用多组数据验证SPJ的正确性。SPJ审核流程重要的比赛题目SPJ程序需要另一名管理员进行代码复审。回滚与重判一旦发现SPJ错误需要及时修正并对所有受影响的提交进行重判Rejudge。系统需要支持对单个提交或整个题目的所有提交发起重判操作。问题四数据库连接数耗尽。现象网站无法访问日志显示OperationalError: too many connections。排查检查数据库如PostgreSQL的当前连接数SELECT * FROM pg_stat_activity;发现大量空闲或未正常关闭的连接。根因Django数据库连接未正确关闭特别是在Celery任务中。或者Web服务器Gunicorn的worker数量过多每个worker都持有数据库连接。解决确保Django配置中CONN_MAX_AGE设置合理对于高并发可以设置为0或较短时间让连接及时关闭。在Celery任务函数结束时显式调用django.db.close_old_connections()来关闭闲置连接。调整Gunicorn的worker数量和数据库的max_connections参数使其匹配。安全防护补充Web安全Django本身提供了很多防护CSRF, XSS, SQL注入但仍需注意。用户提交的代码在后台管理界面展示时一定要做HTML转义防止存储型XSS。权限控制严格区分用户角色。普通用户不能访问任何管理接口。题目测试数据文件路径要随机化或不可猜测防止用户直接通过URL下载测试数据。日志与审计所有用户操作登录、提交、管理操作都要记录详细的日志便于事后追溯和安全分析。回顾整个“xtuoj.游戏套装”的构建过程它远不止是一个编程项目更像是一次对Web全栈、系统安全、并发处理和工程化部署的深度实践。从最初一个让编程练习变得像游戏一样有趣的想法到最终形成一个稳定运行、承载数千用户和题目的平台中间经历了无数次的调试、优化和重构。最大的体会是在线评测系统的核心价值在于“公正”与“可靠”。任何判题结果的不一致或安全漏洞都会直接打击用户的信任。因此在评测核心的安全性和稳定性上投入再多的精力都不为过。同时良好的用户体验快速的响应、清晰的错误提示、流畅的交互也是留住用户的关键。这个“游戏套装”或许永远达不到商业级平台的高度但作为一次完整的技术实践它带给我的经验和教训远比实现功能本身更有价值。如果你也想尝试搭建一个希望这篇冗长的分享能帮你避开我们曾经踩过的那些坑。