4

数据库

·39 分钟

1. 数据库系统比文件系统好在哪?

文件系统只负责把字节存下来,怎么解读、怎么保证正确全归应用程序管。数据库系统在存储之上加了一整层管理。

先是结构层面。文件里数据的物理格式跟程序代码绑死,加一个字段,所有读它的程序都得改;数据库通过模式和外模式把逻辑结构和物理存储分开,加字段不影响原有查询。同一份数据散在多个文件里各存一份,改了一处忘了另一处就不一致;数据库用统一的数据模型加上完整性约束(主键、外键、唯一、非空)从机制上防住这件事。

更关键的是并发和故障这两件文件系统完全不管的事。多个程序同时写一个文件,结果取决于谁后写,没有任何保护;数据库用锁和 MVCC 保证并发下的正确性。文件写到一半断电就是一个损坏的半成品;数据库靠日志保证事务要么全做要么全不做,崩溃之后能恢复到一致状态。

最后是查询能力。文件系统只能一行行读进来自己过滤,数据库提供 SQL 声明式查询和索引,你说要什么,怎么取由优化器决定。

代价是数据库更重、运维成本更高,简单场景下反而更慢。所以日志、图片、配置这类不需要复杂查询和事务的数据,至今仍然直接放文件里。

2. 关系模型的三要素是什么?主键、外键、候选键分别是什么?

三要素是数据结构、数据操作、完整性约束。数据结构就是关系,也就是二维表,一行叫元组、一列叫属性;数据操作是选择、投影、连接等关系运算;完整性约束分三类——实体完整性(主键不能为空、不能重复)、参照完整性(外键的值必须在被引用表里存在,或者取空值)、用户定义完整性(业务规则,比如年龄必须大于 0)。

几种键的关系是层层收窄的:能唯一标识一行的属性组叫超键;去掉多余属性、不含任何冗余的超键叫候选键,一张表可以有多个;从候选键里挑一个作为正式标识的叫主键,只能有一个;引用另一张表主键的属性叫外键。举例来说,学生表里学号和身份证号都能唯一确定一个学生,两个都是候选键,选学号做主键,身份证号就是备选;选课表里的学号引用学生表的学号,那就是外键。

还有个概念在判断范式时要用到:主属性指的是包含在任意一个候选键里的属性,其余的叫非主属性。

3. 关系代数的五个基本运算是什么?

并(∪)、差(−)、笛卡尔积(×)、选择(σ)、投影(π)。

选择是按条件筛行,对应 SQL 的 WHERE;投影是挑列,对应 SELECT 后面的字段列表,并且理论上会去重;并和差是两个关系之间的集合运算,要求两个关系的属性数目和类型一致;笛卡尔积把两个关系的每一行两两组合,结果有 m×n 行。

之所以说这五个是基本的,是因为其它运算都能由它们导出:交可以写成 R − (R − S);连接是先做笛卡尔积再用选择过滤出满足连接条件的行,也就是 σ条件(R × S);除法可以用差和笛卡尔积拼出来。

实际执行时数据库不会真的先算笛卡尔积再过滤,那是 m×n 行的开销。优化器会把选择条件下推,用嵌套循环、哈希连接或排序归并来做。关系代数描述的是语义,不是执行计划。

4. 1NF/2NF/3NF 分别解决什么问题?BCNF 呢?

范式是逐级加强的约束,每一级都在消除一类由数据冗余引起的异常——插入异常、删除异常、修改异常。

1NF 要求每个属性都是不可再分的原子值,一个字段里不能塞"张三,李四"这样的列表,也不能有嵌套的表结构。这是关系型数据库的最低门槛。

2NF 在 1NF 基础上要求非主属性完全依赖于候选键,不能只依赖候选键的一部分。这个问题只在联合主键时才出现:选课表的主键是(学号, 课程号),如果表里放了"学生姓名",姓名只依赖学号、跟课程号无关。后果是一个学生选五门课,姓名就重复存五遍,改名要改五处,而且一门课都没选的学生根本插不进来。拆成学生表和选课表就解决了。

3NF 要求非主属性不传递依赖于候选键。学生表主键是学号,里面存了"系号"和"系主任",而系主任依赖系号、系号依赖学号,这就是学号 → 系号 → 系主任的传递依赖。后果一样:换系主任要改所有该系学生的记录,一个还没招生的新系无法录入。把系相关的信息拆成独立的系表即可。

