ARTICLE · 1337691
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 开考校验
开考接口是并发最高的入口之一,校验顺序要“先廉价后昂贵”:
计划状态与时间窗,用本地缓存加定时刷新; 报名资格、剩余次数,一次联合查询解决; 是否已开考,用唯一键加开考令牌做幂等; 设备与会话互斥,Redis 记录当前活跃会话; 人脸核验,证书考试才做,异步执行,不阻塞发卷。
第 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 + 答题间隔异常 × 15riskScore > 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?
在竞争激烈的大环境下,只有不断提升核心竞争力才能立于不败之地。
扫码留言【我要晋级】一对一指导,带你晋级。

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















































































