parasafi
parasafi
发布于 2026-09-13 / 26 阅读
0
0

Mysql-sql语句优化指北

本文以MySQL InnoDB引擎为基础,讲解索引相关概念以及优化手段

版本基线:MySQL 8.0(默认隔离级别 REPEATABLE READ、默认 binlog_format=ROW)。与 5.7 行为不同的地方会单独标注。

一句话总纲:SQL 优化本质只有两件事——让语句扫描的行数尽可能少,让每次交互携带的数据尽可能少。



0. 通用方法论:先定位,再优化

优化最忌讳"凭感觉加索引"。正确顺序是:先找到慢 SQL → 再看执行计划 → 再改 SQL 或索引 → 再验证。

0.1 第一步:慢查询日志把问题 SQL 捞出来

-- 开启慢查询日志(会话级演示,生产建议写进配置文件)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;             -- 超过 1 秒记录
SET GLOBAL log_output = 'FILE';             -- 也可设 TABLE,落到 mysql.slow_log
SET GLOBAL log_queries_not_using_indexes = ON;  -- 记录未走索引的 SQL(线上慎开,日志量爆炸)
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
# 按总耗时排序,取最慢的 10 条
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log
# 按出现次数排序
mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log

没有权限开慢日志时,也可以临时看正在执行的语句:

SHOW FULL PROCESSLIST;
SELECT * FROM information_schema.processlist WHERE command <> 'Sleep' ORDER BY time DESC;

0.2 第二步:EXPLAIN 看懂执行计划

EXPLAIN SELECT ...\G
EXPLAIN FORMAT=TREE SELECT ...\G      -- 看 join 顺序、是否 hash join
EXPLAIN ANALYZE SELECT ...\G          -- 8.0.18+,返回真实耗时与真实行数

EXPLAIN 的 rows、filtered 都是估算值;要真实数据请用 EXPLAIN ANALYZE。

核心字段

字段

含义

关注点

id

查询序号

值越大越先执行;相同则从上往下

select_type

查询类型

SIMPLE / PRIMARY / SUBQUERY / DERIVED / UNION

table

表名

derived2 表示派生表被物化

type

访问类型

最重要,见下方排序

possible_keys

可能用到的索引

只看不用,说明优化器放弃了它

key

实际用的索引

NULL = 未走索引

key_len

索引使用长度

反推联合索引实际用了几列

ref

与索引比较的对象

const / 列名 / NULL

rows

预估扫描行数

估算值,越小越好

filtered

过滤后剩余百分比

长期低于 20% 说明索引选择性差

Extra

附加信息

见下表,第二重要

type 由好到差:

system > const > eq_ref > ref > fulltext > ref_or_null > index_merge
       > unique_subquery > index_subquery > range > index > ALL

实践目标:至少 range,最好是 ref / eq_ref;出现 ALL(全表扫描)或 index(全索引扫描)且表很大,就要重点排查。

Extra 常见值

Extra

含义

好/坏

Using index

覆盖索引,无需回表

✅ 好

Using index condition

索引条件下推(ICP,5.6+)

✅ 好

Using where

在 Server 层过滤

⚠️ 正常,但结合 rows 看是否扫太多

Using join buffer (hash join)

被驱动表无可用索引,走哈希连接

❌ 差

Using filesort

需要额外排序

❌ 差

Using temporary

用到内部临时表

❌ 差

Backward index scan

反向扫描索引

⚠️ 可接受(8.0 有降序索引可优化)

Impossible WHERE / Select tables optimized away

优化器直接短路

✅ 好

0.3 第三步:优化的三个方向

  1. 少读:减少扫描行数 —— 建索引、谓词下推、分区、冷热分离/归档。

  2. 少传:只取需要的列和行 —— 别写 SELECT *、用 LIMIT、尽量命中覆盖索引。

  3. 少等:减少交互次数与事务持有时间 —— 批量写入、短事务、避免在事务里做远程调用。

0.4 示例表结构(后文统一使用)

