
简介基于Flask与MySQL构建的OJ在线评测平台完整工程面向具备Python基础、希望快速搭建可运行判题系统的开发者与课程设计用户。资源整合平台前后端源码、部署文档及数据资料从MySQL表结构设计、Flask应用路由到判题核心逻辑均有覆盖适合作为毕业设计、实训项目或二次开发起点。包体共278个文件压缩后仅8.49MB主要包含33个py源码、31个pyc编译文件、25个js脚本、20个css样式、17个html页面以及scss/less预处理器样式、Pug模板、SVG图标、TTF字体等前端素材另有部署文档与依赖说明目录结构清晰便于按模块查阅。资源已有125人浏览学习压缩包可直接替换本地数据后运行环境配置方式随包文档说明。如有Django、Pytorch、爬虫、可视化、推荐系统、大模型等方向定制需求也可进一步联系交流。1. OJ评测平台为什么一个FlaskMySQL的完整工程值得拆开看在线评测系统的经典链路是“提交代码→后台判题→回写结果”单看链路不算长但实际工程里要处理提交接口的并发、判题线程的调度、进程超时后的清理以及最终输出与期望输出的空白字符对齐。Flask 在这个场景下的优势不是性能而是模块边界清晰你可以把 Web 层、数据层、判题层分开维护不会因为引入重量级框架把查找问题的路径拉长。MySQL 则承担了整个系统里最关键的状态流转记录每一次评测从 PENDING 到 ACCEPTED 或 WRONG_ANSWER全都落在表字段中出问题时可排查性远高于内存队列方案。这套源码包提供的是完整工程、部署文档和可直接导入的 MySQL 数据资料运行环境要求是 Python 3.7 及以上跑起来能覆盖一个小型 OJ 平台的核心考点用户、题目管理、提交评测和结果回显。适合两类人一类是课程设计、内部竞赛系统建设需要快速出活的另一类是五年以上经验、想看看别人在进程隔离和判题一致性上怎么处理细节的。即便部署文档足够详细也建议先看表结构再启动理解数据流的顺序能省去很多排错时间。2. Flask工程结构剖析应用工厂、蓝图与模型边界2.1 应用工厂从全局实例到可配置实例很多 Flask 入门项目喜欢在入口文件顶部直接写app Flask(__name__)这在单脚本 Demo 里没有问题但评测平台要同时跑 Web 服务和后台判题线程还会被单元测试、并发压测脚本反复引用全局实例很难在不同环境下切换配置。这套工程的解法是应用工厂create_app()通过传入配置名来决定连接哪套数据库、是否开启调试模式。# app/__init__.py from flask import Flask from flask_sqlalchemy import SQLAlchemy from config import CONFIG_BY_NAME db SQLAlchemy() def create_app(config_namedefault): app Flask(__name__) app.config.from_object(CONFIG_BY_NAME[config_name]) db.init_app(app) from app.auth import auth as auth_blueprint from app.main import main as main_blueprint from app.submission import submission as submission_blueprint app.register_blueprint(auth_blueprint, url_prefix/auth) app.register_blueprint(main_blueprint) app.register_blueprint(submission_blueprint, url_prefix/submission) return app这里的关键是db.init_app(app)并不会在导入时立刻建立 MySQL 连接而是在请求进入后才按需获取连接池中的连接。这样判题线程可以在独立进程里通过create_app(worker)拿到同一个应用对象再配合app.app_context()操作数据库评测逻辑和 Web 路由就不需要共享全局变量。配置类的命名使用default、testing、production让同一套代码可以便捷地在本地 MySQL 与生产库之间切换这也是部署文档里明确支持“直接替换数据即可”的原因之一。2.2 蓝图按数据领域划分而不是按页面划分Flask 蓝图常见的划分方式是按页面auth 管登录注册页admin 管后台页。但 OJ 平台的用户端和管理端的页面高度重合按页面拆会导致一个业务操作分散在多个蓝图中。这套工程里更合理的思路是按领域拆每个蓝图负责一组数据状态auth 维护用户会话main 提供题目列表和详情展示submission 处理提交和判题结果查询。顺着这个思路阅读源码路径就清晰了。提交一条代码时请求先进 submission 蓝图的/submission/api/submit视图函数负责写入 submissions 表并返回任务编号具体判题动作由独立线程拉取数据库完成不回传在 HTTP 响应体里。这种职责划分避免了一个常见问题将判题逻辑堆在视图中导致接口超时。另外一个值得注意的细节是蓝图 url_prefix 不要和内部路由写重比如 submission 蓝图挂在/submission下内部就不要再出现以/submit开头的硬编码路径否则 reverse 解析会按配置去处理最终把请求路由到错误视图。2.3 SQLAlchemy 模型和提交表的状态字段模型层是评测数据的骨架这里选中submission做说明因为它是整个系统的核心枢纽一条提交同时关联用户、题目、判题结果每次提交还会被判定为不同状态。模型字段的设计直接决定了后台评测线程的读写代价。# app/models.py节选 class Submission(db.Model): __tablename__ submissions id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(users.id), nullableFalse, indexTrue) problem_id db.Column(db.Integer, db.ForeignKey(problems.id), nullableFalse, indexTrue) language db.Column(db.String(20), nullableFalse, defaultpython3) source_code db.Column(db.Text, nullableFalse) status db.Column(db.String(32), defaultPENDING, indexTrue) time_used_ms db.Column(db.Integer, default0) memory_used_kb db.Column(db.Integer, default0) created_at db.Column(db.DateTime, server_defaultdb.func.now()) updated_at db.Column(db.DateTime, server_defaultdb.func.now(), onupdatedb.func.now())status字段为字符串而不是数字枚举目的是日志可读性。当部署后线上排查时直接SELECT * FROM submissions WHERE statusTLE比数字枚举直观得多。time_used_ms和memory_used_kb统一使用整数并明确在字段名里标注单位这一条看似微小却避免了不少换算错误。server_defaultdb.func.now()会在 MySQL 端生成当前时间不依赖应用服务器时区比在 Python 里datetime.now()更可靠。索引方面user_id、problem_id、status都单独加了indexTrue因为在未确定查询模式前单列索引能覆盖大部分检索场景。后续如果提交量变大再考虑联合索引替代单列索引这一点在第 3 章会展开。2.4 查询优化列表页 N1 问题怎么处理OJ 平台的题目列表页和提交历史页是 N1 查询的重灾区。比如展示题目列表时每道题需要关联分类名称、通过率、最近提交时间如果用Problem.query.all()后在模板里循环访问problem.category.name每条题目都会触发一次新的 SQL 查询列表页 30 道题就会出现 31 条 SQL。通过 Flask-SQLAlchemy 的joinedload可以一次性把关联表连接取出避免循环查询。from sqlalchemy.orm import joinedload def list_problems(page, per_page): return Problem.query \ .options(joinedload(Problem.category)) \ .order_by(Problem.id.desc()) \ .paginate(pagepage, per_pageper_page, error_outFalse)joinedload生成的是 LEFT OUTER JOIN会一次性将 problem 和 category 的数据取回普通场景下足够。另一个实际经验是提交历史页不要默认查询全部字段source_code属于 Text 类型可能在列表场景下占几 KB 甚至几十 KB全部查出会拖慢页面。可以准备一个to_summary_dict()方法只序列化用户需要的字段再单独提供查看源码的详情接口。整体看这个工程的模块划分把 Web 和评测职责分得很清楚连索引和序列化都贴近真实部署需求对做自托管 OJ 的团队有直接借鉴价值。3. MySQL 数据表设计用户、题目、提交和测试用例3.1 四张核心表的字段职责导入源码包提供的 SQL 文件后会看到四张主要业务表user、problem、submission、test_case。字段设计的思路是“提交”为主链路用户和题目为两侧关联测试用例单独存储不落文件系统。表名关键字段存储内容与设计意图userid, username, password_hash, rolerole 区分 admin 和普通用户管理接口据此过滤权限problemid, title, description, difficulty, time_limit_ms, memory_limit_kb题目默认时间与内存限制测试用例可单独覆盖submissionid, user_id, problem_id, language, status, time_used_ms, memory_used_kb每次提交一条记录状态由判题线程回写test_caseid, problem_id, input_data, output_data, time_limit_ms, memory_limit_kb一题多条测试数据输入输出用 TEXT 存储3.1.1 字段类型选择的注意点test_case.input_data和output_data的字段类型建议用MEDIUMTEXT而不是VARCHAR。在线评测的输入数据经常是标准输入的多行文本一些压测点可能超过 16KBVARCHAR默认长度不够MySQL 5.7 下还会因为字符集不同出现存储上限差异。使用MEDIUMTEXT时需要注意排序不会生效但测试数据本来就不需要排序只按problem_id等值查询因此没有副作用。用户表的password_hash应该用 60 位以上的长度避免部分旧代码使用 32 位摘要导致入库失败。题目表里time_limit_ms与memory_limit_kb用 INT UNSIGNED不设负数也能防止夜里的边界误写。3.2 评测状态机的流转与系统崩溃恢复评测平台里最容易出现幻觉的地方不是判题算法而是状态机遗漏。完整状态链应该是提交接口写入PENDING判题线程取走任务后更新为RUNNING执行结束再写入ACCEPTED、WRONG_ANSWER、TIME_LIMIT_EXCEEDED、MEMORY_LIMIT_EXCEEDED、RUNTIME_ERROR中的一种。还有一个容易被忽略的状态SYSTEM_ERROR用于判题器自身出错比如测试用例缺失、判题进程被系统杀掉。ALTER TABLE submissions ADD CONSTRAINT chk_sub_status CHECK (status IN ( PENDING, RUNNING, ACCEPTED, WRONG_ANSWER, TIME_LIMIT_EXCEEDED, MEMORY_LIMIT_EXCEEDED, RUNTIME_ERROR, SYSTEM_ERROR ));MySQL 8.0 会真实执行 CHECK 约束5.7 只做语法解析不校验所以这个约束只能作为辅助手段判题器写代码时还是要显式判断合法值。另一个实际问题在崩溃恢复如果整个服务意外退出还停留在RUNNING的提交必须被重置回PENDING否则重启后判题线程永远不会重新处理它们。常见做法是在应用启动阶段执行UPDATE submissions SET statusPENDING WHERE statusRUNNING放在数据库初始化之后、服务监听端口之前。这个 SQL 如果放错位置首批提交会全部卡死排查起来也比较隐蔽。3.3 索引选择如何决定评测队列吞吐评测队列是高并发读写场景用户提交新记录判题线程轮询statusPENDING的记录回写结果时按id定位更新。这里的瓶颈往往是数据库索引没有覆盖查询条件。如果 submissions 表只建了主键索引那么每次拉取待判任务都要做全表扫描再按 id 排序提交量超过 2 万条后队列一次拉取可能耗时几百毫秒判题进度会被拖慢。EXPLAIN SELECT id, problem_id, source_code FROM submissions WHERE status PENDING ORDER BY id ASC LIMIT 10;观察 EXPLAIN 结果如果 type 列是 ALL 或者 Extra 里出现Using filesort说明需要调整索引。有效做法是建立(status, id)联合索引等值条件命中 status排序命中 id一次 B 树查找就能定位到需要处理的提交。实际部署中联合索引比单列索引在评测队列场景下优势明显因为查询条件中 status 是固定等值id 是递增排序联合索引能消除 filesort。提交量继续增长到几十万条时再考虑按 status 分区PENDING 和 RUNNING 单独放分区历史结果归档到另一个分区这一步可以留到业务验证后再做。3.4 数据导入的常见问题导入数据资料时最容易遇到两类错误。第一类是字符集问题创建数据库时如果没有指定utf8mb4中文题面和用户昵称会出现乱码第二类是外键依赖顺序必须先导入 user 和 problem 表再导入 submission 和 test_case 表否则外键找不到父表记录。CREATE DATABASE ojdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; SOURCE /path/to/oj_schema.sql; SOURCE /path/to/oj_data.sql;utf8mb4 与 utf8 的区别不在中文而在 emoji 和生僻字的存储。用户昵称、题目描述里如果可能出现特殊字符直接选 utf8mb4 可以避免后续迁移。导入完成后建议马上执行SHOW TABLE STATUS确认各表的行数和预期一致再开一个连接执行两条带 WHERE 的查询确认索引没有丢失。部署文档里虽然写了直接替换数据即可但本地 MySQL 版本如果低于 5.7部分列类型定义会有差别提前核对建表语句是最稳妥的做法。4. 判题执行链路超时、内存与结果比对4.1 提交接口不直接判题为什么先落库一旦提交接口同步执行判题用户请求的响应时间就等于判题时间极端情况下还可能因为子进程卡死把整个 Web 进程拖崩。工程里的做法是两步走提交接口校验代码非空、语言合法后写入 submissions 表并返回任务编号状态置为PENDING后台线程以固定频率扫描数据库取出待判任务逐条处理。这也解释了为什么很多 OJ 的前端交互都是轮询结果而不是等一次请求直接返回 AC 或 WA。在并发量不大的场景下这种轮询表的方式比引入 Celery 更省心少了一个中间件部署和备份成本都低。判题线程需要控制并发数常见做法是在主线程里维护一个工作池每个 worker 独立拉取任务。主线程负责日志和状态汇总worker 只负责编译、运行、比对。需要注意的是多个 worker 同时拉取任务时要有防重复机制简单做法是SELECT ... WHERE statusPENDING LIMIT 1 FOR UPDATE SKIP LOCKED锁住当前记录并跳过已被其他线程锁定的行避免同一道提交被两个 worker 同时处理。4.2 子进程资源限制setrlimit 和超时兜底判题进程必须在受控环境下运行否则一个while True就可以把服务器 CPU 打满。Linux 下最直接的手段是resource模块配合subprocess在子进程启动前设置 CPU 时间和地址空间上限。下面这段是评测执行器的核心逻辑。# judge/runner.py关键逻辑 import resource import subprocess def _limit_cpu_time(seconds): resource.setrlimit(resource.RLIMIT_CPU, (seconds, seconds 1)) def _limit_memory(kb): resource.setrlimit(resource.RLIMIT_AS, (kb * 1024, kb * 1024)) def run_case(run_cmd, input_data, time_s, mem_kb): def limit_resources(): _limit_cpu_time(time_s) _limit_memory(mem_kb) resource.setrlimit(resource.RLIMIT_NPROC, (20, 20)) proc subprocess.Popen( run_cmd, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, preexec_fnlimit_resources, textTrue, ) try: stdout, stderr proc.communicate(input_data, timeouttime_s 5) return stdout, stderr, proc.returncode except subprocess.TimeoutExpired: proc.kill() proc.wait() return , timeout, -1这里的逻辑按三个层次设置了保护。RLIMIT_CPU限制的是进程消耗的 CPU 时间RLIMIT_AS限制的是整个地址空间大小communicate的 timeout 参数则是对真实时钟的最后兜底。为什么 timeout 要设置成time_s 5而不是等于题目限制因为如果服务器整体负载很高进程实际运行时间可能受调度影响偏离 CPU 时间多出的 5 秒用于避免误判。进入 TimeoutExpired 分支后必须proc.kill()之后再调用一次proc.wait()确保子进程真正退出不然僵尸进程会逐渐堆满进程表。注意resource.setrlimit只对类 Unix 系统生效。如果判题服务跑在 Windows 上需要改用 taskkill 或 psutil 的Process.kill()以及 Job Object 限制子进程否则内存限制会静默失效。参数方面RLIMIT_AS限制的是虚拟内存而 Python 解释器启动本身就需要占用数十 MB所以内存限制不要设置到 64MB 以下否则用户代码还没有开始执行就会异常退出被误判为MEMORY_LIMIT_EXCEEDED。遇到这类问题时要先手动跑一次相同命令行观察解释器本身的峰值内存再确定题目的合理限制。4.3 输出比对规范行尾空格怎么处理判题系统比较程序输出时最不能直接使用字符串因为不同题目对空白字符的约定不同。绝大多数 OJ 的约定是忽略每行末尾的空格和 TAB同时忽略整个输出末尾的空行。如果直接比对用户代码输出2\n而期望数据是2这会误判为WRONG_ANSWER。def normalize_output(s): if s is None: return lines [line.rstrip() for line in s.splitlines()] while lines and lines[-1] : lines.pop() return \n.join(lines).strip() def is_accept(expected, actual): return normalize_output(expected) normalize_output(actual)line.rstrip()负责去掉每一行末尾的空白while循环负责去掉末尾多余空行最后再用strip()对整体兜底。这样既保留了行间空格又兼容不同系统的换行符差异。更严格的 Special Judge 模式会在比对前做浮点数误差判断或正则匹配但多数题目用这种归一化比对已经足够。实际调试中碰到明明输出对了却显示 WA优先检查输出中是否混入了调试用的print其次是语言差异导致的浮点格式化不一致例如 Python 的1.0和 C 的1在部分存活场景下会被判为不同。4.4 编译型语言与解释型语言的分流提交记录里的 language 字段决定了判题执行器的路径。C/C 需要先编译再运行Python 直接交给解释器。一个安全的做法是分开处理两种执行方式。def build_compile_cmd(language, source_path): if language cpp: return [g, -O2, -stdc17, source_path, -o, /tmp/judge_bin] return None # python3 直接运行无编译阶段 def build_run_cmd(language, source_path): if language cpp: return [/tmp/judge_bin] return [python3, source_path]编译时如果g返回非零退出码说明用户代码存在语法错误应判定为COMPILE_ERROR并把编译器的 stderr 回传给前端但需要截断长度防止输出过大撑爆数据库字段。运行阶段Python 相比 C 多一个风险源码文件的后缀名必须是.py并且文件编码尽量固定为 UTF-8避免源码里出现中文注释时因平台默认编码不同而报错。临时目录建议每个提交单独分配运行结束后立即删除防止用户代码在服务器上残留文件也避免多个提交之间互相影响工作目录。更进一步的隔离可以上 Docker但需要额外考虑镜像拉取和内核残留问题也就超出了这套源码在 subprocess 层级的范畴。5. 部署验收从 IDEA 到回归测试5.1 数据库导入与 Flask 配置部署文档里给出的路线是先用 Python 3.7 以上版本创建虚拟环境再安装 requirements.txt然后导入 MySQL 数据资料。源码包中已经带了一个 Windows 虚拟环境目录里面有activate.bat和pyvenv.cfg说明作者是直接在 Windows 下运行的。如果你也是 Windows 环境可以直接进虚拟环境激活脚本启动省去重复安装依赖的等待时间。python -m venv venv # Windows 下也可以直接使用源码包中的 activate.bat venv\Scripts\activate.bat pip install -r requirements.txt激活虚拟环境后先确认config.py里 MySQL 的连接参数和本机一致。SQLALCHEMY_DATABASE_URI包含用户名、密码、地址和库名这里最常见的坑是密码中含有特殊字符导致 URI 解析失败。密码建议先放到环境变量里读取避免代码提交时泄露。在 PyCharm 或 IDEA 中打开工程后要把项目解释器切换到当前虚拟环境而不是系统 Python否则运行时会被 MySQL 驱动的版本不兼容问题卡住。启动前先运行一次建表和初始化的脚本确认四张核心表都存在。5.2 用五个用例验证判题正确性平台能跑通不意味着判题逻辑可靠推荐直接构造一组覆盖五种结果的用例来验收。构造思路是准备五个问题分别对应 AC、WA、TLE、RE和编译失败。from app import create_app app create_app(default) def submit_and_check(problem_id, code, expect_status): with app.test_client() as c: rv c.post(/api/submit, json{ problem_id: problem_id, code: code, language: python3, }) assert rv.status_code 200 # 轮询 submission 状态直到非 PENDING/RUNNING # 用 expect_status 与最终状态比对确认判题器行为分别提交输出正确结果、输出错误结果、死循环、抛异常、语法错误的代码观察最终状态是否与期望一致。重点核对两类边界死循环代码是否在题目时间限制内被切断并返回TIME_LIMIT_EXCEEDED而不是把判题线程拖死语法错误代码是否返回COMPILE_ERROR而不是空白的RUNTIME_ERROR。这两个点最容易出问题也是判题系统可靠性要求最高的地方。全部通过后再用并发脚本同时提交 50 个请求观察 MySQL 的锁等待时间和判题队列消费速度从而确定后台 worker 的开合适数量。本文还有配套的精品资源点击获取