0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

MySQL慢查询调优指南

马哥Linux运维 来源:马哥Linux运维 2026-04-09 10:01 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

背景与目的

MySQL慢查询是数据库性能问题的最常见原因。当一条SQL语句执行超过1秒时,就可能影响用户体验;超过10秒时,通常会收到用户投诉;而超过30秒的查询,往往意味着系统存在严重的性能问题。本文从实战角度出发,系统讲解慢查询的发现、分析、定位和优化方法,帮助DBA和运维工程师建立完整的慢查询优化知识体系。

前置知识:本文假设你具备基本的SQL知识,了解MySQL/MariaDB的基本操作,有过实际数据库维护经验。

环境说明:本文基于MySQL 8.0.36(社区版),MariaDB 10.11.x,使用InnoDB存储引擎。命令示例兼容Percona Server 8.0。

1. 慢查询日志配置与开启

1.1 慢查询日志基础

慢查询日志记录执行时间超过指定阈值的SQL语句,是优化工作的起点。

# 检查慢查询日志是否开启
mysql -e"SHOW VARIABLES LIKE 'slow_query_log%';"
mysql -e"SHOW VARIABLES LIKE 'long_query_time%';"
mysql -e"SHOW VARIABLES LIKE 'log_output%';"

# 输出示例:
# +---------------------+-------------------------------+
# | Variable_name    | Value             |
# +---------------------+-------------------------------+
# | slow_query_log   | OFF             |
# | slow_query_log_file | /var/lib/mysql/mysql-slow.log |
# +---------------------+-------------------------------+
# | Variable_name    | Value             |
# +---------------------+-------------------------------+
# | long_query_time   | 10.000000          |
# +---------------------+-------------------------------+

1.2 临时开启慢查询日志

-- 临时开启慢查询日志
SETGLOBALslow_query_log ='ON';
SETGLOBALslow_query_log_file ='/var/lib/mysql/mysql-slow.log';
SETGLOBALlong_query_time =1; -- 超过1秒记录
SETGLOBALlog_queries_not_using_indexes ='ON'; -- 记录未使用索引的查询

1.3 永久配置(my.cnf)

[mysqld]
# 慢查询日志开关
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1

# 记录未使用索引的查询(生产环境谨慎开启,可能产生大量日志)
log_queries_not_using_indexes = OFF

# 记录管理语句
log_slow_admin_statements = ON

# 记录慢查询到表(mysql.slow_log)
# log_output = 'TABLE'
# 需要时改为FILE或TABLE

# 最小锁定时间(只记录超过此时间的锁定)
# min_examined_row_limit = 1000

1.4 配置脚本

#!/bin/bash
# script: enable_slow_query_log.sh
# 用途:启用并配置MySQL慢查询日志

SLOW_LOG_FILE="/var/lib/mysql/mysql-slow.log"
LONG_QUERY_TIME=1

echo"=== 启用MySQL慢查询日志 ==="

# 检查MySQL是否运行
if! systemctl is-active mysql &>/dev/null;then
 echo"MySQL未运行"
 exit1
fi

# 创建慢查询日志文件
touch"$SLOW_LOG_FILE"
chown mysql:mysql"$SLOW_LOG_FILE"

# 临时启用
mysql <

1.5 pt-query-digest工具

Percona Toolkit中的pt-query-digest是分析慢查询日志的神器。

# 安装
dnf install percona-toolkit -y

# 分析慢查询日志
pt-query-digest /var/lib/mysql/mysql-slow.log

# 输出前10个最慢的查询
pt-query-digest --limit20 /var/lib/mysql/mysql-slow.log

# 分析特定时间段(需要日志包含时间戳)
pt-query-digest --since='2026-04-03 0000'--until='2026-04-03 1200'/var/lib/mysql/mysql-slow.log

# 分析特定数据库
pt-query-digest --filter'$event->{db} && $event->{db} eq "mydb"'/var/lib/mysql/mysql-slow.log

# 将分析结果保存到文件
pt-query-digest /var/lib/mysql/mysql-slow.log > /tmp/query_analysis.txt

# 实时分析当前查询(从processlist)
pt-query-digest --processlist h=localhost --interval 1 --run-time 60

2. explain执行计划解读

2.1 explain基础用法

-- 基础explain
EXPLAINSELECT*FROMusersWHEREid=1;

-- 更详细的输出
EXPLAINANALYZESELECT*FROMusersWHEREid=1;
-- 注意:EXPLAIN ANALYZE只在MySQL 8.0.18+支持

-- 查看JSON格式(更完整)
EXPLAINFORMAT=JSONSELECT*FROMusersWHEREid=1;

2.2 explain输出字段详解

EXPLAINSELECTu.name, o.order_id
FROMusersu
LEFTJOINorders oONu.id = o.user_id
WHEREu.status ='active'ANDo.create_time >'2026-01-01';

输出字段说明

+----+-------------+-------+------------+-------+---------------+--------+---------+------+------+----------+-------------+
| id | select_type| table | partitions |type | possible_keys | key  | key_len | ref | rows | filtered | Extra    |
+----+-------------+-------+------------+-------+---------------+--------+---------+------+------+----------+-------------+

字段 说明
id 查询中SELECT的序列号
select_type SELECT类型(SIMPLE/PRIMARY/SUBQUERY等)
table 涉及的表
partitions 涉及的分区
type 连接类型(性能关键)
possible_keys 可能使用的索引
key 实际使用的索引
key_len 索引长度
ref 与索引比较的列
rows 预计扫描的行数
filtered 过滤后剩余的百分比
Extra 附加信息

