1700万道K12题库MySQL导入与LaTeX公式渲染实践 简介面向在线K12教育从业者的题库数据库学习包围绕数学、物理、化学等学科试题的存储与公式呈现展开重点解决题目结构化入库、LaTeX公式录入及网页端显示等常见难题。压缩包共583个文件以574张PNG题目或公式图片和2份DOCX说明文档为主辅以HTML示例页、JS脚本、SQL建库脚本及TXT说明整体约2.03MB体积轻便却覆盖了从数据结构到前端渲染的关键环节。已有1109人学习。读者可参考其中的数据库表结构、章节与知识点组织方式、题目属性字段设置以及基于MathJax的公式显示示例快速理解一套可落地的K12题库建设思路样本题库与配套说明也便于对照检查录入规范、排查公式显示问题适合产品、研发及教研人员作为方案参考。1. 一套能直接导入的中小学题库1700 万道题省掉的是内容成本去年帮一个做教务系统的团队救火卡了三个月的不是接口是题。手上这份中小学题库 mysql 压缩包解压出来是一套可以直接导入 MySQL 的数据小学到高中全覆盖数学题库和物理题库占大头题干、选项、答案、解析都在总量接近 1700 万道latex 公式显示按$...$的约定存在题干里。对一个想搭免费 k12 题库站、或者给内部组卷工具补内容的团队来说它省掉的正是最贵也最枯燥的那部分。下面按我实际导入、查询、改表的顺序讲踩过的地方都标出来。2. 表结构拆解题目、选项、知识点三张主表怎么扛住 1700 万行拿到一份 dump别急着source。先花二十分钟看表结构能省掉后面几天的返工。我一般先确认三件事主键类型够不够大、长文本放在哪、知识点是关联表还是冗余字段。这三点决定了后面组卷接口是 50ms 还是 5 秒。2.1 六张核心表的分工一套 K12 题库的最小闭环常见做法是拆成六张表学科/学段字典、题目主表、选项表、知识点表、题目与知识点的关联表、试卷与试卷题目表。字典表数据量小但被所有查询引用独立出来之后前端筛选项直接从库里读不用硬编码。表名作用量级关键点t_subject学科 学段字典几十行名称与学段联合唯一t_question题目主表千万级长文本字段、去重哈希t_option选择题选项千万级与主表一对多t_knowledge知识点树万级parent_id 自关联t_question_knowledge题目↔知识点千万级多对多用于反查t_paper / t_paper_question试卷与组卷结果万级保存抽题快照主表建表语句我大概会这么写CREATE TABLE t_question ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, subject_id SMALLINT UNSIGNED NOT NULL COMMENT 学科关联 t_subject.id, stage_id TINYINT UNSIGNED NOT NULL COMMENT 1小学 2初中 3高中, grade_id TINYINT UNSIGNED NOT NULL COMMENT 年级 1~12, type_id TINYINT UNSIGNED NOT NULL COMMENT 1单选 2多选 3填空 4解答, difficulty TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT 1~5 五档, stem TEXT NOT NULL COMMENT 题干公式用 $...$ 包裹, answer TEXT NOT NULL COMMENT 答案解答题可含多行解析, analysis TEXT NULL COMMENT 解析允许为空, knowledge_path VARCHAR(255) NOT NULL DEFAULT COMMENT 如 /12/87/ 冗余路径, has_formula TINYINT(1) NOT NULL DEFAULT 0 COMMENT 题干是否含 LaTeX, content_hash CHAR(32) NOT NULL COMMENT 题干归一化后的 MD5用于去重, PRIMARY KEY (id), UNIQUE KEY uk_hash (content_hash), KEY idx_pick (subject_id, stage_id, type_id, difficulty), KEY idx_know (knowledge_path), FULLTEXT KEY ft_stem (stem) WITH PARSER ngram ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;几个字段不是随手加的。id用BIGINT UNSIGNED1700 万用INT其实也够但后面要拆子题库、要合并别的数据源留足空间比事后改主键便宜得多。stem、answer、analysis用TEXT而不是VARCHAR(2000)是因为 InnoDB 单行上限 8126 字节左右一道带公式和表格的解答题很容易顶到边界把长文本放溢出页主表行更紧凑全表扫描时 IO 明显更省。has_formula是个标记位后面做公式渲染回归测试时直接WHERE has_formula 1抽样就行不用扫全表跑正则。content_hash和uk_hash是防止导入翻倍的保险。1700 万道题分批灌进去中途失败重跑是常事没有唯一键第二次导数直接翻成 3400 万后面所有统计全废。2.2 选项表与知识点多对多别硬 JOIN选项表结构简单但字段顺序和索引有讲究CREATE TABLE t_option ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, question_id BIGINT UNSIGNED NOT NULL, label CHAR(1) NOT NULL COMMENT A/B/C/D…, content TEXT NOT NULL COMMENT 选项正文同样可能含公式, is_right TINYINT(1) NOT NULL DEFAULT 0, sort_no TINYINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_q (question_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_q用(question_id, sort_no)而不是只建question_id因为取一道题的选项时几乎总要按sort_no排序联合索引能顺手把排序也吃掉省一次 filesort。一页试卷 20 道题如果用WHERE question_id IN (...)一次性捞选项20 次主键范围扫描比 20 次单独查询快一个数量级。知识点是这套库里最容易设计错的地方。教科书式做法是标准多对多t_question_knowledge(question_id, knowledge_id)。做后台管理没问题但组卷时按知识点抽题三表 JOIN 加上千万级数据很容易跑出秒级响应。我一般会在主表额外冗余一个knowledge_path形如/12/87/把知识点从根到叶的路径拼进去查询时用前缀匹配-- 抽「87 号知识点及其所有子知识点」下的单选题能走 idx_know 的最左前缀 SELECT id, stem FROM t_question WHERE knowledge_path LIKE /12/87/% AND type_id 1 AND difficulty BETWEEN 2 AND 4 LIMIT 200;路径前缀匹配能吃到索引代价是知识点树调整时要回刷路径。这个代价我认因为组卷是高频操作改知识点是低频操作。t_question_knowledge关联表照样保留用于「一道题属于哪些知识点」的正向展示。2.3 索引与分区别指望一个组合索引打天下idx_pick (subject_id, stage_id, type_id, difficulty)覆盖的是组卷最常见的筛选组合学科 学段 题型 难度区间。字段顺序不能乱把区分度低的stage_id放前面会导致索引选择性差。真正查询时如果只按subject_id和difficulty过滤这个索引只能用到第一列剩下的回表过滤数据量大时会明显变慢——遇到这种查询模式宁可再加一个(subject_id, difficulty, id)的窄索引让覆盖扫描把id直接吐出来。分区要不要上1700 万行对 InnoDB 不算大单表完全撑得住。但如果后面按学科拆成十几个子库、或者想快速删掉某个学段的历史数据按subject_id做 HASH 分区或者按stage_id做 LIST 分区会舒服很多。分区键必须进主键这是硬约束改分区等于重建表所以这件事要在一开始定别等数据灌完了再想。3. 从 zip 到可查库MySQL 参数、分批导入与导入后校验解压之后第一件事不是建库是看文件形态是整库mysqldump出来的.sql还是按表拆开的 CSV/TSV。两种走法完全不同.sql直接source最省事CSV 就得自己写导入脚本。不管哪种服务器参数得先调默认配置导 1700 万行基本会中途断掉。3.1 连接层参数与字符集字符集是这套库的第一道坎。库、表、连接三处必须全是utf8mb4少一处就等着看题干里的生僻字变成问号。[mysqld] character-set-server utf8mb4 collation-server utf8mb4_general_ci max_allowed_packet 256M innodb_buffer_pool_size 8G innodb_log_file_size 1G innodb_flush_log_at_trx_commit 2 local_infile 1max_allowed_packet调到 256M是因为批量 INSERT 拼出来的单条语句很容易超过默认的 64M报Packet too large直接中断。innodb_buffer_pool_size给到物理内存的一半左右导入完之后热数据全在内存里查询性能差好几倍。innodb_flush_log_at_trx_commit 2是导入期的妥协每次提交不强制刷盘速度能快两三倍代价是机器断电可能丢最后几秒的数据——导入阶段丢了重跑就行导入完成后记得改回 1。连接串这边Python 的pymysql必须显式带charsetutf8mb4命令行客户端进来先SET NAMES utf8mb4;。这一步漏掉导进去的中文会以 latin1 存成乱码而且是不可逆的脏数据。3.2 分批导入脚本把大事务拆碎如果是 CSV我会写一个按批提交的脚本而不是一条条INSERT# bulk_import.py —— 按批灌入避免单个大事务把 undo log 撑爆 import csv import sys import pymysql BATCH 5000 # 每批行数5000 左右是吞吐和内存的平衡点 INSERT_SQL ( INSERT INTO t_question (id, subject_id, stage_id, grade_id, type_id, difficulty, stem, answer, analysis, knowledge_path, has_formula, content_hash) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ) def import_csv(conn, path): with conn.cursor() as cur, open(path, newline, encodingutf-8) as f: reader csv.reader(f) buf [] total 0 for row in reader: buf.append(row) if len(buf) BATCH: cur.executemany(INSERT_SQL, buf) # pymysql 会重写成多值 INSERT conn.commit() total len(buf) buf.clear() if total % 100000 0: print(fimported {total}, flushTrue) if buf: cur.executemany(INSERT_SQL, buf) conn.commit() total len(buf) return total if __name__ __main__: conn pymysql.connect( host127.0.0.1, port3306, userroot, passwordsys.argv[1], databasek12, charsetutf8mb4, autocommitFalse, ) print(import_csv(conn, sys.argv[2])) conn.close()executemany在 pymysql 里对 INSERT 语句不是逐条执行它会按占位符把多行拼成一条多值INSERT所以BATCH 5000实际拼出来的是 5000 组 VALUES。这个值不能调太大否则单条语句字节数会顶到max_allowed_packet也不能太小否则网络往返和事务提交开销占大头。5000 到 10000 之间我一般先跑一万行测一下每秒行数再定最终值。导入前在会话里关掉一些校验能再快一截SET SESSION sql_log_bin 0; -- 单机导入不需要写 binlog SET SESSION unique_checks 0; -- 唯一键检查延后注意这会放过重复数据 SET SESSION foreign_key_checks 0;unique_checks 0是双刃剑。它让 InnoDB 走批量插入的快速路径但content_hash的唯一约束在这期间不生效重复题会照单全收。我的做法是导入前先确认源数据是干净的或者干脆不清空这个开关、只关 binlog宁可慢 30% 也别引入去重隐患。3.3 导入后的四项校验导完别急着写接口先跑几条 SQL 把数据质量摸清楚-- 1. 各学科题量分布确认没有整块丢失 SELECT subject_id, COUNT(*) AS cnt FROM t_question GROUP BY subject_id ORDER BY cnt DESC; -- 2. 公式题占比数学/物理应该明显高于语文英语 SELECT subject_id, SUM(has_formula) AS formula_cnt, ROUND(SUM(has_formula) / COUNT(*), 4) AS ratio FROM t_question GROUP BY subject_id; -- 3. 选择题缺选项的孤儿题 SELECT COUNT(*) FROM t_question q LEFT JOIN t_option o ON o.question_id q.id WHERE q.type_id IN (1, 2) AND o.id IS NULL; -- 4. 抽查题干里反斜杠是否还在公式有没有被转义吃掉 SELECT id, LEFT(stem, 80) FROM t_question WHERE has_formula 1 LIMIT 20;第四条最关键。公式题如果被中间层反斜杠转义坑过一次\frac会变成rac\times会变成换页符加imes渲染器直接报错。抽样 20 条肉眼过一遍比后面调试前端渲染快得多。第 3 条查出来的孤儿题一般出现在多选和单选上如果比例超过千分之一说明选项文件和主表没对齐得回原始数据核对。4. 组卷与公式渲染随机抽题的查询写法和 LaTeX 的前端链路数据进库只是开始业务上真正天天被调用的是两个能力按条件随机抽题组卷以及把带公式的题干正确渲染出来。这两个地方各自的坑都很典型。4.1 随机抽题别用 ORDER BY RAND()新手最容易写出的抽题语句是ORDER BY RAND() LIMIT 20。在几千行的表上它能跑在 1700 万行上它会先给全表每一行算一个随机数再排序一次请求 5 秒起步并发上来直接把数据库打死。我一般用「随机列 范围采样」的组合。给主表加一个rand_key字段取值 0 到 1 之间建索引ALTER TABLE t_question ADD COLUMN rand_key FLOAT NOT NULL DEFAULT 0 COMMENT 随机采样键, ADD KEY idx_rand (subject_id, type_id, rand_key); -- 初始化时刷一遍分片执行别一条 UPDATE 打全表 UPDATE t_question SET rand_key RAND() WHERE subject_id 2 AND id % 8 0;抽题时先随机一个阈值从该阈值往后取不够再从头补-- 第一次采样从随机阈值往后取 20 道 SELECT id, stem, type_id, difficulty FROM t_question WHERE subject_id 2 AND type_id 1 AND difficulty BETWEEN 2 AND 4 AND rand_key 0.3721 ORDER BY rand_key LIMIT 20;idx_rand的最左列是subject_id第二列type_id第三列rand_key这条查询能完整走索引范围扫描取 20 行就停不做全表排序。命中不足 20 行时把阈值改成 0 再补一次两次结果在应用层按id去重。rand_key不需要每次查询都更新隔一段时间重刷一遍就行——它的作用是打散分布不是保证每次抽到的题绝对不同。还有一种更稳的做法是主键区间采样先SELECT MIN(id), MAX(id)再在区间内随机取 20 个起点每个起点取一道题。缺点是题目 id 不连续时会有空洞需要重试。两种方案我都用前者适合「按知识点难度筛完再随机」后者适合「全库随机」。4.2 公式渲染链路存储约定、转义、渲染三步latex 公式显示能不能成取决于存储约定是否统一。这套库的题干里公式以$...$行内、$$...$$块级存放反斜杠是原始单反斜杠不做双重转义。前端拿到 JSON 后不能直接v-html必须先按定界符切分文本部分转义 HTML公式部分交给 KaTeX。import katex from katex import katex/dist/katex.min.css const MATH_RE /\$\$([\s\S]?)\$\$|\$([^$\n]?)\$/g function escapeHtml(s) { return s.replace(/[]/g, c ( { : amp;, : lt;, : gt;, : quot;, : #39; }[c] )) } export function renderStem(text) { let out let last 0 let m MATH_RE.lastIndex 0 while ((m MATH_RE.exec(text)) ! null) { out escapeHtml(text.slice(last, m.index)) const tex m[1] ?? m[2] const display Boolean(m[1]) try { out katex.renderToString(tex, { throwOnError: false, // 单条公式坏掉只降级不让整道题白屏 strict: false, // 放过题库里混进来的中文、全角符号 displayMode: display, output: html, }) } catch (e) { out escapeHtml(m[0]) // 渲染失败就把原始 LaTeX 显示出来 } last m.index m[0].length } return out escapeHtml(text.slice(last)) }三个参数值得说。throwOnError: false是保命的1700 万道题里一定有手工录入的畸形公式比如少一个右括号、\begin{matrix}没闭合一旦抛异常整页白屏用户体验直接崩。strict: false放行非标准字符K12 题库里\angle ABC后面跟中文、单位写成全角的情况很常见严格模式会一片红。displayMode区分行内和块级块级公式居中独占一行解答题里的多行推导必须用它。try/catch外面那层escapeHtml同样重要。题干是外部录入的数据里面可能混进script公式渲染前必须先把非公式片段转义掉否则就是个 XSS 口子。4.3 接口层别在序列化时二次转义后端把题干拼成 JSON 返回时最隐蔽的坑是反斜杠被转义两次。Python 的json.dumps默认会把\变成\\前端JSON.parse之后拿到的是一个反斜杠看起来正常但如果中间又经过一次字符串拼接或者模板渲染就会变成两个反斜杠KaTeX 收到\\frac直接报错。from flask import Flask, request, jsonify app Flask(__name__) app.json.ensure_ascii False # 中文直出别变 \uXXXX app.get(/api/questions) def list_questions(): subject request.args.get(subject, typeint) limit min(request.args.get(limit, 20, typeint), 50) with get_conn() as conn, conn.cursor() as cur: cur.execute( SELECT id, stem, answer, analysis, has_formula FROM t_question WHERE subject_id %s ORDER BY rand_key LIMIT %s, (subject, limit), ) rows cur.fetchall() # 直接返回原始字符串交给前端 renderStem 处理 return jsonify([{ id: r[0], stem: r[1], answer: r[2], analysis: r[3], hasFormula: bool(r[4]), } for r in rows])ensure_ascii False让中文原样输出配合Content-Type: application/json; charsetutf-8避免前端乱码。查询参数一律走占位符ORDER BY rand_key是固定字符串不是用户输入这条不涉及注入。接口层不做任何 HTML 处理也不做公式预渲染渲染交给前端这样同一份数据在 Web、小程序、导出 Word 各个端可以走不同的渲染器。5. 避坑与排查导入乱码、公式丢反斜杠、组卷慢查询的五条记录这部分是实打实踩出来的每条都按「现象 → 原因 → 解决」写遇到同类问题可以直接对照。5.1 导入阶段的两个坑坑一题干里的公式丢了反斜杠。现象是导入完成后抽查has_formula 1的题\frac{1}{2}变成了rac{1}{2}\times变成了一个换页符加imes前端渲染器整片报ParseError。原因是 MySQL 字符串字面量里反斜杠本身是转义符\f被解释成换页符、\t被解释成制表符\n被解释成换行。原始数据经过INSERT字面量或者某个中间层做了一次字符串处理后反斜杠就被吃掉了。解决办法是在导入会话里关掉反斜杠转义SET SESSION sql_mode CONCAT(sql_mode, ,NO_BACKSLASH_ESCAPES);或者统一走LOAD DATA LOCAL INFILE并指定FIELDS ESCAPED BY 。已经导脏的数据靠REPLACE修不干净因为原本的\t位置现在是真制表符分不清是公式还是排版重导一遍最省事。坑二中文和特殊符号变问号。现象是题干里出现??或者中文这种乱码。前一种通常是连接字符集写成了latin1写入时无法表示的字符被替换成问号属于不可逆损坏后一种是「以 latin1 读 utf8 数据」的典型表现数据本身没坏改连接字符集就能恢复。还有一种更隐蔽的情况库和表都建成了utf8三字节存某些生僻字和数学符号时直接报Incorrect string value。处理方式统一成一个标准动作建库建表一律utf8mb4utf8mb4_general_ci客户端连接串显式charsetutf8mb4命令行进来先SET NAMES utf8mb4;。三处对齐之后再导这类问题基本绝迹。5.2 查询阶段的两个坑坑三组卷接口偶发 8 秒以上。现象是压测时随机组卷的 P99 飙到 8s慢日志里全是ORDER BY RAND()。原因前面说过全表算随机数再排序1700 万行根本扛不住。解决办法是换成rand_key范围采样配合(subject_id, type_id, rand_key)联合索引实测同样的机器上 P99 降到 40ms 以内。改完之后记得把旧接口的缓存也清掉否则会以为是没生效。坑四题库列表的总数统计把库拖垮。现象是后台列表页加载三秒以上SHOW PROCESSLIST能看到一条SELECT COUNT(*)挂了很久。原因是 InnoDB 不像 MyISAM 那样缓存行数带多条件筛选的COUNT(*)必须真实扫描如果筛选列没进索引还要回表。解决办法分场景前台列表直接改成「是否有下一页」的游标分页用LIMIT n1判断不要精确总数后台确实要总数就用覆盖索引把COUNT变成索引扫描再加 5 分钟缓存。别为了一个数字把整张表扫穿。5.3 维护阶段的一个坑坑五批量修正答案把写入全锁住。现象是运维跑了一条UPDATE t_question SET answer ... WHERE subject_id 2影响 200 多万行跑了两分半期间所有组卷请求排队超时。原因是单事务大更新会持有大量行锁undo log 持续膨胀主从延迟也跟着涨。解决办法是按主键区间切片每批两千行提交一次批间 sleep 一下给主从同步留时间-- 每批只改 2000 行循环执行直到 affected_rows 0 UPDATE t_question SET answer TRIM(answer) WHERE subject_id 2 AND id 0 AND id 2000 AND answer TRIM(answer) LIMIT 2000;脚本里把id区间递增每批提交后time.sleep(0.05)。同样的思路适用于任何超过十万行的UPDATE或DELETE宁可跑十分钟也别让线上停摆两分钟。6. 二次开发给这套题库加统计表、全文检索和难度分层原始题库是一份静态内容真正让它变成产品的是后面这三类改造。按投入产出排我建议先做统计再做检索最后碰难度模型。第一步是加答题统计表把「这道题有多难」从人工标注的difficulty变成真实数据CREATE TABLE t_question_stat ( question_id BIGINT UNSIGNED NOT NULL, attempt_cnt INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 作答次数, right_cnt INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 答对次数, avg_seconds INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 平均耗时秒, right_rate DECIMAL(5,4) NOT NULL DEFAULT 0 COMMENT 正确率0~1, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (question_id), KEY idx_rate (right_rate) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 每次作答后增量更新正确率用「旧值算出新值」避免每次全量重算 INSERT INTO t_question_stat (question_id, attempt_cnt, right_cnt, avg_seconds, right_rate) VALUES (%s, 1, %s, %s, %s) ON DUPLICATE KEY UPDATE attempt_cnt attempt_cnt 1, right_cnt right_cnt VALUES(right_cnt), avg_seconds ROUND((avg_seconds * attempt_cnt VALUES(avg_seconds)) / (attempt_cnt 1)), right_rate right_cnt / attempt_cnt;注意最后那行right_rate用的是更新后的right_cnt和attempt_cntMySQL 在ON DUPLICATE KEY UPDATE里按从左到右顺序求值所以字段顺序不能调。正确率数据攒够几万次作答之后就可以把难度分档从人工 1~5 改成按right_rate分位重算试卷的整体难度曲线会明显更合理。第二步是检索。主表上虽然有FULLTEXT ... WITH PARSER ngram但 ngram 对长题干和带公式的文本召回质量一般公式符号会被切碎。题量上到千万级、又要求相关性排序时接一个外部检索服务更合适用update_time做增量同步每五分钟拉一次变更题干里的 LaTeX 在入索引前先剔除定界符只留语义部分检索命中后再回表拿原始题干渲染。改造完成后验证方式很简单拿二十个真实搜索词跑一遍看前十条里公式题的渲染是否正常、题干截断位置有没有切在公式中间。第三步是组卷策略。原来的随机抽题只按知识点和难度过滤改造后可以按「知识点覆盖度 难度分布 历史正确率」做加权同一份试卷里避免出现同一道题的变体。验证方法是跑一个回归脚本固定随机种子生成 100 套卷检查每套的知识点分布方差、难度均值以及和上一次抽题的重复率。改造点改动范围验证方式答题统计新增统计表 作答接口抽样题目的正确率与人工统计对齐全文检索主表 外部索引同步任务20 个真实搜索词的 Top10 人工核对难度重算统计表 组卷逻辑100 套卷的难度均值与目标区间比对有一回我在一张两千万行的表上直接加字段忘了先看机器剩余磁盘ALTER TABLE跑到一半空间不足回滚又花了四十分钟业务停了整个下午。从那以后我每次动大表之前都强制走一遍SHOW TABLE STATUS看数据量和剩余空间、先用小表验证 DDL 耗时、再挑低峰期执行。这套题库结构不复杂难的是数据量大之后每个操作的代价都被放大慢一步就够喝一壶。希望帮到你。本文还有配套的精品资源点击获取