BCNF 更严格,要求每一个决定因素都必须包含候选键。它处理的是主属性之间也存在依赖的情况——3NF 只管住了非主属性,主属性内部的依赖它不管,所以满足 3NF 的表仍然可能有冗余。

工程上不会盲目追求高范式。范式拆得越细表越多,查询要连接的表也越多,性能反而下降,所以实际设计常做反范式——有意冗余几个常用字段(比如订单表里存一份下单时的商品名称快照),用维护一致性的复杂度换查询性能。合理的做法是先按 3NF 设计,再针对具体的查询瓶颈有意识地反规范化。

5. E-R 图怎么画?实体、属性、联系怎么转成表?

E-R 图用三种符号:矩形表示实体(学生、课程),椭圆表示属性(姓名、学分),菱形表示联系(选修),菱形两端标注联系类型(1:1、1:n、m:n)。

转成关系表的规则按联系类型分。

m:n 的联系必须单独建一张表,主键是双方主键的组合。学生和课程是 m:n,就要有一张选课表(学号, 课程号, 成绩)——成绩是联系本身的属性,只能放这里,放学生表或课程表都不对。

1:n 的联系不用单独建表,把"1"那一方的主键作为外键放到"n"那一方就行。一个班有多个学生,学生表加一个班级号外键即可;反过来在班级表里存一个学生列表,就违反 1NF 了。

1:1 的联系可以往任意一方合并,通常放在访问更频繁或数据量更少的那一方,加个外键并设唯一约束。

面试里 E-R 更常以设计题的形式出现——"给你一个选课系统,设计一下表结构",考的其实是能不能识别出 m:n 需要中间表,以及联系自身的属性该往哪儿放。

6. 写一个联表查询?LEFT JOIN 和 INNER JOIN 有什么区别?

SELECT s.name, c.title, sc.score
FROM student s
JOIN score sc ON s.id = sc.student_id
JOIN course c ON c.id = sc.course_id
WHERE sc.score >= 60
ORDER BY sc.score DESC;

INNER JOIN 只保留两边都能匹配上的行,一个学生没有任何成绩记录,他就不会出现在结果里。LEFT JOIN 保留左表的全部行,右表匹配不上的位置补 NULL——想查"所有学生的选课情况,包括一门课都没选的"就必须用 LEFT JOIN。RIGHT JOIN 反过来,实际很少用,把两张表的位置调换写成 LEFT JOIN 更易读。

有个高频的坑:LEFT JOIN 之后在 WHERE 里写右表字段的条件,会把补 NULL 的那些行过滤掉,效果退化成 INNER JOIN。要保留就得把条件写在 ON 里。ON 决定"怎么匹配",在连接时生效;WHERE 决定"保留哪些结果行",在连接完成之后生效。这个区别对 INNER JOIN 无所谓,对 LEFT JOIN 是结果完全不同。

反过来利用这个特性,可以查"没有匹配记录的行":LEFT JOIN 之后写 WHERE 右表主键 IS NULL,剩下的正是左表里没能匹配上的那些。

7. GROUP BY 和 WHERE 的执行顺序?HAVING 是什么?

SQL 的书写顺序和执行顺序不一样。执行顺序大致是 FROM/JOIN → WHERE → GROUP BY → 聚合函数 → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT。

WHERE 在分组之前执行,过滤的是原始行,所以 WHERE 里不能用聚合函数——那时候还没分组,COUNT 无从谈起。HAVING 在分组之后执行,过滤的是分组结果,可以用聚合函数。

SELECT dept, AVG(salary) AS avg_sal
FROM employee
WHERE status = 'active'      -- 先剔除离职的人,再参与分组
GROUP BY dept
HAVING AVG(salary) > 10000   -- 对分组后的结果筛选
ORDER BY avg_sal DESC;

能写在 WHERE 里的条件就不要放到 HAVING 里。WHERE 先过滤能减少参与分组的数据量,放到 HAVING 等于先把全部数据分完组再扔掉一批,白干一遍。

另一个常考点是 SELECT 在 GROUP BY 之后执行,所以 SELECT 里只能出现分组字段和聚合函数,写了别的字段,MySQL 在 ONLY_FULL_GROUP_BY 模式下会直接报错。而 ORDER BY 在 SELECT 之后,所以能用 SELECT 里定义的别名(上面的 avg_sal),WHERE 就不能。

