☰
Java在线考试系统源码:Spring Boot+Redis组卷与自动判分
2026/10/30 2:47:45 网站建设 项目流程

简介:Java在线考试系统源码压缩包面向高校计算机课程设计、毕业设计及需要二次开发的Java Web学习者,围绕在线测试与评估场景提供一套可运行的Web应用实现,并兼顾课程作业与项目实训场景。包内共255个文件,以java与class源码、jsp页面、xml配置及gif/jpg界面素材为主,另含jar依赖、css/js静态资源和sql建库脚本,压缩包约4.54MB,目录覆盖用户管理、考试与题库管理、自动评分、成绩展示等模块,结构较为清晰,适合按模块检索。已有916人学习,适合据此理解Hibernate与MySQL在考试数据存取中的协作方式。读者可参考DAO、Service、Action分层代码和Hibernate映射配置,快速梳理登录验证、题目分类、考试时长控制、答卷提交与成绩查询的业务链路,并在此基础上完成功能裁剪、界面调整或防作弊等扩展,用于课程作业、项目实训和二次开发起步。

1. 从一份群发的 Word 试卷说起:这套 java在线考试系统源码到底解决什么

上个月有个做企业内训的团队找我,说他们每季度的合规考核还在用最土的办法:一份 Word 试卷扔进群里,学员打印、手写、拍照回传,两个助教对着答案一份份数分,一次三百人要折腾三天,还出过把两个人的卷子弄混的事故。他们想上一套 java在线考试系统,找了一圈开源项目,不是 JSP + Servlet 的老结构跑起来一堆 404,就是功能堆得很满但题库、组卷、判分写死在一起,想加个「按章节随机抽题」都不知道从哪下手。这份源码走的是另一条路——Spring Boot 单体应用,控制层、业务层、持久层切得干净,题库、试卷、考试、作答、成绩五块数据各管各的,客观题自动判分,主观题留人工复核入口。它适合两类人:一类是想在两天内把一套能用的在线考试跑起来的中小团队,另一类是想拿一个业务闭环完整、又没复杂到看不懂的项目练手的后端开发者。整份代码量不大,但考试场景该有的坑它基本都踩过一遍,读起来比那种堆了二十个中间件的「演示项目」实在得多。

2. 技术选型与整体架构:Spring Boot 单体为什么反而更适合考试场景

2.1 依赖清单与技术栈取舍

先看它选了什么。后端 Spring Boot 2.x 打底,持久层 MyBatis,数据库 MySQL,缓存和临时状态用 Redis,前端是 Thymeleaf 模板 + 少量 jQuery,没有上 Vue 全家桶。这套组合放在今天不算新,但对考试系统来说恰好卡在合适的位置。

为什么不上 JPA?因为考试系统里最复杂的查询是组卷和成绩统计,前者要按题型、章节、难度三个维度随机抽题,后者要做分组聚合和排名,这两类 SQL 用 MyBatis 手写比用 JPA 的 Criteria 拼要直观得多,也更好调优。为什么不上微服务?一套面向几百到几千人的内部考核系统,拆成题库服务、考试服务、判分服务,光服务间调用的超时和重试就够写一周,收益远小于成本。单体应用在一台 4 核 8G 的机器上跑到两三千并发是没问题的,真正的瓶颈通常在数据库连接池和试卷查询的索引上,而不是架构形态。

Redis 在这里的定位要说清楚:它不存业务主数据,只存三类东西——考试会话(谁在考哪场、剩余时间)、防重复交卷的幂等键、以及组卷时的题目 ID 缓存。这三类数据都有「短生命周期、丢了可以重建」的特征,正好是 Redis 的舒适区。

<!-- pom.xml 关键依赖,版本按 Spring Boot parent 统一管理 --> <dependencies> <!-- Web 层,含内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 模板引擎,考生端页面直接服务端渲染,省掉前后端分离的联调成本 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis 启动器 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <!-- Redis,用于考试会话与幂等控制 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>

上面这段没什么花活,重点在 mybatis-spring-boot-starter 需要单独写版本号,其余交给 parent 统一管理,避免 Spring Boot 和 MyBatis 版本对不上导致启动时报NoSuchMethodError——这个错我在别的项目里踩过一次,堆栈指向的是SqlSessionFactoryBean,实际原因是 MyBatis 版本过旧,跟 Spring 的Configuration类签名不兼容。代码里的scope设置也值得留意,MySQL 驱动标runtime意味着编译期不参与,打包时会进 jar,这个细节在打 fat jar 部署时很关键。

