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 donegrep -c数的是 INSERT 语句的行数而不是记录数因为 dump 通常是多值插入一条 INSERT 带几百行 VALUES。想看真实记录数可以数 VALUES 后面的括号但没必要那么精确量级对了就行。接着建库。字符集这一步没有任何商量余地utf8mb4是底线因为题干里会出现生僻字、数学符号、以及 emoji 之外的增补平面字符。mysql -uroot -p --default-character-setutf8mb4 \ -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-setutf8mb4 \ --max-allowed-packet256M \ 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-55最难, 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)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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( host127.0.0.1, userroot, passwordyour_password, databasetiku, charsetutf8mb4, cursorclasspymysql.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_asciiFalse 只是让中文不变成 \uXXXX和反斜杠转义无关 body json.dumps(payload, ensure_asciiFalse) 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, flagsre.S) text re.sub(r\\\((.?)\\\), r$\1$, text, flagsre.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, flagsre.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 从逗号串拆到关联表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, n10): 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-packet256M两边取小值生效。如果没法改服务端配置就用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-setutf8mb4建库建表也用 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, total20, 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_dup0 ORDER BY id LIMIT %s, (subject, stage, lo_d, hi_d, quota), ) paper.extend(cur.fetchall()) return paperratio的三档和难度的映射关系可以按学段调整小学阶段把简单题比例提到 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就不往下写业务代码。这套动作现在已经是固定流程了。希望帮到你。本文还有配套的精品资源点击获取