背景与目的
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(默认)
工具推荐:
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慢查询调优指南
评论