简介:这是一套基于SSM框架开发的学生在线考试系统完整项目源码,面向计算机相关专业的在校学生与Java初学者,适合用作期末大作业或毕业设计。系统划分学生、老师、管理员三种角色,覆盖加入课程、参加考试、成绩查询、试题录入、考卷发布、课程创建以及用户与权限管理等核心业务,并整合Shiro权限控制、Redis缓存、EasyUI前端与EasyPoi导出,技术栈贴近企业实际开发。压缩包共7008个文件,约763.9MB,包含3798个png界面截图、916个html页面、816个css与530个js前端资源,以及69个java源码、38个jsp页面、51个xml配置和142个jar依赖,另附用例图、ER图、数据库表设计图与详细代码注释。目前已有1073人学习下载。包内还提供运行指导视频、开发工具安装包与完整环境依赖,可帮助读者快速跑通项目、理解分层结构与权限设计,是兼顾学习与答辩展示的实用参考。
1. SSM学生在线考试系统:从考场崩溃到自动判卷,这套架构到底怎么落地
每年期末周,教务处的电话就没停过——学生进不去考场、提交按钮转圈、老师改卷改到凌晨。我见过太多学校用单机版考试软件硬扛几百人并发,结果开考十分钟数据库连接池就爆了。SSM学生在线考试系统要解决的就是这个问题:用Spring + SpringMVC + MyBatis这套经典组合,把考试流程拆成可水平扩展的Web服务。它适合谁?适合有Java Web基础、想做一个能真正跑起来的考试系统的学生或初级工程师,也适合需要给内部培训做在线测评的技术团队。这套架构的核心价值在于分层清晰、事务可控、SQL可优化,遇到高并发时你知道该在哪一层加缓存、在哪一层限流。接下来我会把选型理由、表结构设计、自动判卷逻辑、防作弊手段和性能调优参数全部拆开讲,让你照着就能搭出一套能扛住真实考试场景的系统。
2. 技术选型与数据库设计:为什么SSM在这个场景下比Spring Boot更值得讲
2.1 为什么考试系统选SSM而不是直接上Spring Boot
很多教程一上来就推Spring Boot,说自动配置省事。但在学生在线考试系统这个场景里,SSM的显式配置反而是一种优势。考试系统的核心链路是:登录 → 获取试卷 → 答题 → 提交 → 判卷 → 出分。这条链路上每个环节的事务边界、SQL执行顺序、连接池占用时长都需要精确控制。Spring Boot的自动配置把DataSource和事务管理器藏起来了,出问题时排查链路长。SSM里你手动配DataSource、手动配SqlSessionFactory、手动声明@Transactional,每一层都是透明的。
具体来说,考试提交环节涉及三张表的写入:答题记录表、试卷状态表、成绩表。这三张表必须在同一个事务里,否则会出现“答案存了但成绩没出”的脏数据。SSM里你可以精确控制事务传播行为,比如用Propagation.REQUIRED保证提交操作原子性,用Propagation.REQUIRES_NEW把日志记录独立出去,避免日志写入失败导致整个提交回滚。这种细粒度控制在考试场景里是刚需。
另一个原因是MyBatis的SQL可控性。考试系统里有一类查询特别吃性能:随机抽题。不同题型(单选、多选、判断、填空)分布在不同的表里,需要按知识点、难度、题型三个维度随机抽取。MyBatis允许你直接写原生SQL,用ORDER BY RAND()配合LIMIT,或者用子查询做加权随机。JPA的Criteria API写这种查询会非常别扭,而MyBatis的XML映射文件里你可以把SQL调优到极致。
提示:如果你的团队已经全面转向Spring Boot,SSM的配置方式可以平滑迁移——把XML配置改成Java Config即可,核心的MyBatis映射文件和事务注解不需要动。
2.2 考试系统五张核心表的设计与索引策略
表结构设计直接决定后期能不能扛住并发。我一般会建五张核心表:用户表、试卷表、题目表、答题记录表、成绩表。下面这张表列出了关键字段和索引策略:
| 表名 | 关键字段 | 索引 | 说明 |
|---|---|---|---|
| t_user | id, username, password, role | uk_username | role区分学生/教师/管理员 |
| t_exam | id, title, start_time, end_time, duration, status | idx_status_start | status控制考试状态流转 |
| t_question | id, exam_id, type, content, options, answer, score | idx_exam_type | type区分单选/多选/判断/填空 |
| t_answer | id, exam_id, user_id, question_id, user_answer, score | uk_exam_user_q | 唯一索引防止重复提交 |
| t_score | id, exam_id, user_id, total_score, submit_time | uk_exam_user | 唯一索引保证一人一成绩 |
答题记录表的唯一索引uk_exam_user_q是关键。没有这个索引,学生快速点击提交按钮会产生多条重复记录,判卷时分数会翻倍。加上唯一索引后,第二次插入会抛DuplicateKeyException,你在Service层捕获这个异常返回“请勿重复提交”即可。
试卷表的idx_status_start索引用于考试列表查询。学生登录后需要看到“进行中”和“即将开始”的考试,查询条件是status = 1 AND start_time > NOW(),这个复合索引能让查询走索引扫描而不是全表扫描。
题目表的idx_exam_type索引用于按试卷ID和题型抽题。一场考试可能有50道单选、20道多选、10道判断,抽题时先按exam_id过滤,再按type分组,最后随机取N条。这个索引能显著减少扫描行数。
2.3 随机抽题的SQL写法与性能对比
随机抽题是考试系统里最容易翻车的地方。我见过有人用ORDER BY RAND() LIMIT 10,在题目量小的时候没问题,一旦题库到几万条,这条SQL会全表扫描并生成临时表,响应时间从毫秒级跳到秒级。下面是我常用的两种优化写法:
-- 方案一:子查询限定范围后随机(适合题目ID连续的场景) SELECT * FROM t_question WHERE exam_id = #{examId} AND type = #{type} AND id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM t_question WHERE exam_id = #{examId} AND type = #{type}))) ORDER BY id LIMIT #{count}; -- 方案二:先查ID列表再随机取(适合题目ID不连续但总量可控的场景) SELECT * FROM t_question WHERE id IN ( SELECT id FROM t_question WHERE exam_id = #{examId} AND type = #{type} ORDER BY RAND() LIMIT #{count} );方案一利用主键索引做范围扫描,RAND()只计算一次,性能最好,但要求ID分布均匀。方案二在子查询里做随机,外层用IN走主键索引,适合ID有空洞的情况。实际项目中我会在Service层先查一次该试卷该题型的题目总数,如果总数小于200,直接用方案二;如果大于200,用方案一。这个阈值可以根据服务器配置调整。
参数说明:#{examId}是试卷ID,#{type}是题型编码(1单选、2多选、3判断、4填空),#{count}是抽题数量。注意ORDER BY RAND()在MySQL 8.0里可以用窗口函数替代,但考虑到很多学校还在用MySQL 5.7,上面的写法兼容性更好。
3. 自动判卷与防作弊:从提交到出分的完整链路实现
3.1 提交答案的Service层事务控制与幂等处理
学生点击提交按钮的那一刻,系统要做四件事:保存答题记录、更新试卷状态、计算客观题分数、写入成绩表。这四步必须在一个事务里完成,否则会出现“答案存了但没判卷”的情况。下面是我在Service层的实现骨架:
@Service public class ExamSubmitService { @Autowired private AnswerMapper answerMapper; @Autowired private ScoreMapper scoreMapper; @Autowired private QuestionMapper questionMapper; @Transactional(rollbackFor = Exception.class) public SubmitResult submitExam(Long examId, Long userId, List<AnswerDTO> answers) { // 1. 幂等检查:先查是否已有成绩记录 Score existing = scoreMapper.selectByExamAndUser(examId, userId); if (existing != null) { return SubmitResult.fail("请勿重复提交"); } // 2. 批量插入答题记录 for (AnswerDTO dto : answers) { Answer answer = new Answer(); answer.setExamId(examId); answer.setUserId(userId); answer.setQuestionId(dto.getQuestionId()); answer.setUserAnswer(dto.getUserAnswer()); answerMapper.insert(answer); } // 3. 自动判卷:只判客观题 int totalScore = 0; List<Question> questions = questionMapper.selectByExamId(examId); Map<Long, String> correctAnswers = questions.stream() .collect(Collectors.toMap(Question::getId, Question::getAnswer)); for (AnswerDTO dto : answers) { String correct = correctAnswers.get(dto.getQuestionId()); if (correct != null && correct.equals(dto.getUserAnswer())) { Question q = questions.stream() .filter(item -> item.getId().equals(dto.getQuestionId())) .findFirst().orElse(null); if (q != null) { totalScore += q.getScore(); } } } // 4. 写入成绩表 Score score = new Score(); score.setExamId(examId); score.setUserId(userId); score.setTotalScore(totalScore); score.setSubmitTime(new Date()); scoreMapper.insert(score); return SubmitResult.success(totalScore); } }这段代码的关键点有三个。第一,幂等检查放在最前面,用selectByExamAndUser查成绩表,如果已有记录直接返回失败。这比依赖唯一索引抛异常更友好,因为异常回滚会消耗数据库资源。第二,批量插入答题记录时没有用batchInsert,因为考试提交的答案数量通常在50到100条之间,逐条插入配合事务已经够快,而且逐条插入能精确定位是哪道题插入失败。第三,判卷逻辑只比对字符串,多选题的答案需要在前端提交时按字母顺序排序,比如学生选“C、A、B”,前端统一转成“A,B,C”再提交,后端直接字符串比对即可。
参数说明:@Transactional(rollbackFor = Exception.class)确保任何异常都回滚,包括DuplicateKeyException。SubmitResult是一个简单的返回对象,包含success布尔值和message或score字段。
3.2 防作弊的三个层次:前端限制、后端校验、行为监控
考试系统的防作弊不能只靠前端。我一般分三层来做:
第一层是前端限制。禁用右键、禁用复制粘贴、限制切屏次数。切屏检测用document.visibilitychange事件,学生切到其他窗口时记录一次,超过3次自动交卷。这个逻辑用JavaScript实现:
let switchCount = 0; document.addEventListener('visibilitychange', function() { if (document.hidden) { switchCount++; if (switchCount >= 3) { alert('切屏次数过多,系统将自动交卷'); document.getElementById('submitBtn').click(); } } });第二层是后端校验。前端提交的答案必须经过后端验证:题目ID是否属于该试卷、答案格式是否符合题型要求、提交时间是否在考试时间范围内。后端校验的代码写在Controller层:
@PostMapping("/submit") public Result submit(@RequestBody SubmitRequest request, HttpSession session) { Long userId = (Long) session.getAttribute("userId"); Exam exam = examMapper.selectById(request.getExamId()); // 校验考试时间 Date now = new Date(); if (now.before(exam.getStartTime()) || now.after(exam.getEndTime())) { return Result.fail("不在考试时间内"); } // 校验题目归属 List<Long> questionIds = request.getAnswers().stream() .map(AnswerDTO::getQuestionId).collect(Collectors.toList()); int count = questionMapper.countByIdsAndExamId(questionIds, request.getExamId()); if (count != questionIds.size()) { return Result.fail("题目数据异常"); } return examSubmitService.submitExam(request.getExamId(), userId, request.getAnswers()); }第三层是行为监控。记录学生的答题时间分布,如果某道题在3秒内完成作答且正确,标记为可疑。这个数据存在Redis里,考试结束后由教师端查看。实现方式是在前端每次切换题目时上报一次时间戳,后端计算每道题的停留时长。
注意:防作弊手段要适度。过度限制会导致正常学生操作困难,比如禁用复制粘贴会影响学生使用计算器或草稿纸。建议在考试开始前明确告知学生规则,并在界面上显示切屏剩余次数。
3.3 主观题判卷与成绩复核的接口设计
客观题自动判,主观题需要教师手动阅卷。这里的设计要点是:成绩表先写入客观题分数,主观题分数留空,教师阅卷后更新。成绩复核接口允许学生对分数提出异议,教师端收到复核申请后重新判卷。
// 教师阅卷接口 @PostMapping("/teacher/grade") public Result gradeSubjective(@RequestBody GradeRequest request) { // request包含:answerId, score, comment Answer answer = answerMapper.selectById(request.getAnswerId()); if (answer == null) { return Result.fail("答题记录不存在"); } answer.setScore(request.getScore()); answer.setComment(request.getComment()); answerMapper.updateById(answer); // 重新计算总分 int totalScore = answerMapper.sumScoreByExamAndUser(answer.getExamId(), answer.getUserId()); Score score = scoreMapper.selectByExamAndUser(answer.getExamId(), answer.getUserId()); score.setTotalScore(totalScore); scoreMapper.updateById(score); return Result.success(); }这段代码的逻辑是:教师给某道主观题打分后,重新汇总该学生该试卷的所有题目得分,更新成绩表。sumScoreByExamAndUser是一条聚合SQL,在答题记录表的uk_exam_user_q索引下执行很快。
成绩复核接口类似,学生提交复核申请后,教师端看到申请列表,重新打分后再次触发总分更新。整个链路的关键是保证成绩表的total_score始终等于答题记录表里所有score字段的和。我一般会在数据库层面加一个定时任务,每天凌晨校验一次数据一致性,发现不一致就告警。
4. 避坑与排查:考试系统上线前必须处理的五个问题
4.1 提交时数据库连接池耗尽
现象:考试结束前5分钟,大量学生同时提交,系统报Could not get JDBC Connection,提交失败。
原因:Druid连接池的maxActive默认是8,考试提交涉及多个数据库操作,每个操作占用一个连接,并发上来后连接不够用。
解决:把maxActive调到50,同时设置maxWait为3000毫秒,避免请求无限等待。配置如下:
spring.datasource.druid.max-active=50 spring.datasource.druid.max-wait=3000 spring.datasource.druid.min-idle=10 spring.datasource.druid.validation-query=SELECT 1另外,提交接口的@Transactional会持有连接直到事务结束,判卷逻辑里的循环查询会延长连接占用时间。优化方法是在事务开始前把题目和答案一次性查出来,减少事务内的数据库交互次数。
4.2 随机抽题导致试卷不一致
现象:同一场考试,不同学生拿到的题目数量不一样,有的学生少了两道题。
原因:ORDER BY RAND() LIMIT #{count}在并发时可能因为RAND()的随机性导致某些题目被重复抽取或漏抽,尤其是在子查询方案里,外层IN和内层LIMIT的配合可能出问题。
解决:抽题逻辑放在试卷生成阶段,而不是学生进入考场时。教师创建试卷时,系统一次性抽好题目并写入t_exam_question关联表,学生进入考场时直接按关联表查询。这样每份试卷的题目是固定的,不会出现数量不一致。
4.3 考试时间边界处理错误
现象:考试结束时间到了,学生还能提交;或者考试还没开始,学生就能看到试卷。
原因:时间比较用了>=和<=,没有考虑服务器时间和数据库时间的时区差异。
解决:统一用数据库时间做判断,不要用Java的new Date()。在SQL里用NOW()函数:
SELECT * FROM t_exam WHERE id = #{examId} AND NOW() BETWEEN start_time AND end_time;同时,前端在考试结束前5分钟弹出提醒,倒计时归零后自动交卷。后端在提交接口里再次校验时间,双重保险。
4.4 多选题答案顺序导致误判
现象:学生选了A、B、C,但系统判错,因为标准答案是A、C、B。
原因:多选题的答案存储顺序不一致,直接字符串比对会失败。
解决:前端提交前对多选答案排序,后端判卷时也排序后再比对。在AnswerDTO里加一个normalizeAnswer方法:
public String normalizeAnswer(String answer) { if (answer == null || answer.isEmpty()) return ""; String[] parts = answer.split(","); Arrays.sort(parts); return String.join(",", parts); }判卷时对标准答案和学生答案都调用这个方法,再比对。
4.5 成绩表数据不一致
现象:学生查到的总分和答题记录里各题得分之和对不上。
原因:教师阅卷更新了答题记录的分数,但没有同步更新成绩表的总分;或者并发更新导致覆盖。
解决:在成绩表更新时加乐观锁,用version字段控制。同时,每次更新答题记录分数后,强制重新计算总分并写入成绩表。我一般会在Score实体里加一个@Version注解,MyBatis的乐观锁插件会自动处理版本冲突。
5. 性能调优与进阶技巧:让考试系统扛住千人并发
5.1 用Redis缓存试卷和题目,减少数据库压力
考试开始后,所有学生同时拉取试卷,数据库瞬间承受大量重复查询。我一般会把试卷和题目缓存在Redis里,设置过期时间为考试结束时间加1小时。缓存key的设计是exam:{examId}:questions,value是题目列表的JSON字符串。
public List<Question> getExamQuestions(Long examId) { String key = "exam:" + examId + ":questions"; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, Question.class); } List<Question> questions = questionMapper.selectByExamId(examId); redisTemplate.opsForValue().set(key, JSON.toJSONString(questions), Duration.ofHours(3)); return questions; }缓存更新策略是:教师修改试卷时删除对应的key,下次查询时重新加载。注意不要在考试进行中修改试卷,否则会导致学生看到的题目不一致。
5.2 提交接口的限流与降级
考试结束前5分钟是提交高峰,如果所有请求都打到数据库,连接池扛不住。我一般会在Controller层加一个基于Redis的令牌桶限流,每秒放行200个提交请求,超出的请求返回“系统繁忙,请稍后重试”。
public boolean tryAcquire(String key, int maxPermits, int rate) { String script = "local current = redis.call('incr', KEYS[1]) " + "if tonumber(current) == 1 then " + " redis.call('expire', KEYS[1], 1) " + "end " + "if tonumber(current) > tonumber(ARGV[1]) then " + " return 0 " + "else " + " return 1 " + "end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), String.valueOf(maxPermits) ); return result != null && result == 1; }这个Lua脚本保证原子性,key按秒设置过期时间,实现滑动窗口限流。参数maxPermits根据服务器配置调整,一般单台4核8G的服务器设200比较稳妥。
5.3 用异步判卷提升提交响应速度
如果判卷逻辑复杂(比如包含编程题自动评测),同步判卷会让提交接口响应时间超过3秒。我一般会把判卷逻辑异步化:提交接口只负责保存答案和返回“提交成功”,判卷任务丢到消息队列里,由消费者慢慢处理。
@Transactional public SubmitResult submitExam(Long examId, Long userId, List<AnswerDTO> answers) { // 保存答案 for (AnswerDTO dto : answers) { answerMapper.insert(convertToAnswer(dto, examId, userId)); } // 发送判卷消息 rabbitTemplate.convertAndSend("exam.grade.queue", new GradeMessage(examId, userId)); return SubmitResult.success("提交成功,成绩稍后公布"); }消费者端监听队列,执行判卷逻辑并更新成绩表。这样提交接口的响应时间从秒级降到毫秒级,学生体验好很多。注意消息队列要保证幂等,消费者收到重复消息时先查成绩表是否已有记录。
5.4 考试数据归档与历史查询优化
一个学期下来,答题记录表可能积累几十万条数据。查询历史成绩时如果直接扫全表,响应会很慢。我一般会按学期分表,或者把超过一年的数据归档到历史表。归档策略是:每月1号凌晨,把submit_time超过365天的答题记录和成绩记录迁移到t_answer_history和t_score_history,然后从主表删除。
-- 归档答题记录 INSERT INTO t_answer_history SELECT * FROM t_answer WHERE submit_time < DATE_SUB(NOW(), INTERVAL 365 DAY); DELETE FROM t_answer WHERE submit_time < DATE_SUB(NOW(), INTERVAL 365 DAY);归档前先备份,归档后更新统计信息。这个操作放在低峰期执行,避免影响正常考试。
5.5 监控与告警:上线后必须盯住的三个指标
系统上线后,我每天会看三个指标:数据库连接池活跃数、提交接口平均响应时间、判卷队列积压量。连接池活跃数持续超过maxActive的80%就要扩容;提交接口响应时间超过1秒就要查慢SQL;判卷队列积压超过1000条就要加消费者实例。
监控用Spring Boot Actuator暴露指标,配合Prometheus和Grafana做可视化。告警规则设简单点:连接池活跃数 > 40 持续1分钟、提交接口P99 > 2000ms、队列积压 > 500,触发邮件告警。
这套系统我从零搭过三次,每次都会在考试前一周做压力测试,用JMeter模拟500人同时提交。血泪经验是:不要等到考试当天才发现问题,提前压测能暴露90%的坑。希望帮到你。
本文还有配套的精品资源,点击获取