详解MySQL慢查询优化全流程

描述

问题背景

业务反馈某个查询页面加载需要 3-5 秒,接口超时率高达 30%。开发同学说"数据库有索引",DBA 看了一下说"执行计划没问题",运维看了一眼说"CPU 和内存都够用"。问题到底在哪?

MySQL 慢查询优化不是玄学,也不是调几个参数就能解决的。它需要工程师从业务语义出发,理解 SQL 的执行过程,分析执行计划,找到瓶颈所在,然后从索引设计、SQL 改写、配置调整等多个维度综合优化。

这篇文章用一个真实的优化案例,覆盖从发现慢查询、分析根因、设计方案、实施修复到验证效果的全流程。

环境说明与建表

为了便于理解,我们从一张真实的订单表开始。

业务场景

电商家务系统,用户下单后需要查询"我的订单列表",包含订单基本信息、商品信息、支付信息,条件是当前用户 ID,支持分页和按时间倒序排列。

表结构

 

-- 订单主表
CREATE TABLE `t_order` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单ID',
  `order_no` varchar(32) NOT NULL COMMENT '订单编号',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '订单状态:1待支付 2已支付 3已发货 4已完成 5已取消',
  `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额',
  `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额',
  `pay_time` datetime DEFAULT NULL COMMENT '支付时间',
  `ship_time` datetime DEFAULT NULL COMMENT '发货时间',
  `receive_time` datetime DEFAULT NULL COMMENT '收货时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_user_status` (`user_id`,`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

-- 订单商品明细表
CREATE TABLE `t_order_item` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '明细ID',
  `order_id` bigint NOT NULL COMMENT '订单ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `goods_name` varchar(128) NOT NULL COMMENT '商品名称',
  `goods_id` bigint NOT NULL COMMENT '商品ID',
  `price` decimal(10,2) NOT NULL COMMENT '商品单价',
  `quantity` int NOT NULL COMMENT '购买数量',
  `subtotal` decimal(10,2) NOT NULL COMMENT '小计金额',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_order_id` (`order_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单商品明细表';

