ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot在线考试系统实战:组卷、判分与部署避坑

Spring Boot在线考试系统实战:组卷、判分与部署避坑 简介基于Spring Boot的在线考试系统设计文档面向高校毕业设计或课程项目开发者围绕在线考试管理的经典选题提供从需求分析、技术选型到系统实现的一整套论文方案。文档重点讲解了后端框架、Java语言与MySQL数据库的配合方式并依据MVC架构将系统划分为管理员、教师、学生三类角色模块覆盖试题管理、考试发布、在线答题、成绩统计等核心业务同时讨论了前端界面实现、后端逻辑编写、数据库表结构设计、用户密码加密与数据备份等安全性措施以及功能测试与性能优化思路。资源为1个docx文件压缩包约3.88MB属纯文档型资料可作为毕业设计说明书或小型在线考试项目的参考蓝本。已有122人学习下载。通过这份材料读者可以快速把握在线考试系统的整体设计脉络与模块划分理解所用技术栈的具体落地方式还能直接借鉴其摘要、目录和章节组织用于规范撰写论文或开展同类管理系统的设计开发。1. 在线考试系统为什么值得用 Spring Boot 重做一遍很多学校、培训机构和在线教育平台的考试流程至今还是人工为主出卷老师用 Excel 抽题教务打印密封考试结束再人工阅卷、手动登分。一百人规模的课程考试前后磨掉一周很正常试卷漏印、分数登错这类问题还很难补救。线上化之后“基于 Spring Boot 的在线考试系统设计与实现”就成了后端开发者和毕业设计课题里反复出现的需求它真正要管的不是把一张卷子搬到网页上而是随机组卷、自动判分、防重复提交、成绩可信这一段完整链路。Spring Boot 约定大于配置的工程风格配合 MyBatis 对 SQL 的精确把控让这套系统在单机部署时足够简单真要拆服务也有成熟路径。这篇文章我不按功能列表展开而是按一条可落地的主线来讲先把数据表设计出来再用 IntelliJ IDEA 社区版把工程跑起来然后实现组卷和判分最后集中说部署阶段容易翻车的地方。2. 先别写代码考试系统的数据模型与选型理由2.1 六张核心表与一条完整的考试链路在线考试系统的核心链路其实很短学生登录 → 选择已发布的考试 → 进入考试 → 随机或固定拿到试卷 → 答题 → 提交 → 自动判分 → 成绩落库。但这条链路一旦并发进来数据表设计如果没想清楚后面改起来就是动筋骨的事。我一般会把表拆成六张用户表、题库表、考试表、试卷明细表、考试记录表、答题明细表。下面这张用户表是地基角色字段决定权限边界CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role TINYINT NOT NULL DEFAULT 2, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );role 用 TINYINT 而不是字符串是为了避免 Java 侧到处写魔法值。实际项目里我会再建一张字典表把 1、2、3 解释成管理员、教师、学生但考试接口里判断角色只需要比较整数性能更好代码也更短。这样做的代价是查询展示层需要多一次字典翻译属于正常的取舍。题库表字段稍微多一点因为它要同时支撑手动组卷和随机组卷CREATE TABLE question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT 1单选 2多选 3判断 4简答, content TEXT NOT NULL, options TEXT COMMENT JSON格式的选项组, answer VARCHAR(255) NOT NULL, score INT NOT NULL DEFAULT 2, difficulty TINYINT NOT NULL DEFAULT 1 );单选、多选、判断这类客观题用 answer 字段直接存标准答案简答题的 answer 存参考答案要点自动判分只判断选择题和判断题简答留给教师人工阅卷。options 字段用 JSON 字符串存是为了避免为每种题型单独建一张选项表毕竟项目初期题目量不过几千条JSON 字段的查询压力可以忽略。考试表是状态流转的核心start_time 和 end_time 决定考试是否在有效期内CREATE TABLE exam ( id BIGINT AUTO_INCREMENT PRIMARY KEY, exam_name VARCHAR(100) NOT NULL, subject_id BIGINT NOT NULL, total_score INT NOT NULL DEFAULT 100, duration_minutes INT NOT NULL DEFAULT 90, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 );试卷明细表把一场考试和题目关联起来sort_no 控制题目显示顺序。考试记录表是学生答题状态的总账我特意加了一个联合唯一索引这是后面防止重复交卷的最强兜底CREATE TABLE exam_paper ( id BIGINT AUTO_INCREMENT PRIMARY KEY, exam_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL, sort_no INT NOT NULL ); CREATE TABLE exam_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, objective_score DECIMAL(6,2), total_score DECIMAL(6,2), submit_time DATETIME, status TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_exam_user (exam_id, user_id) );注意两点。第一考试记录表里的 status 用 0、1、2 分别表示作答中、已交卷、已判分不要用字符串否则索引长度和比较开销都会变大。第二uk_exam_user 这个联合唯一索引不仅保证一名学生对一场考试只有一条总记录还能在极端并发下挡住重复交卷这一点我放到后面避坑章节再展开。2.2 为什么选 Spring Boot MyBatis 而不是 JPA 或 FastAPI选型理由在真实项目里往往不是技术最新而是团队和维护成本最合适。Spring Boot 这个组合在国内后端团队里几乎人手都会尤其是学校里的信息系统多半跑在 Windows 服务器或一台低配 CentOS 上OpenJDK 8 加 MySQL 5.7 是默认环境Spring Boot 2.3.x 到 2.6.x 全都能覆盖这个范围。MyBatis 相对 JPA 的最大优势是 SQL 完全可控。在线考试系统里随机抽题是个典型场景SELECT * FROM question WHERE subject_id #{subjectId} AND type #{type} ORDER BY RAND() LIMIT #{count}这种 SQL 用 MyBatis 注解直接写在 Mapper 接口上就行DBA 或者维护者一眼能看懂换成 JPA 的 Criteria API写出来既冗长又不好调优随机抽题这类需求还要绕一层 Specification完全没有必要。MyBatis-Plus 在此基础上提供了现成的分页插件和逻辑删除毕业后接手的新人也容易上手。有人会问既然要轻量用 Python FastAPI 行不行。FastAPI 写接口确实更短但考试系统一旦要对接学校的统一身份认证、教务处选课数据、成绩导出到 Excel 报表这些业务 Java 生态里的工具类、POI 库、定时任务方案都更成熟。另一个现实是大量计算机类专业的课程设计和毕业设计课题都用 Java 选题后面做论文答辩、代码讲解时Spring Boot 的经典三层结构比 FastAPI 更容易把设计与实现讲清楚。Spring Boot 版本这里多说一句2.3.x 和 2.6.x 是网上资料最密集的两个版本网上搜得到的解决方案基本都对得上。Spring Boot 3 开始强制要求 JDK 17并且把 javax 命名空间换成了 jakarta如果你的依赖中有一些老牌工具库没升级编译期就会翻车。对于在线考试系统这个体量的项目建议直接选 Spring Boot 2.6.13资料多、踩坑成本低迭代到 3.x 等工作量完全可以等系统稳定后再评估。3. 从零跑通工程骨架IDEA 社区版和配置文件的细节3.1 没有 Spring Initializr 的社区版怎么起步IntelliJ IDEA 社区版免费但不带 Spring Initializr新建项目时找不到 Spring Boot 模板。常见做法是先用 Maven 骨架创建一个空白 Java 项目然后把 Spring Boot 的依赖写进 pom.xml。实际操作非常简单不要因为缺一个模板就放弃手写 pom.xml 反而能让你看清每个依赖是干什么的。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.6.13/version relativePath/ /parent groupIdcom.example/groupId artifactIdonline-exam/artifactId version1.0.0/version properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.2.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /projectpom.xml 里最关键的几处mybatis-spring-boot-starter 的版本一定要显式指定因为 Spring Boot 官方 parent 不会管理它的版本mysql-connector-java 的 scope 设为 runtime代码里不需要直接 import 它spring-boot-starter-web 自带内嵌 Tomcat后续打 jar 包直接 java -jar 启动即可。接下来建一个主类所有层都从它身上分出去SpringBootApplication MapperScan(com.example.exam.mapper) public class OnlineExamApplication { public static void main(String[] args) { SpringApplication.run(OnlineExamApplication.class, args); } }MapperScan 的作用是让 MyBatis 自动扫描 mapper 包下的所有接口不用一个个在接口上加 Mapper 注解。初学阶段很多人漏了这一步启动后直接报 Invalid bound statement (not found)其实根因就是 Mapper 接口没有被容器识别。3.2 application.yml 数据源参数和 MyBatis 关键配置配置文件的坑比代码还多。我最常提醒别人的一句话是先把时区和编码在连接串里写死不要留给部署环境去猜。下面是经过多环境验证的配置模板server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.exam.entity configuration: map-underscore-to-camel-case: true logging: level: com.example.exam.mapper: debugurl 中 serverTimezoneAsia/Shanghai 解决 MySQL 8 时区报错和日期差八小时的问题characterEncodingutf8 防止中文乱码这两个参数一百个项目里一百个需要。HikariCP 是 Spring Boot 2.x 默认连接池maximum-pool-size 对在线考试系统来说 20 足够了不是说越大越好连接池太大反而会让 MySQL 的线程调度开销上升50 人同时交卷这种场景20 个连接完全能扛住关键是别把连接长时间攥在手里。mybatis.mapper-locations 指向 XML 文件目录即使你用注解 SQL 也建议保留这一项方便后续把复杂 SQL 挪到 XML 里。map-underscore-to-camel-case 必须写成 true否则数据库里 create_time 字段映射不到 Java 实体的 createTime 属性查出来的对象这个字段就是 null这是新手最容易困惑的玄学问题。logging.level 把 Mapper 包设为 debug开发期能在控制台看到每次执行的 SQL 和参数排查问题效率直接翻倍。4. 组卷与判分在线考试最核心的两块代码4.1 随机组卷实现每题型单独抽样与总分约束在线考试系统最常见的需求是自动组卷。实现方式不止一种最直接的是在 Service 里按题型分别调用随机查询然后统一写入试卷明细表。下面这个例子假设一场考试包含 20 道单选、10 道多选、10 道判断分值分别是 2 分、3 分、2 分总分正好 100。Service public class PaperServiceImpl implements PaperService { private final QuestionMapper questionMapper; private final ExamPaperMapper examPaperMapper; public PaperServiceImpl(QuestionMapper questionMapper, ExamPaperMapper examPaperMapper) { this.questionMapper questionMapper; this.examPaperMapper examPaperMapper; } Override Transactional(rollbackFor Exception.class) public void generatePaper(Long examId, Long subjectId) { // 先删旧题同一场考试重复组卷会导致题目翻倍 examPaperMapper.deleteByExamId(examId); ListQuestion singles questionMapper.selectRandomByType(subjectId, 1, 20); ListQuestion multiples questionMapper.selectRandomByType(subjectId, 2, 10); ListQuestion judges questionMapper.selectRandomByType(subjectId, 3, 10); int sortNo 1; for (Question q : singles) { examPaperMapper.insert(examId, q.getId(), q.getScore(), sortNo); } for (Question q : multiples) { examPaperMapper.insert(examId, q.getId(), q.getScore(), sortNo); } for (Question q : judges) { examPaperMapper.insert(examId, q.getId(), q.getScore(), sortNo); } } }对应 Mapper 里的随机查询Select(SELECT * FROM question WHERE subject_id #{subjectId} AND type #{type} ORDER BY RAND() LIMIT #{count}) ListQuestion selectRandomByType(Param(subjectId) Long subjectId, Param(type) Integer type, Param(count) Integer count);这里有几个关键点。一是每题型分别抽样不能混在一起 ORDER BY RAND() 后随机取 40 道因为那样很可能单选挤占多选的数量卷面结构就乱了。二是方法上加了 Transactional一旦中间某一步失败整个组卷不留下半张卷子。三是 deleteByExamId 放在开头保证接口重复调用不会造成题目翻倍。参数说明上count 的数量要和考试的题型分值配合。比如你设置单选题每题 2 分、多选每题 3 分、判断每题 2 分那么 20 单选 10 多选 10 判断正好 100 分。如果题库里某个题型的记录数少于 countLIMIT 返回不了那么多最后总分就不够因此组卷完成后最好加一道总分校验逻辑发现不对就抛出异常回滚。4.2 提交判分的事务设计防重复提交与分数一致性学生交卷是系统里并发压力最大的接口。前端把作答明细一次性 POST 过来后端要做三件事校验考试时间、落答案明细、算客观题分数。这个接口不能容忍重复调用否则成绩和明细都会翻倍。我用下面的方式实现Override Transactional(rollbackFor Exception.class) public SubmitResult submit(SubmitRequest request) { Exam exam examMapper.selectById(request.getExamId()); LocalDateTime now LocalDateTime.now(); if (now.isBefore(exam.getStartTime()) || now.isAfter(exam.getEndTime())) { throw new BusinessException(不在考试时间范围内交卷被拒绝); } ExamRecord record examRecordMapper.selectByExamAndUser(request.getExamId(), request.getUserId()); if (record null) { record new ExamRecord(); record.setExamId(request.getExamId()); record.setUserId(request.getUserId()); examRecordMapper.insert(record); } else if (record.getStatus() 1) { throw new BusinessException(这张试卷已经提交过请勿重复提交); } int objectiveScore 0; for (AnswerItem item : request.getAnswers()) { Question question questionMapper.selectById(item.getQuestionId()); boolean right item.getUserAnswer() ! null question.getAnswer().equalsIgnoreCase(item.getUserAnswer()); if (right) { objectiveScore question.getScore(); } examRecordItemMapper.insert(record.getId(), item.getQuestionId(), item.getUserAnswer(), right, right ? question.getScore() : 0); } examRecordMapper.updateScore(record.getId(), BigDecimal.valueOf(objectiveScore), now); return SubmitResult.ok(record.getId()); }事务方法里查询 exam_record 时如果第一次点交卷还没提交事务第二次并发请求会读到同样的空记录然后两个事务同时 insert最终就会出现两条记录。这就是为什么我在第 2 章强调数据库要加 uk_exam_user 唯一索引一旦第二个 insert 撞上唯一索引数据库会抛 DuplicateKeyException业务层捕获后转成“请勿重复提交”这才是最终的防线。代码里的 status 1 判断只是前置拦截数据库唯一索引才是兜底。评分逻辑上客观题分数逐题累加存到 objective_score 字段。实际考试系统里简答题往往需要人工阅卷所以提交后总成绩先不落库等教师在管理端把简答判完再更新 total_score。自动判分和人工判分分离边界清晰很多。5. 在线考试系统上线避坑五个让我加班到凌晨的问题5.1 考试明明没结束页面却提前锁死现象学生在本地看到距离考试结束还有 30 分钟但系统提示不在考试时间范围内交卷被拒绝。原因服务器时区是 UTCMySQL 连接串没加 serverTimezone数据库存进去的时间和 JVM 的 LocalDateTime.now() 差八小时end_time 在数据库里的值比真实时间早八小时考试自然提前过期。解决数据库连接 URL 里显式加上 serverTimezoneAsia/Shanghai同时实体类时间字段统一用 LocalDateTime 而不是 java.util.DateDDL 里的 DATETIME 也保持一致。改完重启后再把 end_time 在数据库里重新核对一次别想当然认为不用改。5.2 查出来的题目字段一半是 null现象学生在答题页面看见的题目内容正常但题目的 id、score 等部分字段显示空前后端联调时一头雾水。原因MyBatis 默认不开启驼峰映射数据库里的 subject_id 映射不到 Java 属性的 subjectIdscore 因为两边同名所以正常。查出来的对象里下划线字段全是空的。解决在 application.yml 里设置 mybatis.configuration.map-underscore-to-camel-case: true。如果某些 SQL 用了自定义 resultMap记得在 resultMap 里把 column 和 property 对应写清楚因为驼峰映射对自定义 resultMap 也会失效。5.3 用户双击交卷成绩被记了两次现象前端交卷按钮没有做防抖学生连点两下管理端看到同一学生同一场考试出现两条记录总分错误。原因事务还没提交时第二个请求来查 exam_record查询结果是空于是它也在插入两个事务最终都成功唯一索引没建就会发生。解决exam_record 表建联合唯一索引 uk_exam_user(exam_id, user_id)Service 层里捕获 DuplicateKeyException 并返回友好提示前端在发起交卷请求后立刻把按钮置灰。三层都加上基本可以杜绝这类问题。5.4 交卷高峰期数据库死锁现象50 人同时提交时MySQL 日志出现 Deadlock found部分学生提交失败前端重试后成功。原因每个交卷请求在一个事务里要 insert 答题明细多行还要 update exam_record多个事务以不同顺序获取同一批行锁数据库只能回滚其中一个。解决让所有事务按同一顺序拿锁。我在代码里先 insert exam_record_item最后 update exam_record 主记录同时在 exam_record_item 上为 exam_record_id 建普通索引减少全表扫描和锁竞争。如果并发还高可以考虑给 exam_record 主记录先执行一条 select for update 再操作明细但这会降低吞吐使用前要压测验证。5.5 返回 JSON 时接口卡死现象学生端加载考试成绩列表服务端 CPU 飙升前端控制台报 StackOverflowError。原因实体类之间的关系没处理好。User 里持有 List Role 里又反向持有 List Jackson 序列化时来回递归最后爆栈。解决实体类只保留正向关系反向关系字段加 JsonIgnore或者更干净的做法是新增 VO 类Controller 只返回 VO不直接把 JPA 和 MyBatis 的实体类吐给前端。在线考试系统通常只有三四个角色但序列化问题及时处理能省很多调试时间。6. 上线前最后两件事监控端点与并发抽检6.1 把 Actuator 端点打开别让系统变成黑匣子考试系统上线后最怕的是考试当天出问题你连服务是死是活都不知道。Spring Boot Actuator 就是用来解决这个问题的。在 pom.xml 加一个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后在 application.yml 里暴露关键端点management: endpoints: web: exposure: include: health,info,metrics这样 /actuator/health、/actuator/info、/actuator/metrics 就能访问。健康检查里会显示数据库连接是否正常内存使用量也能通过 metrics 拉出来。如果有运维平台可以把 health 端点配置成定时轮询考试期间每 30 秒探一次服务挂了立刻报警。Spring Boot Admin 这类工具就是在 Actuator 基础上做的可视化部署时把服务地址注册进去磁盘、内存、HTTP 请求耗时一目了然值得在正式环境用上。6.2 用 JMeter 做 50 人同时交卷的并发抽检考试系统的并发峰值集中在交卷那一刻不是登录也不是看卷。上线前我会用 JMeter 做一个简单压测一个线程组模拟 50 个学生同时向 /api/exam/submit 发请求观察错误率和响应时间。线程数设 50Ramp-Up 时间设为 0 秒表示全部瞬间并发每个线程循环 1 次避免把重复交卷的幂等逻辑也测混进去。第一次压测常见的结果是错误率 20%原因是连接池默认值较小大量请求在获取数据库连接时排队超时。这时把 HikariCP 的 maximum-pool-size 从 10 调到 20错误率会明显下降。如果还不行看是不是事务里查询太慢锁表优先去优化那两条最热门的 SQL。压测之后我会再看一眼指标99% 响应时间不能超过 3 秒错误率低于 1%这两条不达标就继续调达标再放考试上线。这套系统的落地路径核心就是把表建好、事务边界划清、确认幂等和并发验证到位。每个人的生产环境不一样但踩过的坑和验证手段是相通的希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进