ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

1700万条K12题库MySQL导入、公式渲染与组卷优化

1700万条K12题库MySQL导入、公式渲染与组卷优化

简介:这份面向K12在线教育从业者的题库数据库资料包,聚焦试题存储与数理化公式录入显示这一常见难题,适合需要搭建或优化题库系统的教研、产品与技术团队参考。压缩包共583个文件、约2.03MB,其中1个sql文件给出数据库结构与样本题库,2份docx文档讲解数据结构与题库设计,2个html与2个js配合MathJax实现LaTeX公式在网页端的渲染,另有574张png截图用于展示公式效果与字段界面,辅以txt说明和gif演示,便于按模块对照理解。已有1109人学习下载,可作为题库建设的实践样本。读者可从中了解章节层级与知识点体系如何组织、题目属性如何设置,以及LaTeX公式从录入到前端显示的完整链路,并借助样本数据快速验证自己的建库思路,减少在公式兼容与数据结构设计上的试错成本。

1. 中小学题库 mysql.zip 解压之后:1700 万条记录先别急着 source

这个包的定位很明确——一份可以直接导进 MySQL 的中小学题库数据,覆盖数学题库、物理题库等主要科目,题干里带 LaTeX 公式,总量在 1700 万条这个量级,主打的是免费 K12 题库底座。它解决的是「从零搭题库」这件事里最脏最累的一段:不是写页面,而是把一份能直接查询、能渲染公式、能支撑组卷的题整理出来。适合两类人,一类是想快速验证教育类产品的开发者,一类是需要离线题库做统计和清洗的团队。

反直觉的地方在于,导入翻车的原因基本不是数据量。1700 万条 INSERT 在现代机器上跑几个小时也就过去了,真正卡住人的是字符集选错、max_allowed_packet 太小、以及公式里的反斜杠在某一层被吃掉。后面几章就按「认表 → 存公式 → 建索引 → 排坑 → 组卷」这个顺序拆。

2. 拆开 dump 文件:表结构识别与字段落地

拿到一个 mysql.zip,最没效率的做法是直接source进去然后对着几百张表发呆。先在文件层面看清楚它长什么样,再决定怎么导、导到哪个库、要不要改字符集。

2.1 先看文件,再看表

解压之后一般是一个或几个.sql文件,有的包会附带一个README或者字段说明。先用命令行把体量和结构摸清楚,不要一上来就开客户端。

# 解压到独立目录,避免和已有数据混在一起 unzip 中小学题库mysql.zip -d tiku_dump # 看文件大小和数量 ls -lh tiku_dump # 统计每个 sql 文件里的 INSERT 语句条数,粗略估算数据量 for f in tiku_dump/*.sql; do echo -n "$f: " grep -c "^INSERT INTO" "$f" done

grep -c数的是 INSERT 语句的行数而不是记录数,因为 dump 通常是多值插入(一条 INSERT 带几百行 VALUES)。想看真实记录数,可以数 VALUES 后面的括号,但没必要那么精确,量级对了就行。

接着建库。字符集这一步没有任何商量余地,utf8mb4是底线,因为题干里会出现生僻字、数学符号、以及 emoji 之外的增补平面字符。

mysql -uroot -p --default-character-set=utf8mb4 \ -e "CREATE DATABASE tiku DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"

这里用utf8mb4_general_ci而不是utf8mb4_0900_ai_ci,原因是后者只在 MySQL 8.0 及以上存在,如果你手上的实例是 5.7,建库语句会直接报错。跨版本迁移的场景下,utf8mb4_general_ci是最稳的兜底选择,排序精度差异在题库检索里几乎感知不到。

导入时把几个关键参数带上,能省掉后面一半的排查时间:

mysql -uroot -p --default-character-set=utf8mb4 \ --max-allowed-packet=256M \ tiku < tiku_dump/questions.sql

--max-allowed-packet必须给足,因为多值 INSERT 的单条语句可能有几 MB,默认 4M 或 16M 很容易在跑到一半时断开。这个参数客户端和服务端都要够大,客户端这边用命令行参数覆盖,服务端那边要改my.cnf里的max_allowed_packet,两边取小值生效。

2.2 用 information_schema 反查题干字段

不同来源的题库表名差异极大,question、tiku_question、subject_question都见过,靠猜表名不现实。更靠谱的办法是按字段特征去认:题干一定是长文本,选项要么是长文本要么是 JSON,答案通常短。

