夜雨聆风学习资料网

ARTICLE · 1337691

Spring Boot 构建在线考试与题库系统:组卷策略、防作弊、自动判卷与成绩分析

Spring Boot 构建在线考试与题库系统:组卷策略、防作弊、自动判卷与成绩分析
“

技术栈:Spring Boot 3.x + MyBatis-Plus + MySQL 8 + Redis 7 + RocketMQ + WebSocket + MinIO + EasyExcel

在线考试这摊子事,做个演示版不难,真上生产是另一回事。几千人同一时间开考,交卷口一堵,组卷规则一复杂,判卷再掺点主观题,问题就全冒出来了。下面按一套实际落地的 Spring Boot 3 方案,把领域模型、组卷、考试过程、防作弊、判卷、并发和成绩分析串一遍。代码和表结构都做了裁剪,重点看设计取舍。

一、业务场景

同一套题库内核一般要接四类活:题库管理、正式在线考试、练习测评、证书考试。它们看着都能复用题目表,实际诉求差很远。

题库管理是资产沉淀,题目要版本化、能批量导入,没有考试时间窗,也不涉及判卷。在线考试严格限时,防作弊要强,客观题自动判、主观题人工判,成绩要归档。练习测评追求即时反馈,交卷后马上出分,错题本能回流到下一轮练习。证书考试最严,固定卷库或抽题后还要做试卷等值,判卷常常双评仲裁,成绩发布也得受控。

统一建模时别按场景建四套表,用“考试计划 + 试卷类型”区分,把差异收敛到配置和状态机里。比如练习测评可以在交卷接口内同步判完;正式考试交卷只落答案和状态,判卷走异步;证书考试在组卷阶段就要把难度系数和等值策略考虑进去。

二、领域模型

2.1 实体关系

knowledge_point  知识点(树形,支持多级)question         题目(题干、题型、难度、答案、解析)question_option  题目选项question_kp      题目-知识点关联(多对多,含权重)paper            试卷(模板/实例)paper_question   试卷题目(快照,含顺序号、分值)exam_plan        考试计划(时间窗、时长、次数、组卷规则)exam_attempt     作答记录(一次考试一次 attempt)attempt_answer   作答明细(题目快照 + 考生答案 + 得分)attempt_event    行为日志(切屏、抓拍、粘贴等)exam_result      成绩(总分、排名、发布状态)

2.2 三个关键设计决策

(1)试卷必须做题目快照。 题库的题干和答案会被持续修订,但历史考卷不能被修改。paper_question 不能只存 question_id,要冗余存储题目内容、选项、答案和分值的快照,用 JSON 字段就行,保证“卷子一旦生成就冻结”。这里有个容易踩的坑:快照里带答案,发卷接口必须裁剪字段,别把整个 snapshot JSON 直接丢给前端。

(2)作答记录与试卷解耦。exam_attempt 持有 paper_id 和 exam_plan_id,把“考务”与“答卷”分开。补考、重考在计划允许的轮次内递增 attempt_no;如果业务把补考当成独立场次,也可以新建 exam_plan,别把重考逻辑硬塞进计划本身。

(3)得分落在 attempt_answer 上,exam_result 只做汇总。 主客观题的判分结果、判卷人、判卷时间都在明细上,成绩汇总表可以随时重算,避免判卷逻辑变更后成绩无法回滚。

2.3 核心建表(节选)