CREATE TABLE t_user (
  id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  phone       VARCHAR(20)     NOT NULL,
  nickname    VARCHAR(64)     NOT NULL DEFAULT '',
  city        VARCHAR(32)     NOT NULL DEFAULT '',
  status      TINYINT         NOT NULL DEFAULT 1,
  create_time DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_phone (phone),
  KEY idx_city_status (city, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
​
CREATE TABLE t_order (
  id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id     BIGINT UNSIGNED NOT NULL,
  order_no    VARCHAR(32)     NOT NULL,
  amount      DECIMAL(12,2)   NOT NULL DEFAULT 0.00,
  status      TINYINT         NOT NULL DEFAULT 0,
  create_time DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_user_status_time (user_id, status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

1. INSERT 语句优化

1.1 先看清一次插入的耗时构成

MySQL 官方给的相对耗时比例(数值只是量级参考):

环节

相对耗时

建立连接 Connecting

3

发送查询到服务端 Sending query

2

解析查询 Parsing query

2

插入行 Inserting row

1 × 行大小

插入索引 Inserting indexes

1 × 索引数量

关闭连接 Closing

1

结论:固定开销(3+2+2+1 = 8)远大于插入本身。所以插入优化的第一优先级永远是 合并操作,第二优先级是 减少索引维护。

另外:B+ 树索引的插入成本随表大小按 log N 增长,表越大插入越慢。

1.2 批量插入代替逐条插入

-- ❌ 1000 次网络往返 + 1000 次 SQL 解析
INSERT INTO t_user (phone, nickname) VALUES ('13800000001', 'a');
INSERT INTO t_user (phone, nickname) VALUES ('13800000002', 'b');
-- ... 重复 1000 次
​
-- ✅ 1 次往返
INSERT INTO t_user (phone, nickname) VALUES
  ('13800000001', 'a'),
  ('13800000002', 'b'),
  ('13800000003', 'c');

注意事项:

  • 单条 SQL 不能超过 max_allowed_packet(默认 64MB,建议保持默认或适度调整)。

  • 单批建议 500 ~ 1000 行,或控制在 1 ~ 5MB;批太大反而会拉长事务、放大主从延迟。

  • MyISAM 可调 bulk_insert_buffer_size 进一步加速(InnoDB 无效)。

1.3 用显式事务包住批量插入

innodb_flush_log_at_trx_commit=1(默认)时,每次事务提交都要 fsync 刷盘一次 redo log。逐条 INSERT 自动提交 = 逐条刷盘。

-- 方式一:关闭自动提交
SET autocommit = 0;
-- ... 批量 INSERT ...
COMMIT;
​
-- 方式二:显式事务(推荐,语义清晰)
START TRANSACTION;
-- ... 批量 INSERT ...
COMMIT;

⚠️ 事务不要无限大。大事务的代价:undo log 膨胀、主从延迟拉长、锁等待时间变长、binlog 巨大、回滚成本极高。建议每 1000 ~ 5000 行提交一次,并配合 1.2 的批大小一起调。

1.4 按主键顺序插入,避免页分裂

InnoDB 使用聚簇索引:表数据本身就是主键的 B+ 树,主键顺序决定了数据的物理写入顺序。

主键类型

写入行为

后果

自增/趋势递增

总是追加到最右侧叶子节点,顺序写

页分裂少、页利用率高、插入快

UUID / 随机字符串

随机位置插入

频繁页分裂,页利用率降到 50%~70%,插入与查询同时变慢

官方原话:批量插入时 按主键顺序插入更快,对放不进 buffer pool 的大表尤其重要。

如果业务必须用 UUID:

-- 8.0:有序 UUID(交换时间低位,使 UUID 变得单调递增),存 BINARY(16) 而不是 CHAR(36)
ALTER TABLE t_user ADD COLUMN uid BINARY(16) NULL, ADD UNIQUE KEY uk_uid (uid);
INSERT INTO t_user (uid) VALUES (UUID_TO_BIN(UUID(), 1));
SELECT BIN_TO_UUID(uid, 1) FROM t_user;

另外,主键长度也要控制——见 3.3 覆盖索引与回表。

1.5 批量导入时临时关闭一致性校验

SET unique_checks = 0;
SET foreign_key_checks = 0;
-- ... 导入 ...
SET unique_checks = 1;
SET foreign_key_checks = 1;

原理:关闭唯一性检查后,InnoDB 可以借助 change buffer 批量写二级索引,省下大量随机 I/O。

⚠️ 前提是你自己保证数据没有重复键、外键引用完整;否则会写进"脏"数据,且事后很难修。

1.6 大批量导入用 LOAD DATA

官方数据:从文本文件导入时,LOAD DATA 通常比 INSERT 快约 20 倍。

LOAD DATA LOCAL INFILE '/tmp/user.csv'
INTO TABLE t_user
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(phone, nickname, city, @create_time)
SET create_time = STR_TO_DATE(@create_time, '%Y-%m-%d %H:%i:%s');

其他批量导入经验:

  • 先导入数据,再建二级索引:数据量大时"建表 → 导入 → 建索引"比"带着索引导入"快得多。

  • 大批量迁移前可先 DROP INDEX,导入完成后再 ADD INDEX。

  • 往全新实例灌数据时可临时 ALTER INSTANCE DISABLE INNODB REDO_LOG(⚠️ 生产环境严禁使用,意外宕机会导致实例损坏)。

  • MySQL Shell 的 util.importTable() / util.loadDump() 支持并行导入,比单线程 INSERT 快一个量级。

  • 8.0 中 innodb_autoinc_lock_mode 默认为 2(交叉模式),并发批量插入性能最好,一般无需调整。

1.7 索引不是越多越好

插入一行 = 写 1 次聚簇索引 + 写 N 次二级索引。按官方的比例估算,插入索引的耗时 ≈ 1 × 索引数量。

实践建议:

  • 只建真正被查询使用的索引,定期用 sys.schema_unused_indexes 找出从未被使用的索引。

  • 能靠联合索引覆盖的场景,不要再单独建单列索引(避免冗余索引)。

  • 写多读少的表(日志、流水),索引要克制。

-- 8.0 内置视图:从未使用过的索引
SELECT * FROM sys.schema_unused_indexes;
-- 冗余索引(是另一个索引的前缀)
SELECT * FROM sys.schema_redundant_indexes;

1.8 常见坑与对照

写法

问题

建议

INSERT ... SELECT

RR 隔离级别下会对源表加共享 next-key 锁,可能阻塞源表写入

用 SELECT ... INTO OUTFILE + LOAD DATA;或改为 RC 隔离级别(binlog_format=ROW 前提下);或拆批

REPLACE INTO

语义是"删除 + 插入":触发器被触发两次、自增 ID 跳变、外键级联删除被触发

用 INSERT ... ON DUPLICATE KEY UPDATE,并注意它同样会导致自增 ID 跳变

INSERT IGNORE

吞掉所有错误,包括类型转换错误

谨慎使用,只用于确定可忽略的重复键场景

逐条 INSERT ... ON DUPLICATE KEY UPDATE

每条都是一次独立事务与解析

改成批量 VALUES 形式

高并发下的 ON DUPLICATE KEY UPDATE

会在唯一索引上加 next-key 锁,易死锁

批量时按主键排序;失败重试

显式写入默认值

增加解析开销

值等于默认值时直接省略该列


2. UPDATE 语句优化

2.1 本质:UPDATE = SELECT + 写开销

官方明确:UPDATE 的优化方式与 SELECT 相同,额外开销来自"写";写开销取决于更新的数据量和涉及的索引数量,未被更新的索引不会维护。

所以:UPDATE 慢,先把它当成 SELECT 慢来治 —— 先 EXPLAIN 它的 WHERE。

2.2 WHERE 必须命中索引,否则锁会被放大

InnoDB 的行锁是加在索引记录上的,能不能精确定位,直接决定锁的范围:

WHERE 情况

加锁范围

后果

命中主键 / 唯一索引

精确锁 1 行

✅ 并发友好

命中普通二级索引

命中的索引记录 + 相邻间隙(RR 下为 next-key lock)

⚠️ 可接受,注意范围别太大

完全没有可用索引

全表扫描过程中扫描到的每一行都要加锁(RR 下还含间隙锁)

❌ 并发场景下几乎等于锁表

-- ❌ phone 上没有索引:为了更新 1 行,扫描并锁住整张表
UPDATE t_user SET status = 0 WHERE phone = '13800000000';
​
-- ✅ 先补索引(示例表里 uk_phone 已经存在,这里演示缺失的情况)
ALTER TABLE t_user ADD INDEX idx_phone (phone);

排查锁情况(8.0):

SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 't_user';
SELECT * FROM performance_schema.data_lock_waits;   -- 谁在等谁的锁
SELECT * FROM sys.innodb_lock_waits;                -- 更好读的封装视图

2.3 不要更新主键和索引列

  • 更新主键:等价于"删除 + 重新插入",聚簇索引中的位置发生变化,可能触发页分裂。

  • 更新二级索引列:删旧索引项 + 插新索引项,成本随索引数量线性放大。

  • 更新可变长字段(如 VARCHAR 变长):可能触发行溢出/页分裂,频繁发生要留意表空间碎片。

  • 大字段、频繁更新的字段,考虑拆到独立的扩展表,"热点行"越小越好。

必须更新主键时,优先"插入新行 + 删除旧行"的批量方式,而不是逐行 UPDATE。

2.4 大事务拆小:分批更新

-- ❌ 一次更新 1000 万行:长时间持锁、undo 暴涨、binlog 巨大、主从延迟飙升、还可能撑爆 redo
UPDATE t_order SET status = 2 WHERE status = 1 AND create_time < '2024-01-01';
​
-- ✅ 按唯一列分批,应用层循环直到 affected_rows = 0
UPDATE t_order SET status = 2
WHERE status = 1 AND create_time < '2024-01-01'
ORDER BY id
LIMIT 2000;

要点:

  • 必须 ORDER BY 一个唯一/单调列,保证每批稳定推进、不重复不遗漏。

  • binlog_format=STATEMENT 时,UPDATE ... LIMIT 不带 ORDER BY 会导致主从不一致(从库可能更新了另外的行)。8.0 默认 ROW 格式,复制是安全的;但养成写 ORDER BY 的习惯没有坏处。

  • 批与批之间 sleep 20~100ms,给锁等待和复制线程留出喘息空间。

  • 批大小靠压测确定:观察 Seconds_Behind_Source、performance_schema.replication_applier_status_by_worker(8.0)以及锁等待。

  • 更稳妥的写法是先查主键再按主键更新,避免更新过程中 status 变化导致扫描范围抖动:

-- 应用层循环
SELECT id FROM t_order WHERE status = 1 AND create_time < '2024-01-01' ORDER BY id LIMIT 2000;
UPDATE t_order SET status = 2 WHERE id IN (/* 上一步拿到的主键列表 */);

2.5 减少锁冲突与死锁

  • 按主键顺序更新(ORDER BY id):多个事务加锁顺序一致就不容易形成环路等待。

  • 同表多行更新尽量合并成一条 SQL,减少加锁/释放次数;但要评估它一次锁定的范围。

  • 先 SELECT ... FOR UPDATE 锁定目标行,再做业务计算,最后 UPDATE —— 缩短持锁时间(但会引入间隙锁,需谨慎)。

  • 绝对不要在事务中做远程调用、文件 IO、复杂计算:事务的持续时间就是锁的持有时间。

  • 死锁不是绝症:InnoDB 会自动回滚代价小的事务,业务侧对 Deadlock found when trying to get lock 要有重试机制。

SHOW ENGINE INNODB STATUS\G   -- 查看 LATEST DETECTED DEADLOCK 段落

2.6 长事务与 MVCC 的隐性代价

长事务不仅持锁久,还会让 undo log 无法 purge:

  • 回滚段膨胀、历史版本链变长;

  • 其他事务的 consistent read 要顺着版本链回溯,读也一起变慢;

  • purge 线程堆积,磁盘 IO 升高。

-- 找出运行超过 60 秒的事务
SELECT trx_id,
       trx_state,
       trx_mysql_thread_id,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
       trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY duration_sec DESC;

规范:写操作事务控制在毫秒级;确实需要处理大量数据时,用 2.4 的分批方案。

2.7 常见坑与对照

写法 / 用法

问题

建议

UPDATE 无 WHERE

全表更新 + 全表加锁

禁止;必要时开 sql_safe_updates 兜底,或用 LIMIT 分批

WHERE id IN (1000 个 id)

单事务过大、锁范围宽

拆成 200~500 一组

用 affected_rows 判断"记录是否存在"

值没变化时影响行数为 0(除非客户端设置 CLIENT_FOUND_ROWS / JDBC useAffectedRows=false)

需要精确判断请先 SELECT ... FOR UPDATE

UPDATE 后再 SELECT 校验结果

多一次往返,且不在同一快照里

用 affected_rows,或在同一事务内先查后改

频繁 SET col = col + 1 热点行

同一行的行锁串行化,吞吐上不去

应用层合并计数(内存累加 + 定时落库),或按维度分片打散

更新参与联合索引的列

索引重建 + 可能改变最左前缀的排序分布

评估写放大,避免把"多变字段"放进联合索引靠前位置


3. SELECT 查询优化

3.1 三条基础原则

  1. 只取需要的列:SELECT * 会阻断覆盖索引、放大网络与反序列化开销,还会因为表结构变更埋坑。

  2. 只取需要的行:加 WHERE 收敛范围、加 LIMIT 提前停止。

  3. 让 WHERE 能走索引:这是下面 3.2 的清单要解决的问题。

3.2 索引失效 / 不生效清单

#

写法

原因

改写方式

1

WHERE YEAR(create_time) = 2024

索引列上做函数运算,B+ 树有序性被打乱

WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'

2

WHERE id + 1 = 100

索引列参与表达式

WHERE id = 99

3

WHERE phone = 13800000000(phone 是 VARCHAR)

隐式类型转换:字符串列被转成数字比较,索引失效

传字符串 '13800000000'。反过来 WHERE id = '100'(id 是 INT)常量转数字,索引可以正常使用

4

WHERE nickname LIKE '%张%'

前置通配符无法定位 B+ 树起点

'张%' 可用索引;中间匹配考虑 FULLTEXT 全文索引或 ES

5

WHERE a = 1 OR b = 2 且 b 无索引

一侧无索引即全表扫描

两边各自建索引让优化器走 index_merge,或改写成 UNION ALL

6

WHERE status != 1、NOT IN (...)、NOT LIKE

负向条件通常要扫描大部分数据,优化器直接放弃索引

用 >/< 收敛出可用区间;或 LEFT JOIN ... WHERE 右表.id IS NULL 做反向查询

7

索引 (a,b,c),条件只有 WHERE b = 1

违反最左前缀

调整索引列顺序,或补上 a 的等值条件

8

索引 (a,b,c),WHERE a = 1 AND b > 2 ORDER BY c

b 已是范围条件,c 无法继续用于有序定位

把 c 提到 b 前,或把排序列设计为 (a, c, b)

9

WHERE a IS NULL / IS NOT NULL

索引可以存 NULL,但选择性强时优化器才用;数据几乎全非空时 IS NOT NULL 会被判定为"扫全表更便宜"

用 EXPLAIN 验证;必要时用默认值代替 NULL

10

联查时两表字符集/排序规则不一致

关联列发生隐式转换,被驱动表索引失效

统一 utf8mb4 + 同一个 collation

11

WHERE status = 1(status 只有 2 个取值)

不是"失效",而是优化器判断"用索引更贵"(区分度太低)

建联合索引把高区分度列放前面,如 (status, create_time);或用 FORCE INDEX 验证

12

SELECT * FROM t ORDER BY 非索引列

无法用索引排序

见第 5 章

13

LIMIT 1000000, 10

深分页要扫描并丢弃 100 万行

见第 6 章

14

只对列的前缀建索引(前缀索引)

前缀索引无法完全确定顺序,也无法覆盖

前缀索引只用于"选择性地过滤",不要指望它排序

判断是否失效的唯一标准是 EXPLAIN 里 key 和 type 的实际取值,不要背结论。

3.3 覆盖索引与回表

InnoDB 的二级索引叶子节点里存的是主键值,不是整行数据。因此查询非索引列时,需要拿主键回到聚簇索引再查一次 —— 这就是回表。

若查询涉及的所有列都在索引中,就是覆盖索引,Extra 会显示 Using index。

ALTER TABLE t_order ADD INDEX idx_user_status_amount (user_id, status, amount);
​
EXPLAIN SELECT user_id, status, amount
FROM t_order
WHERE user_id = 100 AND status = 1\G
-- Extra: Using index   ← 覆盖索引,无回表

由此推出一个容易忽视的结论:主键越短越好。 每个二级索引的叶子节点都存一份主键:

主键类型

单行占用

1 亿行 × 3 个二级索引的额外空间

BIGINT 自增

8 字节

≈ 2.2 GB

CHAR(36) UUID

36 字节

≈ 10 GB

所以:能用自增 BIGINT 就别用 UUID 做主键;一定要用 UUID 就用 BINARY(16) + 有序 UUID。

3.4 联合索引:最左前缀、列顺序与 ICP

最左前缀:索引 (a, b, c) 能支持 a、(a,b)、(a,b,c) 的定位,不能单独支持 b、c、(b,c)。

列顺序的经验法则:

  1. 等值条件列在前,范围条件列在后。

  2. 有排序需求时,排序列紧随等值列之后(详见第 5 章)。

  3. 区分度高的列尽量靠前,但要以真实查询模式为准,不要为了"最左"牺牲覆盖索引能力。

用 key_len 验证实际用了几列:

-- 索引 (user_id, status, create_time),user_id BIGINT NOT NULL(8) / status TINYINT NOT NULL(1) / create_time DATETIME NOT NULL(5)
EXPLAIN SELECT * FROM t_order WHERE user_id = 100\G             -- key_len: 8  → 只用 user_id
EXPLAIN SELECT * FROM t_order WHERE user_id = 100 AND status = 1\G  -- key_len: 9  → 用 user_id + status

常见列类型的 key_len 估算:TINYINT 1、INT 4、BIGINT 8、DATETIME 5、VARCHAR(n)(utf8mb4)= 4n + 2,可空列再加 1。

索引条件下推 ICP(5.6+,默认开启):把 WHERE 中"只用索引列就能判断"的条件下推到存储引擎层,先过滤再回表,从而减少回表次数。

-- 索引 (zipcode, lastname, firstname)
EXPLAIN SELECT * FROM people
WHERE zipcode = '95054'
  AND lastname LIKE '%etrunia%'
  AND address LIKE '%Main Street%'\G
-- Extra: Using index condition  ← ICP 生效,lastname 的判断在引擎层完成

适用条件(官方口径):

  • 使用 range、ref、eq_ref、ref_or_null 访问方式,且确实需要读取完整行;

  • 支持 InnoDB / MyISAM(含分区表);

  • InnoDB 下只对二级索引生效(聚簇索引本来就要读整行,没有收益);

  • 涉及子查询、存储函数的条件不会被下推;

  • 建立在虚拟生成列上的二级索引不支持 ICP。

3.5 子查询、半连接与改写

现代优化器(5.6+)会把 IN 子查询做半连接(semi-join)优化、把派生表合并(derived merge),多数场景不需要手工改写 —— 但一定要 EXPLAIN 验证,不要盲信。

-- 可能被物化成临时表(Extra 出现 Using temporary 或 table=derived2)
SELECT * FROM t_order
WHERE user_id IN (SELECT id FROM t_user WHERE city = '杭州');
​
-- 改写为 JOIN(t_user.id 是主键,不会放大行数,语义等价;否则需要 DISTINCT 去重)
SELECT o.*
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE u.city = '杭州';

经验法则:

  • IN vs EXISTS:本质是"小表驱动大表"。外层小用 IN,外层大用 EXISTS。

  • 8.0.20+ 的半连接、反连接(NOT EXISTS)也能走哈希连接,写法上的差异进一步缩小。

  • 子查询里带 LIMIT、聚合时往往无法合并,会被物化成临时表;这类写法要用 EXPLAIN 重点确认。

3.6 统计与判存的取舍

-- 无 WHERE:InnoDB 会挑"最小的那个二级索引"来扫描(覆盖索引,不回表)
SELECT COUNT(*) FROM t_order;
​
-- 有 WHERE:按条件选索引,但仍然要扫描全部匹配行
SELECT COUNT(*) FROM t_order WHERE status = 1;
​
-- 只要判断存在,加 LIMIT 1 让优化器提前停止
SELECT 1 FROM t_order WHERE user_id = 100 LIMIT 1;
  • COUNT(*) vs COUNT(1) vs COUNT(col):前两者等价;COUNT(col) 统计非 NULL 值,语义不同。

  • 百万级以上的精确总数,考虑维护计数表或写缓存,不要每次实时 COUNT(*)。

  • MySQL 8.0.14 起引入 innodb_parallel_read_threads(默认 4),可以并行扫描聚簇索引加速 SELECT COUNT(*) 这类全表扫描;但它消耗的是用户线程资源,不要盲目调大。


4. 多表联查优化

4.1 联查的执行模型:从 NLJ 到 Hash Join

算法

出现的版本

行为

是否理想

NLJ(简单嵌套循环)

早期

外层每读一行,内层逐行匹配;内层无索引时反复全表扫描

❌ 无索引时很差

INLJ(索引嵌套循环)

—

内层通过索引查找匹配行

✅ 理想状态

BNL(块嵌套循环)

≤ 8.0.19

外层行放进 join_buffer_size 缓冲区,内层全表扫描并与缓冲区比较

⚠️ 已废弃

Hash Join

8.0.18 引入

对被驱动表建哈希表,等值连接只需一次扫描;不要求连接条件是等值

✅ 无索引时的兜底

关键事实(官方):

  • 8.0.18 起默认能走哈希连接就走哈希连接;

  • 8.0.20 起 BNL 被彻底移除,凡是以前用 BNL 的地方现在都用哈希连接;并且从 8.0.20 开始,非等值连接(如 t2.c1 < t3.c1)、半连接、反连接、外连接也能走哈希连接;

  • 哈希连接的可用内存受 join_buffer_size 限制,超出会落盘;落盘文件数超过 open_files_limit 会导致查询失败;

  • EXPLAIN 里出现 Using join buffer (hash join) 意味着被驱动表没有可用索引,这是要消灭的信号。

EXPLAIN FORMAT=TREE
SELECT * FROM t1 JOIN t2 ON t1.c1 = t2.c1\G
-- -> Inner hash join (t2.c1 = t1.c1)
--     -> Table scan on t2
--     -> Hash
--         -> Table scan on t1

联查优化的第一原则:给被驱动表的关联列建索引,让 type 至少到 ref,消灭 Using join buffer 与 type=ALL。

4.2 小表驱动大表

嵌套循环的成本 ≈ 驱动表行数 × 被驱动表单次查找成本。

  • 驱动表(外层)要尽量小:先用 WHERE 过滤收敛。

  • 被驱动表(内层)的关联列必须有索引,让单次查找是 ref / eq_ref。

  • 优化器一般会自己选,但统计信息失真时会选错;用 EXPLAIN 的 rows 观察,必要时用 STRAIGHT_JOIN 强制顺序并逐条验证。

4.3 关联字段的三条硬要求

  1. 被驱动表的关联列必须有索引(否则退化为哈希连接或全表扫描)。

  2. 两边的数据类型必须一致。类型不一致会触发隐式转换,让索引失效:

-- ❌ t_order.user_id 是 BIGINT,t_user_ext.uid 是 VARCHAR(32)
--    字符串列被转成数字比较 → t_user_ext.uid 的索引失效
SELECT * FROM t_order o JOIN t_user_ext e ON o.user_id = e.uid;
​
-- ✅ 统一为 BIGINT
ALTER TABLE t_user_ext MODIFY uid BIGINT UNSIGNED NOT NULL;
  1. 字符集与排序规则必须一致。字符集不同(utf8 vs utf8mb4)必然发生转换并导致索引失效;仅 collation 不同也会在比较时引入额外开销,建议全库统一 utf8mb4 + utf8mb4_0900_ai_ci(8.0 默认)。

-- 排查库表字符集是否统一
SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db' AND TABLE_COLLATION NOT LIKE 'utf8mb4%';

4.4 ON 与 WHERE:LEFT JOIN 的经典坑

对 LEFT JOIN 而言,写在 ON 里的是"连接条件",写在 WHERE 里的是"结果集过滤":

-- ❌ 条件写在 WHERE:右表为 NULL 的行被过滤掉,LEFT JOIN 语义退化成 INNER JOIN
SELECT u.id, u.nickname, o.order_no
FROM t_user u
LEFT JOIN t_order o ON u.id = o.user_id
WHERE o.status = 1;
​
-- ✅ 条件写在 ON:保留"没有匹配订单"的用户
SELECT u.id, u.nickname, o.order_no
FROM t_user u
LEFT JOIN t_order o ON u.id = o.user_id AND o.status = 1;

反过来,这个特性也可以被利用:WHERE o.status = 1 这种"对右表列的非空约束"会让优化器把外连接改写为内连接,从而获得更灵活的执行顺序和更好的性能。

4.5 三表及以上:控制中间结果集

联查的代价是乘积式累积的:中间结果集每放大一次,后续的每一次嵌套都会再放大一次。

  • 尽早过滤:把高选择性的 WHERE 放在最靠近驱动表的位置。

  • 中间结果集列要少:SELECT 只保留真正需要的列。

  • 避免"大表 × 大表"直接连接:先各自收敛成小集合再连接。

  • 4 张表以上、或出现了笛卡尔积(type=ALL 且无连接条件),优先考虑拆成多次单表查询在应用层装配。

4.6 实战案例:三表联查从 2.4s 到 8ms

场景:

表

行数

现有索引

t_order

5000 万

PRIMARY(id)、uk_order_no、idx_user_status_time (user_id, status, create_time)

t_user

200 万

PRIMARY(id)、uk_phone

t_user_ext(1:1 会员扩展表)

200 万

PRIMARY(uid)

需求:查"全站最近 20 笔已支付订单,附带下单用户昵称与会员等级"。

CREATE TABLE t_user_ext (
  uid   BIGINT UNSIGNED NOT NULL,
  level TINYINT NOT NULL DEFAULT 0,
  PRIMARY KEY (uid)
) ENGINE=InnoDB;
​
-- 慢 SQL
SELECT o.id, o.order_no, o.amount, o.create_time, u.nickname, e.level
FROM t_order o
LEFT JOIN t_user u     ON o.user_id = u.id
LEFT JOIN t_user_ext e ON u.id = e.uid
WHERE o.status = 1
ORDER BY o.create_time DESC
LIMIT 20;
-- EXPLAIN 关键输出(慢)
+----+-------+------+---------------+------+-------------------------------+
| id | table | type | possible_keys | key  | Extra                         |
+----+-------+------+---------------+------+-------------------------------+
|  1 | o     | ALL  | NULL          | NULL | Using where; Using filesort   |
|  1 | u     | ALL  | NULL          | NULL | Using join buffer (hash join) |
|  1 | e     | ALL  | NULL          | NULL | Using join buffer (hash join) |
+----+-------+------+---------------+------+-------------------------------+

问题分析:

  1. 现有索引以 user_id 为最左列,而查询条件是 status、排序是 create_time,索引顺序与查询模式不匹配 → t_order 全表扫描 + Using filesort;

  2. t_user、t_user_ext 的主键明明可用,却都退化成哈希连接 —— 因为驱动表输出的行数不受控(5000 万行),优化器判断"建哈希表"比"逐行主键查找"更划算;

  3. 三表联查的代价是乘积式放大:驱动表每多输出一行,后面两张表都要多走一次。

优化:先改索引,再谈其他

-- 补一个"以 status 为最左列、create_time 紧随其后"的索引
ALTER TABLE t_order ADD INDEX idx_status_time (status, create_time);
-- 优化后执行计划(预期)
+----+-------+---------+------------------+-------------------+--------------------------------+
| id | table | type    | possible_keys    | key               | Extra                          |
+----+-------+---------+------------------+-------------------+--------------------------------+
|  1 | o     | ref     | idx_status_time  | idx_status_time   | Using where                    |
|  1 | u     | eq_ref  | PRIMARY          | PRIMARY           | NULL                           |
|  1 | e     | eq_ref  | PRIMARY          | PRIMARY           | NULL                           |
+----+-------+---------+------------------+-------------------+--------------------------------+

要点:

  • 第 1 步从"全表扫描 5000 万行 + 排序"变成"沿 idx_status_time 有序扫描,取够 20 行即停(LIMIT 生效)",这是数量级的差距;

  • 第 2、3 步只由 20 行驱动,各做 20 次主键 eq_ref 查找,成本可以忽略;

  • 驱动表被收敛之后,被驱动表的主键索引才能发挥作用 —— 这也是"先减小驱动表"比"先给被驱动表加索引"更优先的原因;

  • 如果关联列不是主键(例如按手机号 o.phone = u.phone),必须确保被驱动表该列有索引,否则又会回到 Using join buffer (hash join);

  • 顺带检查关联列类型:o.user_id 是 BIGINT,若 t_user_ext.uid 被定义成 VARCHAR(32),eq_ref 就会消失(隐式转换)。

4.7 大表联查的替代方案

方案

做法

适用场景

应用层装配

拆成两次单表查询,第二次用 WHERE id IN (一批 id) 批量捞

跨库、跨服务,或联查表数据量都很大

冗余字段

订单表冗余 user_nickname 快照

读多写少、允许最终一致(下单时快照,昵称变更异步刷新)

宽表 / 预聚合

用定时任务把联查结果落到宽表

报表、列表页等高频固定查询

搜索引擎

把需要多维过滤与排序的字段同步到 ES

复杂条件下的检索与排序


5. ORDER BY 优化

5.1 两种执行路径

ORDER BY 只有两种命运:

  1. 用索引排序:Extra 中不出现 Using filesort,直接按索引顺序读取,LIMIT 场景下几乎零成本;

  2. filesort:Extra 中出现 Using filesort,需要把匹配行读出来再排序,多一个排序阶段。

判断标准只有一个:EXPLAIN 的 Extra 里有没有 Using filesort。

5.2 能用索引排序的情形

假设存在索引 (key_part1, key_part2),以下形态可能用索引避免排序(是否真正使用还取决于优化器的代价判断):

-- 1) 完全匹配索引顺序
SELECT * FROM t1 ORDER BY key_part1, key_part2;
​
-- 2) 前导列是等值条件,其余列有序(最常见的可用形态)
SELECT * FROM t1 WHERE key_part1 = 'x' ORDER BY key_part2;
​
-- 3) 范围条件与排序列是同一列
SELECT * FROM t1 WHERE key_part1 > 'x' ORDER BY key_part1;
SELECT * FROM t1 WHERE key_part1 < 'x' ORDER BY key_part1 DESC;
​
-- 4) ORDER BY 未命名索引前导列,但它在 WHERE 中是常量,索引同样可用
SELECT * FROM t1
WHERE key_part1 = 'x' AND key_part2 > 'y'
ORDER BY key_part2;   -- 不必从 key_part1 开始写
​
-- 5) InnoDB 二级索引隐含主键,因此查询主键 + 索引列也能用索引排序
SELECT pk, key_part1, key_part2 FROM t1 ORDER BY key_part1, key_part2;
​
-- 6) 全 DESC 的反向扫描(8.0 显示 Backward index scan)
SELECT * FROM t1 ORDER BY key_part1 DESC, key_part2 DESC;

