简介:这份资源是面向高校计算机相关专业学生与Java开发初学者的毕业设计参考文档,围绕基于Spring Boot框架的在线考试系统展开,帮助读者理解如何用主流技术栈完成一个具备实际业务价值的Web项目。文档完整覆盖需求分析、系统架构、数据库设计与功能实现,采用MVC模式划分管理员、教师、学生三大模块,涉及试题管理、考试发布、成绩统计与查询等核心流程,并介绍了Spring Boot、Java与MySQL的整合思路及密码加密、权限控制等安全措施。资源包共1个docx文件,约3.88MB,内容为结构完整的论文正文,含中英文摘要、目录及各章节论述,便于直接参考写作框架与实现细节。目前已有122人学习,适合需要选题参考、技术方案梳理或论文结构借鉴的读者使用。
1. 在线考试系统为什么总在交卷那一刻翻车
平时跑得好好的在线考试系统,一到集中交卷就出问题:答案提交超时、客观题分数算错、同一考生出现两份答卷。这类故障不是玄学,根子往往在并发写入和事务边界上。基于 Spring Boot 的在线考试系统设计与实现,核心要解决的就是把「出题、组卷、作答、判分、统计」这条链路做成可复现、可压测、可回滚的工程结构,而不是一个只能演示的 Demo。它适合正在做课程设计的学生、要交付内部考核模块的开发者,以及想把考试流程线上化的团队。下面按我实际搭过的一套方案,从建表到判分逐层拆开讲,参数和坑都写清楚。
2. 先把领域模型定死:五张核心表与状态机
在线考试系统最容易返工的地方不是代码,是表结构。题目、试卷、作答、成绩这四类数据的关系一旦定错,后面判分和统计会一直打补丁。我一般先把状态机画清楚再建表:试卷有草稿、已发布、已结束三态;作答记录有进行中、已提交、已判分三态。状态流转必须由服务端控制,前端传什么状态都不信。
2.1 五张核心表的最小字段设计
题目表存题干、题型、选项、标准答案、分值;试卷表存标题、总分、时长、状态;试卷题目关联表存试卷与题目的顺序和每题分值快照;作答记录表存考生、试卷、开始时间、提交时间、状态;作答明细表存每道题的考生答案和得分。这里有个关键点:试卷发布时要把每题分值快照到关联表,不能判分时再去题目表读分值,否则改题会污染历史成绩。
-- 题目表:选项和答案用 JSON 存,兼容单选/多选/判断 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT '1单选 2多选 3判断', stem TEXT NOT NULL, options JSON COMMENT '选项数组', answer JSON NOT NULL COMMENT '标准答案,多选为数组', score INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷题目关联表:分值快照是判分一致性的关键 CREATE TABLE paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, sort_no INT NOT NULL, score_snapshot INT NOT NULL COMMENT '发布时锁定分值', UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 作答记录表:用唯一键防止同一考生重复开考 CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已提交 2已判分', start_time DATETIME NOT NULL, submit_time DATETIME, total_score INT DEFAULT NULL, UNIQUE KEY uk_paper_user (paper_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段说明:score_snapshot是血泪经验换来的,早期版本判分时联表读题目分值,结果有人改了题目分值,历史成绩全乱。uk_paper_user唯一键配合「插入冲突即返回已有记录」的逻辑,能挡住重复开考。options和answer用 JSON 而不是拆表,是因为题型扩展时不用改表结构,代价是不能用数据库做答案比对,判分必须走应用层。
2.2 状态流转为什么必须服务端说了算
前端提交时只传作答内容,不传状态和分数。服务端收到提交请求后,先查exam_record当前状态,只有「进行中」才允许提交,提交动作在一个事务里完成:更新记录状态为已提交、写入提交时间、触发判分。判分完成后再把状态推到已判分并写总分。这样即使前端被篡改,也造不出「未开考直接有成绩」的数据。常见做法是用乐观锁,在更新时带上status=0条件,影响行数为 0 就说明已被处理过,直接返回幂等结果。
3. 用 Spring Boot 把组卷和判分跑通
模型定完就进入实现。组卷和判分是两个最容易写出性能问题的环节:组卷要按题型和分值抽题,判分要批量处理几百份答卷。我的做法是组卷走一次查询加内存组装,判分走异步线程池加批量更新,避免在请求线程里做重活。
3.1 组卷接口:按题型配额抽题并锁定分值
组卷不是随机抽题那么简单,要满足「单选 10 道、多选 5 道、判断 5 道」这类配额,还要避免同一知识点重复。下面这段代码演示按题型配额抽题并写入关联表,分值在发布时快照。
@Service public class PaperAssembleService { @Resource private QuestionMapper questionMapper; @Resource private PaperQuestionMapper paperQuestionMapper; // quota: 题型 -> 抽题数量,例如 {1:10, 2:5, 3:5} @Transactional(rollbackFor = Exception.class) public void assemble(Long paperId, Map<Integer, Integer> quota) { int sortNo = 1; for (Map.Entry<Integer, Integer> entry : quota.entrySet()) { Integer type = entry.getKey(); Integer count = entry.getValue(); // 按题型随机抽题,ORDER BY RAND() 仅适合题量小的场景 List<Question> picked = questionMapper.pickByType(type, count); if (picked.size() < count) { throw new BizException("题型 " + type + " 题量不足"); } for (Question q : picked) { PaperQuestion pq = new PaperQuestion(); pq.setPaperId(paperId); pq.setQuestionId(q.getId()); pq.setSortNo(sortNo++); pq.setScoreSnapshot(q.getScore()); // 分值快照 paperQuestionMapper.insert(pq); } } } }逻辑说明:整个组卷在一个事务里,任何题型题量不足就整体回滚,避免出现半张试卷。pickByType用ORDER BY RAND() LIMIT ?实现,题量在几千以内没问题,题量上万时要改成先查 ID 列表再内存随机,否则数据库会因排序全表而变慢。score_snapshot在这里写入,后续判分只读这张表,题目表怎么改都不影响已发布试卷。
3.2 判分逻辑:客观题批量比对与幂等提交
判分要处理单选、多选、判断三种题型。单选和判断直接比对,多选要求选项集合完全一致才给分,少选多选都不给。下面这段是判分核心,注意它先校验状态再更新,保证幂等。
@Service public class ScoreService { @Resource private ExamRecordMapper examRecordMapper; @Resource private AnswerDetailMapper answerDetailMapper; @Resource private PaperQuestionMapper paperQuestionMapper; @Transactional(rollbackFor = Exception.class) public int judge(Long recordId) { ExamRecord record = examRecordMapper.selectById(recordId); if (record == null || record.getStatus() != 1) { return -1; // 非已提交状态,直接返回,保证幂等 } List<AnswerDetail> details = answerDetailMapper.listByRecord(recordId); List<PaperQuestion> pqs = paperQuestionMapper.listByPaper(record.getPaperId()); Map<Long, Integer> scoreMap = pqs.stream() .collect(Collectors.toMap(PaperQuestion::getQuestionId, PaperQuestion::getScoreSnapshot)); int total = 0; for (AnswerDetail d : details) { Question q = questionMapper.selectById(d.getQuestionId()); boolean right = compare(q.getAnswer(), d.getUserAnswer(), q.getType()); int got = right ? scoreMap.getOrDefault(q.getId(), 0) : 0; d.setScore(got); total += got; } answerDetailMapper.batchUpdateScore(details); // 批量更新,避免逐条 examRecordMapper.updateStatusAndScore(recordId, 2, total); return total; } // 多选必须集合完全相等,单选判断直接字符串比对 private boolean compare(String standard, String user, Integer type) { if (user == null) return false; if (type == 2) { Set<String> s = parseSet(standard); Set<String> u = parseSet(user); return s.equals(u); } return standard.trim().equalsIgnoreCase(user.trim()); } }参数说明:judge方法入口先判断status != 1就返回,这是幂等保护,重复调用不会重复加分。batchUpdateScore用CASE WHEN或 MyBatis 的foreach拼批量 SQL,几百条明细一次更新,比逐条 update 快一个数量级。多选比对用集合相等,注意前端传的选项顺序可能不同,必须先解析成集合再比。
3.3 交卷高峰的异步判分与线程池参数
集中交卷时如果同步判分,请求线程会被占满,接口大面积超时。我的做法是提交接口只做落库和状态更新,判分丢给线程池异步执行,前端轮询成绩。线程池参数按「判分是 CPU 密集型」来设:核心线程数取 CPU 核数,队列用有界队列,拒绝策略用调用者执行兜底。
@Configuration public class JudgePoolConfig { @Bean("judgeExecutor") public ThreadPoolExecutor judgeExecutor() { int cores = Runtime.getRuntime().availableProcessors(); return new ThreadPoolExecutor( cores, // 核心线程数:CPU 核数 cores * 2, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), // 有界队列,防止任务堆积 new ThreadPoolExecutor.CallerRunsPolicy() // 满了由提交线程执行 ); } }参数说明:核心线程数等于 CPU 核数是因为判分主要是字符串比对和集合运算,不涉及 IO 等待。队列必须有界,无界队列在交卷洪峰时会吃光内存。CallerRunsPolicy让提交线程自己执行,相当于给上游限流,比直接丢弃任务安全。判分任务里要再查一次状态,防止重复判分。
4. 避坑与排查:上线后最常遇到的五个问题
这套结构在压测和试运行阶段暴露过不少问题,下面五条是出现频率最高的,每条按现象、原因、解决写清楚,照着排查能省很多时间。
4.1 交卷接口偶发超时,日志里全是连接池等待
现象:压测 500 并发交卷时,部分请求超过 5 秒,日志出现获取数据库连接超时。原因:提交接口里同步做了判分,判分又逐条更新明细,单请求持有连接时间过长,连接池被占满。解决:把判分移到异步线程池,提交接口只做两次写操作;同时把明细更新改成批量,单次判分持有连接时间从几百毫秒降到几十毫秒。连接池大小按「最大并发 × 单请求持有时间 / 目标响应时间」估算,不要盲目调大。
4.2 同一考生出现两份答卷
现象:考生反馈提交后刷新又出现一份新答卷。原因:开考接口没有做幂等,前端重复点击或网络重试时插入了两条exam_record。解决:给paper_id + user_id加唯一键,开考时先查后插,捕获唯一键冲突异常后返回已有记录。这个坑的后悔药就是唯一键,建表时加上,后面不用改代码。
4.3 多选题明明选对却判零分
现象:考生选了正确选项但顺序不同,判分给零分。原因:比对时用了字符串相等,["A","B"]和["B","A"]不相等。解决:多选一律解析成Set再比,同时统一大小写和去空格。前端提交前也做一次排序,减少服务端解析压力。这个属于典型的数据格式不一致,排查时先打印标准答案和考生答案的原始字符串。
4.4 成绩统计和明细对不上
现象:成绩表总分和明细得分之和不一致。原因:判分过程中部分明细更新失败但总分已写,事务边界没覆盖全。解决:判分明细更新和总分写入放在同一个事务里,任何一条更新失败整体回滚。另外统计接口不要实时汇总明细,直接读成绩表总分,明细只用于展示。对账时用定时任务比对总分和明细之和,发现不一致告警。
4.5 考试中途修改题目导致成绩异常
现象:考后有人改了题目分值,历史成绩出现偏差。原因:判分时实时读题目表分值,没有用快照。解决:判分只读paper_question.score_snapshot,题目表的分值修改不影响已发布试卷。如果业务要求改分,必须走「重新发布试卷」流程,生成新的快照,而不是直接改题目表。
5. 判分结果的验证与压测技巧
判分逻辑写完不能只靠手工点几下,要有一套可重复的验证方法。我一般分三层验证:单元测试覆盖三种题型的边界、集成测试跑完整考试流程、压测验证交卷高峰。下面这个表格是我常用的验证用例设计,覆盖了容易出错的边界。
| 用例 | 输入 | 期望结果 |
|---|---|---|
| 单选正确 | 标准 A,作答 A | 得满分 |
| 单选大小写 | 标准 A,作答 a | 得满分 |
| 多选全对乱序 | 标准 A,B,作答 B,A | 得满分 |
| 多选少选 | 标准 A,B,作答 A | 零分 |
| 多选多选 | 标准 A,B,作答 A,B,C | 零分 |
| 未作答 | 作答 null | 零分 |
| 重复判分 | 同一记录判两次 | 总分不变 |
压测时不要只压提交接口,要模拟「开考、拉题、作答、交卷」完整链路,否则测不出真实瓶颈。我一般用脚本先批量开考生成记录,再并发交卷,观察判分线程池队列深度和数据库连接池活跃数。队列深度持续增长说明判分能力不足,要么加线程要么优化判分 SQL;连接池活跃数打满说明有慢查询,先看慢日志再调池大小。
还有一个容易被忽略的点:判分完成后的成绩查询接口要做缓存。成绩一旦判分就不会变,用record_id做 key 缓存几分钟,能挡住考生集中刷新成绩的流量。缓存失效时间不用太长,判分完成后主动删一次缓存即可。这套方案我前后迭代了三版,最大的教训是别在请求线程里做重活,以及建表时把唯一键和快照字段一次加够,后面能省掉大量补丁。希望帮到你。
本文还有配套的精品资源,点击获取