CREATETABLE question (idBIGINT PRIMARY KEY AUTO_INCREMENT,  bank_id       BIGINTNOTNULLCOMMENT'题库/科目',typeTINYINTNOTNULLCOMMENT'1单选 2多选 3判断 4填空 5简答',  difficulty    DECIMAL(3,2) NOTNULLDEFAULT0.50COMMENT'0-1,越大越难',  score         DECIMAL(5,1) NOTNULLDEFAULT1.0COMMENT'默认分值',  stem          TEXTNOTNULL,  answer        JSONNOTNULLCOMMENT'标准答案快照',  analysis      TEXTCOMMENT'解析',statusTINYINTNOTNULLDEFAULT1,versionINTNOTNULLDEFAULT0,KEY idx_pool (bank_id, type, difficulty, status)) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;CREATETABLE paper_question (idBIGINT PRIMARY KEY AUTO_INCREMENT,  paper_id      BIGINTNOTNULL,  question_id   BIGINTNOTNULL,  seq_no        INTNOTNULLCOMMENT'卷面顺序',  score         DECIMAL(5,1) NOTNULL,snapshotJSONNOTNULLCOMMENT'题干/选项/答案冻结快照',UNIQUEKEY uk_paper_q (paper_id, question_id),UNIQUEKEY uk_paper_seq (paper_id, seq_no));CREATETABLE exam_attempt (idBIGINT PRIMARY KEY AUTO_INCREMENT,  plan_id       BIGINTNOTNULL,  paper_id      BIGINTNOTNULL,  user_id       BIGINTNOTNULL,  attempt_no    INTNOTNULLDEFAULT1COMMENT'同一计划内的作答轮次,补考/重考递增',statusTINYINTNOTNULLDEFAULT0COMMENT'0未开始 1作答中 2已交卷 3已判卷 4缺考',  start_time    DATETIME     NULL,  end_time      DATETIME     NULLCOMMENT'服务端计算的截止时间',  submit_time   DATETIME     NULL,  score         DECIMAL(6,2) NULL,versionINTNOTNULLDEFAULT0,UNIQUEKEY uk_plan_user_no (plan_id, user_id, attempt_no),KEY idx_end_time (status, end_time));

uk_plan_user_no 管的是“同一计划同一用户同一轮次只能有一条作答记录”,别拿它去挡补考。开考幂等还要配合开考令牌和 Redis 锁,后文会讲。

三、组卷策略

组卷是整个系统技术含量最高的一环,本质是一个带约束的组合抽样问题:给定题型配比、知识点分布、难度分布、总分约束,从题库里抽出一组不重复且满足约束的题目。

3.1 规则模型

把组卷策略抽象成规则列表,别写死在代码里:

public record RuleItem(    Long bankId,    Long kpId,           // 知识点,null 表示不限    Integer type,        // 题型,null 表示不限    BigDecimal difficultyMin, // 难度区间    BigDecimal difficultyMax,    Integer count,       // 题量    BigDecimal score     // 每题分值){}

一张试卷就是 List<RuleItem> 加总分校验,管理员在后台配置即可,不需要发版。

3.2 抽题算法

ORDER BY RAND() 尽量别用,百万级题库上会触发全表扫描和临时表排序,是典型的慢 SQL 重灾区。实战里一般两种做法:

一种是 ID 区间抽样。先 SELECT MIN(id), MAX(id) 拿到区间,随机取点后用 id >= ? LIMIT 1 定位,重复到满足数量;失败再退化到另一种。它适合 ID 连续、删除不多的题库。

另一种是按桶预分组加内存洗牌。先按 (type, difficulty 分档, kp) 做维度聚合,每组取 ID 集合缓存进 Redis,题库变动时异步刷新。抽题时从缓存 List 上洗牌后截取前 N 个,性能是微秒级。题库通常也就十万量级,ID 集合完全放得进内存,所以我一般优先用这种,ID 区间抽样作为兜底或超大题库的补充。

3.3 装配流程

@Service@RequiredArgsConstructorpublicclassPaperAssembler{privatefinal QuestionPoolCache poolCache;   // Redis 分桶缓存privatefinal PaperQuestionMapper mapper;public List<PaperQuestion> assemble(Long paperId, List<RuleItem> rules){// 规则按可用题量升序排列,稀缺的规则先满足,降低后期冲突概率        List<RuleItem> sorted = rules.stream()            .sorted(Comparator.comparingInt(poolCache::sizeOf))            .toList();        Set<Long> picked = new HashSet<>();        List<PaperQuestion> result = new ArrayList<>();int seq = 1;for (RuleItem rule : sorted) {            List<Long> pool = poolCache.candidates(rule);// 过滤已选中的题目,避免跨规则重复            List<Long> available = pool.stream().filter(id -> !picked.contains(id)).toList();if (available.size() < rule.count()) {thrownew BizException("规则 " + rule.kpId() + " 题量不足,需 "                    + rule.count() + ",现有 " + available.size());            }// 洗牌 + 截取,保证随机性            List<Long> shuffled = new ArrayList<>(available);            Collections.shuffle(shuffled, ThreadLocalRandom.current());for (int i = 0; i < rule.count(); i++) {                Long qid = shuffled.get(i);                picked.add(qid);                result.add(new PaperQuestion(paperId, qid, seq++, rule.score()));            }        }return result;    }}

工程上还要补几个环节。约束过紧时要允许降级,比如难度区间放宽一档、知识点放宽到父节点,最多重试 3 次,仍失败就提示管理员调整规则。抽完题后统计整卷平均难度,偏离目标区间就做局部交换,拿低难度题换高难度题,别让整卷偏难或偏易。最后统一走组卷校验器,校验总分、题量、题型配比、知识点覆盖率和重复题占比,不通过就拒绝发布。

3.4 难度系数的动态校准

人工设置的难度只是初始值。系统要定期用真实数据回算:

难度系数 = 1 - (该题平均得分 / 该题满分)

样本量不足,比如答题人数少于 30,就保留原值;样本够了按 0.7 × 旧值 + 0.3 × 回算值 平滑更新,避免剧烈波动影响既有组卷规则。同一时刻还应输出区分度,也就是高分组得分率与低分组得分率之差,区分度低于 0.2 的题目提示淘汰。

3.5 人工组卷

人工组卷不是简单拖拽。至少要做三件事:实时查重,同一知识点已选数量要提示;约束实时校验,总分和配比用仪表盘展示;AI 推荐辅助,按当前已选题目推荐相似难度、相近知识点的题目,用向量检索就能做。人工组卷结果同样要走校验器,确保和自动组卷产出的试卷结构一致。

四、考试过程

4.1 开考校验

开考接口是并发最高的入口之一,校验顺序要“先廉价后昂贵”:

  1. 计划状态与时间窗,用本地缓存加定时刷新;
  2. 报名资格、剩余次数,一次联合查询解决;
  3. 是否已开考,用唯一键加开考令牌做幂等;
  4. 设备与会话互斥,Redis 记录当前活跃会话;
  5. 人脸核验,证书考试才做,异步执行,不阻塞发卷。

第 4 步不是简单 SETNX 就完了。我的做法是 Redis 存当前活跃会话 attempt:active:{userId},后开考用 Lua 比较并覆盖,同时给旧会话发下线事件;如果只做 SETNX,后来的人进不来,也踢不掉前一个。旧会话下线事件要写进 attempt_event,后面风险分要用。

4.2 题目乱序

乱序的目的是防止邻座抄袭。给每个 attempt 生成 displaySeq → questionId 的映射,存进 Redis 的 attempt:order:{attemptId},前端按 displaySeq 展示,答案按 questionId 提交。选项乱序同理,在题干快照中保存一个 optionOrder 数组,前端渲染时按数组重排,提交时用原始选项值。记住,乱序只影响展示,不影响判卷,判卷始终基于 questionId。

4.3 答题缓存

答题写入极其频繁,不能每题实时落库。采用“Redis 主写 + 定时落盘”:

// key: exam:answer:{attemptId}, field: questionId, value: {"v":"A,B","ts":1730000000}publicvoidsaveAnswer(Long attemptId, Long questionId, String answer){    String key = "exam:answer:" + attemptId;    redis.opsForHash().put(key, questionId.toString(),        JSON.toJSONString(new AnswerPayload(answer, System.currentTimeMillis())));    redis.expire(key, Duration.ofHours(6));// 标记为脏,由定时任务批量落库    redis.opsForSet().add("exam:dirty:" + attemptId, questionId.toString());}

落盘任务每 10 秒扫描一次活跃 attempt,批量 upsert 到 attempt_answer;交卷时强制全量落盘并校验。Redis 不可用时降级为直接写库,这时候要靠接口层限流把数据库保护住。

4.4 倒计时同步

倒计时必须以服务端为准。发卷时返回 endTime(服务端时间戳)和 serverTime,前端计算 offset = serverTime - 本地时间,用 offset 修正本地显示;同时每 30 秒心跳一次,重新校准 offset 并返回服务端剩余秒数。心跳用 WebSocket 长连接也行,普通轮询也行,别把倒计时信任交给前端。交卷时服务端以自己的 endTime 判断是否超时,前端改时间没有意义。

4.5 自动交卷

到期未交卷的必须由服务端兜底。常见两种实现:

Redis ZSet 扫描,exam:deadline 按 endTime 做 score,定时任务每秒 ZRANGEBYSCORE 0 now LIMIT 0 500 取出到期 attempt 投递到 MQ。取到后要 ZREM 或 Lua 原子取删,避免重复投递;消费端仍然按 attemptId 幂等。缺点是扫描频率和数据量增长有关,需要分片。

RocketMQ 延时消息,开考时发一条延时消息,延时等于考试时长。注意版本差异:4.x 只有固定延时等级,改 broker 配置也不适合当常规方案;5.x 的定时消息可以做到任意延时。别把 4.x 的限制当成所有版本的限制。实际项目里我更常用 ZSet 加分片扫描,简单可控,延长考试时间时直接改 score 就行。

五、防作弊设计

防作弊的核心思想是:前端手段只能提高作弊成本,服务端校验与行为留痕才是兜底。

常见的几类手段,我一般分三层处理。切屏搜答案靠 visibilitychange 和 blur 事件埋点,累计次数超阈值就告警或强制交卷;他人代考靠考前人脸核验加考中随机抓拍,抓拍图入对象存储,人工抽查;多设备作答靠设备指纹加会话互斥,后开的踢掉前一个并记录事件;邻座抄袭靠题目乱序、选项乱序和正文水印,从源头降低成功率;复制粘贴题目监听 copy、paste 和右键,记录事件;脚本刷题看答题间隔和轨迹异常,进风险分模型,低风险观察、高风险人工复核;交卷后篡改靠不可逆状态机和审计日志,attempt 状态只能按状态机 CAS 往前走。

5.1 设备指纹与服务端校验

前端采集 Canvas/WebGL 指纹、UA、屏幕尺寸、时区,做 SHA-256 得到设备 ID,开考时与用户绑定。同一设备 ID 在短时间内登录多个账号参加同一场考试,直接标记为高风险。设备指纹不是银弹,浏览器环境会变,但作为辅助信号够用了。

5.2 摄像头抓拍的工程细节

抓拍不能同步阻塞答题。前端每 60 秒用 getUserMedia 加 Canvas.drawImage 截一帧,压缩为 JPEG,质量 0.6,宽度 320px,先向服务端申请对象存储直传凭证,传到 MinIO/OSS 后再调接口记录元数据:

@PostMapping("/snapshot")public R<Void> snapshot(@RequestParam Long attemptId,                        @RequestParam String objectKey){// 文件走对象存储直传,这里只记录元数据,避免大文件打穿应用    eventProducer.send(new AttemptEvent(attemptId, EventType.SNAPSHOT,        Map.of("objectKey", objectKey, "ts", System.currentTimeMillis())));return R.ok();}

抓拍图必须脱敏保存、设定保留期限,比如 90 天自动清理,并在考前协议中明确告知用户,这是合规底线。

5.3 行为日志与风险分

所有行为事件,切屏、抓拍、粘贴、心跳丢失、答案高频变更,统一投递到 MQ,批量落 attempt_event。判卷前置一个风险分模型:

riskScore = 切屏次数 × 5 + 抓拍异常 × 20 + 粘贴次数 × 8 + 答题间隔异常 × 15

riskScore > 60 的答卷自动打上“待复核”标签,进入人工审核队列。这样既不会误伤正常考生,也能把有限的监考人力用在最可疑的卷子上。异常事件除了落库,也可以推给监考端 WebSocket,让监考老师当场看到。

六、自动判卷

6.1 判卷策略的可扩展设计

用策略模式按题型分发,新增题型只需要实现接口并注册:

publicinterfaceGradingStrategy{inttype();GradeResult grade(QuestionSnapshot q, String answer);}@ComponentpublicclassMultiChoiceStrategyimplementsGradingStrategy{publicinttype(){ return2; }public GradeResult grade(QuestionSnapshot q, String answer){        Set<String> correct = q.answerSet();        Set<String> actual = splitAndNormalize(answer);if (actual.isEmpty()) return GradeResult.zero("未作答");// 错选不得分if (!correct.containsAll(actual)) return GradeResult.zero("含错误选项");if (actual.equals(correct)) return GradeResult.full(q.score());// 漏选给半分(可配置)return GradeResult.partial(q.score().multiply(new BigDecimal("0.5")), "漏选");    }}

判卷一定用 paper_question 里的快照答案,不要回查题库当前答案,否则题库一改,历史卷子全乱。

6.2 各题型规则要点

单选和判断做精确匹配,答案先 trim 再大小写归一。多选全对满分、漏选部分分、错选零分,规则值做成配置。填空题多空独立计分,每空答案做归一化,去空格、全半角转换、去标点、英文小写,支持一空多标准答案,["Java","JAVA","java"] 视为等价;数值类题目用容差比较,比如 ±0.01。简答和论述不直接给最终分,而是给出建议分和依据,交由人工确认。

6.3 主观题的辅助评分

主观题纯人工成本高,可以用“机器辅助 + 人工确认”两步走。先做关键词命中,预置采分点,比如“时间复杂度 O(nlogn)”占 2 分,命中即累加建议分;再做相似度参考,与参考答案做分词后的 Jaccard 相似度或向量余弦相似度,给出区间参考;最后人工评卷,评卷人看到建议分、命中采分点高亮和参考答案,一键确认或修改。

证书类考试建议启用双评机制:两位评卷人独立打分,分差超过阈值,比如满分的 10%,进入第三人仲裁,取仲裁结果。

6.4 判卷流程与发布

交卷 → 状态 SUBMITTED → 客观题即时判分(毫秒级)                    → 主观题入待评队列(MQ)                    → 人工评卷完成                    → 状态 GRADED → 成绩校验(总分=明细之和)                    → 成绩发布(受计划配置控制,可批量延迟发布)

成绩发布前要做一次一致性校验:exam_result.score == SUM(attempt_answer.score),不一致就告警并阻止发布。判卷不可逆,任何重算都通过“新增判卷版本”实现,保留历史。

七、高并发与可靠性

7.1 开考峰值

千人同时开考,压力集中在发卷接口。考试开始前 10 分钟,把该场次的试卷题目批量写入 Redis 的 paper:snapshot:{paperId},发卷时只读 Redis,不碰数据库;注意答案字段留在服务端,发卷响应只给展示字段。网关层按用户维度做令牌桶限流,防止脚本抢跑。开考时间窗如果业务允许,可以给 ±5 分钟弹性进入,削平瞬时尖峰。发卷接口也别返回全量题干,只回题目 ID 和元信息,题干通过内部 CDN/OSS 签名地址或分批拉取,别把带宽和数据库一起打满。

7.2 交卷队列

交卷时不要做判卷和统计,接口只做三件事:落盘答案、改状态、投递消息,200ms 内返回。

@Transactionalpublicvoidsubmit(Long attemptId, Long userId){// CAS 状态流转,天然幂等:只有 IN_PROGRESS 才能转 SUBMITTEDint updated = attemptMapper.casUpdateStatus(        attemptId, userId, AttemptStatus.IN_PROGRESS, AttemptStatus.SUBMITTED);if (updated == 0) return;  // 已提交过,直接返回成功    answerService.flushToDb(attemptId);// 别在事务里直接 send,消息可能比事务提交先到;提交后再投递    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {@OverridepublicvoidafterCommit(){            mqProducer.send("exam-grade-topic",new GradeMessage(attemptId, System.currentTimeMillis()));        }    });}

生产上如果对消息可靠性要求高,再套本地消息表或 RocketMQ 事务消息。判卷消费端按 attemptId 幂等处理,重复消息直接跳过。

7.3 幂等提交

幂等至少三道保险。第一道是状态机 CAS,UPDATE ... WHERE status = 1,影响行数为 0 说明已提交。第二道是唯一键,uk_plan_user_no 防同一轮次重复开考,补考通过 attempt_no 递增。第三道是幂等表,前端提交携带 submitToken,开考时下发,服务端 Redis SETNX 校验,防重放。

7.4 断线恢复

考生断网是最常见的线上事故。前端答案写 IndexedDB,同时维护“未确认队列”;网络恢复后按 questionId 逐条补交。冲突别信客户端时间戳,服务端以 attempt_answer 的版本号或最后落库记录为准,客户端只负责把本地没同步上去的答案补上来。Redis 里答案实时写 Hash,TTL 设为考试时长加 2 小时;MySQL 10 秒批量落盘,最坏丢 10 秒数据。恢复流程是:重新进入考试,校验 attempt 状态仍为 IN_PROGRESS,拉取服务端答案与剩余时间,前端合并本地未同步答案,然后继续作答。

八、成绩分析与收尾

8.1 统计报表

成绩数据量大,实时聚合扛不住,走离线预计算加在线查询。考试结束后由 MQ 触发聚合任务,产出分数段分布、平均分、中位数、标准差、题目得分率、知识点掌握度、区分度排名。结果写入 exam_stat 宽表,数据量再大就上 ClickHouse,前端查询直接读结果表。排名用 Redis ZSet 维护,支持实时查看和分段刷新。

8.2 错题分析

对每个考生维护错题本:attempt_answer 中 score < full_score 的题目按知识点聚合,输出“薄弱知识点 Top5”。练习测评场景下,错题本可以直接作为下一轮组卷的题源,形成学习闭环。

8.3 导出能力

导出万级成绩单时必须流式处理,禁止 selectList 一次性加载:

publicvoidexport(Long planId, OutputStream out){try (ExcelWriter writer = EasyExcel.write(out, ScoreRow.class).build()) {        WriteSheet sheet = EasyExcel.writerSheet("成绩").build();int pageNo = 1;while (true) {            List<ScoreRow> rows = resultMapper.page(planId, (pageNo - 1) * 2000, 2000);if (rows.isEmpty()) break;            writer.write(rows, sheet);            pageNo++;        }    }}

大文件导出做成异步任务:提交、生成、上传 OSS、返回带时效签名的下载链接,避免长连接超时。

8.4 数据安全

导出与列表接口中手机号、身份证按规则脱敏,仅授权角色可看原文。敏感字段 AES-GCM 落库,密钥走 KMS。数据权限按学校、班级、场次隔离,评卷人只能看到分配给自己的题块。查成绩、改分数、导出全部写审计日志,谁在什么时间改了什么,要能追溯到人。成绩发布前对考生完全不可见,发布操作需二次确认并记录。

8.5 最后几句

这套东西真要上线,最值钱的不是某个算法,而是几条底线:试卷和答案做快照,别让题库修订污染历史;attempt 用状态机 CAS 推着走,幂等、防重、断线恢复都能落在一个地方;交卷口只干轻活,判卷、统计、导出统统异步;组卷规则、判分规则、风险阈值别写死。前端那些防作弊手段有用,但别把它们当安全边界,服务端留痕和校验才是。

后面如果继续演进,组卷可以上约束求解器做等值卷,主观题可以拿大模型给建议分但人工终审得留着;行为日志也别只做事后复核,接到实时风控里,边考边预警才有意义。

🎁 福利时间

如果你正在备战大厂面试,我整理了一个 开发者的知识库 涵盖 Java 程序员需要掌握的核心知识。

知识库地址:https://farerboy.com/


架构设计之道在于在不同的场景采用合适的架构设计,架构设计没有完美,只有合适。

在代码的路上,我们一起砥砺前行。用代码改变世界!

感谢观看,如果觉得对您有用,还请动动您那发财的手指头,点赞、转发、在看、收藏

更多精彩合集请关注公众号🔽🔽🔽🔽🔽🔽🔽🔽

欢迎学习或从事编程开发、技术招聘 HR 进群,欢迎大家分享自己公司的内推信息,相互帮助,一起进步!

工作 3 年还在写 CRUD?简历投递杳无音讯?面试屡屡受挫,迟迟拿不到 offer?

在竞争激烈的大环境下,只有不断提升核心竞争力才能立于不败之地。

扫码留言【我要晋级】一对一指导,带你晋级。

—— 斩获心仪Offer,破解面试密码 ——

广告人士勿入,切勿轻信私聊,防止被骗

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料