基于Java的在线考试系统:Spring Boot+MyBatis+Redis
2026/8/29 23:44:29 网站建设 项目流程

简介:在线考试系统是JavaWeb开发中的经典项目,涵盖用户认证、业务逻辑、数据库设计与部署上线等核心环节。其底层技术栈通常以Spring Boot作为基础框架,结合MyBatis实现数据持久化,并通过Redis解决验证码存储、考试令牌与题库缓存等并发场景问题。从系统设计角度看,数据库表结构决定了业务扩展性,而自动组卷算法与防重复提交机制则直接关系到考试公平性与系统稳定性。这一应用场景既适用于高校毕业设计,也可支撑企业内部培训考核。围绕此类系统的技术选型、核心算法与部署实践,对开发者提升工程能力具有实际参考价值。现以“基于Java的在线考试系统”项目为例,完整拆解从技术选型到部署上线的全流程,帮助读者理解每个设计决策背后的权衡逻辑。 又到了毕业设计旺季,后台每天都能收到一堆关于“基于Java的在线考试系统”的私信。说实话,每次看到这个题目我都有点恍惚——一届又一届的学生,做着几乎一模一样的系统,但真正能把这个项目讲清楚、讲明白、能扛住答辩追问的,确实不多。这个项目之所以长盛不衰,是因为它几乎覆盖了JavaWeb开发的核心环节:从用户认证到业务逻辑,从数据库设计到部署上线,每个环节都能考察到。但与此同时,它也藏着不少“暗坑”,稍不注意就会在答辩或实际运行中翻车。

这篇文章我就以“基于Java的在线考试系统设计与实现”这个项目为蓝本,把我自己的设计思路、数据库表结构、核心算法(尤其是自动组卷和防作弊)、以及部署过程中的各种坑,完整地拆开来讲。无论你是正在做毕设、还是想在公司内部快速搭一套培训考试系统,这篇文章都能给你一个可以“抄作业”的完整方案。

1. 系统整体设计与技术选型的底层逻辑

1.1 技术选型:为什么是Spring Boot而不是SSH或SSM

很多教材还在教SSH(Struts2+Spring+Hibernate)甚至更早的JSP+Servlet,但说实话,这些技术栈在今天的企业里已经很少见了。我在做这个在线考试系统时,选型思路非常明确:Spring Boot 2.x + MyBatis Plus + MySQL 8.0 + Redis(可选)。这套组合是当前Java后端开发最主流、最不容易出错、也最适合“快速交付”的一套。

先说为什么不用SSH。Struts2的拦截器机制和XML配置过于繁琐,Action类又和Servlet API藕断丝连,写起来极其痛苦。Hibernate呢,虽然全自动ORM听起来很美好,但实际项目里一旦涉及到复杂查询、多表联查、动态SQL,Hibernate的HQL和Criteria API能把人绕晕,而且它的N+1查询问题很让人头疼。相比之下,MyBatis把SQL完全交给你掌控,写复杂查询时心里特别有底。

Spring Boot带来的最大好处就是“约定大于配置”。以前搭一个SSM项目,要配置web.xml、spring-mvc.xml、spring-mybatis.xml、数据源、事务管理器,光是配置文件就够写一天。Spring Boot直接通过spring-boot-starter-webspring-boot-starter-data-redis这些起步依赖搞定,一个application.yml全部搞定,内置Tomcat,打包成jar后一句java -jar就能跑起来。我实际测下来,从零搭建到能跑通登录接口,SSM大概需要2小时,Spring Boot只需要15分钟。

版本选择上要注意一个坑:Spring Boot 2.x对应Java 8/11,Spring Boot 3.x强制要求Java 17。如果你的毕设环境是学校机房那种老掉牙的JDK 1.8,就千万别用Spring Boot 3.x,否则编译直接报错UnsupportedClassVersionError。我个人推荐Spring Boot 2.7.x,这是2.x系列的最后版本,稳定且资料多。

前端方面,我选了Thymeleaf模板引擎,而不是前后端分离的Vue。原因很简单:这个系统的用户规模和应用场景决定了它不需要前后端分离。题库管理、考试列表、在线答题这些页面,用Thymeleaf加一点原生JS和Ajax就能实现,还能省去跨域、Token刷新、路由鉴权这些麻烦。你要是硬上Vue+Spring Boot,反而会因为知识点太多,答辩时被老师问得一头冷汗。

1.2 功能模块划分:这套系统该有哪些功能

