4. 慢查询分析与SQL优化:慢SQL定位方法、索引优化与分页查询优化

在报表导出服务中,数据库查询往往是性能瓶颈的“重灾区”。一个未优化的慢SQL可能导致整个导出链路超时、连接池耗尽,甚至拖垮数据库实例。本章将系统讲解慢SQL的定位方法、索引优化实战以及分页查询的两种主流方案对比。

核心原则: 报表导出场景下的SQL优化,优先关注“减少扫描行数”和“避免排序与回表”,其次才是“减少返回行数”。

4.1 慢SQL定位方法

4.1.1 慢查询日志(Slow Query Log)

MySQL的慢查询日志是定位性能问题的第一手资料。通过配置阈值,系统会自动记录执行时间超过设定值的SQL语句。

配置项 说明 推荐值(报表导出场景)
slow_query_log 是否开启慢查询日志 ON
long_query_time 慢查询阈值(秒) 1(导出查询建议更严格)
log_queries_not_using_indexes 记录未使用索引的查询 ON
min_examined_row_limit 最少扫描行数才记录 1000(过滤小查询)
-- 查看当前慢查询日志状态
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 临时开启(生产环境谨慎)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;

-- 分析慢查询日志(使用mysqldumpslow工具)
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log

4.1.2 EXPLAIN执行计划分析

获取到慢SQL后,使用EXPLAIN命令分析其执行计划,重点关注以下字段:

字段 重点关注值 优化方向
type ALL(全表扫描)、index(全索引扫描) 至少达到range或ref
possible_keys 为NULL 检查查询条件是否可建索引
key 实际使用的索引 确认是否使用了预期索引
rows 扫描行数远大于返回行数 增加过滤性更强的索引
Extra Using filesort、Using temporary 优化排序或分组,避免临时表
-- 示例:分析一个报表查询
EXPLAIN SELECT
    o.order_id, o.order_date, o.amount, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY o.amount DESC
LIMIT 1000;

-- 重点关注:type、key、rows、Extra
实战经验: 在报表导出场景中,Using filesort 和 Using temporary 是性能杀手。如果 rows 超过百万级别,即使 type 为 ref,也需要考虑进一步优化。

4.2 索引优化实战

4.2.1 复合索引设计原则

报表查询通常涉及多个过滤条件和排序字段,复合索引的设计至关重要。

  • 最左前缀原则: 查询条件必须从索引最左列开始匹配。
  • 区分度高的列放在前面: 例如 order_date 比 status 区分度更高。
  • 覆盖索引: 索引包含查询所需的所有列,避免回表。
  • 避免冗余索引: (a, b) 索引可以覆盖 (a) 索引,无需重复创建。
-- 反例:为每个字段单独建索引
CREATE INDEX idx_date ON orders(order_date);
CREATE INDEX idx_status ON orders(status);
CREATE INDEX idx_customer ON orders(customer_id);

-- 正例:根据查询模式建立复合索引
-- 查询:WHERE order_date BETWEEN ? AND ? AND status = ? ORDER BY amount DESC
CREATE INDEX idx_date_status_amount ON orders(order_date, status, amount DESC);

-- 覆盖索引示例:查询只需要 order_id, order_date, amount
CREATE INDEX idx_covering ON orders(order_date, status, amount, order_id);

4.2.2 索引失效场景排查

场景 示例 解决方案
对索引列使用函数 WHERE DATE(order_date) = '2024-01-01' 改为范围查询 WHERE order_date >= ? AND order_date < ?
隐式类型转换 WHERE order_id = '12345'(字符串 vs 数字) 保持类型一致
LIKE 以通配符开头 WHERE name LIKE '%张三%' 考虑全文索引或搜索引擎
OR 条件未全部覆盖索引 WHERE status = 1 OR amount > 1000 拆分为 UNION 或使用覆盖索引
不等于条件(!=, <>) WHERE status != 0 改为 IN (1, 2, 3) 或范围查询
注意: 报表导出服务中,ORDER BY 和 GROUP BY 也容易导致索引失效。确保排序字段与索引顺序一致,且方向(ASC/DESC)匹配。