⚠️ 注意:SELECT * 会带来回表成本。如果索引没有覆盖查询所需的所有列,优化器可能判断"全表扫描 + filesort"反而更便宜,从而主动放弃索引排序。给 SELECT 瘦身,往往就能让优化器改用索引。

5.3 用不上索引排序的情形

官方列举的典型场景:

  • ORDER BY key1, key2 —— 排序列来自不同索引;

  • ORDER BY key1_part1, key1_part3 —— 使用了索引中非连续的列;

  • WHERE key2 = 'x' ORDER BY key1 —— 取行用的索引与排序想用的索引不是同一个;

  • ORDER BY ABS(key)、ORDER BY -key —— 排序键是表达式;

  • 联查时,排序列不是来自第一个非 const 表;

  • ORDER BY 与 GROUP BY 表达式不同;

  • 只对列建了前缀索引(无法完全确定顺序);

  • HASH 索引(如 MEMORY 引擎);

  • 索引列上加了范围条件后再按后续列排序:索引 (a, b),WHERE a > 1 ORDER BY b —— 只能保证 a 有序,b 在 a 的每个值内部才有序。

5.4 混合升降序与降序索引(8.0)

-- 8.0:DESC 不再被忽略,会真正按降序存储
ALTER TABLE t ADD INDEX idx_a_desc_b_asc (a DESC, b ASC);
​
EXPLAIN SELECT * FROM t ORDER BY a DESC, b ASC\G   -- Extra 无 Using filesort
EXPLAIN SELECT * FROM t ORDER BY a ASC, b DESC\G   -- 反向扫描同一索引也可以

