资讯动态

基于NodeJS+Vue+MySQL的在线编程笔试平台设计实现

发布时间:2026/10/14 23:25:40 来源:尧图企业网站定制
手头刚好做过一个类似的系统从搭建到上线踩了不少坑把整个过程整理出来。这个项目以NodeJSVueMySQL为核心技术栈实现了在线编程笔试平台的主要功能题目管理、考试创建、在线编辑器、代码执行判题、考试成绩汇总。适合正在做毕业设计、或者企业里需要搭建内部笔试环境的技术同学参考也适合想了解这类系统整体架构的后端开发者。1. 技术栈选型与整体架构思路1.1 为什么选了这三个技术而不是其他组合这个系统最核心的要求有两点一是开发周期要短二是在线判题这部分对异步IO的处理要求比较高。NodeJS在这里的优势非常明显它天生就是事件驱动、非阻塞IO模型处理大量并发提交请求比传统同步框架更合适。考试场景下几十个人同时提交代码每个提交都要经过编译、执行、比对结果这一系列操作NodeJS可以用异步队列把这些操作串起来而不需要为每个请求单独开线程。Vue这边在线笔试页面有典型的交互密集特点题目列表切换、代码编辑器、倒计时、判定结果实时回传。Vue的双向绑定和组件化开发能把页面的状态管理做得很干净。比如编辑器区域、题目详情区域、提交记录区域各是一个独立组件互不干扰考试结束自动交卷这种逻辑可以放在全局状态里统一处理。MySQL在这个场景下是完全够用的题目表、用户表、考试记录表都是典型的结构化数据ACID事务能保证成绩记录的一致性。有同学问我为什么不用MongoDB我的看法是题目、考试、提交记录之间有关联查询的需求比如“查某场考试所有考生的成绩排名”用关系型数据库做JOIN和聚合统计会顺手得多。架构上我拆成了三个模块前端Web应用Vue、后端API服务NodeJSExpress、判题执行模块。前端负责题目展示和代码编辑后端负责业务逻辑和数据存取判题模块独立出来用一个任务队列接收待判题目执行完把结果写回数据库。模块之间通过HTTP接口通信判题模块异步处理避免阻塞主流程。1.2 项目目录结构划分实际项目的目录结构我建议按下面这种方式组织职责清晰后期维护也方便online-judge-platform/ ├── client/ # Vue前端工程 │ ├── src/ │ │ ├── api/ # 接口请求封装 │ │ ├── components/ # 公共组件编辑器、题目卡片等 │ │ ├── views/ # 页面视图 │ │ ├── store/ # Vuex状态管理 │ │ └── router/ # 路由配置 ├── server/ # NodeJS后端工程 │ ├── controllers/ # 请求处理层 │ ├── models/ # 数据模型 │ ├── routes/ # 路由定义 │ ├── judge/ # 判题核心逻辑 │ ├── services/ # 业务服务层 │ └── app.js # 应用入口 └── sql/ # 数据库初始化脚本我见过不少项目把所有代码堆在一个文件夹里开始写起来快但到后面改一个功能要翻半天文件。前后端分离加分层架构前期多花一点时间建目录后期省下的时间远多于这个成本。2. 数据库设计与核心表结构2.1 五张核心表的设计思路再重复一遍在线编程笔试平台和普通考试系统的最大区别在于题库要能复用、考试要分组卷、判题记录需要独立存储。我设计了五张表用户表user存储系统所有用户出题管理员和考生共用一张表通过role字段区分角色这个字段用TINYINT类型存储0代表出题管理员1代表考生。密码字段不要存明文最少要做一次哈希加密预算充足的话加盐处理。题目表exercise核心字段包括题目标题、题目描述、难度等级1简单、2中等、3困难、时间限制毫秒、内存限制MB、测试用例。这里有一个设计细节测试用例我用的JSON字段存储而不是单独建一张测试用例表。因为判题时一次要取整组用例JSON字段一次读出来直接用省去多次关联查询。如果后面题目数量到了几千道再用单独的用例表也不迟起步阶段JSON字段是效率最高的方案。CREATE TABLE exercise ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, difficulty TINYINT NULL DEFAULT 1, description TEXT NOT NULL, time_limit INT NULL DEFAULT 2000 COMMENT 单位毫秒, memory_limit INT NULL DEFAULT 256 COMMENT 单位MB, test_cases JSON NULL, solution TEXT NULL COMMENT 参考答案不返回给前端, created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_difficulty (difficulty) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;考试表exam一场考试的基本信息考试名称、考试码考生通过这个码进入考试、开始时间、结束时间、考试时长、状态。状态字段建议用整型0未开始、1进行中、2已结束、3已取消。考试题目关联表exam_question中间表记录每场考试包含哪些题目以及每题的分数。这样同一个题库可以组出不同的试卷满足不同岗位、不同难度的笔试需求。提交记录表submission这是判题系统的核心数据表每提交一次代码就记录一条。包括考试ID、用户ID、题目ID、提交的代码、语言类型、判题状态、执行耗时、内存占用、得分。判题状态用TINYINT枚举我定义的是0等待判题、1通过、2运行超时、3内存超限、4运行时错误、5答案错误、6编译错误。CREATE TABLE submission ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, exercise_id BIGINT NOT NULL, code TEXT NOT NULL, language VARCHAR(20) NOT NULL, judge_status TINYINT NOT NULL DEFAULT 0, time_cost INT DEFAULT 0, memory_cost INT DEFAULT 0, score INT DEFAULT 0, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_exam_user (exam_id, user_id), INDEX idx_judge_status (judge_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 索引规划和查询优化这个系统的高频查询是“某场考试某个考生的所有提交记录”所以idx_exam_user考试ID、用户ID这个联合索引一定要建。另外“待判题队列”要按judge_status查询单独给judge_status建索引就够用。实际开发中我遇到过一个典型问题考试结束后管理员要看全部考生的成绩排名查询语句按score字段降序排序。数据量小的时候没事一旦提交记录到了几万条这个查询就会变慢。解决方案是加一个成绩汇总表考试结束时不从submission表实时计算而是把汇总结果写进一张统计表查询直接读汇总表。说句经验之谈这类系统的数据库设计核心思路是有多少种业务查询场景就为这些场景建立对应索引不要想着一开始把所有可能用到的索引都建上。索引多了写入速度会明显下降得不偿失。2.3 初始化脚本和连接池配置数据库连接池参数我当时踩过一个坑默认的连接池大小是10考试高峰期100个人同时操作连接直接打满后面排队的人直接超时。后续把连接池最大值调到了50并增加了排队等待时间限制这个问题才算解决。连接字符串里可以这样配置const pool mysql.createPool({ host: localhost, user: root, password: password, database: oj_db, waitForConnections: true, connectionLimit: 50, queueLimit: 0 });queueLimit设为0表示不限制排队数量但如果长时间排队前端那个超时机制会发挥作用这属于业务层的容错设计后面讲常见问题的时候会提到。3. 判题引擎设计与核心实现3.1 判题的整体流程判题是整个平台的技术难点也是最容易出问题的地方。在线编程笔试的判题流程可以拆成这几步考生点击“提交代码”前端把代码和题目ID发送到后端后端把提交记录写入数据库状态置为“等待判题”判题服务从任务队列取出这条记录根据题目配置的参数语言、时间限制、内存限制创建执行环境编译并执行用户代码输入测例数据捕获程序输出和期望输出进行比对把判题结果写回数据库同时通过WebSocket通知前端这里有一个很重要的设计决策判题过程一定要异步化。如果同步判题一个题目跑2秒30个人同时提交就要60秒前端早就超时断连了。异步化以后提交接口只负责存记录、进队列立刻返回“已收到”判题完成后再推送结果用户体验完全不同。用一个合适的比喻来描述判题服务就像一个考场收卷员收卷、批改、登分是不同环节收卷员不会因为一桌人同时交卷就手忙脚乱而是按顺序一张一张处理。3.2 代码执行与安全限制这是判题系统最关键的部分——用户提交的是任意代码必须保证执行环境的安全稳定。我的做法是使用NodeJS的child_process模块在子进程中执行用户代码并加上严格的运行限制。首先要限制执行时间。考生代码里写个死循环如果不加限制整个服务器都可能被拖垮。我在子进程外面包了setTimeout超时直接强制kill进程。其次限制内存。NodeJS程序可以通过NODE_OPTIONS环境变量限制堆内存上限C和Java程序有各自的参数可以配置。最关键的是限制代码的权限。考生代码可能包含文件操作、网络请求等危险调用不能放行。我的做法是把用户代码放到一个隔离的临时目录里执行这个目录在执行完后第一时间删除。另外在受限用户下运行子进程不让它以root权限执行。const { spawn } require(child_process); const fs require(fs); const path require(path); /** * 执行用户代码做判题 * param {string} language 代码语言javascript | python | cpp * param {string} code 用户提交的代码 * param {string} input 测例输入 * param {number} timeLimit 时间限制毫秒 * param {number} memoryLimit 内存限制MB */ function judgeCode(language, code, input, timeLimit 2000, memoryLimit 256) { return new Promise(resolve { // 1. 创建临时工作目录 const workDir /tmp/oj/${Date.now()}_${Math.random().toString(36).slice(-4)}; fs.mkdirSync(workDir, { recursive: true }); // 2. 把代码写入文件 const fileMap { javascript: { name: main.js, command: node }, python: { name: main.py, command: python3 }, cpp: { name: main.cpp, command: g } }; const config fileMap[language]; fs.writeFileSync(path.join(workDir, config.name), code); // 3. 构造启动命令。限制内存、切换工作目录 const args []; const env { ...process.env, NODE_OPTIONS: --max-old-space-size${memoryLimit} }; if (language cpp) { // 先编译 args.push(config.name, -o, main); spawn(g, args, { cwd: workDir }).on(exit, (compileCode) { // ... }); } else { // 非编译语言直接执行 const child spawn(config.command, [config.name], { cwd: workDir, env, timeout: timeLimit }); // 后续处理略 } }); }这只是核心片段完整实现还要处理stdin输入、stdout输出收集、stderr错误捕获、进程退出码判断等逻辑。每个语言都有各自的编译执行差异这部分代码写起来很磨人但有条件的同学尽量把判题环境做成Docker容器隔离效果会好得多也方便部署到单独的机器上。3.3 两种题型的判题逻辑我在实际做系统时发现编程题有两种常见的考法判题逻辑完全不同一开始没区分开导致结果经常误判。标准输入输出型题目要求“读取输入输出答案”比如常见的“计算两个数之和”。判题时把测试数据写入子进程的stdin然后读取stdout和期望输出做比对。这种题型更贴近真实编程场景。函数填空型LeetCode那类给一个函数签名考生实现函数体判题时直接用测试代码调用这个函数检查返回值是否匹配。这种题型不适合用stdin/stdout来判断判题逻辑是注入一段测试代码编译后执行。我的建议是第一版系统先做标准输入输出型判题逻辑统一不需要为每个语言单独写测试脚本。后面再到函数型进一步区分。比对时要忽略末尾的换行符、多个空格压缩等问题不然明明正确答案也会被判错。我写了一个normalizeOutput函数专门处理这类输入输出归一化这个细节非常关键考试时很容易让考生情绪崩掉。4. 考试流程与前端核心功能4.1 在线编辑器集成方案在线编程笔试平台的前端核心就是代码编辑器我当时直接在组件里集成了Monaco Editor就是VS Code的编辑器内核。安装方式很简单npm install monaco-editor monaco-editor/vue3然后封装一个编辑器组件支持语言切换、主题切换、代码自动补全。实际做的时候有几个坑要提醒一下Monaco的worker配置。Webpack环境中不配置worker控制台会报找不到worker脚本。Vite环境下需要这样处理import * as monaco from monaco-editor; import editorWorker from monaco-editor/esm/vs/editor/editor.worker?worker; self.MonacoEnvironment { getWorker: () new editorWorker() };还有一个编辑器体验的细节考生写代码时默认的智能提示可以保留但禁用了自动格式化快捷键不然考生按了某个快捷键代码全乱格式容易增加焦虑感。编辑器默认建议设置只读提示、显示行号、括号颜色匹配这些直接提升考试体验。4.2 考试状态管理和防作弊策略前端有一个专门的考试状态模块统一管理当前试卷、剩余时间、题目切换状态、提交记录、切屏次数。剩余时间用定时器计算每秒钟减少1秒到0时自动触发交卷。时间同步这一点很关键前端倒计时用的是从服务器拉取到的结束时间戳用当前时间戳相减得到剩余时间。不能用前端本地时间做倒计时基准考生改本地时间就能延长考试这是大忌。后端在交卷时还会用数据库的服务器时间校验双保险。防作弊方案我做了两层第一层是切屏检测。监听页面的visibilitychange事件一旦考生切换到其他标签页记录切屏事件和时间。考试页面失去焦点时屏幕上显示警告弹窗提示切屏会被记录。阈值可以设置为超过3次切屏触发警告超过规定次数就禁止继续作答。第二层是试卷个体差异化。同一场考试的考生题目顺序随机打乱选项在选择题中可以随机排列。题目可以从题库按策略抽取比如同一维度难度按比例抽题保证不同考生拿到的试卷难度一致、内容不同。视觉层面前端设置了禁止复制粘贴部分代码场景除外、右键菜单禁用。但这只是一个体验层面的方案技术上一切以服务端校验为准前端限制只是提高作弊成本。4.3 提交判题状态及时反馈考生点击提交后前端轮询或通过WebSocket拿到最新判题状态。为了简化系统第一版我直接用轮询提交后每2秒请求一次接口查状态。后来考生人数多了轮询太多才换成了WebSocket。WebSocket方案是这样的建立连接时携带token和考试ID后端把连接和一个映射关系的Map绑定。判题完成后服务端向这条连接推送结果消息{ type: judge_result, submitId: 123456, judgeStatus: 1, timeCost: 350, memoryCost: 18, score: 100 }前端收到消息后更新当前题目状态栏把判定结果展示出来。实测下来WebSocket方案比轮询的即时性好很多提交后1秒内就能刷出结果而且减轻了服务端接口压力。5. 实战踩坑记录与排查方案5.1 判题服务并发门槛与任务队列系统刚上线时第一个遇到的正式场景就是30个人同时点击提交NodeJS进程CPU瞬间飙到接近100%大量提交请求超时不少考生提交了但收不到结果。原因分析后很明确大量判题任务同时执行每个子进程都占用CPU机器资源被瞬间打满。解决办法是引入一个判题任务队列设置同时执行的任务数量上限。比如机器是4核就设置最多并发执行4个判题任务其他任务在队列中等待。const queue []; let runningCount 0; const MAX_CONCURRENT 4; function enqueue(judgeTask) { queue.push(judgeTask); processNext(); } function processNext() { if (runningCount MAX_CONCURRENT || queue.length 0) { return; } runningCount; const task queue.shift(); task.execute().finally(() { runningCount--; processNext(); }); }实际上更成熟的方案是把判题做成独立服务部署在单独的机器上或者使用Redis做分布式消息队列。不过对于一次几百人的校招笔试简单任务队列已经能稳定扛住。5.2 MySQL慢查询优化案例考试结束后查看成绩是管理员高频操作。第一版我直接在submission表上做聚合统计结果在数据量几万条时这个接口响应时间超过3秒体验很差。优化做了两方面一是加了一个exam_scoresheet表考试交卷时实时更新成绩二是把submission表按考试ID分区减少全表扫描范围。分区的效果非常明显按考试ID查询时只扫对应的分区响应时间从3秒下降到了300毫秒以内。这个案例想说的是优化查询不要上来就想着加缓存先看执行计划确认是不是缺了索引、是不是能在写入时就把统计结果算好。很多问题其实一条合适的索引就解决了。5.3 前端编辑器常见的体验问题Monaco在低性能设备上加载速度很慢。我最初把Monaco打包在首屏Bundle里结果考试页面首屏加载花了十几秒非常影响体验。解决方案是Monaco改为按需加载进入考试页时才动态加载编辑器资源并且使用CDN缓存。实测下来首屏时间从十几秒降到3秒多有些考生用旧电脑也能顺畅考试。还有一个中文输入法的问题考生用拼音输入法时输入过程中的拼音字母会被编辑器当作代码内容导致代码错乱。我的处理是监听compositionstart和compositionend事件在输入法组合期间禁用编辑器的部分操作这个过程本身不算复杂但极重要。这个坑如果没提前处理考场里会遇到大量“代码莫名花了”的投诉。5.4 常见问题排查速查表问题现象可能原因解决方案提交后状态一直是“等待判题”判题服务未启动或任务队列卡死检查Node进程是否存活查看日志中队列消费情况用户代码死循环导致CPU飙升缺少超时限制或子进程隔离不当检查执行命令是否使用了timeout参数确认子进程kill逻辑高并发时数据库连接失败MySQL连接池太小调大连接池上限增加排队等待限制判题结果和本地运行不一致测试用例格式问题或输入输出归一化处理遗漏检查用例JSON格式确认输出比对前做了换行和空格归一化WebSocket频繁断开反向代理未配置Upgrade协议Nginx中配置Upgrade和Connection头信息考试中代码突然丢失前端本地存储溢出或状态管理失误定时自动保存到localStorage提交时从状态中获取最新代码6. 部署上线与后续演进方向6.1 PM2进程管理与Nginx反向代理部署上线的时候NodeJS服务不能直接用node命令裸跑万一进程挂了服务就完全不可用了。我用PM2做进程守护配置了cluster模式利用多核CPU同时设置内存超限自动重启。// ecosystem.config.js module.exports { apps: [{ name: oj-server, script: ./src/app.js, instances: max, exec_mode: cluster, max_memory_restart: 500M, env: { NODE_ENV: production, PORT: 3000 } }] };启动命令很简单pm2 start ecosystem.config.js。PM2还内置了日志管理我用pm2 logs随时查看错误输出这比自己在代码里写日志文件要方便。前端打包后是纯静态文件用Nginx托管同时把/api和/ws路径反向代理到NodeJS服务。这里有一个关键配置WebSocket的代理需要额外设置Upgrade头信息否则连接建立不起来。server { listen 80; server_name oj.example.com; root /var/www/oj/dist; index index.html; location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }实测中这个请求链路整体稳定加上HTTPS证书配置线上跑了几场模拟考试没有出现明显问题。6.2 判题服务独立部署方案上面提到用内置任务队列其实这只适合单机场景。如果考生规模再大判题服务应该做成独立模块甚至独立部署。演进方案是后端收到提交后把任务写入Redis队列判题服务从Redis取任务执行结果再写回MySQL和Redis。Redis在这里充当消息中间件贴上这个架构可以让系统轻松扛住更大规模并发。我用手机流量做过一次模拟实测30人并发提交单机NodeJS4核判题队列90秒内所有任务全部完成考生端平均收到结果的延迟在10秒以内。对于笔试场景这个性能已经够用了毕竟一场考试不可能几千人同时精准提交。6.3 后续还能扩展什么方向系统的基础功能完成后有几个很有价值的扩展方向第一是代码相似度检测。 考生的作弊手段很多是互相传代码后端存了所有提交记录可以跑字符串相似度算法把相似度超阈值的代码标记出来。这个功能对企业校招非常实用。第二是成绩分布统计。考试结束后按难度拆开统计正确率判断题库质量。我自己在做这个功能时发现不少题目的正确率和预估难度差异很大这就是调整题目难度和分数赋值的重要依据。第三是AI辅助评阅思路。后续可以和主流AI能力做结合为每道题生成个性化点评。不过这涉及到预算问题和服务稳定性属于远期规划了。结束语几点最后的体会整个平台从零搭建到上线最大的感受是“在线编程笔试平台”看起来只是一个普通的管理系统但实际上真正的难点和核心价值都在判题引擎的安全性和稳定性上。数据库设计、前端编辑器集成、防作弊逻辑这些只要动手做都能掌握但判题这关如果没做好系统就是一个只能存代码的电子文档库。我给准备复现这个项目的同学留三条建议第一先做最小可用版本从JS语言一种判题开始跑通全流程再加其他语言不要贪多。第二测试用例的设计一定要覆盖边界情况0值、空输入、大数据量都测一遍。第三部署上线前一定做一次并发模拟测试把连接池参数和判题队列并发数调好否则正式使用时你会收到大量考生反馈。希望这篇记录对你有参考价值。这套系统的代码已经从零写好跑通如果你在实现过程中遇到具体问题可以根据上面的设计思路排查调试大概率能找到问题所在。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