在线教育平台里,题库和组卷是最容易被低估的模块:看起来就是"从题库里随机抽几道题",但做上线后会发现一堆问题——同一份卷子重复题太多、考生连抽两次抽到同一批题、难度忽高忽低、大题库抽题越来越慢。本文讲清组卷引擎的三个核心问题:随机怎么抽、难度怎么控、重复怎么防,并给一套可直接抄走的实现。
一份合格的组卷不是"随机 N 道题",而是同时满足:
这四条对应三个工程问题:抽题算法、权重控制、去重策略。
最朴素的 ORDER BY RAND() 在大题库下是灾难:数据库要为每一行生成随机数再全表排序,几万道题就开始明显变慢。生产环境常用两套替代:
方案 A:ID 区间随机 + 兜底(适合题库量稳定)
-- 先按难度分层,在层内用主键区间随机取题
SELECT * FROM question
WHERE difficulty = 'mid'
AND id >= (SELECT FLOOR(RAND() * (MAX(id)-MIN(id))) + MIN(id) FROM question WHERE difficulty='mid')
ORDER BY id LIMIT 20;方案 B:内存加权洗牌(适合按知识点筛选后的候选集较小,几百到几千道)
// 按权重洗牌:每道题给一个随机分值,权重高的更容易排前
function weightedShuffle(questions) {
return questions
.map(q => ({ q, w: Math.random() ** (1 / (q.weight || 1)) }))
.sort((a, b) => b.w - a.w)
.map(x => x.q);
}实践上最稳的是"先按约束筛选候选集 → 再在候选集内加权随机"两步:第一步用 SQL 把"难度+知识点+题型"筛出来,第二步在内存里做权重分配,避免全表随机排序。
难度最容易踩的坑是"先随机抽 50 道,再检查难度分布,不合格就重抽"——题库大时可能反复重试也凑不齐。正确做法是分层抽:
目标卷:30 题 = 易9 + 中15 + 难6 步骤: 先按难度分成三个候选池 再从"易"池抽9、从"中"池抽15、从"难"池抽6 拼接后做一次轻微校验(可接受小幅偏差)
这样每次抽题几乎必然命中目标分布,不会陷入"整卷重抽"的循环。每个难度池内部再按知识点配额细分(比如"中"池里章节 A 至少 5 道),把约束拆到每一层去满足,而不是最后统一校验。
function composePaper(pools, quota) {
// pools: {easy:[], mid:[], hard:[]}, quota: {easy:9, mid:15, hard:6}
const result = [];
for (const [level, count] of Object.entries(quota)) {
const picked = weightedShuffle(pools[level]).slice(0, count);
result.push(...picked);
}
return shuffle(result); // 最后整体打乱,避免"前易后难"过于明显
}同卷内不重复:抽题时维护一个 Set,命中即跳过——这个最简单,别漏就行。
跨次不重复:给每个考生维护"最近做过的题 id 集合",抽题时把命中题目的权重降为 0 或极大降低:
-- 抽题时排除最近做过的题(或用 NOT IN 降低优先级)
SELECT * FROM question
WHERE difficulty = 'mid'
AND id NOT IN (SELECT question_id FROM user_history WHERE user_id = ? AND ts > DATE_SUB(NOW(), INTERVAL 7 DAY))
ORDER BY RAND() LIMIT 15;题库更新后不串题:题目删除、选项调整会影响已出卷,建议题目表用软删除 + 版本号,组卷时只取"当前有效版本",已发出的卷子快照化保存(存题目快照而非实时引用),避免"考到一半题被改了"。
重复类型 | 手段 | 代价 |
|---|---|---|
同卷重复 | 抽题 Set 去重 | 无 |
跨次重复 | 最近做过集合降权/排除 | 需要维护用户历史表 |
题目被改/删 | 卷子存题目快照 | 存储略增,换来稳定 |
坑 | 现象 | 解法 |
|---|---|---|
ORDER BY RAND() | 大题库组卷秒级变慢 | 先筛选候选集再内存抽 |
整卷抽完再验难度 | 反复重抽凑不齐 | 按难度/知识点分层抽 |
忘做同卷去重 | 一份卷子重复题 | 抽题 Set |
不做跨次去重 | 考生反复做同一批题 | 用户历史降权 |
卷子实时引用题目 | 改题影响已发卷 | 存题目快照+版本号 |
权重写死 | 冷门题永远抽不到 | 权重可配置+动态调整 |
组卷引擎的复杂度取决于题库规模和约束数量。中小平台建议:题库按"难度+知识点"加索引,组卷走"SQL 筛选候选集 + 内存加权抽题 + 分层配额"三步,用户历史用一张表按 (user_id, question_id) 去重。先跑通"难度分层 + 同卷去重"这两个最基础的,再逐步加"跨次去重、知识点配额、题目快照"。抽题算法可以先在本地用真实题库数据做压测,确认单次组卷耗时在可接受范围(如 200ms 内),再上线。这套思路同样适用于抽奖、权益随机派发、问卷题目编排等"带约束的随机选取"场景。
在线组卷看着是"随机抽几道题",本质是"带约束的随机选取"工程:用分层配额控制难度,用候选集筛选保证性能,用 Set 和历史表防重复,用快照保证稳定。把这三件事做好,组卷就从"能出题"变成"出得又快又稳又合理",这几乎是所有教育类系统的硬需求。
以上为通用后端技术实践分享,仅作技术交流。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。