简介:这份资源是面向高校计算机相关专业学生与Java后端初学者的一份毕业论文文档,主题为基于Spring Boot的在线考试系统设计与实现,可帮助读者理解如何将Spring Boot、Java与MySQL整合落地到实际项目中。压缩包内仅含1个docx文件,约3.88MB,即完整论文正文,涵盖摘要、目录、需求分析、系统架构、数据库设计、功能实现与测试等章节,采用MVC模式划分管理员、教师、学生三大模块,并涉及用户认证、试题管理、成绩统计、密码加密等实现细节。目前已有122人学习下载,适合作为课程设计、毕业设计选题参考,或用于梳理在线考试系统的整体开发流程与关键技术选型,读者可从中获取完整的项目设计思路、模块划分方式与论文写作框架。
1. 在线考试系统为什么总在“交卷那一刻”翻车
做过在线考试系统的人大多有一个共同记忆:平时压测都挺稳,一到集中交卷的那几分钟,接口响应时间从 80ms 飙到 3s,数据库连接池直接打满,运气差一点还能看到几份卷子状态卡在“考试中”。这不是玄学,是典型的写放大叠加锁竞争——每份答卷提交时既要写答案明细,又要更新考试记录状态,还要触发判分,三件事挤在一个事务里,并发一上来就互相拖。
基于 Spring Boot 的在线考试系统,核心要解决的就是把“出题、组卷、作答、交卷、判分、成绩分析”这条链路做稳。它适合两类人:一类是要在课程、培训、认证场景里落地一套可用系统的开发者;另一类是想拿它练手 Spring Boot 全家桶(Security、JPA/MyBatis、Redis、消息队列)的进阶学习者。这篇笔记按我实际搭过的一套模拟项目来讲,从表结构、组卷算法、交卷削峰,一路讲到判分和防作弊的边界,能照着复现,也能看到哪些地方别硬抄。
2. 先把领域模型和表结构定死:别让答案表拖垮整个库
在线考试系统最容易埋雷的地方不是代码,是表设计。很多人上来就exam、question、answer三张表打天下,结果做到“一场考试多套卷”“同一题不同分值”“主观题人工阅卷”时全得重构。我一般会先把领域模型拆成五块:试卷模板、题目、选项、考试场次、作答记录。下面这套结构是我在模拟项目X里跑通的版本,MySQL 8 + InnoDB,字段做了精简但保留了关键约束。
2.1 核心表结构与索引设计
-- 题目表:题干与题型分离,选项单独存 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4主观', stem TEXT NOT NULL COMMENT '题干', difficulty TINYINT DEFAULT 3 COMMENT '1-5,用于组卷抽题', knowledge_tag VARCHAR(64) COMMENT '知识点标签,用于分析', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- 选项表:一道题多个选项,正确与否用 is_correct 标记 CREATE TABLE question_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL, content VARCHAR(512) NOT NULL, is_correct TINYINT DEFAULT 0, sort_no INT DEFAULT 0, KEY idx_qid (question_id) ) ENGINE=InnoDB; -- 考试场次:一场考试对应一套卷,绑定时间窗 CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration_min INT NOT NULL COMMENT '作答时长(分钟)', total_score INT NOT NULL DEFAULT 100, status TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束' ) ENGINE=InnoDB; -- 试卷题目关联:同一题在不同场次可有不同分值 CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL, sort_no INT NOT NULL, UNIQUE KEY uk_exam_q (exam_id, question_id) ) ENGINE=InnoDB; -- 作答记录:一个用户一场考试一条主记录 CREATE TABLE answer_sheet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0作答中 1已交卷 2已判分', objective_score INT DEFAULT 0, subjective_score INT DEFAULT 0, submit_time DATETIME, UNIQUE KEY uk_exam_user (exam_id, user_id) ) ENGINE=InnoDB; -- 答案明细:主观题答案可能很长,用 TEXT CREATE TABLE answer_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sheet_id BIGINT NOT NULL, question_id BIGINT NOT NULL, answer TEXT COMMENT '选择题存选项id逗号拼接,主观题存文本', score INT DEFAULT NULL COMMENT '判分后回填', KEY idx_sheet (sheet_id) ) ENGINE=InnoDB;这套结构的关键取舍有三个。第一,exam_question单独存分值,而不是把分值写死在question上,因为同一道题在练习卷和正式卷里权重不同,这是血泪经验。第二,answer_sheet和answer_item拆开,主记录只存状态和总分,明细单独写,交卷时更新主记录、批量插明细,避免一行大记录反复锁。第三,uk_exam_user唯一索引是防重复交卷的第一道闸,比在代码里查一遍再插更可靠。
2.2 组卷抽题的参数怎么设
组卷不是随机ORDER BY RAND()就完事,那样在几万道题时会全表扫描。常见做法是按知识点和难度分层抽题,用knowledge_tag + difficulty建联合索引,再按配额抽。下面是我常用的抽题逻辑,参数含义写在注释里。
// 按知识点配额抽题:每个标签抽 count 道,难度区间 [minD, maxD] public List<Question> pickByTag(String tag, int count, int minD, int maxD) { // 先按索引过滤,再随机排序,数据量大时用子查询限制范围 String sql = "SELECT * FROM (" + " SELECT id, stem, type, difficulty FROM question " + " WHERE knowledge_tag = ? AND difficulty BETWEEN ? AND ? " + " ORDER BY RAND() LIMIT ?" + ") t"; // count 建议不超过该标签题量的 30%,否则随机性下降 return jdbcTemplate.query(sql, rowMapper, tag, minD, maxD, count); }参数上我一般这样定:难度区间默认[2,4],太简单的题区分度低,太难的题容易让整卷均分崩掉;单标签抽取比例不超过该标签总题量的 30%,否则同一套卷重复率高;整卷题量控制在 40 到 60 道,超过 60 道时前端渲染和交卷写入都会变重。如果题量真的很大,ORDER BY RAND()要换成“先取 id 区间再随机”的写法,否则慢查询日志会被它刷屏。
3. 交卷削峰:把三件事拆开,别挤在一个事务里
前面说的“交卷翻车”,根因就是提交动作太重。我的做法是把交卷拆成三步:先落答案(快),再异步判分(慢),最后回写成绩(可重试)。这样交卷接口只做一次主记录状态更新加一次批量插入,响应能压到几十毫秒。
3.1 交卷接口的最小实现
@Transactional public SubmitResult submit(Long examId, Long userId, List<AnswerItemDTO> items) { // 1. 幂等校验:唯一索引兜底,这里先查一次减少异常 AnswerSheet sheet = sheetMapper.findByExamAndUser(examId, userId); if (sheet == null || sheet.getStatus() != 0) { throw new BizException("重复交卷或考试不存在"); } // 2. 批量写答案明细,用 foreach 拼一条 insert,减少网络往返 answerItemMapper.batchInsert(sheet.getId(), items); // 3. 更新主记录状态为已交卷,提交时间以服务端为准 sheetMapper.markSubmitted(sheet.getId(), new Date()); // 4. 发消息触发异步判分,不阻塞交卷响应 mqProducer.send("exam.judge", sheet.getId()); return SubmitResult.ok(sheet.getId()); }逻辑说明:第 1 步的幂等校验不能只靠查,最终要靠uk_exam_user和状态字段双重保证,否则并发下两个请求可能都查到“作答中”。第 2 步批量插入时,items建议按 500 条一批切分,太大容易触发max_allowed_packet。第 4 步发消息用本地消息表或事务消息,保证“状态已改但消息没发出去”这种情况能被补偿扫描捞回来。
参数上,交卷接口的连接池超时我设 3s,MQ 发送超时 1s,判分消费者并发度按 CPU 核数的 2 倍起步。如果考试规模在千人以内,其实可以不用 MQ,用 Spring 的@Async加线程池也能扛,但要注意线程池队列满了之后的拒绝策略,别把交卷请求给拒了。
3.2 判分服务的异步消费
@RabbitListener(queues = "exam.judge", concurrency = "4") public void onJudge(Long sheetId) { AnswerSheet sheet = sheetMapper.findById(sheetId); if (sheet.getStatus() != 1) return; // 已判分则跳过,保证幂等 List<AnswerItem> items = answerItemMapper.listBySheet(sheetId); int objective = 0; for (AnswerItem item : items) { Question q = questionCache.get(item.getQuestionId()); if (q.getType() == 4) continue; // 主观题留给人工 // 选择题比对:多选要求完全一致,单选直接相等 if (isCorrect(q, item.getAnswer())) { item.setScore(examQuestionMapper.getScore(sheet.getExamId(), q.getId())); objective += item.getScore(); } else { item.setScore(0); } answerItemMapper.updateScore(item.getId(), item.getScore()); } sheetMapper.markJudged(sheetId, objective); }这里有个容易忽略的点:判分消费者必须幂等,因为 MQ 可能重复投递。我用status != 1直接跳过,简单有效。客观题判分逻辑里,多选题的“完全一致”判定要先把选项 id 排序再比对,否则用户按不同顺序勾选会被误判,这个坑我在模拟项目X里踩过一次,后来统一在写入前就排序。
4. 避坑与排查:这五个问题几乎每个在线考试系统都会遇到
4.1 交卷时数据库连接池被打满
现象是交卷高峰期接口大面积超时,日志里全是Connection is not available。原因通常是交卷事务里做了太多事,或者判分逻辑同步执行,把连接占住不放。解决分两步:先把判分改成异步,交卷事务只保留写答案和改状态;再把连接池maximumPoolSize从默认 10 调到 30 到 50,同时把connectionTimeout设成 3s,让拿不到连接的请求快速失败而不是无限等。改完再压一轮,观察activeConnections曲线是否还贴着上限。
4.2 考试时间到了但用户还能提交
现象是end_time已过,接口仍返回成功。原因是只在前端做了倒计时,后端没校验。解决是在交卷接口里加服务端时间判断:now > end_time + 宽限就拒绝,宽限一般给 30 到 60 秒,用于补偿网络延迟。注意别用客户端传的时间,一律以服务端为准,否则改本地时间就能绕过。
4.3 主观题分数回填后总分对不上
现象是人工阅卷完,成绩单总分和明细之和不一致。原因多是回填主观题分数时只更新了answer_item,忘了重算answer_sheet的总分。解决是把“重算总分”做成一个独立方法,每次分数变动后调用,并且用UPDATE ... SET subjective_score = (SELECT SUM...)在数据库层算,避免应用层累加误差。重算后加一条校验日志,总分不等于明细和就告警。
4.4 同一用户重复交卷产生两条记录
现象是成绩列表里一个用户出现两条。根因是并发提交时幂等校验失效。解决是依赖uk_exam_user唯一索引,插入冲突时捕获DuplicateKeyException并返回“已交卷”,而不是先查后插。这个改动很小,但能彻底堵住并发漏洞。
4.5 组卷抽题慢到超时
现象是创建考试时接口要好几秒。原因是ORDER BY RAND()在大表上全扫描。解决是给knowledge_tag和difficulty建联合索引,抽题时先用索引缩小范围,再在小结果集里随机;或者维护一张“可抽题 id 池”的缓存表,定时刷新,抽题直接从池里取。题量过万后,第二种方案更稳。
5. 判分准确性与防作弊的边界:一个可验证的收尾技巧
判分这块,客观题好办,真正难的是主观题和防作弊的度。我的经验是:主观题不要指望自动判分做到百分百,做成“关键词命中 + 人工复核”更现实。具体做法是给每道主观题配一组关键词和权重,判分时统计命中情况给一个参考分,最终分由阅卷人确认。这样既减轻阅卷量,又不会因为算法误判引发争议。
防作弊方面,切屏检测、复制粘贴拦截这类前端手段只能提高成本,挡不住有心人。真正有效的是后端行为分析:记录每道题的作答时长、修改次数、选项切换频率,交卷后跑一遍异常检测。比如某道题作答时间小于 3 秒且正确,或者整卷选项分布高度集中,就标记为可疑,推给人工复核。下面这个校验方法我一般放在判分之后跑。
// 异常作答检测:返回可疑原因列表,空列表表示正常 public List<String> detectAnomaly(Long sheetId) { List<String> flags = new ArrayList<>(); List<AnswerItem> items = answerItemMapper.listBySheet(sheetId); long totalMs = 0; for (AnswerItem item : items) { // duration_ms 在作答时由前端定时上报,服务端只做参考 totalMs += item.getDurationMs(); // 规则1:作答时间过短且答对 if (item.getDurationMs() < 3000 && item.getScore() != null && item.getScore() > 0) { flags.add("题" + item.getQuestionId() + "作答过快且正确"); } } // 规则2:整卷平均每题耗时低于 5 秒 if (!items.isEmpty() && totalMs / items.size() < 5000) { flags.add("整卷平均作答时间异常偏低"); } return flags; }参数上,3 秒和 5 秒这两个阈值不是拍脑袋,是我在模拟项目X里按正常考生的作答分布取的 5% 分位。不同题型要分开设:选择题阈值可以低一些,主观题低于 10 秒基本不正常。检测结果只做标记,不直接判作弊,最终由人工决定,这样既保留了威慑,又避免了误伤。
最后说个我自己的习惯:每次上线前,我都会用脚本模拟 200 个用户同时交卷,观察交卷接口 P99 和判分队列积压量。P99 超过 500ms 或者积压超过 1000 条,就先别上,回去看连接池和消费者并发。这个习惯帮我省了好几次半夜被叫起来处理的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取