-- 找出所有长文本字段,基本就能定位题干和解析 SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tiku' AND DATA_TYPE IN ('text', 'mediumtext', 'longtext', 'varchar') AND (CHARACTER_MAXIMUM_LENGTH IS NULL OR CHARACTER_MAXIMUM_LENGTH > 200) ORDER BY TABLE_NAME, ORDINAL_POSITION;

跑完这条,结果里出现两三个长文本字段的那张表,九成就是题目主表。再补一条统计各表行数的语句,把空表和主表区分开:

SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'tiku' ORDER BY TABLE_ROWS DESC LIMIT 20;

TABLE_ROWS在 InnoDB 下是估算值,误差可能到 20% 以上,但用来判断「哪张表是主表」完全够用。真要看准确值就SELECT COUNT(*),1700 万行在普通 SSD 上大概几十秒。

2.3 题目主表的字段怎么落

如果原包的表结构不合你的用法,我一般会另建一张规范化的主表,把数据洗过去,而不是硬改原表。参考结构如下:

CREATE TABLE question ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, subject TINYINT UNSIGNED NOT NULL COMMENT '1数学 2物理 3化学 4语文 5英语', stage TINYINT UNSIGNED NOT NULL COMMENT '1小学 2初中 3高中', grade TINYINT UNSIGNED NOT NULL COMMENT '年级,1-12', qtype TINYINT UNSIGNED NOT NULL COMMENT '1单选 2多选 3填空 4解答 5判断', difficulty TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '1-5,5最难', stem MEDIUMTEXT NOT NULL COMMENT '题干,含 LaTeX 公式', options JSON DEFAULT NULL COMMENT '选项数组,选择题用', answer TEXT COMMENT '答案,多空用 || 分隔', analysis MEDIUMTEXT COMMENT '解析,含 LaTeX 公式', knowledge_ids VARCHAR(255) DEFAULT NULL COMMENT '知识点 id 逗号串', source VARCHAR(64) DEFAULT NULL COMMENT '题源标记', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_subject_stage_diff (subject, stage, difficulty), KEY idx_knowledge (knowledge_ids(64)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

几个设计取舍值得说清楚。选项用JSON而不是单独一张选项表,是因为题库场景里选项几乎永远是「跟着题目整取整存」,没有单独按选项查询的需求,拆表只会让每次取题多一次 JOIN。MySQL 5.7 起原生支持 JSON 类型,5.6 就只能用TEXT存 JSON 字符串,这点要看你实例版本。

答案字段用||分隔多空,是填空题的常见做法,比嵌套 JSON 更好做批量统计,比如统计「有多少道题是三空填空」可以直接LENGTH(answer) - LENGTH(REPLACE(answer,'||',''))。

knowledge_ids用逗号串是妥协方案,为的是和原始数据对齐,第 4 章会讲这个设计的代价和补救办法。

2.4 千万级导入的性能开关

1700 万行用默认配置导,可能要跑一晚上。批量导入前临时关掉几个保护机制,速度能差好几倍:

SET GLOBAL innodb_flush_log_at_trx_commit = 2; SET GLOBAL sync_binlog = 0; SET SESSION unique_checks = 0; SET SESSION foreign_key_checks = 0;

innodb_flush_log_at_trx_commit改成 2 表示每秒刷一次日志而不是每次提交都刷,崩溃时最多丢 1 秒数据,导入场景可以接受,导完记得改回 1。sync_binlog = 0同理,主从环境下要谨慎。unique_checks和foreign_key_checks关掉是跳过唯一性和外键校验,前提是你确认数据本身没有冲突——这一步建议先在小样本上跑一遍。

提示:这几条是会话级和全局级混着的,SET SESSION只影响当前连接,SET GLOBAL影响新连接,改完记得在导入结束后逐条还原,别把这套配置留在生产环境里。

3. LaTeX 公式从库里到屏幕:三道转义关口

公式显示是这份资源最容易出问题的地方,也是它区别于普通文本题库的核心价值。题干里的\frac{1}{2}、\sqrt{x}、\angle ABC能不能正确显示,取决于存储、传输、渲染每一环有没有被多转义或少转义。

3.1 先抽样看公式到底长什么样

不要假设公式的定界符是$...$,实际数据里$...$、$$...$$、\(...\)、\[...\]甚至不带定界符裸写的情况都存在,同一个包里混用也不稀奇。

-- 抽样看题干里的公式形态 SELECT id, LEFT(stem, 200) AS stem_head FROM question WHERE subject = 1 AND (stem LIKE '%\\\\%' OR stem LIKE '%$%') LIMIT 20;

注意LIKE里的反斜杠本身也要转义,'%\\\\%'在 SQL 字符串解析后是%\\%,匹配的是数据里的单个反斜杠。这一步的目的是确认两件事:公式定界符是哪几种,以及反斜杠在库里是单写还是双写。

如果查出来是\\frac,说明数据在入库时被多转义了一层,渲染前必须先还原,否则前端拿到的是双反斜杠,KaTeX 直接报错。

3.2 三道关口:SQL 转义、驱动转义、JSON 转义

反斜杠在整条链路上会被处理三次,任何一次处理不当都会让公式变成乱码:

第一道是 SQL 层。如果你用INSERT语句手工补数据,字符串里的\f会被 MySQL 当成换页符,\b当成退格符,\n当成换行。必须写成\\frac才能存进一个反斜杠。mysqldump 导出的文件里就是这个形态,所以用source导入是安全的,用自己拼的脚本导入就危险。

第二道是驱动层。主流 Python 驱动比如 pymysql,使用参数化查询时不会二次转义,cur.execute("INSERT ... VALUES (%s)", ("\\frac{1}{2}",))存进去就是一个反斜杠。但如果你的代码里用了字符串拼接,就会多一层。

import pymysql conn = pymysql.connect( host='127.0.0.1', user='root', password='your_password', database='tiku', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor, ) with conn.cursor() as cur: cur.execute("SELECT id, stem FROM question WHERE id = %s", (1024,)) row = cur.fetchone() # repr 能直接看出反斜杠是单写还是双写 print(repr(row['stem'][:200]))

repr是关键的自检手段,'\\frac'和'\\\\frac'一眼就能分辨,而直接print出来两者长得差不多,这就是典型的黑匣子式的排查困境。

第三道是 JSON 层。如果接口把题干塞进 JSON 返回,json.dumps会把单个反斜杠再转义成两个,这是 JSON 规范要求的,属于正确行为,前端JSON.parse之后会自动还原,不需要额外处理。真正的问题出在前端拿到字符串之后又自己做了一次replace('\\\\', '\\'),那就是把正确的数据改坏了。

import json payload = {"stem": row['stem']} # ensure_ascii=False 只是让中文不变成 \uXXXX,和反斜杠转义无关 body = json.dumps(payload, ensure_ascii=False) print(body[:200])

ensure_ascii这个参数经常被误认为能解决公式问题,其实它只管中文和特殊字符的 Unicode 转义,和 LaTeX 的反斜杠完全是两回事。这一点踩过的人不少。

3.3 前端渲染:用 auto-render 统一多种定界符

渲染端用 KaTeX 比 MathJax 快,尤其是一页几十道题的时候差别明显。用 auto-render 插件可以一次性处理多种定界符,不用自己写正则去切。

import renderMathInElement from 'katex/dist/contrib/auto-render'; const stemEl = document.getElementById('stem'); renderMathInElement(stemEl, { delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\[', right: '\\]', display: true }, { left: '\\(', right: '\\)', display: false }, ], throwOnError: false, // 单条公式出错不中断整页渲染 errorColor: '#cc0000', strict: false, // 放行非标准命令,题库数据里常有 ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'], macros: { '\\R': '\\mathbb{R}', '\\degree': '^{\\circ}' }, });

几个参数值得单独说。throwOnError: false是生产环境的必备项,1700 万条数据里必然有写错的公式,一条报错让整页白屏是不可接受的,改成false之后错误公式会以红字原样显示,至少不影响其他题目。strict: false是为了兼容\degree这类非 KaTeX 标准命令,题库数据经常来自 Word 公式编辑器转换,不规范写法很常见。ignoredTags里必须包含code和pre,否则题干里引用的代码片段会被当成公式解析。

macros用来给常见但冗长的命令起别名,比如把^{\circ}定义成\degree,可以让原始数据更干净,但要注意只在渲染层加,不要反向去改库里的数据——库是唯一真相源,渲染层做适配就行。

3.4 存储侧统一:写一个归一化脚本

在入库前跑一遍归一化,比在渲染层做兼容要省心得多。目标是把所有定界符统一成$...$和$$...$$,同时把多余的反斜杠压平。

import re def normalize_latex(text: str) -> str: if not text: return text # 1. 把 \( \) 和 \[ \] 统一成 $ 和 $$ text = re.sub(r'\\\[(.+?)\\\]', r'$$\1$$', text, flags=re.S) text = re.sub(r'\\\((.+?)\\\)', r'$\1$', text, flags=re.S) # 2. 把 \\frac 这类双反斜杠压成单反斜杠(只处理命令名,不碰换行) text = re.sub(r'\\\\([a-zA-Z]+)', r'\\\1', text) # 3. 去掉公式块内多余的空白换行 text = re.sub(r'\$\$(.+?)\$\$', lambda m: '$$' + m.group(1).strip() + '$$', text, flags=re.S) return text

第一条和第二条正则的顺序不能反。先处理定界符再压反斜杠,是因为\[和\]本身也是双反斜杠形态,如果先压反斜杠,\[会变成[,定界符就丢了。这个顺序错误非常隐蔽,跑完看着好像没问题,实际公式全变成普通文本。第三条的strip()是为了去掉公式块首尾的换行,KaTeX 对首尾空白比较敏感,$$后面跟一个换行有时会导致渲染偏移。

批量更新的时候按主键分批走,别一条 UPDATE 扫全表:

UPDATE question SET stem = %s, analysis = %s WHERE id = %s;

1700 万行全量洗一遍,单条更新大概要跑十几个小时,建议只洗「题干里含反斜杠或定界符」的子集,先用 SELECT 把 id 捞出来,再按 500 条一批提交。

4. 按知识点和难度抽题:索引设计与分页写法

题库能导入只是第一步,真正决定它好不好用的是查询。组卷场景的典型诉求是「数学、初中、难度 3、知识点是二次函数,抽 10 道」,这类查询在 1700 万行上没有合适的索引就是灾难。

4.1 知识点标签:逗号串的代价与补救

第 2 章把knowledge_ids设计成了逗号串,好处是和原始数据对齐、导入快,坏处是没法走索引精确匹配。WHERE knowledge_ids LIKE '%1024%'这种写法不仅全表扫,还会误匹配到10240。

补救办法是加一张关联表,把逗号串拆成行:

CREATE TABLE question_knowledge ( question_id BIGINT UNSIGNED NOT NULL, knowledge_id INT UNSIGNED NOT NULL, PRIMARY KEY (question_id, knowledge_id), KEY idx_knowledge_q (knowledge_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 从逗号串拆到关联表(MySQL 8.0 可用递归 CTE) INSERT INTO question_knowledge (question_id, knowledge_id) SELECT q.id, CAST(TRIM(j.kt) AS UNSIGNED) FROM question q JOIN JSON_TABLE( CONCAT('["', REPLACE(q.knowledge_ids, ',', '","'), '"]'), '$[*]' COLUMNS (kt VARCHAR(32) PATH '$') ) AS j WHERE q.knowledge_ids IS NOT NULL AND q.knowledge_ids <> '';

JSON_TABLE是 8.0 才有的,5.7 上得用存储过程或者应用层循环。这条语句的思路是把逗号串临时拼成 JSON 数组,再展开成行,比写一堆SUBSTRING_INDEX要清爽。PRIMARY KEY (question_id, knowledge_id)保证幂等,重复执行不会插重。

关联表建好之后,知识点查询走idx_knowledge_q,回表拿题目详情,比 LIKE 快两个数量级。

4.2 复合索引的列顺序

组卷查询的过滤条件通常是「科目 + 学段 + 难度」,索引列顺序按区分度从高到低排不一定对,更该按「等值条件的组合频率」排。科目只有 5 个值,学段 3 个,难度 5 个,三者组合也就 75 种,区分度都不高,但组合起来过滤效果很好。

-- 已建:KEY idx_subject_stage_diff (subject, stage, difficulty) EXPLAIN SELECT id, stem, options FROM question WHERE subject = 1 AND stage = 2 AND difficulty = 3 ORDER BY id LIMIT 20;

看EXPLAIN的时候重点看三列:type应该是ref或range,出现ALL就是全表扫;rows应该是几千以内而不是千万级;Extra里出现Using filesort说明排序没走索引。因为id是主键且包含在二级索引的叶子节点里,ORDER BY id在 InnoDB 下能直接利用索引顺序,不会额外排序。

如果查询里带了knowledge_id,那就得走两张表:

SELECT q.id, q.stem, q.options FROM question_knowledge qk JOIN question q ON q.id = qk.question_id WHERE qk.knowledge_id = 1024 AND q.subject = 1 AND q.stage = 2 AND q.difficulty = 3 LIMIT 20;

这条的执行计划取决于优化器选谁当驱动表。知识点过滤后剩余行数少的时候,用qk驱动更好;如果某个知识点挂了几十万道题,反过来用q驱动再过滤可能更快。可以用STRAIGHT_JOIN强制顺序做对比测试,但别写死在生产代码里。

4.3 随机抽题:别用 ORDER BY RAND()

这是题库项目里最常见的翻车点。ORDER BY RAND() LIMIT 10在 1700 万行上会生成临时表、给每行算随机数、再排序,实测几十秒起步,并发一上来直接把数据库打满。

可行的替代方案是主键区间随机。先用一次查询拿到 id 上下界,然后在应用层生成随机 id,用>=定位:

import random def pick_random_questions(conn, subject, stage, difficulty, n=10): with conn.cursor() as cur: cur.execute( "SELECT MIN(id) AS lo, MAX(id) AS hi FROM question " "WHERE subject=%s AND stage=%s AND difficulty=%s", (subject, stage, difficulty), ) bound = cur.fetchone() lo, hi = bound['lo'], bound['hi'] if lo is None: return [] picked, seen = [], set() # 尝试次数上限,避免数据稀疏时死循环 for _ in range(n * 10): if len(picked) >= n: break rid = random.randint(lo, hi) if rid in seen: continue seen.add(rid) with conn.cursor() as cur: cur.execute( "SELECT id, stem, options, answer FROM question " "WHERE id >= %s AND subject=%s AND stage=%s AND difficulty=%s " "ORDER BY id LIMIT 1", (rid, subject, stage, difficulty), ) row = cur.fetchone() if row and row['id'] not in {p['id'] for p in picked}: picked.append(row) return picked

这个做法的原理是利用主键的 B+ 树有序性,id >= rid可以直接定位到索引位置再顺序扫一行,代价是 O(log n)。局限也很明显:如果数据在 id 空间上分布稀疏(比如删过大量数据),随机命中率会下降,n * 10的尝试上限就是为此设的保险。数据稀疏到一定程度,就得改用「预生成随机 id 池」或者按知识点分桶各抽若干,把随机摊到小集合上。

4.4 深分页:从 OFFSET 换成 keyset

后台管理页面翻到第 1000 页时,LIMIT 20 OFFSET 19980会先扫掉前 19980 行再丢弃,越翻越慢。换成游标分页,把「第几页」换成「上一页最后一条的 id」:

SELECT id, stem, difficulty FROM question WHERE subject = 1 AND stage = 2 AND difficulty = 3 AND id > %s -- 上一页最后一条的 id ORDER BY id LIMIT 20;

前端只需要记住last_id,不需要页码。代价是不能跳页,但题库管理后台基本只用「上一页/下一页」,这个限制可以接受。如果产品经理坚持要页码,那就得接受深翻页慢,或者在业务层做 id 区间缓存。

5. 避坑与排查:导入和查询阶段最常见的五类翻车

下面这些坑都是在这类题库包上反复出现的,按「现象 → 原因 → 解决」写,遇到了直接对号入座。

5.1 导入跑到一半断开,报 packet 过大

现象:source执行到某个文件的中段报ERROR 2006 (HY000): MySQL server has gone away或者Packet too large,前面导入的数据还在。

原因:单条多值 INSERT 的语句长度超过了服务端的max_allowed_packet,服务端直接断开连接。dump 文件为了导入效率,通常把几百行 VALUES 拼成一条语句,几 MB 很常见。

解决:服务端my.cnf里把max_allowed_packet调到256M并重启,客户端命令行加--max-allowed-packet=256M,两边取小值生效。如果没法改服务端配置,就用split把大 sql 文件按行数切开分次导入,或者让导出方改成--net-buffer-length更小的参数重新导一份。

5.2 公式渲染报错,控制台提示 Undefined control sequence

现象:页面上公式位置显示红字,KaTeX 报Undefined control sequence: \degree。

原因:数据里用了 LaTeX 扩展包里的命令(比如\degree、\xrightarrow),而 KaTeX 只实现了 LaTeX 的一个子集,不认识的命令直接报错。

解决:短期在 auto-render 的macros里补别名,\degree映射到^{\circ};中期用脚本扫一遍全库,把所有非标准命令列出来,能映射的映射,映射不了的在渲染层做白名单过滤,退化成纯文本显示。别指望 KaTeX 全都支持,strict: false只是不报错,不代表能渲染。

5.3 中文变成问号或者乱码

现象:导入后查出来是????或者䏿–‡。

原因:两种可能。????是连接字符集不是 utf8mb4,数据在传输过程中被替换;䏿–‡是数据本身以 latin1 存进去又被当成 utf8 读出来,属于典型的双重编码错误。

解决:先用SHOW VARIABLES LIKE 'character_set%'确认服务端、客户端、连接三处的字符集。导入时必须带--default-character-set=utf8mb4,建库建表也用 utf8mb4。如果数据已经错了,????是不可逆的(原始信息已丢失),䏿–‡可以用CONVERT(BINARY(CONVERT(col USING latin1)) USING utf8mb4)试着救回来,但要在小样本上先验证。

5.4 同一道题出现多次,组卷时重复出题

现象:抽 10 道题,里面有两三道是一模一样的。

原因:原始数据来自多个渠道合并,题干完全相同但 id 不同;或者导入了两次同一个文件,主键自增所以没冲突。

解决:先查重再洗。按题干的前 200 个字符加科目建哈希列,然后分组统计:

SELECT subject, MD5(LEFT(stem, 200)) AS h, COUNT(*) AS c FROM question GROUP BY subject, h HAVING c > 1 ORDER BY c DESC LIMIT 50;

保留 id 最小的那条,其余打上is_dup = 1标记而不是物理删除,方便回溯。组卷查询里统一加AND is_dup = 0。

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

现象:接口平时 200ms,高峰期偶尔飙到 5 秒,慢查询日志里没有记录。

原因:慢查询日志阈值默认 10 秒,5 秒的查询压根不记录;另一种可能是查询本身不慢,但连接池被打满,请求在排队。

解决:把long_query_time临时调到 1 秒,同时开log_queries_not_using_indexes,跑一天看日志。连接池的问题看应用的连接等待时间和活跃连接数,把池大小和数据库的最大连接数对上。这一步没有后悔药,只能靠日志,所以上线前就把这两个开关配好。

6. 从裸库到组卷接口:按难度配额抽题与结果对账

数据洗干净、索引建好之后,最后一步是把它包成一个能用的接口。组卷的核心逻辑不是随机抽,而是「按难度配额抽」——一张卷子要求简单 30%、中等 50%、困难 20%,随机抽是满足不了的。

def build_paper(conn, subject, stage, total=20, ratio=(0.3, 0.5, 0.2)): # ratio 对应难度 1-2 / 3 / 4-5 三档 buckets = [(1, 2, round(total * ratio[0])), (3, 3, round(total * ratio[1])), (4, 5, total - round(total * ratio[0]) - round(total * ratio[1]))] paper = [] for lo_d, hi_d, quota in buckets: with conn.cursor() as cur: cur.execute( "SELECT id, stem, options, answer FROM question " "WHERE subject=%s AND stage=%s AND difficulty BETWEEN %s AND %s " "AND is_dup=0 ORDER BY id LIMIT %s", (subject, stage, lo_d, hi_d, quota), ) paper.extend(cur.fetchall()) return paper

ratio的三档和难度的映射关系可以按学段调整,小学阶段把简单题比例提到 0.5 更合理。配额取整会有余数,最后一档用减法兜底,保证总数严格等于total,这个细节不做的话经常出现「要 20 道给了 18 道」。

抽完之后必须做一次对账,验证抽到的题数和科目、难度分布是否和预期一致:

SELECT difficulty, COUNT(*) AS c FROM question WHERE id IN (101, 205, 309, 412, 518) GROUP BY difficulty;

对账这一步看着琐碎,但它是唯一能证明「抽题逻辑没抽歪」的手段。我一般会在接口返回体里带一个debug字段,把实际抽到的难度分布写进去,压测时一眼就能看出哪一档配额没抽满。

讲个血泪经验。早期做类似项目时,我觉得随机抽题用ORDER BY RAND()简单直接,测试环境几千条数据跑得飞快,上线到百万级直接被打爆,那天的排查花了整整六个小时才发现是这一行 SQL。从那以后,我每次拿到一份新题库,导入完成后的第一件事就是跑一遍EXPLAIN,把所有组卷用到的查询挨个过一遍执行计划,再压一轮并发,不看到type是ref或range就不往下写业务代码。这套动作现在已经是固定流程了。

希望帮到你。

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

RELATED READING

延伸阅读

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