在线考试系统开发实战:Spring Boot+MyBatis实现组卷判分与并发控制
2026/9/17 3:57:12 网站建设 项目流程

简介:基于Java的在线考试管理系统毕业设计资料包,主要面向计算机、软件工程等专业的本科毕业生,解决毕业设计阶段在线考试系统课题从架构设计、编码实现到论文撰写的完整需求。系统涵盖用户登录、试题管理、在线答题、自动评分、成绩统计等典型模块,可用于快速搭建可演示的课程设计或毕设原型。压缩包仅1.41MB,共132个文件,以jsp页面、class类文件、jar依赖库及gif界面截图为主体,附带htm说明、doc论文材料以及mdf/ldf数据库备份等,结构清晰,便于按需查阅。资料包含源代码、毕业论文、开题报告、外文翻译与英文文献、答辩PPT,能支撑从开题到答辩的全过程。已有444人学习浏览,适合即将答辩或尚在系统开发初期的同学直接参考整体架构、运行调试并对照论文理解关键模块实现;同时可在现有源码基础上扩展随机组卷、计时答题、成绩导出等功能,提高毕设完成度。

1. 在线考试管理系统没那么简单:从需求到代码的取舍

很多人在做 Java 毕业设计时一眼相中“在线考试管理系统”,因为它看起来熟面孔多:登录、题库、组卷、判分、成绩单。真正动手才发现,考试系统最麻烦的不是 CRUD,而是“考试过程中系统必须保持正确”这一条。学生交卷那一刻,服务端要同时处理网络抖动、重复点击、倒计时截止、随机组卷不重复、主观题判分留痕,任何一环没做干净,答辩现场就会翻车。

这套系统的设计核心不是一个高并发秒杀平台,而是一个“状态机 + 事务边界”的工程题。后端选型常落在 Spring Boot + MyBatis 上,前端用 Vue 或 JSP 都能接受;数据库则必须把试题、试卷、答卷、考试记录拆清楚。本文从数据模型到实际代码,把在线考试管理系统的关键路径拆开讲,覆盖组卷算法、自动判分、并发去重、超时判定和本地运行排错。每段代码都可以直接抄进你的毕业设计里,但更重要的是理解为什么要这样写。

2. 在线考试管理系统的数据模型与建表语句

考试系统的业务状态比一般管理系统多。一次考试生命周期包含:创建、发布、进行中、已结束;一份答题记录包含:未开始、答题中、已提交、已判分。数据模型必须把这些状态固化成字段,而不是靠程序里临时判断。

2.1 五个核心表与关系

常见做法是拆五张主表,再加两张关联表。主表分别是用户表(student/teacher 同表加角色)、考试表(exam)、试题表(question)、试卷表(exam_paper)、答卷表(answer_sheet)。如果每张卷子的题目固定,exam_paper 和 question 之间可以直接用中间表 exam_paper_question;如果支持随机组卷,就往中间表加一个抽题规则字段。

这里先理清关系:一个考试对应一份或随机多份试卷,一个试卷包含多道试题,一个学生一次考试只产生一份答卷,答卷详情表存每一道题的作答内容和得分。不要把作答内容直接塞进 answer_sheet 的一个字段里,除非你确定只考选择题。

2.2 建表 SQL 与字段参数说明

下面给出适合 MySQL 8.0 的核心建表语句,省略了冗余索引,只保留关键约束。注意字符集统一用 utf8mb4,避免录入表情或公式符号时变乱码。

CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', role TINYINT NOT NULL DEFAULT 2 COMMENT '1教师,2学生', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用,0禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_name VARCHAR(100) NOT NULL, paper_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration INT NOT NULL COMMENT '考试时长,单位分钟', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未发布,1进行中,2已结束', shuffle_questions TINYINT NOT NULL DEFAULT 1 COMMENT '是否乱序题目,1是0否' ) ENGINE=InnoDB COMMENT='考试表'; CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_type TINYINT NOT NULL COMMENT '1单选,2多选,3判断,4主观', content TEXT NOT NULL COMMENT '题干', options TEXT COMMENT 'JSON数组,如["A","B"]', answer VARCHAR(1000) COMMENT '客观题答案或主观题要点', score INT NOT NULL DEFAULT 5, difficulty TINYINT NOT NULL DEFAULT 3 COMMENT '1-5,5最难', course_id BIGINT COMMENT '所属课程ID,用于分类抽题' ) ENGINE=InnoDB COMMENT='试题表'; CREATE TABLE answer_sheet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0答题中,1已提交,2已判分', total_score DECIMAL(6,2) DEFAULT NULL COMMENT '最终得分', submit_time DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' ) ENGINE=InnoDB COMMENT='答卷主表'; CREATE TABLE answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, answer_sheet_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(2000) NOT NULL COMMENT '学生提交的答案', score DECIMAL(6,2) DEFAULT NULL COMMENT '本题得分', is_correct TINYINT DEFAULT NULL COMMENT '1对0错,主观题判分后写入' ) ENGINE=InnoDB COMMENT='答卷明细表');

字段参数说明:password 长度设到 255,因为 BCrypt 加密串本身超过 60 字符。duration 用分钟整数,而不是存开始结束时间差,因为结束时间可能被人工延期,但每场考试的总时长是固定的。question 的 options 存 JSON 字符串,方便前端解析,也避免为了几个选项单独建表。answer_sheet 里的 version 字段在后面并发控制章节会用到,这里先留坑。

2.3 状态字段与考试流程的映射

状态字段用 TINYINT 而不是字符串枚举,存储更紧凑,Java 里用枚举类映射。考试的 status 变化由定时任务或用户在管理端触发,每次发布考试前都要生成一次帖子。这里的核心规则是:exam.start_time 和 end_time 只是计划时间,真正允许答题的时间窗口要以考试发布后学生开始答题的那一刻为准,并用 duration 限制最长答题时间。

学生的答题状态在 answer_sheet.status 上体现。刚开始考试时插入一条状态为 0 的记录;学生点击“交卷”后,系统先执行判分逻辑,再一次性把 status 改成 1;主观题批改完成后改成 2。注意 status 不能直接从 0 跳到 2,因为自动判分和人工判分是两个步骤,合在一起容易丢失操作记录。

3. 用 Spring Boot + MyBatis 实现随机组卷与自动判分

现在进入代码层。我常用的组合是 Spring Boot 2.7 + MyBatis-Plus + MySQL,因为 MyBatis-Plus 的代码生成器和 LambdaQueryWrapper 能减少毕业设计里的样板代码,也让答辩时更容易解释 SQL 的运行逻辑。

3.1 项目结构与核心依赖

包结构建议按 controller、service、mapper、entity、common 划分,不要把所有代码写进 controller。一个典型的 controller 只负责接收参数和返回统一结果,业务规则全部下沉到 service。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

依赖说明:mybatis-plus-boot-starter 会自动配置 SqlSessionFactory 与事务管理器,所以不要再手动引入 mybatis 和 spring-jdbc 的重复版本。lombok 用来减少 getter/setter 代码,答辩时老师看你实体类更清爽;如果团队规范不允许 lombok,可以去掉,但要补齐实体类方法。

3.2 随机组卷:从题库按规则抽题

随机组卷不是简单的 order by rand(),因为试题量到几千条以后,order by rand() 会全表扫描并临时排序,性能很差。常见做法是先查出符合条件的题目 id 列表,在 Java 里做随机抽取再回表查详情。