2.3 type字段详解(性能从优到差)

-- system:表只有一行(系统表)
EXPLAINSELECT*FROMmysql.time_zone_name;

-- const:最多匹配一行(主键或唯一索引)
EXPLAINSELECT*FROMusersWHEREid=1;

-- eq_ref:JOIN时使用主键或唯一索引
EXPLAINSELECT*FROMorders oJOINusersuONo.user_id = u.id;

-- ref:使用非唯一索引
EXPLAINSELECT*FROMordersWHEREstatus='pending';

-- ref_or_null:类似ref,但包含NULL查询
EXPLAINSELECT*FROMordersWHEREuser_id =1ORuser_idISNULL;

-- range:使用索引范围查询
EXPLAINSELECT*FROMordersWHEREcreate_timeBETWEEN'2026-01-01'AND'2026-01-31';

-- index:全索引扫描
EXPLAINSELECTCOUNT(*)FROMorders;

-- ALL:全表扫描(最差)
EXPLAINSELECT*FROMordersWHEREorder_nameLIKE'%test%';

2.4 Extra字段常见值

-- Using index:覆盖索引,无需回表
EXPLAINSELECTuser_id,nameFROMusersWHEREname='John';

-- Using where:使用WHERE过滤
EXPLAINSELECT*FROMusersWHEREstatus='active';

-- Using temporary:使用临时表(性能差)
EXPLAINSELECTname,COUNT(*)FROMordersGROUPBYname;

-- Using filesort:使用文件排序(性能差)
EXPLAINSELECT*FROMusersORDERBYcreate_timeDESC;

-- Using index condition:索引条件下推
EXPLAINSELECT*FROMordersWHEREuser_id =1ANDorder_nameLIKE'A%';

-- Using MRR:使用多范围读优化
EXPLAINSELECT*FROMordersWHEREuser_idIN(1,2,3);

2.5 慢查询分析脚本

#!/bin/bash
# script: analyze_slow_queries.sh
# 用途:从慢查询日志提取并分析SQL

SLOW_LOG="/var/lib/mysql/mysql-slow.log"
REPORT_FILE="/tmp/slow_query_report_$(date +%Y%m%d).txt"

echo"=== MySQL慢查询分析报告 ===">"$REPORT_FILE"
echo"生成时间:$(date)">>"$REPORT_FILE"
echo"">>"$REPORT_FILE"

# 使用pt-query-digest分析
ifcommand-v pt-query-digest &> /dev/null;then
 echo"【1】最慢的10个查询:">>"$REPORT_FILE"
  pt-query-digest --limit10"$SLOW_LOG">>"$REPORT_FILE"2>&1

 echo"【2】查询次数最多的SQL:">>"$REPORT_FILE"
  pt-query-digest --order-by Query_time:sum --limit10"$SLOW_LOG">>"$REPORT_FILE"2>&1

 echo"【3】未使用索引的查询:">>"$REPORT_FILE"
  pt-query-digest --filter'$event->{巡查索引} =~ /No index/'"$SLOW_LOG">>"$REPORT_FILE"2>&1
else
 echo"pt-query-digest未安装,使用mysqldumpslow">>"$REPORT_FILE"

 # 备用方案:使用mysqldumpslow
 echo"【1】最慢的10个查询:">>"$REPORT_FILE"
  mysqldumpslow -t 10"$SLOW_LOG">>"$REPORT_FILE"2>&1
fi

cat"$REPORT_FILE"

3. 索引数据结构:B+树原理

3.1 为什么MySQL选择B+树

现代关系数据库索引几乎都使用B+树作为数据结构,原因如下:

磁盘友好

B+树每个节点通常等于一个磁盘页(16KB)

高通常为3-4层(16KB * 3层 = 数十GB索引)

查询只需3-4次磁盘IO

范围查询高效

叶子节点用双向链表连接

范围查询只需定位起点,顺序扫描即可

与B树的区别

B树所有节点都存储数据

B+树只有叶子节点存储数据,内部节点只存储键

B+树内部节点更小,树高更低

3.2 B+树可视化

          [50 | 100 | 200]
          /   |   
     [<50]   [50-99] [100-199] [>=200]
      |    |    |     |
    数据1   数据2   数据3   数据4
      |    |    |     |
    数据5   数据6   数据7   数据8

    实际B+树结构:

          [50    | 100    | 200    ]
          /     |      
     [页1]     [页2]        [页3]
     |        |          |
  +--+--+--+--+   +--+--+--+--+     +--+--+--+--+
  | | | | |   | | | | |     | | | | |
 数据 数据 数据 数据 数据 数据 数据 数据  数据 数据 数据 数据
  (磁盘页)    (磁盘页)        (磁盘页)

3.3 主键索引与普通索引

-- users表
CREATETABLEusers(
 idINTPRIMARYKEY,     -- 主键索引(聚集索引)
 nameVARCHAR(100),
  emailVARCHAR(100),
  ageINT,
 statusCHAR(1),
 INDEXidx_email (email),   -- 普通索引(非聚集)
 INDEXidx_age_status (age,status) -- 联合索引
);

-- 主键索引结构(B+树,叶子节点存储完整行数据)
-- id=1 -> [完整行数据]
-- id=2 -> [完整行数据]
-- ...

