网站变慢的元凶,往往藏在数据库里
很多站长的优化路径是这样的:先加内存、再换 SSD、然后上缓存插件、最后换成 LNMP 架构,速度还是不满意。折腾一圈之后打开 top 一看,CPU 高得吓人的其实是 mysqld,而 Nginx 和 PHP-FPM 反倒很闲。这时候问题就很清楚了——瓶颈在数据库的慢查询上。
对个人站来说,数据库调优的投入产出比极高:你不需要换服务器,不需要重写代码,只要找出那几条拖后腿的 SQL,加个索引或者改个写法,页面加载时间可能直接从 3 秒降到 300 毫秒。而找出慢查询的工具链非常成熟:MySQL 自带的慢查询日志(slow query log)加上 Percona 的 pt-query-digest,可以说是每个站长都该掌握的基础技能。
本文从零开始,讲清楚怎么开启慢查询日志、怎么用 pt-query-digest 分析、怎么读懂它输出的报告、以及几种最常见的慢查询模式和对应的优化方法。
第一步:开启慢查询日志
慢查询日志默认是关闭的。开启有两种方式:临时用 SQL 命令(重启失效,适合调试),或者写进配置文件(永久生效,推荐)。
-- 临时开启(当前会话/全局生效,重启失效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过 1 秒记入日志
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 没走索引的也记录
-- 查看当前状态
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
永久生效则改 /etc/mysql/mysql.conf.d/mysqld.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_slow_admin_statements = 1
log_slow_replica_statements = 1
min_examined_row_limit = 100
参数说明:
long_query_time:单位秒,可带小数(比如 0.5)。个人站建议先设 1 秒,抓大问题;优化到一定程度后再降到 0.5 秒或 0.2 秒。设成 0 会记录所有查询,日志爆炸,不要用。log_queries_not_using_indexes:记录没走索引的查询。注意它对小表也会记录,日志可能非常吵,高峰期建议关掉,或者配合min_examined_row_limit使用。min_examined_row_limit:扫描行数低于这个值的查询不记录,能过滤掉"没走索引但表很小"的噪音。
配置改完要重启 MySQL 或用 SET GLOBAL 动态生效。日志文件记得配 logrotate,否则长期运行可能涨到几个 G。Debian/Ubuntu 下装 mysql-server 时会自动带 /etc/logrotate.d/mysql-server,检查一下路径是否对得上。
第二步:看懂慢查询日志的原始格式
分析之前先瞄一眼原始日志,心里有个数:
# Time: 2026-10-02T09:15:01.123456Z
# User@Host: webuser[webuser] @ localhost [] Id: 8821
# Query_time: 3.204517 Lock_time: 0.000112 Rows_sent: 20 Rows_examined: 1284390
SET timestamp=1759396501;
SELECT id, title, content FROM wp_posts
WHERE post_type='post' AND post_status='publish'
ORDER BY post_date DESC LIMIT 20;
关键字段:
Query_time:这条 SQL 执行耗时,3.2 秒。Lock_time:等锁的时间。Rows_sent:返回了多少行。Rows_examined:扫描了多少行。这个值是重点——扫描了 128 万行只返回 20 行,说明索引严重缺失,典型的全表扫描。
一条查询的 Rows_examined / Rows_sent 比值越大,越说明需要索引优化。理想情况是相近,比如扫描 25 行返回 20 行。
第三步:安装 pt-query-digest
原始日志能看,但日志一多就抓瞎。Percona Toolkit 里的 pt-query-digest 能把日志聚合成一份清晰的报告,按总耗时排序,一眼看出哪条 SQL 最该优化。
# Debian/Ubuntu
apt-get install percona-toolkit
# CentOS/RHEL
yum install percona-toolkit
# 或者直接下二进制
如果软件源里没有,也可以从 Percona 官网下载 .deb / .rpm 包,或者用 cpana 装。pt-query-digest 本身是 Perl 脚本,依赖 perl-DBI,装完 pt-query-digest --version 能输出版本号就算成功。
第四步:生成分析报告
最常用的一条命令:
pt-query-digest /var/log/mysql/slow.log > /tmp/slow_report.txt
less /tmp/slow_report.txt
也可以直接分析正在跑的、或者指定时间段的日志:
# 只看最近 1 小时的日志(先用 head 或 tail 定位)
pt-query-digest --since '1h' /var/log/mysql/slow.log
# 指定某个查询的详细报告
pt-query-digest --filter '$event->{arg} =~ m/^SELECT/i' /var/log/mysql/slow.log
# 从 tcpdump 抓的包里分析(进阶,可分析未开启慢日志的库)
报告分成三大块:总体概览、Profile、单条查询详情。逐一看。
第五步:读懂报告第一部分——总体概览
# Overall: 1.23k total, 45 unique, 0.06 QPS, 0.71x concurrency
# Time range: 2026-10-02 08:00:00 to 2026-10-02 14:00:00
# Attribute total min max avg 95% stddev median
# ============ ======= ======= ======= ======= ======= ======= =======
# Exec time 1245s 1s 28s 1s 2s ...
# Lock time 3s 0 120ms 2ms
# Rows sent 24.6k 0 2.3k 20
# Rows examine 89.2M 0 1.2M 72.4k
# Query size 621k 120 8.2k 505
这里最值得关注的是 Rows examine 总量 和 Exec time 总量。如果 Rows examine 是个巨大的数字(几千万上亿),说明存在大量全表扫描,索引是主要矛盾。
如果日志里有 45 个 unique query,你不用一个一个看——报告会按"总耗时"排序,往往前 3 条就占了 60%~80% 的时间。这就是二八法则在数据库调优里的体现。
第六步:读懂报告第二部分——Profile(谁最耗时)
# Profile
# Rank Query ID Response time Calls R/Call Items
# ==== ================== =============== ===== ====== =====
# 1 0x8A9C1D2E3F4A5B6C 812.4s 65.2% 120 6.77s SELECT wp_posts
# 2 0x1B2C3D4E5F6A7B8C 340.1s 27.3% 80 4.25s SELECT wp_options
# 3 0x9F8E7D6C5B4A3F2E 60.2s 4.8% 200 0.30s UPDATE wp_postmeta
排名第一的这条 SELECT wp_posts,120 次调用、总耗时 812 秒、平均每次 6.77 秒,占了总耗时的 65%。优化它,收益立竿见影。
典型的 WordPress 站,慢查询重灾区往往是:wp_options(autoload 选项太多)、wp_postmeta(meta 查询)、wp_posts 的复杂查询、以及 ORDER BY ... LIMIT 没有索引的情况。
第七步:读懂报告第三部分——单条查询详情
# Query 1: 0.14 QPS, 0.71x concurrency, ID 0x8A9C1D2E3F4A5B6C at byte 8821
# This item is included in the report because it matches --limit.
# Scores: V/M = 0.44
# Time range: ...
# Attribute pct total min max avg 95% stddev median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count 10 120
# Exec time 65 812s 2s 28s 7s 9s 4s 6s
# Lock time 0 40ms 0 12ms 333us 280us ...
# Rows sent 0 2.4k 0 40 20 20 0 20
# Rows examine 91 81.2M ... 1.2M 692k ...
# Query size 1 14.4k 120 120 120
# String:
# Databases zz1984
# Hosts localhost
# Users webuser
# Query_time distribution
# 1us
# ...
# 100ms #
# 1s ################################################################
# 10s+ ####
# Tables
# SHOW TABLE STATUS LIKE 'wp_posts'\G
# SHOW CREATE TABLE `wp_posts`\G
# EXPLAIN /*!50100 PARTITIONS*/
SELECT id, title, content FROM wp_posts
WHERE post_type='post' AND post_status='publish'
ORDER BY post_date DESC LIMIT 20\G
这条最关键:
- Rows examine 81.2M / Rows sent 2.4k:扫描量与返回量差了三万多倍,铁定全表扫描。表应该是几十万行到了百万级。
- Query_time distribution:能看到耗时分布,1s 到 10s 占大头,说明是稳定的慢,不是偶发。
- 报告最后会贴出完整的 SQL,并且提示你可以用
EXPLAIN分析。Percona Toolkit 很贴心,直接给出了SHOW CREATE TABLE和EXPLAIN的模板。
第八步:用 EXPLAIN 找出缺的索引
拿到慢查询的原始 SQL,直接丢给 EXPLAIN:
EXPLAIN SELECT id, title, content FROM wp_posts
WHERE post_type='post' AND post_status='publish'
ORDER BY post_date DESC LIMIT 20;
输出里重点看这几列:
| 列名 | 要看什么 |
|---|---|
| type | ALL 表示全表扫描,最差;index 次差;range 尚可;ref/eq_ref/const 最好 |
| key | 实际用到的索引,NULL 表示没用索引 |
| rows | 预估扫描行数,这个数要尽量小 |
| Extra | 出现 "Using filesort"(额外排序)或 "Using temporary"(临时表)要警惕 |
针对上面的查询,合适的索引是:
ALTER TABLE wp_posts
ADD INDEX idx_type_status_date (post_type, post_status, post_date);
为什么是这个顺序?因为 MySQL 索引的最左前缀原则:WHERE 里的等值条件 post_type、post_status 放前面,ORDER BY post_date 放最后,这样既能快速过滤又能直接利用索引有序性避免 filesort。加了索引后再 EXPLAIN,type 会变成 range 或 ref,rows 大幅下降,Extra 里的 filesort 也会消失。
几种最常见的慢查询模式与对策
模式一:SELECT * 加上深分页。 LIMIT 100000, 20 这种写法 MySQL 会先扫描前 10 万行再丢弃,越翻越慢。改成延迟关联或游标分页:
-- 慢
SELECT * FROM wp_posts ORDER BY id LIMIT 100000, 20;
-- 快:先用索引拿主键,再回表
SELECT p.* FROM wp_posts p
JOIN (SELECT id FROM wp_posts ORDER BY id LIMIT 100000, 20) t ON p.id = t.id;
模式二:函数作用在索引列上。 WHERE DATE(created_at) = '2026-10-02' 会让索引失效,应改成范围查询:
WHERE created_at >= '2026-10-02 00:00:00'
AND created_at < '2026-10-03 00:00:00'
模式三:隐式类型转换。 字段是 varchar 却传了数字,或者反过来,索引都会失效。WHERE user_id = 123(字段是字符串)会全表扫。检查字段类型和传参类型是否一致。
模式四:OR 条件。 WHERE a=1 OR b=2 往往无法用索引,可以拆成两个查询 UNION ALL。
第九步:把优化变成常态
调优不是一次性的。建议建立这样的循环:
- 开启慢查询日志,
long_query_time从 1 秒起步。 - 每周跑一次
pt-query-digest,看有没有新的慢查询冒出来。 - 对 Top 3 的慢查询做
EXPLAIN,该加索引加索引,该改写法改写法。 - 定期用
pt-duplicate-key-checker清理冗余索引,用pt-index-usage找出没被用到的索引。
另外提一句:索引不是越多越好。每个索引都会拖慢写入(INSERT/UPDATE),也会占用磁盘。个人站的表通常不大,几个精准的复合索引就够,不要盲目照搬大厂方案。
写在最后
数据库调优听起来高大上,但落到个人站上其实就是"找到那条最慢的 SQL,给它加个对的索引"。慢查询日志负责找,pt-query-digest 负责排序,EXPLAIN 负责诊断,ALTER TABLE 负责解决——这套组合拳的成本几乎为零,收益却立竿见影。与其不停加配置升级服务器,不如先花一个小时把慢查询理一遍,你会发现很多性能问题根本不缺硬件,缺的只是一个索引。