慢查询排查与调优
慢查询日志、profiling、参数调优、schema 设计
慢查询排查与调优
一、慢查询定位流程
慢查询优化完整流程:
[发现慢 SQL]
↓
[开启慢查询日志 / 监控告警]
↓
[定位具体 SQL]
↓
[EXPLAIN 分析执行计划]
↓
[查看索引使用情况]
↓
[分析 SQL 写法 / Schema 设计]
↓
[制定优化方案]
├── 索引优化: 加索引 / 重建索引 / 联合索引调整
├── SQL 改写: 避免函数操作 / 拆分大查询
├── Schema 优化: 分表 / 字段类型调整
└── 架构优化: 引入缓存 / 读写分离 / 分库分表
↓
[验证优化效果]
└── 再次 EXPLAIN 确认 / 压测验证
1.1 开启慢查询日志
-- ===== 慢查询日志配置 =====
-- 开启慢查询日志 (生产环境建议开启)
SET GLOBAL slow_query_log = ON;
-- 设置慢查询阈值 (单位: 秒)
-- 线上推荐 1 秒, 某些核心接口可以设置 0.1 秒
SET GLOBAL long_query_time = 1;
-- 记录未使用索引的查询 (用于排查索引问题)
SET GLOBAL log_queries_not_using_indexes = ON;
-- 查看配置是否生效
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
-- 设置日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
1.2 使用 pt-query-digest 分析慢日志
# Percona Toolkit 的 pt-query-digest 是分析慢查询日志的标准工具
pt-query-digest /var/log/mysql/mysql-slow.log > slow_analysis.txt
输出包含:
1. 总览: 总查询数、总耗时、各类查询占比
2. 按响应时间排名的 Top SQL
3. 每类 SQL 的详细统计: 执行次数、平均耗时、最大耗时
1.3 使用 performance_schema
-- MySQL 5.7+ 推荐使用 performance_schema (无需重启)
-- 查看最耗时的 SQL Top 10
SELECT DIGEST_TEXT,
COUNT_STAR AS executions,
SUM_TIMER_WAIT/1000000000000 AS total_time_sec,
AVG_TIMER_WAIT/1000000000000 AS avg_time_sec,
SUM_ROWS_EXAMINED AS rows_examined,
SUM_ROWS_SENT AS rows_sent,
(SUM_ROWS_EXAMINED / SUM_ROWS_SENT) AS exam_sent_ratio
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- exam_sent_ratio 越大说明扫描的行数远超返回的行数, 需要优化!
-- 如果 ratio > 100, 说明存在严重的"扫描大量行但只返回少数行"的问题
1.4 使用 sys 库
-- MySQL 5.7+ sys 库 (performance_schema 的封装)
-- 查看哪些 SQL 扫描的行数最多
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;
-- 查看未使用索引的查询
SELECT * FROM sys.statements_with_temp_tables LIMIT 10;
-- 查看执行慢的查询
SELECT * FROM sys.statement_analysis
ORDER BY avg_latency DESC
LIMIT 10;
---
二、常见慢查询原因深度分析
2.1 索引失效场景大全
-- 创建测试表
CREATE TABLE user_orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
order_no VARCHAR(32),
amount DECIMAL(10,2),
status VARCHAR(20),
phone VARCHAR(20),
created_at DATETIME,
INDEX idx_user_id(user_id),
INDEX idx_order_no(order_no),
INDEX idx_created_status(created_at, status),
INDEX idx_phone(phone)
);
-- ===== 1. 函数操作 =====
-- 失效
EXPLAIN SELECT * FROM user_orders WHERE DATE(created_at) = '2024-01-15';
-- 优化: 用范围查询代替函数
EXPLAIN SELECT * FROM user_orders WHERE created_at >= '2024-01-15 00:00:00' AND created_at < '2024-01-16 00:00:00';
-- 失效
EXPLAIN SELECT * FROM user_orders WHERE LEFT(order_no, 5) = 'ORD01';
-- 优化: 改用 LIKE (右模糊)
EXPLAIN SELECT * FROM user_orders WHERE order_no LIKE 'ORD01%';
-- MySQL 8.0.13+ 支持函数索引
CREATE INDEX idx_date_created ON user_orders((DATE(created_at)));
-- ===== 2. 隐式类型转换 =====
-- phone 列是 VARCHAR, 传入了数字
EXPLAIN SELECT * FROM user_orders WHERE phone = 13800138000;
-- 等价于: CAST(phone AS SIGNED) = 13800138000, 对列用了函数!
-- 优化: 传入字符串类型
EXPLAIN SELECT * FROM user_orders WHERE phone = '13800138000';
-- ===== 3. LIKE 左模糊 =====
EXPLAIN SELECT * FROM user_orders WHERE order_no LIKE '%ORDER123%';
-- 优化: 如果必须做全文搜索, 考虑 Elasticsearch 或 MySQL 全文索引
ALTER TABLE user_orders ADD FULLTEXT INDEX ft_order_no(order_no);
SELECT * FROM user_orders WHERE MATCH(order_no) AGAINST('ORDER123');
-- ===== 4. OR 导致全表扫描 =====
-- idx_user_id 和 idx_phone 分别存在,但 OR 在一起时可能全表扫描
EXPLAIN SELECT * FROM user_orders WHERE user_id = 100 OR phone = '13800138000';
-- 优化: 用 UNION ALL 替代
SELECT * FROM user_orders WHERE user_id = 100
UNION ALL
SELECT * FROM user_orders WHERE phone = '13800138000' AND (user_id != 100 OR user_id IS NULL);
-- 或者: MySQL 5.0+ 的 Index Merge 优化, 但不确定性高
-- ===== 5. 联合索引违反最左前缀 =====
EXPLAIN SELECT * FROM user_orders WHERE status = 'paid';
-- idx_created_status(created_at, status), 跳过了第一列, 索引失效!
-- 优化: 为 status 单独建索引
ALTER TABLE user_orders ADD INDEX idx_status(status);
-- ===== 6. IN + ORDER BY 组合不当 =====
EXPLAIN SELECT * FROM user_orders
WHERE status IN ('paid', 'shipped')
ORDER BY created_at DESC;
-- 可能 filesort! 因为 IN 会产生多个范围, 排序无法使用索引
-- 优化: 去掉 IN, 用 UNION 代替
SELECT * FROM user_orders WHERE status = 'paid' ORDER BY created_at DESC
UNION ALL
SELECT * FROM user_orders WHERE status = 'shipped' ORDER BY created_at DESC;
-- 但最终排序仍然需要合并... 这就是个难题
2.2 JOIN 性能问题
-- 创建两张测试表
CREATE TABLE customers (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
email VARCHAR(100),
created_at DATETIME
);
CREATE TABLE payments (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
amount DECIMAL(10,2),
pay_time DATETIME,
INDEX idx_customer_id(customer_id)
);
-- ===== 驱动表选择 =====
-- 左连接: 左表是驱动表
-- customers 10 万行, payments 500 万行
EXPLAIN SELECT c.name, p.amount
FROM customers c
LEFT JOIN payments p ON c.id = p.customer_id
WHERE c.created_at > '2023-01-01';
-- 内连接: MySQL 自动选择小表做驱动表
-- customers 10 万行, payments 500 万行
EXPLAIN SELECT c.name, p.amount
FROM customers c
INNER JOIN payments p ON c.id = p.customer_id;
-- 查看执行计划中的驱动表: id 字段最小的那个 (或第一个出现的)
-- ===== JOIN 字段必须加索引 =====
-- 如果 JOIN 字段没索引, 被驱动表每次都要全表扫描!
-- 小表 1 万行 × 大表 100 万行 = 需要 1 万 × 100 万 = 100 亿次比较
-- ===== JOIN 过多问题 =====
-- 4 表 JOIN 的性能分析:
EXPLAIN SELECT *
FROM a
JOIN b ON a.id = b.a_id
JOIN c ON b.id = c.b_id
JOIN d ON c.id = d.c_id;
-- 驱动表 a, 逐层 JOIN
-- 如果每张表 10 万行, 且索引都在, 查询量级: 10 万 × 索引查找 × 3
-- 但如果索引缺失, 就变成: 10 万 × 10 万 × 10 万 × 10 万!
-- 最佳实践: 超过 3 张表 JOIN 时考虑重构业务逻辑
2.3 子查询性能问题
-- ===== 低效的子查询 =====
-- 方式 1: IN 子查询
SELECT * FROM customers
WHERE id IN (
SELECT customer_id FROM payments WHERE amount > 1000
);
-- 方式 2: EXISTS 子查询
SELECT * FROM customers c
WHERE EXISTS (
SELECT 1 FROM payments p
WHERE p.customer_id = c.id AND p.amount > 1000
);
-- 方式 3: JOIN 改写 (通常最高效)
SELECT DISTINCT c.*
FROM customers c
INNER JOIN payments p ON c.id = p.customer_id
WHERE p.amount > 1000;
-- ===== 关联子查询 =====
-- 子查询对外层每一行都执行一次
SELECT * FROM orders o
WHERE o.amount > (
SELECT AVG(amount) FROM orders WHERE customer_id = o.customer_id
);
-- 每行都重新计算 AVG, 效率极低!
-- 优化: 先聚合, 再 JOIN
SELECT o.*
FROM orders o
INNER JOIN (
SELECT customer_id, AVG(amount) AS avg_amount
FROM orders
GROUP BY customer_id
) tmp ON o.customer_id = tmp.customer_id
WHERE o.amount > tmp.avg_amount;
2.4 大分页问题 (LIMIT offset 过大)
-- 分页越往后越慢
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100000, 20;
-- 执行过程:
-- 1. 扫描到第 100020 行
-- 2. 丢弃前 100000 行
-- 3. 只返回 20 行
-- 扫描了大量无用的行!
-- ===== 优化方案 1: 延迟关联 =====
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 100000, 20
) tmp ON o.id = tmp.id;
-- 子查询能用索引覆盖 (id, created_at), 只需要在索引树中扫描
-- 然后通过主键关联回表取完整数据, 回表次数只有 20 次!
-- 执行计划对比:
-- 优化前: Using index condition; Using filesort
-- 优化后: Using index (子查询) + Using where (外查询)
-- ===== 优化方案 2: 游标分页 (基于游标/指针) =====
-- 适用于"加载更多"、"无限滚动"场景
-- 前端传来上一页最后一个 order 的 created_at 和 id
SELECT * FROM orders
WHERE (created_at < '2024-01-15 10:30:00' -- 上一页最后一条的时间
OR (created_at = '2024-01-15 10:30:00' AND id < 1000)) -- 相同时间用 ID 区分
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 这种方式的优点:
-- 1. 不关心 offset, 直接走索引范围扫描
-- 2. 数据量大时性能稳定
-- 3. 每页查询时间基本一致
-- ===== 优化方案 3: 倒序查询 =====
-- 如果经常需要看"最新"数据, 可以分页反着查
-- 原来: LIMIT 100000, 20 (跳太多页)
-- 倒着来: 从最新时间往前数 20 条
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20; -- 永远只查最新的, 性能好
-- ===== 优化方案 4: 业务降级 =====
-- 对于用户来说, 100 页以后的数据几乎没人看
-- 限制最大翻页数: 只能翻到 100 页
-- 或直接提示"请输入搜索条件精确查找"
---
三、MySQL 参数调优
3.1 内存相关参数
# my.cnf 内存相关配置
1. Buffer Pool (最重要的参数!)
建议: 物理内存的 60%-80%
如果是一台 32G 内存的专用 MySQL 服务器:
innodb_buffer_pool_size = 20G
2. Buffer Pool 实例数 (减少内部锁竞争)
规则: 每个实例至少 1G
innodb_buffer_pool_instances = 8
3. redo log 大小
建议: 256M-4G, 根据写入量调整
innodb_log_file_size = 1G
innodb_log_files_in_group = 2 # 总共 2G redo log
4. redo log 刷盘策略
1 = 最安全, 每次提交都刷盘 (默认)
2 = 写入 OS cache, 每秒刷盘
innodb_flush_log_at_trx_commit = 1
5. 临时表大小
超过此大小时, 临时表从内存转到磁盘
tmp_table_size = 64M
max_heap_table_size = 64M
6. 排序缓冲区 (每个会话独享, 不宜过大)
sort_buffer_size = 2M
join_buffer_size = 2M
7. 连接缓冲区
read_buffer_size = 1M
read_rnd_buffer_size = 1M
3.2 连接相关参数
# 最大连接数
不是越大越好, 连接数过高会导致 MySQL 上下文切换频繁
根据服务器配置和业务: 200-2000
max_connections = 500
连接超时 (空闲连接断开时间, 避免连接池资源浪费)
wait_timeout = 600 # 非交互式连接
interactive_timeout = 600 # 交互式连接
连接数过多时的应急方案:
设置 admin_address 和 admin_port, 管理员通过专用端口连接
即使普通连接耗尽, 管理员还能连上来排查问题
3.3 日志相关参数
# 二进制日志
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW # 推荐 ROW 格式
sync_binlog = 1 # 每次写入都刷盘
binlog_expire_logs_seconds = 604800 # 保留 7 天
慢查询日志
slow_query_log = ON
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
3.4 InnoDB 专用调优
# InnoDB IO 相关
如果使用 SSD, 设置 0 (让操作系统自动处理)
innodb_flush_neighbors = 0 # SSD 不需要预读相邻页
脏页刷新策略
脏页比例达到此值时, 强制刷新
innodb_max_dirty_pages_pct = 75
自适应刷新
innodb_adaptive_flushing = ON
IO 线程数
SSD 可以设置高一些
innodb_read_io_threads = 8
innodb_write_io_threads = 8
双写缓冲区 (保证数据页写入的原子性)
SSD 上可以考虑禁用(需要确认 SSD 是否支持原子写)
innodb_doublewrite = ON # 默认开启, 不建议关闭
数据页压缩 (节省磁盘空间, 消耗 CPU)
innodb_compression_level = 6 # 可选
---
四、Schema 设计优化
4.1 字段类型选择原则
-- 原则 1: 选择最小的数据类型
-- ❌ BAD: 年龄用 INT (4字节)
age INT NOT NULL,
-- ✅ GOOD: 年龄用 TINYINT UNSIGNED (1字节, 0-255)
age TINYINT UNSIGNED NOT NULL,
-- ❌ BAD: IP 用 VARCHAR(15)
ip VARCHAR(15),
-- ✅ GOOD: IP 用 INT UNSIGNED (4字节, INET_ATON/NTOA 转换)
ip INT UNSIGNED,
-- 查询时: WHERE ip = INET_ATON('192.168.1.1')
-- 展示时: SELECT INET_NTOA(ip) FROM ...
-- 原则 2: 使用 NOT NULL
-- ❌ BAD: 可为空
email VARCHAR(100),
-- ✅ GOOD: 不为空 + 默认值
email VARCHAR(100) NOT NULL DEFAULT '',
-- 理由: NULL 列需要额外的空位标记, 索引也更复杂, 查询时 != 不等于 NULL
-- 原则 3: 选择合适长度
-- ❌ BAD: VARCHAR(255) 但实际只有 10 个汉字
status VARCHAR(255),
-- ✅ GOOD: 按实际需求
status VARCHAR(20) DEFAULT 'pending',
-- 原则 4: 主键选 BIGINT 而非 INT (考虑未来扩展)
-- 如果可能超过 21 亿, 必须用 BIGINT
id BIGINT AUTO_INCREMENT PRIMARY KEY,
4.2 索引设计原则
-- 原则 1: 区分度高的列放在联合索引前面
-- 计算区分度
-- 性别区分度: 2/10000 = 0.0002 (极差)
SELECT COUNT(DISTINCT gender) / COUNT(*) FROM users;
-- 手机号区分度: 几乎为 1 (极好)
SELECT COUNT(DISTINCT phone) / COUNT(*) FROM users;
-- 原则 2: 不为每个列单独建索引, 而是建联合索引
-- ❌ BAD: 三个单列索引
INDEX idx_status(status),
INDEX idx_created(created_at),
INDEX idx_type(type),
-- ✅ GOOD: 一个联合索引
INDEX idx_status_created_type(status, created_at, type),
-- 原则 3: 评估索引数量
-- 索引越多, 写入越慢 (每次 INSERT/UPDATE/DELETE 都要更新索引)
-- 索引越多, 占用空间越大
-- 一般单表索引不超过 5-6 个
-- 原则 4: 删除无用索引
-- 检查冗余索引
SELECT * FROM sys.schema_redundant_indexes;
-- 或:
SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'your_db';
4.3 反范式设计
-- 场景: 订单列表需要展示用户名
-- 范式化设计:
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT NOT NULL,
amount DECIMAL(10,2),
created_at DATETIME
);
-- 查询订单列表时需要每次 JOIN users 表
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id;
-- 反范式化设计: 冗余用户名字段
CREATE TABLE orders_denormalized (
id INT PRIMARY KEY,
user_id INT NOT NULL,
user_name VARCHAR(50), -- 冗余字段!
amount DECIMAL(10,2),
created_at DATETIME,
INDEX idx_user_name(user_name)
);
-- 优点: 不需要 JOIN, 查询快
-- 缺点: 用户改名时需要同步更新所有订单
-- 适用场景:
-- 1. 用户几乎不改名
-- 2. 订单列表查询量极大, JOIN 成为瓶颈
-- 3. 可以接受数据最终一致性 (后台异步同步改名)
---
五、实战案例
案例 1:分页查询从 3 秒优化到 10 毫秒
-- 优化前 (3+ 秒):
SELECT * FROM orders
WHERE created_at > '2024-01-01'
ORDER BY id
LIMIT 100000, 20;
-- EXPLAIN:
-- type: ALL (全表扫描)
-- Extra: Using filesort
-- rows: 500万
-- 优化后 (10ms):
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE created_at > '2024-01-01'
ORDER BY id
LIMIT 100000, 20
) tmp ON o.id = tmp.id;
-- EXPLAIN (子查询):
-- type: range (使用索引范围扫描, 很高效!)
-- Extra: Using index (覆盖索引, 不需要回表)
-- rows: 100020 (只扫描了必要的行)
-- 再对比: 如果业务允许, 用游标分页:
SELECT * FROM orders
WHERE created_at > '2024-01-01' AND id > 100000
ORDER BY id
LIMIT 20;
-- 性能: < 1ms
案例 2:JOIN 从 30 秒优化到 0.1 秒
-- 优化前 (30 秒):
SELECT a.*, b.name, c.title
FROM logs a
JOIN users b ON a.user_id = b.id
JOIN articles c ON a.article_id = c.id
WHERE a.created_at > '2024-01-01'
ORDER BY a.created_at DESC
LIMIT 20;
-- 问题: logs 表 1 亿行, 即使有索引, 临时表排序也需要大量 IO
-- Extra: Using index condition; Using temporary; Using filesort
-- 优化后 (0.1 秒):
-- Step 1: 先只查日志表 (WHERE 条件能用上索引)
-- Step 2: 用子查询延迟关联
SELECT a.*, b.name, c.title
FROM (
SELECT id, user_id, article_id, created_at
FROM logs
WHERE created_at > '2024-01-01'
ORDER BY created_at DESC
LIMIT 20
) a
JOIN users b ON a.user_id = b.id
JOIN articles c ON a.article_id = c.id;
-- 子查询只取 20 条, 然后再 JOIN 两次
-- 回表次数: 20 × 2 = 40 次, 非常快!
案例 3:用覆盖索引消除排序
-- 创建联合索引 (确保 ORDER BY 的字段在索引中)
CREATE INDEX idx_status_created ON orders(status, created_at);
-- 查询
SELECT id, order_no, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 100;
-- EXPLAIN:
-- type: ref
-- key: idx_status_created
-- Extra: Using index (覆盖索引!)
-- 没有 Using filesort!
-- 因为索引本身就是按 (status, created_at) 排序的,
-- 在索引树中, status='paid' 的部分已经按 created_at 排好序了!
---
六、面试高频题
1. 你如何优化一个慢查询?
先通过慢查询日志定位具体 SQL,然后用 EXPLAIN 分析执行计划。看 type、rows、Extra 字段:
- type=ALL → 缺索引或有索引未用上
- rows 太大 → 需要更好的过滤条件
- Using filesort → ORDER BY 未用索引,需要优化索引
- Using temporary → 临时表排序/去重,需要优化
然后针对性加索引、改写 SQL 或重构业务逻辑。
2. 分页查询很慢怎么办?
核心是避免扫描大量行。使用延迟关联(先查主键再回表)或游标分页(基于指针而非偏移量)。如果业务允许,限制最大翻页数或引导用户使用搜索。
3. 如何发现 MySQL 的性能瓶颈?
使用 SHOW PROCESSLIST 看当前运行的查询;用 performance_schema 按等待事件排序;用 SHOW ENGINE INNODB STATUS 看 InnoDB 内部锁和 IO;配合操作系统工具(top/iostat/vmstat)看资源使用情况。
4. 什么时候用反范式设计?
当 JOIN 成为性能瓶颈且字段很少变化时,可以冗余少量字段到主表。接受最终一致性和额外的更新维护成本。
5. 如何选择缓冲区大小?
innodb_buffer_pool_size 设为物理内存的 60-80%。如果 Buffer Pool 命中率低于 95%(查询 Innodb_buffer_pool_read_requests / Innodb_buffer_pool_reads),需要增加。
核心要点
- 慢查询定位与分析流程
- 索引失效常见场景
- 大分页优化(延迟关联/游标分页)
- MySQL 参数调优
- 数据库 schema 设计规范