-- 普通索引结构(B+树,叶子节点存储主键值)
-- email='a@test.com' -> [id=1]
-- email='b@test.com' -> [id=2]
-- ...
-- 查询时需要回表:根据email查到id,再根据id查完整数据

4. 索引类型详解

4.1 主键索引

-- 创建表时指定主键
CREATETABLEorders (
  order_idBIGINTPRIMARYKEY,
  user_idBIGINT,
  total_amountDECIMAL(10,2),
  create_time DATETIME
);

-- 修改表添加主键
ALTERTABLEordersADDPRIMARYKEY(order_id);

-- 复合主键
CREATETABLEorder_items (
  order_idBIGINT,
  item_idBIGINT,
  quantityINT,
  PRIMARYKEY(order_id, item_id) -- 复合主键
);

特点

每个表只能有一个主键

主键值唯一且非空

InnoDB会自动使用主键作为聚集索引

建议使用自增BIGINT作为主键(性能最优)

4.2 唯一索引

-- 创建唯一索引
CREATEUNIQUEINDEXidx_emailONusers(email);

-- 或在表定义中
CREATETABLEusers(
 idINTPRIMARYKEY,
  emailVARCHAR(100)UNIQUE, -- 自动创建唯一索引
  phoneVARCHAR(20),
 UNIQUEINDEXidx_phone (phone)
);

-- 添加唯一索引
ALTERTABLEusersADDUNIQUEINDEXidx_email (email);

与主键的区别

主键不允许NULL,唯一索引允许NULL(但只能有一个NULL)

一个表只有一个主键,可以有多个唯一索引

主键自动创建聚集索引,唯一索引是普通索引

4.3 普通索引

-- 单列索引
CREATEINDEXidx_nameONusers(name);

-- 查看表的所有索引
SHOWINDEXFROMusers;

-- 创建索引的完整语法
CREATEINDEXidx_statusONorders(status)
 USINGBTREE
 COMMENT'订单状态索引';

4.4 前缀索引

适用于VARCHAR或TEXT类型的前N个字符创建索引。

-- 取前10个字符
CREATEINDEXidx_email_prefixONusers(email(10));

-- 取前20个字符
CREATEINDEXidx_address_prefixONusers(address(20));

-- 注意事项:
-- 1. 前缀长度选择要足够长,避免过多冲突
-- 2. 前缀索引只支持 =、<、>、LIKE 'xxx%' 查询
-- 3. 不能用于 ORDER BY 和 GROUP BY

前缀长度选择参考

-- 计算前缀选择性(区分度)
SELECT
 COUNT(DISTINCTLEFT(email,5)) /COUNT(*)asprefix_5,
 COUNT(DISTINCTLEFT(email,10)) /COUNT(*)asprefix_10,
 COUNT(DISTINCTLEFT(email,15)) /COUNT(*)asprefix_15,
 COUNT(DISTINCTLEFT(email,20)) /COUNT(*)asprefix_20,
 COUNT(DISTINCTemail) /COUNT(*)asfull
FROMusers;

4.5 联合索引

多个列组合成一个索引,最左前缀原则是核心。

-- 创建联合索引
CREATEINDEXidx_user_status_timeONorders(user_id,status, create_time);

-- 联合索引的B+树结构
-- 按照 (user_id, status, create_time) 顺序构建
-- 排序优先级:user_id > status > create_time

联合索引使用条件

-- 可以使用索引(全匹配)
SELECT*FROMordersWHEREuser_id =1;
SELECT*FROMordersWHEREuser_id =1ANDstatus='paid';
SELECT*FROMordersWHEREuser_id =1ANDstatus='paid'ANDcreate_time >'2026-01-01';

-- 无法使用索引(跳过user_id)
SELECT*FROMordersWHEREstatus='paid';
SELECT*FROMordersWHEREstatus='paid'ANDcreate_time >'2026-01-01';
SELECT*FROMordersWHEREcreate_time >'2026-01-01';

-- 可以使用索引(范围查询后的列无法使用)
SELECT*FROMordersWHEREuser_id =1ANDstatus>'paid'; -- status之后的列无法使用

-- LIKE前缀匹配可以使用
SELECT*FROMordersWHEREuser_id =1ANDcreate_timeLIKE'2026-01%';

5. 索引失效的典型场景

5.1 函数和运算导致索引失效

-- 失效:函数运算
SELECT*FROMordersWHEREYEAR(create_time) =2026;
SELECT*FROMordersWHEREDATE_FORMAT(create_time,'%Y') ='2026';
SELECT*FROMordersWHEREcreate_time +INTERVAL1DAY>NOW();

-- 正确做法:保持索引列独立
SELECT*FROMordersWHEREcreate_time >='2026-01-01'ANDcreate_time < '2027-01-01';

-- 失效:算术运算
SELECT * FROM users WHERE age + 1 = 30;

-- 正确做法
SELECT * FROM users WHERE age = 29;

5.2 类型转换导致索引失效

-- 失效:字符串列用数字查询(MySQL会隐式转换)
CREATETABLEtest(phoneVARCHAR(20));
SELECT*FROMtestWHEREphone =13800138000; -- 数字自动转字符串,但无法使用索引

-- 正确做法
SELECT*FROMtestWHEREphone ='13800138000';

-- 失效:数字列用字符串查询
CREATETABLEtest2 (idINT);
SELECT*FROMtest2WHEREid='1'; -- 字符串转数字,可以走索引
-- 但反过来:
SELECT*FROMtest2WHEREid='1abc'; -- 数字转字符串,无法使用索引

