☰
SpringBoot+Vue+MyBatis+MySQL企业在线考试系统自研实战与踩坑记录
2026/10/17 3:31:39 网站建设 项目流程

在企业里做在线考试系统,第一个要回答的问题往往不是技术问题,而是:为什么不直接买一套现成的?我之前参与过一个企业内部培训平台的考试模块,当时比选了一圈市面上的SaaS方案,按年付费不便宜,数据还得托管在别人那边,自定义组卷策略和防作弊逻辑更难按业务定制。最后决定自研,技术栈就是标题里这套:SpringBoot + Vue + MyBatis + MySQL。整套系统从题库维护、组卷、在线答题、自动阅卷到成绩统计,全部自己掌控。

这篇文章把我从零搭建这套系统的完整思路写出来,包括数据库设计、核心模块实现、并发与安全细节、部署上线,以及几个让我印象深刻的线上事故。文章适合正在规划同类系统的开发人员,也适合想了解考试系统内部原理的产品和技术负责人。内容偏实战,我会直接把踩过的坑和解决方案讲清楚,不会留太多"自己体会"的悬念。

1. 先聊清楚:考试系统的需求边界与技术选型理由

1.1 真正要解决的三个核心问题

在线考试系统看起来就是个"发卷子、做题、出成绩"的流程,但一旦放到企业场景,需求会变得非常具体。我当时梳理下来,真正硬性的要求是这三个:

第一,题库要能独立于试卷管理。企业内部考试往往要先沉淀题库,再根据不同的考试(新人培训、季度考核、认证考试)从题库抽题,而且题型有单选、多选、判断、填空、主观题,每道题要有知识点和难度等级两个维度,方便后面按比例组卷。

第二,一场考试要支持上千人同时在线。统计部门平时几百人还好,但年底全员考核的时候,几千人同一时间点进系统答题,交卷那一刻的并发压力最大。这也是我坚持自研而不是套用简单Demo的原因——很多开源项目压根没考虑过这种并发模型,连基本的连接池参数都没调过。

第三,防作弊和操作审计必须是内置能力。企业考试不像学校监考,没有物理监考老师,我们至少要能记录切屏次数、答题耗时异常、同一账号多地登录等特征,给管理员一个判定参考,而不是等到成绩出来才发现有人代考。

另外还有一个隐性需求是数据主权。考试成绩、题库内容都算企业资产,放在第三方SaaS里,虽然方便,但数据安全审计这关过不去。这也是很多企业客户回头来自研的根本原因。

1.2 前后端技术栈为什么这样定

再聊选型。标题里的SpringBoot + Vue + MyBatis + MySQL是经过比选后定下来的,而不是随便拼的。

先说后端:SpringBoot在Java生态里属于最稳妥的选择。考试系统核心是业务逻辑密集型的应用——组卷算法、答题记录、状态机流转、并发写入,这些用Java配合Spring的声明式事务管理非常顺手。MyBatis比JPA/Hibernate更直白,我可以完全控制每一条SQL,尤其是在批量插入答题记录、联表统计成绩这类场景下,直接把SQL写进Mapper里,性能瓶颈一目了然。

前端选Vue是因为考试页面有大量前端状态要维护——当前题号、已作答标记、倒计时、切屏记录、答案暂存,Vue的响应式数据流让这块逻辑很自然。配合Element Plus做管理后台,开发效率很高,不需要自己拼一套UI组件。

MySQL没什么好争议的,考试系统是典型的关系型业务数据,题目、考试、答案之间有明确关联和事务要求,用MySQL配合InnoDB事务引擎最合适。整套组合下来有一个共同点:每层都有大量成熟案例可参考,不会因为是冷门技术组合而在某个细节上卡住。

2. 数据库设计:四层模型与模板/实例分离

数据库设计是整个考试系统里最值得认真对待的部分。我的划分思路是四层:用户层、题库层、考试层、答题层。下面这张表可以快速看清整体结构:

层级核心表关键字段作用
用户层sys_user, sys_roleuser_name, role_code用户与权限
题库层question_bank, question, question_option, knowledge_pointquestion_type, difficulty, correct_answer题库沉淀与维护
考试层exam_template, exam_paper, paper_question, exam_instancestart_time, duration, status考试定义与状态流转
答题层exam_answer, exam_operation_loginstance_id, score, log_content答题记录与行为审计

2.1 四层核心表结构

用户层不展开说,场景不同字段差异很大,重点是后面三层。