规则:混合升降序能走索引的前提,是索引的同向性(ASC/DESC 组合)与 ORDER BY 一致(方向本身可以整体反过来,反着扫即可)。

限制(官方):

  • 仅 InnoDB 支持;

  • 含降序列的二级索引不支持 change buffer,可能影响写入性能;

  • 带无序分组的 MIN() / MAX() 聚合优化不适用;

  • 不支持 HASH / FULLTEXT / SPATIAL 索引。

5.7 及以前:索引定义中的 DESC 被忽略,ORDER BY a ASC, b DESC 这类混合排序基本必然 filesort。

5.5 filesort 内部机制与调优

  • 内存还是磁盘:结果集超出 sort_buffer_size 会落盘并做多路归并。

  • 8.0.12 起 sort_buffer_size 按需增量分配(之前是每次固定分配这么多),所以可以放心调大,不必担心小查询浪费内存。

  • Sort_merge_passes 状态变量 > 0 就说明发生了磁盘归并,这是调大 sort_buffer_size 的信号。

  • 优化器 trace 的 filesort_summary 能给出真相:

"filesort_summary": {
  "rows": 100,
  "examined_rows": 100,
  "number_of_tmp_files": 0,
  "peak_memory_used": 25192,
  "sort_mode": "<sort_key, packed_additional_fields>"
}
  • sort_mode 三种取值:

