从零构建在线评测系统:Docker沙箱、Celery异步与安全设计实践
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 Editor(VS 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 -std=c++11 -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', namespace='CELERY') # 自动从所有已注册的Django app中发现任务(tasks.py) app.autodiscover_tasks() @app.task(bind=True, ignore_result=True) def debug_task(self): print(f'Request: {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(bind=True, max_retries=3) 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(exc=exc, countdown=60) # 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( image='gcc:latest', command=f'g++ -O2 -std=c++11 -o /tmp/main /tmp/main.cpp', volumes={tmpdir: {'bind': '/tmp', 'mode': 'rw'}}, working_dir='/tmp', remove=True, # 运行后自动删除容器 stdout=True, stderr=True ) if compile_container.exit_code != 0: return {'status': 'CE', 'message': compile_container.stderr.decode()} # 2. 运行 # 运行需要更严格的限制 run_container = client.containers.run( image='gcc:latest', command=f'timeout {time_limit+1} /tmp/main', # 使用timeout做第二重保险 volumes={tmpdir: {'bind': '/tmp', 'mode': 'ro'}}, # 只读挂载 working_dir='/tmp', stdin_open=True, # 开启标准输入,用于喂数据 mem_limit=f'{memory_limit}m', # 内存限制,例如 '256m' # CPU限制通过cpu_period和cpu_quota实现,例如限制为单核的50% cpu_period=100000, cpu_quota=50000, # 安全配置:以非root用户运行,限制能力 user='nobody', cap_drop=['ALL'], # 丢弃所有特权能力 security_opt=['no-new-privileges:true'], remove=False, # 先不删除,我们要获取详细状态 detach=True # 后台运行 ) # 将输入数据写入容器的标准输入 socket = run_container.attach_socket(params={'stdin': 1, 'stream': 1}) socket.send(input_data.encode()) socket.close() # 等待容器运行结束,或超时 try: result = run_container.wait(timeout=time_limit+2) # 等待时间稍长 exit_code = result['StatusCode'] # 获取输出 stdout = run_container.logs(stdout=True, stderr=False).decode() stderr = run_container.logs(stdout=False, stderr=True).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和内存使用峰值)、以及完善的错误处理。
安全加固要点:
- 用户身份:务必使用
user='nobody'或创建一个专用的无特权用户来运行容器内的进程。- 能力丢弃:
cap_drop=['ALL']丢弃所有Linux能力,这是防止容器内进程进行特权操作的关键。- 禁止权限提升:
security_opt=['no-new-privileges:true']防止进程通过SUID等方式提升权限。- 只读挂载:运行阶段,将代码目录以只读模式挂载,防止程序修改自身代码或写入额外文件。
- 网络隔离:默认情况下,Docker容器有网络。对于判题,应该使用
network_mode='none'完全禁用网络,防止用户程序进行网络通信(如发起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拉取代码,配置生产环境设置(
DEBUG=False, 设置正确的数据库和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 worker:
celery -A xtuoj worker --loglevel=info --concurrency=4(并发数根据CPU核心数调整) - 启动Celery beat(如果需要定时任务):
celery -A xtuoj beat --loglevel=info - 配置Nginx反向代理,将80/443端口的请求转发到Gunicorn,并处理静态文件。
- 用Gunicorn启动Django应用:
- 使用进程管理:使用
supervisor来管理Gunicorn和Celery进程,保证它们意外退出后能自动重启。
性能调优经验:
- 数据库优化:
Submission表会快速增长,需要定期归档旧数据,或者按时间分表。- 为频繁查询的字段建立索引,如
Submission表的user_id,problem_id,status,create_time。 - 使用
select_related和prefetch_related来减少ORM查询的N+1问题。
- 缓存策略:
- 使用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的ulimit(CPU time)。 - 在判题逻辑中,对运行容器的操作加上全局超时控制(如使用
signal.alarm或multiprocessing的timeout)。 - 监控服务器内存和Docker容器状态,设置报警。我们写了一个监控脚本,定期检查运行时间过长的容器并强制清理。
- 确保设置了Celery任务的
问题二:用户提交恶意代码,导致系统负载飙升。
- 现象:服务器CPU或内存使用率突然达到100%,系统响应缓慢。
- 排查:通过
docker stats命令快速查看哪个容器在消耗资源。通过查看最近提交记录,定位到可疑用户和题目。 - 根因:用户代码可能包含计算密集型死循环、内存泄漏、或试图创建海量线程/进程(
fork bomb)。 - 解决:
- 强化Docker资源限制:除了内存和CPU,还可以限制进程数(
pids-limit)。对于fork bomb,pids-limit非常有效。 - 系统调用过滤:使用定制的
seccomp配置文件,禁止fork,clone,kill等危险系统调用。 - 运行前静态分析(可选):对于某些语言(如C/C++),可以尝试用简单的静态分析工具或规则,在编译前检查代码中是否包含明显的恶意函数调用(如
fork()),但这种方法误报率高,且容易被绕过,只能作为辅助。
- 强化Docker资源限制:除了内存和CPU,还可以限制进程数(
问题三: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参数,使其匹配。
- 确保Django配置中
安全防护补充:
- Web安全:Django本身提供了很多防护(CSRF, XSS, SQL注入),但仍需注意。用户提交的代码在后台管理界面展示时,一定要做HTML转义,防止存储型XSS。
- 权限控制:严格区分用户角色。普通用户不能访问任何管理接口。题目测试数据文件路径要随机化或不可猜测,防止用户直接通过URL下载测试数据。
- 日志与审计:所有用户操作(登录、提交、管理操作)都要记录详细的日志,便于事后追溯和安全分析。
回顾整个“xtuoj.游戏套装”的构建过程,它远不止是一个编程项目,更像是一次对Web全栈、系统安全、并发处理和工程化部署的深度实践。从最初一个让编程练习变得像游戏一样有趣的想法,到最终形成一个稳定运行、承载数千用户和题目的平台,中间经历了无数次的调试、优化和重构。最大的体会是,在线评测系统的核心价值在于“公正”与“可靠”。任何判题结果的不一致或安全漏洞,都会直接打击用户的信任。因此,在评测核心的安全性和稳定性上投入再多的精力都不为过。同时,良好的用户体验(快速的响应、清晰的错误提示、流畅的交互)也是留住用户的关键。这个“游戏套装”或许永远达不到商业级平台的高度,但作为一次完整的技术实践,它带给我的经验和教训,远比实现功能本身更有价值。如果你也想尝试搭建一个,希望这篇冗长的分享能帮你避开我们曾经踩过的那些坑。