在线考试系统的核心不是“考试”,而是“考务管理”。我见过很多同学的系统只做了“学生登录进去答题,然后出成绩”这一个流程,这在答辩时是致命的——老师一定会问“你怎么防止学生重复提交?”“题目怎么保证随机性?”“考试成绩怎么统计分析?”

我的功能设计分为四个角色、六个核心模块:

  • 管理员端:用户管理(教师和学生账号的CRUD)、课程管理(一门课程对应一场或多场考试)、题库管理(单选、多选、判断三种题型的增删改查)、试卷管理(手动组卷和自动组卷)、考试管理(创建考试、设置考试时间、发布/停用考试)、成绩统计(导出Excel)。
  • 教师端:本课程范围内的题库维护、手动组卷、查看学生成绩和排名。和管理员相比,教师不能操作其他课程的试卷,这个用数据权限控制。
  • 学生端:查看已发布的考试列表、进入考试答题、提交试卷、查看自己的历史成绩和得分明细(如果是客观题,可以看对错)。
  • 公共功能:登录认证、验证码、退出登录、个人信息修改、密码修改。

这里有个容易忽略的点:考试管理一定要区分“创建考试”和“发布考试”两个状态。创建考试只是把试卷、时间、规则配置好,这时候学生看不到;只有管理员手动点击“发布”,学生端才会显示这场考试。这样做的好处是,你可以提前把试卷配好,到点了再一键发布,避免学生提前看到考试信息。

完整代码和数据库脚本我整理打包好了,包含详细的部署文档和讲解视频,有需要的可以直接下载参考。

2. 数据库设计:一张表一张表地拆给你看

2.1 核心表结构与字段设计

数据库设计是整个系统的地基,地基不稳,后面全是雷。我设计的数据表一共有8张:用户表(sys_user)、角色表(sys_role)、用户角色关联表(sys_user_role)、课程表(edu_course)、题库表(exam_question)、试卷表(exam_paper)、试卷题目关联表(exam_paper_question)、考试记录表(exam_record)。

下面挑几张核心表详细说。

用户表(sys_user),字段设计如下:

id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键' username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名' password VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)' real_name VARCHAR(50) COMMENT '真实姓名' email VARCHAR(100) COMMENT '邮箱' phone VARCHAR(20) COMMENT '手机号' avatar VARCHAR(255) COMMENT '头像地址' status TINYINT DEFAULT 1 COMMENT '状态:1启用 0禁用' create_time DATETIME DEFAULT CURRENT_TIMESTAMP update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

密码这里多说两句。很多课设代码用的是MD5加密,这是不安全的。MD5早就被彩虹表攻破了,答辩时老师如果问你“密码怎么存储的”,你说MD5,大概率会被追问“MD5有什么安全问题”,答不上来就很尴尬。我用的是BCryptPasswordEncoder,Spring Security自带的加密器,每次加密生成的盐都不同,同一个密码两次加密结果不一样,安全性高很多。

题库表(exam_question)

id BIGINT PRIMARY KEY AUTO_INCREMENT question_type TINYINT COMMENT '题型:1单选 2多选 3判断' question_content TEXT COMMENT '题干' option_a VARCHAR(255) COMMENT '选项A' option_b VARCHAR(255) COMMENT '选项B' option_c VARCHAR(255) COMMENT '选项C' option_d VARCHAR(255) COMMENT '选项D' answer VARCHAR(10) COMMENT '正确答案,单选如A,多选如ABD,判断如T/F' score INT DEFAULT 5 COMMENT '每题分值' difficulty TINYINT DEFAULT 1 COMMENT '难度:1简单 2中等 3困难' course_id BIGINT COMMENT '所属课程' create_by BIGINT COMMENT '创建人ID'

这里有几个设计上的决策点。选项字段option_a到option_d是单独列,而不是存成一个JSON数组。有些同学图省事把四个选项拼成一个字符串塞进一个字段,这在查询和回显时特别痛苦——你得自己split,还得处理选项里本来就带逗号的情况。四列虽然看起来笨,但最清晰、最稳定。此外,判断题的答案我用TF两个字符存储,而不是正确/错误两个汉字,这样在做答案比对时效率更高。还有一个细节是为了方便试卷展示,我给题干和选项都设置了足够长的字段长度,题干用TEXT类型,避免题目一长就被截断,进而造成批改错乱。

试卷题目关联表(exam_paper_question)

id BIGINT PRIMARY KEY AUTO_INCREMENT paper_id BIGINT COMMENT '试卷ID' question_id BIGINT COMMENT '题目ID' question_order INT COMMENT '题目序号'