5.3 LIKE通配符导致索引失效

-- 失效:前导通配符
SELECT*FROMordersWHEREorder_nameLIKE'%test%';
SELECT*FROMordersWHEREorder_nameLIKE'%test';

-- 生效:后置通配符
SELECT*FROMordersWHEREorder_nameLIKE'test%';

-- 优化方案:使用全文索引
ALTERTABLEordersADDFULLTEXTINDEXft_order_name (order_name);
SELECT*FROMordersWHEREMATCH(order_name) AGAINST('test');

-- 优化方案:使用Elasticsearch

5.4 OR条件导致索引失效

-- 失效:OR条件两边都未使用索引
SELECT*FROMusersWHEREname='John'ORemail ='john@test.com';

-- 生效:确保OR两边都有索引(MySQL 8.0+)
SELECT*FROMusersWHEREname='John'
UNIONALL
SELECT*FROMusersWHEREemail ='john@test.com'ANDname<>'John';

-- 使用IN替代OR(如果有索引)
SELECT*FROMusersWHEREnameIN('John','Mary','Tom');

5.5 NOT操作符导致索引失效

-- 失效:NOT IN / NOT EXISTS
SELECT*FROMordersWHEREstatusNOTIN('paid','shipped');
SELECT*FROMordersWHEREstatus!='paid';
SELECT*FROMordersWHERENOTEXISTS(SELECT1FROMusersWHEREusers.id = orders.user_id);

-- 正确做法:尽量使用IN或正向条件
SELECT*FROMordersWHEREstatusIN('pending','cancelled');

-- 某些情况下可以使用覆盖索引优化
SELECT*FROMordersWHEREidNOTIN(SELECTorder_idFROMcancelled_orders);

5.6 索引失效检查脚本

#!/bin/bash
# script: check_index_usage.sh
# 用途:检查索引使用情况,找出未使用的索引

mysql -e"
-- 检查未使用的索引(需要 PERFORMANCE_SCHEMA 开启)
SELECT
  OBJECT_SCHEMA AS '数据库',
  OBJECT_NAME AS '表名',
  INDEX_NAME AS '索引名',
  SEQ_IN_INDEX AS '索引顺序',
  COLUMN_NAME AS '列名'
FROM information_schema.STATISTICS
WHERE OBJECT_SCHEMA = 'your_database'
ORDER BY OBJECT_NAME, INDEX_NAME, SEQ_IN_INDEX;
"

# 或者使用 pt-index-usage 工具分析慢查询日志
# pt-index-usage /var/lib/mysql/mysql-slow.log --user=root --password=xxx

echo"检查慢查询中的索引使用情况"
echo"建议使用 EXPLAIN 分析可疑查询"

6. 深入理解count(*)优化

6.1 count(*) vs count(1) vs count(col)

-- 没有任何性能差异(MySQL优化器会统一处理)
SELECTCOUNT(*)FROMorders;
SELECTCOUNT(1)FROMorders;
SELECTCOUNT(primary_key)FROMorders; -- 主键非NULL,始终有值

-- 有差异的情况
SELECTCOUNT(col)FROMorders; -- col列可能为NULL,需要检查每行

-- 测试验证
EXPLAINSELECTCOUNT(*)FROMorders; -- type: index, rows: 预估行数
EXPLAINSELECTCOUNT(1)FROMorders; -- 相同执行计划
EXPLAINSELECTCOUNT(id)FROMorders; -- 相同执行计划

6.2 count(*) 在不同引擎的实现

InnoDB引擎

count(*) 需要全表扫描或索引扫描

使用主键索引扫描最快(因为主键索引B+树叶子节点包含完整数据)

如果有WHERE条件,需要过滤后计数

-- 最快的count(*)(使用主键索引)
SELECTCOUNT(*)FROMorders; -- 全表计数

-- 最快的count(*),带条件
SELECTCOUNT(*)FROMordersWHEREstatus='paid'; -- 需要扫描status索引

6.3 count(*) 优化技巧

-- 场景:需要同时统计多个条件的数量
-- 低效:多次全表扫描
SELECTCOUNT(*)FROMordersWHEREstatus='paid';
SELECTCOUNT(*)FROMordersWHEREstatus='pending';
SELECTCOUNT(*)FROMordersWHEREstatus='cancelled';

-- 高效:一次扫描,多个统计
SELECT
 SUM(status='paid')ASpaid_count,
 SUM(status='pending')ASpending_count,
 SUM(status='cancelled')AScancelled_count,
 COUNT(*)AStotal_count
FROMorders;

-- 或者使用 GROUP BY
SELECTstatus,COUNT(*)ascntFROMordersGROUPBYstatus;

6.4 大表count(*)优化

-- 创建计数器表(适合实时性要求不高的场景)
CREATETABLEorder_stats (
  stat_dateDATEPRIMARYKEY,
  total_ordersBIGINTDEFAULT0,
  paid_ordersBIGINTDEFAULT0,
  pending_ordersBIGINTDEFAULT0
);

-- 定时更新统计(使用事件调度器)
DELIMITER $$
CREATEEVENTe_update_order_stats
ONSCHEDULE EVERY1HOUR
DO
BEGIN
 INSERTINTOorder_stats (stat_date, total_orders, paid_orders, pending_orders)
 SELECT
   CURRENT_DATE,
   COUNT(*),
   SUM(status='paid'),
   SUM(status='pending')
 FROMorders
 ONDUPLICATEKEYUPDATE
    total_orders =VALUES(total_orders),
    paid_orders =VALUES(paid_orders),
    pending_orders =VALUES(pending_orders);