8. 子查询和 JOIN 哪个快?视图是什么?

没有绝对答案,取决于优化器。现代 MySQL 会把很多子查询自动改写成 JOIN,两者的执行计划可能完全一样。但有几条经验是确定的:IN 子查询在早期 MySQL 版本里会对外层每一行执行一次子查询,n 行就是 n 次,改成 JOIN 明显更快,5.6 之后有了半连接优化,差距缩小了;相关子查询(子查询里引用了外层的列)天然是逐行执行的,通常比 JOIN 慢;EXISTSIN 的差别在驱动方向上,EXISTS 适合外表大内表小(对外层每行去内表探测,探到一条就停),IN 适合子查询结果集小的情况。判断依据始终是 EXPLAIN 出来的执行计划,不是死记规则。

视图是一条被命名保存的查询语句,本身不存数据,查视图时数据库把它展开成底层查询去执行。它的价值在于封装复杂查询(把五张表的连接封成一个视图,业务代码直接查视图)和权限控制(只把视图授权给某个用户,让他只能看到表的部分列或部分行)。代价是容易掩盖性能问题——一个看起来很简单的视图查询,展开之后是多表连接。

物化视图会把结果真正存下来,查得快但有数据新鲜度问题。MySQL 原生不支持,一般用定时任务刷新的汇总表来代替。

9. 索引的底层数据结构是什么?为什么用 B+ 树不用二叉树?

MySQL InnoDB 的索引是 B+ 树。不用二叉树的核心原因是磁盘 IO:二叉树每个节点只有两个分支,树高是 log₂n,百万级数据要走二十层,每层一次随机磁盘 IO;B+ 树一个节点就是一个 16KB 的数据页,能存上千个键,扇出上千,同样的数据量三层就够,加上根节点常驻内存,一次查询实际只要一两次磁盘 IO。

B+ 树相对 B 树的两处改动也都是为数据库准备的:内部节点只存键不存数据,一页能放下更多键、树更矮;叶子节点用链表串起来,范围查询和 ORDER BY 顺着链表扫就行,不用回到根节点重查。这部分的结构细节在数据结构与算法第 9 题展开过。

哈希索引也是存在的,等值查询 O(1) 比 B+ 树还快,但不支持范围查询和排序,所以只用在 Memory 引擎和 InnoDB 的自适应哈希索引里。这也说明选数据结构不能只看单点性能,要看能不能覆盖主要的查询模式。

10. 聚集索引和非聚集索引有什么区别?

聚集索引的叶子节点直接存整行数据,索引的顺序就是数据的物理存储顺序,所以一张表只能有一个。InnoDB 里主键就是聚集索引——没有显式主键就用第一个唯一非空索引,再没有就自己造一个隐藏的行 ID。

非聚集索引(也叫二级索引)的叶子节点存的不是整行,而是主键值。查询时先在二级索引树里找到主键,再拿主键去聚集索引树里查一次拿到完整行,这个过程叫回表,等于走了两棵树。一张表可以有多个二级索引。

由此能推出几个实际影响。一是覆盖索引:如果要查的字段在二级索引里已经全都有了(比如 SELECT id, name FROM t WHERE name = 'x',name 上有索引,而 id 就是索引叶子里存的主键),就不用回表,性能好很多,这是索引优化的重要手段。二是主键不要用长字符串,每个二级索引的叶子都要存一份主键值,主键越长,所有二级索引都跟着变大。三是主键最好递增,聚集索引按主键顺序物理存放,递增插入是顺次往后追加,用 UUID 这类随机值会插到中间导致页分裂和碎片。

MyISAM 没有聚集索引,主键索引和二级索引的叶子存的都是行的物理地址,两者结构一样,也就不存在回表这回事。

11. 什么情况下才该加索引?加多了有什么问题?

该加的场景是:出现在 WHERE、JOIN ON、ORDER BY、GROUP BY 里的字段;区分度高的字段,也就是不同值的数量占总行数比例大的;数据量已经大到全表扫描明显变慢的表。

不该加的是:区分度极低的字段,典型是性别,只有两个值,走索引要找到一半的主键再逐个回表,比直接全表扫描还慢,优化器多半会放弃它;频繁更新的字段;数据量很小的表,几百行全表扫一次就够了,走索引反而多一层开销。

