
简介《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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 题目主体 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选项判断题也走这张表用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 题目 × 知识点多对多带权重 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 作答流水只追加不更新 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户 × 知识点聚合靠定时任务或答题后增量更新 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段的含义值得单独说。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和删除标记分开这件事踩过的人不少。status0表示题目暂时停用、还能恢复真删除走物理删除两者混成一个字段后面想做「题目回收站」就没法做了。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. 组卷与错题回流抽题算法和掌握度怎么落到 SQL4.1 把组卷需求写成可计算的三条约束「给我抽一套期末模拟卷」这句话翻译成代码之前必须先变成数字。我一般把需求收敛成三条约束题型配额、知识点覆盖下限、难度分布。题型配额好理解单选 20 道、多选 10 道、判断 10 道、简答 4 道。知识点覆盖下限指的是每个一级知识点至少出几道防止整张卷子全压在 Servlet 一章上。难度分布常见的是易 40%、中 40%、难 20%但这个比例不宜写死应该做成组卷规格的参数。三条约束之间是会打架的。某个知识点下只有 5 道多选题却要求每个知识点至少 2 道多选那就必然凑不齐。所以组卷算法必须允许「软失败」能凑齐的尽量凑齐凑不齐的降低约束并在返回结果里带上告警而不是直接抛异常。这一点在真实使用中很关键题库初期题量不足是常态。另一个必须提前定的参数是重复控制。同一份卷子里出现两道题干相似度极高的题体验很差。我一般用question_id级别的去重加题干前 30 字的去重前者靠集合后者靠一个SetString缓存。4.2 一个贪心抽题实现和它的参数实现上不追求最优解。组卷本质是个带约束的集合覆盖问题精确求解要上整数规划对一份期末复习卷来说完全是杀鸡用牛刀。贪心加两轮补齐就够了代码短结果也能解释。public class PaperBuilder { /** 组卷规格各题型数量 每个知识点保底数量 */ public record SpecSpec(int single, int multiple, int judge, int perKpMin) {} public ListQuestion build(long userId, ListLong kpIds, Spec spec) { // 一次 IN 查询把涉及的知识点下的题全捞出来在内存里分组避免 N1 MapLong, ListQuestion byKp loadByKp(kpIds); MapLong, Question picked new LinkedHashMap(); SetString 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); ListQuestion paper new ArrayList(picked.values()); // 用 userId 和日期做种子同一个人同一天抽到的卷子稳定便于复现 Collections.shuffle(paper, new Random(userId * 31L LocalDate.now().toEpochDay())); return paper; } private boolean accept(Question q, SetString 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(MapLong, Question picked, MapLong, ListQuestion byKp, int qtype, int quota, SetString 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 秒重启后又恢复正常没有任何报错。这种情况十有八九是 N1 查询。早期版本的组卷逻辑是「先查知识点列表再对每个知识点查一次题目」知识点有 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, bits64): 对题干分词后的 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做成入库前的强制校验导致同一批题在三个文档里各进了一份清理时花的时间比建库还多。希望帮到你。本文还有配套的精品资源点击获取