在线考试系统高并发实战:预生成组卷方案设计与落地 做在线考试系统最怕什么不是题库不够大而是开考那几分钟服务器直接被打穿。之前我参与维护过一个线上考试模块用的实时组卷方案平时功能测试一点问题没有到正式考试并发一起来组卷接口超时、考生进不去考场、后台报警刷屏。后来我把组卷逻辑彻底挪到了考试之前做成了一套考试预生成方案也就是CodeSpirit。这个项目解决的就是在线考试里“临场组卷”带来的性能风险和质量不确定性核心思路是提前把符合要求的试卷批量生成好、封存好开考的时候直接发放。文章适合从事在线教育、认证考试系统研发的同学或者说准备自研考试平台、但不想在并发上踩坑的团队参考。后面我会把从题库建模到试卷发放的完整设计思路、关键代码结构、实战排查经验都展开讲尽量让读者拿回去就能用。做预生成方案最难的不是写代码而是想清楚“生成出来的卷子到底能不能用”。这背后牵扯题库质量、组卷约束、试卷封存、并发发放一整套链路。下面我从设计思路讲起再逐步落到技术实现和运维细节。1. 什么是考试预生成为什么要做这件事1.1 实时组卷的三个显性痛点很多在线考试系统最初都采用实时组卷考生点击“进入考试”后端从题库里按题型抽题、拼卷、落库再把试卷返回给前端。这个逻辑在并发低的时候没什么感觉但正式考试恰恰是高并发场景所有考生在同一时刻涌入。我实测过一次3000人同时在线的考试开考瞬间组卷接口的P99延迟从200毫秒飙到17秒数据库连接池直接被打满大批考生卡在“正在生成试卷”的加载页面。这是第一个痛点性能冲击。第二个痛点是难度和知识点覆盖不可控。实时组卷如果只做简单随机出来的卷子可能同一考场内难度差异很大有的考生抽到一堆难题有的考生抽到一堆基础题对公平性影响非常直接。想加入复杂的约束条件、又要在几秒内算出结果实时环境很难吞得下这个计算量。第三个痛点是组卷失败的波及面会成片扩散。组卷依赖题库查询、随机数生成、数据库事务任何一个环节抖动都会让一个考场里几十个人进不去。考试时间不等人这种故障往往只能靠运维手工切方案场面非常狼狈。1.2 预生成方案的适用边界把出卷动作集中到开考前批量完成考试期间只做“查表发放”就是预生成方案。CodeSpirit正是围绕这个思路实现的。它适合的场景很典型期末考试、期中统考、认证考试、竞赛初赛这些场景的考试时间和试卷结构都提前确定完全可以预生成。不适合的场景是随堂测验、自适应考试或者那种需要根据考生前一题作答结果动态出下一题的考试这类需求不在预生成方案的目标范围内。所以预生成方案并不是比实时组卷更高级它只是用一个预先计算的过程把不确定性转移到可控时间窗口里。对稳定性要求高、对公平性敏感的考试场景这套方案就非常合适。反过来如果只是日常小测也没必要上这么重的设计。预生成是给“开考瞬间必须稳”的场合用的。2. 整体架构设计与关键决策2.1 核心模块怎么划分CodeSpirit整体拆成四个模块exam-core题库模型、组卷算法、状态机、快照生成等核心逻辑。exam-api对外提供REST接口比如题库管理、组卷任务发起、试卷封存、试卷发放。exam-worker异步执行预生成任务接收消息后跑组卷和校验流程把结果写入试卷池。exam-store试卷存储和发放的底层实现负责向Redis写卷子、落MySQL以及处理发放时的幂等逻辑。为什么要把worker独立出来而不是直接放在API进程里主要是为了隔离压力。预生成任务通常耗时从几十秒到几分钟不等如果放进API进程开考前的批处理任务会占掉大量线程资源影响其他考试接口的响应。异步化之后预生成任务就变成后台批处理对在线服务的影响降到了最低。2.2 预生成的任务闭环一份试卷从配置到考生可见要经过一条完整的状态链路DRAFT草稿→ GENERATED已生成→ VERIFIED校验通过→ SEALED已封存→ PUBLISHED已发布→ ARCHIVED已归档DRAFT阶段保存的是配置信息比如考试名称、时间、题型分配、难度曲线。发起预生成后worker根据参数组装试卷状态进入GENERATED。接着自动跑校验题目总数对不对、各题型数量是否匹配、难度均值是否落在区间内、分数总和是否等于100、会不会和已封存的卷子高度雷同。校验通过后进入VERIFIED再封存成不可修改的版本。到了开考时间点状态自动切到PUBLISHED前端才能拉到题目。考试结束后试卷转ARCHIVED用于后续成绩复核和试卷分析。这套状态机的核心价值在于“封存”这个动作。封存之后任何人改不了试卷内容题目被锁得非常死。如果有人想偷改试卷或者提前泄露答案必须先经过管理员解封权限整个过程在审计上完全可追踪。2.3 技术选型为什么落在这套组合上选型上后端用Spring Boot为主考试系统需要成熟的MVC生态、事务管理、消息机制这套Java技术栈最稳。存储层用MySQL存题库、考试配置、试卷快照。题库本身是结构化数据题目类型、难度、知识点、答案都是强字段用关系型数据库管理最清晰。Redis用来做发放时的试卷池和会话级缓存。发放本身就是高并发读Redis的LPOP、SETNX天然适合做原子取号试卷快照整体缓存在Redis里避免发放时再回MySQL查JSON性能差距非常明显。当初我纠结过要不要把试卷快照直接用文档型数据库存储后来还是选了MySQL的JSON字段。因为需要和题库、考试配置做事务关联把快照放在同一个库里能保证生成试卷时的事务一致性。这里的题目快照不只要存题目原文和选项还要存这道题当时的难度、知识点编码。把这些内容都固定下来后续统计试卷信度、区分度才有原始依据也避免题库内容被修改后影响历史考卷。这里有一个非常容易忽略的点试卷快照从一开始就应该“内容全量”不能只存题目ID。如果只存ID题库里某道题后来被改过内容历史考卷呈现出来的就变成新题了。成绩复核时发现问题谁都没法说清楚当时的题目到底长什么样。3. 题库建模与组卷算法核心细节3.1 题目元数据到底要建哪些字段组卷算法做得好不好一半取决于约束条件设计另一半取决于题库数据的质量。一道题至少需要包含这几类元数据题型单选、多选、判断、填空、简答。组卷时按题型拿题不能混着出。难度值五档离散值或连续值。建议存0到1的浮点数如0.2、0.4、0.6、0.8、1.0。难度可以结合历史作答正确率自动校准而不是人工拍脑袋填写。知识点编码两级标签就够用一级为大类二级为具体章节比如“数据结构→二叉树”。按知识点约束出题能保证卷面覆盖整个教学大纲。预估作答时长组卷时加上总时长校验避免题目量过大考生在规定时间内根本答不完。使用次数记录一道题被出过几次防止高频考题反复出现降低泄露风险。这些字段在题目录入阶段就必须规范好。如果题库里题目连知识点都没打组卷约束就无从谈起。CodeSpirit的入题工具里专门做了数据体检导入时统计缺失字段并自动告警目标是让质量问题在源头就被拦截。3.2 组卷约束条件怎么拆组卷参数决定了最终卷面的样貌可以拆成四层约束题型配额每种题型出多少题、每题多少分。这是最硬的约束通常直接写死在配置里。知识点覆盖每个考点至少出现几道题或者设定出现比重。这个约束保证卷面不偏。难度分布整张试卷的难度均值或各难度档位的题目占比。一般用“均值允许波动区间”来表示比如整卷难度均值建议在0.55左右允许波动正负0.03。相似度控制防止同一场考试里不同考生的试卷高度雷同。做法是对题目做规范化哈希再对已生成试卷做交叉对比。拆完这四层约束大概就能明白组卷算法不能简单随机。它本质上是一个带约束的随机采样问题。CodeSpirit在实现时每一步都走“预筛、加权随机、校验、回退”的流程。举例来说先按题型和知识点配额抽题抽取时按难度权重做加权随机抽完之后立即计算整卷难度均值如果均值越界就替换掉一些高难度题或低难度题最多迭代若干次仍然不满足就放弃自动生成转人工审核。3.3 一个组卷配置实例举一个具体的例子。假设某课程期末考试要求总分100分考试时长90分钟。题型配置如下单选20道每题2分难度档位0.2/0.4/0.6/0.8/1.0按10%/20%/45%/20%/5%分布多选10道每题3分难度分布10%/20%/40%/20%/10%判断10道每题1分难度分布10%/20%/40%/20%/10%简答4道每题5分只出0.4/0.6/0.8三档合并计算总分20乘2加10乘3加10乘1加4乘5正好等于100分。整卷题目数44道预估作答总时长要控制在70到85分钟之间留出交卷检查的时间。这个配置可以写进组卷任务的JSON里worker解析后开始跑题。实测下来在题库量5万道左右、索引建齐的情况下生成一套这样的卷子耗时大约1到3秒。生成几十套卷子也就是几分钟的事所以预生成方案在时间上完全没有压力。我习惯出卷后人工抽查两套重点看简答题是否和已考过的题目雷同、题型顺序是否合理。这类细节用代码比较难量化人工复核依然有必要。4. 试卷封存、发布与发放策略4.1 封存不是加个只读标签那么简单封存的本质是“快照加防篡改”两个动作。快照把试卷内容完整保存下来防篡改则是给快照算一个消息摘要记录在试卷主记录上。发放时从Redis里读到的内容要和摘要比对不一致就判定异常触发告警和回滚。摘要算法用过MD5也用过SM3如果项目有等保要求建议优先用SM3开源项目里用MD5做示例也够用关键是保留这个校验思路不能省略。同时“封存”还涉及权限控制。建议在管理后台把封存和解封作为独立操作记录操作人、操作时间、操作原因。谁在什么时候看过试卷内容导过一次全卷后台都能查到痕迹。对于考场工作人员来说这是一种很直接的泄露威慑。4.2 三种发放模式对应不同考试场景发放策略按考试场景分成三种模式identical全员同卷所有考生拿同一份卷最简单性能也最好适合开卷考试或考场人数有限的场次。pool卷池随机提前生成比如20套试卷考生每次进入时从池子里弹出一套这套卷子一旦弹出就绑定该考生。实现上直接用Redis的LPOP原子性有保证。fixed一人一卷每名考生绑定唯一的试卷按考生ID哈希或预分配结果取卷。适合防作弊要求高的考试。我在一个具体项目里做过混合方案。开考后前5分钟进入的考生从固定卷池取卷5分钟后的迟到考生只能领到剩余卷子并打上迟到标识。这样既保证公平又能减少开考瞬间的重复发放冲突。实现并不复杂关键是发放接口能识别考生状态并返回对应的试卷。4.3 发放必然要考虑幂等和兜底发放接口最容易踩的坑是重复领取。同一考生连续点击两次“进入考试”如果没有幂等控制他可能领到两份不同试卷或者同一份卷子被重复记录导致成绩表错乱。解决办法是在发放环节做双重校验。第一层向Redis写“uid到paperId”的映射时用SETNX同一把key只能写成功一次由Redis拦截高并发请求。第二层MySQL考生答卷表加唯一索引保证最终只能存在一份答卷记录。这两层缺一不可Redis管拦截MySQL管最终一致。再一个兜底是卷池不足。如果预生成套数少了池子空了后面的考生就领不到卷。CodeSpirit的做法是设置一个水位线池子里剩余卷数低于阈值就触发告警同时预留紧急预案管理员可以手动为一小批迟到考生重新生成备用卷。整个流程在运维端要有明确的操作入口不能等到考试进行到一半才来临时处理。5. 项目落地实操开源方案怎么跑起来5.1 快速部署需要准备的东西如果读者打算把CodeSpirit拿下来跑一跑需要准备三样基础环境一台能跑Spring Boot的机器、一个MySQL数据库、一个Redis实例。拉取代码后修改配置里的数据库连接信息、Redis地址、exam.paper.mode发放模式、卷池大小启动工程后先执行题库导入命令把一批带难度、知识点标签的样例题导进去然后在管理后台创建一场考试、提交组卷参数、发起预生成。正常情况下几十秒后就能在后台看到生成完毕的试卷列表。样例题库内置了几千道题让新手第一天就能跑通“录入题目、配置考试、预生成、模拟领取、查看答卷”的完整闭环。建议第一次跑的时候把卷池数量调小一点比如5套方便人工观察分配逻辑。等确认整个链路没问题了再按真实场景调整卷池规模。5.2 最容易改坏的三个地方第一是状态机流转。曾有人在GENERATED和VERIFIED之间加了“人工抽检”分支但没有同步状态迁移的校验逻辑结果试卷流转到VERIFIED后永远无法封存直到任务调度器日志报错才发现问题。状态机改动要谨慎建议所有流转都走同一个service方法不要在业务代码里到处直接改状态字段。第二是快照字段的扩充。后期想给试卷增加“题型说明”字段以为只改快照生成逻辑就够了漏了历史数据的兼容导致旧试卷发布时读不到新字段出现空白。扩充快照结构时要么给新字段设默认值要么在读取时做空值兜底千万别假设所有老数据都有新字段。第三是发放的Redis脚本。如果直接在业务代码里先判断再取数操作不是原子的并发下就会出现分卷错误。正确做法是把发放逻辑写进Lua脚本让整个操作在一个Redis事务里完成。这一步新手容易写错测试时又很难暴露因为并发漏洞需要压力测试才能看到。强烈建议第一次写发放接口时直接用现成的Lua脚本不要自己另起炉灶。6. 常见问题排查与经验速查我把实际使用中遇到比较多的问题整理成了速查表读者可以直接对照排查。问题现象可能原因排查顺序解决方案预生成跑完前端看不到试卷状态停在校验中或未发布查看状态机日志确认publishTime到开考时间自动发布手动调整需走管理员操作同一考生进入考试后领到两份卷发放接口缺少幂等控制查Redis中uid映射是否存在查MySQL唯一索引发放环节加SETNX与答卷表唯一索引不同考生拿到的卷疑似雷同相似度去重没有做二次交叉校验查封存卷摘要比对日志组卷完成后增加全量交叉比对相同题目超过阈值则重新生成题库后期改题后历史试卷内容变了快照只存了题目ID查试卷快照表里是否有content字段重建快照确保题目原文和选项完整落库考试开始后卷池为空预生成套数不足看卷池水位日志确认考中领取量提高预生成规模按峰值领取量加20%余量配置难度均值校验反复失败题库某档难度题目数量不足统计各难度档位题目数量优先补齐题库算法只做兜底处理表格之外还有几条实操经验想单独拿出来讲一讲。第一日志一定要带任务追踪ID。预生成任务和试卷发放是异步链路单看一段日志很难定位是组卷参数问题还是存储问题。从发起任务开始就把taskId往下传每一阶段都打一行“taskId加阶段加结果”的结构化日志排查效率能提高好几个量级。第二试卷池水位监控要单独做。我给每个考试建一个Redis key比如paper_pool考试ID然后每分钟查一次长度低于阈值就告警。这个监控和CPU监控不一样它预警的是直接影响考生的业务风险建议接上短信和群通知。第三预生成时间要尽量安排在考前1到2小时。这样就算自动校验或人工复核发现问题还有充足的时间重新生成。太早生成的话试卷在系统里封存的时间窗口变长虽然有权限控制但人员接触面一定是越大风险越大。太晚生成又可能来不及复核这个时间窗口要拿捏好。做完了这几套在线考试系统我最大的体会是预生成方案看起来只是把生成动作挪到了考前但背后是一整套状态管理、快照、幂等、监控的设计。只要把这套东西想清楚考试系统的稳定性能提升一个量级。CodeSpirit在这条路上做了开源实现想用的团队可以直接拿来改造花几天时间把题库规范好就能跑出靠谱的考务闭环。最后再分享一个小技巧刚开始接触预生成方案时别急着把题库铺到几万道题先用几百道题把整套流程跑通。跑通之后再逐步加数据、加并发测试每一步都确认没有问题再往前走。做考务系统稳字当头这才是最重要的。