4.3 分页查询优化:游标分页 vs 偏移分页

报表导出通常需要分批获取大量数据。传统的 LIMIT OFFSET 分页在深翻页时性能急剧下降,而游标分页(Keyset Pagination)能有效解决这一问题。

4.3.1 偏移分页(OFFSET)的问题

当 OFFSET 很大时,数据库仍然需要扫描并丢弃前 N 行数据,导致扫描行数随页码线性增长。

-- 偏移分页:越往后越慢
SELECT * FROM orders
WHERE status = 1
ORDER BY id
LIMIT 1000 OFFSET 100000;
-- 扫描行数:100000 + 1000 = 101000 行

4.3.2 游标分页(Keyset Pagination)

基于上一页最后一条记录的某个唯一有序字段(如主键、时间戳)进行过滤,利用索引直接定位起始位置。

-- 游标分页:每次只扫描需要的行
-- 第一页
SELECT * FROM orders
WHERE status = 1
ORDER BY id
LIMIT 1000;

-- 第二页(传入上一页最后一条记录的 id)
SELECT * FROM orders
WHERE status = 1 AND id > 1000
ORDER BY id
LIMIT 1000;
-- 扫描行数:1000 行(仅限索引扫描)

4.3.3 两种方案对比

对比维度 偏移分页(OFFSET) 游标分页(Keyset)
深翻页性能 线性下降(扫描行数 = OFFSET + LIMIT) 恒定高效(扫描行数 ≈ LIMIT)
实现复杂度 简单,直接使用 LIMIT OFFSET 中等,需维护上一页游标值
随机跳页 支持(如直接跳到第100页) 不支持(只能顺序翻页)
数据一致性 翻页过程中新增/删除数据可能导致重复或遗漏 基于游标过滤,不受新增数据影响
适用场景 用户交互式翻页(前几页) 报表导出、数据批量处理(深翻页)
报表导出推荐: 使用游标分页。导出任务通常是顺序分批处理,不需要随机跳页。配合 ORDER BY id 或 ORDER BY create_time, id(复合游标),可以稳定高效地遍历全表数据。
游标分页 vs 偏移分页:扫描行数对比 偏移分页(OFFSET) 第1页:扫描 1000 行 第10页:扫描 10000 行 第100页:扫描 100000 行 扫描行数 = OFFSET + LIMIT 游标分页(Keyset) 第1页:扫描 1000 行 第10页:扫描 1000 行 第100页:扫描 1000 行 扫描行数 ≈ LIMIT(恒定) 游标分页实现要点 1. 游标字段必须唯一且有序(通常使用主键 id) 2. 复合游标:ORDER BY create_time, id(处理时间重复) 3. 配合 WHERE 条件使用,确保索引能覆盖排序字段 4. 适用于顺序批量导出,不支持随机跳页

4.4 综合优化案例

假设有一个报表导出查询,需要按日期范围导出订单数据,并按金额降序排列。优化前后的对比:

-- 优化前:全表扫描 + 文件排序
EXPLAIN SELECT * FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY amount DESC
LIMIT 1000;
-- type: ALL, rows: 5000000, Extra: Using where; Using filesort

-- 优化后:使用复合索引 + 覆盖索引 + 游标分页
-- 1. 创建复合索引
CREATE INDEX idx_date_amount ON orders(order_date, amount DESC, id);

-- 2. 使用覆盖索引(只查询需要的字段)
SELECT order_id, order_date, amount, customer_id
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY amount DESC, id
LIMIT 1000;
-- type: range, key: idx_date_amount, rows: 1000, Extra: Using index condition
优化效果: 扫描行数从 500 万行降至 1000 行,查询耗时从 8.2 秒降至 0.03 秒。配合游标分页,整个导出任务可以在恒定时间内完成,不再受数据总量影响。