sort_mode

含义

<sort_key, rowid>

排序缓冲区只放排序键 + 主键,排完再回表取数据(两阶段)

<sort_key, additional_fields>

排序键 + 查询需要的列,排序后直接从缓冲区取数据,不用回表

<sort_key, packed_additional_fields>

同上,但附加字段紧凑打包,更省内存

  • max_length_for_sort_data 在 8.0.20 起已废弃且不再生效。5.7 时代"调低它来强制走 rowid 排序"的技巧已经失效,不要再照搬。

  • 其他相关参数:max_sort_length(排序时字符串列的截断长度)、read_rnd_buffer_size(随机读的缓冲)、tmpdir(临时文件目录,建议指向独立磁盘,可用 : 分隔多个目录轮询使用)。

5.6 改写示例

示例一:索引列顺序与排序需求不匹配

-- 现有索引:idx_user_status_time (user_id, status, create_time)
-- ❌ user_id 是等值,但 status 不是,create_time 无法保证有序 → Using filesort
SELECT id, order_no FROM t_order
WHERE user_id = 100
ORDER BY create_time DESC
LIMIT 20;
​
-- ✅ 按"等值列在前、排序列紧随其后"补索引
ALTER TABLE t_order ADD INDEX idx_user_time (user_id, create_time);