END$$
DELIMITER ;

-- 使用近似值(准实时场景)
SELECTTABLE_ROWSFROMinformation_schema.TABLESWHERETABLE_NAME ='orders';
-- 注意:TABLE_ROWS是估算值,可能有50%误差

7. 分页优化:深度分页问题

7.1 深度分页问题原理

-- 问题查询:偏移量越大,越慢
SELECT*FROMordersORDERBYidLIMIT1000000,20;

-- 执行过程:
-- 1. 读取前1000020行
-- 2. 丢弃前1000000行
-- 3. 返回20行
-- 偏移量100万,需要扫描100万+20行

7.2 优化方案1:使用主键ID游标

-- 第一页
SELECT*FROMordersORDERBYidLIMIT20;

-- 得到 last_id = 1000

-- 第二页:使用上一页的最大ID
SELECT*FROMorders
WHEREid>1000
ORDERBYid
LIMIT20;

-- 进阶:支持任意跳转
-- 假设用户想跳到第50000页,每页20条
-- 最后一页的id需要从数据库获取,或使用其他定位方式

7.3 优化方案2:延迟关联

-- 原始查询(慢)
SELECT*FROMorders
WHEREstatus='paid'
ORDERBYcreate_timeDESC
LIMIT100000,20;

-- 优化:先查ID,再关联获取完整数据
SELECTo.*
FROMorders o
INNERJOIN(
 SELECTidFROMorders
 WHEREstatus='paid'
 ORDERBYcreate_timeDESC
 LIMIT100000,20
) tONo.id = t.id;

7.4 优化方案3:范围查询

-- 如果有连续的自增ID,可以利用范围查询
-- 用户在第500页,看到的最后一条ID是 10000

-- 查询第501页
SELECT*FROMorders
WHEREid>10000
ORDERBYid
LIMIT20;

-- 结合条件
SELECT*FROMorders
WHEREid>10000ANDstatus='paid'
ORDERBYid
LIMIT20;

7.5 优化方案4:记录总数缓存

-- 不显示精确总数,只显示"上一页/下一页"
-- 适合Feed流等场景

-- 获取每页数据
SELECT*FROMorders
WHEREid< 10000
ORDER BY id DESC
LIMIT 20;

-- 检查是否有更多
SELECT COUNT(*) FROM orders WHERE id < 10000;
-- 如果 > 20,说明还有下一页

7.6 分页优化脚本

#!/bin/bash
# script: test_pagination_performance.sh
# 用途:测试不同分页方式的性能

mysql -e"
-- 测试不同偏移量的查询时间
SET profiling = 1;

-- 浅分页
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 10000, 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 50000, 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 100000, 20;

SHOW PROFILES;
"

8. 慢查询案例分析

8.1 案例1:订单统计查询

问题SQL

-- 原始慢查询
SELECT
 DATE(create_time)ASorder_date,
 COUNT(*)ASorder_count,
 SUM(total_amount)AStotal_amount,
  user_name
FROMorders
WHEREcreate_time >='2026-01-01'
GROUPBYDATE(create_time), user_name
ORDERBYorder_dateDESC;

问题分析

GROUP BY 和 ORDER BY 字段不一致

user_name 未被索引覆盖

缺少合适的索引

优化后

-- 创建索引
ALTERTABLEordersADDINDEXidx_create_time (create_time);

-- 优化SQL
SELECT
 DATE(create_time)ASorder_date,
 COUNT(*)ASorder_count,
 SUM(total_amount)AStotal_amount
FROMorders
WHEREcreate_time >='2026-01-01'
GROUPBYDATE(create_time)
ORDERBYorder_dateDESC;

8.2 案例2:用户行为分析

问题SQL

-- 原始查询(20秒)
SELECT
  u.id,
  u.name,
 COUNT(DISTINCTo.id)ASorder_count,
 COUNT(DISTINCTe.id)ASevent_count
FROMusersu
LEFTJOINorders oONu.id = o.user_id
LEFTJOINeventseONu.id = e.user_id
WHEREu.register_time >='2026-01-01'
GROUPBYu.id, u.name;

优化方案

-- 创建索引
ALTERTABLEusersADDINDEXidx_register_time (register_time);
ALTERTABLEordersADDINDEXidx_user_id (user_id);
ALTERTABLEeventsADDINDEXidx_user_id (user_id);

-- 改写SQL:分解为多个简单查询
SELECT
  u.id,
  u.name,
 COALESCE(o.order_count,0)ASorder_count,
 COALESCE(e.event_count,0)ASevent_count
FROMusersu
LEFTJOIN(
 SELECTuser_id,COUNT(*)ASorder_count
 FROMorders
 GROUPBYuser_id
) oONu.id = o.user_id
LEFTJOIN(
 SELECTuser_id,COUNT(*)ASevent_count
 FROMevents
 GROUPBYuser_id
) eONu.id = e.user_id
WHEREu.register_time >='2026-01-01';

8.3 案例3:分页导出

问题SQL

-- 导出100万条数据,每次20条,需要50000次
SELECT*FROMorders
WHEREstatus='completed'
ORDERBYcreate_timeDESC
LIMIT1000000,20;

优化方案

-- 方案1:使用主键范围
-- 第一次查询
SELECTidFROMorders
WHEREstatus='completed'
ORDERBYcreate_timeDESC
LIMIT20;
-- 得到最大ID:last_id = 1000000

