背景与目的
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 临时开启慢查询日志
-- 临时开启慢查询日志 SET GLOBAL slow_query_log = 'ON'; SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log'; SET GLOBAL long_query_time = 1; -- 超过1秒记录 SET GLOBAL log_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未运行" exit 1 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 --limit 20 /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 EXPLAIN SELECT * FROM users WHERE id = 1; -- 更详细的输出 EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1; -- 注意:EXPLAIN ANALYZE只在MySQL 8.0.18+支持 -- 查看JSON格式(更完整) EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1;
2.2 explain输出字段详解
EXPLAIN SELECT u.name, o.order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' AND o.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:表只有一行(系统表) EXPLAIN SELECT * FROM mysql.time_zone_name; -- const:最多匹配一行(主键或唯一索引) EXPLAIN SELECT * FROM users WHERE id = 1; -- eq_ref:JOIN时使用主键或唯一索引 EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id; -- ref:使用非唯一索引 EXPLAIN SELECT * FROM orders WHERE status = 'pending'; -- ref_or_null:类似ref,但包含NULL查询 EXPLAIN SELECT * FROM orders WHERE user_id = 1 OR user_id IS NULL; -- range:使用索引范围查询 EXPLAIN SELECT * FROM orders WHERE create_time BETWEEN '2026-01-01' AND '2026-01-31'; -- index:全索引扫描 EXPLAIN SELECT COUNT(*) FROM orders; -- ALL:全表扫描(最差) EXPLAIN SELECT * FROM orders WHERE order_name LIKE '%test%';
2.4 Extra字段常见值
-- Using index:覆盖索引,无需回表 EXPLAIN SELECT user_id, name FROM users WHERE name = 'John'; -- Using where:使用WHERE过滤 EXPLAIN SELECT * FROM users WHERE status = 'active'; -- Using temporary:使用临时表(性能差) EXPLAIN SELECT name, COUNT(*) FROM orders GROUP BY name; -- Using filesort:使用文件排序(性能差) EXPLAIN SELECT * FROM users ORDER BY create_time DESC; -- Using index condition:索引条件下推 EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND order_name LIKE 'A%'; -- Using MRR:使用多范围读优化 EXPLAIN SELECT * FROM orders WHERE user_id IN (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分析 if command -v pt-query-digest &> /dev/null; then echo "【1】最慢的10个查询:" >> "$REPORT_FILE" pt-query-digest --limit 10 "$SLOW_LOG" >> "$REPORT_FILE" 2>&1 echo "【2】查询次数最多的SQL:" >> "$REPORT_FILE" pt-query-digest --order-by Query_time:sum --limit 10 "$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表 CREATE TABLE users ( id INT PRIMARY KEY, -- 主键索引(聚集索引) name VARCHAR(100), email VARCHAR(100), age INT, status CHAR(1), INDEX idx_email (email), -- 普通索引(非聚集) INDEX idx_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 主键索引
-- 创建表时指定主键 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, user_id BIGINT, total_amount DECIMAL(10,2), create_time DATETIME ); -- 修改表添加主键 ALTER TABLE orders ADD PRIMARY KEY (order_id); -- 复合主键 CREATE TABLE order_items ( order_id BIGINT, item_id BIGINT, quantity INT, PRIMARY KEY (order_id, item_id) -- 复合主键 );
特点:
每个表只能有一个主键
主键值唯一且非空
InnoDB会自动使用主键作为聚集索引
建议使用自增BIGINT作为主键(性能最优)
4.2 唯一索引
-- 创建唯一索引 CREATE UNIQUE INDEX idx_email ON users(email); -- 或在表定义中 CREATE TABLE users ( id INT PRIMARY KEY, email VARCHAR(100) UNIQUE, -- 自动创建唯一索引 phone VARCHAR(20), UNIQUE INDEX idx_phone (phone) ); -- 添加唯一索引 ALTER TABLE users ADD UNIQUE INDEX idx_email (email);
与主键的区别:
主键不允许NULL,唯一索引允许NULL(但只能有一个NULL)
一个表只有一个主键,可以有多个唯一索引
主键自动创建聚集索引,唯一索引是普通索引
4.3 普通索引
-- 单列索引 CREATE INDEX idx_name ON users(name); -- 查看表的所有索引 SHOW INDEX FROM users; -- 创建索引的完整语法 CREATE INDEX idx_status ON orders(status) USING BTREE COMMENT '订单状态索引';
4.4 前缀索引
适用于VARCHAR或TEXT类型的前N个字符创建索引。
-- 取前10个字符 CREATE INDEX idx_email_prefix ON users(email(10)); -- 取前20个字符 CREATE INDEX idx_address_prefix ON users(address(20)); -- 注意事项: -- 1. 前缀长度选择要足够长,避免过多冲突 -- 2. 前缀索引只支持 =、<、>、LIKE 'xxx%' 查询 -- 3. 不能用于 ORDER BY 和 GROUP BY
前缀长度选择参考:
-- 计算前缀选择性(区分度) SELECT COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) as prefix_5, COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) as prefix_10, COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) as prefix_15, COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) as prefix_20, COUNT(DISTINCT email) / COUNT(*) as full FROM users;
4.5 联合索引
多个列组合成一个索引,最左前缀原则是核心。
-- 创建联合索引 CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time); -- 联合索引的B+树结构 -- 按照 (user_id, status, create_time) 顺序构建 -- 排序优先级:user_id > status > create_time
联合索引使用条件:
-- 可以使用索引(全匹配) SELECT * FROM orders WHERE user_id = 1; SELECT * FROM orders WHERE user_id = 1 AND status = 'paid'; SELECT * FROM orders WHERE user_id = 1 AND status = 'paid' AND create_time > '2026-01-01'; -- 无法使用索引(跳过user_id) SELECT * FROM orders WHERE status = 'paid'; SELECT * FROM orders WHERE status = 'paid' AND create_time > '2026-01-01'; SELECT * FROM orders WHERE create_time > '2026-01-01'; -- 可以使用索引(范围查询后的列无法使用) SELECT * FROM orders WHERE user_id = 1 AND status > 'paid'; -- status之后的列无法使用 -- LIKE前缀匹配可以使用 SELECT * FROM orders WHERE user_id = 1 AND create_time LIKE '2026-01%';
5. 索引失效的典型场景
5.1 函数和运算导致索引失效
-- 失效:函数运算 SELECT * FROM orders WHERE YEAR(create_time) = 2026; SELECT * FROM orders WHERE DATE_FORMAT(create_time, '%Y') = '2026'; SELECT * FROM orders WHERE create_time + INTERVAL 1 DAY > NOW(); -- 正确做法:保持索引列独立 SELECT * FROM orders WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01'; -- 失效:算术运算 SELECT * FROM users WHERE age + 1 = 30; -- 正确做法 SELECT * FROM users WHERE age = 29;
5.2 类型转换导致索引失效
-- 失效:字符串列用数字查询(MySQL会隐式转换) CREATE TABLE test (phone VARCHAR(20)); SELECT * FROM test WHERE phone = 13800138000; -- 数字自动转字符串,但无法使用索引 -- 正确做法 SELECT * FROM test WHERE phone = '13800138000'; -- 失效:数字列用字符串查询 CREATE TABLE test2 (id INT); SELECT * FROM test2 WHERE id = '1'; -- 字符串转数字,可以走索引 -- 但反过来: SELECT * FROM test2 WHERE id = '1abc'; -- 数字转字符串,无法使用索引
5.3 LIKE通配符导致索引失效
-- 失效:前导通配符 SELECT * FROM orders WHERE order_name LIKE '%test%'; SELECT * FROM orders WHERE order_name LIKE '%test'; -- 生效:后置通配符 SELECT * FROM orders WHERE order_name LIKE 'test%'; -- 优化方案:使用全文索引 ALTER TABLE orders ADD FULLTEXT INDEX ft_order_name (order_name); SELECT * FROM orders WHERE MATCH(order_name) AGAINST('test'); -- 优化方案:使用Elasticsearch
5.4 OR条件导致索引失效
-- 失效:OR条件两边都未使用索引 SELECT * FROM users WHERE name = 'John' OR email = 'john@test.com'; -- 生效:确保OR两边都有索引(MySQL 8.0+) SELECT * FROM users WHERE name = 'John' UNION ALL SELECT * FROM users WHERE email = 'john@test.com' AND name <> 'John'; -- 使用IN替代OR(如果有索引) SELECT * FROM users WHERE name IN ('John', 'Mary', 'Tom');
5.5 NOT操作符导致索引失效
-- 失效:NOT IN / NOT EXISTS SELECT * FROM orders WHERE status NOT IN ('paid', 'shipped'); SELECT * FROM orders WHERE status != 'paid'; SELECT * FROM orders WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = orders.user_id); -- 正确做法:尽量使用IN或正向条件 SELECT * FROM orders WHERE status IN ('pending', 'cancelled'); -- 某些情况下可以使用覆盖索引优化 SELECT * FROM orders WHERE id NOT IN (SELECT order_id FROM cancelled_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优化器会统一处理) SELECT COUNT(*) FROM orders; SELECT COUNT(1) FROM orders; SELECT COUNT(primary_key) FROM orders; -- 主键非NULL,始终有值 -- 有差异的情况 SELECT COUNT(col) FROM orders; -- col列可能为NULL,需要检查每行 -- 测试验证 EXPLAIN SELECT COUNT(*) FROM orders; -- type: index, rows: 预估行数 EXPLAIN SELECT COUNT(1) FROM orders; -- 相同执行计划 EXPLAIN SELECT COUNT(id) FROM orders; -- 相同执行计划
6.2 count(*) 在不同引擎的实现
InnoDB引擎:
count(*) 需要全表扫描或索引扫描
使用主键索引扫描最快(因为主键索引B+树叶子节点包含完整数据)
如果有WHERE条件,需要过滤后计数
-- 最快的count(*)(使用主键索引) SELECT COUNT(*) FROM orders; -- 全表计数 -- 最快的count(*),带条件 SELECT COUNT(*) FROM orders WHERE status = 'paid'; -- 需要扫描status索引
6.3 count(*) 优化技巧
-- 场景:需要同时统计多个条件的数量 -- 低效:多次全表扫描 SELECT COUNT(*) FROM orders WHERE status = 'paid'; SELECT COUNT(*) FROM orders WHERE status = 'pending'; SELECT COUNT(*) FROM orders WHERE status = 'cancelled'; -- 高效:一次扫描,多个统计 SELECT SUM(status = 'paid') AS paid_count, SUM(status = 'pending') AS pending_count, SUM(status = 'cancelled') AS cancelled_count, COUNT(*) AS total_count FROM orders; -- 或者使用 GROUP BY SELECT status, COUNT(*) as cnt FROM orders GROUP BY status;
6.4 大表count(*)优化
-- 创建计数器表(适合实时性要求不高的场景) CREATE TABLE order_stats ( stat_date DATE PRIMARY KEY, total_orders BIGINT DEFAULT 0, paid_orders BIGINT DEFAULT 0, pending_orders BIGINT DEFAULT 0 ); -- 定时更新统计(使用事件调度器) DELIMITER $$ CREATE EVENT e_update_order_stats ON SCHEDULE EVERY 1 HOUR DO BEGIN INSERT INTO order_stats (stat_date, total_orders, paid_orders, pending_orders) SELECT CURRENT_DATE, COUNT(*), SUM(status = 'paid'), SUM(status = 'pending') FROM orders ON DUPLICATE KEY UPDATE total_orders = VALUES(total_orders), paid_orders = VALUES(paid_orders), pending_orders = VALUES(pending_orders); END$$ DELIMITER ; -- 使用近似值(准实时场景) SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'orders'; -- 注意:TABLE_ROWS是估算值,可能有50%误差
7. 分页优化:深度分页问题
7.1 深度分页问题原理
-- 问题查询:偏移量越大,越慢 SELECT * FROM orders ORDER BY id LIMIT 1000000, 20; -- 执行过程: -- 1. 读取前1000020行 -- 2. 丢弃前1000000行 -- 3. 返回20行 -- 偏移量100万,需要扫描100万+20行
7.2 优化方案1:使用主键ID游标
-- 第一页 SELECT * FROM orders ORDER BY id LIMIT 20; -- 得到 last_id = 1000 -- 第二页:使用上一页的最大ID SELECT * FROM orders WHERE id > 1000 ORDER BY id LIMIT 20; -- 进阶:支持任意跳转 -- 假设用户想跳到第50000页,每页20条 -- 最后一页的id需要从数据库获取,或使用其他定位方式
7.3 优化方案2:延迟关联
-- 原始查询(慢) SELECT * FROM orders WHERE status = 'paid' ORDER BY create_time DESC LIMIT 100000, 20; -- 优化:先查ID,再关联获取完整数据 SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = 'paid' ORDER BY create_time DESC LIMIT 100000, 20 ) t ON o.id = t.id;
7.4 优化方案3:范围查询
-- 如果有连续的自增ID,可以利用范围查询 -- 用户在第500页,看到的最后一条ID是 10000 -- 查询第501页 SELECT * FROM orders WHERE id > 10000 ORDER BY id LIMIT 20; -- 结合条件 SELECT * FROM orders WHERE id > 10000 AND status = 'paid' ORDER BY id LIMIT 20;
7.5 优化方案4:记录总数缓存
-- 不显示精确总数,只显示"上一页/下一页" -- 适合Feed流等场景 -- 获取每页数据 SELECT * FROM orders WHERE id < 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) AS order_date, COUNT(*) AS order_count, SUM(total_amount) AS total_amount, user_name FROM orders WHERE create_time >= '2026-01-01' GROUP BY DATE(create_time), user_name ORDER BY order_date DESC;
问题分析:
GROUP BY 和 ORDER BY 字段不一致
user_name 未被索引覆盖
缺少合适的索引
优化后:
-- 创建索引 ALTER TABLE orders ADD INDEX idx_create_time (create_time); -- 优化SQL SELECT DATE(create_time) AS order_date, COUNT(*) AS order_count, SUM(total_amount) AS total_amount FROM orders WHERE create_time >= '2026-01-01' GROUP BY DATE(create_time) ORDER BY order_date DESC;
8.2 案例2:用户行为分析
问题SQL:
-- 原始查询(20秒) SELECT u.id, u.name, COUNT(DISTINCT o.id) AS order_count, COUNT(DISTINCT e.id) AS event_count FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN events e ON u.id = e.user_id WHERE u.register_time >= '2026-01-01' GROUP BY u.id, u.name;
优化方案:
-- 创建索引 ALTER TABLE users ADD INDEX idx_register_time (register_time); ALTER TABLE orders ADD INDEX idx_user_id (user_id); ALTER TABLE events ADD INDEX idx_user_id (user_id); -- 改写SQL:分解为多个简单查询 SELECT u.id, u.name, COALESCE(o.order_count, 0) AS order_count, COALESCE(e.event_count, 0) AS event_count FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id ) o ON u.id = o.user_id LEFT JOIN ( SELECT user_id, COUNT(*) AS event_count FROM events GROUP BY user_id ) e ON u.id = e.user_id WHERE u.register_time >= '2026-01-01';
8.3 案例3:分页导出
问题SQL:
-- 导出100万条数据,每次20条,需要50000次 SELECT * FROM orders WHERE status = 'completed' ORDER BY create_time DESC LIMIT 1000000, 20;
优化方案:
-- 方案1:使用主键范围 -- 第一次查询 SELECT id FROM orders WHERE status = 'completed' ORDER BY create_time DESC LIMIT 20; -- 得到最大ID:last_id = 1000000 -- 后续查询 SELECT * FROM orders WHERE status = 'completed' AND id < 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 * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000); -- MySQL 5.6+会自动优化为JOIN,但显式写法更清晰 SELECT DISTINCT u.* FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.amount > 1000; -- EXISTS改写 SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000 );
9.2 OR改写为UNION
-- 低效:OR条件 SELECT * FROM products WHERE category = 'electronics' OR brand = 'Apple'; -- 高效:UNION SELECT * FROM products WHERE category = 'electronics' UNION SELECT * FROM products WHERE brand = 'Apple' AND category != 'electronics'; -- 或者使用UNION ALL(如果确认不重复) SELECT * FROM products WHERE category = 'electronics' UNION ALL SELECT * FROM products WHERE brand = 'Apple' AND category != 'electronics';
9.3 LIKE改写
-- 低效:前导通配符 SELECT * FROM products WHERE name LIKE '%iphone%'; -- 优化方案1:全文索引 ALTER TABLE products ADD FULLTEXT INDEX ft_name (name); SELECT * FROM products WHERE MATCH(name) AGAINST('+iphone' IN BOOLEAN MODE); -- 优化方案2:Elasticsearch -- 将数据同步到ES,使用ES搜索 -- 优化方案3:使用虚拟列+索引 ALTER TABLE products ADD COLUMN name_first_char CHAR(1) GENERATED AS (LEFT(name, 1)) STORED; CREATE INDEX idx_name_first_char ON products(name_first_char); SELECT * FROM products WHERE name_first_char = 'i' AND name LIKE 'i%iphone%';
9.4 COUNT(DISTINCT)优化
-- 低效:多个COUNT DISTINCT SELECT COUNT(DISTINCT user_id) AS user_count, COUNT(DISTINCT product_id) AS product_count, COUNT(DISTINCT category_id) AS category_count FROM orders; -- 高效:使用子查询 SELECT (SELECT COUNT(DISTINCT user_id) FROM orders) AS user_count, (SELECT COUNT(DISTINCT product_id) FROM orders) AS product_count, (SELECT COUNT(DISTINCT category_id) FROM orders) AS category_count; -- 或者使用SQL_CALC_FOUND_ROWS(已废弃) SELECT SQL_CALC_FOUND_ROWS * FROM orders LIMIT 20; SELECT FOUND_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,不存用户详细信息 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, user_id INT NOT NULL, total_amount DECIMAL(10,2), create_time DATETIME, INDEX idx_user_id (user_id) ); -- 用户表:用户详细信息 CREATE TABLE users ( user_id INT PRIMARY KEY, user_name VARCHAR(100), email VARCHAR(200), phone VARCHAR(20) ); -- 如果需要关联查询,使用JOIN SELECT o.*, u.user_name FROM orders o JOIN users u ON o.user_id = u.user_id; -- 反范式化:冗余数据提升查询性能 -- 适合读多写少、实时性要求不高的场景 CREATE TABLE orders_denormalized ( order_id BIGINT PRIMARY KEY, user_id INT NOT NULL, user_name VARCHAR(100), -- 冗余存储,避免JOIN total_amount DECIMAL(10,2), create_time DATETIME );
10.3 分区表
-- 按日期分区 CREATE TABLE orders ( order_id BIGINT, user_id INT, total_amount DECIMAL(10,2), create_time DATETIME, PRIMARY KEY (order_id, create_time) -- 必须包含分区键 ) PARTITION BY RANGE (YEAR(create_time)) ( PARTITION p2024 VALUES LESS THAN (2025), PARTITION p2025 VALUES LESS THAN (2026), PARTITION p2026 VALUES LESS THAN (2027), PARTITION p_future VALUES LESS THAN MAXVALUE ); -- 查询特定分区的数据(只扫描目标分区) SELECT * FROM orders WHERE create_time >= '2026-01-01' AND create_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 * FROM orders_1 WHERE user_id = 123; -- 全局表(数据量小,所有分片都冗余存储) CREATE TABLE categories ( id INT PRIMARY KEY, name VARCHAR(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 需要优化 Using where 可能需要优化 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
全部0条评论
快来发表一下你的评论吧 !