加多了的代价有三块。写入变慢——每次增删改都要同步维护所有相关的索引树,可能触发页分裂和重新平衡,索引越多写入越慢。空间占用——每个索引都是一棵完整的 B+ 树,索引总大小超过数据本身是很常见的。优化器负担——候选索引越多,选执行计划的分析成本越高,还可能选错索引,结果比不加还慢。

工程上更推荐用联合索引替代多个单列索引。比如 (a, b, c) 一棵树就能服务 WHERE aWHERE a AND bWHERE a AND b AND c 三类查询,比建三个单列索引省得多,这就是最左前缀原则。

12. 哪些情况下索引会失效?

失效的根源基本都是同一个:B+ 树的有序性被破坏了,没法二分定位。

在索引列上做运算或套函数,比如 WHERE YEAR(create_time) = 2026WHERE id + 1 = 5。索引树里存的是原始值的顺序,套了函数之后的顺序关系无从查起,只能全表算一遍。改写成范围条件 WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01' 就能用上索引。

隐式类型转换,字段是 varchar 却写成 WHERE phone = 13800138000,MySQL 会把字段转成数字再比较,等价于在列上套了函数,索引失效。反过来字段是 int 却传字符串则没问题,因为转的是常量那一侧。

LIKE '%abc' 以通配符开头。B+ 树是按前缀有序的,不知道开头是什么就无从定位,而 LIKE 'abc%' 可以走索引。

OR 连接的条件里只要有一个字段没索引,整条就退化成全表扫描——因为必须扫全表才能确定那个没索引的条件,改成 UNION 可以让两边各自走索引。

联合索引不满足最左前缀。索引 (a, b, c)WHERE b = 1 查不了,因为树是先按 a 排序、a 相同再按 b 排,跳过 a 之后 b 在整棵树里是无序的。同理,范围查询之后的列也用不上索引:WHERE a = 1 AND b > 2 AND c = 3 里的 c 用不上,因为 b > 2 匹配的是一个区间,区间内部 c 是无序的。

还有 !=NOT INIS NOT NULL 这类否定条件。以及优化器估算出走索引要回表的行数太多(一般超过全表的百分之二三十)时会主动放弃索引——最后这种严格说不算失效,是优化器算过账之后的正确选择。

13. MySQL 索引有什么优缺点?

优点是把查询从全表扫描的 O(n) 降到树高级别的 O(log n);同时因为 B+ 树本身有序,还能直接支撑排序和分组,省掉额外的排序开销;唯一索引还顺带提供了唯一性约束。

缺点是写入变慢、占用空间、需要维护,这些在第 11 题展开过。再补一个容易忽略的:索引会随着增删逐渐产生碎片,页的填充率下降,扫描时要读的页数变多,需要定期 OPTIMIZE TABLE 或重建。

真正要说清楚的是这个权衡的本质——索引是拿空间和写入性能换查询性能。所以判断该不该加索引,得先知道这张表是读多还是写多:日志类的表写远多于读,索引就要克制;报表类的表基本只读,索引可以多建。

14. ACID 分别是什么?哪个最难保证?

原子性(Atomicity):事务里的操作要么全成功要么全不做,中间失败要回滚。靠 undo log 实现——每次修改前先记下反向操作,回滚时反着执行一遍。

一致性(Consistency):事务结束后数据库仍然满足所有约束和业务规则,转账前后两个账户总额不变是最经典的例子。

隔离性(Isolation):并发执行的多个事务互不干扰,效果等同于某种串行执行。靠锁和 MVCC 实现。

持久性(Durability):事务一旦提交,修改就永久生效,随后断电也不丢。靠 redo log 实现——提交时先把修改顺序写进日志并落盘,数据页可以慢慢刷,崩溃后拿日志重放。

最难保证的是隔离性,因为它是唯一一个跟性能直接冲突的。完全的隔离意味着串行执行,吞吐会很难看,所以数据库才提供了四个隔离级别,让你自己决定在正确性和性能之间怎么取舍。另外三个基本没有妥协余地,原子性和持久性由日志机制保证,不需要用户参与决策。

四者的关系是:AID 是手段,C 是目的。原子性、隔离性、持久性都是为了最终让数据保持一致。

15. 事务有哪四个隔离级别?MySQL 默认是哪个?

四个级别的差别,就在于分别防住了并发下的哪几种读问题——脏读是读到了别人还没提交的数据,不可重复读是同一行两次读到的值不一样,幻读是同一个范围两次读到的行数不一样,三者的界限下一题再细说。

