
页面加载缓慢、查询超时、系统卡顿——当这些问题集中爆发时,数据库慢查询往往是罪魁祸首之一。很多人的第一反应是:“让开发加个索引”。这招有时候管用,但更多时候,盲目加索引不仅白费力气,还可能把事情搞得更糟。
真正的慢查询优化,走的是一套系统性的排查与决策流程。你得先搞清楚“慢”是不是真的存在,然后揪出执行计划背后的瓶颈,最后才能开出对症的药方。只盯着索引,等于放弃了 SQL 重写、配置调优、表结构改造等其他选项。本文就以 MySQL 为例,梳理从发现到根治慢查询的完整实战路径,这些方法对 PostgreSQL 和 Oracle 同样有极高的参考价值。
在深入排查之前,你或许需要一个能集中讨论这些复杂技术问题、分享脚本和心得的去处。在云栈社区,有不少同道中人会探讨这类运维与架构上的硬骨头,说不定能给日常排查带来新思路。
1 慢查询的发现与确认
发现问题,是解决问题的第一步。如果连哪些 SQL 在拖后腿都不知道,优化自然无从谈起。
1.1 开启慢查询日志
MySQL 慢查询日志是排障的基石,但它默认处于关闭状态。可以通过以下命令查验和开启。
查看当前慢查询配置:
-- 查看慢查询相关变量
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_output';
-- 查看是否启用了慢查询日志
SHOW VARIABLES LIKE 'slow_query_log';
-- 查看慢查询日志文件路径
SHOW VARIABLES LIKE 'slow_query_log_file';
动态配置慢查询日志:
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 设置慢查询阈值为 1 秒(可以是浮点数)
SET GLOBAL long_query_time = 1;
-- 设置日志输出格式(TABLE 或 FILE)
SET GLOBAL log_output = 'FILE';
-- 设置慢查询日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
以上配置在 MySQL 重启后会丢失。欲永久生效,需写入配置文件 /etc/mysql/my.cnf 或 /etc/my.cnf:
[mysqld]
# 开启慢查询日志
slow_query_log = 1
# 慢查询日志文件路径
slow_query_log_file = /var/log/mysql/slow.log
# 慢查询阈值(秒)
long_query_time = 1
# 记录没有使用索引的查询
log_queries_not_using_indexes = 1
# 将慢查询记录到文件
log_output = FILE
保存后重启服务:
systemctl restart mysql
# 或
systemctl restart mysqld
1.2 慢查询日志格式解读
一旦启用,日志便会忠实地记录每一次慢查询的细节:
# Time: 2024-01-15T10:30:45.123456Z
# User@Host: app_user[app_user] @ localhost []
# Query_time: 5.234567 Lock_time: 0.001234 Rows_sent: 100 Rows_examined: 50000
SET timestamp=1705315845;
SELECT * FROM orders WHERE user_id = 12345 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
几个关键字段揭示了查询的本质:
Query_time:查询实际执行时长(秒)。这是最核心的指标。
Lock_time:等待锁的时间。
Rows_sent / Rows_examined:Rows_examined 是扫描行数,Rows_sent 是实际返回给客户端的行数。二者比值越大,说明查询在大量数据中“大海捞针”,效率往往很低,是需要优化的典型特征。高效的查询应当让 Rows_examined 尽可能接近 Rows_sent。
1.3 使用 mysqldumpslow 分析日志
直接翻阅原始日志文件无异于大海捞针。mysqldumpslow 可以帮助你快速汇总出最需要关注的头部慢查询:
# 显示最慢的 10 条查询
mysqldumpslow -t 10 /var/log/mysql/slow.log
# 常用参数说明:
# -t N 显示前 N 条最慢的查询
# -s 排序标准 (c:计数, t:时间, l:锁时间, r:返回行数)
# -a 不聚合相同的查询
# -g PAT 只显示匹配 pattern 的查询
# 示例:按总查询时间排序
mysqldumpslow -t 10 -s t /var/log/mysql/slow.log
# 显示扫描行数最多的查询
mysqldumpslow -t 10 -s r /var/log/mysql/slow.log
# 显示查询次数最多的查询
mysqldumpslow -t 10 -s c /var/log/mysql/slow.log
# 过滤特定表的查询
mysqldumpslow -t 10 -g 'orders' /var/log/mysql/slow.log
# 使用正则过滤
mysqldumpslow -t 10 -a -g 'SELECT.*FROM.*WHERE' /var/log/mysql/slow.log
1.4 使用 pt-query-digest 进行深度分析
若 mysqldumpslow 提供的是摘要,那么 Percona Toolkit 里的 pt-query-digest 就是一份详尽的分析报告。
# 安装 Percona Toolkit (Ubuntu/Debian)
apt-get install percona-toolkit
# 安装 (RHEL/CentOS)
yum install percona-toolkit
# 基本用法
pt-query-digest /var/log/mysql/slow.log
# 输出到文件
pt-query-digest /var/log/mysql/slow.log > /tmp/query_analysis.txt
# 只分析最近 24 小时的记录
pt-query-digest --since '24h' /var/log/mysql/slow.log
# 分析特定时间段
pt-query-digest --since '2024-01-15 10:00:00' --until '2024-01-15 12:00:00' /var/log/mysql/slow.log
它的输出会包含查询的响应时间分布、执行频次、平均扫描行数等,甚至能直接标记出潜在的问题查询。
2 使用 EXPLAIN 剖析执行计划
找出了是哪些 SQL 慢,接下来就是理解它们“为什么慢”。EXPLAIN 是你手中的显微镜。
2.1 EXPLAIN 基本用法
EXPLAIN 能展示 MySQL 优化器是如何“盘算”着执行一条查询的。
-- 基本格式
EXPLAIN SELECT * FROM orders WHERE user_id = 12345;
-- 更详细的 JSON 格式输出
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 12345;
-- 同样适用于 UPDATE、DELETE、INSERT
EXPLAIN UPDATE orders SET status = 'shipped' WHERE order_id = 100;
EXPLAIN DELETE FROM orders WHERE status = 'cancelled';
2.2 输出字段详解
以一条简单的 JOIN 查询为例:
EXPLAIN SELECT o.*, u.name FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345 AND o.status = 'paid'
ORDER BY o.created_at DESC LIMIT 20;
输出可能如下:
+----+-------------+-------+------+---------------+------+---------+-------+------+----------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------+---------------+------+---------+-------+------+----------+----------------+
| 1 | SIMPLE | o | ref | idx_user | idx_user | 5 | const | 50 | 10.00 | Using where; Using filesort |
| 1 | SIMPLE | u | const| PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------+---------------+------+---------+-------+------+----------+----------------+
逐一理解这里的列非常重要:
id:查询的序列号。id 越大越先执行,id 相同则从上往下执行。
select_type:查询类型,如 SIMPLE(简单查询)、PRIMARY(主查询)、SUBQUERY(子查询)、DERIVED(派生表)等。
table:操作的表。
type:访问类型,这是衡量查询效率的核心指标。从好到差依次为:system > const > eq_ref > ref > range > index > ALL。出现 ALL 即全表扫描,是必须重点优化的对象。
possible_keys:可能用到的索引。
key:实际使用的索引。如果这里是 NULL,说明没有用上索引。
rows:预估需要扫描的行数。这个值是基于统计信息的估算,越小越好。
filtered:按条件过滤后剩余行数的百分比。
Extra:包含关键的性能提示,如 Using filesort(需额外排序)和 Using temporary(需临时表),出现这些通常是明确的优化信号。
2.3 type 字段详解
这是需要你刻在脑子里的性能优劣顺序表:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL。
日常优化中,你只需确保关键查询的 type 达到 range 级别以上。
-- 查看 type 为 ALL 的查询(无索引,全表扫)
EXPLAIN SELECT * FROM orders WHERE created_at > '2024-01-01';
-- 创建索引后,type 优化为 range
CREATE INDEX idx_orders_created_at ON orders(created_at);
EXPLAIN SELECT * FROM orders WHERE created_at > '2024-01-01';
Extra 就像是详细医嘱,告诉你查询具体经历了什么。
-- 示例查询,出现 Using filesort,因为排序无法利用索引
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC;
-- 优化方案:创建覆盖索引,一举消除 filesort
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
一旦在 Extra 里看到 Using filesort 和 Using temporary,你就要立刻警觉起来,这意味着磁盘 I/O 和额外开销。
2.5 使用 EXPLAIN ANALYZE(MySQL 8.0+)
从 MySQL 8.0 开始,EXPLAIN ANALYZE 登场。它不仅给出计划,还会真实执行查询,并给出每个步骤的实际时间消耗和返回行数。这让“预估”与“现实”的差距一览无余,统计信息过时或执行计划错误的隐藏问题再也无处遁形。
EXPLAIN ANALYZE
SELECT o.*, u.name FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345 AND o.status = 'paid'
ORDER BY o.created_at DESC LIMIT 20;
3 索引的创建与优化
理解了优化器如何做决策,现在可以主动出击——用索引来引导它。但索引是把双刃剑。
3.1 索引原理简述
在 MySQL 的 InnoDB 引擎中,最常用的是 B+Tree 索引。数据或数据指针按序存储在叶子节点上,叶子节点间通过链表相连。它既能保证高效的等值查找,也能优雅地处理范围查询,这就是它能同时加速 = 和 > 的原因。
代价同样明显:索引占空间,且每次写操作(增、删、改)都需要同步维护索引,从而拖慢写入速度。
3.2 创建索引的原则
1. 高区分度优先
区分度,即 COUNT(DISTINCT column) / COUNT(*),是衡量一个列“值不值得”被索引的关键。
-- 查看列的区分度
SELECT COUNT(DISTINCT status) / COUNT(*) FROM orders; -- 可能很低,只有几种状态
SELECT COUNT(DISTINCT user_id) / COUNT(*) FROM orders; -- 可能很高
为区分度低的列(如 sex, status)创建索引,好比在新华字典里为“的”字建一个索引,意义不大。反之,订单表里的 user_id 就是索引的首要人选。
2. 遵循最左前缀原则
对于复合索引 INDEX idx(a, b, c),它能有效支持 a、a,b、a,b,c 这三种组合的查询。但单独用 b 或 c 来查,这个索引就爱莫能助了。
-- 创建复合索引
CREATE INDEX idx_orders_user_status ON orders(user_id, status, created_at);
-- 能走索引的查询
SELECT * FROM orders WHERE user_id = 123;
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid' AND created_at > '2024-01-01';
-- 无法高效利用索引的查询
SELECT * FROM orders WHERE status = 'paid';
3.3 索引创建实战
综合运用前面的原则,来看一个真实的优化场景:
-- 慢查询语句
-- SELECT * FROM orders WHERE user_id = 12345 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
-- 优化思路:
-- 1. 等值查询字段 user_id 和 status 放前面,高区分度的 user_id 在前。
-- 2. 排序字段 created_at 跟在其后,并指定降序(MyQL 8.0+支持)。
-- MySQL 8.0+ 的一步到位方案
CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC);
-- MySQL 5.7 的折中方案(降序索引不被支持,排序会依然用到 filesort)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
3.4 索引失效的陷阱
建了索引不代表万事大吉,使用不当,优化器会直接绕过它。
- 对索引列使用函数或计算:
-- 索引失效
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- 应该这样
SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
- 前导模糊查询:
-- 索引失效
SELECT * FROM orders WHERE order_no LIKE '%123%';
-- 可以使用索引
SELECT * FROM orders WHERE order_no LIKE 'ABC123%';
- 隐式类型转换:
-- 若 user_id 是 INT 类型,与字符串比较可能导致索引失效
SELECT * FROM orders WHERE user_id = '12345';
-- 正确用法:类型严格匹配
SELECT * FROM orders WHERE user_id = 12345;
3.5 查看索引使用情况
索引建完上线后,一定要复查它是否真的被用上了。
-- 查看表上有哪些索引
SHOW INDEX FROM orders;
-- 通过 EXPLAIN 确认 key 字段是否是你期望的索引
EXPLAIN SELECT * FROM orders WHERE user_id = 12345;
-- 查看索引的基数(Cardinality),了解区分度
SHOW TABLE STATUS LIKE 'orders';
-- MySQL 8.0+ 专有视图
SELECT * FROM mysql.index_statistics WHERE table_name = 'orders';
4 SQL 语句优化
有时候,问题的根子不在索引,而在 SQL 本身。哪怕有着完善的索引,糟糕的 SQL 也能让性能一塌糊涂。
4.1 消灭常见低效模式
- *杜绝 `SELECT `**:只取所需列,尤其是避免回表,也让覆盖索引能真正派上用场。
-- 低效
SELECT * FROM orders WHERE order_id = 12345;
-- 高效
SELECT order_id, user_id, total_amount FROM orders WHERE order_id = 12345;
- LIMIT 不光是功能,也是性能:不加
LIMIT 的应用层过滤是在浪费网络和内存。
SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 20;
- 分解大查询:一个复杂的多表 JOIN,不如拆成多个简单查询,在应用层组装数据。这样做可以减小锁的竞争,也更容易利用缓存。
-- 可以尝试将这一步拆分为:
-- 1. 从 orders 表筛选出主键
SELECT order_id FROM orders WHERE created_at > '2024-01-01' LIMIT 1000;
-- 2. 再用这些主键去关联其他表,或用 IN 查询
4.2 驯服 ORDER BY 和 GROUP BY
排序和分组是性能消耗大户。最优解是让索引来有序地提供数据。
-- 配合索引 idx_orders_user_status_created(user_id, status, created_at DESC)
-- 以下查询可直接利用索引顺序,避免 filesort
SELECT * FROM orders
WHERE user_id = 123
ORDER BY status, created_at DESC
LIMIT 20;
4.3 优化 JOIN 操作
4.4 用 EXISTS 替代 IN(在特定场景下)
当子查询结果集较大时,EXISTS 找到第一条匹配记录即返回,性能往往优于 IN。
-- 子查询可能返回大量数据时,用 EXISTS
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM users u WHERE u.id = o.user_id AND u.status = 'vip'
);
5 数据库配置优化
软件层面的优化是前提,但配置决定了这些优化的天花板。尤其是与 MySQL 核心引擎相关的参数。
5.1 关键配置参数
一份值得你在生产环境细细打磨的配置片断:
[mysqld]
# InnoDB 缓冲池大小,官方建议设为物理内存的 70%-80%。
innodb_buffer_pool_size = 12G
# 日志文件大小,越大性能越好,但崩溃恢复时间也越久
innodb_log_file_size = 1G
# 日志刷新策略,1 代表每次事务提交都刷盘,保证 ACID,但性能较差
innodb_flush_log_at_trx_commit = 1
# 最大连接数
max_connections = 500
# 临时表和内存表大小设置,防止因结果集过大而被迫写入磁盘
tmp_table_size = 256M
max_heap_table_size = 256M
# 慢查询配置
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
5.2 InnoDB 缓冲池优化
你要时常了解它的“健康状况”:
-- 查看缓冲池整体使用情况
SHOW STATUS LIKE 'Innodb_buffer_pool%';
-- 查看缓冲池大小配置
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 动态调整缓冲池大小(MySQL 5.7+)
SET GLOBAL innodb_buffer_pool_size = 12873741824; -- 12GB
-- 设置缓冲池实例数,建议与 CPU 核心数一致,减少内部争用
innodb_buffer_pool_instances = 8
-- 数据库重启后自动读取热数据到缓冲池
innodb_buffer_pool_load_at_startup = 1
5.3 连接数管理
连接数过少会阻塞应用,过多则会耗尽内存。
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections';
-- 查看最大连接数配置
SHOW VARIABLES LIKE 'max_connections';
-- 临时调整最大连接数
SET GLOBAL max_connections = 1000;
-- 查看当前所有连接
SHOW FULL PROCESSLIST;
-- 批量杀掉空闲时间超过 3600 秒的连接
SELECT CONCAT('KILL ', id, ';')
FROM information_schema.processlist
WHERE Command = 'Sleep' AND Time > 3600;
6 慢查询优化实战案例
理论看完了,我们用几个典型的线上场景来操练一下。
6.1 案例一:分页查询性能“跳水”
后台列表常见的 LIMIT N, M 深分页(页面越往后翻越快不起来),是经典的优化案例。
-- 低效的深分页,offset 大到一定程度时,查询会慢得离谱
SELECT * FROM orders ORDER BY order_id DESC LIMIT 1000000, 20;
-- 问题剖析:MySQL 需要顺序扫描到 offset + limit 行的位置,offset 越大,扫描越多。
-- 优化方案一:基于游标的分页(记住上一页最后一条的 order_id)
SELECT * FROM orders
WHERE order_id < 1234567 -- 上次查询返回的最小 ID
ORDER BY order_id DESC
LIMIT 20;
-- 优化方案二:若必须跳页,优先走覆盖索引减少扫描量
SELECT order_id FROM orders ORDER BY order_id DESC LIMIT 1000000, 20;
6.2 案例二:统计查询的“数据洪流”
在大表上直接做 GROUP BY 的统计查询,无疑是拿服务器性能和用户耐心在裸奔。
-- 低效:每次请求都扫描整个 orders 表的当月数据进行聚合计算
SELECT DATE(created_at) AS day, COUNT(*) AS order_count
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY DATE(created_at);
-- 优化方案:建立一张轻量级的日汇总表,通过定时任务灌注数据
CREATE TABLE orders_daily_summary (
stat_date DATE PRIMARY KEY,
order_count INT NOT NULL DEFAULT 0,
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 定时任务执行合并写入
INSERT INTO orders_daily_summary (stat_date, order_count, total_amount)
SELECT DATE(created_at), COUNT(*), SUM(total_amount)
FROM orders WHERE DATE(created_at) = CURDATE()
GROUP BY DATE(created_at)
ON DUPLICATE KEY UPDATE
order_count = VALUES(order_count),
total_amount = VALUES(total_amount);
6.3 案例三:模糊搜索的优化路径
LIKE '%keyword%' 是全表扫描的同义词,但业务上确实存在复杂的文本搜索需求。
-- 最差劲的做法
SELECT * FROM users WHERE name LIKE '%zhang%';
-- 优化方案一:用 MySQL 内置的全文索引(5.6+),适用于简单的中文分词搜索
ALTER TABLE users ADD FULLTEXT INDEX ft_users_name(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('zhang');
-- 优化方案二:引入 Elasticsearch 这类专业的搜索引擎,是处理复杂模糊搜索的最佳实践。
-- 优化方案三:如果搜索场景总是前缀匹配(如搜索框自动补全)
CREATE INDEX idx_users_name_prefix ON users(name(10));
SELECT * FROM users WHERE name LIKE 'zhang%'; -- 可以利用到索引
7 监控与预防
优化不是一劳永逸的买卖。建立常态化的监控与治理流程,才能长治久安。
7.1 持续监控慢查询
一段 shell 脚本,每天自动生成慢查询分析报告,让你的告警系统介入。
#!/bin/bash
# 每天凌晨分析昨天的慢查询日志
DATE=$(date -d "yesterday" +%Y-%m-%d)
SLOW_LOG="/var/log/mysql/slow.log"
REPORT="/var/log/mysql/slow_query_report_${DATE}.txt"
pt-query-digest --since "$(date -d 'yesterday 00:00:00' +%s) seconds" \
--until "$(date -d 'yesterday 23:59:59' +%s) seconds" \
--report-format=query_report \
$SLOW_LOG > $REPORT
# 如果新发现的慢查询超过阈值,则发送告警
if [ -s "$REPORT" ]; then
count=$(grep -c "Query" $REPORT || true)
if [ "$count" -gt 10 ]; then
echo "Found $count slow queries in the report" | mail -s "Slow Query Alert" ops@example.com
fi
fi
MySQL 5.6+ 的原生监控利器,性能开销极低,是查看当前实时热点查询的不二之选。
-- 启用相关的 instrument 和 consumer
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES' WHERE NAME LIKE 'statement/%';
UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES' WHERE NAME LIKE 'events_statements%';
-- Top 10 总耗时最长的 SQL 摘要
SELECT
DIGEST,
COUNT_STAR,
SUM_TIMER_WAIT / 1000000000000 AS total_time_sec,
AVG_TIMER_WAIT / 1000000000000 AS avg_time_sec,
SUM_ROWS_EXAMINED,
SUM_ROWS_SENT,
SUBSTR(DIGEST_TEXT, 1, 100) AS query_sample
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
7.3 建立慢查询治理闭环
将慢查询的发现、分析、解决、验证、归档形成一个闭环:
- 日常监控:基于报表和告警,主动发现新增慢查询。
- 分析根因:使用
EXPLAIN 进行精准分析,定位瓶颈。
- 制定方案:选择索引优化、SQL 重写、参数调整等手段。
- 上线验证:在测试环境或预发环境验证效果,确认
Rows_examined 显著降低。
- 归档记录:将问题和解决方案写入团队知识库,形成长效积累。
8 结论
数据库慢查询优化绝非“加个索引”这么简单,它是一个系统性的工程。牢记这套排障心法:慢查询日志定目标,EXPLAIN 分析抓根因,然后精准施策,最后靠监控守住成果。
加索引是手段,不是终极答案。未经分析的索引添加,不仅可能无效,引入的额外写入负担和空间占用甚至会恶化整体性能。同样地,SQL 语句重写、数据库配置调优等手段,也都各有其适用的战场。运维工程师的价值,正在于能够系统性地运用这些工具和方法,形成自己解决复杂问题的逻辑闭环。
参考资料:
- MySQL 8.0 Reference Manual - Optimizing Queries
- MySQL 8.0 Reference Manual - EXPLAIN Statement
- Percona Blog - pt-query-digest 文档
- High Performance MySQL, 3rd Edition
SHOW [GLOBAL] STATUS, SHOW VARIABLES 相关文档