题库层:

  • question_bank:题库表,字段有bank_name、type(内部考核/新人培训/认证考试等)、status
  • question:题目表,关键字段包括question_type(1单选 2多选 3判断 4填空 5主观)、difficulty(1-5)、knowledge_point_id(关联知识点)、content、analysis(答案解析)、status
  • question_option:选项表,只存选项内容和标识,不直接在这里解决"哪个是对"的问题,正确项单独靠字段对错来标记。这样设计是为了支持选项乱序时仍然能准确判断对错
  • knowledge_point:知识点表,一个树形结构,方便按知识点维度抽题

考试层:

  • exam_template:考试模板表,也就是一场考试的定义。字段有exam_name、start_time、end_time、duration(作答时长,分钟)、pass_score、status(草稿/发布/进行中/已结束)、total_score
  • exam_paper:试卷配置表,或者叫组卷规则表。它不直接存题,而是存规则,例如:单选10题每题2分、多选5题每题3分,按知识点A抽60%、知识点B抽40%
  • paper_question:试卷题目快照表。这里有个关键设计,后面单独讲
  • exam_instance:考试实例表。考生在有效期内点击"开始考试"时生成一条记录,状态有未开始/进行中/已交卷/已判分。一张考生对应一场考试,只允许有一次有效实例。这张表是整个并发控制的核心

答题层:

  • exam_answer:答题记录表。语义上存的是"某考生某道题的作答内容",字段有instance_id、question_id、option_ids(选择题,可能是多个)、content(填空/主观题内容)、score(判分后回填)、is_correct
  • exam_operation_log:考试行为日志表。切屏、离开页面、IP变化、登录异常都记录在这里,是防作弊分析的数据来源

整体上我把exam_template和exam_instance分开,就是模板/实例分离,一个是"这场考试长什么样",一个是"这个考生这场考试进行到哪一步"。这个区分在考试系统中特别重要,因为同一场考试有成千上万个考生的实例,如果混在一张表里,交卷时全部更新同一行记录,数据库行锁会激烈竞争。

2.2 模板表与实例表:为什么考试内容必须做快照

这一节我想重点说paper_question这张表的设计逻辑。

最开始我有个想法是exam_paper里直接维护题目的关联关系,考生开始考试时实时去题目表里查题目内容。这个设计有一个隐患:考试已经开始,管理员又修改或删除了某道题,正在考试的考生看到的试卷内容就变了,严重的会让答案串位、标准答案对不上。

正确做法是:管理员在发布考试时,系统根据组卷规则把这次考试的题目全部抽出来,复制到paper_question表中,每一行记录题目内容的冗余字段(题干、选项、标准答案、分值)。此后题目表无论怎么改,正在进行的考试都不受影响。考生的答题记录也基于这份快照来判断对错。

所以paper_question的字段比常规的关联表多一些,大概是这样:

  • paper_id(关联试卷配置)
  • question_id(保留一个原始题目ID用于回溯)
  • question_content(题干内容快照)
  • option_json(选项内容快照,JSON格式)
  • correct_answer(标准答案快照)
  • score(本题分值)
  • sort_order(题号顺序)

得益于这个设计,后来我们遇到过好几次"改完题目发现考试数据错乱"的问题,但已经发布的考试完全不受影响,只有下次组卷时才用新题目。

2.3 索引与字段设计的关键细节

三个值得注意的设计细节:

第一,exam_instance这张表要给(exam_id, user_id)建唯一索引。一个考生对同一场考试只能有一个实例,这是防重复进入、重复交卷的数据库层兜底。没有这个约束,靠业务代码判断"是否已交卷"在高并发下必然出现漏判。

第二,exam_answer表的核心索引是(instance_id, question_id)唯一索引。考生多次提交同一道题的答案,用INSERT ... ON DUPLICATE KEY UPDATE的方式去更新,而不是先查再决定插入还是更新。这个写法在交卷场景下能少一半的数据库往返。

第三,exam_operation_log的查询维度是instance_id + 时间范围,所以索引至少要(instance_id, create_time)。我见过很多日志表只建instance_id单列索引,管理后台查一次切屏记录就要全表扫。

时间字段统一用datetime,数据库连接串里显式指定serverTimezone=Asia/Shanghai。这个问题会在后面的踩坑章节展开讲,但设计之初就该定下来。

3. 核心功能实现:题库、组卷、答题与判分

3.1 题库管理与Excel批量导入

题库管理是后台管理端最基础的功能,无非是增删改查加上知识点维护,这些CRUD不展开写。我想重点说的是批量导入。