隔离级别脏读不可重复读幻读
读未提交 Read Uncommitted可能可能可能
读已提交 Read Committed不会可能可能
可重复读 Repeatable Read不会不会可能(InnoDB 下基本已解决)
串行化 Serializable不会不会不会

MySQL 的 InnoDB 默认是可重复读,这跟大多数数据库不一样——Oracle、PostgreSQL、SQL Server 默认都是读已提交。MySQL 选可重复读有历史原因:早期基于 statement 格式的主从复制,在读已提交下会出现主从数据不一致,可重复读能避免这个问题。现在用 row 格式复制已经没有这个约束,很多高并发场景反而会手动调成读已提交,因为它的锁范围更小、并发度更高。

串行化实际几乎不用。它给所有读操作都加锁,等于把并发变成排队,性能代价太大。

16. 脏读、不可重复读、幻读有什么区别?MySQL 怎么解决幻读?

三个都是并发读写导致的问题,区别在于读到了什么样的"不该读到的东西"。

脏读是读到了另一个事务还没提交的修改,而那个事务后来回滚了——你读到的是一个从未真正存在过的值。

不可重复读是同一个事务里两次读同一行结果不一样,因为中间另一个事务提交了对这行的修改,侧重的是同一行的值变了。

幻读是同一个事务里两次执行同样的范围查询,第二次多出(或少了)几行,因为中间另一个事务插入或删除了符合条件的行,侧重的是行数变了。

不可重复读和幻读的本质差别在于解决手段:不可重复读针对的是已存在的行,给这行加锁就能防住;幻读针对的是"还不存在的行",你没法给一行尚未插入的数据加锁,所以必须锁住一个区间。

MySQL 在可重复读级别下用两套机制解决幻读。快照读(普通的 SELECT)走 MVCC,事务第一次读时生成一个一致性视图,之后所有读都基于这个快照,别的事务插入了新行也看不见,幻读自然不发生。当前读(SELECT ... FOR UPDATELOCK IN SHARE MODE,以及 UPDATE 和 DELETE)读的是最新数据,靠间隙锁——不只锁住命中的行,还锁住这些行之间以及边界之外的间隙,别的事务想往这个区间里插入就会被阻塞。行锁加间隙锁合起来叫 Next-Key Lock,这是 InnoDB 默认的加锁方式。

MVCC 的实现值得知道:每行数据有隐藏的事务 ID 和回滚指针,指向 undo log 里的历史版本,串成一条版本链。读的时候拿事务的一致性视图去版本链上找第一个"对我可见"的版本。这样读操作完全不加锁,读写互不阻塞,是 InnoDB 高并发的关键。

17. 乐观锁和悲观锁有什么区别?各自适用什么场景?

这是两种并发控制的思路,不是具体的锁类型。

悲观锁假设冲突一定会发生,所以先加锁再操作,别人只能等着。数据库的行锁、表锁、SELECT ... FOR UPDATE 都属于悲观锁。乐观锁假设冲突很少,操作时不加锁,只在提交时检查数据有没有被别人改过,改过就回滚重试。实现通常靠版本号或时间戳:读的时候记下 version,更新时写成 UPDATE t SET val = ?, version = version + 1 WHERE id = ? AND version = ?,影响行数为 0 就说明被人抢先改了,重试。

选择标准是冲突概率。写冲突频繁时用悲观锁,因为乐观锁会不断失败重试,重试的开销比等锁还大;读多写少、冲突罕见时用乐观锁,省掉了加锁解锁的开销,也不会因为持锁时间长阻塞别人。库存扣减这类高并发抢同一行的场景通常用悲观锁或数据库的原子操作;后台管理系统里编辑一条记录,两个人同时改同一条的概率很低,用乐观锁提示"数据已被他人修改"更合适。

顺带说一下行锁和表锁:锁的粒度越细并发度越高,但锁本身的管理开销也越大。InnoDB 支持行锁,MyISAM 只有表锁,这是 InnoDB 成为默认引擎的重要原因之一。要特别注意 InnoDB 的行锁是加在索引上的,如果 WHERE 条件没走索引,会退化成锁全表,这是实际中很容易踩的坑。

18. 数据库写入失败了怎么办?怎么保证数据一致?

先分清失败发生在哪一层,处理方式完全不同。