-- 后续查询
SELECT*FROMorders
WHEREstatus='completed'ANDid< 1000000
ORDER BY create_time DESC
LIMIT 20;

-- 方案2:使用临时表分批处理
CREATE TEMPORARY TABLE temp_export_ids (
    id BIGINT PRIMARY KEY
);

-- 分批插入ID
INSERT INTO temp_export_ids
SELECT id FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 100000;

-- 分批导出
SELECT o.* FROM orders o
INNER JOIN temp_export_ids t ON o.id = t.id
ORDER BY o.create_time DESC;

8.4 慢查询分析报告脚本

#!/bin/bash
# script: slow_query_report.sh
# 用途:生成慢查询分析报告

DB_NAME="orders_db"
REPORT_FILE="/tmp/mysql_slow_report_$(date +%Y%m%d).txt"

echo"=== MySQL慢查询分析报告 ===">"$REPORT_FILE"
echo"数据库:${DB_NAME}">>"$REPORT_FILE"
echo"生成时间:$(date)">>"$REPORT_FILE"
echo"">>"$REPORT_FILE"

# 1. 慢查询统计
echo"【1】慢查询统计(最近24小时):">>"$REPORT_FILE"
mysql -D"$DB_NAME"-e"
SELECT
  COUNT(*) AS total_slow_queries,
  AVG(query_time) AS avg_query_time,
  MAX(query_time) AS max_query_time,
  COUNT(DISTINCT db) AS affected_databases
FROM mysql.slow_log
WHERE start_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR);
">>"$REPORT_FILE"2>&1

echo"">>"$REPORT_FILE"

# 2. 最慢的10个查询
echo"【2】最慢的10个查询:">>"$REPORT_FILE"
mysql -D"$DB_NAME"-e"
SELECT
  query_time,
  rows_sent,
  rows_examined,
  LEFT(query_sql, 100) AS sql_preview,
  last_seen
FROM (
    SELECT
      query_time,
      rows_sent,
      rows_examined,
      query_sql,
      MAX(start_time) AS last_seen
    FROM mysql.slow_log
    WHERE start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
    GROUP BY query_sql
    ORDER BY query_time DESC
    LIMIT 10
  ) t;
">>"$REPORT_FILE"2>&1

echo"">>"$REPORT_FILE"

# 3. 未使用索引的查询
echo"【3】未使用索引的查询统计:">>"$REPORT_FILE"
mysql -D"$DB_NAME"-e"
SELECT
  LEFT(query_sql, 100) AS sql_preview,
  COUNT(*) AS exec_count,
  AVG(query_time) AS avg_time
FROM mysql.slow_log
WHERE start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
 AND query_sql LIKE '%Using%filesort%'
GROUP BY LEFT(query_sql, 100)
ORDER BY exec_count DESC
LIMIT 10;
">>"$REPORT_FILE"2>&1

cat"$REPORT_FILE"

9. SQL改写技巧

9.1 IN改写为EXISTS/JOIN

-- 低效:IN子查询
SELECT*FROMusers
WHEREidIN(SELECTuser_idFROMordersWHEREamount >1000);

-- MySQL 5.6+会自动优化为JOIN,但显式写法更清晰
SELECTDISTINCTu.*
FROMusersu
INNERJOINorders oONu.id = o.user_id
WHEREo.amount >1000;

-- EXISTS改写
SELECT*FROMusersu
WHEREEXISTS(
 SELECT1FROMorders o
 WHEREo.user_id = u.idANDo.amount >1000
);

9.2 OR改写为UNION

-- 低效:OR条件
SELECT*FROMproducts
WHEREcategory='electronics'ORbrand ='Apple';

-- 高效:UNION
SELECT*FROMproductsWHEREcategory='electronics'
UNION
SELECT*FROMproductsWHEREbrand ='Apple'ANDcategory!='electronics';

-- 或者使用UNION ALL(如果确认不重复)
SELECT*FROMproductsWHEREcategory='electronics'
UNIONALL
SELECT*FROMproductsWHEREbrand ='Apple'ANDcategory!='electronics';

9.3 LIKE改写

-- 低效:前导通配符
SELECT*FROMproductsWHEREnameLIKE'%iphone%';

-- 优化方案1:全文索引
ALTERTABLEproductsADDFULLTEXTINDEXft_name (name);
SELECT*FROMproductsWHEREMATCH(name) AGAINST('+iphone'INBOOLEANMODE);

-- 优化方案2:Elasticsearch
-- 将数据同步到ES,使用ES搜索

-- 优化方案3:使用虚拟列+索引
ALTERTABLEproductsADDCOLUMNname_first_charCHAR(1)GENERATEDAS(LEFT(name,1))STORED;
CREATEINDEXidx_name_first_charONproducts(name_first_char);
SELECT*FROMproductsWHEREname_first_char ='i'ANDnameLIKE'i%iphone%';

9.4 COUNT(DISTINCT)优化

-- 低效:多个COUNT DISTINCT
SELECT
 COUNT(DISTINCTuser_id)ASuser_count,
 COUNT(DISTINCTproduct_id)ASproduct_count,
 COUNT(DISTINCTcategory_id)AScategory_count
FROMorders;

-- 高效:使用子查询
SELECT
  (SELECTCOUNT(DISTINCTuser_id)FROMorders)ASuser_count,
  (SELECTCOUNT(DISTINCTproduct_id)FROMorders)ASproduct_count,
  (SELECTCOUNT(DISTINCTcategory_id)FROMorders)AScategory_count;