示例二:范围条件吃掉排序能力

-- 索引 (user_id, create_time, amount)
-- ❌ create_time 是范围条件,后面的 amount 无法用于有序定位
SELECT * FROM t_order
WHERE user_id = 100 AND create_time > '2024-01-01'
ORDER BY amount DESC LIMIT 20;
​
-- ✅ 把 amount 提前,让排序列紧跟在等值列 user_id 之后
ALTER TABLE t_order ADD INDEX idx_user_amount (user_id, amount, create_time);

示例三:无法避免 filesort 时,先缩小排序集合

-- ❌ 对百万行排序后才取 10 行
SELECT * FROM t_order WHERE status = 1 ORDER BY create_time DESC LIMIT 10;
​
-- ✅ 若有 idx_status_time (status, create_time):等值 + 有序,取够即停
ALTER TABLE t_order ADD INDEX idx_status_time (status, create_time);

示例四:GROUP BY + ORDER BY 表达式不一致

-- ❌ ORDER BY 与 GROUP BY 表达式不同,必然 filesort
SELECT city, COUNT(*) FROM t_user GROUP BY city ORDER BY COUNT(*) DESC;
​
-- ✅ 把排序键显式对齐到索引列(示例:统一按 city 排序,做业务侧再加工)
SELECT city, COUNT(*) AS cnt FROM t_user GROUP BY city ORDER BY city;

