本文以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。
核心字段
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 常见值
0.3 第三步:优化的三个方向
少读:减少扫描行数 —— 建索引、谓词下推、分区、冷热分离/归档。
少传:只取需要的列和行 —— 别写
SELECT *、用LIMIT、尽量命中覆盖索引。少等:减少交互次数与事务持有时间 —— 批量写入、短事务、避免在事务里做远程调用。
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 官方给的相对耗时比例(数值只是量级参考):
结论:固定开销(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+ 树,主键顺序决定了数据的物理写入顺序。
官方原话:批量插入时 按主键顺序插入更快,对放不进 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 常见坑与对照
2. UPDATE 语句优化
2.1 本质:UPDATE = SELECT + 写开销
官方明确:UPDATE 的优化方式与 SELECT 相同,额外开销来自"写";写开销取决于更新的数据量和涉及的索引数量,未被更新的索引不会维护。
所以:UPDATE 慢,先把它当成 SELECT 慢来治 —— 先 EXPLAIN 它的 WHERE。
2.2 WHERE 必须命中索引,否则锁会被放大
InnoDB 的行锁是加在索引记录上的,能不能精确定位,直接决定锁的范围:
-- ❌ 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 常见坑与对照
3. SELECT 查询优化
3.1 三条基础原则
只取需要的列:
SELECT *会阻断覆盖索引、放大网络与反序列化开销,还会因为表结构变更埋坑。只取需要的行:加
WHERE收敛范围、加LIMIT提前停止。让 WHERE 能走索引:这是下面 3.2 的清单要解决的问题。
3.2 索引失效 / 不生效清单
判断是否失效的唯一标准是
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 ← 覆盖索引,无回表由此推出一个容易忽视的结论:主键越短越好。 每个二级索引的叶子节点都存一份主键:
所以:能用自增 BIGINT 就别用 UUID 做主键;一定要用 UUID 就用 BINARY(16) + 有序 UUID。
3.4 联合索引:最左前缀、列顺序与 ICP
最左前缀:索引 (a, b, c) 能支持 a、(a,b)、(a,b,c) 的定位,不能单独支持 b、c、(b,c)。
列顺序的经验法则:
等值条件列在前,范围条件列在后。
有排序需求时,排序列紧随等值列之后(详见第 5 章)。
区分度高的列尽量靠前,但要以真实查询模式为准,不要为了"最左"牺牲覆盖索引能力。
用 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 = '杭州';经验法则:
INvsEXISTS:本质是"小表驱动大表"。外层小用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(*)vsCOUNT(1)vsCOUNT(col):前两者等价;COUNT(col)统计非 NULL 值,语义不同。百万级以上的精确总数,考虑维护计数表或写缓存,不要每次实时
COUNT(*)。MySQL 8.0.14 起引入
innodb_parallel_read_threads(默认 4),可以并行扫描聚簇索引加速SELECT COUNT(*)这类全表扫描;但它消耗的是用户线程资源,不要盲目调大。
4. 多表联查优化
4.1 联查的执行模型:从 NLJ 到 Hash Join
关键事实(官方):
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 关联字段的三条硬要求
被驱动表的关联列必须有索引(否则退化为哈希连接或全表扫描)。
两边的数据类型必须一致。类型不一致会触发隐式转换,让索引失效:
-- ❌ 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;字符集与排序规则必须一致。字符集不同(
utf8vsutf8mb4)必然发生转换并导致索引失效;仅 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
场景:
需求:查"全站最近 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) |
+----+-------+------+---------------+------+-------------------------------+问题分析:
现有索引以
user_id为最左列,而查询条件是status、排序是create_time,索引顺序与查询模式不匹配 →t_order全表扫描 +Using filesort;t_user、t_user_ext的主键明明可用,却都退化成哈希连接 —— 因为驱动表输出的行数不受控(5000 万行),优化器判断"建哈希表"比"逐行主键查找"更划算;三表联查的代价是乘积式放大:驱动表每多输出一行,后面两张表都要多走一次。
优化:先改索引,再谈其他
-- 补一个"以 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 大表联查的替代方案
5. ORDER BY 优化
5.1 两种执行路径
ORDER BY 只有两种命运:
用索引排序:
Extra中不出现Using filesort,直接按索引顺序读取,LIMIT场景下几乎零成本;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三种取值:
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。
优点与代价:
绝大多数列表页(信息流、订单列表、日志检索)都适合改成这种形式。
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 方案对比
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 章改游标分页 / 延迟关联