-- 或者使用SQL_CALC_FOUND_ROWS(已废弃)
SELECTSQL_CALC_FOUND_ROWS*FROMordersLIMIT20;
SELECTFOUND_ROWS();

10. 表结构设计优化

10.1 选择合适的数据类型

-- 使用最小数据类型
TINYINT vs INT vs BIGINT
-- TINYINT: -128 to 127 (无符号0-255)
-- INT: -2B to 2B (无符号0-4B)
-- BIGINT: -9 Quintillion to 9 Quintillion

-- 使用DECIMAL而非FLOAT/DOUBLE(金融场景)
DECIMAL(10,2) vs FLOAT vs DOUBLE
-- DECIMAL精确存储,适合金额
-- FLOAT/DOUBLE近似值,有精度丢失风险

-- VARCHAR vs CHAR
CHAR(10)  -- 固定长度,不足右补空格,适合定长如手机号
VARCHAR(255)-- 可变长度,适合大多数字符串

-- 日期类型选择
DATE   -- '2026-01-01' 精确到天
DATETIME -- '2026-01-01 1200' 精确到秒
TIMESTAMP-- 4字节,时间戳,适合记录创建/更新时间

10.2 范式化与反范式化

-- 第三范式(3NF)设计
-- 订单表:只存user_id,不存用户详细信息
CREATETABLEorders (
  order_idBIGINTPRIMARYKEY,
  user_idINTNOTNULL,
  total_amountDECIMAL(10,2),
  create_time DATETIME,
 INDEXidx_user_id (user_id)
);

-- 用户表:用户详细信息
CREATETABLEusers(
  user_idINTPRIMARYKEY,
  user_nameVARCHAR(100),
  emailVARCHAR(200),
  phoneVARCHAR(20)
);

-- 如果需要关联查询,使用JOIN
SELECTo.*, u.user_name
FROMorders o
JOINusersuONo.user_id = u.user_id;

-- 反范式化:冗余数据提升查询性能
-- 适合读多写少、实时性要求不高的场景
CREATETABLEorders_denormalized (
  order_idBIGINTPRIMARYKEY,
  user_idINTNOTNULL,
  user_nameVARCHAR(100), -- 冗余存储,避免JOIN
  total_amountDECIMAL(10,2),
  create_time DATETIME
);

10.3 分区表

-- 按日期分区
CREATETABLEorders (
  order_idBIGINT,
  user_idINT,
  total_amountDECIMAL(10,2),
  create_time DATETIME,
  PRIMARYKEY(order_id, create_time) -- 必须包含分区键
)PARTITIONBYRANGE(YEAR(create_time)) (
 PARTITIONp2024VALUESLESSTHAN(2025),
 PARTITIONp2025VALUESLESSTHAN(2026),
 PARTITIONp2026VALUESLESSTHAN(2027),
 PARTITIONp_futureVALUESLESSTHANMAXVALUE
);

-- 查询特定分区的数据(只扫描目标分区)
SELECT*FROMordersWHEREcreate_time >='2026-01-01'ANDcreate_time < '2026-02-01';

-- 查看分区信息
SELECT * FROM information_schema.PARTITIONS WHERE TABLE_NAME = 'orders';

10.4 分库分表

-- 按用户ID哈希分表(逻辑上)
-- 实际实现依赖中间件如 ShardingSphere、MyCAT

-- 示例:订单表拆分为4张表
orders_0, orders_1, orders_2, orders_3

-- 分片规则:user_id % 4
-- 查询时需要指定分片键
SELECT*FROMorders_1WHEREuser_id =123;

-- 全局表(数据量小,所有分片都冗余存储)
CREATETABLEcategories (
 idINTPRIMARYKEY,
 nameVARCHAR(100)
); -- 复制到所有分片

11. 总结:索引使用避坑指南

11.1 索引设计原则

【核心原则】
1. 为WHERE、ORDER BY、GROUP BY的列创建索引
2. 索引列尽量选择区分度高的列
3. 联合索引遵循最左前缀原则
4. 避免在索引列上使用函数或运算
5. 控制索引数量(每个索引占用磁盘空间)

【创建索引的场景】
- 主键自动有索引,无需额外创建
- 外键列创建索引(提升JOIN性能)
- 经常作为查询条件的列
- 经常需要排序的列
- 区分度高的列(cardinality高)

【避免创建索引的场景】
- 区分度低的列(如性别、状态)
- 更新频繁的列
- 表数据量很小

11.2 慢查询优化Checklist

【第一步:发现】
□ 确认慢查询日志已开启
□ 找到最慢的查询
□ 记录查询时间和影响行数

【第二步:分析】
□ 使用EXPLAIN分析执行计划
□ 检查type列(避免ALL/index)
□ 检查Extra列(避免Using filesort/temporary)
□ 确认索引是否被使用

【第三步:优化】
□ 添加/修改索引
□ 改写SQL语句
□ 优化表结构
□ 减少查询范围

【第四步:验证】
□ 重新执行查询,对比时间
□ 再次使用EXPLAIN确认
□ 监控慢查询日志确认改善

11.3 常用优化命令速查

