404 Not Found

404 Not Found


nginx

查询优化

查询优化

🎯 生活类比

想象你在图书馆找一本书:

📚 核心概念

1. EXPLAIN 执行计划

EXPLAIN 是查询优化的核心工具,它告诉你数据库如何执行一条 SQL。

SQL
EXPLAIN SELECT * FROM users WHERE username = 'alice';

关键字段解读:

字段 含义 关注点
type 访问类型 ALL(全表扫描) → indexrangerefeq_refconst,越往右越好
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 分析发现:

优化步骤:

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 查询优化方法:

📝 作业

  1. 对以下 SQL 执行 EXPLAIN 分析并优化:
    SQL
    SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND user_id IN (SELECT id FROM users WHERE status = 1);
    
  2. 为一个文章列表页设计合适的索引策略(支持按分类筛选、按时间排序、分页)。
  3. 将一条使用 LIMIT 20 OFFSET 100000 的查询改写为游标分页方式。

下一课→28-project.md

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