我这个项目前后花了三周左右,从需求评审到正式上线,给一家职业培训机构做的在线远程考试系统。技术栈选的是SpringBoot3 + Vue3 + MyBatis + MySQL 8.0,这个组合听起来不新鲜,但在真实考试业务里,它比我预想的要稳得多,也踩了不少文档里不会写的坑。
这篇文章不打算从零教你敲一遍代码,那太浪费你时间。我更想把在线远程考试系统从设计到上线的关键决策、核心数据表、交卷那一瞬间后端干了什么、Vue3前端怎么管考试状态,以及部署时遇到的几个真实问题,一次性讲透。你要是正准备做类似系统,或者刚拿到一套源码想改成自己用的,这篇应该能帮你少走不少弯路。
1. 考试系统选型:为什么SpringBoot3+Vue3这套组合依然是2025年的稳妥答案
1.1 在线考试系统的真实业务画像
先别急着选技术,先搞清楚考试系统到底在解决什么问题。我这里接到的需求是这样的:题库里有几千道题,按科目、知识点、题型、难度分类;考试分两种,一种是有监考老师盯着看的集中式机考,另一种是考生自己在家远程考,需要防作弊;一场考试最多能同时容纳五六百人;考试结束需要自动阅卷和人工阅卷结合,分数要能导出,还要支持补考、重考。
把这些需求列出来,你会发现它和很多互联网应用不太一样。它不是一个高并发读写的系统——几百人同时在线考试,QPS其实很低,真正的压力集中在两个时刻:一个是开考瞬间所有人同时刷出试卷,另一个是交卷瞬间所有人同时写答题记录。这两个峰值只要提前设计好,单机MySQL完全扛得住。所以选型的第一原则就是:不要为了技术热度引入复杂度。
1.2 为什么不是微服务,也不是复杂中间件
项目立项时有人提议用微服务,拆出用户服务、题库服务、考试服务、阅卷服务。我当时直接否了。原因很简单,一个几百人同时用的考试系统,微服务带来的网络开销、分布式事务、部署成本,全是负资产。你需要的是一套能快速上线、易于维护、出了问题能单点排查的系统。
SpringBoot单体应用在这个场景下是够用的。Tomcat默认线程池200,加一下配置轻松支持上千并发;MySQL 8.0加上合理索引和处理交卷事务,压测下来,500人同时交卷也就几十秒内全部落库。所以我的选型是:
- 后端:SpringBoot 3.2.x + MyBatis 3.5.x + MySQL 8.0
- 前端:Vue 3.4 + Vite + Pinia + Element Plus
- 缓存和限流:Redis(单机版就够)
- 文件存储:本地磁盘 + Nginx静态映射
这套组合的好处是,任何一个环节出了问题,团队里随便一个Java开发或前端都能接手。你换一套微服务,光运维成本就够喝一壶的。
1.3 版本选型踩过的坑:javax到jakarta的变化
如果是直接拿SpringBoot 2.x的老项目改造,或者习惯用JDK8,这里有个大坑要提醒。SpringBoot 3.x强制要求JDK17及以上,而且包名从javax.*改成了jakarta.*。这意味着很多老代码里的import javax.servlet、import javax.annotation.Resource全部要改。
我第一次把项目从SpringBoot 2.7升级到3.2时,mvn编译直接报了一堆红,都是包名问题。用IDEA全局替换javax变成jakarta能解决大部分,但像MyBatis等第三方库也要注意是否支持SpringBoot3。好在MyBatis 3.5.5之后的版本都兼容了。
前端也一样,Vue3需要Node.js 18以上,如果本机还是Node 14或16,npm install会各种报错,Vite 5直接不支持老版本Node。我的建议是直接用Node 20 LTS,别用奇数版本,省得后面调试崩溃。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型:考试记录和答题明细必须这么设计
2.1 五张核心表,少一张都会出问题
在线考试系统的数据表我不建议设计得太花哨,但下面这五张是刚需:
question:题目表,字段包括题目内容、题型、选项、正确答案、分值、所属科目、知识点、难度、状态。paper:试卷表,记录一张试卷的基础信息。paper_question:试卷和题目的关联表,记录这套试卷抽了哪些题,每题的顺序、分值。exam_record:考试记录表,每一个考生参加一次考试就是一条记录,字段包括考生ID、试卷ID、开始时间、交卷时间、总得分、状态。answer_detail:答题明细表,考生对每一道题的作答记录,字段包括考试记录ID、题目ID、考生答案、判分结果、得分。
除了这五张,如果你有主观题人工阅卷的需求,可能还要一张manual_review表,记录阅卷人、阅卷时间、阅卷得分。但核心就是上面这些。
设计的时候有个原则:答题明细表和考试记录表必须分开。最开始我图省事,想直接在exam_record表里放一个JSON字段存所有答案,后来发现要查某道题的对错率、做试卷分析、按题目维度统计正确率时,JSON字段查询特别别扭,索引用不上,SQL写得想骂人。
2.2 为什么必须做试卷快照
这是我觉得整个系统设计里最重要的一张表:paper_snapshot。它解决的是一个特别实际的问题:题库里的题目不是一成不变的,某个老师可能考完试第二天就把一道题的正确答案改掉了。如果没有快照,后面回溯某个考生的成绩时,看到的是已经改过的题目和答案,分数就对不上了。
我的做法是:发布考试时,后台就把这套试卷的题目和正确答案复制一份到paper_snapshot表,考生交卷后阅卷全部从快照里取题,而不是从question表取。这样无论题库怎么变动,历史成绩都是稳定可追溯的。
这个改动的代价很小,就是发布考试时多一次批量insert,但收益是巨大的。考生如果对成绩有疑问,管理员能清晰看到考生当时考的是哪些题、正确答案是什么,而不是一句"以当前题库为准"糊弄过去。
2.3 答题明细表主键和索引设计
答题明细表是整个系统数据量增长最快的表,一次在线考试500人,每人50道题,一次考试就产生25000条明细。这时候主键和索引设计不能拍脑袋。
我用的是自增bigint主键,并且建了两个关键索引:
- 唯一索引
uk_record_question(record_id, question_id),防止同一个考生对同一道题重复插入数据。 - 普通索引
idx_record_id(record_id),方便交卷后按考试记录查所有答题明细。
这里有一个真实踩过的坑:最初我把唯一索引建反了,建成了(question_id, record_id),虽然也能保证唯一,但在按record_id查明细时,这个复合索引的左边前缀是question_id,导致record_id的查询走不上索引,交卷后加载答题记录特别慢。后来调整成(record_id, question_id),查询直接从几十毫秒降到了几毫秒。
2.4 MySQL 8.0建表的几个细节
字符集务必用utf8mb4,别再用utf8。utf8在MySQL里最多只支持3个字节,存一个特殊表情就报错,考生答案里如果有特殊符号(比如数学题的π、化学元素符号)很容易出问题。排序规则我用的是utf8mb4_general_ci,够用。
还有一个小建议:分数相关的字段不要用float和double,直接用DECIMAL(10,2)或者DECIMAL(5,2)。浮点数在累加时会有精度误差,比如60道题每道1分,浮点累加出来可能是59.999999998。虽然差得不多,但考试成绩这东西,差0.01都会被考生质疑,还不如一开始就避开。
3. 出题、抽题、阅卷:考试业务里最容易写崩的三块逻辑
3.1 随机抽题的SQL性能陷阱
题目要求是从题库里按题型、知识点、难度抽题,比如"单选题20道,出自第一章5道、第二章5道;多选题10道,难度系数0.3到0.7"。
新手最常见的写法是ORDER BY RAND() LIMIT n。这在数据量小的时候没毛病,但题库几千道题、字段又多时,ORDER BY RAND()会让MySQL把所有行都读出来随机排序,再取前n条,性能会很差。一万道题抽20道,查询可能就要几秒钟。
我的做法是分两步:
第一步,先做知识点维度统计,确定每个知识点抽几道:比如第一章需要5道单选题,第二章需要5道,第三章不需要。这个结果在发布考试时就已经算好存入paper_question表。
第二步,抽题时不在MySQL里做随机排序,而是在业务层先查出该知识点、该题型下的所有题目ID,比如第一章单选题一共有80道,我再通过随机偏移量从这80个ID里取5个。这样SQL查询走的是索引,随机逻辑在内存里完成,性能好得多。
3.2 客观题自动判分,主观题不要硬碰
客观题(单选、多选、判断)自动判分逻辑相对直接。单选题比对user_answer和correct_answer;多选题我采用的策略是"完全匹配才得分",选多、选少、选错均为0分,这么做虽然严格,但规则清晰,考生没有争议空间。
需要注意的一个细节是:选项答案统一大写,入参做一次trim().toUpperCase(),不要存小写,否则a和A算错就是事故。
主观题(简答、问答、作文)我坚决不搞自动判分的花活。系统把这些题标记为AWAITING_REVIEW状态,后台人工阅卷列表里按题型归组,阅卷老师一道一道打分。网上有各种基于文本相似度的自动阅卷方案,但真实考试成绩是有法律效力的,算法误判导致考生投诉,麻烦远大于省下的一点人力。
3.3 交卷接口必须整体事务,否则会数据不一致
交卷是整个系统里最核心的接口。前端点击交卷,后端要做的事情是:把答题明细批量写入answer_detail、读取快照判分、汇总总分、更新exam_record状态和成绩。
这四个步骤如果在多个方法里分头执行,中间任何一步失败,就会出现考生已经交卷但成绩是0,或者答题明细写了一半然后考试记录状态还是"考试中"的尴尬情况。我的做法是外层加@Transactional,把这四步包成一个事务。交卷接口的内部流程大概是这样:
java复制@Transactional(rollbackFor = Exception.class)
public SubmitResult submitExam(SubmitExamRequest request) {
// 1. 校验考试记录状态,防止重复交卷
ExamRecord record = examRecordMapper.selectById(request.getRecordId());
if (record == null || record.getStatus() != ExamStatus.EXAM_IN_PROGRESS) {
throw new BusinessException("考试记录不存在或已交卷");
}
// 2. 批量插入答题明细
answerDetailMapper.batchInsert(request.getAnswers());
// 3. 从快照读取题目,逐题判分
List<PaperSnapshot> snapshots = snapshotMapper.selectByPaperId(record.getPaperId());
BigDecimal totalScore = gradingService.grade(record, snapshots, request.getAnswers());
// 4. 更新考试记录
record.setTotalScore(totalScore);
record.setStatus(ExamStatus.SUBMITTED);
record.setSubmitTime(new Date());
examRecordMapper.updateById(record);
return new SubmitResult(record.getId(), totalScore);
}
这里有一个特别容易犯的错:先更新了考试记录状态,再写答题明细,或者直接在判分过程中去question表查题而不是去snapshot。顺序反了、查错了表,轻则数据对不上,重则交卷失败后考生还能再次交卷,造成重复数据。事务加上状态校验,这两层双保险才能挡住绝大多数问题。
另外要注意,这种批量插入最多一次别塞太多,像我这边的场景一次交卷50道题,直接用MyBatis的foreach批量插入没问题。但如果有人做那种一次几百道题的考试,最好分批插入,每批100条,防止超过MySQL的max_allowed_packet限制。
4. MyBatis在项目里真正用到的几个高级能力
4.1 批量插入答题明细的动态SQL
答题明细的插入我用的是MyBatis的<foreach>动态SQL,这是最基础也最常用的能力。但这里有个细节容易被忽略:values里的每个字段都要对上,而且必须注意批量插入时的SQL长度。
MyBatis的默认实现是拼接一条超长的INSERT INTO ... VALUES (...),(...),(...),如果一次提交几百条,SQL长度会非常大,可能触发数据库的max_allowed_packet限制。我当时的做法是200条一批,用ListUtils.partition或者自己写个循环分批执行,稳得很。Java配置里还可以开启一个参数:
yaml复制spring:
datasource:
hikari:
jdbc-url: jdbc:mysql://localhost:3306/exam_db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true
rewriteBatchedStatements=true这个参数能让你用SqlSession批量插入时真正走JDBC的批量通道,而不是一条条执行,插入性能提升好几倍。
4.2 自定义TypeHandler处理JSON答案
有些题型的答案不是简单字符串,比如多选题答案是["A","B"],填空题答案是["北京","2025"]。如果你不想在实体类里把字段定义成String然后手动JSON序列化,可以自定义一个TypeHandler。
继承BaseTypeHandler<List<String>>,在setNonNullParameter里用ObjectMapper把List转成JSON字符串,在getNullableResult里把JSON字符串转回List。然后字段上加@TableField(typeHandler = JsonStringListTypeHandler.class)(MyBatis-Plus)或者XML resultMap里指定typeHandler。
用TypeHandler的好处是,业务代码里读到的是正常List对象,不用到处处理JSON解析异常。
4.3 为什么考试系统默认不要开MyBatis二级缓存
网上很多教程会列MyBatis的二级缓存配置,但做考试系统我的建议是:别开。
先说一级缓存。一级缓存默认是开启的,它是SqlSession级别的缓存,同一个Session内重复查询相同SQL会直接走缓存。这个在开发时看起来很美好,但在考试系统里有个隐患:如果我开启了事务,某个考生交卷前查询了一次自己的答题记录,然后交卷失败回滚,再次查询时可能读到的是旧缓存里的数据,导致明明没交卷却看到已交卷的情况。虽然UPDATE操作会清缓存,但时序一旦没对上,排查起来特别心累。
二级缓存默认关闭。就算开了,它默认缓存粒度是「每个Mapper namespace」,不同表之间的关联查询、外部系统直接改库、多实例部署时的缓存一致性问题,都会让你头疼。考试系统对答案的实时准确性要求极高,缓存一份错误的答案比没有缓存更糟糕。所以我的结论是:本地缓存不主动开,交给Redis做业务层缓存,至少Redis的过期和手动删除是可控的。
4.4 用MyBatis拦截器统一填充公共字段
create_time、update_time、create_by、update_by这四个字段,每次insert和update都要手动set,代码里全是重复的,很容易漏掉。我用了一个MyBatis的Interceptor来做统一处理。
实现Interceptor接口,拦截Executor的update方法,判断SQL类型是INSERT还是UPDATE,然后给参数对象里的公共字段赋值。如果项目用的是MyBatis-Plus,事情更简单,直接继承MetaObjectHandler重写insertFill和updateFill,字段上标@TableField(fill = FieldFill.INSERT)。
这里有一个细节:拦截器里判断字段是否存在时要用反射遍历字段,别硬编码实体类,否则以后新增实体就又要改拦截器。我就是栽过一次,后来写成了通用的反射填充,新表新实体不用再动这个逻辑。
5. Vue3前端考试页面的状态管理与防作弊交互
5.1 答题页面的组件拆分
考试页面是整个前端体验的核心,我拆成四个部分:
- 左侧区域:题目导航列表,显示题号,已答的标识为蓝色,未答的标识为灰色。
- 中间区域:题目卡片,包含题干、选项、单选/多选/判断/填空的输入控件。
- 右侧区域:答题卡概览和交卷按钮。
- 底部/顶部:倒计时栏。
组件拆分的原则是:题目列表和题目卡片之间只通过Pinia store通信,不要组件层层传props。我最早就是父组件一个currentIndex传下去,答题卡一个方法传上来,改了两版后发现逻辑散得到处都是,后来统一改成store驱动。
5.2 Pinia管理考试状态与刷新恢复
考试时最怕考生不小心按一下F5,或者浏览器崩溃,之前的作答全没了。这个问题必须解决,不能只提示"刷新后重考"。
我用了Pinia把考试状态统一管理起来:
typescript复制interface ExamState {
examId: string;
recordId: string;
currentIndex: number;
answers: Record<string, string>;
remainingSeconds: number;
examEndTime: number; // 时间戳
submitted: boolean;
}
考生每做一道题,store里的answers就更新一次,同时通过防抖函数把answers和currentIndex存到localStorage。页面onMounted时检查localStorage有没有当前考试记录的缓存,如果有就恢复,没有才发起新考试。
交卷成功后,清除localStorage里对应的键。这样就算考生中途刷新页面、断网重连,重新进入考试路由时数据都能恢复过来。这个体验对非技术用户非常重要,考试环境已经够紧张了,答题全部丢失的挫败感是致命的。
5.3 倒计时和防切屏:别做得太激进
倒计时组件我直接用setInterval每秒减1,但有一个关键点:不要在Vue组件里用单纯的setInterval做倒计时,组件卸载、页面切换、浏览器标签页休眠都会导致计时不准。更稳妥的做法是记录考试截止时间的endTime,倒计时显示时不断用endTime - Date.now()计算剩余秒数。
typescript复制const remainingMs = computed(() => {
return Math.max(0, examEndTime - Date.now());
});
防切屏这块我有话要说。网上很多模板是监听visibilitychange事件,只要检测到切走就立刻交卷,这种设计太暴力。真实场景里考生可能只是切出去看时间、接个电话、切输入法,一场考试下来意外情况太多了。我的实现是:记录切出次数,切出一次弹警告,超过三次在后台标记该考生有作弊风险,而不是直接交卷。同时监听copy、paste、contextmenu事件,禁止复制、粘贴和右键。这套组合在真实使用中投诉最少,又能起到警示作用。
5.4 播放m3u8监考录像回放
在线远程考试系统如果要加摄像头监考,就绕不开视频录制和回放。我见过不少项目用WebRTC把考生摄像头画面实时传给老师,但回放功能做得很少。实际上考试结束后,管理员更需要逐题回看考生的操作录像。
我们当时用的方案是:前端通过WebRTC采集摄像头画面,录制时切成一个个小片段上传到服务器转成HLS流,生成m3u8索引文件。回放时用video.js配合hls.js插件播放:
vue复制<video ref="videoEl" class="video-js vjs-default-skin"></video>
typescript复制import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import 'hls.js';
const player = videojs(videoEl.value, {
sources: [{ src: '/record/xxx/index.m3u8', type: 'application/x-mpegURL' }],
autoplay: false,
controls: true
});
这里有个经验:m3u8切片文件不要设置得太小,一个小片段两三秒,一场考试两个小时会产生几千个文件,对服务器小文件读写压力特别大。我最后调到每片5到10秒,配合Nginx的gzip_static和客户端缓存,回放加载速度还算可接受。
5.5 交卷二次确认和本地校验
交卷按钮点击后,我做了两层校验。第一层是本地校验:遍历answers,统计未答题数量,弹窗提示"你还有N道题未作答,确定要交卷吗?"。第二层是后端校验:考试记录状态必须还是"考试中"才允许交卷,否则直接拒绝并提示"已交卷或考试不存在"。
提交按钮在点击后要立即置灰并显示"提交中...",防止考生因为网络延迟连续点了五六次,产生多条交卷请求。后端靠唯一索引和状态校验兜底,前端把这个体验做好就能从根源上避免大部分重复提交。
6. 从源码到可用:部署上线的5个实际坑与优化
6.1 环境准备清单
如果是从零开始部署,环境这一块其实能卡住不少非专业运维人员。我按这套流程走就很顺:
- 安装JDK17,配好
JAVA_HOME和PATH,命令行执行java -version确认。 - 安装MySQL 8.0,初始化数据库,执行建库脚本,注意数据库字符集选
utf8mb4。 - 安装Nginx,把前端Vue项目
npm run build生成的dist目录拷贝到Nginx的html目录下。 - 后端打jar包:
mvn clean package -DskipTests,然后nohup java -jar exam-server.jar --spring.profiles.active=prod &启动。 - 在Nginx里配置反向代理,把
/api开头的请求转发到后端8080端口。
Nginx的关键配置长这样:
nginx复制server {
listen 80;
server_name exam.example.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
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 / 的try_files如果没有最后兜底到index.html,用户直接在浏览器里访问某个路由(比如/exam/123),刷新后会404。因为Vue Router是history模式,真正的文件系统里没有exam/123这个路径,必须把请求重写到index.html。
6.2 SpringBoot生产环境配置:连接池和限流
开发环境的SpringBoot配置可以直接用默认值,但生产环境我会重点关注两个参数。第一个是HikariCP连接池:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
maximum-pool-size不是越大越好。我试过设成100,结果数据库一下子被连接打满,反而把MySQL压垮了。一般来说,20个连接就足够支撑几百人的交卷并发,MySQL端max_connections默认151,别轻易改动。
第二个限流,Redis在SpringBoot里的使用就派上了用场。交卷接口必须防刷,我用Redis做固定窗口限流,记录每个考生对交卷接口的访问次数:
java复制String key = "exam:submit:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
}
if (count != null && count > 5) {
throw new BusinessException("操作过于频繁,请稍后再试");
}
同一个考生30秒内最多调用5次交卷接口,能挡住手滑连点,也能挡住脚本刷成绩。
6.3 压测时发现的死锁问题与慢SQL优化
上线前我用JMeter模拟了500个用户同时交卷,结果发现了一个偶发的死锁错误:Deadlock found when trying to get lock; try restarting transaction。
排查了很久,问题出在交卷事务里更新exam_record的方式。不同考生交卷时并发更新同一张表的不同行,按理说不应该互相锁,但MySQL的InnoDB在事务里如果先按record_id顺序不确定地更新多行,比如有的线程先更新record_id=1再更新record_id=2,另一个线程先更新2再更新1,就可能死锁。
解决办法有两个,我最后都用了:
- 所有更新
exam_record的SQL,在更新前提前按record_id排序,保证锁的获取顺序一致。 - 在
exam_record表的状态和record_id上建联合索引,让更新语句尽量走索引定位行,减少锁范围。
还有一个慢SQL特别典型:成绩列表页要查某场考试的所有记录,并统计每个考生的答题明细。最开始是先用select * from exam_record where exam_id = ?查出记录,然后再循环查每条记录的答题明细,结果N+1查询,列表页加载要好几秒。改成一次性查明细:
sql复制SELECT * FROM answer_detail
WHERE record_id IN (SELECT id FROM exam_record WHERE exam_id = ?)
ORDER BY record_id, question_id
配合idx_record_id索引,数据量在几万条的级别下响应时间从原来的几秒降到几百毫秒。这个优化做完,后台管理页面的体验明显顺畅了。
6.4 MySQL安装配置和连接参数的一些细节
最后提一个比较基础的坑。MySQL 8.0安装完后,如果直接用root账户让后端连接,通常会遇到认证插件问题或者远程访问被拒。8.0默认的认证插件是caching_sha2_password,老的MySQL驱动可能不支持。
解决办法有三种:要么升级MySQL驱动到8.0版本,要么在创建用户时指定IDENTIFIED WITH mysql_native_password BY '密码',要么就在JDBC URL上加allowPublicKeyRetrieval=true。我推荐升级驱动,因为后续升级维护更省心。
JDBC连接串里这些参数值得注意:
code复制jdbc:mysql://localhost:3306/exam_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
serverTimezone不配,连接时会报时区错误;useSSL=false在测试环境可以减少握手开销;rewriteBatchedStatements=true对批量插入性能提升明显。这些细节看着小,真要踩上了,每一个都能折腾你半天。
我个人做完这个项目最大的体会是,考试系统的技术难点从来不在某个框架有多炫,而在数据一致性——交卷那一瞬间,几十万条答题明细要安全落库、分数要算得准确、状态要切换得无懈可击。把这些基础问题研究透,比盲目上新框架有价值得多。后面如果再迭代,我会优先考虑接入人脸识别做考前身份核验,以及用大模型辅助主观题初筛评分,但这些都是增量优化,地基还是这套SpringBoot+Vue+MyBatis+MySQL的组合。