单条 SQL 在事务内失败,靠事务回滚就够了,数据库保证原子性,不会留下半成品。这里的前提是应用代码必须真的把这些操作包在一个事务里,而不是分成几条独立提交。

连接超时、主库宕机这类失败,客户端根本不知道服务端到底执行了没有。这时候盲目重试是危险的:如果第一次其实成功了、只是响应丢在路上,重试就会写两遍。解决办法是让写操作幂等——给请求带一个唯一的业务 ID,写之前先查这个 ID 是否已存在,或者直接给这个 ID 建唯一索引,重复写会被约束挡掉。

跨系统的一致性,比如"扣库存"和"通知下游"分属两个系统、一个成功一个失败,数据库事务管不了。常见做法是把要发的消息先作为一条记录写进本地数据库,和业务操作在同一个事务里提交,再由后台任务读这张表去投递并重试,这就是本地消息表;或者用消息队列的事务消息。核心思路都是把"跨系统的原子性"转化成"本地事务加可靠重试",接受中间态,保证最终一致。

还有必须做的兜底:写入失败要记录足够的日志(谁、什么时候、写什么、失败原因),要有告警,要有对账机制定期核对上下游数据。分布式系统里做不到永不失败,能做的是让失败可发现、可重放、可核对。

19. 怎么备份 MySQL?全量备份和增量备份有什么区别?

逻辑备份用 mysqldump,导出的是 SQL 语句,跨版本跨平台通用、内容可读,缺点是大库很慢、恢复更慢,因为要重新执行一遍所有 INSERT。物理备份直接拷数据文件,用 Percona XtraBackup 之类的工具做热备,速度快得多,适合大库,但要求目标环境的版本和配置匹配。

全量备份是把当前所有数据完整备一份,恢复简单,直接还原就行,但每次都备全量,既占空间又慢。增量备份只备上次备份之后变化的部分,MySQL 里靠 binlog 实现,因为 binlog 记录了所有的写操作。

两者是配合使用的:比如每周日做一次全量,之后每天只归档 binlog;需要恢复时先还原全量备份,再用 mysqlbinlog 把 binlog 重放到指定时间点,可以精确恢复到误操作发生的前一秒,这叫 point-in-time recovery。

运维上有几个点必须强调:备份要异地存放,跟数据库在同一台机器上的备份,机器一坏就一起没了;binlog 要开启并设置合理的保留期,否则增量恢复无从谈起;最重要的是定期演练恢复——从没恢复成功过的备份不能算备份,只有真的还原过一次,才知道它是有效的。

20. 慢查询怎么排查?EXPLAIN 主要看什么?

先定位再分析。开启慢查询日志(slow_query_log = ONlong_query_time 设成 1 秒或更低),跑一段时间后用 mysqldumpslowpt-query-digest 汇总,找出耗时最多的那几条。注意排序依据应该是"总耗时 = 单次耗时 × 执行次数",一条 0.5 秒但每秒跑一百次的查询,危害远大于一条 5 秒但每天只跑一次的。线上正卡着的时候可以用 SHOW PROCESSLIST 看当前在跑什么。

拿到具体 SQL 后用 EXPLAIN 看执行计划,重点是这几列:

看什么
type访问类型,从好到坏大致是 const → eq_ref → ref → range → index → ALL。出现 ALL(全表扫描)或 index(全索引扫描)就要警惕
key实际用了哪个索引,为 NULL 说明一个都没用上
possible_keys本来可以用的索引,跟 key 不一致说明优化器主动放弃了某个索引
rows预估要扫描的行数,越小越好;跟最终返回行数差距悬殊,说明过滤效率低
ExtraUsing index 是覆盖索引(好事);Using filesort 表示需要额外排序;Using temporary 表示用了临时表,后两个通常是优化重点

常见的优化手段:给 WHERE、JOIN、ORDER BY 涉及的字段加合适的索引,用联合索引做成覆盖索引,同时消掉回表和 filesort;避免 SELECT *,只取需要的列;深分页(LIMIT 1000000, 20)改成用上次的最大 ID 做条件往后取,避免扫描并丢弃前一百万行;大事务拆小,减少锁的持有时间。

但也要看清楚问题是不是真出在 SQL 上。有时候慢是因为表设计不合理、数据量该分表了,或者卡在锁等待上(SHOW ENGINE INNODB STATUS 能看到锁信息),这些加索引解决不了。