ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaEE期末题库结构化:知识点树、组卷抽题与掌握度

JavaEE期末题库结构化:知识点树、组卷抽题与掌握度

简介:《JavaEE期末复习题库》面向准备JavaEE课程期末考核的高校学生与需要梳理MyBatis基础知识的自学者,聚焦MyBatis配置元素、SQL映射、ORM理解与工作原理等高频考点。压缩包内共1个docx文件,约41KB,按章节组织填空题、判断题、选择题和简答题,并附参考答案,便于对照自测与查漏补缺。已有2646人学习,说明其对课堂复习和考前突击具有一定参考价值。内容围绕 、 、 、mybatis-config.xml、SqlSessionFactory、Executor、MappedStatement等核心概念展开,同时涉及模糊查询、SQL注入防范与ORM框架优势,读者可用于快速建立知识框架、定位薄弱环节并完成针对性练习。

1. 期末前两周才翻题库,问题往往出在结构而不是题量

每到期中过后,总有人开始攒 JavaEE 期末复习题库:把课件里的选择题、历年卷里的简答题、课本上的填空题一道道敲进 Word,临考前打印出来背。我见过最典型的一次,某开发者攒了八百多道题的题库,考前翻了两页就放下了——不是题不够,是题没有结构:同一道 Servlet 生命周期的题散在三个文档里,错过的题第二次照错,也没人说得清哪一章最弱。题库真正的技术含量不在题量,而在题目能不能被检索、被抽题、被统计。这份笔记讲的是把一份格式混乱的 JavaEE 期末复习题库,做成能按知识点抽题、能记错题、能算出掌握度的最小系统,有建站经验的能直接照搬,带课维护题库的也能拿去参考。

2. 先定知识点树再定题目表:六张表撑起一个可检索的题库

2.1 为什么知识点树必须先于题目表落库

最常见的起手式是建一张question表,加一个chapter VARCHAR(64)字段存「第 3 章 Servlet」。题量两百以内这套写法还能跑,一旦需求变成「抽 10 道覆盖第 3 章到第 6 章、每个子知识点至少一道」,字符串字段就彻底不够用了:它表达不了层级,做不了子树查询,同一道题挂两个章节时还得复制一份题干。只要动到组卷,这一刀迟早要重来。

我一般先建一棵知识点树,再用中间表把题目挂到树节点上,一道题允许挂多个节点。这在 JavaEE 里尤其必要,比如「过滤器链的执行顺序」这道题,既属于「Servlet 规范」下的过滤器节点,也属于「MVC 分层与请求处理」节点。挂多个节点意味着抽题时这道题在两个知识点下都算数,覆盖率统计才不会漏。

树本身用parent_id加path两个字段冗余存。parent_id维护父子关系,给人看;path存形如/01/05/的物化路径,给查询用。查子树时一句WHERE path LIKE '/01/05/%'就能拿到全部后代,既不用递归 CTE,也不需要在应用层拼循环。代价是移动节点要批量更新整棵子树的path,但知识点树一个学期也改不了几次,这笔账很划算。

还有一条容易忽略的:题目和知识点之间要带权重。一道题如果主要考 JSP 九大对象、顺带涉及 EL 表达式,两个节点的权重就不该一样。抽题时按权重分摊,统计掌握度时才不会被「顺带涉及」的节点污染。

2.2 六张核心表的 DDL 与索引取舍

下面这段 DDL 是 MySQL 下能直接跑的版本,字符集统一utf8mb4,题干和解析用TEXT而不是VARCHAR,因为编程题题干带代码片段,长度不可控。