5.7 ORDER BY RAND() 的替代

-- ❌ 对全表每一行生成随机数并排序,百万行表上是灾难
SELECT * FROM t_user ORDER BY RAND() LIMIT 10;
​
-- ✅ 方案一:主键连续时,随机一个起点后顺序取
SELECT * FROM t_user
WHERE id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM t_user)))
ORDER BY id
LIMIT 10;
​
-- ✅ 方案二:主键稀疏时,随机取 N 个起点分别取一条,应用层合并
SELECT * FROM t_user WHERE id >= ? ORDER BY id LIMIT 1;

官方手册把 ORDER BY RAND() LIMIT 15 列为"适合内存 filesort"的形态,但那指的是"能省掉临时文件",并不代表它便宜:RAND() 必须对全表每一行求值。

5.8 GROUP BY 在 8.0 的隐式排序变化

5.7 及以前,GROUP BY 在某些情况下会隐式排序,所以大家习惯加上 ORDER BY NULL 来抑制排序。

8.0 起 GROUP BY 不再隐式排序,ORDER BY NULL 已经没有意义,可以删掉。反过来说:如果你的代码依赖 GROUP BY 的输出顺序,必须显式写 ORDER BY,否则升级到 8.0 后顺序可能变化。

另外 GROUP BY 仍可能产生内部临时表(Extra: Using temporary),能借助索引有序性完成分组时,效率会高很多。


6. LIMIT 优化

6.1 LIMIT 会改变优化器的决策

官方描述了若干"带 LIMIT 时的特殊行为":

  • 不写 LIMIT 会全表扫描的场景,写了 LIMIT 优化器可能改用索引 —— 因为只取少量行时索引更划算;

  • LIMIT + ORDER BY:找到前 N 行就停止排序;若排序走索引则极快;若必须 filesort,则仍要取出全部匹配行并排序其大部分,之后再停止;

  • LIMIT + DISTINCT:找到 row_count 个唯一行即停止;

  • GROUP BY 能按索引顺序完成时:不会计算多余的分组;

  • 够数就中止查询并把结果发给客户端 —— 除非用了 SQL_CALC_FOUND_ROWS(此时必须算完);

  • LIMIT 0 立即返回空集,可用于校验 SQL 合法性与获取结果集元数据;

  • 有 LIMIT 且 ORDER BY 走不上索引时,优化器可能用内存 filesort 而不是归并文件;

  • 8.0.21 起可用 optimizer_switch 关闭"优先选择有序索引"的行为:

-- 默认优先选择能满足 ORDER BY 的有序索引(有时反而更慢)
SET optimizer_switch = 'prefer_ordering_index=off';

官方示例:SELECT c2 FROM t WHERE id2 > 3 ORDER BY id1 LIMIT 2

  • 默认:走 PRIMARY(type=index,有序扫描),因为 LIMIT 很小;

  • 关闭 prefer_ordering_index 后:走 i(id2, c1)(type=range,Using index condition)+ Using filesort。

这说明:"有没有 Using filesort"不是唯一判据,要看总的扫描行数与代价。

6.2 深分页为什么慢

SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10;

执行过程:沿主键从第 1 行开始扫描,读取 1,000,010 行,丢弃前 1,000,000 行,返回最后 10 行。

如果排序走的是二级索引(如按 create_time 排序),代价更高 —— 前 1,000,010 行每一行都要回表去取 * 涉及的列。

深分页的根本矛盾是:"跳过 N 行"这个动作本身必须被计算。所以任何"只改写法不改前提"的技巧都只是降低常数系数,真正的解法是不让数据库去数被跳过的行。

6.3 方案一:游标分页 / seek 分页(推荐)

不再告诉数据库"跳过 N 行",而是告诉它"从哪一行之后开始取"。

-- 第一页
SELECT id, order_no, amount, create_time
FROM t_order
ORDER BY id
LIMIT 10;
​
-- 之后每页,把上一页最后一条的游标值传回来
SELECT id, order_no, amount, create_time
FROM t_order
WHERE id > 1000000            -- 上一页最后一条的 id
ORDER BY id
LIMIT 10;

按非唯一列排序时,必须把唯一列一起放进排序键,构成"全序游标":

-- 按创建时间倒序翻页:排序键必须是 (create_time, id) 组合,否则同秒数据会乱序
SELECT id, order_no, amount, create_time
FROM t_order
WHERE (create_time = ? AND id < ?)   -- ? = 上一页最后一条的 create_time / id
   OR create_time < ?
ORDER BY create_time DESC, id DESC
LIMIT 10;

⚠️ 行构造器写法的坑:WHERE (create_time, id) < (?, ?) 语义正确,但官方提醒:当行构造器不覆盖索引前缀时,优化器可能只用到第一列(key_len 只算一列)。此时改写成上面的 OR 形式往往能用到完整的联合索引,务必用 EXPLAIN 对比 key_len。

优点与代价:

维度

说明

性能

O(1) 定位,翻到第 10 万页也是常数级成本

稳定性

排序键唯一时,绝对不会重复或遗漏

限制

不支持跳页(无法直接跳到第 500 页),只能"下一页/上一页/滚动加载"

前提

排序列必须单调且唯一(或与唯一列组合成全序)

绝大多数列表页(信息流、订单列表、日志检索)都适合改成这种形式。

6.4 方案二:延迟关联(必须保留跳页时)

思路:先在覆盖索引上完成分页,拿到主键后再回表取整行。

-- ❌ 深分页 + SELECT *:扫描 100 万行且每行都回表
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10;
​
-- ✅ 延迟关联:内层只在主键上分页(覆盖索引、不回表),外层只回表 10 次
SELECT o.*
FROM t_order o
INNER JOIN (
  SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10
) AS d ON o.id = d.id
ORDER BY o.id;

