一、概述
1.1 背景介绍
MySQL 性能问题在生产环境中的表现通常是渐进式的:业务量增长、数据量膨胀,某天突然发现 P99 响应时间从 50ms 涨到 2s。慢查询是最常见的根因,而索引设计不合理又是慢查询的主要来源。
MySQL 8.4 LTS 在查询优化器、直方图统计、索引跳跃扫描等方面有明显改进,但核心的分析方法论没有变化:先定位慢查询,再用 EXPLAIN 分析执行计划,最后针对性地调整索引或 SQL。
1.2 技术特点
慢查询日志:记录执行时间超过阈值的 SQL,是性能分析的起点
EXPLAIN:展示查询执行计划,判断是否走索引、扫描行数等关键信息
索引优化:覆盖索引、联合索引、索引下推(ICP)是三个核心手段
Buffer Pool 调优:InnoDB 缓冲池命中率直接影响 I/O 压力
1.3 适用场景
业务响应时间突然变慢,需要快速定位根因
新功能上线前的 SQL 审查
定期的数据库健康检查
数据量增长后的索引重新评估
1.4 环境要求
| 组件 | 版本要求 | 说明 |
|---|---|---|
| MySQL | 8.4.x LTS | 优化器在 8.4 有改进 |
| pt-query-digest | 3.5+ | Percona Toolkit 组件 |
| 操作系统 | Linux | pt-query-digest 依赖 Perl |
| 权限 | PROCESS, SELECT | 分析慢查询和执行计划所需 |
二、详细步骤
2.1 慢查询日志配置
2.1.1 开启慢查询日志
# /etc/mysql/mysql.conf.d/mysqld.cnf [mysqld] slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 # 超过 1 秒记录,生产初期可设 0.5 log_queries_not_using_indexes = OFF # 不建议开,会产生大量噪音 log_slow_extra = ON # 8.0.14+ 支持,记录更多上下文信息 min_examined_row_limit = 100 # 扫描行数少于 100 的不记录,过滤简单查询
动态修改(无需重启):
SETGLOBALslow_query_log =ON; SETGLOBALlong_query_time =1; SETGLOBALlog_slow_extra =ON;
2.1.2 pt-query-digest 分析
# 安装 Percona Toolkit apt install percona-toolkit # Ubuntu # 或 yum install percona-toolkit # CentOS # 基础分析:按总执行时间排序,输出 Top 10 慢查询 pt-query-digest /var/log/mysql/slow.log --limit10 --order-by Query_time:sum > /tmp/slow_report.txt # 只分析最近 1 小时的慢查询 pt-query-digest /var/log/mysql/slow.log --since"1h" --limit20 # 过滤特定数据库 pt-query-digest /var/log/mysql/slow.log --filter'$event->{db} eq "production_db"' # 输出到 MySQL 表,便于历史对比 pt-query-digest /var/log/mysql/slow.log --review h=127.0.0.1,D=percona,t=query_review --historyh=127.0.0.1,D=percona,t=query_history --no-report
pt-query-digest 输出解读:
# Query 1: 0.50 QPS, 2.50x concurrency, ID 0xABC123 at byte 12345 # This item is included in the report because it matches --limit. # Scores: V/M = 1.23 # Time range: 2024-01-15 1000 to 2024-01-15 1100 # Attribute pct total min max avg 95% stddev median # ============ === ======= ======= ======= ======= ======= ======= ======= # Count 15 1800 # Exec time 42 1800s 0.5s 5s 1s 2.1s 0.8s 0.9s # Lock time 2 90s 0s 0.1s 0.05s 0.08s 0.02s 0.04s # Rows sent 8 14400 1 20 8 15 4 8 # Rows examine 65 585000 100 1000 325 800 200 300
关注指标:Rows examine / Rows sent比值,超过 100 说明索引效率低。
2.2 EXPLAIN 执行计划解读
2.2.1 核心字段含义
EXPLAINSELECTo.id, o.amount, u.name FROMorders o JOINusersuONo.user_id = u.id WHEREo.status ='pending' ANDo.created_at >'2024-01-01' ORDERBYo.created_atDESC LIMIT20G
输出示例:
id: 1 select_type: SIMPLE table: o partitions: NULL type: range ← 关键字段 possible_keys: idx_status_created,idx_created_at key: idx_status_created ← 实际使用的索引 key_len: 10 ref: NULL rows: 1250 ← 预估扫描行数 filtered: 100.00 Extra: Using index condition; Using filesort ← 注意 filesort
type 字段(从好到差排序):
| type | 含义 | 性能 |
|---|---|---|
| system | 表只有一行 | 最优 |
| const | 主键或唯一索引等值查询 | 极好 |
| eq_ref | JOIN 时使用主键/唯一索引 | 很好 |
| ref | 非唯一索引等值查询 | 好 |
| range | 索引范围扫描 | 可接受 |
| index | 全索引扫描 | 较差 |
| ALL | 全表扫描 | 最差,必须优化 |
Extra 字段关键信息:
Using index:覆盖索引,无需回表,性能最优
Using index condition:索引下推(ICP),在索引层过滤,减少回表次数
Using filesort:需要额外排序,如果数据量大会很慢
Using temporary:使用临时表,GROUP BY 或 ORDER BY 时出现,需要重点关注
Using where:在 Server 层过滤,索引没有完全覆盖 WHERE 条件
2.2.2 EXPLAIN ANALYZE(8.0.18+)
-- EXPLAIN ANALYZE 实际执行查询并返回真实耗时 EXPLAINANALYZE SELECT*FROMordersWHEREuser_id =12345ANDstatus='paid'G -- 输出包含实际执行时间和行数 -- -> Filter: (orders.status = 'paid') (cost=5.25 rows=3) (actual time=0.045..0.089 rows=2 loops=1) -- -> Index lookup on orders using idx_user_id (user_id=12345) -- (cost=3.50 rows=15) (actual time=0.038..0.075 rows=15 loops=1)
actual rows与rows(预估)差距大时,说明统计信息过期,需要ANALYZE TABLE。
2.3 索引设计原则
2.3.1 联合索引最左前缀
-- 假设有联合索引 idx_user_status_created (user_id, status, created_at) -- 能用索引(最左前缀匹配) SELECT*FROMordersWHEREuser_id =1; SELECT*FROMordersWHEREuser_id =1ANDstatus='paid'; SELECT*FROMordersWHEREuser_id =1ANDstatus='paid'ANDcreated_at >'2024-01-01'; -- 不能用索引(跳过了 user_id) SELECT*FROMordersWHEREstatus='paid'; SELECT*FROMordersWHEREstatus='paid'ANDcreated_at >'2024-01-01'; -- 范围查询后的列无法用索引过滤 -- 以下查询中 status 列无法通过索引过滤 SELECT*FROMordersWHEREuser_id =1ANDcreated_at >'2024-01-01'ANDstatus='paid'; -- 建议改为:WHERE user_id = 1 AND status = 'paid' AND created_at > '2024-01-01' -- 把等值条件放前面,范围条件放最后
2.3.2 覆盖索引
-- 原始查询,需要回表 SELECTid, amount, created_atFROMordersWHEREuser_id =1ANDstatus='paid'; -- 创建覆盖索引,包含查询所需的所有列 ALTERTABLEordersADDINDEXidx_covering (user_id,status, amount, created_at); -- EXPLAIN 中 Extra 显示 "Using index",无需回表 -- 对于高频查询,覆盖索引能将响应时间从毫秒级降到微秒级
覆盖索引的代价是索引体积增大,写入时维护成本上升。对于写多读少的表,不要滥用。
2.3.3 索引下推(ICP)
-- 联合索引 idx_age_name (age, name) -- 查询:WHERE age > 20 AND name LIKE 'Zhang%' -- 没有 ICP 时: -- 1. 存储引擎用 age > 20 找到所有记录 -- 2. 回表取完整行 -- 3. Server 层用 name LIKE 'Zhang%' 过滤 -- 有 ICP 时(MySQL 5.6+ 默认开启): -- 1. 存储引擎用 age > 20 找到索引记录 -- 2. 在索引层直接检查 name LIKE 'Zhang%' -- 3. 只有满足条件的记录才回表 -- EXPLAIN Extra 显示 "Using index condition" -- 验证 ICP 是否生效 SEToptimizer_switch ='index_condition_pushdown=on'; -- 默认开启
2.4 InnoDB Buffer Pool 调优
# /etc/mysql/mysql.conf.d/mysqld.cnf [mysqld] # Buffer Pool 大小:物理内存的 50-75% # 16GB 内存的服务器,专用 MySQL 实例设 10-12GB innodb_buffer_pool_size = 10G # Buffer Pool 实例数:每个实例独立锁,减少竞争 # 建议每个实例 1-2GB,10GB 设 8 个实例 innodb_buffer_pool_instances = 8 # 预热:重启后自动加载上次的热数据 innodb_buffer_pool_dump_at_shutdown = ON innodb_buffer_pool_load_at_startup = ON innodb_buffer_pool_dump_pct = 25 # 只 dump 最热的 25% # 监控 Buffer Pool 命中率 # 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
-- 查看 Buffer Pool 命中率 SELECT FORMAT( (1- ( variable_value / ( SELECTvariable_value FROMperformance_schema.global_status WHEREvariable_name ='Innodb_buffer_pool_read_requests' ) )) *100,2 )AShit_rate_pct FROMperformance_schema.global_status WHEREvariable_name ='Innodb_buffer_pool_reads'; -- 命中率低于 95% 时需要考虑增大 Buffer Pool -- 查看 Buffer Pool 使用详情 SELECTpool_id, pool_size, free_buffers, database_pages, hit_rate FROMinformation_schema.INNODB_BUFFER_POOL_STATS;
2.5 连接池配置
[mysqld] max_connections = 500 # 根据业务并发量设置,不要无限调大 thread_cache_size = 50 # 缓存线程数,减少线程创建开销 wait_timeout = 600 # 空闲连接超时(秒) interactive_timeout = 600 max_connect_errors = 100 # 连接错误次数上限,超过则封锁 IP # 连接队列 back_log = 128 # TCP 连接队列长度,高并发时适当增大
-- 监控连接状态 SHOWSTATUSLIKE'Threads_%'; -- Threads_connected: 当前连接数 -- Threads_running: 活跃线程数(真正在执行 SQL) -- Threads_cached: 缓存中的线程数 -- 如果 Threads_running 持续接近 max_connections,说明有连接积压 -- 查看当前连接详情 SELECTuser, host, db, command,time, state, info FROMinformation_schema.PROCESSLIST WHEREcommand !='Sleep' ORDERBYtimeDESC LIMIT20;
三、示例代码和配置
3.1 生产案例:从慢查询到索引优化完整流程
案例背景
电商订单表,数据量 5000 万行,某天下午业务反馈订单列表页响应时间从 200ms 涨到 8s。
3.1.1 定位慢查询
# 分析最近 30 分钟的慢查询 pt-query-digest /var/log/mysql/slow.log --since"30m" --order-by Query_time:sum --limit5
输出发现最慢的 SQL:
SELECTo.id, o.order_no, o.amount, o.status, o.created_at,
u.name, u.phone
FROMorders o
JOINusersuONo.user_id = u.id
WHEREo.merchant_id =1001
ANDo.statusIN('pending','paid')
ANDo.created_atBETWEEN'2024-01-01'AND'2024-01-31'
ORDERBYo.created_atDESC
LIMIT20OFFSET0;
-- 平均执行时间 6.8s,扫描行数 320 万
3.1.2 分析执行计划
EXPLAINSELECTo.id, o.order_no, o.amount, o.status, o.created_at,
u.name, u.phone
FROMorders o
JOINusersuONo.user_id = u.id
WHEREo.merchant_id =1001
ANDo.statusIN('pending','paid')
ANDo.created_atBETWEEN'2024-01-01'AND'2024-01-31'
ORDERBYo.created_atDESC
LIMIT20G
结果:type: ALL,rows: 50000000,Extra: Using where; Using filesort。全表扫描 + 文件排序,问题明确。
3.1.3 索引设计决策
-- 分析列的选择性 SELECT COUNT(DISTINCTmerchant_id) /COUNT(*)ASmerchant_selectivity, COUNT(DISTINCTstatus) /COUNT(*)ASstatus_selectivity, COUNT(DISTINCTDATE(created_at)) /COUNT(*)ASdate_selectivity FROMorders; -- merchant_selectivity: 0.0002(低,1万个商户/5000万行) -- status_selectivity: 0.00000012(极低,只有几个状态值) -- date_selectivity: 0.0006(低) -- 设计联合索引:等值条件在前,范围条件在后 -- merchant_id(等值)+ status(IN,等值)+ created_at(范围+排序) ALTERTABLEorders ADDINDEXidx_merchant_status_created (merchant_id,status, created_at); -- 如果需要覆盖索引(避免回表),加上 SELECT 的列 -- 但 name、phone 在 users 表,JOIN 无法避免 -- 只能覆盖 orders 表的列 ALTERTABLEorders ADDINDEXidx_merchant_status_created_cover (merchant_id,status, created_at,id, order_no, amount, user_id);
3.1.4 验证优化效果
-- 强制使用新索引验证
EXPLAINSELECTo.id, o.order_no, o.amount, o.status, o.created_at,
u.name, u.phone
FROMorders oFORCEINDEX(idx_merchant_status_created)
JOINusersuONo.user_id = u.id
WHEREo.merchant_id =1001
ANDo.statusIN('pending','paid')
ANDo.created_atBETWEEN'2024-01-01'AND'2024-01-31'
ORDERBYo.created_atDESC
LIMIT20G
-- type: range, rows: 1250, Extra: Using index condition
-- 扫描行数从 5000 万降到 1250,响应时间降到 15ms
3.2 直方图统计(MySQL 8.0+)
-- 对低选择性列创建直方图,帮助优化器做更准确的行数估算 ANALYZETABLEordersUPDATEHISTOGRAMONstatus, merchant_idWITH256BUCKETS; -- 查看直方图信息 SELECT*FROMinformation_schema.COLUMN_STATISTICS WHEREtable_name ='orders'G -- 直方图适合:不适合建索引但需要准确统计的列 -- 不适合:高选择性列(直接建索引更好)、频繁更新的列(直方图不自动更新)
四、最佳实践和注意事项
4.1 最佳实践
4.1.1 索引设计原则
区分度优先:联合索引中,区分度高的列放前面(等值条件优先于范围条件)
控制索引数量:单表索引不超过 5 个,写多读少的表更要克制
定期清理无用索引:
-- 查找从未使用的索引(需要运行足够长时间后查询)
SELECTobject_schema, object_name, index_name
FROMperformance_schema.table_io_waits_summary_by_index_usage
WHEREindex_nameISNOTNULL
ANDcount_star =0
ANDobject_schemaNOTIN('mysql','performance_schema','information_schema')
ORDERBYobject_schema, object_name;
4.1.2 SQL 编写规范
-- 避免在索引列上使用函数 -- 错误:无法使用 created_at 上的索引 SELECT*FROMordersWHEREYEAR(created_at) =2024; -- 正确: SELECT*FROMordersWHEREcreated_at >='2024-01-01'ANDcreated_at < '2025-01-01'; -- 避免隐式类型转换 -- 错误:phone 是 VARCHAR,传入整数会导致全表扫描 SELECT * FROMusersWHERE phone = 13800138000; -- 正确: SELECT * FROMusersWHERE phone = '13800138000'; -- 大分页问题:OFFSET 越大越慢 -- 错误:OFFSET 100000 需要扫描 100020 行 SELECT * FROM orders ORDERBYidLIMIT20OFFSET100000; -- 正确:游标分页 SELECT * FROM orders WHEREid >100000ORDERBYidLIMIT20;
4.1.3 定期维护
-- 更新统计信息(数据变化超过 10% 后执行) ANALYZETABLEorders; -- 重建索引(索引碎片率高时) -- 查看碎片率 SELECTtable_name, ROUND(data_free / (data_length + index_length) *100,2)ASfrag_pct FROMinformation_schema.TABLES WHEREtable_schema ='production_db' ANDdata_free >0 ORDERBYfrag_pctDESC; -- 在线重建(8.0+ 支持,不锁表) ALTERTABLEordersENGINE=InnoDB, ALGORITHM=INPLACE,LOCK=NONE;
4.2 注意事项
4.2.1 常见误区
警告:以下操作在生产环境中可能造成严重性能问题。
SELECT *会导致覆盖索引失效,始终明确列出需要的字段
在大表上直接ALTER TABLE ADD INDEX会锁表,使用pt-online-schema-change或gh-ost
FORCE INDEX只用于临时调试,不要提交到生产代码
4.2.2 常见错误
| 错误现象 | 原因分析 | 解决方案 |
|---|---|---|
| 索引存在但不走 | 统计信息过期,优化器误判 | ANALYZE TABLE 更新统计信息 |
| 加索引后反而变慢 | 索引选择性太低,回表开销大于全表扫描 | 删除该索引,考虑覆盖索引 |
| ORDER BY 走 filesort | 排序列不在索引中,或索引顺序不匹配 | 调整联合索引列顺序 |
| JOIN 性能差 | 被驱动表关联列无索引 | 在被驱动表的关联列上建索引 |
| 深分页极慢 | OFFSET 大导致扫描大量行后丢弃 | 改用游标分页或延迟关联 |
五、故障排查和监控
5.1 实时性能诊断
-- 查看当前正在执行的慢 SQL(超过 5 秒) SELECTid,user, host, db,time, state,LEFT(info,200)ASsql_snippet FROMinformation_schema.PROCESSLIST WHEREcommand ='Query' ANDtime>5 ORDERBYtimeDESC; -- 查看锁等待情况 SELECT r.trx_idASwaiting_trx_id, r.trx_mysql_thread_idASwaiting_thread, r.trx_queryASwaiting_query, b.trx_idASblocking_trx_id, b.trx_mysql_thread_idASblocking_thread, b.trx_queryASblocking_query FROMinformation_schema.INNODB_TRX r JOINinformation_schema.INNODB_TRX b ONr.trx_wait_startedISNOTNULL ANDb.trx_id = ( SELECTblocking_trx_id FROMperformance_schema.data_lock_waits WHERErequesting_engine_transaction_id = r.trx_id LIMIT1 );
5.2 性能监控指标
5.2.1 关键指标
# 实时监控 MySQL 状态 mysqladmin -u root -p extended-status -i 1 | grep -E"Questions|Slow|Threads_running|InnoDB_buffer"
5.2.2 监控指标说明
| 指标名称 | 正常范围 | 告警阈值 | 说明 |
|---|---|---|---|
| Buffer Pool 命中率 | > 99% | < 95% | 低于 95% 需增大 buffer pool |
| Slow queries/s | < 1 | > 10 | 每秒慢查询数 |
| Threads_running | < 20 | > 50 | 活跃线程数,高说明有积压 |
| InnoDB row lock waits | < 5/s | > 50/s | 行锁等待频率 |
| Questions/s | 业务基线 | 基线 2x | QPS 突增可能是慢查询堆积 |
5.3 备份与恢复
5.3.1 慢查询日志轮转
# /etc/logrotate.d/mysql-slow
/var/log/mysql/slow.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
# 通知 MySQL 重新打开日志文件
mysql -u root -p"${MYSQL_ROOT_PASS}"-e"FLUSH SLOW LOGS;"
endscript
}
六、总结
6.1 技术要点回顾
慢查询定位:pt-query-digest 按总耗时排序,Rows examine / Rows sent > 100是优化信号
EXPLAIN 解读:type 从 ALL 优化到 range 或 ref,消除 Using filesort 和 Using temporary
索引设计:等值条件在前、范围条件在后、覆盖索引消除回表
Buffer Pool:命中率低于 95% 必须扩容,重启后预热避免冷启动性能抖动
6.2 进阶学习方向
Performance Schema 深度使用:比 SHOW STATUS 更细粒度的性能数据,可以定位到具体 SQL 的 I/O 等待
查询重写插件:ProxySQL 的 query_rewrite 功能,在不改代码的情况下修改 SQL
分区表:超过 1 亿行的表考虑按时间分区,配合分区裁剪减少扫描范围
6.3 参考资料
MySQL 8.4 优化器文档
Percona Toolkit 文档
Use The Index, Luke- 索引原理最佳学习资源
附录
A. 命令速查表
# 开启慢查询日志 mysql -e"SET GLOBAL slow_query_log=ON; SET GLOBAL long_query_time=1;" # 分析慢查询 pt-query-digest /var/log/mysql/slow.log --limit10 # 查看表索引 SHOW INDEX FROM ordersG # 更新统计信息 ANALYZE TABLE orders; # 查看 Buffer Pool 命中率 mysql -e"SHOW STATUS LIKE 'Innodb_buffer_pool_read%';" # 在线加索引(大表使用) pt-online-schema-change --alter"ADD INDEX idx_name (col)"D=db,t=table --execute
B. 配置参数详解
| 参数 | 推荐值 | 说明 |
|---|---|---|
| innodb_buffer_pool_size | 物理内存 60-70% | 最重要的性能参数 |
| innodb_buffer_pool_instances | buffer_pool_size/1GB | 减少锁竞争 |
| long_query_time | 0.5-1 | 慢查询阈值(秒) |
| max_connections | 300-500 | 根据并发量设置 |
| thread_cache_size | 50-100 | 线程缓存,减少创建开销 |
C. 术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| 覆盖索引 | Covering Index | 索引包含查询所需全部列,无需回表 |
| 回表 | Table Lookup | 通过索引找到主键后再查完整行数据 |
| 索引下推 | Index Condition Pushdown | 在存储引擎层过滤索引条件,减少回表 |
| 直方图 | Histogram | 列值分布统计,帮助优化器估算行数 |
| 文件排序 | Filesort | 无法利用索引排序,需要额外排序操作 |
-
数据库
+关注
关注
7文章
4092浏览量
68676 -
MySQL
+关注
关注
1文章
938浏览量
29846
原文标题:MySQL性能优化实战:慢查询分析与索引调优全流程
文章出处:【微信号:magedu-Linux,微信公众号:马哥Linux运维】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
数据库:为什么SQL使用了索引,却还是慢查询?
MySQL索引的使用问题
为什么ElasticSearch复杂条件查询比MySQL好?
MySQL慢查询分析与索引调优全流程
评论