-- 知识点树:parent_id 表达层级,path 支持子树查询 CREATE TABLE knowledge_point ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0, name VARCHAR(128) NOT NULL, path VARCHAR(512) NOT NULL COMMENT '物化路径,如 /01/05/', sort_no INT NOT NULL DEFAULT 0, UNIQUE KEY uk_parent_name (parent_id, name), KEY idx_path (path) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 题目主体 CREATE TABLE question ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, qtype TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4填空 5简答 6编程', stem TEXT NOT NULL, answer TEXT NOT NULL, analysis TEXT, difficulty TINYINT NOT NULL DEFAULT 2 COMMENT '1易 2中 3难', source VARCHAR(64) NOT NULL DEFAULT '', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', content_hash CHAR(32) NOT NULL COMMENT '归一化题干 MD5,用于去重', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_hash (content_hash), KEY idx_type_diff (qtype, difficulty) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选项:判断题也走这张表,用 T/F 两个 key CREATE TABLE question_option ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, question_id BIGINT UNSIGNED NOT NULL, opt_key CHAR(2) NOT NULL, opt_text VARCHAR(512) NOT NULL, UNIQUE KEY uk_q_key (question_id, opt_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 题目 × 知识点,多对多,带权重 CREATE TABLE question_kp ( question_id BIGINT UNSIGNED NOT NULL, kp_id BIGINT UNSIGNED NOT NULL, weight DECIMAL(3,2) NOT NULL DEFAULT 1.00, PRIMARY KEY (question_id, kp_id), KEY idx_kp (kp_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 作答流水,只追加不更新 CREATE TABLE attempt ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, question_id BIGINT UNSIGNED NOT NULL, paper_id BIGINT UNSIGNED NOT NULL DEFAULT 0, user_answer TEXT, is_correct TINYINT NOT NULL, cost_ms INT NOT NULL DEFAULT 0, answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_q (user_id, question_id), KEY idx_user_time (user_id, answered_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户 × 知识点聚合,靠定时任务或答题后增量更新 CREATE TABLE user_kp_stat ( user_id BIGINT UNSIGNED NOT NULL, kp_id BIGINT UNSIGNED NOT NULL, answer_cnt INT NOT NULL DEFAULT 0, correct_cnt INT NOT NULL DEFAULT 0, last_wrong_at DATETIME NULL, mastery DECIMAL(5,2) NOT NULL DEFAULT 0, PRIMARY KEY (user_id, kp_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个字段的含义值得单独说。content_hash存的是题干归一化之后的 MD5,配合唯一索引uk_hash,同一道题从不同文档里解析两遍,第二次插入会直接报唯一键冲突,而不是悄悄多出一条重复题。question_option里opt_key用CHAR(2)而不是CHAR(1),是为了给「以上都对」「A 和 B 都正确」这类复合选项留AA、AB的余地。

索引方面,question上只留了idx_type_diff一个组合索引,因为组卷的主查询永远是「按题型筛、按难度排」。attempt上的两个索引分工明确:idx_user_q服务「这道题我做过没有」,idx_user_time服务「今天做了多少题」。question_kp的主键是(question_id, kp_id),所以反向查询「这个知识点下有哪些题」必须再建idx_kp,否则按知识点抽题时会全表扫。

提示:path字段长度给到 512 是留了余量。JavaEE 的知识点一般三层就够,但真到四层、五层时,VARCHAR(128)会被截断,而截断在 MySQL 严格模式下直接报错,排查起来很容易被误认为是编码问题。

2.3 qtype 和 difficulty 存成小整数,而不是字符串

题型和难度用TINYINT而不是VARCHAR,不是为了省空间,是为了让 MySQL 在索引上少掉坑。VARCHAR(16)的qtype在utf8mb4下索引条目要十几字节,TINYINT只占一字节,同一个索引页能多塞十几倍的行,按题型筛题时的随机 IO 明显下降。更实际的一点是范围查询:难度做BETWEEN 1 AND 2在整数列上是天然有序的,用「简单/中等/困难」这种字符串存,排序顺序会变成拼音序,ORDER BY difficulty出来的结果跟你想的完全是两回事。

枚举值的映射不要散落在业务代码里。我一般用一个 Java 枚举托住,getCode()给数据库,fromCode()给反序列化,前端拿到的永远是带中文名的 DTO,而不是光秃秃的1、2、3。这样后期加「连线题」「程序改错题」时,只改枚举加一个映射,不用去翻每一个if (type == 3)。

字段存储类型取值范围常见误用
qtypeTINYINT1–6用字符串存中文题型名,排序错乱
difficultyTINYINT1–3用 0–100 的浮点数,组卷时无法分档
statusTINYINT0/1用is_deleted又用status,语义重叠
weightDECIMAL(3,2)0.10–1.00用 FLOAT,求和后出现 0.9999

把status和删除标记分开这件事,踩过的人不少。status=0表示题目暂时停用、还能恢复,真删除走物理删除,两者混成一个字段,后面想做「题目回收站」就没法做了。

3. 从 Word 题库到入库:解析脚本与四种脏格式的清洗

3.1 导出的题库文本常见的四种脏格式

手头拿到的题库素材,九成是 Word 或者从 PDF 转出来的纯文本。这两种来源带出来的脏格式,基本逃不出下面四类。

第一种是题号不连续或重号。几个文档合并之后,题号会从 1 重新开始,或者中间跳过一批。如果把题号当成主键或者唯一标识,合并时就必然冲突。我的做法是题号只作为解析时的切分信号,不落库。

第二种是选项换行。原本一行A. 请求转发被 Word 的自动换行拆成「A. 请求」和「转发」两行,如果解析器把每个非空行都当成新选项,就会解析出一堆只有一个字的选项。这类问题在长选项上尤其密集。

第三种是答案内联在题干里。有些卷子写成「下面哪个不是 Servlet 生命周期方法(B)」,答案在括号里,后面没有单独的「答案:」行。这种题如果只认「答案:」关键字,整道题的答案字段就是空的,而且不会报错。

第四种是全角半角混排加页眉页脚残留。(和(、,和,交替出现,每一页顶部还带着「第 3 页 共 12 页」这种行。页脚一旦被当成题干的一部分,同一道题在两页之间就会被解析成两道不一样的题。

3.2 用 Python 状态机切题:正则只负责认路

这里用 Python 写解析器,不是因为它是唯一选择,而是处理文本的库最顺手。核心思路是把「正则」和「状态」分开:正则只判断当前行是什么类型,状态机负责决定这一行的内容接到哪个字段上。

import hashlib import re import unicodedata # 行级模式:只负责识别「这一行像什么」,不负责理解语义 RE_QNO = re.compile(r'^\s*(\d{1,3})\s*[.、.]\s*(.*)$') RE_OPT = re.compile(r'^\s*([A-H])\s*[.、.]\s*(.*)$') RE_ANS = re.compile(r'^\s*(答案|参考答案)\s*[::]\s*(.*)$') RE_ANA = re.compile(r'^\s*(解析|分析)\s*[::]\s*(.*)$') RE_TYPE = re.compile(r'[((]\s*(单选|多选|判断|填空|简答|编程)') # 页眉页脚:整行丢弃,不参与任何字段拼接 RE_NOISE = re.compile(r'^\s*(第\s*\d+\s*页|共\s*\d+\s*页|JavaEE\s*期末).*$') TYPE_MAP = {'单选': 1, '多选': 2, '判断': 3, '填空': 4, '简答': 5, '编程': 6} def normalize(text: str) -> str: """全角转半角、去空白、去标点,让同一道题的不同排版得到同一个 hash""" text = unicodedata.normalize('NFKC', text) text = re.sub(r'\s+', '', text) text = re.sub(r'[,。、;:!?“”‘’()()【】\[\].,;:!?"\']', '', text) return text.lower() def content_hash(stem: str) -> str: return hashlib.md5(normalize(stem).encode('utf-8')).hexdigest() def parse(lines): items, cur, state = [], None, None for raw in lines: line = raw.rstrip('\n') if not line.strip() or RE_NOISE.match(line): continue m = RE_ANS.match(line) if m and cur is not None: cur['answer'] = m.group(2).strip() state = 'answer' continue m = RE_ANA.match(line) if m and cur is not None: cur['analysis'] = m.group(2).strip() state = 'analysis' continue m = RE_OPT.match(line) if m and cur is not None and state in (None, 'stem', 'option'): cur['options'].append({'key': m.group(1), 'text': m.group(2).strip()}) state = 'option' continue m = RE_QNO.match(line) if m: if cur: items.append(cur) stem = m.group(2).strip() tm = RE_TYPE.search(stem) cur = { 'no': int(m.group(1)), # 题型标记从题干里剥掉,避免污染 content_hash 'stem': RE_TYPE.sub('', stem).strip(), 'qtype': TYPE_MAP[tm.group(1)] if tm else 1, 'options': [], 'answer': '', 'analysis': '', } state = 'stem' continue # 续行:按当前状态把内容接回对应字段 if cur is None: continue if state == 'option': cur['options'][-1]['text'] += line.strip() elif state == 'answer': cur['answer'] += line.strip() elif state == 'analysis': cur['analysis'] += line.strip() else: cur['stem'] += line.strip() if cur: items.append(cur) for it in items: it['content_hash'] = content_hash(it['stem']) return items

这段代码里最关键的不是正则写得多漂亮,而是三处状态处理。第一处是RE_QNO命中之后立刻把cur推进结果列表并开新对象,保证每道题只属于一个题号。第二处是续行逻辑,state决定了这一行的文字到底拼到题干、最后一个选项、答案还是解析上,选项换行的问题就靠cur['options'][-1]['text'] += line.strip()这一句接住。第三处是RE_TYPE.sub('', stem),把「(单选)」这类题型标记从题干里剥掉再算 hash,否则同一道题在「标了题型」和「没标题型」的文档里会算出两个不同的 hash,去重直接失效。

参数上,RE_QNO的题号限定 1 到 3 位,是防止把「2023 年期末」这种行误判成题号。RE_OPT限定A-H,覆盖了多选最多八项的情况,再多就得改[A-Z],但那时要同步调opt_key的长度。normalize里的NFKC归一化负责全角转半角,中文引号不在 NFKC 的范围内,所以要单独用正则再剥一遍。

3.3 归一化去重与 5% 人工抽检

解析出来的列表先落一张临时表,别直接往question里插。落临时表之后跑两件事:一是按content_hash分组,找出同一个 hash 出现多次的记录;二是按qtype分组统计数量,看看各题型的分布是否合理。前者是去重,后者是发现「整段解析失败」的问题——如果一份本该有 40 道简答题的文档解析出来只有 3 道,那基本可以断定是简答题的答案换行没接住。

去重不能只看 hash 完全相同。曾经遇到过一次,两道题只差一个「下列」和「以下」,hash 完全不同,但语义一模一样。这类靠 hash 抓不住,只能靠后面的相似度体检(第 6 章讲)。真正需要人工抽检的是另一类:hash 相同的记录里,有没有本不该相同的。比如两个知识点下各有一道「简述 Servlet 生命周期」,hash 一样但知识点不同,直接删掉一条就会丢挂载关系。我一般会在去重脚本里保留question_kp的合并逻辑,同一 hash 的多条记录合并成一条题,所有知识点挂载全部保留。

抽检比例给 5% 是个经验值。题量 500 以内抽 25 道,500 到 2000 抽 50 道,重点看三类:选项数少于 2 的、答案为空或答案不在选项范围内的、题干长度超过 300 字的。前两类说明解析有硬伤,第三类通常是页眉页脚混进了题干。

注意:去重脚本一定要能重跑。第一次跑删掉了一批,第二次修正了解析规则再跑,如果脚本假设「源数据永远干净」,就会把已经入库的题再插一遍。稳妥的做法是入库统一走INSERT ... ON DUPLICATE KEY UPDATE,让唯一索引兜底。

4. 组卷与错题回流:抽题算法和掌握度怎么落到 SQL

4.1 把组卷需求写成可计算的三条约束

「给我抽一套期末模拟卷」这句话,翻译成代码之前必须先变成数字。我一般把需求收敛成三条约束:题型配额、知识点覆盖下限、难度分布。题型配额好理解,单选 20 道、多选 10 道、判断 10 道、简答 4 道。知识点覆盖下限指的是每个一级知识点至少出几道,防止整张卷子全压在 Servlet 一章上。难度分布常见的是易 40%、中 40%、难 20%,但这个比例不宜写死,应该做成组卷规格的参数。

三条约束之间是会打架的。某个知识点下只有 5 道多选题,却要求每个知识点至少 2 道多选,那就必然凑不齐。所以组卷算法必须允许「软失败」:能凑齐的尽量凑齐,凑不齐的降低约束并在返回结果里带上告警,而不是直接抛异常。这一点在真实使用中很关键,题库初期题量不足是常态。

另一个必须提前定的参数是重复控制。同一份卷子里出现两道题干相似度极高的题,体验很差。我一般用question_id级别的去重加题干前 30 字的去重,前者靠集合,后者靠一个Set<String>缓存。

4.2 一个贪心抽题实现和它的参数

实现上不追求最优解。组卷本质是个带约束的集合覆盖问题,精确求解要上整数规划,对一份期末复习卷来说完全是杀鸡用牛刀。贪心加两轮补齐就够了,代码短,结果也能解释。

public class PaperBuilder { /** 组卷规格:各题型数量 + 每个知识点保底数量 */ public record SpecSpec(int single, int multiple, int judge, int perKpMin) {} public List<Question> build(long userId, List<Long> kpIds, Spec spec) { // 一次 IN 查询把涉及的知识点下的题全捞出来,在内存里分组,避免 N+1 Map<Long, List<Question>> byKp = loadByKp(kpIds); Map<Long, Question> picked = new LinkedHashMap<>(); Set<String> stemPrefix = new HashSet<>(); // 第一轮:每个知识点先保底 perKpMin 道,按掌握度升序,优先补弱项 for (Long kp : kpIds) { byKp.getOrDefault(kp, List.of()).stream() .filter(q -> accept(q, stemPrefix)) .sorted(Comparator.comparingDouble(q -> mastery(userId, kp))) .limit(spec.perKpMin()) .forEach(q -> picked.putIfAbsent(q.getId(), q)); } // 第二轮:按题型配额补齐,同题型内难度从高到低,保证难题不被保底题挤掉 fill(picked, byKp, 1, spec.single(), stemPrefix); fill(picked, byKp, 2, spec.multiple(), stemPrefix); fill(picked, byKp, 3, spec.judge(), stemPrefix); List<Question> paper = new ArrayList<>(picked.values()); // 用 userId 和日期做种子,同一个人同一天抽到的卷子稳定,便于复现 Collections.shuffle(paper, new Random(userId * 31L + LocalDate.now().toEpochDay())); return paper; } private boolean accept(Question q, Set<String> stemPrefix) { if (q.getStatus() != 1) return false; String key = q.getStem().substring(0, Math.min(30, q.getStem().length())); return stemPrefix.add(key); // add 返回 false 说明前缀重复,直接跳过 } private void fill(Map<Long, Question> picked, Map<Long, List<Question>> byKp, int qtype, int quota, Set<String> stemPrefix) { long have = picked.values().stream().filter(q -> q.getQtype() == qtype).count(); int need = (int) (quota - have); if (need <= 0) return; byKp.values().stream() .flatMap(List::stream) .filter(q -> q.getQtype() == qtype) .filter(q -> accept(q, stemPrefix)) .sorted(Comparator.comparingInt(Question::getDifficulty).reversed()) .limit(need) .forEach(q -> picked.putIfAbsent(q.getId(), q)); } }

这段代码有三个设计点值得说。第一轮按掌握度升序排,mastery越低越先被抽中,等于每次组卷都优先暴露弱项,这比纯随机抽题的教学价值高得多。第二轮按难度降序补,是防止保底轮把简单题抽光之后,配额轮只能拿简单题凑数,最后整张卷子偏易。shuffle用userId * 31 + 日期做种子,是为了让同一份卷子在同一天内可复现——调试时如果题目每次都变,排查一道题的显示问题会非常痛苦。

accept里的前缀去重有个细节:substring要防越界,题干长度小于 30 时取全长。字符串前缀去重只能挡住「开头一模一样」的题,比如同一道题在两个文档里只差最后一句解释。更彻底的相似度检查放到第 6 章。

参数上,perKpMin一般设 1 到 2。设成 3 的话,一级知识点有 8 个,光保底就要 24 道题,再加上题型配额,很容易超出一张卷子的题量,导致第一轮抽完第二轮没有余量。single/multiple/judge三者的比例,我的习惯是 2:1:1,简答题单独按知识点抽,不参与这个配额体系,因为简答题的评分要人工介入,数量天然受限。

4.3 掌握度公式与「今日必刷 20 题」的查询

掌握度不是一个需要精确的数字,它只需要能排序、能看出趋势。用「正确率」直接当掌握度会有个明显问题:只做过一道题并且做对了,正确率 100%,跟做了 50 道对了 48 道的 96% 完全不是一回事。所以要加平滑。

我用的公式是拉普拉斯平滑乘一个时间衰减:

mastery = 100 * (correct_cnt + 1) / (answer_cnt + 2) * decay decay = 1 - 0.5 * exp(-days_since_last_wrong / 30)

(correct_cnt + 1) / (answer_cnt + 2)让新题目的掌握度从 50% 起步,做过一两道题不会直接冲到 100%。decay让 30 天没碰过的知识点掌握度自动打折,最近刚错过的时间衰减最狠,这样可以保证「久未复习」和「刚翻车」的节点优先被推出来。

落到 SQL 里长这样:

-- 掌握度:平滑正确率 × 时间衰减 SELECT kp_id, ROUND( 100.0 * (correct_cnt + 1) / (answer_cnt + 2) * (1 - 0.5 * EXP(-DATEDIFF(NOW(), IFNULL(last_wrong_at, NOW())) / 30.0)), 2) AS mastery FROM user_kp_stat WHERE user_id = #{userId};

「今日必刷 20 题」的查询要把「没做对过的题」和「弱知识点」两件事叠在一起。用LEFT JOIN attempt加is_correct = 1过滤掉已经做对的题,剩下的就是待巩固的:

SELECT q.id, q.stem, q.difficulty, MIN(s.mastery) AS m FROM question q JOIN question_kp qk ON qk.question_id = q.id JOIN user_kp_stat s ON s.kp_id = qk.kp_id AND s.user_id = #{userId} LEFT JOIN attempt a ON a.question_id = q.id AND a.user_id = #{userId} AND a.is_correct = 1 WHERE q.status = 1 AND a.id IS NULL GROUP BY q.id, q.stem, q.difficulty ORDER BY m ASC, q.difficulty DESC LIMIT 20;

MIN(s.mastery)这一句是必须的,因为一道题可能挂在两个知识点上,取较低的那个掌握度,题才会出现在更弱的那个知识点的复习队列里。GROUP BY里把q.stem和q.difficulty也列出来,是为了兼容ONLY_FULL_GROUP_BY模式,少了这两个字段查询会直接报错。

attempt表的写入要跟user_kp_stat的更新放在同一个事务里,先插流水再UPDATE ... SET answer_cnt = answer_cnt + 1, correct_cnt = correct_cnt + IF(is_correct, 1, 0)。这一步漏了事务,一旦中途失败,流水和聚合就对不上,掌握度算出来永远是错的,而且错得很隐蔽。

5. 题库上线后最容易翻车的 5 个地方

5.1 抽出来的卷子同一知识点扎堆

现象很直观:一张本该覆盖全书的模拟卷,前十道题全考 JDBC,后面的题一道 Servlet 都没有。用户第一反应是「题库出问题了」,实际上是抽题 SQL 用了ORDER BY RAND() LIMIT 20。

ORDER BY RAND()给每一行生成一个随机数再排序,本质上是全局随机,完全不考虑知识点分布。题量小的时候看不出来,题库上千道之后,随机结果偏斜是必然的。而且这个写法在 InnoDB 上会触发全表扫加临时表,几万行的时候接口能卡到三秒以上。

解决方式是先按知识点分桶,每个桶内抽够保底数,再合并打散,也就是 4.2 节那两轮逻辑。如果实在要用 SQL 层完成,至少改成按question_kp分组后用窗口函数ROW_NUMBER() OVER (PARTITION BY kp_id ORDER BY RAND()),再取rn <= perKpMin,这样随机只在桶内发生,不会破坏覆盖。

5.2 多选题被判错,学生答案顺序不同

现象:学生选了 A、B、C,系统判错,正确答案字段里存的是B,A,C。这种题在复习阶段特别容易引发争议,因为学生确实答对了。

根因是答案比较用了字符串相等。多选题的答案在库里存成B,A,C还是A,B,C还是A、B、C,取决于录入的人当时怎么敲的,没有任何约束保证顺序一致。

解决思路是把比较前移:入库时和判题时都对答案做一次归一化,剥掉分隔符、转大写、按字母排序,再比较。判断题单独处理,把对/错、T/F、√/×统一映射成T/F。填空题和简答题走不了这条路,只能人工或关键字匹配,所以这两类题型在判题接口里要单独分支,不能混在客观题的统一判题逻辑里。

5.3 同一道题入库两次,唯一索引没拦住

现象:去重明明加了uk_hash,题量还是比预期多出一截,而且重复的两条题内容看起来一模一样。

排查下来,问题出在 hash 的计算时机。解析脚本里content_hash是在「题干 + 题型标记」的基础上算的,同一道题在 A 文档里带「(单选)」,在 B 文档里不带,算出来就是两个 hash。唯一索引拦不住,因为它看到的是两个不同的值。

解决方式是在算 hash 之前先做一次彻底清洗:剥掉题号前缀、剥掉题型标记、剥掉分值标注「(5 分)」、剥掉首尾空白和括号内的重复内容,再做归一化。这一步做完,同一道题不管从哪个文档来,hash 都应该一致。验证方法很简单,拿同一道题手改五种排版,跑一遍脚本,看五个 hash 是不是同一个。

5.4 错题本越刷越多,最后没人看

现象:错题本第一周有 20 道,第三周变成 120 道,用户直接放弃这个功能。

根因是错题记录按attempt明细累积,每错一次就加一条,重做时又错又加一条。用户看到的「错题」其实是「错题次数」,不是「还没掌握的题」。

解决方式是错题本按(user_id, question_id)聚合,只保留最近一次作答状态。连续答对两次就从错题本移除,答错一次就重置连续计数。这样错题本的数量会稳定在一个真实反映薄弱面的水平,而不是单调增长。实现上可以在user_kp_stat之外再加一张user_question_stat,字段就是last_correct、correct_streak、wrong_cnt,写入成本很低。

5.5 组卷接口偶发超时,日志里看不出慢在哪

现象:平时组卷 200 毫秒,偶尔飙到 4 秒,重启后又恢复正常,没有任何报错。

这种情况十有八九是 N+1 查询。早期版本的组卷逻辑是「先查知识点列表,再对每个知识点查一次题目」,知识点有 30 个就发 30 条 SQL。平时数据量小、连接池够用,看不出问题;一旦并发上来,连接池被打满,请求就开始排队,表现出来就是偶发超时,而且日志里每条 SQL 都很快,看不到慢查询。

解决方式是改成一次IN查询全捞出来,在应用层按kp_id分组。如果题库大到一次捞不完,就按题型分三次查,每次带qtype条件。同时给question_kp的idx_kp和question的idx_type_diff补上覆盖索引,让这三条查询都能走索引覆盖,不回表。改完之后用EXPLAIN确认rows的数量级,如果某条查询的rows还是接近全表,那就是索引没命中,回头检查条件字段的类型是否和索引列一致。

6. 用题干相似度给题库做一次体检

hash 去重只能抓完全相同的题,差一个字的近重复题抓不住。题库上到五百道之后,我一般会跑一次相似度体检,用 SimHash 加汉明距离,成本低,结果也够用。

import hashlib def simhash(tokens, bits=64): """对题干分词后的 token 列表求 64 位 SimHash""" v = [0] * bits for t in tokens: h = int(hashlib.md5(t.encode('utf-8')).hexdigest(), 16) for i in range(bits): v[i] += 1 if (h >> i) & 1 else -1 return sum(1 << i for i in range(bits) if v[i] > 0) def hamming(a, b): return bin(a ^ b).count('1')

分词用最简单的二元切分就够,中文题干不依赖词库也能拿到尚可的效果。阈值我一般这么定:

汉明距离判定处理方式
0–3高度重复自动合并,保留最早入库的一条
4–8疑似同题导出成表格,人工复核
9 以上不同题不处理

距离 3 以内直接合并,是我在几百道题上试出来的经验值,定得更严会漏掉「以下/下列」这种单字替换。4 到 8 这段一定要人工看,因为 JavaEE 里存在大量「只改一个关键字、考完全不同知识点」的题,比如「请求转发和重定向的区别」和「请求转发和包含的区别」,字面很近,考点不同,自动合并就是灾难。

这个体检脚本我一般只在两个时机跑:题库批量导入之后,以及每个学期开学前。跑完把疑似重复的清单导出来,人工过一遍,比等到学生反馈「这两道题答案冲突」再去翻要省事得多。

血泪经验是:不要指望一次性把题库做干净。题库是个会持续生长的东西,每学期都会有人往里加题、改答案、补解析,结构设计得再好,也挡不住新题的格式千奇百怪。所以解析脚本、去重脚本、相似度体检这三件事,最好都做成能重复跑的命令,而不是一次性的临时脚本。真正让我后悔的是早期没把content_hash做成入库前的强制校验,导致同一批题在三个文档里各进了一份,清理时花的时间比建库还多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进