为什么需要一张关联表,而不是在试卷表里存一个“题目ID列表”?因为关系型数据库设计的第一范式和第二范式告诉我们,不能把多个值存在一个字段里。如果你把题目ID用逗号拼成一个字符串存进试卷表,以后想统计“这套试卷有多少道单选”都只能靠代码遍历字符串split,查询效率低不说,逻辑还容易错。关联表加一个question_order字段控制题目顺序,组卷时按question_order升序排列就行。

考试记录表(exam_record),这张表是系统的核心,我这样设计:

id BIGINT PRIMARY KEY AUTO_INCREMENT exam_id BIGINT COMMENT '考试ID' paper_id BIGINT COMMENT '试卷ID' student_id BIGINT COMMENT '学生ID' total_score INT COMMENT '总分' user_score INT COMMENT '得分' duration INT COMMENT '实际用时(分钟)' submit_time DATETIME COMMENT '提交时间' status TINYINT COMMENT '状态:0未提交 1已提交 2考试超时' answer_detail TEXT COMMENT '答题明细JSON'

answer_detail字段,我用来存一个JSON数组,记录每道题的答题情况,结构如下:

[ {"questionId": 1, "userAnswer": "A", "isCorrect": true, "score": 5}, {"questionId": 2, "userAnswer": "ABD", "isCorrect": false, "score": 0} ]

为什么要存JSON而不是为每道题建一张明细表?因为一份试卷可能有50道题,如果每道题都在明细表里占一行,50个考生就是2500行,而且这些明细只有在学生提交后、查看历史成绩时才会被读取,属于“低频大数据”。用JSON存一列,读取时JSON.parse一下就还原出来,写起来也简单——提交时一次性把整个数组序列化后存进去。考试明细不做外键关联,而是冗余存下题号、答案、得分,是为了防止题目被修改后历史记录跟着变。比如老师改了一道题,学生之前的成绩单如果通过外键去查,就查不到当时的答案了。

2.2 表关系设计与索引优化

表关系我用一句话概括:用户和角色是多对多,课程和题目是一对多,试卷和题目是多对多,考试和试卷是一对一,考试和学生是一对多

关于索引,我建了这几个:

  • exam_question(course_id):按课程查题目列表时高频使用。
  • exam_paper_question(paper_id):组卷后加载试卷题目时使用。
  • exam_record(student_id):学生查看自己的历史考试记录时使用。
  • exam_record(exam_id, student_id):联合索引,查“某场考试里某个学生是否已提交”时高频使用。
  • sys_user(username):登录验证时按用户名查用户,必须有唯一索引。

这里要注意联合索引的最左前缀原则。(exam_id, student_id)这个联合索引,只有在查询条件同时包含exam_id时才能用到。如果你经常要查“某学生参加的所有考试”,那就要单独建student_id索引,或者把联合索引顺序调成(student_id, exam_id)。我实际设计中因为两种情况都有,所以建了两个索引,虽然多占一点磁盘,但查询速度有保障。

3. 核心功能实现:这几块代码是答辩必问

3.1 登录认证与验证码:安全是第一道门槛

登录模块我用的是Session方式,配合Kaptcha生成图片验证码。流程是这样的:

