1. 为什么要把组卷这件事单独做成一个系统先说个我自己的体会。接触过不少教学管理类的业务最容易被低估的就是组卷需求。你以为是简单的从题库里随机抽几道题但真正用起来才知道老师要的不只是随机而是可控的随机题目难度要符合正态分布知识点要覆盖最近讲过的几个章节题型配比要固定总分必须在100或者150不能连着两次考试出现同一道题更不能抽出来一道题跟当前教材版本对不上。这套约束叠加起来靠手工在Excel里翻题库基本不现实靠纯SQL拼条件也没法长期维护。所以我做了这个基于Spring Boot的Web版数学题库组卷系统核心就是把组卷规则从人脑里搬到代码里试题维护、知识点树管理、自动组卷、在线考试、自动判分、成绩导出全都串成一条线。这篇文章适合谁看两类人。第一类是要做毕设、做课程项目或者给学校/培训机构做内部系统的开发者可以参考完整的表设计和组卷算法思路第二类是在做教学信息化产品、想了解组卷引擎怎么实现的工程师可以直接跳到第四章的约束求解逻辑那部分是整个系统的灵魂。系统本身不复杂真正有价值的是建模方式和算法取舍。我把整个开发过程里最关键的部分拆开讲包括为什么用Spring Boot、数据库表怎么设计的、组卷算法怎么写才不会出现抽不出题的尴尬以及上线后遇到的两个比较隐蔽的坑。2. 技术选型与工程结构Spring Boot为骨架MySQL为底座2.1 为什么选Spring Boot而不是更重的方案我见过不少同类项目上来就用微服务、ES、消息队列结果一个组卷系统部署起来要四个节点运维成本比开发成本还高。对于题库组卷这种场景数据量级顶多是几十万道题、几千个用户并发考试Spring Boot单应用加上MySQL配合Redis做缓存和分布式锁已经绰绰有余。Spring Boot在这个项目里的优势很实在自动配置大幅度减少了样板代码集成MyBatis Plus、Spring Security、Validation只需要几行配置内嵌Tomcat打完jar包就能跑部署到学校机房或者云服务器都方便生态成熟遇到问题社区资料多就算让基础一般的同学接手也能很快上手分层开发非常清晰Controller、Service、Mapper各管一摊代码容易维护。我没有引入太重的权限框架Spring Security配合JWT做登录鉴权就够。前端用的是Thymeleaf加一套现成的后台管理模板考试页面单独做了移动端适配。这里有个实际考虑组卷系统的主要使用场景是机房和手机页面太重反而影响体验。2.2 后端模块划分与目录结构项目按功能域划分包结构而不是按技术层划分。这样做的最大好处是新增一个功能模块时不需要同时改四五个包com.example.mathbank ├── config // 全局配置跨域、拦截器、Redis配置 ├── controller // 接口层 │ ├── admin // 后台管理接口题库维护、用户管理 │ ├── paper // 组卷相关接口 │ └── exam // 考试与答题接口 ├── service // 业务层 │ ├── question │ ├── paper │ └── exam ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 入参出参对象 ├── common // 统一返回结果、异常处理、枚举 └── utils // 工具类随机算法、公式处理、导出2.3 辅助组件与Redis的真实角色Redis在这个系统里承担了三件事缓存登录态、缓存热点题目、给在线考试做临时答卷存储。第三个功能最容易忽略——考生做答的过程中如果每点一次保存都写MySQL高并发下数据库压力不小。我让答卷先存Redis交卷时再一次性落库这样写库次数从几十次降为一次。如果你部署的环境没有Redis也可以用ConcurrentHashMap做个本机缓存应付低并发场景但一旦有多台服务器就必须用Redis的统一缓存否则用户请求分散到不同节点会出现登录态丢失这些莫名问题。3. 数学题库的表结构设计一个坑一个坑趟出来的经验3.1 知识点树与题库的关联设计数学题最大的特点是强章节关联性。一道函数题可能同时涉及函数定义域和不等式求解两个知识点如果不支持多知识点关联组卷时按知识点查题就会漏。我设计了三级结构科目表数学、物理等知识点表用parent_id支持无限层级题目表和知识点通过中间表tb_question_kp关联一个题目可以挂多个知识点。组卷时选择某个知识点默认会把它下面所有子知识点的题都纳入候选池这是组卷体验的关键。一开始我犯过错误只按选中的知识点本身查题结果老师选了三角函数就一眼只能出那几道直接归属的题把子节点漏光了。后来改成递归查子知识点效果立刻不一样。3.2 题目主表的字段取舍字段类型说明idbigint主键subject_codevarchar科目编码question_typetinyint1单选 2多选 3填空 4解答difficultydecimal(3,2)难度系数0.1~1.0contenttext题干数学公式用LaTeX存储optionstext选项存JSON数组answertext答案填空/解答题存文本analysistext解析sourcevarchar题目来源自编/教材/教辅statustinyint0草稿 1已审核 2已停用used_countint累计使用次数题目表字段看上去简单有两个细节值得你特别注意。第一个是公式存储。数学题里大量公式我的做法是题干和选项统一用LaTeX格式存前端选MathJax渲染。这样既不会像图片那样无法检索又能保证排版质量。图片题则用单独的资源路径字段存URL不直接存二进制。第二个是审核状态。题库系统必须保留草稿和已审核两个状态。组卷时只允许抽取已审核的题否则录入员随便存一道乱码题都可能被抽到试卷里。这个设计在项目初期确实增加了一点录入工作量但线上之后省下的解释成本非常多。3.3 试卷与题目之间的快照关系这是整个表结构设计中最重要的一节。题库里的题目内容理论上可以被管理员修改但已经生成的试卷不允许跟着变。比如上周生成的期中试卷里有一道函数题这周管理员发现题目解析有误就改了数据库那这周学生打开上周的卷子时题干解析全变了就没法交代。解决方案是生成试卷时把题目内容直接冗余一份到试卷题目明细表tb_paper_question中。换句话说tb_paper_question不仅存paper_id和question_id还把题干、选项、答案、解析全部复制一份。以后除非人工重新组卷试卷内容永远不变。表名核心作用tb_subject科目tb_knowledge_point知识点树tb_question题库主体tb_question_kp题目-知识点关联tb_paper试卷头总分、时限、平均难度tb_paper_question试卷题目快照tb_exam考试批次发布记录tb_exam_record学生答题明细与得分tb_user系统用户教师/学生/管理员4. 自动组卷核心算法把抽题变成解约束方程4.1 组卷参数到底有哪些约束自动组卷本质是个带约束的随机抽样问题。一次组卷请求传入的约束条件有这五类题目数量与题型配比如单选题10道、多选题5道、填空题5道、解答题2道知识点范围选择若干知识节点抽题时只从这些节点及其子节点选题难度分布按比例设定容易:中等:困难比如6:3:1总分限制题目分数之和必须等于设定总分排除条件排除近期考试已经用过的题目。这五类约束放在一起不能简单写一句SELECT ... ORDER BY RAND()就完事——很可能抽完单选再抽多选时题目不够了或者难度分布对不上了。4.2 分题型分批抽题的设计思路我的组卷引擎核心逻辑分为三个阶段分组、配额、抽样回溯。分组阶段把候选题目按 schoolexam 分组——其实更准确说按题型难度档切分。比如把单选拆成单选-容易、单选-中等、单选-困难三个桶。然后根据难度比例把每种题型的配额分配到各个桶里。例如要求10道单选题容易:中等:困难 6:3:1那么分别抽6、3、1道。注意这里不能直接拿10乘以比例然后四舍五入因为可能出现加起来不到10或者超过10的情况。我用的是最少余数法先按比例下取整剩余名额依次分配给余数最大的桶保证总数正好。这个阶段完成后系统手里有了一张抽题任务清单[ {type: 单选, difficultyLevel: easy, kpIds: [...], count: 6}, {type: 单选, difficultyLevel: medium, kpIds: [...], count: 3}, {type: 单选, difficultyLevel: hard, kpIds: [...], count: 1} ]然后进入抽样阶段对每个任务执行一次带排除条件的随机查询。4.3 排除题目的实现与题池耗尽处理抽题时最麻烦的就是排除近期用过的题。我在查询里拼了一个NOT IN子句里面放的是该科目最近N次已参与试卷的题目ID集合。一般排除最近三次考试的量就够了。SELECT q.id, q.content, q.options, q.answer, q.analysis, q.difficulty FROM tb_question q WHERE q.subject_code #{subjectCode} AND q.question_type #{questionType} AND q.status 1 AND q.difficulty BETWEEN #{minDiff} AND #{maxDiff} AND EXISTS ( SELECT 1 FROM tb_question_kp qk WHERE qk.question_id q.id AND qk.kp_id IN foreach collectionkpIds itemkpId open( separator, close) #{kpId} /foreach ) AND q.id NOT IN ( SELECT pq.question_id FROM tb_paper_question pq WHERE pq.question_id IS NOT NULL AND pq.create_time #{excludeStartTime} ) ORDER BY RAND() LIMIT #{count}但是这里埋了一个雷如果某个知识点下题目总量很少同时又要排除最近用过的很可能抽不满。比如一道题附带了3个子知识点排除近期出现过的那一批后可能只剩0个。遇到这种情况怎么办不能直接报错说题目不够用户体验太糟糕。我实现了一套约束松弛机制按顺序放宽条件先放宽排除条件把NOT IN范围缩小一半再放宽难度区间把上下限各扩大0.1最后才放宽知识点范围把子知识点扩展到父节点。每一步都记录实际命中数量如果最终还差几道就从不强制排除的题里补。系统返回的试卷里会附带一个实际命中率指标让老师一眼看到这次组卷有几道题是降级匹配的。这个设计投入产出比非常高避免了组卷引擎变成一个动不动就失败的脆弱工具。4.4 避免同卷重复题与全局公平性随机抽样天然会遇到同一道题被抽中两次的问题。虽然按分组后每组抽不同题型理论上不会互相冲突但多选和解答题如果题干完全一样就会重复。我在每组抽完后维护一个Set保存已选题目ID下一组抽题时把Set里的ID拼进NOT IN确保一大张卷子里不会出现两道一样的题。还有一个全局考虑如果每张新的试卷都使用ORDER BY RAND()纯粹的随机排序热门知识点里的低使用次数题目会被更快消耗冷门知识点里的题可能一直不被抽到。我给每道题维护了一个used_count在抽题SQL排序时除了RAND()之外还会乘以一个基于used_count的权重系数让使用次数少的题有更高概率被优先抽取。实际验证下来这能明显延长整个题库的生命周期老师不会觉得翻来覆去就是那几道题。5. 关键流程落地自动组卷、在线考试与自动判分5.1 组卷Service的完整调用链前端发起一个自动组卷请求后后端的调用链路是这样的PostMapping(/paper/auto) public ResultPaperVO autoGenerate(RequestBody Valid PaperRequirementDTO dto) { Long teacherId SecurityUtils.getCurrentUserId(); PaperVO paper paperService.autoGenerate(teacherId, dto); return Result.ok(paper); }paperService.autoGenerate内部先做参数预校验比如总分是否等于各题分值之和题型数量是否合理知识点是否为空。预校验通过后调用composeService.execute执行上面说的约束拆解、配额计算、分桶抽样、快照写入。需要特别强调一个事务边界问题。autoGenerate方法必须加Transactional因为整个组卷过程涉及四张表的写入tb_paper、tb_paper_question、tb_question.used_count的更新、tb_user_paper的关联记录。任何一个环节失败都要整体回滚不能出现试卷头和试卷明细对不上的情况。但在实际并发场景中我碰到了一个分布式事务的尴尬问题两个老师同时点自动组卷恰好抽中了同一道题都去更新tb_question的used_count容易产生锁等待。我的处理方案是used_count的更新不放在组卷事务里改成一个异步的、允许失败的计数更新用一个本地消息表记录定时任务去补偿。核心的试卷生成过程只锁住tb_paper的插入这样并发压力小很多。5.2 在线考试模块怎么处理答题暂存学生打开试卷后选择答案提交最直接的想法是每点一次保存就INSERT一条记录。但这样数据库访问频率太高而且如果学生中途断网数据库里可能有很多半截记录需要清理。我做的事是学生端每道题的作答结果实时存入Rediskey为exam:user:{userId}:{examId}value为一个JSON数组记录每道题的选项或文本答案交卷时把Redis里的数据一次性读取出来写入tb_exam_record同时清理缓存如果学生没点交卷就关掉了页面下次打开时先从Redis恢复未提交答案。对学生来说体验就像写在线文档一样随时保存不丢数据。对系统来说MySQL的写入量大大减少考试高峰期的数据库压力明显下降。5.3 自动判分选择题直判主观题半自动自动判分我们分了两个层次选择题单选/多选和填空题的准确比较很简单选项ID和答案字符串完全匹配即可。填空题的难点在于答案可能有多个写法比如π/2和1.5708。我的处理方式是录入试题时给填空题答案配置一个可接受答案集合JSON数组存储判分时挨个比较。有些空格还会涉及近似值比如答案在0.499到0.501之间可判对这就需要在题目表加一个tolerance字段存容差范围。实际用下来老师对这套可配置规则满意度很高。解答题没有完全自动化我的做法是把试卷切分成自动判分题和人工判分题两类。提交答卷后系统自动判完选择填空然后把解答题汇总到待批改列表里老师可以按题号批改、填分数系统再自动累加总分。如果硬要做OCR加语义判分投入产出比太低在两三周的项目周期里不划算。5.4 成绩统计与导出考试结束后生成了所有成绩还需要给老师一个简单好用的统计页面平均分、最高分、最低分、分数段分布、各题正确率。我实现这些统计时没有额外建报表表考试结束后直接写一个定时任务从tb_exam_record聚合数据写入tb_exam_stat表中。查询接口只读统计表响应速度在毫秒级。导出功能走了两个方向成绩单发Excel试卷内容发PDF。Excel用EasyExcel导出班级成绩和前几名名单很方便。PDF这块要考虑公式渲染我用的方案是后端把LaTeX转成MathML再套用模板渲染成PDF。如果你不需要复杂的公式排版直接用html2pdf把前端页面转成PDF也行速度更快实现成本低很多。6 上线之后踩的两个坑排序性能与随机种子问题6.1 题库量大了之后ORDER BY RAND()的灾难开发阶段题库只有几百道题完全没感觉。等真正录入了一两万道题自动组卷开始变慢最慢能达到十几秒。定位的时候我先看SQL执行计划发现问题出在ORDER BY RAND()——它需要对全表所有符合条件的数据生成随机数再排序数据量一大开销就上来了。优化方案不是去掉RAND()而是缩小RAND()的作用范围。我先单独统计每个题型难度知识点分桶里的题目数量然后从中算出需要的随机offset定位一批ID再根据ID去查询完整数据。// 伪代码示意先随机取ID再回表查详情 ListLong ids questionMapper.selectRandomIdsByBucket(bucket, count); ListQuestion questions questionMapper.selectByIds(ids);selectRandomIdsByBucket里采用WHERE id (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM tb_question))) LIMIT #{count}这种偏移方式。它比ORDER BY RAND()快得多适合ID分布相对均匀的场景。如果发现ID分布明显空洞也可以先用MIN(id)和MAX(id)缩小范围再过滤符合条件的数据。这个优化让组卷时间从十几秒降到了1秒左右。6.2 随机数重复同一分钟生成的卷子一模一样上线一段时间后有老师反馈同一分钟点两次自动组卷出来的卷子怎么一模一样这个原因很典型Java的Random类默认种子是当前系统时间的纳秒级数值但MySQL的RAND()如果在一个SQL执行周期里被调用多次结果可能因为查询重写被复用。所以纯粹依赖数据库层随机数不能保证多次请求的随机性。我最后用了一个双保险思路组卷前生成一个用户维度的随机种子字符串拼到SQL注释中比如/* seed:abc123 */ SELECT ...MySQL在解析SQL时会将不同的注释作为不同SQL缓存项进而产生不同的执行计划路径在应用层再做一轮随机打散每个桶内先用RAND()取偏序但最终排序交给Java的Collections.shuffle来打乱。这两层配合后同一秒内多次组卷的结果差异性稳定了很多老师再也没有抱怨过怎么都是这几道题。6.3 考试高峰期Redis和一个隐藏的MySQL大坑某次模拟考试高峰期几十个学生同时交卷系统突然出现大量连接池耗尽异常。排查下来问题出在交卷事务里先批量插入tb_exam_record然后更新考试状态再删Redis缓存最后统计成绩——整个事务链路里嵌了两次远程Redis调用和一次较重的聚合计算事务持有数据库连接的时间太长连接池自然就被打满了。修复方法很粗暴也有效把Redis清除和统计成绩从提交事务里移出去。Redis操作改成先更新数据库、事务提交成功后再删除缓存统计成绩改成MQ异步处理。这里我学到一条原则事务里尽量不要做远程调用无论是Redis、RPC还是外部HTTP接口都会显著拉长数据库事务的持有时间并发一大就会连锁崩溃。7 如果让我重新做一遍这些地方我会调整先说数据建模。首次设计时为了追求灵活我给题目表加了很多可空字段比如image_desc、source_ref、difficulty_level_name后来维护起来才发现冗余字段背后是维护成本。重新做的话我会坚持每个字段必须有一个当下的明确用途原则宁可在后续需求到来时再迁移扩展也不要一开始铺一大堆可能用不上的字段。然后是组卷引擎的扩展性。目前的约束求解是针对当前需求定制的如果以后要支持题型-知识点-难度三维交叉约束或者要支持按命题人比例抽题composeService里的代码会越长越难维护。如果再来一次我会把约束定义拆成策略模式每种约束是一个独立策略类通过注册中心管理。这样新增一种约束只需要加一个实现类不影响原有逻辑。另外一个容易被忽略的点是公式输入体验。目前题目录入用的是一个开源富文本编辑器可以打LaTeX但老师普遍不熟悉LaTeX语法录入效率很低。如果重做我会把录入界面做成所见即所得的公式编辑器键盘输入分式、根号直接可视化。这道关不解决题库的录入速度永远是瓶颈组卷算法再快也没弹药可用。最后还想分享一个项目管理层面的体会。组卷系统的需求方往往不是技术人员老师表达的需求是我要一份难度适中的卷子这种模糊描述。我建议开发时一定要设计一个组卷结果可视化反馈页面把本次组卷的知识点覆盖率、难度分布曲线、题目重复率都直观展示出来。这个页面做出来之后需求方和开发者的沟通成本直线下降因为大家终于可以对着具体指标说话了。如果有朋友要做类似的教学类项目不管技术栈是Spring Boot还是别的框架我都建议先花三分之一的时间把题库模型和组卷规则讨论清楚再动手写代码。业务逻辑理顺了后面就是体力活。