做在线编程笔试平台这件事,真正上手之后才会理解,能让成绩服众是一个系统能否存活的前提。你以为的难点是把代码提交上来,实际上的难点是判题准确、防作弊可追溯、异常恢复不丢代码。这篇文章记录了我基于 NodeJS + Vue + MySQL 从零搭建一个在线笔试平台的全过程,包括需求拆解、数据库设计、前后端实现,以及实测中踩过的几个大坑。如果你正在做招聘笔试系统、校招考试系统或者在线编程评测平台,这应该是你需要的少走弯路的参考。
1. 在线笔试平台真正要解决的三件事:判题、纪律与异常兜底
1.1 判题、纪律与异常兜底,哪个都不能少
标题里写着“在线编程笔试平台”,很多人第一反应是“做一个能在线写代码、交卷、出分的网站”。真做起来才发现,代码展示和提交只是最普通的一环。一个能在真实考场稳定运行的笔试平台,至少要同时解决三件事。
判题。考生提交的代码在浏览器里只是文本,到了服务器上,它要被编译、运行、喂入测试数据、捕获输出,再和标准答案比对,最后换算得分。这一步不仅要快,还要防死循环、防内存爆炸、防越权读文件。判题坏了丢分还是小事,测试时被恶意代码拖垮服务器才是大麻烦。
会场纪律。线下考试有监考老师,线上只能靠切屏记录、窗口失焦记录、心跳超时来辅助判断。如果系统只默默记录而不在切屏时弹提醒,考生切出去搜答案的心理成本就太低了。
异常兜底。一场真实考试里,几乎必然有考生遭遇浏览器误刷新、网线松动、电脑休眠。如果刷新一次代码全丢,考生心态直接崩掉,后续任何成绩都有争议。
1.2 三类使用者决定了选型方向
把使用对象梳理清楚,技术选型就顺理成章了。这个平台上有三类角色。
考生端:登录、看题、写代码、自动存草稿、提交、查判题结果。界面必须简洁,任何多余按钮都会在紧张状态下引发误操作。
出题端:创建考试、维护题目、配置测试用例和判题规则。这个角色一般由管理员承担,操作频率低,但要求的灵活性高。
监考端:查看考试实时状态、查切屏记录、处理成绩异议、导出成绩。
角色和关系摆出来,数据模型的轮廓就清晰了:考生—考试—题目—答卷—判题记录,是一条强关系链。加上“成绩可信度”这个硬指标,我用关系型数据库 MySQL 存状态和成绩,用文件系统存测试用例数据。后端选 NodeJS,是因为前后端可以共用 JavaScript,判题 worker 里跑脚本语言也很顺手;前端选 Vue,开发效率和组件复用都不错。单场考试百人规模,单机部署这套组合实测完全扛得住。
2. 数据库设计:五类表支撑出一场考试的完整生命周期
2.1 五张核心表串起的完整数据链路
在线笔试的数据模型,本质是一条链路:考生报名一场考试,考试包含若干题目,考生对每道题产生一份答卷,答卷送进判题队列,判题结果回写成绩。
我用五张核心表串起这条链路。第一张是用户表 user,存账号、密码哈希、角色。第二张是考试表 exam,关键字段是 start_time、end_time、duration 和 status。第三张是题目表 question,除了题目描述、样例和分值,还需挂一组测试用例文件的路径。第四张是答卷表 answer,这是最重要的表。第五张是判题记录表 judge_record,每次判题产生一条记录,和答卷表保持一对一。
建表语句的骨架长这样:
CREATE TABLE exam ( exam_id INT AUTO_INCREMENT PRIMARY KEY, exam_name VARCHAR(128) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration INT NOT NULL COMMENT '考试时长(分钟)', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束' ); CREATE TABLE question ( question_id INT AUTO_INCREMENT PRIMARY KEY, exam_id INT NOT NULL, title VARCHAR(128) NOT NULL, description TEXT, sample_input TEXT, sample_output TEXT, score INT NOT NULL, testcase_path VARCHAR(255) NOT NULL ); CREATE TABLE answer ( answer_id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, exam_id INT NOT NULL, question_id INT NOT NULL, draft_code LONGTEXT, submitted_code LONGTEXT, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已提交 2判题中 3已判题', score INT DEFAULT NULL, submit_time DATETIME DEFAULT NULL, UNIQUE KEY uk_user_exam_question (user_id, exam_id, question_id) ); CREATE TABLE judge_record ( record_id INT AUTO_INCREMENT PRIMARY KEY, answer_id INT NOT NULL, judge_status TINYINT NOT NULL COMMENT '0编译失败 1运行错误 2部分通过 3全部通过', compile_output TEXT, run_result TEXT, exec_time_ms INT, memory_kb INT, test_case_passed INT, test_case_total INT );这里有两个设计取舍要说明。
第一,测试用例不直接放表里。我按 question_id 建目录,每道题一个 JSON 文件,里面包含输入、期望输出、权重和评测方式。原因很简单:测试用例数据可能很大,塞进 MySQL 会让表结构臃肿,查询还慢。文件方式读取快、扩展方便,管理后台维护题目时直接操作文件系统即可。
第二,草稿代码和提交代码分开存。草稿是考生编辑过程中自动保存的临时内容,提交代码是明确点击“提交”后的锁定版本。判题只针对 submitted_code,草稿永不进判题流程。这个分离让“继续答题”和“交卷出分”两条链路互不干扰,也方便后期追溯。
2.2 状态字段:考试数据里真正的规则
笔试系统的核心不在字段多不多,而在状态是否清晰。我主要维护三组状态。
考试状态按时间推算,status 用 0/1/2 表示未开始、进行中、已结束。注意,这里不用数据库里定时任务去改状态,而是在查询接口里根据 start_time 和 end_time 动态计算,避免定时任务漏跑导致考生进不了考场。
答卷状态是草稿→提交→判题中→已判题。判题 worker 只消费 status=1 的记录,消费前用条件 UPDATE 抢锁,抢不到说明已在判,任务直接丢弃。完整流程严格串行,防止重复判题。
判题状态是编译失败、运行错误、部分通过、全部通过。判题记录表里还会保存编译输出和每个用例的运行结果摘要,方便管理员排错,也给考生展示“有用例未通过”的反馈。
2.3 并发写入的底层保护
考试结束瞬间几十人同时交卷,数据库瞬时写压力不大,但并发问题依然存在。我在 answer 表建了联合唯一索引 (user_id, exam_id, question_id),保证同一考生同一道题只有一行答卷。再用事务包住“提交算分”的关键步骤。判题 worker 回写成绩时必须用条件 UPDATE,绝不能先 SELECT 再 UPDATE。这两个机制让竞赛式并发场景下也不会丢成绩。具体 bug 和修复过程,我在第 5 节单独复盘。
3. NodeJS 后端:会话鉴权、判题队列与接口链路
3.1 分层与接口主链路
后端我分成四层:路由层只做参数校验,服务层写业务逻辑,数据访问层封装 SQL,worker 层消费判题队列。这样做的收益是:后来想加缓存或换消息队列,只需要动数据访问层和 worker 层,路由和服务不受影响。
接口主链路如下:
- POST /api/auth/login,登录并签发 JWT
- GET /api/exam/list,获取当前用户可见的考试列表
- GET /api/exam/:id/detail,拿考试详情和题目列表
- POST /api/answer/draft,保存草稿
- POST /api/answer/submit,提交答案
- GET /api/answer/result,查询判题结果
- POST /api/admin/exam、POST /api/admin/question,管理端维护考试与题目
登录鉴权用 JWT 就够了。体验上有个细节:token 过期时间要设置为考试时长加半小时,防止考生在交卷前 token 过期被强制踢出。
3.2 判题 worker:限时、限输出、限内存
判题队列是整个系统的核心。我没有引入消息中间件,而是用 NodeJS 的 Events 模块在进程内做任务队列。为什么不用 RabbitMQ 或 Kafka?百人规模笔试的判题吞吐量每秒也就几个用例,引入中间件徒增运维成本。队列真正要防的是单任务失控。
worker 消费一个判题任务时执行如下流程:
- 把 submitted_code 写入工作区临时文件。
- 根据语言调用编译器或者解释器。
- 依次读取测试用例,注入 stdin,捕获 stdout/stderr,统计耗时和内存。
- 把输出和期望输出比对,记录通过数。
- 汇总成绩,回写 answer 表和 judge_record 表。
- 通过 WebSocket 通知前端判题完成。
第 3 步是最容易翻车的地方。用户代码可能死循环、可能申请大量内存、可能疯狂打印输出。每个用例子进程都要设置硬超时,我默认 2 秒,超时立即 kill。内存用操作系统级 ulimit 限制,比如把虚拟内存限制到 256MB,能从根上拦住“申请大内存拖垮机器”的恶意代码。
我贴一段关键代码:
const { spawn } = require('child_process'); function runWithSandbox(command, args, input, { timeoutMs = 2000, memMB = 256 } = {}) { return new Promise((resolve) => { const child = spawn('bash', ['-c', `ulimit -v ${memMB * 1024}; ${command} ${args.join(' ')}`], { stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d.toString(); }); child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('exit', (code) => resolve({ code, stdout, stderr })); child.on('error', (err) => resolve({ code: -1, stdout, stderr: err.message })); child.stdin.end(input); }); }这段代码还有改进空间,比如超时后必须 child.kill() 并把状态记为“运行超时”。但整体思路是:把每个用例隔离成独立子进程,给它们在 shell 层套上资源限制。
输出量控制同样重要。用户在代码里写一个死循环去打印日志,stdout 会无限膨胀。spawn 的 maxBuffer 默认 1MB,不够保险。我在子进程 stdout 上做切块收集,累计超过 5MB 直接终止进程并判定运行错误。这个细节不加,有人用一段打印脚本就能灌爆整个服务。
3.3 JWT 与考试时间校验
后端要守住的另一个核心规矩是:所有写操作都必须在考试窗口内发生。考生把代码写好放在本地,考试结束 5 分钟后再把接口包装成“提交”请求,如果不校验,就会得到一张超时交的卷子。
实现很简单:服务端在处理保存草稿和提交答案时,先从数据库读取当前考试时间窗口,再和服务器当前时间比对。时间以服务端为准,前端倒计时只是展示层,不参与判定。同时,提交请求里带的 JWT 保存着用户的 userId 和 examId,服务端要校验这两个值是否匹配当前路径参数,防止考生越权操作别人的答卷。
4. Vue 前端:编辑器、倒计时和三个让人崩溃的边界场景
4.1 编辑器选型:CodeMirror 还是 Monaco
浏览器里的代码编辑器是这个平台最容易被低估的部分。考生要在里面敲一两个小时的代码,如果编辑器卡顿、快捷键失灵、输中文出 bug,这位考生的心情会直接影响整场考试的评价。
选型上,Monaco 功能最强,智能提示和性能对超大文件表现都很惊艳,但体积大,首屏加载慢。笔试场景考生操作路径非常固定,只是分屏看题加编辑代码,不需要满配工作台。我选了 CodeMirror 6,体积小、启动快、语法高亮足够,课堂笔试场景完全够用。如果产品规划要做 AI 辅助编程、代码自查这类进阶功能,再切 Monaco 也不迟。
编辑器组件在 Vue 里的集成方式很直接,拿到题目后创建编辑器实例,监听 change 事件做草稿自动保存即可。
<template> <div ref="editorContainer" class="code-editor"></div> </template> <script setup> import { ref, onMounted } from 'vue'; import { EditorView, basicSetup } from 'codemirror'; import { javascript } from '@codemirror/lang-javascript'; const props = defineProps({ modelValue: String, disabled: Boolean, }); const emit = defineEmits(['update:modelValue']); const container = ref(null); let view = null; onMounted(() => { view = new EditorView({ doc: props.modelValue || '', extensions: [ basicSetup, javascript(), EditorView.updateListener.of((update) => { if (update.docChanged) { emit('update:modelValue', update.state.doc.toString()); } }), ], parent: container.value, }); }); </script>这里要多说一句:不要把整个编辑器实例直接塞进双向绑定里,性能会出问题。正确做法是监听 docChanged 事件向外发更新,父组件收到后做防抖、存 localStorage、向后端同步草稿。
4.2 答题状态机:草稿、提交、判题中
一个考生的答题卡上,每道题有四种视觉状态:未开始、草稿已保存、判题中、已完成。这四种状态在 Vue 里用状态库管理。每道题的本地操作先更新 store,再异步同步服务端。UI 上,判题中的题目按钮必须 loading 并禁用,防止考生重复点击触发重复判题。
状态流转这块最容易出现的问题是“用户在提交瞬间又改了几行代码”。处理方式:点击提交后,前端立刻把当前代码锁定为 submitted_code,并把编辑器切到只读模式;后端收到提交请求后只认 submitted_code,不再理会后续草稿更新。提交完成到判题结果返回这一段,考生看到的是“判题中”,不能改,这样成绩才有权威性。
4.3 倒计时与断线恢复
倒计时看似简单,做不好会翻车。真实考试中考生改本地系统时间或电脑休眠,如果前端只用本地时钟算剩余时间,成绩可信度就被钻了空子。我的做法是:倒计时基准始终用服务器返回的 end_time,前端算出剩余秒数,每隔 3 到 5 分钟向后端同步一次服务器时间校准。电脑休眠唤醒后,剩余秒数可能直接变成负数,此时触发“时间已到”,锁定试卷并拉取最终成绩。
断线恢复同样重要。心跳接口每 30 秒打一次,连续两次失败就提示“网络连接异常,请检查网络”。代码继续存在本地,不锁死操作。网络恢复后,前端批量上传所有未同步的草稿。不要因为断网就把提交按钮禁用,考生完全可以把代码保存下来,等网络恢复再提交,后端只认截止时间。
切屏行为同样在前端处理。监听 visibilitychange 和 window blur,触发时向后端写一条 monitor_log,同时弹非阻塞提示“已记录切屏行为”。设计原则是“记录 + 提醒 + 交给后台”,而不是强行锁界面。否则换个输入法弹窗也会被记录,考生只会觉得系统很烦。
5. 实测踩坑:卡死判题、代码越权、并发丢分与刷新丢代码
这一节是我最想写的。前面所有设计都源于真实踩坑,按时间顺序复盘四个印象最深的坑。
5.1 死循环代码卡死整个判题进程
第一版判题逻辑我直接写在 HTTP 请求处理器里,本地单用例测试没问题。直到压测时有人提交了一段while(true){},服务器整个 Node 进程 CPU 100%,其他所有请求全部无响应。刚开始还以为是数据库连接池满了,后来用 top 定位到 Node 进程占满单核,才意识到是用户代码把事件循环彻底占住了。
根源链路往下捋:判题任务挤在请求线程里,CPU 被死循环占住后,心跳、提交、查询全部排队,等于整个考试服务瘫痪。修复分两层:第一层把判题从请求线程摘出去,做成独立 worker 进程消费队列;第二层给每个测试用例的子进程设置硬超时,超时立即 kill。两层一起落地后再用死循环压测,主服务完全不受影响。
5.2 越权读取文件:一次内部测试触发的警示
内部联调时,我故意写了一段代码去读取服务器本地文件,比如系统配置文件,结果真的读到了。这个漏洞在真实的招聘场景下足以致命:考生读完别人试卷里的标答,成绩直接失去意义。
修复分三层。第一层,工作目录隔离:每个判题任务在独立临时目录运行,任务结束后删掉整个目录。第二层,权限剥离:运行用户代码的子进程改用低权限操作系统用户启动,不授予任何文件系统的写权限。第三层,网络访问阻断:隔离环境禁止外网连接,防止读取到的信息被“传出去”。
要说清楚一点,纯子进程隔离只能防普通考生。在严肃校招或竞赛场景,最稳妥的方案是容器隔离加资源配额。容器镜像瘦身,不带敏感工具,容器内无法访问宿主文件系统,网络默认禁用,CPU、内存、磁盘全部配额化。本文方案更适合内部笔试、课程评测这类可信度要求没那么极端的环境。
5.3 并发交卷丢分的根因与修复
模拟 100 人同时交卷时,有几名考生的题目状态是“已判题”,但 score 字段是空值。后台看判题记录,判题记录都在,就是答卷表里成绩没写进去。
根因藏在判题 worker 回写成绩用的“先查再写”逻辑。worker 判完先查询当前答卷状态,等于已提交就更新成判题中,再判题,完成后更新成绩。并发场景下,两个判题任务可能同时查询到同一行,都以为自己是合法的,后完成的任务把前一个的成绩覆盖掉。
修复手法是让所有状态修改都带上条件。消费任务用UPDATE answer SET status=2 WHERE answer_id=? AND status=1,影响行数为 0 说明已经有人在判,直接丢弃本次任务。判题完成用UPDATE answer SET score=?, status=3 WHERE answer_id=? AND status=2 AND judge_record_id=?,保证只有最新判题记录能写回成绩。配合联合唯一索引,这个 bug 彻底消失。
5.4 刷新丢代码:双层草稿方案的诞生
这个问题在验收环节差点出大丑。有人写了一半答案按了一下 F5,页面刷新后代码全没了,他说这个系统怎么敢上线。确实,考试系统最核心的用户信任就是“我写的东西不能丢”。
修复方案是双层草稿。第一层,前端防抖自动保存:考生停止敲键盘 1 秒后,把代码写入 localStorage,刷新后立即回显。第二层,服务端定时草稿:每 15 秒把草稿代码推送到后端,跨设备或清缓存后也能找回。刷新后先取 localStorage 快照,再从服务端拉最新草稿,两处数据以服务端为准无缝替换。恢复完成后弹一个非阻塞提示,不让考生惊慌。
6. 上线前需要做的几件事:安全、并发与体验自查
系统开发完成、功能自测通过只是第一步。我习惯在真正组织一场考试前,再花两三天跑一遍下面的验证清单。
6.1 安全用例集:用恶意代码打自家平台
我会维护一组“攻击用例”脚本,定期在平台上跑一遍。覆盖场景包括:死循环、无限递归、申请大内存、读文件、大量写文件、输出超大文本、衍生大量子进程。这些用例跑完,逐个确认平台拦截结果。漏掉的就补规则。这一步虽然费时间,但判题核心一旦被击穿,整个平台就没有任何公信力可言。
6.2 并发压测与成绩一致性核查
百人规模我不上重型压测工具,直接用 Node 写脚本,模拟 100 个请求同时提交答案,再查成绩表做一致性断言。重点看三个指标:交卷接口失败率必须为 0;成绩记录数和答卷数一致;判题完成耗时在可接受范围。我的实测里,单机 100 道题全量判题约 2 分钟内完成,人均等待不难受。如果交卷失败率不为 0,优先排查数据库连接池和事务冲突。
6.3 全流程演练与边界体验自查
最后,在正式考试前拿一道真实题目从出题、组卷、答题、提交、判题、出分走一遍全流程,再选一个同事扮演“考生”故意制造异常操作:中途刷新、切屏、断网、最后 30 秒交卷。所有环节顺畅了才放进真实考场。我自己的经验是,这个演练至少能揪出三个测试环境发现不了的联动问题,值得花掉大半天。
我现在的习惯是,每开一场新考试前,会把上一场的监控记录和判题记录做一次复盘。看有没有考生明明写得很顺却因为切屏记录太多被人工标记;看有没有判题耗时异常的题目用例;看最后 10 秒的提交压力曲线,估算下次要不要把服务器规格提到两倍。这些运行期数据才是这套平台持续稳定运转的底气。