@PostMapping("/login") public Result login(@RequestParam String username, @RequestParam String password, @RequestParam String captcha, HttpSession session) { // 1. 校验验证码 String sessionCaptcha = (String) session.getAttribute("captcha"); if (sessionCaptcha == null || !sessionCaptcha.equalsIgnoreCase(captcha)) { return Result.error("验证码错误"); } // 2. 查询用户 SysUser user = userService.getByUsername(username); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { return Result.error("用户名或密码错误"); } // 3. 校验状态 if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } // 4. 保存登录状态 session.setAttribute("loginUser", user); session.setAttribute("loginRole", roleService.getRoleByUserId(user.getId())); return Result.success(); }

这里会有一个验证码被反复使用的问题,需要保证校验之后立即从Session中移除,防止同一验证码被重放。另外,验证码校验是“先校验验证码再校验密码”,这样做的目的是:先消耗掉用户一次验证码,防止有人用脚本暴力试密码时每次都先刷新一下验证码。逻辑上,如果验证码错了,就不需要再去查数据库了,能减轻一点数据库压力。

登录后的权限控制,我用的是Spring MVC的HandlerInterceptor拦截器,判断Session里有没有用户,以及角色标识是否符合访问路径要求。管理员和教师端路径以/admin/开头,学生端以/student/开头。拦截器里要注意放行静态资源,否则CSS和JS会被拦住,页面样式全丢。

3.2 自动组卷算法:一段让很多人挠头的逻辑

组卷是整个系统里比较有含金量的一环。手动组卷就是老师从题库里勾选题目,难点不大;自动组卷则要根据规则从题库里随机抽题。规则通常是这样的:单选题15道(每题3分)、多选题5道(每题5分)、判断题10道(每题2分),总分100分,且各题型难度都控制在“基础为主、少量提高”的分布。

我的实现思路是分题型、按条件过滤、再随机抽取,核心代码如下:

public List<ExamPaperQuestion> generatePaper(Integer courseId, Integer singleCount, Integer singleScore, Integer multiCount, Integer multiScore, Integer judgeCount, Integer judgeScore) { List<ExamPaperQuestion> paperQuestions = new ArrayList<>(); // 按题型分别抽题 paperQuestions.addAll(randomPickQuestions(courseId, 1, singleCount, singleScore)); paperQuestions.addAll(randomPickQuestions(courseId, 2, multiCount, multiScore)); paperQuestions.addAll(randomPickQuestions(courseId, 3, judgeCount, judgeScore)); // 打乱整个试卷的题目顺序 Collections.shuffle(paperQuestions); // 重新设置序号 for (int i = 0; i < paperQuestions.size(); i++) { paperQuestions.get(i).setQuestionOrder(i + 1); } return paperQuestions; } private List<ExamPaperQuestion> randomPickQuestions(Integer courseId, Integer type, Integer count, Integer score) { // 查出该课程该题型的所有题目 List<ExamQuestion> allQuestions = questionMapper.selectList( new LambdaQueryWrapper<ExamQuestion>() .eq(ExamQuestion::getCourseId, courseId) .eq(ExamQuestion::getQuestionType, type) .eq(ExamQuestion::getStatus, 1)); // 如果题库不够,直接抛异常提醒老师 if (allQuestions.size() < count) { throw new BusinessException("题库不足,该题型还需要" + (count - allQuestions.size()) + "道题"); } // 随机抽取count道 Collections.shuffle(allQuestions); List<ExamQuestion> picked = allQuestions.subList(0, count); return picked.stream().map(q -> { ExamPaperQuestion pq = new ExamPaperQuestion(); pq.setQuestionId(q.getId()); pq.setScore(score); return pq; }).collect(Collectors.toList()); }

这段代码其实体现了几个很重要的工程思想:

  • 先把该课程、该题型的全部题目查出来,在内存里shuffle,再取前count个。这种做法在小数据量下非常高效、且逻辑简单。如果你的题库有几十万道题,那需要优化为SQL里使用ORDER BY RAND() LIMIT count,但那样在大数据量下性能会很差。我实测过,5000道题以内用内存shuffle完全没问题,响应时间在50毫秒以内。
  • 抽题前一定要判断题库数量是否足够。很多同学不写这个判断,结果题库只有20道单选题、组卷规则却要求25道,程序直接抛出IndexOutOfBoundsException。这里我抛的是自定义的BusinessException,会在前端弹出一个友好的错误提示“题库不足,请先添加题目”,而不是让用户看到一堆堆栈异常。
  • 抽题后将整个试卷Collections.shuffle一次,确保三道大题的题目是混合排列的,而不是“单选题全部在前、多选题在后”。从防作弊的角度讲,这种混合排列能减少相邻考生互相瞄答案的概率。

3.3 在线考试“防作弊”双保险:提交前和提交后都要拦

在线考试系统一个高频踩坑点就是——学生提交试卷后,再刷新页面,又能重新答一遍,且系统把第二次的答案也存下来了。这个问题的根源在于你没有做重复提交校验。

我的处理方案是“前端+后端双重锁”。前端在考生进入考试页时,向后端请求一个“考试令牌”(examToken),这个令牌就是一张试卷的paperId加上一个随机UUID,存储在Redis或Session中,并和考试记录关联。当学生点击“提交试卷”按钮时,前端立刻把按钮置灰、禁用,并向后端发起提交请求,请求头中携带这个examToken。后端在服务端做两层判断:

@PostMapping("/submit") public Result submit(@RequestParam Long examId, @RequestParam String examToken, @RequestBody String answerJson) { // 判断是否已有提交记录 ExamRecord record = recordMapper.selectOne( new LambdaQueryWrapper<ExamRecord>() .eq(ExamRecord::getExamId, examId) .eq(ExamRecord::getStudentId, currentUserId())); if (record != null) { return Result.error("请勿重复提交"); } // 校验考试令牌 String tokenInCache = examTokenService.getToken(examId, currentUserId()); if (!examToken.equals(tokenInCache)) { return Result.error("令牌无效,请刷新页面重试"); } // 删除令牌,使令牌失效 examTokenService.deleteToken(examId, currentUserId()); // ... 后续判分逻辑 }

这里“删除令牌”是至关重要的一步,它是真正的“锁”。即使前端按钮没被禁用、用户狂点提交,后端在第二次请求时令牌已经删掉了,校验必然失败,从根源杜绝重复提交。

然后说说自动判分。单选题和判断题的判分比较简单,直接比对字符串即可。多选题稍微麻烦一点,因为多选题目通常要求“完全一致才得分”,也就是说学生选了AB,正确答案是ABC,不能得分。如果业务要求“少选得一半分”,那还得在判分逻辑里单独处理。我在系统中采用了“严格判分”模式:

public int judgeMulti(String userAnswer, String correctAnswer, int score) { // 排序后比较,避免AB和BA被判定为错误 String normalizedUser = normalize(userAnswer); String normalizedCorrect = normalize(correctAnswer); if (normalizedUser.equals(normalizedCorrect)) { return score; } return 0; }

注意归一化处理。如果学生提交的答案是B,A,D,正确答案是A,B,D,不排序直接equals肯定判错。所以我在判分前会把答案字符串按逗号分割、去空格、排序、再拼接,保证顺序不影响判分结果。这个问题在答辩时出现过,一定要处理干净。

超时自动交卷也是一个重要的功能需求。考试界面有倒计时,前端通过JavaScript计时,倒计时归零时自动触发提交。但前端的倒计时是可以被篡改的,比如用开发者工具把时间改长,所以后端必须再做一层“超时兜底”。我的做法是:判分时先判断当前时间和开考时间是否超过考试时长,如果超过,强制截断作答并标记为超时提交。具体实现是考试记录表在创建时记录start_time,提交时计算duration = (now - start_time) / 60000,再和考试设定的时长比对,超出即按超时处理。这里需要用System.currentTimeMillis()来计算,别用new Date().getTime(),后者在时区配置有误时容易出问题。

4. 数据库与Redis配置:生产环境跑起来的必要条件

4.1 MySQL连接与连接池参数调优

application.yml中,数据库连接配置看起来简单,但有几个参数值得刻意设置:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true&rewriteBatchedStatements=true username: root password: yourpassword hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 max-lifetime: 1800000

这里有几个“坑王”,挨个说:

  • serverTimezone=Asia/Shanghai必须加上。MySQL 8.0默认时区是UTC,你如果不在连接串里指定时区,Java插入的时间会和本地时间差8小时。你晚上8点创建一场考试,数据库里存的却是中午12点,第二天考试直接还没开始。
  • useSSL=false要加。MySQL 8.0默认尝试使用SSL连接,如果你本机没有配置SSL证书,会报错,加上这个参数就绕过去了。
  • allowMultiQueries=true允许在一个语句中用分号分隔多条SQL。这在初始化数据脚本、批量导入题库时很有用。比如在测试环境跑一个大的init.sql时,不需要自己拆分成几百条单独的execute,直接一条语句全跑完。
  • HikariCP的连接池大小不是越大越好。MySQL默认的max_connections是151,你设置成100,其他应用就连不上了。一般小型系统maximum-pool-size设20就绰绰有余,因为单台服务器即使有200个并发请求,绝大多数都阻塞在业务逻辑和IO上,真正同时占用数据库连接的不会超过20。连接池开太大,反而白白占用数据库连接槽位,造成不必要的资源紧张。

4.2 Redis缓存:不是可选项,是必备项

有人可能会觉得,一个小型考试系统用不上Redis。我一开始也是这么想的,但做完后复盘,发现Redis至少有三个不可替代的场景:

  • 存储验证码。Session存储验证码在单体架构下没问题,但毕业设计现在也流行用Docker部署,一旦你将来扩展成多实例,Session就无法跨实例共享,而Redis天然就是分布式的。
  • 存储考试令牌。上面说的防重复提交令牌,用Redis存储时可以通过SET key value EX 120设置过期时间,也就是考试开始后120分钟内令牌有效,考试结束后自动销毁,避免内存泄漏。
  • 缓存题库。题库表的题目内容基本不变,只有教师偶尔修改。第一次查询时从MySQL加载并放到Redis里,之后读取走缓存,QPS能提升一个量级。如果学生同时并发进入考试页面,后端要反复读取同一个试卷的题目列表,缓存的意义非常大。

Redis的配置和使用也简单:

spring: redis: host: localhost port: 6379 password: yourpassword database: 0 timeout: 5000 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

注意,Spring Boot 2.x默认用的是Lettuce连接池,而不是Jedis。Lettuce是基于Netty的异步驱动,性能更好,但你在网上搜到的一些老教程还在用Jedis。如果你在application.yml里配置了spring.redis.jedis.pool,在Spring Boot 2.x下会直接不生效,启动时甚至不报错,等并发一高就抛异常。这个坑很隐蔽,排查了半天才发现是连错了配置前缀。另外,database: 0默认使用的是0号库,如果你的项目里同时跑了多个系统,建议隔离开来用不同的数据库编号,防止key冲突导致数据串了。

4.3 代码中Redis的正确使用姿势

考试令牌的Redis存取代码如下,注意要用StringRedisTemplate而不是RedisTemplate<String, Object>。后者默认的序列化器是JDK序列化,存进去的内容是一串带特殊前缀的二进制,肉眼没法检查,而且跨语言跨平台兼容性差。StringRedisTemplate直接存字符串,Redis客户端里一眼就能看到内容,排查问题特别方便。

public void saveExamToken(Long examId, Long userId, String token) { String key = "exam:token:" + examId + ":" + userId; stringRedisTemplate.opsForValue().set(key, token, 120, TimeUnit.MINUTES); } public String getExamToken(Long examId, Long userId) { String key = "exam:token:" + examId + ":" + userId; return stringRedisTemplate.opsForValue().get(key); } public void deleteExamToken(Long examId, Long userId) { String key = "exam:token:" + examId + ":" + userId; stringRedisTemplate.delete(key); }

核心的拦截器也要用Redis辅助实现考试超时校验。在进入考试页面时,我会把考试开始时间也存到Redis里:

exam:start_time:{examId}:{userId} -> 1635292800000

每次考生提交答案时,从Redis里取这个时间戳,用System.currentTimeMillis()减一下得到实际用时,超过考试时长就直接判为超时。实际实现时我还会在前端通过WebSocket或者定时轮询的方式,在服务端主动踢出超时考生。

5. 部署与运维实战:从源代码到可运行系统

5.1 环境准备与部署步骤

整个部署我分成五步走,每一步都踩过坑,你按下面的顺序操作基本不会出大问题。

第一步:安装JDK并配置环境变量。我推荐JDK 8(即1.8)或者JDK 11,原因前面说过,Spring Boot 2.7.x完全兼容。Windows下安装完成后,在环境变量里新建JAVA_HOME指向JDK安装目录,在Path里追加%JAVA_HOME%\bin。验证方式是命令行输入:

java -version javac -version

两条命令都能正常输出版本号,说明JDK环境OK。如果java能用但javac报“不是内部或外部命令”,大概率是Path没配置好,或者你装的是JRE而不是JDK。

第二步:安装MySQL 8.0并初始化数据库。安装过程中会让你设置root密码,记好。然后创建数据库:

CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

注意一定要显式指定utf8mb4。如果使用默认的latin1,插入中文数据时直接报错或乱码。然后导入SQL脚本:

mysql -u root -p exam_system < exam_system.sql

导入时如果提示ERROR 1046 (3D000): No database selected,说明你忘了指定数据库名,会退回到系统默认库,初始化表和数据的语句全跑错。

第三步:配置application.yml。把数据库账号密码、Redis地址改成自己的,最关键的是检查serverTimezone参数有没有漏。有同学在这里出现过连接超时报错Access denied for user 'root'@'localhost',先检查密码,再检查MySQL是否有用户权限:

SELECT user, host, plugin FROM mysql.user WHERE user = 'root';

MySQL 8.0默认的认证插件是caching_sha2_password,有时旧驱动不支持,会报Public Key Retrieval is not allowed。解决方式是在连接串后加allowPublicKeyRetrieval=true,或者把用户的认证插件改成mysql_native_password。我用的是最新驱动,没遇到这个问题,但老驱动用户一定要记住这一步。

第四步:打包项目。在项目根目录执行:

mvn clean package -DskipTests

如果Maven下载依赖一直卡住,试试配置阿里云镜像,在settings.xmlmirrors标签里加入:

<mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/public</url> </mirror>

打包成功后,target目录下会生成一个exam-system-0.0.1-SNAPSHOT.jar,这就是可执行文件。

第五步:启动项目

java -jar exam-system-0.0.1-SNAPSHOT.jar

生产环境建议用nohup方式后台运行:

nohup java -jar exam-system-0.0.1-SNAPSHOT.jar > exam.log 2>&1 &

启动后,访问http://localhost:8080就能看到登录页面了。如果想改端口,在application.yml里加:

server: port: 8080

5.2 部署时最常遇到的5个启动异常

  • Port 8080 was already in use:端口被占用。先找占用进程,Windows下用netstat -ano | findstr 8080,再用taskkill /F /PID 进程号杀掉;Linux下用fuser -k 8080/tcp。也可以直接改端口避开。杀进程时一定要注意确认是不是你自己的Java进程,别误杀了别人的服务。

  • Failed to configure a DataSource:启动时Spring Boot找不到数据源。原因几乎都是application.yml里数据库配置没生效,或者注解里没加@MapperScan。先检查配置文件里urlusernamepassword这三个参数有没有写对,再看看spring.datasource这块的缩进是不是被IDE自动改坏了。YAML对缩进极其敏感,层级错了配置文件就会静默失效。

  • Caused by: java.net.ConnectException: Connection refused:连接被拒绝。通常是MySQL没启动、Redis没启动、或者端口号写错。分别检查mysql -u root -p能不能连上,redis-cli ping能不能返回PONG

  • Incorrect string value: '\xE5\xBC\xA0...' for column 'real_name':插入中文报错。这个就是数据库表结构编码问题,把表和字段的字符集改成utf8mb4即可。

  • java.lang.OutOfMemoryError: Insufficient memory:内存不足。Spring Boot默认堆内存很小,跑一个大系统或者并发一高就OOM。在启动命令里加参数:

java -Xms256m -Xmx512m -jar exam-system.jar

-Xms是启动时初始内存,-Xmx是最大内存。如果服务器内存充足,可以给到-Xms512m -Xmx1024m。加内存后还OOM,就要怀疑代码里有内存泄漏了,重点检查有没有在循环里不断往集合里塞对象,或者是不是把整个文件读进内存没有关闭流。

6. 从0到1的完整实现流程(附部署文档核心内容)

6.1 初始化项目骨架的五分钟快建法

我们以Spring Initializr方式举例。打开https://start.spring.io/,选择Maven工程、Spring Boot 2.7.18、JDK8,依赖选择:

  • Web
  • Thymeleaf
  • MyBatis Framework
  • MySQL Driver
  • Data Redis
  • Lombok
  • Validation

生成后导入IDEA,在启动类上加上@MapperScan("com.example.mapper"),然后新增包结构:

com.example.exam ├── config ├── controller ├── interceptor ├── mapper ├── pojo │ ├── entity │ ├── dto │ └── vo ├── service │ └── impl ├── util └── ExamApplication.java

遵循这种分包规范,答辩时老师问“项目结构怎么设计的”,你可以很清晰地说出每层的职责,比你随便乱放包要好很多。

6.2 部署文档要写什么:给“另一个自己”看

我在项目里附带的部署文档,目录结构是这样的:

部署文档 ├── 1-环境要求.md ├── 2-数据库初始化.md ├── 3-项目配置文件修改.md ├── 4-打包与运行.md ├── 5-常见问题排查.md └── 6-管理员初始账号说明.md

其中6是非常重要的一环,我见过太多人部署成功后找不到管理员账号。在SQL脚本里要明确插入初始管理员数据,比如:

INSERT INTO sys_user (id, username, password, real_name, status, create_time) VALUES (1, 'admin', '$2a$10$abc...', '系统管理员', 1, NOW()); INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1);

文档里明确写着:管理员账号 admin,初始密码 123456,第一次登录后请立即修改,并在“常见问题”里说明“如果你用admin登录报密码错误,大概率是密码里包含了需要转义的字符,或者你复制SQL时半角和全角括号没转换”。

部署文档的核心原则,我用一句话总结:写给你自己两周后看的,而不是写给一次也用不上的“用户”。把每一处配置改动都记录原因,当时为了调试某个问题临时改了什么,最后又改回去了,都写清楚。这样即使你哪天把环境搞坏了,回头看文档也能很快恢复现场。

7. 常见问题与实战排查记录

7.1 高频问题速查表

问题现象可能原因解决措施
启动报DataSource错误配置没生效或MySQL没启动检查application.yml缩进、MySQL是否正常启动
中文乱码连接串缺少编码参数或库表编码不对连接串加characterEncoding=utf8,建库用utf8mb4
时间相差8小时时区配置缺失连接串和Redis配置都要指定Asia/Shanghai
端口被占用上一次进程没杀干净手动找PID并kill,或直接换端口
验证码总是错误Kaptcha配置的字符集有误检查Kaptcha生成字符时是否只用了数字和字母,排除混淆字符
自动组卷抽不到题同课程题目数量不足在题库中补充该课程题量,或调低组卷规则
重复提交试卷成功令牌锁失效检查Redis连接和删除令牌的时机
登录后跳转404拦截器没放行静态资源和登录页在拦截器配置中忽略/login/css/**/js/**等路径
答案怎么提交不了前端请求格式和后端不一致检查Ajax的contentTypedata类型,不要在JSON里混用表单序列化
导出成绩Excel乱码Excel模板编码错误检查Excel导出工具类的文件流编码为UTF-8,并在响应头设置正确内容类型

7.2 排查工具与技巧

部署环境里遇到问题,别慌,先看一眼日志。Spring Boot的日志默认输出在控制台,如果用了nohup方式启动,就查看exam.log。日志级别可以在application.yml里设置:

logging: level: com.example.exam.mapper: debug

把MyBatis的Mapper包日志级别设为debug,就能在日志里看到每一条SQL语句和传入的参数,这对排查“SQL执行结果和预期不一致”特别有用。但部署到生产环境时,记得把debug改回info,否则SQL和参数全被打印出来,存在信息泄露风险。

如果怀疑是数据库问题,先用命令行连上去手动执行一遍SQL,看能不能跑通,排除是不是项目代码传参的问题。如果是Redis问题,用redis-cli monitor命令实时监视所有发到Redis的指令,立刻就能看到Token有没有正确写入。

一个很实用的小技巧:在本地开发时,开启Spring Boot的DevTools热部署,改完代码自动重启,能节省大量手动重启的时间。但要注意,DevTools在打包时不会进入生产环境的jar里,因为spring-boot-maven-plugin默认会排除掉它,不用手动处理。

8. 项目扩展方向:别让系统停在答辩那一刻

在线考试系统做完之后,如果你还有余力,我非常建议在这几个方向上加一点功能,不仅能让系统更完整,也能在简历上写得更漂亮:

  • 导入题库Excel。用EasyExcel或POI写一个批量导入接口,老师直接上传Excel文件,自动解析题目并存入题库。这个功能在实际使用中几乎是刚需,因为手动录入几百道题目太痛苦了。
  • 考试公告与通知。在系统首页加一个公告栏,管理员可以发布考试通知,学生登录后就能看到。用一个notice表加一个简单的前端页面就搞定,却能让系统显得更真实。
  • 成绩分析报表。用ECharts在教师端画一个“成绩分布直方图”或“平均分趋势图”。前端只要接入ECharts的CDN,后端提供一个聚合查询的接口返回数据就行。答辩时展示图表,比干巴巴地列一个成绩表格要惊艳得多。
  • 多语言支持。用i18n机制把页面文案抽成messages.propertiesmessages_en.properties,做一个语言切换按钮。这个在面试中也是一个不错的亮点,能体现你对国际化开发的理解。

从个人经验来看,面试时“基于Java的在线考试系统”这个项目可以讲出很多深度。我在面试候选人的时候,如果对方做过这个项目,我最喜欢问三个问题:“你是怎么防止学生重复提交的?”“自动组卷算法怎么保证随机性和均衡性?”“如果有一天考试并发量突然变成了1000人,你的系统会在哪里先崩?”这三个问题,能把“只会CRUD”的人和真正理解系统设计的人快速区分开。

我个人在做这个系统时有一个比较深的体会:技术难点往往不在于写了多少行代码,而在于你做出每个决策时,能不能给出理由。为什么用Spring Boot不用SSM?为什么题库表用四列选项而不是一个JSON字段?为什么Redis存储令牌要设置过期时间?这些问题没有标准答案,但每个决策背后其实都涉及性能、安全、可维护性的权衡取舍。把这些权衡想明白了,答辩时自然有底气;想不明白,就算系统能跑起来,一问细节还是会露馅。

最后再分享一个小技巧:课题做完之后,一定要做一次完整的数据备份。把MySQL的mysqldump导出脚本、项目的完整源码、部署文档、数据库初始化脚本打成一个压缩包,再额外上传到网盘一份。不是吓唬你,我见过太多人在答辩前夜电脑崩溃、源码找不回来的惨剧了。花五分钟备份一次,能省掉你后面几天的崩溃。部署文档和视频我额外整理了一份,整个打包好放在网盘里,需要的直接去下载就行。祝各位顺利通过答辩,这套东西学到的框架和思路,以后在企业里做真实项目也完全够用。

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

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

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

立即咨询