1. 查询执行流程
SQL 语句
→ 词法/语法分析 → 解析树(Parse Tree)
→ 语义检查(表/列存在性、权限)
→ 查询重写(视图展开、子查询优化、常量传播)
→ 生成候选执行计划
→ 代价估算(统计信息 + 代价模型)
→ 选择最优计划
→ 执行引擎执行
2. EXPLAIN 详解
2.1 EXPLAIN 输出列
EXPLAIN ANALYZE
SELECT e.name, d.name, SUM(s.amount)
FROM employees e
JOIN departments d ON e.dept_id = d.id
JOIN sales s ON e.id = s.emp_id
WHERE s.date > '2024-01-01'
GROUP BY e.id, d.name;
| 列 | 说明 | 关键值 |
|---|---|---|
id | 执行顺序编号 | 相同 id 从上到下执行 |
select_type | 查询类型 | SIMPLE/PRI/ SUBQUERY/DERIVED |
table | 访问的表 | 别名/子查询 |
type | 访问类型 | ** ALL < index < range < ref < eq_ref < const ** |
possible_keys | 可能用的索引 | |
key | 实际用的索引 | |
key_len | 索引使用长度 | 越短越好(但需完整匹配) |
ref | 索引对比值 | const/列名 |
rows | 估算扫描行数 | 越小越好 |
filtered | 过滤后剩余比例 | 越高越好 |
Extra | 额外信息 | Using index 好,Using filesort 差 |
2.2 type 列优化目标
| type | 说明 | 场景 |
|---|---|---|
system | 系统表,仅一行 | 极罕见 |
const | 主键/唯一索引等值 | WHERE id = 1 |
eq_ref | JOIN 主键/唯一索引 | JOIN 条件为主键 |
ref | 普通索引等值 | WHERE status = 'active' |
range | 索引范围 | WHERE id BETWEEN 1 AND 100 |
index | 全索引扫描 | 索引覆盖 SELECT 列 |
ALL | 全表扫描 | 无索引或索引未命中 |
优化目标:至少要达到 range,最好是 ref 或 eq_ref。
2.3 Extra 列关注
| 值 | 含义 | 好坏 |
|---|---|---|
| Using index | 覆盖索引 | ✅ 好 |
| Using where | WHERE 过滤 | ⚠️ 正常 |
| Using filesort | 额外排序 | ❌ 差(内存/磁盘排序) |
| Using temporary | 临时表 | ❌ 差(GROUP BY/DISTINCT) |
| Using join buffer | 连接缓存 | ⚠️ 大表 JOIN 需要 |
| Impossible WHERE | 永远为假 | ✅ 优化器直接返回空 |
3. 统计信息与代价模型
3.1 统计信息类型
表级统计:
- 行数(rows)
- 数据页数
- 平均行长度
列级统计(直方图/采样):
- 不同值数量(Cardinality)
- 最小/最大值
- NULL 比例
- 数据分布(直方图,8.0+)
索引统计:
- 索引页数
- 索引 Cardinality
3.2 代价估算
代价 = I/O 代价 + CPU 代价
单表查询估算:
- 全表扫描代价 = 数据页数 × 每页 I/O 代价
- 索引扫描代价 = 索引页数 × I/O 代价 + 回表行数 × I/O 代价
多表 JOIN 估算:
- Nested Loop:外表行数 × 内表单次查找代价
- Hash Join:构建哈希表代价 + 探测代价
- Merge Join:排序代价 + 合并代价
4. 优化技巧
4.1 常见慢查询优化
-- 1. 避免 SELECT *
SELECT id, name FROM users WHERE status = 'active';
-- 比 SELECT * 更可能走覆盖索引
-- 2. 范围查询后的列用不了索引
CREATE INDEX idx_a_b ON t(a, b);
SELECT * FROM t WHERE a > 1 AND b = 2;
-- a 是范围,b 不走索引
-- 解决:INDEX(a) 或改写为多个 = 条件
-- 3. 大偏移分页优化(1000000, 10)
-- ❌ 慢:扫描 1000010 行,取 10 行
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- ✅ 快:先查 id,再关联
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) tmp
ON o.id = tmp.id;
-- 4. 隐式转换导致索引失效
WHERE phone = 13800138000 -- phone 是 varchar,先转 int
-- ✅ WHERE phone = '13800138000'
-- 5. IN vs EXISTS
-- 大表 IN 小表 → EXISTS 更优
-- 小表 IN 大表 → IN 更优
4.2 JOIN 优化
-- 驱动表选择:小表驱动大表
SELECT * FROM small_table s
JOIN big_table b ON s.id = b.small_id;
-- small_table 作为驱动表(外表)
-- 对 big_table 走索引查找
-- 避免笛卡尔积
SELECT * FROM a, b; -- 如果没有连接条件 → CROSS JOIN
SELECT * FROM a JOIN b ON a.id = b.a_id; -- ✅ 正确
5. 慢查询分析流程
1. 发现慢查询
- 开启慢查询日志:slow_query_log = ON, long_query_time = 1
- 或 performance_schema.events_statements_summary_by_digest
2. 获取执行计划
EXPLAIN ANALYZE SELECT ...;
3. 分析瓶颈
- type = ALL?→ 加索引
- Extra = Using filesort?→ 排序字段加入索引
- Extra = Using temporary?→ 优化 GROUP BY
- rows 很大?→ 减少扫描范围或加索引
4. 验证优化效果
- 对比优化前后的 EXPLAIN
- 使用 bench 测试真实性能
6. 查询优化器深入:代价估算模型
6.1 基数估计(Cardinality Estimation)
查询优化器的核心是代价估算,而代价估算的准确性取决于基数估计——即估算每个操作符的输出行数。基数估计不准是导致执行计划劣化的首要原因。
基数估计原理:
表 T 有 100 万行,列 col 有 1000 个不同值(Cardinality=1000)
WHERE col = 'X' 的基数估计:
假设均匀分布 → 1000000 / 1000 = 1000 行
但实际情况往往不均匀(如 80-20 法则):
col = 'A' → 80万行(80% 的数据,最常见的值)
col = 'Z' → 1行(非常罕见的值)
优化器估算 1000 行 → 选择索引查找
实际 80万行 → 全表扫描才是最优的!
MySQL 8.0 直方图(Histogram):
-- 为列创建直方图,解决非均匀分布问题
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 128 BUCKETS;
-- 查看直方图
SELECT COLUMN_NAME, HISTOGRAM
FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'orders';
-- 删除直方图
ANALYZE TABLE orders DROP HISTOGRAM ON status;
-- 持久化统计信息(重启后不丢失)
SET GLOBAL innodb_stats_persistent = ON;
SET GLOBAL innodb_stats_auto_recalc = ON;
直方图将列的数据分布划分为 N 个桶(buckets),每个桶记录行数范围,优化器估算 WHERE col = 'X' 时直接查直方图,而非简单假设均匀分布。
6.2 统计信息过时问题
当表数据频繁变更(增删改)时,统计信息会过时,导致基数估计偏差:
-- 查看表的最后分析时间
SELECT last_update, n_rows
FROM mysql.innodb_table_stats
WHERE table_name = 'orders';
-- 手动更新统计信息
ANALYZE TABLE orders;
-- PostgreSQL 中查看统计信息
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname = 'orders';
-- PostgreSQL 更新统计
ANALYZE orders;
自动统计更新策略:
| 数据库 | 自动更新机制 | 配置 |
|---|---|---|
| MySQL | 变更行数超过 (10% + 更快) | innodb_stats_auto_recalc |
| PostgreSQL | 基于 autovacuum_analyze_threshold | autovacuum_analyze_scale_factor = 0.1 |
| SQL Server | 触发阈值(20% 变更) | AUTO_UPDATE_STATISTICS |
6.3 选择错误的执行计划
有时即使统计信息准确,优化器也会选择次优计划:
-- 典型场景:IN 子查询优化器选择物化子查询(慢)
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers WHERE country = 'CN'
);
-- 优化器可能选择:
-- 1) 物化子查询 → 创建临时表 → 与 orders 做 JOIN(内存/磁盘临时表)
-- 2) 半连接(Semi Join) → EXISTS 方式逐行检查(通常更优)
-- 强制优化器使用半连接(MariaDB/PostgreSQL)
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers WHERE country = 'CN'
) SEMI JOIN customers USING (customer_id);
-- 或改写为 EXISTS(多数数据库优化得更好)
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM customers c
WHERE c.id = o.customer_id AND c.country = 'CN'
);
7. MySQL 8.0 查询优化新特性
7.1 不可见索引(Invisible Indexes)
-- 创建不可见索引(优化器不会使用,但会维护)
CREATE INDEX idx_test ON orders(created_at) INVISIBLE;
-- 测试索引效果(强制使用)
SELECT /*+ INDEX(orders idx_test) */ * FROM orders WHERE created_at > '2024-01-01';
-- 确认有效后设为可见
ALTER TABLE orders ALTER INDEX idx_test VISIBLE;
用途:
- 在生产环境安全测试新索引效果,不影响现有查询
- 渐进式评估索引收益,避免贸然添加导致写性能下降
7.2 降序索引(Descending Indexes)
-- 支持显式 DESC 索引(8.0 之前是升序索引反向扫描,效率较低)
CREATE INDEX idx_created_desc ON orders(created_at DESC);
-- 优化 ORDER BY DESC 场景
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
-- 8.0 之前:升序索引反向扫描(低效)
-- 8.0+:直接利用降序索引(高效)
7.3 函数索引(Functional Indexes)
-- 为函数表达式创建索引
CREATE INDEX idx_year ON orders( (YEAR(created_at)) );
-- 查询时直接使用
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- 走函数索引,而非全表扫描
-- JSON 路径索引
CREATE INDEX idx_json ON products( (data->>'$.category') );
SELECT * FROM products WHERE data->>'$.category' = 'electronics';
7.4 哈希表连接(Hash Join)
MySQL 8.0 引入了真正的 Hash Join(此前仅支持 Nested Loop Join):
-- 当连接条件没有索引、且两个表数据量都较大时
SELECT * FROM large_A a
JOIN large_B b ON a.hash_col = b.hash_col;
-- MySQL 8.0 选择 Hash Join:
-- 1. 扫描小表 B,构建哈希表
-- 2. 扫描大表 A,探测哈希表
-- 3. 时间复杂度 O(m + n),优于嵌套循环 O(m × n)
8. 深度分页优化
当数据量达到数千万时,传统的 LIMIT offset, size 分页严重退化:
-- ❌ 深度分页(越往后越慢)
SELECT * FROM logs ORDER BY id DESC LIMIT 1000000, 10;
-- MySQL 需要扫描 1000010 行,丢弃前 1000000 行
-- ✅ 方案一:书签式分页(推荐)
-- 利用上一页最后一条的 id 作为游标
SELECT * FROM logs
WHERE id < 9900_000_000 -- 上一页最后一条的 id
ORDER BY id DESC
LIMIT 10;
-- 时间复杂度 O(LIMIT),与页数无关!
-- ✅ 方案二:延迟关联
SELECT l.* FROM logs l
JOIN (
SELECT id FROM logs
ORDER BY id DESC LIMIT 1000000, 10
) t ON l.id = t.id;
-- 先查 id(覆盖索引),再关联完整行
-- ✅ 方案三:搜索引擎(海量数据)
-- 使用 Elasticsearch / OpenSearch 的 from/size 或 search_after
分页方案对比:
| 方案 | 第一页 | 第 1000 页 | 数据一致性 | 适用 |
|---|---|---|---|---|
| LIMIT offset | 快 | 极慢 | 实时 | 小表、浅分页 |
| 书签游标 | 快 | 快 | 读取时固定 | 大表、非跳页 |
| 延迟关联 | 快 | 中 | 实时 | 中等规模 |
| 搜索引擎 | 快 | 快 | 延迟 | 海量数据 |
延伸阅读
9. 查询优化实战案例分析
9.1 案例一:索引选择性不足导致全表扫描
场景:用户查询系统,按手机号前缀模糊搜索 WHERE phone LIKE '138%'。
-- 表结构
CREATE TABLE users (
id BIGINT PRIMARY KEY,
phone VARCHAR(20),
name VARCHAR(50),
INDEX idx_phone (phone) -- B+树索引
);
-- 查询
SELECT * FROM users WHERE phone LIKE '138%';
-- 执行计划:type=range, key=idx_phone ✅ 走索引范围扫描
-- 但如果改为
SELECT * FROM users WHERE phone LIKE '%138%';
-- 执行计划:type=ALL, key=NULL ❌ 前导通配符无法走索引
-- 解决方案:倒排索引或搜索引擎
-- MySQL 8.0.13+ 支持降序索引 + 虚拟列
ALTER TABLE users ADD COLUMN phone_reverse VARCHAR(20) AS (REVERSE(phone)) VIRTUAL;
CREATE INDEX idx_phone_rev ON users(phone_reverse);
-- 改写查询
SELECT * FROM users WHERE phone_reverse LIKE REVERSE('%138');
-- 利用后缀匹配走索引(REVERSE('138%') = '%831' 仍是前导通配符,
-- 实际应使用全文索引或 Elasticsearch 倒排索引)
对于全文搜索场景,更推荐 Elasticsearch 或 MySQL 全文索引:
-- MySQL 全文索引
CREATE FULLTEXT INDEX ft_idx ON users(name, description);
SELECT * FROM users WHERE MATCH(name, description) AGAINST('搜索关键词' IN BOOLEAN MODE);
9.2 案例二:隐式类型转换导致索引失效
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
phone VARCHAR(20), -- 字符串类型
status INT,
INDEX idx_phone (phone)
);
-- ❌ 错误:整数与字符串比较,发生隐式转换
SELECT * FROM orders WHERE phone = 13800138000;
-- 执行计划:type=ALL(全表扫描),Extra=Using where
-- 原理:phone 是 VARCHAR,比较时 MySQL 将 phone 列的每个值转为 INT
-- 导致无法使用索引,且函数转换本身消耗 CPU
-- ✅ 正确:使用字符串常量
SELECT * FROM orders WHERE phone = '13800138000';
-- 执行计划:type=ref, key=idx_phone ✅
更多隐式转换陷阱:
| 场景 | 问题 | 正确写法 |
|---|---|---|
| VARCHAR = INT | 列转 INT | VARCHAR = ‘字符串’ |
| DATETIME = 20240801 | 列转 INT 或字符串 | DATETIME = ‘2024-08-01’ |
| ENUM = ‘value’ | ENUM 内部存储是 INT | ENUM = ‘value’(正常) |
| JSON = ‘string’ | JSON 提取后类型不确定 | 显式 CAST |
9.3 案例三:JOIN 顺序选择不当
-- 表:orders (1000万行), customers (100万行), order_items (5000万行)
-- ❌ 低效:大表驱动小表(Nested Loop 遍历 5000万行)
SELECT o.*, c.name
FROM order_items oi -- 5000万行(驱动表)
JOIN orders o ON oi.order_id = o.id
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'CN';
-- 优化器可能错误选择驱动表,此时需要 STRAIGHT_JOIN 强制顺序
-- ✅ 高效:小结果集驱动大表
SELECT o.*, c.name
FROM customers c -- 过滤后约 10 万行
STRAIGHT_JOIN orders o ON o.customer_id = c.id
STRAIGHT_JOIN order_items oi ON oi.order_id = o.id
WHERE c.country = 'CN';
-- 或者使用子查询先过滤
SELECT o.*, c.name
FROM (
SELECT id FROM customers WHERE country = 'CN' -- 先找 CN 客户
) c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id;
9.4 案例四:NOT IN 的性能问题
-- ❌ 低效:NOT IN 遇到 NULL 返回空结果集,且通常不走索引
SELECT * FROM orders
WHERE customer_id NOT IN (
SELECT id FROM customers WHERE status = 'banned'
);
-- ✅ 改写为 NOT EXISTS(更语义化,通常性能更好)
SELECT * FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM customers c
WHERE c.id = o.customer_id AND c.status = 'banned'
);
-- ✅ 或 LEFT JOIN + IS NULL
SELECT o.* FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id AND c.status = 'banned'
WHERE c.id IS NULL;
-- 性能对比(1亿行 orders,10万 banned customers):
-- NOT IN: ~15s(全表扫描子查询)
-- NOT EXISTS: ~200ms(索引半连接)
-- LEFT JOIN: ~300ms(索引 JOIN)
10. 查询优化方法论总结
系统化地优化慢查询应遵循以下步骤:
1. 发现慢查询
└── slow_query_log / performance_schema.events_statements_summary_by_digest
2. 获取执行计划
└── EXPLAIN ANALYZE / EXPLAIN FORMAT=JSON
3. 分析瓶颈
├── type = ALL? → 检查索引、WHERE 条件选择性
├── Extra = Using filesort? → 排序字段加入索引(或优化查询)
├── Extra = Using temporary? → 简化 GROUP BY / DISTINCT
├── rows 很大? → 减少扫描范围(时间窗口、分页)
└── key = NULL? → 创建合适索引或改写查询
4. 制定优化方案
├── 索引优化(最常用):添加 / 调整 / 删除索引
├── 查询改写:避免前导通配符、隐式转换、不必要的 SELECT *
├── 架构优化:读写分离、缓存(Redis)、搜索引擎(ES)
└── 数据治理:归档历史数据、分区表
5. 验证效果
├── 对比优化前后的 EXPLAIN
├── 压测验证(sysbench / mysqlslap)
└── 监控生产环境指标回归
索引设计原则:
- 最左前缀:复合索引 (a, b, c) 只能用于 a、ab、abc 的查询,不能单独用 b 或 bc
- 选择性优先:对 WHERE 条件中区分度高的列优先建索引(如手机号 > 性别)
- 避免冗余:(a) 和 (a, b) 同时存在时,如果 (a) 很少单独使用,可删除 (a)
- 覆盖索引:查询列全在索引中时无需回表,SELECT 只取需要的列
- 写放大权衡:每个索引都增加 INSERT/UPDATE/DELETE 的写入开销,索引并非越多越好。需要持续监控索引使用情况,清理长期不使用的低价值索引。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。