2.2 三端职责边界:考生端、管理端、判分链

架构上它按使用者切成三块,但物理上还是一个应用,靠 URL 前缀和拦截器区分权限。

考生端路径统一在/exam/**下,负责登录校验、拉取试卷、提交单题答案、交卷。这里的核心约束是:考生只能看到自己那场考试的题目,且提交答案时必须携带考试记录 ID,服务端用它反查试卷归属,防止有人改 URL 里的参数去答别人的卷子。

管理端在/admin/**下,管题库增删改查、组卷、发布考试、查看成绩、主观题打分。管理端的操作大多是低频重操作,比如组卷时会一次性从题库抽几十道题并写入试卷明细表,这块不需要做特别激进的性能优化,把事务边界划对就行。

判分链不是独立服务,而是一组放在service.grade包下的类,由交卷动作触发。客观题(单选、多选、判断)在交卷的同一个事务里比对标准答案并写分数;主观题(简答、论述)不判分,只把答案原文落库并标记为「待复核」,管理端再调接口逐题给分。这样切的好处是交卷响应时间可控,不会因为某个考生的论述题写了三千字就卡住整个请求。

权限这块用的是拦截器加注解,@RequireRole("STUDENT")标在 Controller 类上,拦截器从 Session 里取角色做比对。注意它没上 Spring Security,原因是这套系统的权限模型就两级角色,上 Security 要写一大堆配置类,反而把项目撑大了。这是一个很务实的取舍,二次开发时如果你想加「阅卷老师」这种第三角色,直接扩枚举和拦截器判断即可,不用动框架。

2.3 从零把项目跑起来:配置、建库、启动

跑起来的步骤其实就三步,但第二步的配置容易出错,我按顺序说。

第一步,准备好 JDK 8 或 11 和 MySQL 5.7 以上版本。项目里没有 Docker Compose,所以数据库得自己起。建一个库叫exam_system,字符集选utf8mb4,排序规则utf8mb4_general_ci——用utf8的话,考生在简答题里输入生僻字或者 emoji 会截断报错,这是血泪经验。

第二步,改application.yml。配置项不多,但有几个必须对齐。

server: port: 8080 servlet: session: timeout: 120m # 会话超时要比最长考试时长更长,否则考到一半掉登录 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 hikari: maximum-pool-size: 20 # 按并发考生数估算,一般取 CPU 核数 * 2 + 磁盘数 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 database: 0 thymeleaf: cache: false # 开发期关缓存,改模板不用重启;上线改成 true mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.exam.entity configuration: map-underscore-to-camel-case: true # 数据库 snake_case 自动映射到 Java 驼峰

serverTimezone必须显式写成Asia/Shanghai,不写的话,MySQL 8 驱动会按服务器时区解析,考试开始时间偏移 8 小时,学员点进去直接提示「考试未开始」。session.timeout设 120 分钟是为了覆盖两小时的长考试,但真正的剩余时间控制不靠 Session,靠 Redis 里那条记录,Session 只是兜底。maximum-pool-size不要照抄网上教程写 100,连接池开太大,数据库端反而会因为线程切换变慢,20 到 30 是中小规模考试的合理区间。

第三步,执行项目sql/目录下的建表脚本和初始化数据,然后mvn spring-boot:run。启动日志里看到 Tomcat 监听 8080 且没有MapperScan相关异常,就说明数据源和 MyBatis 都通了。默认管理员账号密码写在初始化 SQL 的注释里,第一次登录后务必改掉。

提示:如果启动报Invalid bound statement (not found),九成是mapper-locations路径没对上,或者 Mapper 接口没加@Mapper注解,先查这两个,别急着怀疑版本。

3. 目录结构与数据模型:从题库到成绩单,五张表撑起整个闭环

3.1 包结构分层与各层职责

项目的包结构是典型的按层切分,根包com.example.exam下平铺了六个子包,没有按业务模块再拆。这种切法在项目规模不大时反而清爽,找代码时按「这是控制器还是服务」的思路走,不用先想「这属于题库域还是考试域」。

com.example.exam ├── controller # 考生端与管理端控制器,一个业务动作一个方法 ├── service # 业务逻辑,接口 + impl 两层 │ ├── impl │ └── grade # 判分相关,策略模式实现客观题判分 ├── mapper # MyBatis 接口,与 resources/mapper 下的 XML 一一对应 ├── entity # 数据库实体,字段与表一一对应 ├── dto # 传输对象,如组卷请求、交卷请求 ├── config # 拦截器注册、Redis 序列化配置、跨域配置 └── common # 统一返回体、异常定义、常量枚举

几个约定值得说。entity和dto是分开的,实体类只做数据库映射,所有对外接口的入参出参都用 DTO,这样加字段或者脱敏时不用动实体。common里放了一个Result<T>统一返回体,code、message、data三段式,前端统一按code === 200判断,省掉每个接口写一套解析逻辑。

grade包是判分策略的落点。里面有一个QuestionGrader接口,单选、多选、判断各实现一个类,通过题型枚举做路由。这么设计的原因是:多选题的判分规则经常变,有的考试要求「全对才得分」,有的要求「少选得一半分、错选不得分」,如果写在一个大 if-else 里,改一次规则要动整段逻辑,还容易改坏单选题。拆成策略后,加一种新规则就是加一个实现类。

resources/mapper下的 XML 是按实体拆的,QuestionMapper.xml、ExamRecordMapper.xml这种。注意组卷那条 SQL 写在PaperMapper.xml里,用了ORDER BY RAND()加LIMIT,这个写法在题目量小于一万条时没问题,超过之后要改成按 ID 区间随机取值,后面避坑章节会细说。

3.2 核心表设计:题目、试卷、考试、作答、成绩

数据模型是整个项目的地基,我把它抽象成五张主表和两张关联表,理解了这个,后面所有代码都是围着它们转。

表名作用关键字段
question题库,所有题目的原始定义id、type、content、options、answer、chapter_id、difficulty
paper试卷定义,一张卷子对应一条记录id、title、total_score、duration、pass_score
paper_question试卷与题目的关联,记录题目在卷内的顺序和分值paper_id、question_id、sort_no、score
exam一场考试,把试卷和考生群体绑起来id、paper_id、start_time、end_time、status
exam_record考生的答卷主记录id、exam_id、user_id、start_time、submit_time、total_score、status
answer_detail每道题的作答明细record_id、question_id、user_answer、score、is_correct
user考生与管理员账号id、username、password、role、real_name

设计上有两个关键点。第一,题目分值和试卷分值解耦:question表里不存分值,分值是写在paper_question.score上的。这样做的好处是同一道题在这张卷子里值 2 分,在另一张卷子里可以值 5 分,题库不用复制多份。很多初学者会把分值直接放题目表,结果组第二张卷子时发现改不动。

第二,exam_record和answer_detail是一对多,且answer_detail里冗余了score和is_correct。冗余是为了查成绩详情时不用回表 join 题目表再算一遍,判分时算完直接落下来,查询就是单表扫。这属于典型的以空间换查询性能,考试场景读远多于写,值得。

3.3 建表 SQL 与索引取舍

挑exam_record和answer_detail两张表看 DDL,因为这两张表是数据量增长最快的。

CREATE TABLE `exam_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `exam_id` BIGINT NOT NULL COMMENT '所属考试', `user_id` BIGINT NOT NULL COMMENT '考生', `start_time` DATETIME NOT NULL COMMENT '实际开考时间', `submit_time` DATETIME DEFAULT NULL COMMENT '交卷时间,未交卷为 NULL', `total_score` DECIMAL(6,2) DEFAULT NULL COMMENT '总分,判分后写入', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已交卷 2已判分 3作废', PRIMARY KEY (`id`), UNIQUE KEY `uk_exam_user` (`exam_id`, `user_id`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `answer_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `record_id` BIGINT NOT NULL, `question_id` BIGINT NOT NULL, `user_answer` TEXT COMMENT '考生答案原文', `score` DECIMAL(5,2) DEFAULT NULL COMMENT '本题得分', `is_correct` TINYINT DEFAULT NULL COMMENT '1对 0错 NULL待判', PRIMARY KEY (`id`), UNIQUE KEY `uk_record_question` (`record_id`, `question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

exam_record上的uk_exam_user是防重复开考的第一道闸门,同一个考生同一场考试只能有一条记录,重复插入会直接报主键冲突,业务层捕获后返回「你已进入过该考试」。这个唯一索引比在代码里先查后插要可靠得多,并发下也不会漏。idx_user_status服务于考生端的「我的考试列表」,按用户查状态是高频操作。

answer_detail的uk_record_question同理,保证一道题在一条答卷里只有一条作答记录。考生断线重连后重新提交同一道题,走的是ON DUPLICATE KEY UPDATE或者先查再更新,不会插出两条。user_answer用TEXT而不是VARCHAR(255),因为简答题可能写很长,VARCHAR截断会导致考生答案丢失,这种事故一旦发生就是投诉。

初始化数据脚本里通常会塞一个管理员、一个测试考生、二十道样题和一张样卷,方便第一次跑起来就能走通完整流程。样题的chapter_id和difficulty要覆盖多个取值,否则你测随机组卷时永远只能抽到同一批题,看不出效果。

4. 核心链路拆解:组卷、答题、交卷判分的代码怎么走

4.1 组卷:固定卷与按规则随机抽题

组卷分两种模式,项目默认实现了固定卷——管理员在页面上勾选题目,前端把题目 ID 数组传过来,后端批量写入paper_question。这段逻辑简单但事务边界要划好。

@Service public class PaperServiceImpl implements PaperService { @Autowired private PaperMapper paperMapper; @Autowired private PaperQuestionMapper paperQuestionMapper; /** * 创建固定试卷 * @param req 包含试卷标题、时长、及格线,以及题目列表(含题目ID和分值) * @return 新试卷ID */ @Override @Transactional(rollbackFor = Exception.class) public Long createFixedPaper(CreatePaperRequest req) { // 1. 先落主表,拿到自增主键 Paper paper = new Paper(); paper.setTitle(req.getTitle()); paper.setDuration(req.getDuration()); paper.setPassScore(req.getPassScore()); paper.setTotalScore(req.getQuestions().stream() .map(PaperQuestionItem::getScore) .reduce(BigDecimal.ZERO, BigDecimal::add)); // 总分由题目分值累加得出 paperMapper.insert(paper); // 2. 批量插入关联表,一次 SQL 搞定,避免循环里逐条 insert List<PaperQuestion> relations = new ArrayList<>(); for (int i = 0; i < req.getQuestions().size(); i++) { PaperQuestionItem item = req.getQuestions().get(i); PaperQuestion pq = new PaperQuestion(); pq.setPaperId(paper.getId()); pq.setQuestionId(item.getQuestionId()); pq.setScore(item.getScore()); pq.setSortNo(i + 1); // 顺序就是前端传过来的顺序 relations.add(pq); } paperQuestionMapper.batchInsert(relations); return paper.getId(); } }

这段代码有两个地方容易出问题。一是totalScore由后端累加而不是前端传,前端传的分值只作为单题分值参考,总分必须服务端算,否则有人篡改请求就能造出一张总分对不上的卷子。二是batchInsert必须在一条 SQL 里完成,如果写成 for 循环调insert,一百道题就是一百次数据库往返,组卷接口会从 200 毫秒变成 3 秒。对应的 XML 用<foreach>拼VALUES,注意单条 SQL 别超过max_allowed_packet,一般题目量在一千以内是安全的。

随机组卷的入口在autoGeneratePaper,思路是按题型分组,每组按chapter_id和difficulty过滤后随机取 N 条。常见做法是先SELECT id FROM question WHERE ... ORDER BY RAND() LIMIT #{count}拿到 ID 列表,再回表查详情。数据量小时一步到位,数据量大时必须拆两步,原因在避坑章节展开。

4.2 答题:倒计时、断线续答与答案暂存

考生点「开始考试」时,后端做三件事:往exam_record插一条status=0的记录、把开考时间和考试时长算出的截止时间写进 Redis、返回试卷内容(不含标准答案)。

@Override public ExamStartVO startExam(Long examId, Long userId) { // 1. 先查是否已有进行中的记录,有就续答,不重复开考 ExamRecord exist = examRecordMapper.findByExamAndUser(examId, userId); if (exist != null && exist.getStatus() == 0) { return buildStartVO(exist, loadPaperWithoutAnswer(exist)); } if (exist != null) { throw new BizException("该考试你已交卷,不能重复参加"); } // 2. 新开考,插入记录。唯一索引兜底,并发重复点击会在这里抛异常 Exam exam = examMapper.selectById(examId); ExamRecord record = new ExamRecord(); record.setExamId(examId); record.setUserId(userId); record.setStartTime(LocalDateTime.now()); record.setStatus(0); examRecordMapper.insert(record); // 3. 截止时间写 Redis,key 带 recordId,value 是毫秒时间戳 long deadline = System.currentTimeMillis() + exam.getDuration() * 60_000L; redisTemplate.opsForValue().set( "exam:deadline:" + record.getId(), String.valueOf(deadline), exam.getDuration() + 10, TimeUnit.MINUTES); // 多留 10 分钟容错 return buildStartVO(record, loadPaperWithoutAnswer(record)); }

loadPaperWithoutAnswer是关键,它查paper_questionjoinquestion,但要把answer和analysis两个字段置空再返回。如果图省事直接把Question实体序列化给前端,打开浏览器开发者工具就能看到所有标准答案,这门考试等于作废。我见过有项目就是这么翻车的,上线第二天就有学员反馈「答案在接口里」。

单题作答的接口是/exam/answer,每次提交写一次answer_detail,用ON DUPLICATE KEY UPDATE保证重复提交是覆盖而不是新增。这样设计是为了支持「边答边存」——考生每选一个选项就提交一次,断网重连后页面能回填之前的选择。倒计时不是前端自己数,而是每次提交答案时后端返回剩余秒数,前端拿这个值刷新显示,防止有人改本地时间作弊。

超过截止时间再提交答案,后端会比对 Redis 里的 deadline,超时则拒绝写入并直接触发自动交卷。这里要注意,exam_record的start_time用的是应用服务器时间,Redis 存的是毫秒时间戳,两者必须在同一时区,否则会出现「刚开考就提示超时」的诡异现象。

4.3 交卷判分:客观题自动比对与主观题挂起

交卷是整个链路里最需要保证幂等的动作。考生可能因为网络抖动连点两次交卷按钮,也可能前端超时重试,如果判分逻辑不幂等,成绩会被算两遍甚至覆盖成 0 分。

@Override @Transactional(rollbackFor = Exception.class) public BigDecimal submitExam(Long recordId, Long userId) { // 1. 用 Redis 做幂等锁,SETNX 成功才继续,失败说明已有交卷在处理 String lockKey = "exam:submit:" + recordId; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException("交卷处理中,请勿重复提交"); } try { ExamRecord record = examRecordMapper.selectById(recordId); if (record == null || !record.getUserId().equals(userId)) { throw new BizException("答卷不存在"); } // 2. 已经交过卷的直接返回原分数,保证重复调用结果一致 if (record.getStatus() >= 1) { return record.getTotalScore(); } // 3. 逐题判分:客观题走策略,主观题留空 List<AnswerDetail> details = answerDetailMapper.listByRecord(recordId); BigDecimal total = BigDecimal.ZERO; for (AnswerDetail d : details) { Question q = questionCache.get(d.getQuestionId()); QuestionGrader grader = graderFactory.get(q.getType()); if (grader == null) { d.setScore(null); // 主观题,等待人工复核 d.setIsCorrect(null); continue; } BigDecimal s = grader.grade(q, d.getUserAnswer()); d.setScore(s); d.setIsCorrect(s.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0); total = total.add(s); answerDetailMapper.updateScore(d); } // 4. 回写主记录,状态置为已交卷 record.setSubmitTime(LocalDateTime.now()); record.setTotalScore(total); record.setStatus(1); examRecordMapper.updateById(record); redisTemplate.delete("exam:deadline:" + recordId); return total; } finally { // 注意:锁不主动删,让它自然过期,避免判分事务还没提交锁就没了 } }

幂等这里用了SETNX加过期时间,而不是synchronized,因为应用可能部署两个实例,本地锁管不住跨节点。finally里故意不删锁是个反直觉但正确的选择:如果判分事务因为数据库慢还没提交,锁先被删了,第二个请求进来读到的还是status=0,就会重复判分;让锁自然过期,虽然多等 30 秒,但保证了「处理中」这个语义完整。这是一个典型的「宁可慢一点也别算错」的取舍。

判分策略接口很简单,BigDecimal grade(Question q, String userAnswer),多选实现类里先把标准答案和考生答案都按分隔符拆成 Set,再比交集和差集:全对给满分,有错选给零分,少选按配置给一半。这个配置项放在常量类里,二次开发时改成从数据库读就能做成可配置。

主观题在交卷时不判分,answer_detail里score是 NULL,exam_record的状态是 1(已交卷)而不是 2(已判分)。管理端的阅卷页面按status=1 AND 存在 score IS NULL 的明细筛出待复核的卷子,老师逐题打分后,把状态推到 2。这个双状态设计让「交卷」和「出成绩」解耦,学校场景里老师集中阅卷通常要一两天,不能让考生一直卡在「成绩计算中」。

5. 避坑与排查:上线前最容易被忽略的五个点

5.1 时间相关的坑:时区、超时与跨天考试

现象一:学员点进考试,页面直接提示「考试未开始」,但管理端显示考试时间是今天。

原因基本锁定在时区。application.yml里没写serverTimezone,MySQL 8 驱动默认用服务器本地时区解析DATETIME,而应用服务器可能是 UTC,两边差 8 小时。解决方式是连接串固定写serverTimezone=Asia/Shanghai,同时把 JVM 启动参数加上-Duser.timezone=Asia/Shanghai,保证LocalDateTime.now()取到的也是同一时区。这件事没有后悔药,一旦考试当天出问题,只能靠改数据库里的start_time抢救。

现象二:考试时长 60 分钟,学员答了 55 分钟提交,提示已超时。

原因是 Redis 里的 deadline 存的是毫秒时间戳,但设置过期时间时用了duration + 10分钟,而判超时用的是System.currentTimeMillis()和 deadline 比对。如果服务器做了 NTP 时间同步,恰好在考试期间往回跳了几秒,就会出现提前超时。解决方式是把判定逻辑改成「剩余时间小于 0 才拒绝」,并且允许 30 秒的宽限窗口,同时监控服务器时间同步频率。

现象三:晚上 23:30 开始的考试,跨过零点后时长计算变成负数。

这是用LocalTime算时长的典型翻车。LocalTime只表示一天内的时间,23:30 加 60 分钟会回绕到 00:30,两个LocalTime相减得到负数。正确做法是全程用LocalDateTime或者毫秒时间戳做运算,只在展示层格式化成时分秒。

5.2 并发与幂等相关的坑:重复开考与成绩覆盖

现象一:同一场考试,某个考生在exam_record里出现了两条记录。

要么是表上没建uk_exam_user唯一索引,要么是建了但业务代码先查后插,两个请求同时查到「不存在」,然后都执行插入。唯一索引是最后一道防线,代码里也要先靠它兜底——把插入包在 try-catch 里,捕获DuplicateKeyException后转成「你已进入过该考试」的正常响应,而不是抛 500。

现象二:考生反馈分数是 0 分,但管理端看到的答卷明细里明明有正确答案。

这是判分时读到了旧数据。交卷时先查answer_detail再逐条更新,如果考生在交卷请求发出的同时还在提交最后一道题,可能出现「明细还没落库,判分已经把总分算完了」。解决办法是在交卷入口先做一个「冻结」动作:把exam_record的状态改成「交卷中」,同时拒绝该 record 下所有新的答案提交请求,等判分完成再推到「已交卷」。状态机多一个中间态,能挡掉一大类时序问题。

现象三:用synchronized给判分方法加锁,部署两个实例后失效。

单机锁管不住多实例,这个坑很经典。换成 Redis 的SETNX分布式锁,或者更简单一点,用数据库的行锁SELECT ... FOR UPDATE锁住这条exam_record,后者不依赖额外组件,代价是并发交卷时会有一点排队,但对考试场景完全可接受。

5.3 判分与数据一致性的坑

现象一:多选题考生选了三个选项,其中两个正确一个错误,系统给了满分。

String.contains做答案比对是罪魁祸首。选项"A"是"AB"的子串,"AB".contains("A")返回 true,于是一个错误的单选答案被判对了。正确做法是把答案字符串拆成字符集合,用Set.equals比较,或者按位运算存成整数的位图。这个 bug 隐蔽性极强,因为只有部分组合会触发,小规模测试根本发现不了。

现象二:主观题阅卷后,考生端看到的成绩还是旧的。

因为考生端查成绩读的是exam_record.total_score,而老师打分只更新了answer_detail.score,没有触发主表重算。解决方式是在阅卷完成时,重新累加该 record 下所有明细的分数并回写主表,同时把状态推到 2。这一步必须放在同一个事务里,否则会出现「状态已判分但总分没变」的中间态。

现象三:成绩导出 Excel 时,某些行的小数位不一致,导致排名错乱。

DECIMAL(6,2)存进去是两位小数,但代码里用double累加会产生0.30000000000000004这种值,写回数据库时被截断,总分和明细之和对不上。凡是涉及分数、金额的计算一律用BigDecimal,且divide必须指定精度和舍入模式,不然除不尽直接抛ArithmeticException。

注意:这五个点里有三个在开发环境完全复现不出来,只会在真实考试的压力下暴露。上线前最好用脚本模拟二十个并发交卷、一场跨零点考试、以及一道答案含相同字母的多选题,能把大部分问题提前炸出来。

6. 二次开发:把写死的组卷改成策略模式,顺便加一层答案快照

拿到这套代码后最想动的通常是组卷。默认的固定卷加简单随机抽题,满足不了「每个章节抽 5 道、难度系数 0.3 占 30%」这种需求。改造思路是把组卷规则抽成一个可配置的对象,而不是继续在autoGeneratePaper里加 if。

第一步,定义规则结构。一条规则包含章节 ID、题型、难度区间、抽取数量、每题分值。用 JSON 存到paper表新增的rule_config字段里,这样不用建新表。

@Data public class PaperRule { private Long chapterId; // 章节,null 表示不限 private Integer type; // 题型:1单选 2多选 3判断 4简答 private BigDecimal minDifficulty; // 难度下限 private BigDecimal maxDifficulty; // 难度上限 private Integer count; // 抽取数量 private BigDecimal score; // 每题分值 }

第二步,在 Service 里按规则循环抽题,每条规则调一次 Mapper,SQL 里用WHERE chapter_id = #{chapterId} AND type = #{type} AND difficulty BETWEEN ... ORDER BY RAND() LIMIT #{count}。抽完之后要校验实际抽到的数量是否等于count,抽不满说明题库储备不足,直接抛异常让管理员补题,不要静默返回一张缺题的卷子。

第三步,把抽到的题目 ID 去重。同一个知识点在不同规则下可能被重复命中,重复的题放进一张卷子会让考生觉得莫名其妙。

Set<Long> picked = new HashSet<>(); List<PaperQuestion> relations = new ArrayList<>(); int sort = 1; for (PaperRule rule : rules) { List<Question> candidates = questionMapper.randomPick(rule); if (candidates.size() < rule.getCount()) { throw new BizException("章节 " + rule.getChapterId() + " 题目不足,需要 " + rule.getCount() + " 道,实际 " + candidates.size() + " 道"); } for (Question q : candidates) { if (!picked.add(q.getId())) { continue; // 已抽过,跳过,宁可少一道也不出重复题 } PaperQuestion pq = new PaperQuestion(); pq.setQuestionId(q.getId()); pq.setScore(rule.getScore()); pq.setSortNo(sort++); relations.add(pq); } }

这段代码里picked.add返回 false 就continue的写法,比先contains再add少一次哈希查找,题目量上千时差别不大,但习惯养成了没坏处。真正要注意的是「抽不满就抛异常」这个判断放在去重之前还是之后——放在之前会把「因为去重导致少一道」误判成「题库不足」,我的做法是抽题时多抽 20%,去重后再截断到目标数量,兼顾了不重复和抽得满。

第二个改造点是答案快照。现在answer_detail存的是考生答案,但标准答案是从question表实时读的。如果考完试管理员改了题库里的答案,历史成绩会跟着变,这在审计场景下是灾难。做法是在交卷时把标准答案和判分规则一起序列化,写进answer_detail新增的snapshot字段(JSON 类型)。这样任何时候回看一份答卷,判分依据都是当时的快照,跟题库后续怎么改无关。

验证方式很直接:造一份答卷,交卷后去题库把某道题的标准答案改掉,再查这份答卷的分数和判分明细,如果分数没变、明细里显示的仍是旧答案,说明快照生效了。这个测试我每次改判分逻辑都要跑一遍,因为它是唯一能证明「历史数据不受影响」的手段,光看代码很容易漏掉某个直接读题库的分支。

从那以后我每接一个考试类项目,都会先把「时间、幂等、答案快照」这三件事在纸上列出来,逐条确认代码里有没有对应的保护,再开始写业务功能。看着像拖慢进度,实际上省掉的是上线后半夜被电话叫起来改数据的那些时间。希望帮到你。

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

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

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

立即咨询