@Service public class ExamService { @Resource private QuestionMapper questionMapper; @Resource private ExamPaperQuestionMapper paperQuestionMapper; public List<Long> generatePaperQuestions(Long examId, Long courseId, Integer singleCount, Integer multiCount) { // 1. 从题库捞出候选题目ID,只查id列减少传输 List<Question> candidates = questionMapper.selectList(new LambdaQueryWrapper<Question>() .eq(Question::getCourseId, courseId) .exists("SELECT 1 FROM exam_paper_question epq WHERE epq.question_id = question.id") .select(Question::getId, Question::getQuestionType) .eq(Question::getQuestionType, 1)); // 单选 // 2. 随机打乱后取前N个 Collections.shuffle(candidates); List<Long> pickedIds = candidates.stream() .map(Question::getId) .limit(singleCount) .toArray(); return pickedIds; } }

参数说明:exist 子查询用来限制只能在已审核通过的题目中抽,避免把临时录入的废题发给学生。如果题目总数小于要抽的数量,limit 不会报错,但会返回不足,需要在业务层抛异常提示“题库中符合条件的单选题目不足”。另一种做法是把试题表加一个 status 字段标记是否可用,上面用 exists 是避免额外字段。两者都可以,但建议加status TINYINT DEFAULT 1,查询和索引更简单。

3.3 答题提交与自动判分的事务边界

自动判分的关键是判分逻辑必须发生在事务内,并且要保证“提交答卷”和“更新得分”要么都成功,要么都失败。下面代码演示一次事务性的交卷入口。

@Transactional(rollbackFor = Exception.class) public SubmitResult submitAnswerSheet(Long sheetId, List<UserAnswerDTO> answers) { AnswerSheet sheet = answerSheetMapper.selectById(sheetId); if (sheet == null || sheet.getStatus() != 0) { throw new BusinessException("答卷不存在或已提交"); } // 1. 先逐题判分,统计总分 BigDecimal total = BigDecimal.ZERO; List<AnswerDetail> details = new ArrayList<>(); for (UserAnswerDTO dto : answers) { Question q = questionMapper.selectById(dto.getQuestionId()); boolean correct = q.getAnswer().equalsIgnoreCase(dto.getUserAnswer()); AnswerDetail detail = new AnswerDetail(); detail.setAnswerSheetId(sheetId); detail.setQuestionId(q.getId()); detail.setUserAnswer(dto.getUserAnswer()); detail.setIsCorrect(correct); detail.setScore(correct ? q.getScore() : BigDecimal.ZERO); details.add(detail); if (correct) { total = total.add(BigDecimal.valueOf(q.getScore())); } } // 2. 批量插入明细,更新主表状态 answerDetailMapper.insertBatch(details); sheet.setStatus(1); sheet.setTotalScore(total); sheet.setSubmitTime(LocalDateTime.now()); answerSheetMapper.updateById(sheet); return new SubmitResult(total); }

事务边界说明:@Transactional必须加在 public 方法上,且不能被同类中另外的方法直接调用,否则 Spring 的代理生效。rollbackFor 设为 Exception.class 是因为默认只在抛出 RuntimeException 时回滚,而这里可能捕获到检查异常。判分时直接使用 equalsIgnoreCase 处理客观题,多选题答案如果存的是A,B这种字符串,注意先排序再比较;或者存 JSON 数组,用 JSON 库判断集合相等。

3.4 试卷与答题记录的读写分离思路

虽然毕业设计不需要真正部署读写分离,但在代码结构上可以保留这层设计。AnswerSheetMapper 上两个方法分开:一个是selectForUpdate,一个是普通查询。前端加载考试详情时走普通查询;学生点击交卷时,service 内部先走selectForUpdate锁住答卷行,防止重复提交。这种写法在答辩时可以说“为高并发预留了读写分离的改造空间”,不会显得生硬。

@Select("SELECT * FROM answer_sheet WHERE id = #{id} FOR UPDATE") AnswerSheet selectForUpdate(Long id);

FOR UPDATE是悲观锁的一种,它会在事务内锁定这一行,其它事务要更新这行必须等待。在交卷场景里,同一份答卷只可能被同一个学生提交,锁竞争极小,所以悲观锁比乐观锁更简单可靠。但要注意,SELECT ... FOR UPDATE必须在事务中执行,否则锁会在查询结束立刻释放,形同虚设。

4. 在线考试系统的并发控制与时间校验

在线考试系统最容易被老师追问的点是:如果学生在最后一秒点交卷,但请求因为网络延迟多发了两次,系统会不会产生两份答卷?如果考试已经截止,但学生本地没有刷新,服务端是否还能接受他的提交?这一章就是把这些问题用代码堵死。

4.1 重复提交与幂等处理

前后端都要做防重复。前端把交卷按钮在第一次点击后置灰并显示“正在提交”,这是体验层;后端必须用逻辑挡住重复请求。前面已经用status != 0的判断挡住第二次提交,但这个判断在第一次提交尚未提交事务时并不可靠——两个并发请求都读到 status=0,都能通过检查。

这时候需要唯一约束。在 answer_sheet 表上加一个业务唯一键:exam_id+user_id,保证同一个学生同一场考试只有一条答卷记录。

ALTER TABLE answer_sheet ADD UNIQUE KEY uk_exam_user (exam_id, user_id);

数据库层唯一索引是最底层的兜底。代码里即使先通过 status 判断,再插入明细并更新状态,最终插入时如果已有记录,数据库会抛DuplicateKeyException,利用这个异常可以友好提示“考试已提交”。更稳的方案是用 insert 而不是 update 做幂等:INSERT INTO answer_sheet ... ON DUPLICATE KEY UPDATE,但那样需要先算出总分再插入,结构上不如先插入后更新清晰。

4.2 数据库乐观锁与考试超时判定

answer_sheet 表里的 version 字段在这里派上用场。每次提交或保存草稿时,先读取版本号,在 update 语句的 where 条件里带上前一版版本号:

UPDATE answer_sheet SET status = 1, total_score = #{totalScore}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion};

如果更新行数为 0,说明其它请求已经改了这条记录,当前请求应当终止并提示“已在其他页面提交”。这种乐观锁适用于保存草稿、人工改分等写操作;配合前面的悲观锁FOR UPDATE时要注意不能同时用,否则会互相阻塞甚至死锁。选一种即可,不要在同一个事务里混用。

超时判定同样不能依赖前端计时器。正确做法是服务端每次接收答案时计算当前时间是否晚于submit_time + duration分钟。这里有一个易错点:submit_time是学生创建答卷的时间,而不是考试结束时间。如果一个考试时长 60 分钟,学生 09:00 进入,10:30 才交,系统应该以 10:00 为最迟时间拒绝写入答案,哪怕 exam.end_time 设置在 11:00。

public void checkDeadline(AnswerSheet sheet, Exam exam) { LocalDateTime deadline = sheet.getCreateTime().plusMinutes(exam.getDuration()); if (LocalDateTime.now().isAfter(deadline)) { throw new BusinessException("考试时间已到,自动提交失败,请联系监考老师"); } }

这里还有一个“软截止”与“硬截止”的差别。考试进行中批量保存答案时,超时后被拒是合理的;但学生已经点了交卷按钮,即使超时,系统也应该允许提交当前已保存的答案,判分时以超时时间点为界截断。所以交卷接口要分成两个:一个“保存答案”接口,严格检查超时;一个“正式交卷”接口,覆盖超时但只判分已保存的答案。

4.3 常见异常场景与参数调整

在 MySQL 默认隔离级别 REPEATABLE READ 下,SELECT ... FOR UPDATE和乐观锁的配合还有个隐藏问题:如果一个事务先按 sheetId 查了行并修改,另一个事务等待,前一个事务提交后,后一个事务读取到的是旧快照。所以在使用乐观锁时,where 条件里的 version 必须来自事务开始前的那次查询,不能在同一事务内重新查询再更新。

下面给出一个调参清单,碰到并发异常时优先检查这些地方,而不是一上来就改数据库隔离级别:

参数位置推荐值说明
Tomcat 最大线程数200server.tomcat.threads.max,毕业设计不用太高
连接池最大活动数20spring.datasource.hikari.maximum-pool-size
事务超时时间30 秒@Transactional(timeout = 30)
数据库 innodb_lock_wait_timeout50 秒超过后事务不立即失败,但日志会明显变慢

Tomcat 线程数在考试场景下并不能无限增加,因为每个线程最终都要抢数据库连接。连接池大小设为 CPU 核心数 ×2 左右比较合理,200 线程配 20 个数据库连接时,多余的请求会在应用层等待。答辩时如果说清楚这个关系,比甩出一堆配置要有价值。

5. 从毕业设计源代码到本地运行的三个关键动作

拿到一个标着“源代码+论文+开题报告+答辩PPT”的压缩包,第一步不是打开 idea 就是双击数据库脚本,而是先看文件目录结构。常见的打包方式有两种:一种是直接用 IDEA 导入整个 Maven 工程,另一种是前后端分离,前端 node_modules 也在包里。分清这两种结构,能少走很多弯路。

5.1 环境准备与数据库初始化

无论包里的 README 写得多简单,我都建议依次检查四样东西:JDK 版本、Maven 仓库镜像、MySQL 字符集、Redis 是否被依赖。很多毕业设计源码会用到 Redis 做 token 或缓存,但压缩包里不一定附带 Redis 安装包。如果项目启动时提示连接 Redis 失败,先看配置文件中 spring.redis.host 和 port 是否指向本机 localhost。

Java 环境变量配置是老生常谈,但每次都有同学卡住。确认 JAVA_HOME 指向 JDK 安装目录,而不是 JRE;Path 里加%JAVA_HOME%\bin。在命令行执行java -version验证时,注意 IDEA 默认用的可能是内置 JBR(JetBrains Runtime),它编译运行没问题,而 mvn 命令用的是系统 JDK,两者版本不一致就会出现“编译成功但启动报模块不支持”的诡异问题。

数据库初始化步骤固定这四条,顺序不要乱:

mysql -u root -p < init.sql mysql -u root -p < insert_demo_data.sql

先建库建表,再灌演示数据。如果包里有多个 sql 文件,需要按文件名前缀顺序执行,或者看 db 目录下的说明。遇到中文乱码,检查 sql 文件编码是否为 UTF-8,Windows 下记事本另存为容易变成 GBK 导致导入后中文变问号。

5.2 启动失败时的排错清单

最常遇到的是端口冲突。Spring Boot 默认端口是 8080,如果之前跑过其他项目,日志里会有Port 8080 was already in use。这时候不要去改前端页面里的请求地址,而应该在application.yml里改 server.port,同时保持前端代理一致。如果用的是前后端分离,开发时 Vue 的 devServer 配置 proxy 转发到后端的 8018 端口,那么后端端口改成多少,proxy 的 target 也要同步改。

另一个高频失败点是 MyBatis-Plus 的实体类映射。表名user_account在 Java 类里如果叫UserAccount,默认驼峰转下划线没问题;但如果表名是user,而实体类叫User,MyBatis-Plus 会把 SQL 生成成user_account,因为你可能用了@TableName("user_account")。建议在数据库脚本里就统一命名规范,避免实体类注解写错。

5.3 答辩演示前的验证脚本

与其现场用界面慢慢点,不如准备一套自动化验证流程。先用 curl 模拟登录并抽出 JWT token,然后连续调用组卷接口两次,断言两道卷的题目序列不一致;再模拟同一份答卷提交两次,看第二次是否返回“已提交”的提示。这个过程中观察控制台日志里的 SQL 执行顺序,能快速发现事务未生效的问题。

在停表前,把验证逻辑归纳为三个断言:提交前总成绩为空,提交后总成绩等于客观题得分之和;重复提交返回同一错误码;超时请求被拒绝但系统不崩溃。把这三点写进线上文档,答辩时老师会让你现场演示,你只需要点击对应接口的运行按钮,而不必像无头苍蝇一样找菜单。

最后的提醒是:在线考试系统最怕“看起来能跑,实际规则全是错的”。你设计的每一个字段、每一个事务方法、每一条状态流转,都值得在本地写一段单元测试。哪怕只是 JUnit 里断言一次判分结果,都比在答辩现场临时点按钮更有说服力。

本文还有配套的精品资源,点击获取

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

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

立即咨询