企业做题库通常不是一道道录进去的,而是手里已经有一堆Excel或Word文档形式的题目。我们当时提供了一个模板Excel:题型、知识点、难度、题干、选项A/B/C/D、正确答案、解析,每列有固定格式。后端的处理逻辑是:读文件 → 按行校验(题型是否合法、难度是否在1-5、选项数是否匹配、答案格式是否对上)→ 将校验失败的行和错误原因原样返回给前端 → 全部通过则批量插入。

这里强烈建议不要一次性读整个文件后for循环单条INSERT,量大的时候几百道题就能把数据库拖慢。我们用MyBatis的batch模式,一次性提交,800道题基本一两秒就插完。校验阶段也不要攒到最后报错,失败超过20条直接终止导入并给出明细,不然Excel里几百条全错的话,报告清单会刷屏。

3.2 两种组卷策略的实现

组卷是整个系统中业务含金量最高的部分,至少要支持固定组卷和随机抽题两种模式。

固定组卷简单,就是管理员从题库里手工选题,保存到paper_question表里,题号和分值一起维护。难点在前端交互:左侧题库条件筛选、右侧已选题目列表,支持拖拽排序和分值修改。这种模式适合认证考试、技能考核——题目必须是完全确定的。

随机抽题更复杂一些,规则模型大概是:试卷配置表里维护若干题目组(paper_section),每个组有题型、题目数量、每题分值、知识点/难度分布比例。考试发布时,后台写一个组卷调度方法,遍历每个题目组,按配置从题库里查询符合条件的题目池,再用随机算法取出指定数量,落到paper_question表。

随机抽题要处理一个边界:符合条件的题目数量不足。比如配置了"单选题10题、难度3-4、知识点JAVA基础",但题库里符合条件的只有6道,这时候应该怎么处理?我们的做法是发布前做一次预校验,发现不足直接阻止发布,提示管理员调整规则或补充题库,而不是发布后组卷时静默少几道题。

3.3 在线考试流程状态机设计

考生端的核心是一个状态机:待开始 → 进行中 → 已交卷 → 已判分(出成绩)。

到待开始这一步之前,后端先要校验几个条件:考试是否在有效期内、该考生是否已被导入考试名单、是否已经生成过实例。这些都通过后,创建一个exam_instance记录,状态置为进行中,并设置实际开始时间为当前时间。

考试的结束时间不是exam_template.end_time,而是考生实际开始时间加上duration。在企业考试里,每个考生进入考试的时间可能不同,所以结束时间必须动态计算。后端在每次加载题目、暂存答案、交卷时都校验当前时间是否超过实际截止时间,超过就按超时自动交卷处理。这里的前端倒计时只是一个辅助,真正决定生死的是后端时间校验。

交卷时要做的事按顺序是:校验考试是否已交卷(防重复)→ 把所有暂存答案批量写入exam_answer → 更新exam_instance状态为已交卷并记录交卷时间 → 触发判分逻辑。主观题不自动判,考试状态要从"已交卷"变成"待判分",管理员在后台按主观题逐个打分后,成绩才算最终出来。

3.4 自动阅卷与人工复核

自动阅卷的规则不复杂,依据paper_question快照里的标准答案判断每道题是否正确:

  • 单选题:用户选项ID等于正确选项ID
  • 判断题:用户选择结果等于正确结果
  • 多选题:这里容易踩坑,用户的选项集合必须和标准答案集合完全一致才算对,少选多选都不得分。要做集合比对,不能简单对比字符串
  • 填空题:先去掉首尾空格再比较,支持多个空格的分词答案

我见过一些系统对多选题用"包含关系"算分——选了A、B,答案是A、B、C,还会给一部分分。企业考试一般不允许这种模糊计分,标准答案就应该是唯一确定的。

自动判完分后,分数写入exam_instance,状态更新为已判分。主观题走人工评分页,管理员能看到考生作答内容和参考答案,打分后可以留评语。总成绩=客观题自动得分+主观题人工得分,超出总分上限的要做校验,防止人工分和自动分加和异常。

4. 并发与安全:考试系统最容易翻车的地方

4.1 交卷时的并发写入控制

考试系统并发压力最大的瞬间就是大批考生同时点交卷。假如3000人同时交卷,每人的答案写入算上批量INSERT,瞬间对数据库的写入压力是非常可观的。

我的处理策略分三层:

第一层,数据库连接池要给足。Druid或HikariCP都行,maximum-pool-size设到50以上,同时MySQL侧max_connections至少调到200,并预留一点buffer。连接数不是越大越好,要结合机器内存估算,我当时按物理机的经验配置是80左右,实测够用。

