
前阵子帮一家培训机构做了一套SpringBoot在线考试系统从排期到上线大概六周时间。说实话这类系统在毕设和外包项目里都快被写烂了但真正踩过坑的人都知道它远不是“管理员发布题目、考生在线答题、系统自动判分”这么简单。这套系统落地的过程里我在试卷数据模型、交卷判分一致性、甚至Swagger接口泄露这些问题上反复折腾过今天把它拆开来聊聊。如果你正准备用SpringBoot做在线考试系统无论是课程设计、毕业设计还是真实业务交付这篇的思路、表结构和避坑点都可以直接参考。这套系统我按三类角色设计管理员、教师、考生。管理员管用户和考试教师负责题库维护、组卷、阅卷考生在线参加考试、查看成绩。技术选型上我比较保守没有追最新版本而是选了一堆生产环境验证过的稳定组合SpringBoot 2.7.13、JDK8、MyBatis-Plus 3.5.3.2、MySQL 8.0、Redis、JWT前端用Vue3加Element Plus。下面按从设计到落地的顺序把关键环节一个一个说清楚。1. 在线考试系统真正难的不是CRUD而是这四件事很多第一次做考试系统的人上来就建几张表写几个增删改查接口觉得完事了。实际上这套业务有几个“灵魂”设计没想清楚的话后面改起来都是伤筋动骨。1.1 试卷是“快照”不是“关系”新手最容易犯的错把考试和题目之间直接做成多对多关联exam表关联question表试卷显示的时候实时去查题库。这样做的问题很致命——题库里的题目一旦被修改或删除已经结束的考试和考生成绩就对不上了。比如考生考完试教师过了两天发现某道题的选项有错别字顺手改了题库结果考生的历史试卷和历史答案全跟着变了这成绩怎么解释所以我的方案是exam_paper_question表里冗余一份题目快照。题干、选项、标准答案、分值全部在组卷那一刻拷贝一份到这个表里。此后题库怎么改都不影响已经生成的试卷。实际表结构大概是这样的字段类型说明idbigint主键exam_idbigint考试IDquestion_idbigint原题目ID仅用于追溯question_typevarchar题型单选/多选/判断/简答content_snapshottext题干快照options_snapshotjson选项快照JSON数组answer_snapshotvarchar标准答案快照scoreint该题分值sort_noint试卷内序号注意answer_snapshot必须存快照不能存空。这样自动判分时完全基于快照字段根本不依赖原题。1.2 判分逻辑必须和答题过程解耦另一个容易踩的坑想让考生每答一道题就立刻看到对错和得分于是每次提交答案都跑一遍判分更新分数。表面看体验好实际会带来两个问题。第一数据库压力变大。一场考试几百人同时答题每道题都触发一次判分和更新写操作会非常密集。第二逻辑容易出现状态不一致。比如考生反复修改答案某一次判分失败展示的画面和数据库实际分数就乱了后续再交卷时到底以哪次为准我的做法是答题过程中只保存作答内容不判分。考生点交卷的那一刻系统统一执行一次判分流程一次性把客观题对错算完主观题标记为待阅卷。这样整个流程清晰出故障的窗口也最小。1.3 考试状态是一个状态机不是随手改个字段考试记录的状态千万别用简单的“0未开始、1进行中、2已结束”顶着后面会把自己坑死。因为这里有多个角色在操作考生交卷、时间到自动交卷、教师阅卷、管理员发布成绩。每个操作对状态都有前提要求。我设计的枚举NOT_STARTED未开始ONGOING进行中正在答题PENDING_REVIEW待人工阅卷已交卷但含主观题FINISHED已发布成绩已出CANCELED已取消关键操作都要校验状态。交卷要求状态必须是ONGOING阅卷要求状态必须是PENDING_REVIEW发布成绩要求状态必须是待阅卷且主观题都判完了。任何一步不满足就直接拒绝这样可以从源头上避免重复交卷、重复判分这些问题。1.4 防作弊一定做在后端前端做切屏检测、答题时间限制这些都只是体验层的东西真正可信的校验全在后端。我在系统里做了几件事登录态用JWT接口每次都要校验当前用户身份防止越权访问别人的考试记录。考试开始时记录IP、设备信息交卷时再次校验如果两次IP差异过大标记为异常考试记录提醒教师人工复核。用Redis的分布式锁保证同一个考试记录ID在同一时间只能有一个交卷请求在处理避免考生连续点交卷按钮导致重复提交。记住一个原则前端能做的一切限制都是为了让正常用户用起来舒服而不是用来防攻击者的。后端才是最后一道防线。2. 技术选型定生死SpringBoot版本、持久层和前后端方案在线考试系统的业务复杂度不算低但也没到必须上微服务的程度。单体应用加缓存足够撑起几千人同时考试。真正要花时间想清楚的是技术栈的“匹配度”。2.1 SpringBoot版本别盲目追新热词里有一堆关于“SpringBoot版本太高”“创建项目超时”的问题我这边也见过不少同学拿着最新版SpringBoot和网上教程硬碰最后卡在依赖冲突上。我选的2.7.13这是2.x系列的稳定收尾版本几乎兼容所有主流的第三方starter。说实话如果你团队用的JDK还是8或者11就老老实实留在2.x。SpringBoot 3.x必须配JDK17而且里面很多包名从javax变成了jakartaMySQL驱动坐标也变了网上很多旧教程直接照搬会报一堆包不存在的错。对于考试系统这样的业务型项目稳定压倒一切。pom.xml核心依赖大概是这样的parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.13/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency /dependencies2.2 持久层选型MyBatis-Plus是“爽”但别滥用对比一下方案优点缺点适用场景原生MyBatisSQL可控灵活单表CRUD也要写SQL工作量太大对SQL性能有极致要求Spring Data JPA开发快仓库接口复杂查询SQL难调优快速原型、标准CRUDMyBatis-Plus单表CRUD零SQL分页/逻辑删除好用底层帮你生成了SQL复杂多表还得自己写XML大多数企业级单体应用考试系统里有大量“按条件分页查询”的单表操作比如题目列表、考试列表、考生记录这些用MyBatis-Plus确实省很多时间。但是像“统计一场考试的平均分、及格率、成绩分布”这种多表聚合查询还是自己写SQL更靠谱不要硬用Wrapper拼。application.yml里我这样配置spring: datasource: url: jdbc:mysql://localhost:3306/exam_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:123456} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 3 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意分页插件要单独配置MyBatis-Plus的逻辑删除字段也顺手配好这样删数据的时候都是软删后面恢复和审计都方便。2.3 前后端分离统一返回体是一切协作的基础这套系统是典型的前后端分离项目我用Vue3加Element Plus做管理端和考生端页面。前后端分离最怕接口文档各自理解所以从第一天起我强制统一响应结构public class ResultT { private Integer code; // 200成功其它为失败 private String message; // 提示信息 private T data; // 业务数据 }配合全局异常处理任何业务异常都转成这个结构返回前端直接拦截message展示。接口按角色划分前缀/api/auth/**登录、验证码、用户信息/api/admin/**管理员接口/api/teacher/**教师接口/api/student/**考生接口再配一层Spring Security或拦截器按路径做权限控制。跨域开发环境下用CorsFilter解决生产环境直接用Nginx把前后端域代理到同一个域名下从根上避开跨域。2.4 Redis在考试系统里的四个位置Redis不只是用来存验证码和会话我在这套系统里给它安排了四个核心差事第一缓存热点数据。公告、系统参数、题目类型枚举这种不常变但每次页面都查的数据直接缓存访问量再大也不怕。第二分布式锁。前面提到的交卷防重复、定时任务防重复执行都用Redis的setIfAbsent实现。第三登录失败计数防刷。连续登录失败超过5次就锁15分钟防止暴力破解。第四成绩排名/统计临时数据。考试结束后按分数做排名直接算好后写Redis前台展示快后台也不重复查库。3. 题库与组卷表结构、批量导入和随机抽题的实现细节题库模块是整个系统的基础题目质量直接决定考试有没有效。这里我把设计细节拆开讲。3.1 题库与题目表设计先看两张核心表CREATE TABLE question_bank ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 题库名称, subject VARCHAR(50) NOT NULL COMMENT 科目, description VARCHAR(255), deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME ) COMMENT 题库表; CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bank_id BIGINT NOT NULL COMMENT 所属题库ID, type VARCHAR(20) NOT NULL COMMENT SINGLE/MULTIPLE/JUDGE/ESSAY, content TEXT NOT NULL COMMENT 题干, options JSON COMMENT 选项[{key:A,text:...}], answer VARCHAR(500) NOT NULL COMMENT 标准答案多选用逗号分隔判断用T/F, analysis TEXT COMMENT 答案解析, difficulty VARCHAR(10) NOT NULL COMMENT EASY/MEDIUM/HARD, score INT NOT NULL COMMENT 默认分值, deleted TINYINT DEFAULT 0, create_time DATETIME, update_time DATETIME ) COMMENT 题目表;选项用JSON存储有两个好处单选和多选都能表达新增题型不需要改表结构。但要注意JSON字段在MyBatis里要配置对应的TypeHandler否则查出来的就是字符串还要手动转对象。我一般用jackson的ObjectMapper在实体类的setter里统一处理。3.2 Excel批量导入先校验后导入题库初始化的时候如果让教师一道题一道题手敲谁都会崩溃。所以批量导入Excel是必做的功能。我用EasyExcel而不是传统的POI理由很简单EasyExcel内存占用低几万行的Excel轻松搞定而且注解式模型简洁。导入流程上传文件到后端暂存到临时目录。用EasyExcel流式读取每读一行就做基础校验。有错误的行收集起来返回错误位置和原因一条都不入库。全部校验通过后一次性事务写入。这个“先校验后导入一旦有错全批不落库”的策略很关键。否则数据入了半截题目又是分页展示的教师很难发现到底哪些题目缺失。导入模型的注意点单选题答案只能有一个字母多选题答案是字母组合用逗号分隔判断题答案限定T或F难度字段必须是枚举值。这些校验看着简单但在真实使用中能挡掉90%的脏数据。3.3 组卷逻辑随机抽题也可以做得很严谨组卷分两种手动组卷和随机组卷。手动组卷简单教师从题目列表勾选设置每道题在试卷里的分值保存到exam_paper_question表。随机组卷是重点。需求一般是这样的从某个题库里按题型和难度分配题目数量比如“单选题10道其中简单4道、中等4道、困难2道每题2分”。对应的查询逻辑SELECT * FROM question WHERE bank_id #{bankId} AND type SINGLE AND difficulty EASY AND deleted 0 ORDER BY RAND() LIMIT 4;RAND()在数据量几千条的时候完全够用但如果将来题库到了几十万题这个写法会导致全表扫描。那时候可以改成“一次查出符合条件的所有id在Java层面随机选再按id查详情”的思路。随机组卷的关键在于“随机后必须固化”。抽完题后要立刻循环写入exam_paper_question表把题目的快照字段全部存下来。否则考生A刷新一次页面抽到的题变了考试就没法做了。组卷时还有一个细节题目在试卷里的排序。我建议固定一套规则比如按“单选-多选-判断-简答”的顺序组卷时把同一类型的题放在一起sort_no按100、200、300这样的间隔递增这样后面想在某一类题中间插入新题时不需要把所有题都重排一遍。4. 答题、交卷、自动判分一条链路上的状态机与一致性这是整套系统的核心战场。考生端体验、数据一致性、并发控制全部集中在这里我这里按一次完整考试的流程来讲。4.1 开始考试生成考试记录和答题上下文考生点击“开始考试”时后端要做的工作校验考试存在、当前时间在考试时间段内、考生状态正常。校验该考生没有已经开始且未交卷的考试记录防止一个账号开两场。创建exam_record状态设为ONGOING记录考试开始时间。返回试卷题目给前端。试卷返回方面我选择“全量返回题目前端本地保存草稿”的策略。考试场景跟问卷调研不一样考生需要在一道题上停留思考如果每次切换题目都实时请求接口体验很差。全量返回的确会让首次进入时等待时间长一点但大部分考试题目量也就几十道数据量完全可以接受。4.2 保存答题进度唯一索引防重复考生每做一题前端会把答案异步保存到后端。接口设计很简单POST /api/student/exam/record/{recordId}/answer { questionId: 123, userAnswer: A }后端校验之后做insert或update。这里有个非常容易踩的坑同一道题考生会反复修改答案每次修改都会请求一次如果直接insert考试记录里就会出现同一道题的多条作答记录判分时到底取哪条解决方案是在exam_record_answer表上建联合唯一索引ALTER TABLE exam_record_answer ADD UNIQUE KEY uk_record_question (record_id, question_id);然后SQL用“有则更新无则插入”的写法或者先查一下再决定insert还是update。我用的是后者虽然多一次查询但逻辑直观不容易出错。4.3 交卷接口幂等、锁、事务一个都不能少交卷是全网压力最大的接口也是必须保证数据一致性的接口。想象一下考试结束那一刻几百个考生同时点交卷同一个考生可能还手抖点了两次后端一定要接得住而且只能成功一次。核心代码逻辑如下Transactional(rollbackFor Exception.class) public SubmitResult submit(Long recordId) { // 1. Redis分布式锁防止并发重复交卷 boolean locked redisLock.tryLock(submit: recordId, 5, TimeUnit.SECONDS); if (!locked) { throw new BizException(正在提交试卷请勿重复操作); } try { // 2. 条件更新状态 int rows examRecordMapper.submitIfOngoing(recordId); if (rows 0) { return SubmitResult.alreadySubmitted(); } // 3. 加载答题明细遍历判分 ListAnswerVO answers examRecordAnswerMapper.listByRecordId(recordId); int objectiveScore 0; for (AnswerVO answer : answers) { if (answer.isObjective()) { boolean correct answer.getUserAnswer().equals( answer.getStandardAnswer()); if (correct) { objectiveScore answer.getScore(); } // 记录每道题的对错结果 examRecordAnswerMapper.updateJudgeResult( answer.getId(), correct, correct ? answer.getScore() : 0); } } // 4. 更新总分主观题分数先为0 examRecordMapper.updateTotalScore(recordId, objectiveScore, 0); return SubmitResult.success(objectiveScore); } finally { redisLock.unlock(submit: recordId); } }注意步骤2的“条件更新”是关键SQL是UPDATE exam_record SET status PENDING_REVIEW, submit_time NOW() WHERE id #{recordId} AND status ONGOING如果影响行数是0说明这个记录已经不是“进行中”状态了要么之前已经交过卷要么考试被取消反正不能再走一遍判分逻辑。整个流程必须在一个事务里。用数据库事务保证交卷状态、每个答案的判分结果、总分更新要么全部成功要么全部回滚。4.4 考试时间到自动交卷定时任务加分布式锁总有人忘记交卷所以系统必须能自动交卷。我用SpringBoot自带的Scheduled实现每分钟扫一次Scheduled(cron 0 * * * * ?) public void autoSubmitExpiredExams() { // 查所有已结束但未交卷的考试记录 ListExamRecord expiredRecords examRecordMapper .listExpiredNotSubmitted(LocalDateTime.now()); for (ExamRecord record : expiredRecords) { try { examSubmitService.submit(record.getId()); } catch (Exception e) { log.error(自动交卷失败, recordId{}, record.getId(), e); } } }这里有个生产环境必踩的坑如果系统部署了多个实例同一个定时任务会在每台机器上同时执行导致同一份答题记录被重复处理。虽然我们的submit逻辑本身是幂等的第二次进会返回“已提交”但为了不给数据库添乱最好在定时任务外层加一个Redis分布式锁让同一时刻只有一个实例在跑这个任务。简单实现boolean taskLock redisLock.tryLock(task:autoSubmit, 60, TimeUnit.SECONDS); if (taskLock) { try { // 执行批量自动交卷 } finally { redisLock.unlock(task:autoSubmit); } }自动交卷调用的必须是同一个submit服务不能另写一套逻辑否则又要维护两份不同的交卷代码。5. 上线前必须处理的安全与运维问题Swagger泄露、heapdump与多环境部署很多考试系统开发时很爽一上线就被安全扫描扫出一堆问题。这里讲几个我真实遇到过、也是热词里高频出现的问题。5.1 Swagger接口文档未授权访问要堵死开发阶段用springfox Swagger 2.9.2确实方便。但默认配置下/swagger-ui.html和/v2/api-docs都是公开的这意味着任何知道路径的人都能在线上环境看到你的所有接口定义甚至能顺着接口列表直接调你的后端。我当时的修复方案很简单生产环境不加载Swagger配置。Configuration Profile(!prod) public class SwaggerConfig { // Swagger的Docket配置等 }用Profile注解把Swagger配置限定在非生产环境。如果有的环境还需要部分接口文档那就再加一层Spring Security把swagger相关路径设置成只允许管理员角色访问。顺带提一句如果你用的是springdoc对应配置是springdoc: api-docs: enabled: false swagger-ui: enabled: false5.2 Actuator和Heapdump的敏感信息泄露SpringBoot的Actuator是个好东西但如果你把端点全部暴露出来就相当于把自家保险柜的门钥匙挂在了门口。最典型的漏洞就是heapdump——攻击者下载下来的堆转储文件里可能直接提取到数据库密码、Redis密码、用户Token等运行期内存中的敏感数据。修复手段management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never只暴露health和info端点而且健康检查不展示细节。这样既保留了监控探活能力又把敏感面砍到最小。5.3 配置文件的密码不能明文裸奔数据库密码、Redis密码尽量不要写在yml里直接提交到Git仓库。我用了jasypt-spring-boot-starter做配置项加密密码字段在配置里是一串密文解密的密钥通过环境变量传入。jasypt: encryptor: password: ${JASYPT_SECRET}maven依赖dependency groupIdcom.github.ulisesbocchio/groupId artifactIdjasypt-spring-boot-starter/artifactId version3.0.5/version /dependency然后数据库密码写成spring: datasource: password: ENC(加密后的密文)这样即使配置文件泄露攻击者拿不到环境变量里的密钥也解不出明文密码。5.4 部署War包还是Jar包默认情况下SpringBoot打成可执行jar内置Tomcat一条java -jar就能跑这也是我最推荐的方式。但如果你必须部署到宝兰德、WebLogic、WebSphere这类外置容器确实有客户这么要求那要改成打war包。改动点有三个pom.xml里packaging改成war。启动类继承org.springframework.boot.web.servlet.support.SpringBootServletInitializer并重写configure方法。把spring-boot-starter-tomcat的scope设置成provided避免和容器自带Tomcat冲突。SpringBootApplication public class ExamApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(ExamApplication.class); } }需要注意的是外置容器部署时的上下文路径、临时目录、日志目录都可能和内置Tomcat有差异切换容器前一定要先拿一个最小war包做冒烟测试别等接了真实环境才发现问题。6. 实测踩坑清单自动建表、版本冲突和其他几个容易翻车的点最后这部分我把这个项目从零到上线过程中最值得记录的坑集中列一下很多都是搜不到现成答案的问题。6.1 表不存在自动建表开发方便生产别用热词里“当表不存在自动建表”经常被搜到。刚搭框架的时候我也图省事想用SpringBoot的初始化脚本自动建表。当时用的配置是spring: sql: init: mode: always schema-locations: classpath:db/schema.sql结果发现几个问题第一这个自动初始化在SpringBoot 2.5之后行为和以前不一样和连接池配合时偶尔出现脚本还没执行完业务代码就已经去查表了。第二schema.sql没法做版本管理一旦线上已经跑了一段时间改字段结构就成了噩梦。我的最终选择开发环境用MyBatis-Plus的自动建表能力省事生产环境引入Flyway做版本化数据库迁移。Flyway的好处是每次数据库变更都写成一条迁移脚本脚本用版本号管理团队每个人都按顺序执行不会出现“我本地改了一张表你那边不知道”的状态。Flyway接入非常简单dependency groupIdorg.flywaydb/groupId artifactIdflyway-core/artifactId /dependency脚本放resources/db/migration目录命名规则是V1__init.sql、V2__alter_exam_record.sql这种。启动时会自动检测只执行没执行过的脚本。6.2 版本太高引发的连锁崩溃我有个同事直接用SpringBoot 3.2 JDK21搭项目结果问题一个接一个。首先是javax.servlet包整体变成了jakarta.servlet网上的旧教程和旧工具类全得改其次MyBatis-Plus必须升到3.5.3.2以上才兼容再有MySQL驱动依赖坐标从mysql:mysql-connector-java变成了com.mysql:mysql-connector-j。如果你是跟着教程做项目建议老老实实用教程同版本或者相近版本不要自己“超前”换版本否则教程里的代码和配置全都对不上排查问题相当痛苦。考试系统这种业务系统稳定性优先级远高于“用上最新版本”。6.3 大文件导入时的坑上传限制默认只有1MB批量导入题库时Excel文件控制在几百KB还好但一旦导入的题目带了很多图片、公式文件轻松超过1MB前端就会收到“上传失败”的报错。SpringBoot默认的单个文件大小限制是1MB单次请求总大小限制是10MB。解决问题分两步第一调大限制spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB第二更重要的是不要在大文件上传的请求里做耗时的解析操作。如果题库文件有几十兆Excel解析可能要好几秒甚至更久HTTP请求很容易超时。我的做法是上传接口先把文件保存到本地或OSS返回一个任务ID后端用单独的处理线程或消息队列去解析Excel前端轮询“任务状态”接口解析完成后提示导入结果。这样不仅用户不用傻等后端也不会因为长时间占用请求线程而影响其他接口。6.4 前端工程师能不能直接上手改Java后端项目热词里这个问题挺有意思。答案是可以但如果后端代码结构混乱那又是另一回事。我在这套系统里为了保证“前后端都能维护”做了几个约定模块分包清晰controller、service、mapper、entity、dto分开业务按考试、题库、用户、成绩这些域来分包。所有接口都有Swagger注解参数和返回值一目了然。状态码和异常信息统一定义在枚举里前端可以根据code做统一处理。日志统一用logback每次请求都记录操作日志出了问题大家都能查。这些规矩看着基础但能让一个第一次接触SpringBoot的前端同事也能快速定位问题。后端代码不是写给自己欣赏的艺术品而是整个团队要维护的基础设施。6.5 关于事务里面别调外部接口的小提醒最后提一个比较高发的问题事务方法里千万别做远程调用或者复杂的IO操作。比如交卷的时候需要调用消息服务通知监考老师、调短信服务通知考生这些操作一旦放在判分事务里会无限拉长事务时间数据库连接被长久占用严重时能把连接池打满。我的处理方式是事务只负责和判分相关的最核心数据库操作事务提交成功后再通过Spring的事件发布机制异步发送通知。这样既保证核心数据一致性又不阻塞主流程。回到这套系统本身我在做的时候最大的感受是SpringBoot在线考试系统看起来是一个“管理端考生端”的标准CRUD但真正决定成败的全是这些边界场景——交卷是否幂等、试卷是否固化、状态流转是否严密、线上安全是否堵住。把这些想清楚项目才算是真的能扛住真实用户的使用强度。希望这篇文能帮你少走几步弯路后面如果再有人拿在线考试系统来问怎么做把这篇文章甩过去就够了。