-- 支付记录表
CREATE TABLE `t_pay_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '支付记录ID',
  `order_id` bigint NOT NULL COMMENT '订单ID',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `pay_no` varchar(64) NOT NULL COMMENT '支付流水号',
  `pay_channel` varchar(16) NOT NULL COMMENT '支付渠道:alipay wechat bankcard',
  `pay_amount` decimal(10,2) NOT NULL COMMENT '支付金额',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '支付状态:1待支付 2已支付 3已退款',
  `pay_time` datetime DEFAULT NULL COMMENT '支付时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_pay_no` (`pay_no`),
  KEY `idx_order_id` (`order_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';

 

数据量

t_order:约 800 万条

t_order_item:约 3200 万条(每单平均 4 个商品)

t_pay_record:约 800 万条

初始慢查询

 

-- 查询用户最近 30 天的所有订单详情(带商品明细和支付信息)
SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time,
    oi.id AS item_id,
    oi.goods_name,
    oi.price,
    oi.quantity,
    oi.subtotal,
    pr.pay_no,
    pr.pay_channel,
    pr.pay_amount AS real_pay_amount
FROM t_order o
LEFT JOIN t_order_item oi ON o.id = oi.order_id
LEFT JOIN t_pay_record pr ON o.id = pr.order_id
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

在 MySQL 5.7.30 中执行这条 SQL,耗时 3.2 秒。用 EXPLAIN 分析执行计划。

第 1 步:开启慢查询日志并分析

开启慢查询日志

 

-- 查看慢查询是否开启
SHOW VARIABLES LIKE 'slow_query_log';

-- 查看慢查询阈值(秒)
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询日志(临时)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 超过 1 秒就记录

-- 开启慢查询日志(永久),修改 my.cnf
-- [mysqld]
-- slow_query_log = 1
-- slow_query_log_file = /var/log/mysql/slow.log
-- long_query_time = 1
-- log_queries_not_using_indexes = 1

 

查看慢查询日志内容

 

# 用 mysqldumpslow 汇总慢查询(安装 mysql 包后自带)
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

# 参数说明:
# -s c:按查询次数排序
# -s t:按总执行时间排序
# -s l:按平均执行时间排序
# -t 10:取前 10 条

 

输出示例:

 

Count: 1500  Time=2.85s (4275s)  Lock=0.00s (15s)  Rows=8.5 (12750), RootUser[root]@192.168.1.10
  SELECT o.id, o.order_no, o.status FROM t_order o LEFT JOIN t_order_item oi ON o.id = oi.order_id ...

 

pt-query-digest 深度分析

如果安装了 Percona Toolkit,用 pt-query-digest 可以看到更详细的分析:

 

# 安装 Percona Toolkit
# CentOS: yum install percona-toolkit
# Ubuntu: apt install percona-toolkit

pt-query-digest /var/log/mysql/slow.log --since='2026-05-01 1000' --until='2026-05-01 1200'

 

第 2 步:分析执行计划

EXPLAIN 基本用法

 

EXPLAIN SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time,
    oi.id AS item_id,
    oi.goods_name,
    oi.price,
    oi.quantity,
    oi.subtotal,
    pr.pay_no,
    pr.pay_channel,
    pr.pay_amount AS real_pay_amount
FROM t_order o
LEFT JOIN t_order_item oi ON o.id = oi.order_id
LEFT JOIN t_pay_record pr ON o.id = pr.order_id
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

执行计划字段详解

输出示例:

 

+----+-------------+-------+------------+--------+---------------------------+---------+---------+-------+--------+----------+------------------------------------------+
| id | select_type | table | partitions | type   | key                       | key_len | ref     | rows  | filtered| Extra   |
+----+-------------+-------+------------+--------+---------------------------+---------+---------+-------+--------+----------+------------------------------------------+
|  1 | SIMPLE      | o     | NULL       | ref    | idx_user_id               | 8       | const   | 15680 |   10.00| Using index condition; Using filesort    |
|  1 | SIMPLE      | oi    | NULL       | ref    | idx_order_id              | 8       | o.id    |     4 |  100.00| NULL                                   |
|  1 | SIMPLE      | pr    | NULL       | ref    | idx_order_id              | 8       | o.id    |     1 |  100.00| NULL                                   |
+----+-------------+-------+------------+--------+---------------------------+---------+---------+-------+--------+----------+------------------------------------------+

 

重点字段解读:

type(连接类型):

const:主键或唯一索引等值查询,最多返回一条记录,最优

eq_ref:在联表查询中,被驱动表通过主键或唯一索引等值查询,每行返回一条

ref:通过普通索引等值查询,返回多条匹配记录

range:索引范围扫描(>、<、BETWEEN、IN)

index:全索引扫描,扫描整个索引文件

ALL:全表扫描,最差

本例中三个表都是 ref,看起来还可以,但实际性能问题在后面。

key(实际使用的索引):

本例 o 表用到了 idx_user_id,但还需要 ORDER BY create_time 排序,而 idx_user_id 只包含 (user_id),不包含 create_time,所以产生了 Using filesort。

rows(预估扫描行数):

本例 o 表预估扫描 15680 行,oi 表 4 行(每单 4 个商品),pr 表 1 行

15680 行全部扫描,然后排序取前 20,这个过程很慢

Extra(额外信息):

Using filesort:需要额外的排序步骤,无法利用索引顺序

Using index condition:使用了索引下推(Index Condition Pushdown,ICP)

Using temporary:需要创建临时表

Using where:在存储引擎层之后用 WHERE 条件过滤

Range checked for each record:没有合适的索引,动态计算

理解 Using filesort

Using filesort 是性能杀手。它的意思是:MySQL 无法利用索引顺序,只能把数据读到内存中,在 sort buffer 里排序。

排序的数据量越大,filesort 越慢。如果排序的行数很多,MySQL 会把 sort buffer 分成多份,分别排序,最后合并(外部排序),性能急剧下降。

可以通过 max_length_for_sort_data 参数控制 sort buffer 中每行的大小,但如果这个值设得太小,会导致更频繁的分组合并,反而更慢。

第 3 步:索引优化

问题分析

当前索引:idx_user_id(user_id)

SQL 的 WHERE 条件:user_id = 123456 AND create_time >= ...

SQL 的 ORDER BY:ORDER BY create_time DESC

SQL 的 LIMIT:LIMIT 20

问题在于 idx_user_id 只覆盖了 user_id,MySQL 找到了该用户的所有订单(约 15680 条),然后按 create_time 排序,取前 20 条。

优化方向:让索引包含 WHERE 条件和 ORDER BY 字段,减少回表和排序。

方案 1:覆盖索引

覆盖索引(Covering Index)是指一个索引包含了查询所需的所有字段,MySQL 不需要回表就能拿到所有数据。

对于 t_order 表,最优索引是:

 

-- 创建覆盖索引
ALTER TABLE t_order ADD INDEX idx_user_time (user_id, create_time DESC, status, id, order_no, total_amount, pay_amount, pay_time);

 

但这个索引字段太多,索引体积大,维护成本高。

更合理的做法是分析查询字段,只放必要的:

 

-- 针对当前查询的覆盖索引
-- 注意:user_id 必须在最前面(最左前缀原则)
ALTER TABLE t_order ADD INDEX idx_covering (user_id, create_time DESC);

 

验证索引是否生效:

 

EXPLAIN SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time
FROM t_order o
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

执行计划变化:

 

+----+-------------+-------+------------+------+------------------+---------+---------+-------+--------+----------+-------+
| id | select_type | table | partitions | type | key              | key_len | ref     | rows  | filtered| Extra   |
+----+-------------+-------+------------+------+------------------+---------+---------+-------+--------+----------+-------+
|  1 | SIMPLE      | o     | NULL       | ref  | idx_covering     | 8       | const   |  150  |   10.00| Using where; Using index |
+----+-------------+-------+------------+------+------------------+---------+---------+-------+--------+----------+-------+

 

变化:

type 从 ALL 变成 ref(从全表扫描变成索引查找)

key 从 idx_user_id 变成 idx_covering

rows 从 15680 变成 150(30 天内的订单数)

Extra 变成了 Using where; Using index(覆盖索引,不需要回表)

Using filesort 消失了(索引本身有序)

单表查询从 3.2 秒降到了 3-5 毫秒。

方案 2:联合索引优化(进阶)

实际查询需要关联 t_order_item 和 t_pay_record,需要考虑 JOIN 的顺序和索引。

分析 JOIN 顺序:

t_order 先按 user_id 和 create_time 过滤,得到 150 条记录

用这 150 条的 id 去关联 t_order_item,每单平均 4 条,得到 600 条

用这 150 条的 id 去关联 t_pay_record,每单 1 条,得到 150 条

idx_order_id 在 t_order_item 和 t_pay_record 上已经存在,但最好改成覆盖索引,减少回表:

 

-- t_order_item 的索引优化
ALTER TABLE t_order_item ADD INDEX idx_order_id_covering (order_id, user_id, goods_name, price, quantity, subtotal);

-- t_pay_record 的索引优化
ALTER TABLE t_pay_record ADD INDEX idx_order_id_covering (order_id, user_id, pay_no, pay_channel, pay_amount);

 

再次验证完整查询的执行计划:

 

EXPLAIN SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time,
    oi.id AS item_id,
    oi.goods_name,
    oi.price,
    oi.quantity,
    oi.subtotal,
    pr.pay_no,
    pr.pay_channel,
    pr.pay_amount AS real_pay_amount
FROM t_order o
LEFT JOIN t_order_item oi ON o.id = oi.order_id
LEFT JOIN t_pay_record pr ON o.id = pr.order_id
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

EXPLAIN ANALYZE(MySQL 8.0+)

如果你用的是 MySQL 8.0,可以用 EXPLAIN ANALYZE 看实际执行成本:

 

EXPLAIN ANALYZE SELECT
    o.id AS order_id,
    ...
FROM t_order o
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

输出示例:

 

-> Limit: 20 row(s)  (actual time=0.032..0.048 rows=20 loops=1)
    -> Nested loop left join  (actual time=0.030..0.045 rows=20 loops=1)
        -> Nested loop left join  (actual time=0.027..0.040 rows=20 loops=1)
            -> Index lookup on o using idx_covering (user_id=123456), (actual time=0.010..0.020 rows=20 loops=1)
            -> Index lookup on oi using idx_order_id_covering (order_id=o.id)  (cost=0.71 rows=4) (actual time=0.003..0.004 rows=4 loops=20)
            -> Index lookup on pr using idx_order_id_covering (order_id=o.id)  (cost=0.37 rows=1) (actual time=0.001..0.002 rows=1 loops=20)

 

这个输出非常直观:主查询 0.010 秒找到 20 条记录,每次 JOIN 都是索引查找,每次只需 0.003 秒。

第 4 步:SQL 改写优化

即使索引已经优化,SQL 本身的写法也会影响性能。以下是几种常见的改写思路。

4.1 减少不必要的 LEFT JOIN

当前查询用 LEFT JOIN 关联了 t_pay_record,但实际业务中只有已支付的订单才有支付记录。如果业务上不需要显示未支付订单的支付信息,可以把 LEFT JOIN 改成 JOIN,并在 WHERE 中加上支付状态过滤:

 

SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time,
    oi.id AS item_id,
    oi.goods_name,
    oi.price,
    oi.quantity,
    oi.subtotal,
    pr.pay_no,
    pr.pay_channel,
    pr.pay_amount AS real_pay_amount
FROM t_order o
INNER JOIN t_order_item oi ON o.id = oi.order_id
LEFT JOIN t_pay_record pr ON o.id = pr.order_id AND pr.status = 2
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.create_time DESC
LIMIT 20;

 

改成 INNER JOIN t_order_item 的理由:订单商品明细表不可能没有数据,而且我们需要商品信息,LEFT JOIN 没有意义。

4.2 分步查询 vs 联表查询

如果数据量大、JOIN 多,可以考虑分步查询:

 

-- 第一步:查询主订单(已优化)
SELECT id, order_no, status, total_amount, pay_amount, pay_time, create_time
FROM t_order
WHERE user_id = 123456
    AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY create_time DESC
LIMIT 20;

-- 拿到 20 个订单 ID 后,第二步:查询商品明细
SELECT id, order_id, goods_name, price, quantity, subtotal
FROM t_order_item
WHERE order_id IN (?, ?, ?, ...)
    AND user_id = 123456;  -- 加 user_id 过滤,利用覆盖索引

-- 第三步:查询支付记录
SELECT order_id, pay_no, pay_channel, pay_amount
FROM t_pay_record
WHERE order_id IN (?, ?, ?, ...)
    AND status = 2;

 

分步查询的优势:

每一步都可以独立利用最优索引

避免了 MySQL 优化器选错 JOIN 顺序的问题

可以在应用层做并行查询(三个查询同时发往数据库)

如果某一步查到 0 条,后续查询可以直接跳过

分步查询的劣势:

网络往返次数增加

需要在应用层组装数据

如果 IN 列表很长,需要拆分成多个批次

4.3 使用延迟关联优化分页

分页查询在深度翻页时(OFFSET 很大)性能急剧下降,因为 MySQL 需要先扫描到 OFFSET 位置才能返回 LIMIT 数据。

延迟关联的思路:先在索引中定位到目标行,再关联回表获取所有字段:

 

-- 原始慢分页(OFFSET 10000, LIMIT 20)
SELECT * FROM t_order
WHERE user_id = 123456
ORDER BY create_time DESC
LIMIT 10000, 20;
-- 扫描 10020 行,只返回 20 行

-- 延迟关联优化
SELECT o.id, o.order_no, o.status, o.total_amount, o.pay_amount,
       o.pay_time, o.create_time
FROM t_order o
INNER JOIN (
    SELECT id FROM t_order
    WHERE user_id = 123456
      AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
    ORDER BY create_time DESC
    LIMIT 10000, 20
) AS t USING(id);
-- 内层只扫描索引(覆盖索引),只返回 20 个 ID
-- 外层用这 20 个 ID 查主表,回表 20 次

 

如果总共有 50000 条订单,普通分页在第 500 页时需要扫描 50020 行;延迟关联只需要扫描索引 50020 行(仍然要扫描这么多),但实际回表只有 20 次。这里有个权衡:延迟关联减少的是回表次数,但无法减少索引扫描行数。真正的优化手段是禁止深度翻页,引导用户按时间范围或 ID 区间翻页。

第 5 步:配置参数调优

5.1 调整 innodb_buffer_pool_size

InnoDB 的缓存池大小直接影响查询性能。如果缓存池够大,热数据都在内存里,就不需要频繁磁盘 I/O。

 

# 查看当前设置
mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

# 查看缓存池实例数
mysql> SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';
# 推荐设置:物理内存的 60-80%,但要给操作系统留足内存
# 假设物理机 64GB,MySQL 留 48GB
# 在 my.cnf 中设置:
# innodb_buffer_pool_size = 48G
# innodb_buffer_pool_instances = 4  # 4-8 个实例,减少锁竞争

 

5.2 开启 Query Cache 的替代方案

MySQL 5.7 中 Query Cache 已经被移除(8.0 彻底删除)。不要试图开启 Query Cache。

在 MySQL 5.6/5.7 中,query_cache_type = 0 或 query_cache_size = 0。

替代方案:

应用层缓存(Redis)

使用 ANALYZE TABLE 保持统计信息准确

使用 prepared statements 减少解析开销

5.3 调整 sort_buffer_size

sort_buffer_size 控制 filesort 操作的内存缓冲区大小。如果排序数据量超过这个值,会使用临时文件排序。

 

-- 查看当前值
SHOW VARIABLES LIKE 'sort_buffer_size';  -- 默认 262144(256KB)

-- 适当调大(对每个连接有效,不需要全局太大)
-- SET GLOBAL sort_buffer_size = 1048576;  -- 1MB

 

注意:这个值不是越大越好。每个连接都会分配这么大的内存,如果并发连接数很多,内存会被耗尽。合理值是 1-4MB。

5.4 调整 read_rnd_buffer_size

read_rnd_buffer_size 用于 MySQL 读取排序后的结果集。如果值太小,MySQL 需要多次读取。

 

SHOW VARIABLES LIKE 'read_rnd_buffer_size';  -- 默认 262144(256KB)

 

合理值:2-4MB。

第 6 步:完整优化后的 SQL 和验证

优化后的完整 SQL

 

SELECT
    o.id AS order_id,
    o.order_no,
    o.status,
    o.total_amount,
    o.pay_amount,
    o.pay_time,
    o.create_time,
    oi.id AS item_id,
    oi.goods_name,
    oi.price,
    oi.quantity,
    oi.subtotal,
    pr.pay_no,
    pr.pay_channel,
    pr.pay_amount AS real_pay_amount
FROM t_order o
INNER JOIN t_order_item oi ON o.id = oi.order_id
LEFT JOIN t_pay_record pr ON o.id = pr.order_id AND pr.status = 2
WHERE o.user_id = 123456
    AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
    AND o.status != 5  -- 排除已取消订单
ORDER BY o.create_time DESC
LIMIT 20;

 

新建索引汇总

 

-- t_order 的覆盖索引
ALTER TABLE t_order ADD INDEX idx_user_time_covering (
    user_id, create_time DESC, status, id, order_no,
    total_amount, pay_amount, pay_time
);

-- t_order_item 的覆盖索引
ALTER TABLE t_order_item ADD INDEX idx_order_user_covering (
    order_id, user_id, goods_name, price, quantity, subtotal
);

-- t_pay_record 的覆盖索引
ALTER TABLE t_pay_record ADD INDEX idx_order_status_covering (
    order_id, status, pay_no, pay_channel, pay_amount
);

 

优化前后对比

指标 优化前 优化后
执行时间 3.2 秒 8-12 毫秒
扫描行数(t_order) 15680 150
使用索引 idx_user_id idx_user_time_covering
filesort
回表次数 ~63000 60
逻辑读 约 63000 约 230

验证方法

 

-- 1. EXPLAIN 确认执行计划
EXPLAIN SELECT ... (优化后的 SQL)

-- 2. 使用 ANALYZE TABLE 更新统计信息
ANALYZE TABLE t_order;
ANALYZE TABLE t_order_item;
ANALYZE TABLE t_pay_record;

-- 3. 实际执行并计时
SET profiling = 1;
-- 执行 SQL
SELECT ... (优化后的 SQL)
SHOW PROFILES;

-- 4. 查看执行时间分解
SHOW PROFILE FOR QUERY 1;
-- CPU, Memory, I/O 各阶段耗时

-- 5. 查看优化后的索引使用情况
SHOW INDEX FROM t_order;

 

第 7 步:写入部署与回滚

部署前准备

 

-- 1. 备份原表(生产环境必做)
mysqldump -h 192.168.1.100 -u root -p 
    --single-transaction 
    --quick 
    --databases your_database 
    --tables t_order t_order_item t_pay_record 
    > /backup/before_optimize_$(date +%Y%m%d).sql

-- 2. 在测试环境验证(必须)
-- 在测试库执行同样的索引变更和 SQL 验证

 

分步执行

 

-- 1. 在低峰期执行索引创建(MySQL 5.6+ 支持 Online DDL)
-- 使用 pt-online-schema-change(Percona Toolkit)可以做更安全的变更

-- 直接执行(MySQL 5.6+ 默认支持 Online DDL)
ALTER TABLE t_order ADD INDEX idx_user_time_covering (
    user_id, create_time DESC, status, id, order_no,
    total_amount, pay_amount, pay_time
), ALGORITHM=INPLACE, LOCK=NONE;

-- 2. 验证索引是否创建成功
SHOW INDEX FROM t_order;

-- 3. 用 EXPLAIN 验证 SQL 使用了新索引
EXPLAIN SELECT ... (优化后的 SQL)

-- 4. 在测试环境跑一遍完整的业务回归
-- 确保分页、排序、关联数据都正确

 

回滚方案

 

-- 如果出现问题,删除新增的索引
ALTER TABLE t_order DROP INDEX idx_user_time_covering;
ALTER TABLE t_order_item DROP INDEX idx_order_user_covering;
ALTER TABLE t_pay_record DROP INDEX idx_order_status_covering;

-- 如果表结构被改坏,用备份恢复
-- mysql -u root -p < /backup/before_optimize_20260519.sql

 

常见慢查询优化套路汇总

套路 1:SELECT * 改为只查必要字段

* 会导致回表。明确列出字段,可以利用覆盖索引避免回表。

 

-- 慢
SELECT * FROM t_order WHERE user_id = 123;

-- 快
SELECT id, order_no, status, create_time FROM t_order WHERE user_id = 123;

 

套路 2:隐式类型转换导致索引失效

字段类型和传入参数类型不一致时,MySQL 会做隐式类型转换,导致无法使用索引。

 

-- user_id 是 bigint,但传入字符串 '123456'
-- 慢:隐式转换
SELECT * FROM t_order WHERE user_id = '123456';

-- 快:类型匹配
SELECT * FROM t_order WHERE user_id = 123456;

 

套路 3:OR 条件导致索引失效

OR 条件中如果有字段没有索引,整个查询可能退化为全表扫描。

 

-- 假设 status 没有索引
-- 慢
SELECT * FROM t_order WHERE user_id = 123 OR status = 2;

-- 快:用 UNION 拆分,每个条件独立使用索引
SELECT * FROM t_order WHERE user_id = 123
UNION ALL
SELECT * FROM t_order WHERE status = 2 AND user_id != 123;

 

套路 4:IN 列表过大

 

-- 慢:IN 里 10000 个值
SELECT * FROM t_order WHERE id IN (1, 2, 3, ..., 10000);

-- 快:分批查询,每批 500-1000 个
SELECT * FROM t_order WHERE id IN (1, 2, ..., 500);
SELECT * FROM t_order WHERE id IN (501, 502, ..., 1000);
-- 在应用层并发请求

 

套路 5:COUNT(*) 慢

 

-- 慢:对大表 COUNT(*)
SELECT COUNT(*) FROM t_order WHERE user_id = 123;

-- 快:维护计数器表(异步更新)
-- 优点:毫秒级返回
-- 缺点:可能有一定延迟

-- 快2:用 EXPLAIN 估算(MySQL 8.0+)
EXPLAIN SELECT COUNT(*) FROM t_order WHERE user_id = 123;
-- rows 列的值就是估算行数

 

套路 6:JOIN 顺序错误

MySQL 优化器不总是选择最优 JOIN 顺序。可以用 STRAIGHT_JOIN 强制按你指定的顺序 JOIN:

 

SELECT STRAIGHT_JOIN
    o.id, oi.goods_name, pr.pay_no
FROM t_order o
INNER JOIN t_order_item oi ON o.id = oi.order_id
INNER JOIN t_pay_record pr ON o.id = pr.order_id
WHERE o.user_id = 123;

 

套路 7:GROUP BY 产生临时表和 filesort

 

-- 慢:GROUP BY 字段没有索引
SELECT user_id, COUNT(*) FROM t_order GROUP BY user_id;

-- 快:确保 GROUP BY 字段有索引
ALTER TABLE t_order ADD INDEX idx_user_id (user_id);

-- 快2:如果只需要统计不同 user_id 的数量
SELECT COUNT(DISTINCT user_id) FROM t_order;

 

深度优化:MySQL 8.0 新特性和执行计划解读

MySQL 8.0 带来的查询优化器改进

MySQL 8.0 在查询优化器方面有显著改进,如果生产环境还在用 MySQL 5.6/5.7,建议升级。

1. 不可见索引(Invisible Indexes)

可以隐藏索引不让优化器使用,测试禁用某索引对性能的影响,不需要删除索引就能验证。

 

-- 创建不可见索引
CREATE INDEX idx_test ON t_order_demo (user_id) INVISIBLE;

-- 修改索引可见性
ALTER TABLE t_order_demo ALTER INDEX idx_test INVISIBLE;
ALTER TABLE t_order_demo ALTER INDEX idx_test VISIBLE;

-- 验证:查询时不会使用该索引
EXPLAIN SELECT * FROM t_order_demo WHERE user_id = 1;
-- key 列不会显示 idx_test

 

实战用法:在生产环境测试新索引的影响,不需要 DROP INDEX(危险操作),只需要改成 INVISIBLE,观察查询性能是否下降。如果没下降,说明这个索引不被需要,可以安全删除。

2. 直方图统计信息

MySQL 8.0 引入了直方图(histogram)统计信息,用于估算过滤条件的选择性。

 

-- 为字段创建直方图
ANALYZE TABLE t_order_demo UPDATE HISTOGRAM ON status, amount;

-- 查看直方图信息
SELECT * FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 't_order_demo';

-- 删除直方图
ANALYZE TABLE t_order_demo DROP HISTOGRAM ON status;

 

直方图对于非索引列的 WHERE 条件特别有用,能帮助优化器更准确地估算行数,选择更好的执行计划。

3. 窗口函数(Window Functions)

MySQL 8.0 支持窗口函数,很多原来需要子查询或自连接的 SQL 可以更简洁高效。

 

-- 原来的写法:子查询查每个用户的订单排名
SELECT * FROM (
    SELECT o.*,
           ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn
    FROM t_order o
    WHERE user_id IN (1, 2, 3)
) AS t WHERE rn <= 10;

-- 优化后:直接用窗口函数,MySQL 8.0 可以更高效地执行
SELECT * FROM t_order
WHERE user_id IN (1, 2, 3)
ORDER BY user_id, create_time DESC;

 

4. CTD(Common Table Expression,公共表表达式)

 

-- 使用 WITH 语法简化复杂查询
WITH recent_orders AS (
    SELECT id, user_id, order_no, create_time
    FROM t_order
    WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
)
SELECT
    ro.id,
    ro.order_no,
    ro.create_time,
    oi.goods_name,
    pr.pay_no
FROM recent_orders ro
INNER JOIN t_order_item oi ON ro.id = oi.order_id
LEFT JOIN t_pay_record pr ON ro.id = pr.order_id
WHERE ro.user_id = 123
ORDER BY ro.create_time DESC
LIMIT 20;

 

CTE 让 SQL 更可读,而且 MySQL 8.0 的 CTE 优化器会对查询进行物化,避免重复计算。

执行计划的进阶分析

1. EXPLAIN FORMAT=JSON

JSON 格式的 EXPLAIN 能看到更详细的成本估算:

 

EXPLAIN FORMAT=JSON SELECT o.id, o.order_no
FROM t_order o
WHERE o.user_id = 1
ORDER BY o.create_time DESC
LIMIT 20G

 

输出示例(关键部分):

 

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "1847.35"
    },
    "ordering_operation": {
      "using_filesort": false,
      "table": {
        "table_name": "o",
        "access_type": "ref",
        "key": "idx_user_time",
        "rows_examined_per_scan": 156,
        "rows_produced_per_join": 156,
        "filtered": "100.00",
        "cost_info": {
          "read_cost": "1520.00",
          "eval_cost": "156.00",
          "prefix_cost": "1847.35"
        }
      }
    }
  }
}

 

关键成本字段:

query_cost:总查询成本,越低越好

read_cost:读取数据的成本

eval_cost:评估条件的成本

prefix_cost:当前步骤的累计成本

2. 查看 optimizer trace

MySQL 优化器是如何做出决策的?optimizer_trace 能看到完整的推理过程:

 

-- 开启 optimizer trace
SET optimizer_trace = 'enabled=on';
SET optimizer_trace_max_mem_size = 1000000;

-- 执行查询
SELECT o.id, o.order_no, o.status
FROM t_order o
WHERE o.user_id = 1
ORDER BY o.create_time DESC
LIMIT 20;

-- 查看优化器的推理过程
SELECT * FROM information_schema.OPTIMIZER_TRACEG

-- 关闭
SET optimizer_trace = 'enabled=off';

 

optimizer_trace 输出非常详细,包括:

优化器考虑了哪些执行计划

每个计划的成本估算

为什么不选择某个计划

索引条件下推(ICP)的执行过程

这对于理解"为什么优化器选错了索引"特别有用。

Buffer Pool 调优深度指南

InnoDB Buffer Pool 是 MySQL 最核心的内存区域,它的效率直接影响查询性能。

1. 查看 Buffer Pool 使用情况

 

-- 缓存池整体状态
SHOW ENGINE INNODB STATUSG

-- 关注以下指标(在 STATUS 输出中):
-- Total memory allocated = xxx; Buffer pool size = xxx pages
-- Free buffers = xxx (空闲页)
-- Database pages = xxx (已用数据页)
-- Modified db pages = xxx (脏页数)
-- Pending reads = xxx / Pending writes = xxx (等待中的读/写请求)

-- 更详细的统计(MySQL 5.7+)
SHOW STATUS LIKE 'Innodb_buffer_pool%';

 

关键指标:

Innodb_buffer_pool_pages_total:总页数

Innodb_buffer_pool_pages_free:空闲页数

Innodb_buffer_pool_pages_dirty:脏页数

Innodb_buffer_pool_read_requests:读请求次数

Innodb_buffer_pool_reads:从磁盘读的次数(未命中缓存)

Innodb_buffer_pool_wait_free:等待空闲页的次数(表示缓存池已满且需要读取新页)

命中率计算:

 

-- 计算缓存命中率
SELECT
    (1 - (b.pread - b.pread_local) / bpread_total) * 100 AS read_hit_rate
FROM (
    SELECT
        VARIABLE_VALUE AS pread,
        0 AS pread_local,
        0 ASpread_total
    FROM performance_schema.global_status
    WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests'
) a,
(
    SELECT
        0 AS pread,
        0 AS pread_local,
        VARIABLE_VALUE ASpread_total
    FROM performance_schema.global_status
    WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads'
) b;

 

正常情况下命中率应该在 95% 以上。如果低于 90%,说明 Buffer Pool 不够用,需要增大。

2. 预热 Buffer Pool

MySQL 重启后,Buffer Pool 是空的,所有查询都要从磁盘读。需要预热:

 

-- 方案1:手动加载热点数据(MySQL 5.6+)
-- 设置 innodb_buffer_pool_dump_at_shutdown = ON,重启前自动保存热点页列表
SET GLOBAL innodb_buffer_pool_dump_now = ON;

-- 设置 innodb_buffer_pool_load_at_startup = ON,重启后自动加载
-- 在 my.cnf 中启用

-- 方案2:手动 SQL 预热(指定热点查询)
-- 执行你最常用的查询,让数据进入缓存
SELECT COUNT(*) FROM t_order;
SELECT * FROM t_order WHERE user_id IN (1, 2, 3, ...) LIMIT 10000;

-- 方案3:用 sys schema 的存储过程预热(MySQL 5.7+)
-- CALL sys.ps_trace_thread('/tmp/trace.log', 60);

 

3. 多个 Buffer Pool 实例

Buffer Pool 太大时会有锁竞争。MySQL 5.7 支持多个实例,每个实例独立加锁,减少并发访问争用:

 

# 在 my.cnf 中配置
# innodb_buffer_pool_instances = 4  # 建议 4-8 个实例
# innodb_buffer_pool_size = 48G     # 每个实例大约 12G

# 验证
SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

 

配置原则:

innodb_buffer_pool_instances 建议设置为 CPU 核心数,但不超过 16

innodb_buffer_pool_size 必须能被 innodb_buffer_pool_instances 整除

每个实例至少 1GB 才有效

生产环境操作规范

操作前的检查清单

 

-- 1. 确认当前执行的 SQL
SHOW PROCESSLIST;
-- 找到慢查询对应的连接 ID

-- 2. 确认表的数据量和索引
SHOW TABLE STATUS LIKE 't_order';
SHOW INDEX FROM t_order;

-- 3. 确认没有长时间运行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 如果有未提交的事务,ALTER TABLE 会等待锁

-- 4. 备份
mysqldump -h 192.168.1.100 -u root -p 
    --single-transaction --quick 
    --databases your_database --tables t_order 
    > /backup/t_order_$(date +%Y%m%d).sql

-- 5. 在测试环境执行同样的操作

 

在线 DDL 的正确姿势

MySQL 5.6+ 支持在线 DDL(添加索引时不需要锁表),但仍需注意:

 

-- 正确的方式:ALGORITHM=INPLACE, LOCK=NONE
ALTER TABLE t_order
ADD INDEX idx_user_time (user_id, create_time),
ALGORITHM=INPLACE, LOCK=NONE;

-- 如果不支持在线 DDL(老版本或特殊场景),用 pt-online-schema-change
pt-online-schema-change 
    --alter "ADD INDEX idx_user_time (user_id, create_time)" 
    --execute 
    --charset=utf8mb4 
    --chunk-size=1000 
    --max-load="Threads_running=50" 
    D=yourdatabase,t=t_order

 

pt-online-schema-change 的工作原理:

创建新表(带新索引)

在原表上创建触发器,把新写入的数据同步到新表

分批把原表数据复制到新表(每次 1000 行)

rename 新表为原表名,删除原表

风险提醒:pt-online-schema-change 在数据量极大时(数千万行),触发器会带来额外的写入开销,可能影响正常业务。建议在低峰期操作,并设置 --max-load 限制并发。

验证优化效果的标准流程

 

-- 步骤1:执行 EXPLAIN,确认执行计划正确
EXPLAIN SELECT ... (优化后的 SQL)

-- 步骤2:执行 ANALYZE TABLE,更新统计信息
ANALYZE TABLE t_order;
ANALYZE TABLE t_order_item;
ANALYZE TABLE t_pay_record;

-- 步骤3:用 EXPLAIN 再次确认
EXPLAIN SELECT ... (优化后的 SQL)

-- 步骤4:用 SQL_NO_CACHE 确保不走查询缓存
SELECT SQL_NO_CACHE ... (优化后的 SQL)

-- 步骤5:用 SHOW PROFILE 看各阶段耗时
SET profiling = 1;
SELECT ... (优化后的 SQL)
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;

-- 步骤6:用 EXPLAIN ANALYZE 验证实际执行成本(MySQL 8.0+)
EXPLAIN ANALYZE SELECT ... (优化后的 SQL)

 

总结

MySQL 慢查询优化的核心在于三件事:

第一,知道 SQL 实际在做什么。通过 EXPLAIN、EXPLAIN ANALYZE、慢查询日志、profiling 工具,把 SQL 的执行过程拆解清楚,知道每一步是在扫描全表还是索引扫描,是在内存排序还是文件排序,是回表 1 次还是回表 1 万次。

第二,让索引匹配查询语义。索引设计的核心是:最左前缀匹配、覆盖索引减少回表、避免 filesort。对于 WHERE、ORDER BY、LIMIT 共存的查询,尽量让索引同时覆盖这三者。

第三,改写 SQL 而不是只会调参数。参数调优(buffer pool、sort buffer 等)只能锦上添花,真正决定性能的是索引设计和 SQL 写法。如果 SQL 写得差,再大的 buffer pool 也救不了。

第四,关注 MySQL 8.0 的新特性。不可见索引、直方图、窗口函数、CTE、执行计划 JSON 格式、optimizer trace 等新工具,让优化过程更精准、更可控。

优化完成后,必须在测试环境验证执行计划、业务逻辑正确性、性能提升幅度,然后才能上生产。生产环境操作要备份、回滚预案、低峰期执行。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分