简介:一份基于Java的学生在线考试系统毕业设计论文,主要面向计算机相关专业的学生、毕业设计选题者及在线考试系统开发人员,针对传统考试信息管理难度大、容错率低、数据处理耗费时间等问题,给出完整的系统设计思路与实现方案。文档为单个docx文件,压缩包大小约2.94MB,内含中英文摘要、目录以及绪论、开发环境、系统设计与实现等完整章节。该课题以Mysql数据库存储数据,使用Java语言进行开发,并采用SSM框架完成业务分层,系统覆盖教师管理、试卷管理、错题本管理、试题管理、考试记录、论坛管理和公告管理等功能,同时阐述了数据加密、权限控制、备份机制、远程监考与实时评分等安全性和交互性设计。论文结构完整、层次清晰,既能帮助读者掌握在线考试系统的功能规划与编码思路,也可作为撰写毕业设计论文的格式与内容参考。目前已有63人学习下载,适合正在准备类似课题或需要完整毕设方案的学生。
1. 基于 Java 的学生在线考试系统:从课堂测验到期末大考的完整落地
很多刚入行的 Java 开发者在简历上写“学生在线考试系统”,但真正被问到“你的数据库是怎么防并发交卷的”或者“考试中途断网怎么处理”就卡壳了。这个题目其实是一个典型的企业级 Web 应用缩影:它涉及权限模型、事务一致性、定时任务、文件导入导出,甚至还有一点防作弊的对抗思维。这篇文章从零开始,带你用一个 Spring Boot + MyBatis 的组合把系统跑通,包含数据库设计、核心接口实现、前端页面和部署脚本,最后聊几个线上才会遇到的真实坑——时区错乱、并发交卷、浏览器缓存这些黑匣子问题,我都会给出排查思路和解决方案。无论你是准备 Java 面试、做课程设计,还是想给学校或培训机构搭一套真正能用的考试环境,这条路径都值得跟着走一遍。
2. 系统设计与技术选型:为什么用 Spring Boot + MyBatis 而不是其他组合
2.1 考试系统的核心角色与用例拆解
在线考试系统看着简单,其实角色划分很细。最常见的三类角色是管理员、教师、学生,但如果你真要做成一个能交付的产品,还得加上“超级管理员”和“阅卷人”这两个身份。每个角色对应不同的用例集合——超级管理员管教师账号和系统参数,教师负责出题、组卷、发布考试、阅卷,学生则要完成考试、查看成绩、参与补考。这个模型的复杂度不在于数据表多,而在于状态流转:考试从“编辑中”到“已发布”再到“进行中”“已结束”,每一步都有权限校验和时间窗口判断。
技术选型上,Spring Boot 是当前 Java 后端的事实标准,它内置了 Tomcat、自动配置了数据源和事务管理器,能省掉大量 XML 配置。MyBatis 作为持久层框架,相比 JPA 更贴近 SQL,适合考试系统这种查询条件多变的场景——比如“查询某教师名下所有已发布的考试并按时间倒序排列”,这种 SQL 写在 XML 里维护起来非常直观。我一般不用 MyBatis Plus 的代码生成器,因为自动生成的 Service 层反而把业务逻辑搞乱了。
前端方面,如果你是要交课程设计,直接用 Thymeleaf 模板引擎渲染服务端页面就够用,没必要上 Vue 全家桶增加复杂度。如果你是要做商用的在线考试平台,那前端单独部署一个 Vue 项目会更灵活,后端只提供 JSON 接口。下文的代码示例以后端接口为主,前端只贴关键片段,因为这个项目的重头戏在业务逻辑和并发控制上。
2.2 数据库表设计:五张核心表与三张关联表
考试系统的数据库建模是面试官最爱问的环节,也是第一个翻车高发区。很多初学者把“学生表”和“用户表”分开建,然后发现权限校验要 join 三张表,非常别扭。正确做法是统一用一张sys_user表,用role字段区分身份,再用关联表维护业务关系。
核心表的设计逻辑是这样的:
exam表存储考试元数据,包括考试名称、开始时间、结束时间、时长、总分、及格线、是否允许补考question表存储题目,包含题目类型(单选、多选、判断、填空、简答)、题干、选项 JSON、标准答案、分值paper表是试卷模板,记录题目 ID 列表和分值分布exam_paper是关联表,把考试和试卷绑定起来exam_record表记录学生的答题明细,每道题一行,包含学生答案和得分状态exam_result表保存最终成绩汇总
这个设计最容易被忽视的是exam_record表必须加submit_time字段。没有这个字段,你无法判断学生是否在考试结束后才交卷,也无法做“超时自动交卷”的后台任务。另一个容易踩坑的地方是多选题的答案存储——用逗号分隔的字符串("A,B,C")会比用 JSON 数组更简单,判分时直接按字符串匹配即可,但如果题目选项顺序会变化,就必须用 JSON 加排序后再比较。
建表 SQL 的核心片段如下,其中考试时间全部用datetime类型,不要用timestamp,原因在后面的避坑章节会详谈:
CREATE TABLE `exam` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '考试名称', `exam_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-正式考试 2-补考 3-模拟考试', `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `duration_minutes` int(11) NOT NULL COMMENT '考试时长(分钟)', `total_score` int(11) NOT NULL DEFAULT '100', `pass_score` int(11) NOT NULL DEFAULT '60', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-编辑中 1-已发布 2-进行中 3-已结束', `allow_retry` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否允许补考', `create_by` bigint(20) NOT NULL COMMENT '创建人(教师ID)', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_time` (`status`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的idx_status_time联合索引非常关键。按状态和时间检索是考试系统最高频的查询模式——教师查看“已发布但还没开始的考试”需要where status=1,学生进入考试系统要查“当前正在进行且当前时间在窗口内”的考试,这个索引能让查询走覆盖索引,避免全表扫描。
2.3 项目工程结构:按业务模块分包,别按技术层分包
很多教材习惯按controller/service/mapper三层分包,小项目可以,但考试系统业务一多就会变成灾难。比如“发布考试”这个动作,牵涉到试卷状态修改、通知推送(如果是站内信)、定时任务注册,按技术层分包你要改三个包下的类。我更推荐按业务模块分包,每个模块自带自己的 controller、service、mapper:
com.example.exam ├── common // 通用工具、异常处理、常量 ├── config // Spring 配置类 ├── module │ ├── auth // 登录认证、权限拦截 │ ├── user // 用户管理(教师、学生) │ ├── exam // 考试管理(创建、发布、状态流转) │ ├── paper // 试卷管理(组卷、题目维护) │ ├── answer // 答题与判分(交卷、自动阅卷) │ └── result // 成绩管理与报表 └── security // 拦截器、过滤器模块化分包的好处是,面试时你能直接说出“我负责的是 exam 和 answer 两个模块,各自包含完整的业务链路”。实际协作开发时,多个成员改不同模块的代码几乎不冲突。更重要的是,模块边界清晰后,事务管理才能做对——比如“交卷”这个操作必须保证exam_record批量插入和exam_result更新在同一个事务里,如果散落在不同包,事务注解很容易漏加。
2.4 身份认证与权限控制:基于 Token 的拦截器实现
考试系统不能用传统的 Session 方案,因为学生可能用手机和电脑两个设备同时登录,Session 会互相顶掉。我用 JWT(JSON Web Token)做无状态认证,后端拦截器统一校验 Token 中的角色信息。登录接口返回 Token 后,前端存在localStorage里,每次请求在 Header 带Authorization: Bearer <token>。
权限控制的粒度要做到“接口级别”,而不是“页面级别”。比如POST /api/exam/{id}/publish这个接口必须校验当前用户是教师,且该考试是这位教师本人创建的;学生只能访问POST /api/exam/{id}/submit交卷接口。拦截器里先解析 Token 拿到角色,再在 Service 层校验资源归属权:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } // 解析 JWT,将用户信息放入 ThreadLocal 供后续使用 Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.setUserId(claims.get("userId", Long.class)); UserContext.setRole(claims.get("role", String.class)); return true; } }这段代码里最关键的是ThreadLocal的使用。当请求经过拦截器后,后续的 Service 层、Mapper 层都可以通过UserContext.getUserId()获取当前用户,不必每个方法都传一遍用户 ID 参数。我在实际项目里吃过亏:一开始用request.getAttribute()传递用户信息,结果遇到异步任务时request对象已经不可用,数据全乱了。换成ThreadLocal后问题消失,但要注意在拦截器的afterCompletion里调用UserContext.clear()防止线程池复用导致的数据串号。
3. 核心功能实现:从题库管理到自动阅卷的完整链路
3.1 题库管理:单选、多选、判断、填空、简答的统一存储方案
题库模块是考试系统的地基。设计题目表时,最大的坑是要兼容多种题型。我的做法是:题干统一存content字段,选项统一存options字段,用 JSON 格式存储——单选题的 options 是字符串数组,判断题没有选项就存空数组,填空题的标准答案用数组存多个空格对应的答案。这样查询列表时可以用一条 SQL 查出全部题目,前端根据question_type字段决定渲染方式。
题目表的核心字段如下:
CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `type` tinyint(4) NOT NULL COMMENT '1-单选 2-多选 3-判断 4-填空 5-简答', `content` text NOT NULL COMMENT '题干', `options` json DEFAULT NULL COMMENT '选项(JSON数组)', `answer` text COMMENT '标准答案', `analysis` text COMMENT '答案解析', `difficulty` tinyint(4) NOT NULL DEFAULT '2' COMMENT '1-易 2-中 3-难', `subject_id` bigint(20) DEFAULT NULL COMMENT '所属科目', `create_by` bigint(20) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;选项用 JSON 存储的好处是扩展性好,以后想加“选项随机排序”功能,只需要在前端打乱数组渲染顺序即可,后端不用改表结构。坏处是统计题目时无法用 SQL 直接分析选项分布,但这需求在考试系统里基本不会出现。
这里必须提一个语文层面的问题:题干中的图片和公式怎么处理?我看到很多课程设计里直接把图片 URL 拼进content字段,一旦系统迁移到新域名,所有图片全部失效。更稳妥的方案是把图片转为 Base64 存入数据库?不行,这会撑爆数据库内存。正道是单独一张resource表存文件路径,题干中用占位符引用,渲染时替换为完整 URL 路径。资源表没有建?那系统里凡是带图的题目都会成为日后迁移项目时的定时炸弹。
3.2 组卷逻辑:按题型和难度比例自动抽题
组卷是考试系统里算法含量最高的部分。最简单的手动组卷方案是教师从题库里逐题点击添加,但这样做 60 道题的试卷要花半小时。更实用的做法是“自动组卷 + 人工微调”:教师指定“单选题 20 道、多选题 10 道、判断题 10 道、总难度为中等”,系统从题库中随机抽取满足条件的题目。
自动组卷的核心 SQL 使用了ORDER BY RAND()配合LIMIT:
SELECT id, content, options, answer, difficulty FROM question WHERE type = 1 AND subject_id = #{subjectId} ORDER BY RAND() LIMIT #{count}这个方案在题目量小于一万时性能没有问题。题目量大了之后,ORDER BY RAND()会全表扫描,可以先查出符合条件的题目 ID 集合,在 Java 内存中用Collections.shuffle()打乱后取前 N 个,再一次性查出题目完整信息。我实际采用的是后一种方案,因为当题目量突破 5 万时,前一种方案的单次查询耗时已经超过 800ms,接近数据库慢查询阈值。
组卷完成后,试卷的题目列表存在paper_question关联表里,同时记录每道题在试卷中的序号。抽题时要考虑“相邻题目不能是同一知识点”这种约束,但这属于产品细节,不是技术必选。如果你要对面试官或答辩老师展示亮点,可以在随机抽题后加一个“知识点分散度”校验:把题目的subject_id按试卷序号统计分布,如果连续 3 题属于同一知识点,则重新抽一次。
3.3 考试进行中:倒计时、自动保存与断线重连
考试进行中的用户体验直接决定系统的口碑。倒计时功能很多人用前端setInterval实现,但前端定时器在浏览器切换到后台时会被挂起,时间会越走越慢。正确的做法是:前端只负责展示剩余时间,后端在exam_record表记录start_time,前端每次调用GET /api/exam/{id}/remaining-time接口时,后端用当前服务器时间减去start_time计算出剩余秒数。这样即使学生换了一台电脑,倒计时依然准确。
自动保存功能是避免学生辛辛苦苦答完题却因断网全部丢失的后悔药。我通常的做法是:学生在切换题目时触发保存(每道题一答完就调一次保存接口),另外用一个前端定时器每 60 秒强制保存一次当前试卷的全部答案。保存接口是幂等的,同一道题重复提交不会造成数据异常:
@PostMapping("/api/answer/save") public Result saveAnswer(@RequestBody SaveAnswerRequest request) { // request 中包含 examId、questionId、answer // 先查这道题是否已经存在于 exam_record 表 ExamRecord record = examRecordMapper.selectByExamAndQuestion( request.getExamId(), request.getQuestionId()); if (record == null) { // 不存在则插入 record = new ExamRecord(); record.setExamId(request.getExamId()); record.setQuestionId(request.getQuestionId()); record.setStudentId(UserContext.getUserId()); record.setAnswer(request.getAnswer()); record.setSubmitTime(new Date()); examRecordMapper.insert(record); } else { // 存在则更新,注意只更新答案和提交时间 record.setAnswer(request.getAnswer()); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); } return Result.success(); }这个接口的逻辑并不复杂,但被问到的频率极高——它的本质是“先查后改”的 Upsert 操作。在多线程并发场景下,两个相同请求同时到达可能出现唯一键冲突,所以exam_record表一定要建联合唯一索引(exam_id, student_id, question_id),插入时捕获DuplicateKeyException后转成更新操作。
断线重连的处理是另一个值得写进简历的点。前端检测到网络断开时,把用户当前页面的作答数据存到localStorage,网络恢复后检测到本地有未提交答案,自动弹窗提示并提交。这个方案的坑在于 localStorage 有容量上限(通常 5MB),如果考生答的是简答题,输入了大量文字,可能超出限制。我见过的更先进方案是让前端在断线时改用navigator.sendBeacon()把数据发送到后端接口,这个 API 在页面关闭时也能生效,且能传送较大数据量。
3.4 自动阅卷与人工阅卷:客观题秒判,主观题异步批改
交卷接口是整个系统并发压力最大的地方——考试结束的瞬间,全班同学同一秒点提交按钮。如果代码写成“先逐题判分,再算总分,最后更新成绩单”,数据库会被瞬间的写并发打满。我的设计是分两步走:交卷时只做答案的持久化,不实时判分;判分通过异步任务处理,延迟不过几秒,但并发承载能力提高了十倍。
客观题判分逻辑如下:
public ScoreResult markObjectivePaper(Long examId, Long studentId) { List<ExamRecord> records = examRecordMapper.selectByExamAndStudent(examId, studentId); int totalScore = 0; int correctCount = 0; for (ExamRecord record : records) { Question question = questionMapper.selectById(record.getQuestionId()); if (question == null || question.getAnswer() == null) { continue; // 题目被删除或没有标准答案的,跳过 } if (question.getType() == 1 || question.getType() == 2) { // 单选和多选:字符串完全匹配 if (normalizeAnswer(question.getAnswer()).equals(normalizeAnswer(record.getAnswer()))) { record.setScore(question.getScore()); totalScore += question.getScore(); correctCount++; } else { record.setScore(0); } } else if (question.getType() == 3) { // 判断题:答案只有 T/F if (question.getAnswer().equalsIgnoreCase(record.getAnswer())) { record.setScore(question.getScore()); totalScore += question.getScore(); correctCount++; } } examRecordMapper.updateScore(record.getId(), record.getScore()); } // 更新成绩汇总 ExamResult result = examResultMapper.selectByExamAndStudent(examId, studentId); result.setObjectiveScore(totalScore); result.setStatus("PENDING_MARK"); // 等待人工阅卷主观题 examResultMapper.updateById(result); return new ScoreResult(totalScore, correctCount); }这段代码最关键的是normalizeAnswer方法。多选题的答案可能存在空格、全角逗号和半角逗号混用的情况,比如学生提交的是A,C,标准答案是A,C,肉眼看着一样但字符串比较就是不相等。normalizeAnswer要做的事是:去掉所有空格、统一把全角逗号替换为半角逗号、把逗号分隔的选项排序后重组。排序这一步很重要,因为标准答案可能是A,B,D,学生选的是B,A,D,从逻辑上讲是对的,但直接比较就会误判为错误。这个细节是考试系统里最容易被初学者忽略的坑。
3.5 补考机制:同一考试、不同试卷、独立成绩
补考功能是区分“demo 级系统”和“能上线的系统”的重要标志。补考的逻辑核心是:一个学生不能重复参加同一场考试的同一次机会,但可以参加补考机会。为此我单独建了exam_attempt表,记录每个学生针对每场考试的参与次数:
CREATE TABLE `exam_attempt` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `exam_id` bigint(20) NOT NULL, `student_id` bigint(20) NOT NULL, `attempt_no` int(11) NOT NULL DEFAULT '1' COMMENT '第几次考试', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-未开始 1-进行中 2-已完成 3-缺考', `paper_id` bigint(20) NOT NULL COMMENT '本次考试使用的试卷ID', `start_time` datetime DEFAULT NULL, `submit_time` datetime DEFAULT NULL, `score` decimal(5,2) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_exam_student_attempt` (`exam_id`, `student_id`, `attempt_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;统一用attempt_no区分正式考试和补考,成绩表也一样,每次参加考试生成一条独立的成绩记录。这样查询“学生最终成绩”时,取attempt_no最大的一条记录;改革成绩时,可以同时保留正式考和补考成绩,数据不会覆盖。这个设计在公司里被 DBA 夸过,因为后续要做“成绩进步分析”报表时,直接用attempt_no分组就可以实现,不需要再 JOIN 多张表。
补考触发规则在业务层控制:正式考试成绩低于及格线时,系统更新该学生的考试状态为“可补考”,并自动生成一条attempt_no=2的考试记录,试卷从题库中重新抽一套难度相同的。注意补考的试卷不能和正式考试重复率太高,否则学生之间可以直接互相问答案作弊。我做过的最简单方案:补考卷从正式试卷的备选池中抽取,备选池里的题目与正式卷中超过 30% 的题目不重复。
4. 避坑指南:并发、时区、缓存与浏览器兼容性
4.1 并发交卷导致成绩丢失
现象:考试结束瞬间,全班 50 人同时提交,成绩表中部分学生没有任何记录。
原因:交卷接口里“保存明细”和“计算总分”分开了两个事务。学生手动交卷时走的是完整事务(明细+成绩一起写入),但自动交卷的定时任务没有加事务,导致明细写入后,系统恰好崩溃来不及更新成绩表。
解决:为自动交卷任务增加@Transactional(rollbackFor = Exception.class)注解,并把保存明细和生成成绩放在同一个方法内。同时手动交卷接口要捕获唯一键冲突异常,防止同一学生重复提交时成绩被覆盖。
这个场景是我在本公司真实踩过的。当时定时任务每 5 秒扫描一次超时未交卷的考生,某次数据库连接池耗尽,部分任务的成绩更新被异步丢进失败队列,结果那场考试 18 个学生的成绩从系统里蒸发了。从那以后,所有涉及关键数据的异步任务必须加失败重试机制,重试 3 次仍失败就发告警邮件给运维。
4.2 数据库时区与服务器时区不一致导致考试时间混乱
现象:管理员在后台设置考试 14:00 开始,考生到 13:58 就发现可以进入考试系统了;或者反过来,考试已经结束但系统还显示进行中。
原因:MySQL 的datetime类型不带时区信息,但 JDBC 连接字符串没有显式指定serverTimezone,系统会使用服务器默认时区(可能是 UTC),导致new Date()写入的时间和数据库读到的时间偏差 8 小时。
解决:JDBC 连接串统一加上serverTimezone=Asia/Shanghai&useLegacyDatetimeCode=false,同时 MySQL 服务端default-time-zone = '+08:00'。代码层的日期对象统一采用LocalDateTime,不要用new Date()和Timestamp混用,因为Timestamp内部带时区偏移量,在不同 JDK 版本下行为不一致。
另外要提醒的是,千万别用timestamp类型存考试时间——它的范围只有 1970 到 2038 年,如果考试系统要面向长期运营,选datetime会更安全。这个细节考过不少 Java 八股文面试题,很多答案其实没说到点子上。
4.3 浏览器自动填充和缓存导致表单提交旧数据
现象:考生对某道题改了答案后,页面显示已保存成功,但后端数据库里还是旧答案。
原因:前端用了fetch的默认缓存策略,或者表单控件被浏览器自动填充,页面上显示的是缓存值。还有一种是前端发送请求时并发,第一次请求(旧答案)比第二次(新答案)晚到达后端,后来者覆盖了前者。
解决:所有考试相关接口的响应头统一加Cache-Control: no-store,前端提交答案时加时间戳参数,并在拦截器里做防重入处理。更正规的方案是给保存接口加上“乐观锁”版本号:每次保存答案时附带该题的update_time,后端在更新时检查update_time是否小于请求携带值,若小于则拒绝更新。
代码参考:
@Update("UPDATE exam_record SET answer = #{answer}, update_time = #{now} " + "WHERE id = #{id} AND update_time <= #{clientTime}") int updateAnswerWithoutConflict(@Param("id") Long id, @Param("answer") String answer, @Param("now") Date now, @Param("clientTime") Date clientTime);如果返回行数为 0,说明有更早的请求先落库了,此时可以忽略当前请求或提醒学生重新确认。这种做法能规避掉大部分“前端手滑点了两次保存”造成的覆盖问题。
4.4 题目图片在 HTTPS 环境下无法加载
现象:题干中包含图片的题目,在部署到 HTTPS 域名后,图片加载失败,控制台报Mixed Content错误。
原因:题干中的图片地址是以http://协议存储的,HTTPS 页面默认禁止加载非安全协议的资源。
解决:最简单的方法是部署阶段统一对题干内容做正则替换,把所有http://替换为https://。更稳妥的做法是写一个拦截器,在返回题库列表时动态拼接图片域名,确保图片地址始终使用当前协议。这个问题在本地开发时不会出现,因为本地访问就是 HTTP;一旦上生产环境换 HTTPS,就立刻踩雷,属于部署阶段必然会遇到的坑。
4.5 考试中间刷新页面导致记录被当成“交卷”
现象:考生在考试中途按了 F5 刷新,系统判定为已交卷,无法再进入考场。
原因:前端刷新时重新加载页面,此时调用了GET /api/exam/entry接口查询考生是否已参加考试,而该接口的实现逻辑是“如果exam_attempt.status = 1则返回试卷,否则返回 403”。
解决:入口接口改为“如果存在进行中的考试记录,则继续返回试卷内容;如果不存在,则创建新记录;只有状态为已提交或已过期时才拒绝进入”。更靠谱的做法是在前端将考试状态暂存到sessionStorage,刷新后优先读取本地状态,同时后端接口只负责查询数据,不负责创建记录。
5. 部署与验收:从本地跑通到云服务器上线
5.1 环境准备与首次启动
本地开发环境我习惯用 Docker 起 MySQL,这样团队协作时数据库版本完全一致,不会出现你本地 8.0 同事 5.7 导致 SQL 语法兼容问题。启动命令如下:
docker run -d \ --name exam-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=exam_system \ -e TZ=Asia/Shanghai \ -v /myapp/mysql-data:/var/lib/mysql \ mysql:8.0 --default-time-zone='+08:00'启动后检查容器状态:docker ps | grep exam-mysql。如果容器始终处于Restarting状态,大概率是宿主机 3306 端口被占用,或 MySQL 数据目录权限不对——把挂载目录权限改成 777 即可解决(别问我为什么知道)。
Spring Boot 项目的application.yml配置要点:MyBatis 的map-underscore-to-camel-case设为true,这样数据库的submit_time字段能自动映射到 JavaBean 的submitTime,省去大量@TableField注解。数据源配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root123 hikari: maximum-pool-size: 20 minimum-idle: 5 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/ShanghaiallowPublicKeyRetrieval=true是 MySQL 8.0 连接的必要参数,不加会报Public Key Retrieval is not allowed。这个报错弹出的瞬间,很多从 5.7 迁移过来的老 Java 工程师都会愣住。
5.2 一键启动脚本与系统自检
项目根目录放一个.sh脚本,实现一键启动加端口检测:
#!/bin/bash APP_NAME=exam-system.jar LOG_FILE=logs/exam.log if [ ! -d "logs" ]; then mkdir logs fi # 检查端口是否被占用 PORT=8080 if lsof -i:$PORT > /dev/null 2>&1; then echo "端口 $PORT 被占用,尝试自动释放..." lsof -i:$PORT | awk 'NR==2 {print $2}' | xargs kill -9 sleep 2 fi nohup java -jar target/$APP_NAME --spring.profiles.active=prod > $LOG_FILE 2>&1 & # 检测应用是否就绪 for i in $(seq 1 30); do if curl -s http://localhost:$PORT/actuator/health | grep -q "UP"; then echo "应用启动成功" exit 0 fi sleep 2 done echo "应用启动超时,请查看日志" exit 1用curl探测健康检查端点而不是盲目sleep 10,这是运维老手和新手的区别。Systemd 的ExecStart配置也可以按同样思路写,但脚本方式更适合课程设计和中小团队交付。
部署完成后,建议按顺序走一遍全链路验收:学生登录 → 查看考试列表 → 进入考试 → 答题并保存 → 自动交卷 → 查看成绩。这里面最容易出问题的环节是“学生端时间显示”和“教师端试卷预览”的时区一致性,测试时要刻意把服务器时区和本地电脑时区调成不同区域,检测系统是否出现时间漂移。
5.3 性能摸底:模拟 200 人同时在线考试
考试系统的并发峰值非常集中——开考后第 1 分钟,大量考生同时进入;交卷前 1 分钟,大量考生同时提交。用 JMeter 做一次粗略压测,能测出系统是否扛得住真实考试场景。
JMeter 的线程组配置要点:
- 线程数 200,Ramp-up 设为 10 秒,模拟 10 秒内 200 人同时登录
- 加“HTTP Cookie 管理器”模拟 Session 保持(如果用的是 JWT,则在 HTTP Header Manager 中配置 Authorization 变量)
- 重点压测三个接口:登录、答题保存、交卷
压测时观察阿里的 Arthas 火焰图,如果saveAnswer接口的 TP99 超过 800ms,先查数据库连接池是否不够,再把保存接口的 SQL 优化到只更新必要的字段。我在压测中真实遇到过一次数据库死锁——原因是exam_record表的UPDATE语句没有走唯一索引,导致行锁升级成表锁。解决方式是确保 SQL 的WHERE条件带上联合唯一索引的所有字段。
6. 进阶功能与落地技巧:导出成绩报表与自动排考
系统上线后,教师和管理员最刚需的功能是成绩导出和排考管理。成绩导出用阿里开源的 EasyExcel 库,一行代码搞定 Excel 生成;自动排考则需要一个相对复杂的调度算法。我把这两个功能作为进阶扩展写在这一节,因为它们能大幅提升系统的“产品完成度”。
成绩导出的常见做法是先查出成绩列表,再遍历填充 Excel 行:
List<ExamResult> results = examResultMapper.selectByExamId(examId); String fileName = "exam_" + examId + "_scores.xlsx"; ExcelWriter writer = EasyExcel.write(response.getOutputStream(), ExamResult.class).build(); WriteSheet sheet = EasyExcel.writerSheet("成绩单").build(); writer.write(results, sheet); writer.finish();实际使用时要加一个风险提醒:response.getOutputStream()在数据量很大时可能一次性写入内存,导致 OOM。稳妥的做法是把查询改为分页查询,配合ExcelWriter的write多批次传入数据。另外,导出的成绩单必须按“总分从高到低”排序,否则教师拿到的报表没有可用性。
自动排考的算法相对复杂。当一场考试有多个教室、多个时间段可选时,系统需要把学生分配到具体的座位和批次。最简方案是贪心算法:按学号顺序依次分配,每个教室容量满员后自动切换到下一教室;更优的方案是“冲突检测 + 回溯”,避免同一班级学生在同一时间段被分到不同教室导致监考老师不够。
用贪心排考场我写过一次,结果一个班 30 人被拆到 3 个教室,考务办老师都快哭了。后来改成按班级分组分配,才算真正符合业务预期。这个细节提醒我:技术方案再优雅,也要先理解业务方的真实流程——考试系统不是只给技术人员用的,是要给教务人员和学生天天用的,用户说“不好用”比“有 bug”还致命。
回看这个项目从设计到落地的全过程,我觉得最有价值的不是某段代码写得多么花哨,而是把考试系统中“时间一致性、并发安全、数据可追溯”这三个核心问题都想透了。如果你也在做类似的项目,我的建议是:先花 30 分钟把数据库关系梳理清楚,再写一行代码;中途遇到玄学问题时优先看日志和数据库当前值,不要盲目改代码。希望帮到你。
本文还有配套的精品资源,点击获取