按二级索引排序时同样成立,因为 InnoDB 二级索引的叶子节点天然包含主键:

-- 内层只需 idx_create_time 就能覆盖 (create_time, id),不回表
SELECT o.*
FROM t_order o
INNER JOIN (
  SELECT id FROM t_order
  ORDER BY create_time DESC, id DESC
  LIMIT 1000000, 10
) AS d ON o.id = d.id
ORDER BY o.create_time DESC, o.id DESC;

效果:回表次数从 1,000,010 次降到 10 次。但索引扫描仍然是 100 万行,只是每一行的代价变小了。分页越深,收益越明显,但终究是线性成本。

6.5 方案三:覆盖索引降低单行成本

如果列表页只需要少数几列,直接把这几列做成联合索引,让分页过程完全在索引内完成:

ALTER TABLE t_order ADD INDEX idx_status_time_id (status, create_time, id);
​
-- 仍然扫描 100 万行,但全程不回表
SELECT id, status, create_time
FROM t_order
WHERE status = 1
ORDER BY create_time DESC, id DESC
LIMIT 1000000, 10;

注意:这没有解决深分页的本质问题,只是把每行的代价降到最低。数据量到千万级时,仍然应该优先使用 6.3 的游标分页。

6.6 方案对比

方案

扫描行数

回表次数

支持跳页

适用深度

推荐度

原始 LIMIT N, M

N+M

N+M

✅

浅分页(< 1 万)

⚠️

延迟关联

N+M

M

✅

中等深度、必须跳页

✅

覆盖索引 + 必要列

N+M

0

✅

中等深度、列很少

✅

游标 / seek 分页

M

M

❌

任意深度

✅✅

6.7 分页结果重复、遗漏的根因

排序键不唯一时,行序是不确定的。 官方明确指出:ORDER BY 列的值存在重复时,服务器可以按任意顺序返回这些行,并且同一个查询带上 LIMIT 与不带 LIMIT 可能返回不同顺序(因为带 LIMIT 时排序会提前停止)。

-- ❌ create_time 有重复值时,翻页可能重复或漏掉数据
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 0, 10;
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10, 10;
​
-- ✅ 追加唯一列,构成全序
SELECT * FROM t_order ORDER BY create_time DESC, id DESC LIMIT 10, 10;

结论:任何分页 SQL 的 ORDER BY 都必须能唯一确定顺序。 这是一条比性能更重要的正确性要求。

6.8 SQL_CALC_FOUND_ROWS 已废弃

-- ❌ 8.0.17 起被标记废弃:为了算总数,必须把全部匹配行都读完,无法提前中止
SELECT SQL_CALC_FOUND_ROWS * FROM t_order WHERE status = 1 LIMIT 20;
SELECT FOUND_ROWS();

替代方案:

  • 单独发一条 SELECT COUNT(*)(可以走覆盖索引);

  • 或者干脆改成"多查一行"判断是否还有下一页:

-- 取 N+1 条:返回 21 条说明还有下一页,只展示前 20 条
SELECT id, order_no FROM t_order WHERE status = 1 ORDER BY id LIMIT 21;

6.9 两个小技巧

-- LIMIT 0:快速校验 SQL 语法与结果集元数据(不返回数据)
SELECT id, order_no FROM t_order WHERE status = 1 ORDER BY id DESC LIMIT 0;
-- mysql 客户端还可配合 --column-type-info 查看列类型
​
-- LIMIT 1:只需要"存在性判断"时,让优化器尽早停止
SELECT 1 FROM t_order WHERE user_id = 100 LIMIT 1;

7. 自查清单与参考文档

7.1 写语句(INSERT / UPDATE)

  • 批量插入是否合并成多值 VALUES?批大小是否在 500~1000 行 / 1~5MB?

  • 批量写入是否用显式事务包裹?事务是否控制在 1000~5000 行以内?

  • 主键是否顺序递增(避免 UUID 随机写导致页分裂)?

  • 批量导入是否临时关闭 unique_checks / foreign_key_checks?

  • 超大导入是否用 LOAD DATA,并采用"先灌数据再建索引"?

  • 表的索引是否有冗余?是否用 sys.schema_unused_indexes 清理过?

  • UPDATE 的 WHERE 是否命中索引(否则锁会被放大到全表)?

  • 是否更新了主键或索引列?

  • 大更新是否按唯一列 ORDER BY ... LIMIT 分批,并在批间 sleep?

  • 事务里是否有远程调用或耗时计算?

7.2 查询语句

  • SELECT 列表是否有不必要的列(尤其 SELECT *)?

  • WHERE 中的索引列是否被函数或表达式包裹?

  • 是否发生隐式类型转换(字符串列传数字)?

  • 是否违反最左前缀?key_len 是否符合预期?

  • 是否可以用覆盖索引(Using index)避免回表?

  • 区分度极低的列是否单独建索引(可能被优化器放弃)?

7.3 多表联查

  • 被驱动表的关联列是否有索引?Extra 里是否出现 Using join buffer?

  • 两表关联列的类型、字符集、排序规则是否完全一致?

  • 驱动表是否已被 WHERE 收敛到足够小?

  • LEFT JOIN 的过滤条件是写在 ON 还是 WHERE(语义是否正确)?

  • 三表以上的中间结果集是否可控?是否出现笛卡尔积?

  • 是否值得改成应用层装配或冗余字段?

7.4 排序与分页

  • Extra 里是否有 Using filesort / Using temporary?

  • 索引列顺序是否符合"等值列在前、排序列紧随其后"?

  • 排序方向(含混合升降序)是否与索引同向性一致?

  • Sort_merge_passes 是否大于 0?sort_buffer_size 是否需要调大?

  • 分页 ORDER BY 是否唯一确定顺序(避免重复/遗漏)?

  • 深分页是否已改成游标分页,或至少用延迟关联?

  • 是否还在用已废弃的 SQL_CALC_FOUND_ROWS?

7.5 一条决策流程

慢 SQL
  │
  ├─ 是写语句? → 拆批 + 短事务 + 顺序主键 + 控索引数量
  │
  └─ 是读语句?
        │
        ├─ WHERE 走索引了吗?(EXPLAIN key / type)
        │     ├─ 没走 → 按 3.2 清单改写,或补索引
        │     └─ 走了 → 下一步
        │
        ├─ 扫描行数是否远大于返回行数?
        │     ├─ 是 → 索引选择性/最左前缀/深分页问题
        │     └─ 否 → 下一步
        │
        ├─ 有 Using filesort 吗? → 按第 5 章调整索引列顺序
        │
        ├─ 有多表联查吗? → 按第 4 章检查被驱动表索引与关联列类型
        │
        └─ 有 LIMIT 深分页吗? → 按第 6 章改游标分页 / 延迟关联

7.6 参考文档(MySQL 8.0 官方手册)


评论