查询优化
查询优化
🎯 生活类比
想象你在图书馆找一本书:
- 全表扫描:把图书馆所有书架翻一遍 → 慢
- 索引:先查目录卡片,找到对应书架编号直接去取 → 快
- 执行计划:图书管理员提前告诉你"先查作者索引比先查书名更快" → EXPLAIN
- 慢查询日志:图书馆记录"超过10分钟才找到的书",用于优化排架 → 慢查询日志
- 查询重写:与其问"所有包含'科学'的书",不如先查分类再精确找 → 优化查询方式
📚 核心概念
1. EXPLAIN 执行计划
EXPLAIN 是查询优化的核心工具,它告诉你数据库如何执行一条 SQL。
SQL
EXPLAIN SELECT * FROM users WHERE username = 'alice';
关键字段解读:
| 字段 | 含义 | 关注点 |
|---|---|---|
type |
访问类型 | ALL(全表扫描) → index → range → ref → eq_ref → const,越往右越好 |
key |
实际使用的索引 | NULL 表示没用索引 |
rows |
预估扫描行数 | 越小越好 |
Extra |
额外信息 | Using filesort(需排序)、Using temporary(需临时表) 需关注 |
possible_keys |
可能用到的索引 | 帮助分析索引是否被选中 |
SQL
-- 查看执行计划
EXPLAIN SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name;
-- MySQL 8.0+ 可以查看实际执行统计
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;
type 访问类型详解:
从最差到最优:
ALL → 全表扫描(必须优化)
index → 全索引扫描(比 ALL 稍好)
range → 索引范围扫描(WHERE id > 100)
ref → 非唯一索引查找(WHERE username = 'alice')
eq_ref → 唯一索引查找(JOIN 主键)
const → 常量查找(WHERE id = 1,最快)
system → 系统表(几乎不出现)
2. 索引优化策略
SQL
-- 1. 为 WHERE、JOIN、ORDER BY 常用字段建索引
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_order_user_created ON orders(user_id, created_at);
-- 2. 覆盖索引:查询字段都在索引中,无需回表
-- 如果经常执行 SELECT id, user_id, created_at FROM orders WHERE user_id = ?
CREATE INDEX idx_order_covering ON orders(user_id, created_at, id);
-- 3. 前缀索引:对长字符串字段只索引前 N 个字符
CREATE INDEX idx_user_name_prefix ON users(username(10));
-- 4. 联合索引遵循最左前缀原则
CREATE INDEX idx_abc ON table_name(a, b, c);
-- 可以命中: WHERE a=1 | WHERE a=1 AND b=2 | WHERE a=1 AND b=2 AND c=3
-- 无法命中: WHERE b=2 | WHERE c=3 | WHERE b=2 AND c=3
-- 5. 查看表的索引使用情况
SHOW INDEX FROM orders;
-- 6. 查看索引基数(基数越高,区分度越好)
SELECT
INDEX_NAME,
COLUMN_NAME,
CARDINALITY
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'orders';
3. 避免全表扫描
SQL
-- ❌ 导致全表扫描的写法
-- 1. 对索引列使用函数
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- ✅ 改写为范围查询
SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- 2. 对索引列做运算
SELECT * FROM orders WHERE id + 1 = 100;
-- ✅ 改写为
SELECT * FROM orders WHERE id = 99;
-- 3. 隐式类型转换(phone 是 VARCHAR,传入 INT)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 改写为
SELECT * FROM users WHERE phone = '13800138000';
-- 4. LIKE 以通配符开头
SELECT * FROM users WHERE name LIKE '%alice%';
-- ✅ 如果必须模糊搜索,考虑全文索引
ALTER TABLE users ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('alice' IN BOOLEAN MODE);
-- 5. OR 条件可能导致索引失效
SELECT * FROM users WHERE status = 1 OR age > 25;
-- ✅ 改用 UNION
SELECT * FROM users WHERE status = 1
UNION
SELECT * FROM users WHERE age > 25;
-- 6. NOT IN / NOT EXISTS 可能导致全表扫描
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- ✅ 改用 LEFT JOIN + IS NULL
SELECT u.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;
4. 查询重写技巧
SQL
-- 1. 用 EXISTS 替代 IN(大数据量时更高效)
-- 慢
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- 快
SELECT * FROM users u WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000
);
-- 2. 用 UNION ALL 替代 UNION(不需要去重时)
-- UNION 会去重排序,UNION ALL 不会
SELECT name FROM users_2023 UNION ALL SELECT name FROM users_2024;
-- 3. 避免 SELECT *,只查需要的列
-- 慢
SELECT * FROM orders WHERE user_id = 1;
-- 快
SELECT id, order_no, total_amount, status FROM orders WHERE user_id = 1;
-- 4. 分页优化(深度分页问题)
-- 慢(OFFSET 100000 需要扫描前 100100 行)
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;
-- 快(游标分页,记住上一页最后一条的 id)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
-- 5. 批量操作替代循环操作
-- 慢(循环插入)
INSERT INTO logs (msg) VALUES ('a');
INSERT INTO logs (msg) VALUES ('b');
INSERT INTO logs (msg) VALUES ('c');
-- 快(批量插入)
INSERT INTO logs (msg) VALUES ('a'), ('b'), ('c');
-- 6. 避免在 WHERE 子句中对索引列使用 <> 或 !=
SELECT * FROM users WHERE status != 0;
-- ✅ 如果只有少量状态值,改写为
SELECT * FROM users WHERE status IN (1, 2, 3);
5. 慢查询日志
SQL
-- 查看慢查询日志配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒记录
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 记录未使用索引的查询
-- 分析慢查询日志
-- 使用 mysqldumpslow 工具
-- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-- 使用 pt-query-digest(Percona Toolkit)
-- pt-query-digest /var/log/mysql/slow.log
6. 调优清单
| 步骤 | 操作 | 工具 |
|---|---|---|
| 1 | 开启慢查询日志,找出慢 SQL | slow_query_log |
| 2 | 用 EXPLAIN 分析执行计划 | EXPLAIN |
| 3 | 检查是否命中索引 | type 字段 |
| 4 | 检查是否有 filesort/temporary | Extra 字段 |
| 5 | 重写 SQL 或添加索引 | DDL / SQL 重写 |
| 6 | 验证优化效果 | 对比执行时间 |
7. 常见性能陷阱
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| SELECT * | 查所有列,无法使用覆盖索引 | 只选需要的列 |
| 大 OFFSET | 深度分页扫描大量数据 | 游标分页 |
| N+1 查询 | 循环中逐条查询关联数据 | 批量查询或 JOIN |
| 无索引的 JOIN | 大表关联没建索引 | JOIN 字段加索引 |
| 大事务 | 长时间持锁 | 缩小事务范围 |
| 不合理的数据类型 | 用 VARCHAR 存数字 | 选择合适的类型 |
💡 基本语法
SQL
-- EXPLAIN 基本用法
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0+
-- 查看查询执行时间
SET profiling = 1;
SELECT ...;
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
-- 查看索引
SHOW INDEX FROM table_name;
-- 强制使用指定索引
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 100;
-- 忽略索引(用于对比测试)
SELECT * FROM orders IGNORE INDEX (idx_user_id) WHERE user_id = 100;
💡 提示:优化前先测量,优化后再测量。不要凭感觉优化,用数据说话。
EXPLAIN 是你最好的朋友。
各数据库方言对比
不同数据库在语法上有显著差异,以下是常见操作的对比:
LIMIT 分页
SQL
-- MySQL / PostgreSQL / SQLite
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 20;
-- SQL Server
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- SQL Server 2012 之前
SELECT TOP 10 * FROM orders WHERE id NOT IN (SELECT TOP 20 id FROM orders ORDER BY id);
-- Oracle 12c+
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- Oracle 12c 之前
SELECT * FROM (SELECT o.*, ROWNUM rn FROM orders o WHERE ROWNUM <= 30) WHERE rn > 20;
自增主键
SQL
-- MySQL
CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
-- 或使用默认值
INSERT INTO users (name) VALUES ('Alice'); -- id 自动生成
-- PostgreSQL
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50));
-- PostgreSQL 10+
CREATE TABLE users (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(50));
-- SQLite
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
-- SQLite 也可用 ROWID
INSERT INTO users (name) VALUES ('Alice');
-- SQL Server
CREATE TABLE users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(50));
-- Oracle
CREATE TABLE users (id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR2(50));
字符串函数
SQL
-- 字符串拼接
SELECT CONCAT('Hello', ' ', 'World'); -- MySQL, PostgreSQL
SELECT 'Hello' || ' ' || 'World'; -- PostgreSQL, SQLite, Oracle
SELECT name + ' ' + email FROM users; -- SQL Server
-- 截取子串
SELECT SUBSTRING('Hello World', 1, 5); -- MySQL, SQL Server
SELECT SUBSTR('Hello World', 1, 5); -- PostgreSQL, SQLite, Oracle
-- 字符串长度
SELECT LENGTH('Hello'); -- MySQL, PostgreSQL, SQLite
SELECT LEN('Hello'); -- SQL Server
-- 大小写转换
SELECT UPPER('hello'), LOWER('HELLO'); -- 所有数据库通用
SELECT UCASE('hello'), LCASE('HELLO'); -- MySQL 额外支持
-- 去除空格
SELECT TRIM(' Hello '); -- 所有数据库通用
SELECT LTRIM(' Hello'), RTRIM('Hello '); -- MySQL, SQL Server, PostgreSQL
-- 替换
SELECT REPLACE('Hello World', 'World', 'SQL'); -- 所有数据库通用
日期函数
SQL
-- 当前时间
SELECT NOW(); -- MySQL, PostgreSQL
SELECT CURRENT_TIMESTAMP; -- 所有数据库通用
SELECT GETDATE(); -- SQL Server
SELECT datetime('now'); -- SQLite
-- 日期加减
SELECT DATE_ADD('2024-01-01', INTERVAL 30 DAY); -- MySQL
SELECT '2024-01-01'::DATE + INTERVAL '30 days'; -- PostgreSQL
SELECT DATEADD(DAY, 30, '2024-01-01'); -- SQL Server
SELECT date('2024-01-01', '+30 days'); -- SQLite
-- 提取年/月/日
SELECT YEAR(created_at), MONTH(created_at), DAY(created_at) FROM orders; -- MySQL, SQL Server
SELECT EXTRACT(YEAR FROM created_at) FROM orders; -- PostgreSQL, MySQL 8.0+
SELECT strftime('%Y', created_at) FROM orders; -- SQLite
-- 日期格式化
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') FROM orders; -- MySQL
SELECT TO_CHAR(created_at, 'YYYY-MM-DD') FROM orders; -- PostgreSQL, Oracle
SELECT FORMAT(created_at, 'yyyy-MM-dd') FROM orders; -- SQL Server
SELECT strftime('%Y-%m-%d', created_at) FROM orders; -- SQLite
条件表达式
SQL
-- IF 表达式
SELECT IF(score >= 60, '及格', '不及格') FROM exams; -- MySQL
SELECT IIF(score >= 60, '及格', '不及格') FROM exams; -- SQL Server
SELECT CASE WHEN score >= 60 THEN '及格' ELSE '不及格' END FROM exams; -- 所有数据库通用
-- COALESCE(返回第一个非 NULL 值)
SELECT COALESCE(nickname, username, '匿名') FROM users; -- 所有数据库通用
-- NULLIF(相等时返回 NULL)
SELECT NULLIF(a, b); -- 所有数据库通用
布尔类型
SQL
-- MySQL: 无原生 BOOLEAN,TINYINT(1) 代替
CREATE TABLE users (is_active TINYINT(1) DEFAULT 1);
SELECT * FROM users WHERE is_active = TRUE; -- TRUE 等于 1
-- PostgreSQL: 原生 BOOLEAN
CREATE TABLE users (is_active BOOLEAN DEFAULT TRUE);
SELECT * FROM users WHERE is_active = TRUE;
-- SQLite: 无原生 BOOLEAN,用 INTEGER
CREATE TABLE users (is_active INTEGER DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;
-- SQL Server: 原生 BIT
CREATE TABLE users (is_active BIT DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;
UPSERT(存在则更新,不存在则插入)
SQL
-- MySQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON DUPLICATE KEY UPDATE view_count = view_count + 1;
-- PostgreSQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT (article_id) DO UPDATE SET view_count = stats.view_count + 1;
-- SQLite
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT(article_id) DO UPDATE SET view_count = view_count + 1;
-- SQL Server
MERGE INTO stats AS target
USING (SELECT 1 AS article_id, 1 AS view_count) AS source
ON target.article_id = source.article_id
WHEN MATCHED THEN UPDATE SET view_count = target.view_count + 1
WHEN NOT MATCHED THEN INSERT (article_id, view_count) VALUES (source.article_id, source.view_count);
窗口函数支持
SQL
-- 所有主流数据库都支持(MySQL 8.0+, PostgreSQL, SQL Server, SQLite 3.25+)
SELECT
name,
score,
RANK() OVER (ORDER BY score DESC) AS ranking,
ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS class_rank
FROM students;
-- MySQL 5.7 及以下不支持窗口函数,需用变量模拟
-- GROUP_CONCAT / STRING_AGG
SELECT category_id, GROUP_CONCAT(name SEPARATOR ',') FROM products GROUP BY category_id; -- MySQL
SELECT category_id, STRING_AGG(name, ',') FROM products GROUP BY category_id; -- PostgreSQL
示例:定位并优化一条慢查询(难度⭐)
原始慢查询:
SQL
-- 查询最近30天每个用户的订单总金额(假设 users 表 10万行,orders 表 100万行)
EXPLAIN
SELECT u.name, u.email, SUM(o.total_amount) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY u.id, u.name, u.email
ORDER BY total_spent DESC
LIMIT 20;
EXPLAIN 分析发现:
type: ALL(orders 表全表扫描)rows: 1000000(扫描百万行)Extra: Using temporary; Using filesort
优化步骤:
SQL
-- 1. 为 orders 表添加联合索引
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
-- 2. 优化查询:先过滤再关联
SELECT u.name, u.email, sub.total_spent
FROM users u
INNER JOIN (
SELECT user_id, SUM(total_amount) AS total_spent
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
ORDER BY total_spent DESC
LIMIT 20
) sub ON u.id = sub.user_id
ORDER BY sub.total_spent DESC;
-- 3. 再次 EXPLAIN 确认优化效果
EXPLAIN SELECT u.name, u.email, sub.total_spent ...
-- type: ref, rows: 大幅减少
示例:深度分页优化(难度⭐⭐)
问题查询:
SQL
-- 查询第10000页(每页20条),OFFSET 200000
SELECT id, title, created_at
FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000;
-- 即使有索引,也需要扫描 200020 行,非常慢
方案一:游标分页(推荐)
SQL
-- 记住上一页最后一条的 created_at 和 id
-- 假设上一页最后一条: created_at='2024-03-15 10:30:00', id=50001
SELECT id, title, created_at
FROM articles
WHERE status = 1
AND (created_at < '2024-03-15 10:30:00'
OR (created_at = '2024-03-15 10:30:00' AND id < 50001))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 只需扫描20行,极快
方案二:延迟关联
SQL
-- 先查主键,再关联取完整数据
SELECT a.id, a.title, a.created_at
FROM articles a
INNER JOIN (
SELECT id FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000
) b ON a.id = b.id;
-- 子查询走覆盖索引,主查询按主键取数据
方案三:业务层优化
SQL
-- 如果允许,限制最大翻页深度
-- 只允许查看前1000条结果,提示用户使用搜索缩小范围
SELECT id, title, created_at
FROM articles
WHERE status = 1
AND category_id = 5 -- 增加筛选条件
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
🏢 场景一:电商商品列表查询优化
SQL
-- 原始查询:多条件筛选 + 排序 + 分页
SELECT p.id, p.name, p.price, p.sales_count, c.name AS category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.status = 1
AND p.category_id IN (10, 11, 12, 13)
AND p.price BETWEEN 50 AND 500
ORDER BY p.sales_count DESC
LIMIT 20;
-- 优化:
-- 1. 创建覆盖主要筛选条件的联合索引
CREATE INDEX idx_product_filter ON products(status, category_id, price, sales_count);
-- 2. 如果 EXPLAIN 显示 filesort,调整索引顺序
CREATE INDEX idx_product_sort ON products(status, category_id, sales_count DESC, price);
-- 3. 如果分类表很小,可以冗余分类名到商品表
ALTER TABLE products ADD COLUMN category_name VARCHAR(50);
-- INSERT/UPDATE 时同步维护
🏢 场景二:统计报表查询优化
SQL
-- 原始查询:按月统计订单数据(数据量大,每次查询都很慢)
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
WHERE created_at >= '2023-01-01'
GROUP BY month
ORDER BY month;
-- 优化方案一:预计算汇总表
CREATE TABLE monthly_order_stats (
month_key VARCHAR(7) PRIMARY KEY COMMENT '格式: 2024-01',
order_count INT NOT NULL DEFAULT 0,
revenue DECIMAL(15,2) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 定时任务每天更新(增量计算)
INSERT INTO monthly_order_stats (month_key, order_count, revenue)
SELECT
DATE_FORMAT(created_at, '%Y-%m'),
COUNT(*),
SUM(total_amount)
FROM orders
WHERE created_at >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ON DUPLICATE KEY UPDATE
order_count = VALUES(order_count),
revenue = VALUES(revenue),
updated_at = NOW();
-- 查询汇总表(毫秒级响应)
SELECT * FROM monthly_order_stats ORDER BY month_key;
❓ 常见问题
Q:索引越多越好吗? A: 不是。索引会占用存储空间,降低 INSERT/UPDATE/DELETE 的速度(每次写操作都要更新索引)。只为真正需要加速的查询创建索引,定期清理无用索引。
Q:联合索引的字段顺序怎么选? A: 区分度高的字段放前面(如 user_id 比 status 区分度高);等值查询的字段放前面,范围查询的字段放后面;根据实际查询条件的组合来决定。
Q:查询已经很快了,还需要优化吗? A: 如果响应时间在可接受范围内,不必过度优化。但要注意数据量增长后可能变慢,提前做好压测和索引规划。
Q:EXPLAIN 显示的 rows 准确吗? A: 不完全准确,是基于统计信息的估算。实际执行可能更多或更少。可以通过
ANALYZE TABLE更新统计信息来提高估算准确度。
📖 小节
本课系统讲解了 SQL 查询优化方法:
- EXPLAIN 是分析执行计划的核心工具,重点关注
type、key、rows、Extra - 索引优化:合理创建索引、遵循最左前缀、使用覆盖索引
- 避免全表扫描:不对索引列使用函数/运算、注意隐式类型转换、谨慎使用 LIKE %xxx%
- 查询重写:EXISTS 替代 IN、游标分页替代大 OFFSET、批量操作替代循环
- 慢查询日志:找出性能瓶颈 SQL,有针对性地优化
- 各数据库方言:MySQL/PostgreSQL/SQLite/SQL Server 在语法上有显著差异,编写跨数据库 SQL 时需注意
📝 作业
- 对以下 SQL 执行 EXPLAIN 分析并优化:SQL
SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND user_id IN (SELECT id FROM users WHERE status = 1); - 为一个文章列表页设计合适的索引策略(支持按分类筛选、按时间排序、分页)。
- 将一条使用
LIMIT 20 OFFSET 100000的查询改写为游标分页方式。
下一课→28-project.md