操作 SQL
查看慢查询配置 SHOW VARIABLES LIKE 'slow_query%';
开启慢查询日志 SET GLOBAL slow_query_log = ON;
设置阈值 SET GLOBAL long_query_time = 1;
分析执行计划 EXPLAIN SELECT ...
查看索引 SHOW INDEX FROM table_name;
创建索引 CREATE INDEX idx_name ON table(col);
删除索引 DROP INDEX idx_name ON table;
查看表状态 SHOW TABLE STATUS LIKE 'table_name';

11.4 EXPLAIN结果速判

【type- 从优到差】
const > eq_ref > ref > range > index > ALL

【Extra - 需要优化的标志】
Using filesort     需要优化
Using temporary    需要优化
Usingwhere      可能需要优化
Using index      覆盖索引,好
Using index condition  ICP,可接受

参考信息

版本信息

MySQL:8.0.36(社区版)

Percona Server:8.0.36

MariaDB:10.11.x

存储引擎:InnoDB(默认)

操作系统:Rocky Linux 9.4

工具推荐

Percona Toolkit:pt-query-digest、pt-index-usage

MySQL Workbench:图形化EXPLAIN

performance_schema:查询性能分析

sys schema:性能诊断视图

参考文档

MySQL 8.0 Reference Manual: Optimization

High Performance MySQL, 3rd Edition

MySQL Internals Manual

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

    关注

    7

    文章

    4078

    浏览量

    68524
  • MySQL
    +关注

    关注

    1

    文章

    928

    浏览量

    29738

原文标题:MySQL 慢查询调优:别让索引背锅,你真的用对了吗?

文章出处:【微信号:magedu-Linux,微信公众号:马哥Linux运维】欢迎添加关注!文章转载请注明出处。

收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    mysql查询优化

    mysql查询优化
    发表于 03-12 11:06

    如何对电机进行的好处是什么?

    如何自动对电机进行
    的头像 发表于 08-22 00:03 4129次阅读

    MySQL 基本知识点梳理和查询优化

    本文主要是总结了工作中一些常用的操作,以及不合理的操作,在对查询进行优化时收集的一些有用的资料和信息,适合有 MySQL 基础的开发人员。
    的头像 发表于 12-01 08:14 3713次阅读

    MySQL查询帮助的使用

    在使用MySQL过程中,当遇到操作语法、数据类型的取值范围、功能是否支持等问题时,可以使用MySQL自带的帮助文档查询
    的头像 发表于 04-16 17:14 2215次阅读
    <b class='flag-5'>MySQL</b><b class='flag-5'>查询</b>帮助的使用

    总结一下MySQL常用的方法

    在my.cnf中加上skip-name-resolve,这样可以避免由于解析主机名延迟造成mysql执行
    的头像 发表于 02-08 17:14 1349次阅读

    查询SQL在mysql内部是如何执行?

    我们知道在mySQL客户端,输入一条查询SQL,然后看到返回查询的结果。这条查询语句在 MySQL 内部到底是如何执行的呢?本文跟大家探讨一
    的头像 发表于 01-22 14:53 1358次阅读
    <b class='flag-5'>查询</b>SQL在<b class='flag-5'>mysql</b>内部是如何执行?

    AM6xA ISP指南

    电子发烧友网站提供《AM6xA ISP指南.pdf》资料免费下载
    发表于 09-07 09:52 0次下载
    AM6xA ISP<b class='flag-5'>调</b><b class='flag-5'>优</b><b class='flag-5'>指南</b>

    TAS58xx系列通用指南

    电子发烧友网站提供《TAS58xx系列通用指南.pdf》资料免费下载
    发表于 09-14 10:49 1次下载
    TAS58xx系列通用<b class='flag-5'>调</b><b class='flag-5'>优</b><b class='flag-5'>指南</b>

    MCT8315A指南

    电子发烧友网站提供《MCT8315A指南.pdf》资料免费下载
    发表于 11-12 14:14 1次下载
    MCT8315A<b class='flag-5'>调</b><b class='flag-5'>优</b><b class='flag-5'>指南</b>

    MCT8316A指南

    电子发烧友网站提供《MCT8316A指南.pdf》资料免费下载
    发表于 11-13 13:49 0次下载
    MCT8316A<b class='flag-5'>调</b><b class='flag-5'>优</b><b class='flag-5'>指南</b>

    MCF8316A指南

    电子发烧友网站提供《MCF8316A指南.pdf》资料免费下载
    发表于 11-20 17:21 2次下载
    MCF8316A<b class='flag-5'>调</b><b class='flag-5'>优</b><b class='flag-5'>指南</b>

    MySQL配置技巧

    上个月,我们公司的核心业务系统突然出现大面积超时,用户投诉电话不断。经过紧急排查,发现是MySQL服务器CPU飙升到99%,大量查询堆积。通过一系列配置
    的头像 发表于 07-31 10:27 788次阅读

    MySQL查询终极优化指南

    作为一名在生产环境摸爬滚打多年的运维工程师,我见过太多因为查询导致的线上故障。今天分享一套经过实战检验的MySQL查询分析与索引优化方法
    的头像 发表于 08-13 15:55 940次阅读

    MySQL查询分析与索引全流程

    MySQL 性能问题在生产环境中的表现通常是渐进式的:业务量增长、数据量膨胀,某天突然发现 P99 响应时间从 50ms 涨到 2s。查询是最常见的根因,而索引设计不合理又是
    的头像 发表于 03-06 15:56 229次阅读

    MySQL数据库查询分析与优化实战

    在讨论MySQL查询之前,需要先明确一个关键前提:什么是查询? 不同业务场景下,
    的头像 发表于 04-02 09:38 149次阅读