1. 为什么这类系统至今仍值得做:选型背后的真实原因
在线考试系统并不是什么新鲜项目,市面上现成的产品一抓一大把,但真到落地的时候,很多学校、培训机构、企业内部考核部门还是会选择自己搭一套。我接过不少类似的需求,客户的理由高度一致:现成SaaS平台按年收费、题库数据不在自己手里、题型规则和业务流程没法按需定制,而一套基于SpringBoot+SSM的在线考试系统,源码在自己服务器上,想怎么改就怎么改,数据完全可控。
从技术选型角度说,SpringBoot+SSM这个词看起来有点"混搭"——SSM本身指Spring+SpringMVC+MyBatis,SpringBoot又是一个整合框架。实际上这类项目的标准做法是:用SpringBoot作为基础容器,内部继续沿用SpringMVC做Web层、MyBatis做持久层,这样既保留了SSM时代清晰的Controller-Service-Mapper分层习惯,又能享受SpringBoot自动配置、内嵌Tomcat、一键启动的便利。很多高校的毕业设计和中小型商用项目都偏好这个组合,原因很朴素:团队熟悉、资料丰富、二次开发上手快,而且单体架构对这类系统完全够用。
有人会问,现在都流行微服务,为什么还要做单体?我的回答是:看规模。一个在线考试系统,用户量级通常在几百到几千人,高峰期集中在考试开始和交卷那一刻,真正的并发瓶颈在数据库连接和静态资源加载,不在服务拆分。微服务带来的注册发现、分布式事务、链路追踪这些问题,在这个场景里完全是给自己加负担。SpringBoot单体应用配合合理的连接池配置和Redis缓存,应付几千人同时考试没有任何问题。我见过最夸张的一个案例,某企业内部考核平台就用这套架构扛住了近三千人同时在线答题,关键还是要看代码写得到不到位。
这个系统解决的痛点非常明确:第一,题库管理,支持批量导入、分类维护;第二,试卷生成,支持手动选题和按规则自动组卷;第三,在线考试流程,包括考试倒计时、中途保存、自动交卷;第四,阅卷与成绩管理,客观题自动判分、主观题人工批阅,最后导出成绩报表。核心用户角色有三类:管理员维护系统基础数据和全局参数,教师管理题库、组卷、阅卷和成绩发布,学生参加考试、查看成绩和错题记录。清楚了这些边界,再去设计数据库和接口,方向就不会跑偏。
2. 数据模型设计:题库、试卷、考试会话三张主线怎么落库
2.1 用户与权限表设计
这类系统的用户表不建议只建一张admin大表硬撑。我的习惯是角色做区分,但账号体系共用一张sys_user表,通过role字段区分管理员、教师、学生,权限控制放在后端接口的拦截器里做。这样做的好处是登录逻辑只需要维护一套,Redis里存一份会话,按角色白名单控制接口访问即可。
sys_user表核心字段包括:id、username、password(BCrypt加密存储)、real_name、role(1管理员,2教师,3学生)、department(所属院系或部门)、create_time。如果系统需要更细的权限粒度,比如教师只能管理自己创建的试题,那就再加一个creator_id字段,所有题库、试卷操作都带上数据权限过滤,避免老师A改掉老师B的题。
2.2 题库表:题型、难度、知识点怎么设计
题库设计是整个系统的地基,后面所有功能都基于它。我的建议是至少分成三张表:题目主表、题目选项表、知识点分类表。
题目主表exam_question主要字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| subject_id | bigint | 所属科目 |
| question_type | int | 1单选,2多选,3判断,4填空,5主观 |
| difficulty | int | 1-5难度等级 |
| content | text | 题干内容 |
| analysis | text | 答案解析 |
| creator_id | bigint | 创建人 |
| create_time | datetime | 创建时间 |
选项表单独建的原因是单选题和多选题的选项数量不固定,塞进主表的json字段虽然省事,但后续做选项级统计和文字检索会非常痛苦。exam_question_option表字段很简单:id、question_id、option_label(A/B/C/D)、option_content、is_correct。主观题没有选项,is_correct直接置空即可。
知识点表建议做两层:subject(科目)和knowledge_point(知识点),知识点挂在科目下面。组卷时按知识点维度抽题,能有效避免一套卷子的题目全集中在某个章节。
2.3 试卷-题目关联与考试记录表
试卷和题目是多对多关系,中间表exam_paper_question要记录每题的分值和排序:id、paper_id、question_id、question_order、score。这里有个坑:试卷发布后如果教师又修改了题库里的题目内容,已经生成的试卷不能跟着变。所以正规做法是在组卷完成后把题目内容快照到一张试卷题目快照表,或者直接在中表的question_content字段里冗余一份题干和选项内容。否则开考一半题目变了,学生和老师都会崩溃。
考试记录表exam_record承担整个考试过程的会话管理:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| paper_id | bigint | 试卷ID |
| student_id | bigint | 学生ID |
| status | int | 1未开始,2进行中,3已交卷,4已判分 |
| start_time | datetime | 开始时间 |
| submit_time | datetime | 交卷时间 |
| duration | int | 考试时长(分钟) |
| objective_score | decimal | 客观题得分 |
| subjective_score | decimal | 主观题得分 |
| total_score | decimal | 总分 |
答题明细单独一张exam_answer_record,记录每一道题学生的作答内容:id、record_id、question_id、answer_content、is_correct、score。这张表的数据量最大,查询也最频繁,建议按record_id建索引,后续做成绩导出和错题分析都从这张表拉数据。
3. 自动组卷和考试状态机:后端最容易写乱的两个模块
3.1 手动组卷与自动组卷的实现思路
手动组卷就是教师在选题页面按科目、题型、知识点筛选题目,勾选后逐题设置分值,最后生成试卷。这个功能本身没难度,但从操作体验上要考虑一个细节:组卷过程中要随时保存草稿状态,不能要求教师一次把所有题目选完才提交。我的做法是试卷主表加一个status字段,草稿和已发布分开,组卷页每次操作都实时落库,教师下次登录还能继续编辑。
自动组卷稍微复杂一点。核心逻辑是:教师设定试卷结构,比如单选10题每题2分共20分、多选5题每题4分共20分、判断10题每题1分共10分、主观题若干,然后指定每个题型对应的知识点范围和难度分布,系统按规则从题库随机选题。最原始的写法就是按条件拼接SQL,用ORDER BY RAND() LIMIT N随机抽题,数据量小的时候能用,题库一旦超过几万题,性能就会明显下降。实际项目中我会先用一条COUNT查询确认库存够不够,再随机抽取ID列表,最后按ID列表查询完整题目信息,这种写法比直接ORDER BY RAND()快非常多。
组卷过程中还有一个容易被忽略的需求:卷面平衡。同一套试卷里,不同题目的知识点尽量不要重复,尤其是单选和多选,否则考试信度会受影响。简单做法是在抽取算法里加入已选知识点集合判断,抽题时优先排除知识点重复的题目。这个逻辑用Java的Stream流处理很快,几十道题的过滤计算耗时可以忽略。
3.2 考试生命周期状态机与并发控制
考试流程表面上看起来就是"开始-答题-交卷",但落到状态机上有几个容易出问题的分支。
试卷状态:草稿、已发布、已结束。草稿只有教师可见,发布后学生才能看到并开始考试,考试时间截止后自动进入已结束。如果教师允许多次重考,试卷状态还要配合考试次数限制一起判断。
学生考试记录状态:未开始、进行中、已交卷、已判分。这里最麻烦的是并发边界。学生点击"开始考试"时,后端要做两件事:生成一条exam_record记录并返回考试截止时间,然后前端开始倒计时。如果学生刷新页面或意外关闭浏览器,后端需要能根据record_id恢复之前的答题状态,而不是重新生成一条记录。判断依据很简单:如果存在status=2且未超过截止时间的记录,就直接返回已有记录,不再新建。
交卷动作必须做幂等控制。学生的交卷请求可能因为网络问题被重复提交,后端要先用record_id查状态,已经是已交卷就直接返回成功,不再进行第二次写操作。另一个场景是考试时间到了,学生还没主动交卷,需要有一个定时任务或者交卷时兜底检查——学生提交答案的时间晚于截止时间,则强制以截止时间前的答题内容为准,超时后提交的答案一律忽略。这个逻辑在实际项目里经常被学生挑战,所以判分规则要在系统里写明。
并发控制上还要注意数据库层面的问题。交卷操作涉及多张表的写入:更新exam_record状态、批量插入exam_answer_record、计算客观题得分。如果不在一个事务里执行,中途异常会导致答题明细和考试状态数据不一致。用Spring的@Transactional注解把整个交卷方法包起来,同时给exam_record表的id和status加锁,用乐观锁机制防重复处理。
4. 在线答题与自动判分:交卷链路里的数据和并发坑
4.1 答题卡的设计:前端收集、后端存储
在线答题界面最核心的部分是答题卡状态管理。学生看了几十道题,不可能全部在自己脑子里记住哪些答了哪些没答,所以前端要维护一个题目索引,实时标记已答、未答、标记待定三种状态,侧边栏用不同颜色块展示。数据交互上,我见过两种方案:一种是每答一题就实时提交到后端保存,另一种是本地暂存,交卷时一次性提交。实时保存的体验更稳,但会给数据库带来额外压力;本地暂存简单,却面临浏览器崩溃丢数据的致命风险。
我给出的方案是折中:每道题作答后先存本地缓存,同时做一个3到5秒的节流,把期间修改过的答案统一提交到后端。这样后端始终有最新的答题进度,即使学生中途换设备,只要登录新设备拉取草稿就能继续作答,不会丢数据。
后端存储答题卡需要一张专门的数据结构。我建议用exam_answer_record表的answer_content字段存JSON字符串,比如多选题选A和C就存成["A","C"],填空题存成"{"word1":"答案1","word2":"答案2"}",主观题直接存文本框内容。这里请注意:非关系型字段(题目作答明细)用JSON没问题,但如果以后要做按答案内容的统计分析,JSON会让SQL查询比较痛苦,此时宁可拆列也不用JSON存储。规划阶段就要想清楚统计需求到底有多少。
4.2 客观题自动评分和主观题人工复核
客观题自动判分逻辑不难,但要考虑周全。单选题比对answer_content和题库的is_correct字段即可;多选题稍微麻烦,常见判分规则有"全对才得分"和"漏选得一半分"两种,具体用哪种由教师在创建试卷时设置。判断题本质上是单选的一个变种,答案固定存T和F即可。
评分代码写在一个名为GradeService的服务类里,交卷时把答题卡数据加载进来,遍历每道题:
- 从exam_paper_question取该题的score。
- 根据question_type找到对应的判分策略。
- 更新exam_answer_record.is_correct和score字段。
- 累加objective_score。
主观题无法自动判分,需要进入人工批阅流程。我的做法是交卷后把记录标记为"已交卷但未判分",教师端出现待批阅列表,教师逐题打分并填写评语,最后系统汇总总分。人工批阅过程中,教师希望能看到学生完整的答题卡和该题标准答案的对比视图,所以接口设计上需要一次返回题干、标准答案、学生答案三个数据块。
另外需要一个边界处理:一道主观题如果是空答案,交卷时直接记为0分,不必走人工批阅流程,减少教师的工作量。
4.3 交卷并发:防重复提交与超时兜底
在线考试系统最怕的场景是考试结束那几分钟,几百个学生同时点交卷,后端能不能抗住。我的实测经验是,只要在代码层面做好下面几点,就没有大问题:
- 交卷接口不做任何耗时远程调用,缓存读操作尽量走Redis。
- 数据库一次连接里完成状态更新、明细批量插入、分数汇总,避免多次网络往返。
- 明细批量插入用MyBatis的批量ExecutorType.BATCH,一次性插入几百条比逐条插入性能高一个数量级。
还有一个容易被忽略的点:前端交卷成功后的跳转逻辑。如果后端返回失败,前端要保留学生的答题卡数据并给出明确提示,而不能直接清空本地缓存。这个容错设计看似简单,但没有它,用户一掉线就前功尽弃。
超时兜底更是不可省略。学生开了考试页面挂着不动,时间到了没点交卷,这时后端要有一个机制保证他的试卷仍然会正常结束并自动保存已有答案。实现方案是数据库定时任务扫描status=2且截止时间小于当前时间的记录,写入超时标记并自动完成交卷逻辑。注意定时任务执行前要再检查一遍提交状态,防止和正常交卷流程冲突,产生重复判分。
5. 本地调试到服务器部署:让源码真正跑起来的踩坑记录
5.1 环境准备和配置文件里的隐性坑
这类项目拿到源码后,第一步是本地环境准备。JDK版本建议统一用1.8或11,MySQL版本用5.7或8.0,Maven配置阿里云镜像仓库,这些基础操作大多数人都熟,但在实际调试中我踩过几个隐形坑:
第一个坑是MySQL时区问题。新版MySQL的默认时区跟旧版不一致,SpringBoot连接串里如果没加serverTimezone=Asia/Shanghai和useSSL=false,启动后所有时间字段都会差8个小时,考试倒计时直接错乱。排查这类问题最快的方法是看日志里的异常提示,但更有效的是在一开始就把配置写对。
第二个坑是连接池参数。SpringBoot默认的HikariCP连接池在本地开发没问题,但部署到服务器后,如果并发量稍微上来一点,连接不够就会频繁提示Connection is not available。我给这类项目的经验是:maximumPoolSize至少配到30,minimumIdle配10,连接超时时间connectionTimeout设30000毫秒。MySQL端的max_connections也要同步调大,不然连接池配得再大也白搭。
第三个坑是静态资源路径。前端页面如果是放在resources/static下的静态HTML和JS,本地访问没问题;一旦部署到服务器,如果项目根路径配置了context-path,所有静态资源引用都要加上前缀,否则页面样式和脚本全部丢失。这个问题特别隐蔽,因为接口返回数据正常,只是页面光秃秃的,很容易误判成代码问题。
5.2 联调与验证:从学生答题到成绩导出的完整链路
本地跑通流程后,我强烈建议按照真实用户路径做一次完整的端到端验证,而不是只测单个接口。具体动作包括:管理员创建科目和知识点,教师批量导入题库并自动组卷,学生账号报名参加考试,答题过程中刷新页面验证断点续答,到时间自动交卷,教师端人工批阅主观题,最后管理员导出成绩Excel。
这套流程走下来,才能发现很多单元层面的问题。比如批量导入题库时的Excel解析,编码问题就很常见。我在项目里解决的办法是:上传时先判断文件编码,GBK转UTF-8后再用EasyExcel解析。又比如导出成绩报表时,数字格式、合并单元格、列宽这些细节,用Hutool的ExcelWriter封装起来会省很多事。
还有一个跨域问题,如果前端独立部署在Nginx,后端API跑在另一个端口,联调时一定遇到跨域。我的做法是后端写一个CorsFilter全局配置,允许指定域名跨域访问。但在正式环境里要严格控制跨域白名单,不能图方便直接放开所有来源。
部署到服务器的环节,我通常是三步走:Maven打包成jar,上传到服务器,用systemd配置成守护进程。打包前记得profile切换配置,把数据源地址从localhost改成线上地址,把日志级别从DEBUG调到INFO,避免日志文件一天涨几个GB。启动脚本里加上内存参数:-Xms512m -Xmx1024m,有条件的话再开一个远程调试端口方便线上排查问题。
6. 二次开发视角:接手这套系统时值得关注的扩展点
6.1 新增题型需要改动哪些地方
在线考试系统最常遇到的二次开发需求是扩展新题型,比如完形填空、匹配题、排序题。很多人以为这只是往题库加一个question_type枚举值,实际上涉及面比想象中广:
第一,题库管理页面要新增题型选项,对应的上传模板和解析逻辑要跟着改。第二,组卷接口要对新题型分配分值,随机抽题规则要考虑新题型的题目来源。第三,答题前端要适配新的交互组件,比如排序题需要支持拖拽,匹配题需要支持左右栏连线。第四,判分逻辑要根据新题型的规则单独实现,因为不同的题型判分算法完全不同。第五,成绩报表和错题分析页要把新题型纳入统计维度。
我见过一个项目,开发时为了省事把题型判断写成了大量的if-else,新增题型几乎改动了所有模块的代码。如果从一开始就按策略模式设计,把"题型"抽象成一个策略接口,每种题型都有自己的加载、渲染、判分实现,后续扩展会轻松很多。这也是这类源码项目最重要的质量指标之一。
6.2 性能优化、日志与调试文档的维护建议
系统上线运行一段时间后,性能瓶颈通常会出现在两个地方:一个是考试开始时的试卷加载,另一个是成绩列表的分页查询。
试卷加载慢的原因通常是题目和选项的数据没有做缓存。我的优化方案是:试卷发布后,把整份试卷的结构和题目快照缓存到Redis,学生点击开始考试时直接从缓存拉取,缓存未命中才查数据库。因为试卷一旦发布就不能修改,所以缓存一致性很好维护,只要发布状态变更时删掉对应缓存即可。
成绩列表查询慢则要注意SQL写法。简单分页JOIN三张表就能做的需求,尽量不要在循环里逐条查询学生答题详情。一个比较实用的技巧是,分页查出成绩列表后,用record_id的IN条件一次性批量查出所有答题明细,再在内存里做聚合。
日志方面,我的习惯是在交卷接口、自动组卷接口、登录接口这些核心链路上加耗时段位日志,记录耗时和数据量。排查问题时,配合统一异常处理返回的错误码,能快速定位是哪一层出了问题。调试文档不需要写得多花哨,但要覆盖环境搭建步骤、配置项说明、接口列表、常见问题和解决方式。这类在线考试系统的源码包通常已经附带调试文档和演示数据,二次开发的时候,先把演示数据跑起来对照着看,远比直接啃代码效率高。
我个人实操下来最大的体会是:这类系统技术上不追求"高精尖",难的是把考试流程的每一个边界情况想清楚。成绩怎么算、数据怎么存、超时如何处理、并发怎么写安全,这些说得清,系统就稳;说不清,再多花哨功能上线后也是一堆事故。接手源码项目时,与其急着改需求,不如先把状态机和判分规则从头到尾读一遍。