第二层,答题记录用批量提交。MyBatis的ExecutorType.BATCH一次提交一个考生的全部答案,避免每条答案单独一个事务。单个考生的提交量通常在10-50条之间,批量提交能把事务开销压缩到一次。

第三层,交卷接口要做幂等保护。数据库的(instance_id, question_id)唯一索引已经能挡住重复插入,但为了让用户"不小心双击交卷"不至于得到异常提示,我在业务层又加了一层判断——当instance状态已经是已交卷时,直接返回交卷成功,不重复处理。这样幂等逻辑和业务状态结合,体验最自然。

4.2 防作弊的几个实用手段

考试系统不做防作弊,上线后一定会有问题。我们的实现从数据和事件两个层面做记录。

事件层是这么处理的:前端监听浏览器的visibilitychange事件和window.blur事件,每当页面隐藏或窗口失焦,就记录一次切屏日志,同时向后端发送一条考试行为日志。这个动作要设计成"只记录不惩罚",由管理员根据报告决定是否判定作弊,避免误伤。

数据层可以做一些异常行为分析:

  • 单题作答耗时:一道单选如果平均耗时为5秒,某考生某道题只用了0.3秒就交卷,大概率存在异常
  • 切屏次数超过阈值:例如超过5次,在后台打上重点核查标记
  • 同一账号IP变化:考试中登录IP与开考前IP不一致,值得关注

另外,代考的情况我们是通过IP+设备指纹录日志来辅助分析的。不要指望系统自动抓作弊,这套系统的价值是把异常行为暴露在管理员的视野里,减少复查成本。

4.3 接口层安全设计与状态校验

考试系统的接口安全比普通管理后台要多考虑一层状态校验。

用户登录走JWT,每次请求携带token,后端拦截器检查合法性。这是标准做法,重点在于考试相关的接口不能只检查"登录没登录",还要检查"现在是该考试的正确状态吗"。进入考试要校验时间窗,交卷要校验未交卷,加载下一题要校验进行中。这些判断写在一个切面或拦截器里统一管理,比在controller里一个个复制安全得多。

限流也要做。我们对两个点做了限流:登录接口按IP维度限流(防暴力破解),交卷接口按考试维度限流(防止瞬间流量冲垮后端)。限流用简单的滑动窗口计数器就够了,不需要引入复杂中间件,除非规模再上一个量级。

