
简介这是一份面向K12在线教育机构、题库系统开发者与教研人员的MySQL题库建设资料聚焦数学、物理、化学等学科试题的存储结构、章节与知识点组织以及LaTeX公式录入和网页显示等常见难题。压缩包共583个文件约2.03MB主体为574张PNG界面与题型截图另含docx数据结构说明、HTML与JS公式渲染示例、SQL建库脚本及少量文本文件便于从数据表设计、前端展示到样例题库整体理解。已有1109人学习下载。读者可参考其中的章节层级、知识点体系和题目属性字段设置结合样本题库与数据结构图梳理试题录入、存储、检索和展示流程并借助MathJax等前端方案理解公式排版与兼容处理为自建或改造K12数理化题库提供可落地的结构范本与实现思路。1. 从一份 1700 万条的 K12 题库说起MySQL 里存公式题到底难在哪去年帮一个做在线练习的朋友收拾过一个烂摊子题库散在几十个 Excel 和 txt 里家长端搜「二次函数」要么搜不到要么出来的题干是\frac{1}{2}x^2这种带反斜杠的原始字符串页面上就是一坨乱码。后来的做法是把整个 k12题库 灌进 MySQL题干、选项、答案、解析全部保留 LaTeX 原文前端统一渲染。这份 1700 万题库 的 MySQL 数据包就是按这个思路整理的覆盖数学题库、物理题库到语文英语题型从选择、填空到解答适合自己搭组卷系统、做本地检索或者给现有教学产品补数据底座的开发者。真正的难点不在数据量而在公式怎么存、怎么搜、怎么渲染。2. 题库表结构拆解一份能扛住 1700 万行的 K12 字段设计拿到一个大 SQL 包最忌讳直接source进去然后开始写业务。先看清楚表是怎么切的比后面调十次索引都管用。1700 万行的量级单表设计稍微松一点后面加字段、加索引都是一次几十分钟的锁表操作。2.1 主表字段与建表语句题库类数据的天然主轴是「学科 → 学段 → 年级 → 章节 → 知识点」题目本身再挂题型、难度、来源。我一般把主表压成一张宽表字段控制在 25 个以内正文类的大字段单独放避免SELECT *时把磁盘 IO 打满。CREATE TABLE t_question ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 题目主键, 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, chapter_id INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 章节ID, qtype TINYINT UNSIGNED NOT NULL COMMENT 题型:1单选 2多选 3填空 4解答 5判断, difficulty TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT 难度1-5, stem TEXT NOT NULL COMMENT 题干,LaTeX原样保存, options JSON DEFAULT NULL COMMENT 选项数组,仅选择题有值, answer VARCHAR(512) NOT NULL DEFAULT COMMENT 答案, analysis TEXT COMMENT 解析, source VARCHAR(64) NOT NULL DEFAULT COMMENT 来源标记, has_formula TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 题干是否含LaTeX公式, status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 1正常 0下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_cat (subject,stage,grade,qtype,difficulty), KEY idx_chapter (chapter_id,difficulty), KEY idx_formula (has_formula) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;subject、stage、grade用TINYINT而不是VARCHAR是因为这三个字段会出现在几乎每条查询的WHERE里定长整型的索引体积只有字符串的三分之一左右1700 万行下这个差距会直接体现到索引页的命中率上。stem和analysis用TEXT而不是VARCHAR(2000)是因为长文本走行外存储主键索引页能塞下更多行扫描时少读很多无关数据。has_formula这个冗余字段是我后来加的。渲染层需要判断哪些题要走 KaTeX、哪些直接输出纯文本如果每次都在stem LIKE %\\%上做判断1700 万行的全表扫描基本等于把数据库打死。导入时用一次UPDATE把它算好后面查询就只是一次索引命中。提示如果手里的包已经建好表且字段名不同别急着改包先建一张视图或写一层字段映射把差异挡在应用侧改数据的成本远高于改代码。2.2 选项与答案的存储格式JSON 还是分隔符选择题的选项是题库里最容易设计歪的地方。常见三种做法拆成 A/B/C/D 四个字段、用一个分隔符拼成字符串、存 JSON 数组。前两种在题干换行或者选项本身含公式时会直接崩掉——选项内容里出现$x1$这种用|或##分隔切分逻辑就得写一堆转义补丁。[A. $x1$ 或 $x-1$, B. $x2$, C. $x\\frac{1}{2}$, D. 无实数根]存 JSON 的好处是结构自解释前端JSON.parse之后直接拿到数组不用关心分隔符。MySQL 5.7 以后原生支持JSON类型8.0 还能对 JSON 内部字段建函数索引。代价是导入时要保证 JSON 合法性一条脏数据会让整批LOAD DATA中断。答案字段我建议统一成字符串选择题存A或AB填空题存 LaTeX 原文解答题存分步要点用\n分隔。不要为不同题型建不同答案表1700 万行下多一次 JOIN 就是多一次随机 IO。2.3 知识点标签与索引宽表还是关联表知识点是典型的多对多一道题可能同时挂「二次函数」和「根的判别式」。如果按宽表在主题上开kp1、kp2、kp3三个字段查询时就是三个OR索引基本失效。我一般拆一张关联表CREATE TABLE t_question_kp ( question_id BIGINT UNSIGNED NOT NULL, kp_id INT UNSIGNED NOT NULL, PRIMARY KEY (question_id,kp_id), KEY idx_kp (kp_id,question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;主键(question_id, kp_id)保证一道题不会重复挂同一个知识点反向索引idx_kp支撑「按知识点筛题」这个最高频的查询。这张表在 1700 万题上大概会有 4000 万到 6000 万行单独放一个库或者单独一个表空间做统计类查询时不会拖累主表。2.4 字符集、引擎与导入前的参数准备字符集只有一个答案utf8mb4。K12 题干里会出现生僻字、数学符号、全角标点utf8三字节根本存不下导入时报Incorrect string value十有八九就是这个原因。排序规则用utf8mb4_general_ci就够0900_ai_ci在 8.0 上更准但对中文意义不大反而在部分老客户端上不兼容。引擎必须是 InnoDB别用 MyISAM。1700 万行导入中途失败是常事MyISAM 没有事务中断后表就是半残状态只能删了重来。InnoDB 至少能按批次提交断了从断点续。导入前建议临时改这几个参数导完再调回来SET GLOBAL innodb_flush_log_at_trx_commit 2; -- 导入期放宽刷盘,崩溃可能丢1秒数据 SET GLOBAL sync_binlog 0; -- 关掉binlog同步刷盘 SET GLOBAL innodb_buffer_pool_size 4294967296; -- 至少4G,能全放下索引最好 SET SESSION unique_checks 0; -- 数据包已去重时可关闭校验 SET SESSION foreign_key_checks 0; -- 无外键约束时省掉检查开销innodb_flush_log_at_trx_commit2是导入期提速最明显的开关代价是数据库进程崩溃可能丢最后一秒的事务。所以导完一定要执行一次FLUSH LOGS并把参数改回 1别把这个设置带进生产环境这是血泪经验。3. 把 MySQL 包跑起来从建库、分批导入到第一条查题结构看完就该动手了。这一章按我实际跑一遍的顺序写起库、导数据、校验、压一条查询看基线。中间任何一步没验证就往下走后面出错你会分不清是数据的问题还是代码的问题。3.1 起库与实例参数本地验证不建议直接用 Docker 默认配置默认innodb_buffer_pool_size只有 128M导 1700 万行会慢到怀疑人生。我的做法是起容器时就把内存和字符集定死docker run -d --name k12db \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEk12 \ -p 3306:3306 \ -v /data/k12db:/var/lib/mysql \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_general_ci \ --innodb-buffer-pool-size4G \ --max-allowed-packet256M \ --local-infile1max-allowed-packet256M是为了防止单条INSERT超长被截断local-infile1是给后面LOAD DATA LOCAL INFILE用的。这两个参数不设导入时会在几百万行处报一个看起来毫不相关的错误翻车概率很高。建库语句顺手把字符集写死别依赖服务端默认值CREATE DATABASE k12 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE k12;3.2 分批导入LOAD DATA 的字段转义陷阱大 SQL 包直接source是最慢的路子MySQL 客户端逐条解析INSERT1700 万行走完可能要十几个小时中途断了还得从头来。如果包里是INSERT语句先转成 TSV如果已经是 TSV 或 CSV直接走LOAD DATA。# 把大 SQL 里的 INSERT 抽成 TSV,一行一题 python3 split_insert.py --input k12_full.sql --output data.tsv --batch 50000 # 按 50 万行切分,便于失败重试 split -l 500000 data.tsv chunk_关键在导入语句的转义选项上这一行写错所有公式全废LOAD DATA LOCAL INFILE /data/chunk_aa INTO TABLE t_question CHARACTER SET utf8mb4 FIELDS TERMINATED BY \t OPTIONALLY ENCLOSED BY ESCAPED BY -- 关键:关掉反斜杠转义,否则 \frac 变成 frac LINES TERMINATED BY \n IGNORE 1 LINES (subject, stage, grade, chapter_id, qtype, difficulty, stem, options, answer, analysis, source);ESCAPED BY 是整篇文章最值钱的一行。MySQL 的LOAD DATA默认ESCAPED BY \\遇到\f、\n、\t、\\都会当转义序列处理\frac里的\f会被解释成换页符导进去就是rac{1}{2}。我第一次遇到这个问题查了三个小时以为是字符集最后SELECT HEX(stem)才看出来。关掉转义之后字段内的和\t就变成了硬约束要么导出时统一把字段内的 Tab 替换掉要么改TERMINATED BY用一个数据里绝对不会出现的分隔符比如\x01。分批导入用脚本包一层每批提交后记录行数失败可从断点续for f in chunk_*; do echo loading $f mysql -h127.0.0.1 -uroot -proot123 --local-infile1 k12 \ -e LOAD DATA LOCAL INFILE $f INTO TABLE t_question \ CHARACTER SET utf8mb4 FIELDS TERMINATED BY \t \ OPTIONALLY ENCLOSED BY \ ESCAPED BY \ LINES TERMINATED BY \n \ (subject,stage,grade,chapter_id,qtype,difficulty,stem,options,answer,analysis,source); echo $f done at $(date) load.log done每批 50 万行1700 万行大概 34 批4G 缓冲池下每批几十秒到两三分钟不等。中断了看load.log最后一条从下一批继续不用重跑全量。3.3 导入后校验条数、抽样和公式完整性导入完成第一件事不是写业务是校验。我一般跑三条-- 1.总条数是否对得上源包 SELECT COUNT(*) FROM t_question; -- 2.分学科条数分布,看有没有整段丢失 SELECT subject, COUNT(*) FROM t_question GROUP BY subject ORDER BY 2 DESC; -- 3.含公式的题抽样,重点看反斜杠还在不在 SELECT id, LEFT(stem, 80) FROM t_question WHERE has_formula 1 LIMIT 20; -- 4.内容被截断的异常行 SELECT COUNT(*) FROM t_question WHERE stem OR stem IS NULL;第 3 条是必看的。LEFT(stem, 80)输出里如果\frac变成了rac或者\sqrt变成sqrt说明转义没关干净整批回滚重导比事后修数据便宜得多。另外补一句has_formula的填充逻辑导入完成后跑一次UPDATE t_question SET has_formula 1 WHERE stem LIKE %\\\\% OR analysis LIKE %\\\\% LIMIT 1000000; -- 分批更新,避免长事务锁表注意LIKE里的反斜杠在 MySQL 字符串常量里本身要转义%\\\\%实际匹配的是一个反斜杠。这一层的层数很容易数错写完SELECT一条已知含公式的记录验证一下再执行UPDATE。3.4 查询基线分页和随机抽题别踩 ORDER BY RAND()数据进去之后先压两条最典型的查询看基线别等业务上线才发现慢。按学科 年级 难度翻页第一页没问题翻到第 5000 页会慢得离谱因为LIMIT 100000, 20要先扫 10 万行再丢掉-- 反例:深分页 SELECT id, stem FROM t_question WHERE subject 1 AND stage 2 AND grade 9 ORDER BY id LIMIT 100000, 20; -- 推荐:游标分页,记住上一页最后一个 id SELECT id, stem FROM t_question WHERE subject 1 AND stage 2 AND grade 9 AND id 100000 ORDER BY id LIMIT 20;组卷功能里的随机抽题更危险-- 反例:1700万行上 ORDER BY RAND() 会把临时表撑爆 SELECT id FROM t_question WHERE subject 1 ORDER BY RAND() LIMIT 10;1700 万行做RAND()排序等于全表物化一遍再排序。可行做法是先按条件拿到MIN(id)和MAX(id)在区间里随机取若干个 id再回表过滤# 在应用层生成随机 id,再批量查询,最多重试3轮 import random def pick_random(conn, cond, size10): cur conn.cursor() cur.execute(fSELECT MIN(id), MAX(id) FROM t_question WHERE {cond}) lo, hi cur.fetchone() picked {} for _ in range(3): ids [random.randint(lo, hi) for _ in range(size * 3)] placeholders ,.join([%s] * len(ids)) cur.execute( fSELECT id, stem FROM t_question WHERE id IN ({placeholders}) AND {cond}, ids, ) for row in cur.fetchall(): picked[row[0]] row[1] if len(picked) size: break return list(picked.values())[:size]随机 id 命中率取决于条件的选择性学科 年级这种过滤大约能命中几万分之一取 3 倍量再重试三轮基本够用。条件越苛刻比如精确到某个知识点命中率越低这时候应该走关联表先取题目 id 再回表而不是在主键区间上硬碰。4. LaTeX 公式链路从 varchar 字段到浏览器里的数学公式题库类项目最容易在「看起来跑通了」之后翻车的地方就是公式。数据库里存对了、接口里传对了、前端渲染错了用户看到的还是天书。这一层要做的是把转义层数数清楚把渲染方案定死。4.1 存储层反斜杠在 MySQL / JSON / 前端里的三层转义一个公式从库里到页面上反斜杠至少要过三道手环节原始内容实际形态MySQL 字符串常量\frac{1}{2}\\frac{1}{2}JSON 序列化\frac{1}{2}\\frac{1}{2}JSON 内前端JSON.parse后\frac{1}{2}\frac{1}{2}用SELECT查出来的那一列MySQL 客户端会再转义显示一次所以你在终端看到的\\frac有可能只是显示效果实际存的是一个反斜杠。判断真实内容用HEX(stem)或LENGTH(stem)别靠肉眼数。接口层最常见的错误是在 Python 里把字符串又做了一次.replace(\\, \\\\)结果前端拿到\\\\fracKaTeX 直接报ParseError。正确做法是原样透传序列化交给 JSON 库处理中间不加任何手工替换。# 查询结果直接交给 json.dumps,不做任何手工转义 row {id: r[0], stem: r[1], options: json.loads(r[2]) if r[2] else []} return json.dumps(row, ensure_asciiFalse) # ensure_asciiFalse 保留中文ensure_asciiFalse少一次中文编解码日志里也能直接看出题干内容排查时省很多事。4.2 渲染层KaTeX 与 MathJax 的取舍前端渲染只有两个现实选择。KaTeX 快同步渲染1700 万题库这种规模下一个列表页几十道题也不会卡MathJax 兼容性好支持的宏多但渲染是异步的列表页容易看到公式闪一下再出来。我的默认方案是 KaTeX遇到不支持的语法再降级import katex from katex; import katex/dist/katex.min.css; // 渲染单个公式,失败时返回原文而不是抛错 function renderFormula(tex, displayMode false) { try { return katex.renderToString(tex, { displayMode, throwOnError: false, // 关键:坏公式不要炸掉整个列表 strict: false, // 容忍非标准写法 trust: false, // 禁止 \href 等危险命令 macros: { \\R: \\mathbb{R} }, }); } catch (e) { console.warn(katex render failed:, tex, e.message); return tex; // 降级为原文,至少用户能看到内容 } }throwOnError: false必须开。题库数据是批量导入的必然有少量公式写法不规范一条报错让整页白屏用户体验比显示原文差得多。trust: false是安全线KaTeX 的\href、\includegraphics在trust: true下会执行题干内容来自外部数据包绝不能放开。化学式里的\ce{H2SO4}这类写法 KaTeX 默认不支持需要额外引入mhchem扩展如果数据包里化学科的公式占比高直接上 MathJax 更省事。判断标准很简单抽样 100 条化学题干看 KaTeX 报错率超过 5% 就换。4.3 混合文本切分题干里的文字、公式、图片怎么拆真实题干很少是纯公式更多是「已知函数 $f(x)x^21$求 $f(2)$ 的值」这种文字和公式混排。直接对整段做renderToString会失败必须先切分。// 按 $$...$$ 和 $...$ 切分,保留分隔符本身 const FORMULA_RE /(\$\$[\s\S]?\$\$|\$[^$\n]?\$)/g; function renderStem(stem) { return stem .split(FORMULA_RE) .filter((s) s ! ) .map((seg) { if (seg.startsWith($$)) { return renderFormula(seg.slice(2, -2), true); } if (seg.startsWith($)) { return renderFormula(seg.slice(1, -1), false); } // 普通文本要转义 HTML,防止题干里的 破坏结构 return escapeHtml(seg); }) .join(); }正则里[^$\n]?限定行内公式不能跨行[\s\S]?允许行间公式跨行这个区别很重要——用.?的话行间公式里有换行就匹配不上结果整段被当纯文本输出。另外要处理图片型题目。老题库里有相当一部分题的图形是截图题干里存的是相对路径或者占位符。切分函数里加一条规则匹配到img开头或者特定占位标记时走图片渲染而不是文本转义路径统一拼一个 CDN 前缀别让前端自己去拼相对路径。4.4 渲染结果校验与降级兜底上线前跑一轮全量校验把渲染失败的题挑出来这一步能省掉后面 80% 的用户反馈。// Node 环境批量校验,统计失败率 const failed []; for (const q of questions) { try { katex.renderToString(q.stem, { throwOnError: true, strict: false }); } catch (e) { failed.push({ id: q.id, msg: e.message, stem: q.stem.slice(0, 60) }); } } console.log(失败 ${failed.length} / ${questions.length});按报错信息聚一下类基本就是那几种未定义宏、\text里嵌了中文、多余的大括号、\begin{cases}后面少了\\。同一类错误写个批量修复脚本比手工改快得多。降级路径要提前想好KaTeX 渲染失败时输出code标签包住原文样式上加个浅色背景用户至少能看到内容接口层返回一个render_ok标记前端根据这个标记决定要不要显示「公式渲染异常点击查看原文」的提示。别指望 100% 渲染成功1700 万条数据里总有几条是畸形数据能兜住就赢了。5. 常见问题排查导入乱码、公式错乱、慢查询的 5 条踩坑记录这一章是踩过的坑按「现象 → 原因 → 解决」写能救一个是一个。5.1 导入后所有公式反斜杠消失现象SELECT stem出来rac{1}{2}x^2\sqrt变成sqrt\neq变成换行加eq。原因LOAD DATA用了默认的ESCAPED BY \\MySQL 把\f、\n、\t、\r这些当成了转义序列吃掉了。解决导入语句显式加ESCAPED BY 重新导一遍。已经导错数据的表没有可靠的批量修复办法别在上面写REPLACE脚本试图还原还原不回去。5.2 中文题干变成问号或乱码现象SELECT出来全是????或者æ•°å¦这种。原因客户端连接字符集不是utf8mb4数据在传输层被转了一次再存进去损失不可逆。解决连接串强制指定charsetutf8mb4mysql命令行加--default-character-setutf8mb4建库建表统一utf8mb4_general_ci。三处都要对齐缺一处就会复现。已经存坏的只能重导。5.3 分页翻到后面越来越慢现象LIMIT 0,20几十毫秒LIMIT 200000,20变成好几秒。原因MySQL 深分页要先扫描并丢弃前面 N 行N 越大越慢。解决改成游标分页前端记住上一页最后一个id请求带last_id参数SQL 加AND id last_id。业务上不能改交互的用延迟关联先只在索引上取id再回表拿内容。ORDER BY的字段必须出现在联合索引里否则还是会走文件排序。5.4 随机抽题把数据库拖死现象组卷接口一调用数据库 CPU 飙到 100%其他接口全超时。原因ORDER BY RAND()在 1700 万行上要物化全表再排序。解决改成主键区间随机 回表过滤或者按学科年级预建一张随机数辅助表。抽题接口单独走从库别和查询接口抢连接池。5.5 前端列表页偶发白屏现象某个学科年级组合下页面直接空白控制台报KaTeX ParseError。原因数据包里有少量公式写法不标准孤儿大括号、未定义的\degree宏throwOnError: true时直接抛异常打断渲染。解决throwOnError改成falsestrict改成false未定义宏统一在macros里补上。同时写一个后台任务定期跑全量渲染校验把失败题的目标 id 落表运营侧逐条修。这两条一改白屏概率能压到接近零。剩下的就是数据洁癖问题了。6. 二次开发在题库外面套一层题目服务 API数据包跑通之后下一步基本都是套一层服务。我的做法是单独起一个question-service不让业务方直连数据库好处是索引怎么调、缓存怎么加都在这一层收口。接口设计上做三件事就够了按条件分页查题、按 id 批量取详情、按条件随机抽题组卷。分页和抽题在 SQL 层加LIMIT和游标批量取详情走WHERE id IN (...)一次最多 50 个 id避免超长 SQL。缓存策略上元数据学科、年级、章节、知识点全量缓存到内存这部分几万条、变更极少没必要每次查库题目详情按 id 缓存 30 分钟随机抽题接口不缓存但限制 QPS。真正需要预热的是高频知识点下的题目 id 列表服务启动时把这批 id 捞进本地缓存抽题时直接从 id 池里随机取再批量回表实测能把组卷接口从 800ms 压到 40ms 以内。验证方法很简单接口上线前跑一轮压测用真实的知识点条件打 1000 次抽题看 P99 延迟和数据库 QPS再故意传一个不存在的知识点看空结果是不是 200 而不是 500。这两条过了基本可以放到教学场景里用了。有一点我踩过一开始图省事把题目详情和渲染后的 HTML 一起缓存结果换了 KaTeX 版本之后缓存里的旧 HTML 和新的 CSS 对不上公式全歪。从那以后我每次升级渲染库都强制走一遍「清缓存 → 抽样比对 100 条 → 再放开流量」的流程再急也不跳。希望帮到你。本文还有配套的精品资源点击获取