接口的另一个细节:管理端接口和考生端接口要分开鉴权。用Spring Security的antMatchers或注解配置,把/admin/**和/api/exam/**分别限制到系统管理员和考试参与者,避免越权访问。我在验收测试时专门试过用考生token请求管理端接口,这种越权漏洞在自研系统里非常常见。

5. 部署上线与性能调优

5.1 环境分离与打包部署

从开发到上线,配置环境必须分离。我按三个环境管理:dev本地开发连本地库、test联调测试、prod生产。SpringBoot里用application-{profile}.yml实现,启动时通过--spring.profiles.active=prod指定环境。

打包的时候有个坑要提醒:前端Vue项目的接口地址如果写死成localhost,打包后线上必然接口不通。所以前端也要做环境变量,.env.production里写的是Nginx反向代理路径,比如/api,而不是后端IP。这样前后端都走相对路径,部署到哪都适用。

后端打包命令是mvn clean package -DskipTests,产物是一个SpringBoot可执行jar。生产环境用systemd托管比较正规:

[Service] ExecStart=/usr/local/java/bin/java -jar /data/exam-system/exam-server.jar --spring.profiles.active=prod Restart=always

启动后先检查日志确认端口和数据库连接正常,再配Nginx。

5.2 Nginx 反向代理与前后端联调

前后端分离部署,Nginx要做两件事:托管前端静态文件和反向代理后端接口。

server { listen 80; server_name exam.example.com; location / { root /data/exam-system/dist; index index.html; 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-Proto $scheme; } }

注意try_files那一行,这是Vue Router的history模式必需的配置,否则刷新前端页面时会404。location /api/的proxy_pass后面带不带斜杠也很关键——如果是proxy_pass http://127.0.0.1:8080/;,会把/api替换成/再转发,后端controller里就不用再写一层/api前缀,反之则要保留。这个细节我调了好几个小时才彻底搞明白。

管理端和考生端如果是两个独立项目,可以分别部署两个server块。HTTPS建议直接上用证书,考试系统涉及账号密码和答题数据,明文HTTP实在不太行。上线第一天就该配好,别拖。

5.3 MySQL 与 MyBatis 层的性能优化

数据库优化主要针对三个高频SQL:题库分页查询、答题记录批量插入、成绩统计。

题库分页查询用MyBatis分页插件(PageHelper),注意先count再list这个顺序,如果条件里有OR语句或JOIN,count的SQL要单独优化,让它只查主表的行数,避免全表联查。

答题记录批量插入用ExecutorType.BATCH,这个在判分那节提过,这里再强调一个配置:在数据库连接URL里要加rewriteBatchedStatements=true(MySQL JDBC驱动),否则批处理不会真正合并执行,效果大打折扣。这个参数藏得非常深,很多人不知道。

成绩统计页面通常要展示"考试平均分、最高分、最低分、及格率、各分数段人数"这些指标,建议直接用SQL聚合,不要用Java循环统计。一条GROUP BY就解决了:

SELECT SUM(score >= 60) AS pass_count, SUM(score < 60) AS fail_count, AVG(score) AS avg_score, MAX(score) AS max_score FROM exam_instance WHERE exam_id = #{examId} AND status = '已判分';

MySQL的隐式类型转换和函数索引在统计时容易踩坑,比如status字段如果是varchar存中文,这个查询就没法走索引。条件允许的话,建议加一个deleted和finish_time字段参与索引,避免在线统计时扫描历史数据。

6. 实测踩坑:这些问题不遇到一次不会长记性

6.1 考试过程中的题目编辑导致试卷错乱

这是我自己开发阶段第一次遇到的数据错乱问题。当时测试人员在一场进行中的考试里改了某道题的选项,考生端加载题目时发现这道题的选项变了,之前选的答案也显示不出内容了。

根本原因就是我前面反复强调过的:考试内容没有做快照。最初设计里paper_question只是关联表,题目内容从question表实时取。发现问题后我把设计改成"发布时快照",字段冗余出题干、选项、标准答案,才算彻底解决。

这里想说的是,有些表结构设计问题是上线后才会爆发的,但一旦爆发就是线上事故级别。所以"试卷内容快照"这种设计,从一开始就应该当硬性需求,而不是优化项。

6.2 时区导致自动交卷提前/延后8小时

另一个印象深刻的坑是自动交卷时间不对。现象是:考试明明设置14:00结束,考生13:00开始考试,实际截止时间应该是14:00(duration=60分钟),但系统在20:00才自动交卷。

排查链路很清晰:前端显示倒计时正常,但后端计算超时判断的时间戳有问题。最后发现是MySQL连接串没有指定serverTimezone,JVM默认时区和数据库时区相差8小时,导致new Date()拿到的截止时间和数据库里的datetime比较时出现偏差。

修复方式就是在JDBC连接URL里显式加serverTimezone=Asia/Shanghai。这个问题如果你用的是默认配置,本地开发时通常没事,因为本地JVM和数据库在同一台机器或同一时区,往往到部署到云服务器或者容器化之后就暴露了。建议开发第一天就统一时区配置。

6.3 千人同时进入考试对数据库的冲击

最后一个是压测时暴露的问题。当时模拟3000人同时从管理端开放考试入口、真实考试开始,数据库CPU瞬间飙到90%,登录接口大量超时。

分析后发现瓶颈有两个:一是每个考生进入考试时同时查询了题库、模板、历史成绩等多个接口,合起来是大量SQL;二是这3000个请求几乎同时到达,后端线程池和数据库连接池都被占满。

优化方案:进入考试接口改为一次性查询并组装好考生端所需的全部数据,减少请求树;数据库连接池调大并开启Druid的监控,实时观察SQL执行情况;再用缓存扛住只读数据——题库快照和试卷配置在考试发布后就不变了,完全可以放到Redis或Caffeine缓存里。这里我补充一句,如果现场没有Redis环境,本地用Caffeine做进程内缓存也能扛住大部分压力,关键是把"热点只读数据"和"高频写入数据"分开处理,别让读请求抢占写入连接。

写到这里,这套在线考试系统的核心实现思路就全部梳理完了。我个人的体会是:这类项目在技术上没有特别高深的东西,它就是大量业务逻辑、并发细节和工程化规范的组合。最难的部分往往是那些看似简单、设计错了就要返工的表结构决策,以及不压测永远发现不了的并发问题。

最后再分享一个小技巧:管理后台的考试成绩导出功能,建议直接用EasyExcel分页流式导出,不要把全部成绩一次性查进内存再写文件,几万条数据就能把服务内存撑爆。这个是在真实导出10000人考试成绩时踩到的,改成流式后内存占用从几十MB降到了几MB。如果你还没到这一步,建议提前就把分页导出的逻辑写对。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询