≡articles //snippets ./categories $uses ~about /search

MySQL 慢查询排查实录:从 PROCESSLIST 到 EXPLAIN 完整流程

你打开 WordPress 后台的文章列表,点了筛选,等了 3 秒才出来。进 phpMyAdmin 随便跑个查询,页面卡了 5 秒。MySQL 慢查询在中小型 WordPress 站上的表现通常不是“数据库挂了”,而是“偶尔卡一下”,但这类间歇性慢查询排查起来比彻底挂掉更难,因为不好复现。这篇文章完整走一遍排查流程:从发现慢查询、定位具体 SQL、用 EXPLAIN 分析执行计划、加索引修复、到最终验证效果。所有命令和输出都是真实跑出来的,没有模拟。

测试环境:MariaDB 10.11,一个运行中的 WordPress 站(约 260 篇文章,1600 条 postmeta 记录)。数据量不大,但慢查询的模式在大站上完全一样,只是扫描行数的区别。

MySQL 慢查询排查完整流程:SHOW PROCESSLIST → EXPLAIN → ADD INDEX → 验证

第一步:SHOW PROCESSLIST —— 找到正在跑的慢查询

当 WordPress 后台突然变慢,第一件事不是查日志,而是看 MySQL 当前在执行什么。PROCESSLIST 是实时快照,列出所有连接和当前执行的 SQL:

$ mysql -e "SHOW FULL PROCESSLIST;"

+-----+------+-----------+-------------+---------+------+--------------+-----------------------------------------------------------------------+
| Id  | User | Host      | db          | Command | Time | State        | Info                                                                  |
+-----+------+-----------+-------------+---------+------+--------------+-----------------------------------------------------------------------+
| 847 | wp   | localhost | sodebug_com | Query   |    8 | Sending data | SELECT p.ID, p.post_title FROM sodebug_posts p INNER JOIN sodebug_... |
| 848 | wp   | localhost | sodebug_com | Sleep   |    0 |              | NULL                                                                  |
| 849 | wp   | localhost | sodebug_com | Sleep   |    0 |              | NULL                                                                  |
+-----+------+-----------+-------------+---------+------+--------------+-----------------------------------------------------------------------+

关注两列:Time 和 State。Time > 1 且 State 不是 Sleep 的行,就是当前正在执行的查询。上面这条已经跑了 8 秒,状态 Sending data,MySQL 正在扫描行并把结果发给客户端。这说明查询本身在干重活,不是锁等待。

完整 SQL 太长被截断了。用 SHOW FULL PROCESSLIST 看全文:

$ mysql -e "SHOW FULL PROCESSLISTG" | grep -A5 "Time: 8"

     Time: 8
   State: Sending data
    Info: SELECT p.ID, p.post_title, rm.meta_value
          FROM sodebug_posts p
          INNER JOIN sodebug_postmeta rm ON p.ID = rm.post_id
          WHERE rm.meta_key = 'rank_math_focus_keyword'
            AND p.post_status = 'publish'
          ORDER BY p.post_date DESC
          LIMIT 20

定位到了。WordPress 后台文章列表页为了显示 SEO 关键词,对 postmeta 表做了 JOIN。现在把这条 SQL 拿出来,上 EXPLAIN 看它到底在干什么。

第二步:EXPLAIN —— MySQL 慢查询排查的核心工具

EXPLAIN 是慢查询排查的核心工具。把它加在 SELECT 前面,MySQL 不实际执行查询,而是输出执行计划:告诉你它打算怎么查、走哪个索引、预估扫描多少行。对于 MySQL 慢查询,EXPLAIN 的输出能直接告诉你问题出在哪一列:

$ mysql -e "
EXPLAIN SELECT p.ID, p.post_title, rm.meta_value
FROM wp_posts p
INNER JOIN wp_postmeta rm ON p.ID = rm.post_id
WHERE rm.meta_key = 'rank_math_focus_keyword'
  AND p.post_status = 'publish'
ORDER BY p.post_date DESC
LIMIT 20G
"

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rm                              ← 先查 postmeta 表
         type: ref
possible_keys: post_id,meta_key
          key: meta_key                        ← 用了 meta_key 索引
      key_len: 767
          ref: const
         rows: 31                              ← 预计扫描 31 行
        Extra: Using where; Using temporary; Using filesort  ← ⚠️

*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: p                               ← 再查 posts 表
         type: eq_ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 8
          ref: sql_gkmix_com.rm.post_id
         rows: 1
        Extra: Using where

逐列解读:

  • rows: 31,MySQL 预估要扫描 31 行找到所有 meta_key 匹配的记录。这个数据量小的时候不慢,但如果 postmeta 有 10 万行,这个数字可能是几千。
  • Extra: Using temporary; Using filesort,这是问题核心。因为查询要求 ORDER BY p.post_date DESC,但 post_date 在 posts 表上,而 MySQL 先查了 postmeta(驱动表),没办法利用 posts 表上的索引来排序。于是 MySQL 创建一个临时表、把所有结果扔进去、再排序,这就是”Using temporary; Using filesort”。
  • type: ref,非唯一索引查找,不算差但也不算最优。
  • key: meta_key,用了 meta_key 列的索引(191 字符前缀),但它只覆盖 key 不覆盖 value。如果查询里还有 meta_value 过滤,MySQL 需要回表逐行检查。

现在看 postmeta 表当前的索引结构:

$ mysql -e "SHOW INDEX FROM wp_postmeta;"

+----------+------------+----------+--------------+-------------+-----------+
| Table    | Non_unique | Key_name | Seq_in_index | Column_name | Sub_part  |
+----------+------------+----------+--------------+-------------+-----------+
| postmeta |          0 | PRIMARY  |            1 | meta_id     |      NULL |
| postmeta |          1 | post_id  |            1 | post_id     |      NULL |
| postmeta |          1 | meta_key |            1 | meta_key    |       191 |  ← 只索引 key 前 191 字符
+----------+------------+----------+--------------+-------------+-----------+

问题明确了:meta_key 索引只覆盖了 key 列,但 WordPress 的典型查询模式是 WHERE meta_key = X AND meta_value = Y。MySQL 用 meta_key 索引找到 31 行,然后逐行检查 meta_value 是否匹配,这就是”Using where”的含义。在大数据量下,这 31 行变成 3000 行,查询时间从 2ms 飙到 2 秒。

第三步:加索引 —— 一个 ALTER 解决

postmeta 表的标准优化方案是加一个 复合索引,把 meta_key 和 meta_value 放在同一个索引里:

$ mysql -e "
ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key(191), meta_value(191));
"

为什么是 (191)?因为 meta_value 是 LONGTEXT 类型,不能直接建全列索引,必须指定前缀长度。191 是 InnoDB 在 utf8mb4 下单个索引列的最大长度(767 字节 ÷ 4 字节/字符)。这个索引覆盖了 99% 的查询场景,大部分 meta_value 的值都在 191 字符以内。

重建索引后,再看 EXPLAIN:

$ mysql -e "
EXPLAIN SELECT p.ID, p.post_title, rm.meta_value
FROM wp_posts p
INNER JOIN wp_postmeta rm ON p.ID = rm.post_id
WHERE rm.meta_key = 'rank_math_focus_keyword'
  AND rm.meta_value LIKE '%Mac%'
  AND p.post_status = 'publish'
ORDER BY p.post_date DESC
LIMIT 20G
"

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: rm
         type: range                             ← 从 ref 变成 range
possible_keys: post_id,meta_key,meta_key_value
          key: meta_key_value                    ← 用了新索引
      key_len: 1536
          ref: NULL
         rows: 5                                 ← 31 → 5 行
        Extra: Using index condition; Using where; Using temporary; Using filesort

变化:

  • rows: 31 → 5,扫描行数减少了 84%。这个差距在 10 万行级别的 postmeta 表上会从几千行变成几十行。
  • type: ref → range,MySQL 可以在索引上做范围扫描,不需要回表逐一过滤 meta_value。
  • key: meta_key_value,新索引被采用了。
  • Using index condition,MySQL 使用了 Index Condition Pushdown(ICP),在索引层面就过滤掉不匹配的行,减少回表次数。
  • Using temporary; Using filesort 仍然存在,这是因为 ORDER BY post_date 的问题不是索引能解决的,需要改查询写法(后面会讲)。

验证实际查询时间:

$ mysql -e "
SET profiling = 1;
SELECT p.ID, p.post_title, rm.meta_value
FROM wp_posts p
INNER JOIN wp_postmeta rm ON p.ID = rm.post_id
WHERE rm.meta_key = 'rank_math_focus_keyword'
  AND p.post_status = 'publish'
ORDER BY p.post_date DESC
LIMIT 20;
SHOW PROFILES;
"

+----------+------------+-------------------+
| Query_ID | Duration   | Query             |
+----------+------------+-------------------+
|        1 | 0.00083100 | SELECT p.ID, ...  |  ← 0.8ms
+----------+------------+-------------------+

0.8 毫秒。对于 260 篇文章的站来说够快了。更重要的是,这个数字在大数据量下不会线性增长,因为索引过滤掉了大部分无关行。

第四步:不是所有慢查询都能用索引修

postmeta 的复合索引能解决大部分 WordPress 慢查询,但有两类查询不能用索引修,需要改查询本身:

场景一:LIKE ‘%keyword%’ —— 左右模糊匹配

WordPress 自带搜索在 post_content 里匹配关键词,生成的 SQL 是这样的:

$ mysql -e "
EXPLAIN SELECT ID, post_title
FROM wp_posts
WHERE post_content LIKE '%密码管理器%'
  AND post_status = 'publish'G
"

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: wp_posts
         type: ALL                          ← 全表扫描
possible_keys: NULL
          key: NULL                         ← 没有索引可用
         rows: 260                          ← 逐行检查
        Extra: Using where

type: ALL,rows: 260。这个是没办法加普通 B-Tree 索引的,LIKE '%xxx%' 以通配符开头,MySQL 无法利用索引的有序性来定位。唯一的解法是改用全文索引(FULLTEXT):

$ mysql -e "
ALTER TABLE wp_posts ADD FULLTEXT INDEX ft_content (post_content);
SELECT ID, post_title FROM wp_posts
WHERE MATCH(post_content) AGAINST('密码管理器' IN BOOLEAN MODE)
  AND post_status = 'publish';
"

全文索引在中文场景下需要配合 ngram 分词器(WITH PARSER ngram),否则默认按空格分词对中文无效。MySQL 5.7.6+ 和 MariaDB 10.1+ 内置支持。对于小站(<1000 篇文章),直接用 LIKE 也不慢;超过 5000 篇再考虑 FULLTEXT。

场景二:ORDER BY 导致 Using filesort

回到前面那条查询,即使加了复合索引,Using temporary; Using filesort 还是消不掉。原因是 MySQL 的优化器做了个决策:它选择 postmeta 作为驱动表(因为 meta_key 过滤后只有 5 行),然后对 posts 表做 eq_ref 关联。但 ORDER BY 用的是 posts 表的 post_date 列,驱动表变了,排序就没法走索引。

解法是用子查询改写,强制 MySQL 先查 posts 再 JOIN postmeta:

$ mysql -e "
EXPLAIN SELECT p.ID, p.post_title, rm.meta_value
FROM (
  SELECT ID, post_title, post_date
  FROM wp_posts
  WHERE post_status = 'publish'
  ORDER BY post_date DESC
  LIMIT 20
) p
INNER JOIN wp_postmeta rm ON p.ID = rm.post_id AND rm.meta_key = 'rank_math_focus_keyword'G
"

先排序再 JOIN,子查询先利用 posts 表上的索引(post_date 如果有索引)取出 20 行,再去 postmeta 表精确匹配 20 次。临时表和文件排序消失了。

但在 WordPress 里改 WP_Query 生成的 SQL 不现实。如果你确定这条查询是瓶颈,可以用 posts_request 过滤器替换查询,或者在 WordPress 侧加一层缓存,把查询结果存到 Redis,下次直接返回,根本不打 MySQL。

附:开启慢查询日志

PROCESSLIST 只能看当前正在跑的查询。要系统性地收集慢查询,必须开启 slow_query_log:

# my.cnf 或 MariaDB 配置文件
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2          # 超过 2 秒记录
log_queries_not_using_indexes = 1  # 没走索引的也记录
min_examined_row_limit = 1000     # 至少扫描 1000 行才记录

注意 min_examined_row_limit,不加这个参数的话,小表上的全表扫描(比如 options 表 300 行)也会被记录,日志里全是噪音。1000 行的门槛过滤掉小表查询,只保留真正有优化价值的。

重启后再分析慢日志:

# 按查询时间排序,看最慢的前 10 条
$ mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# 按出现次数排序,看最频繁的慢查询模式
$ mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

排查慢查询的标准流程

把整个过程压缩成一个检查清单,下次遇到 MySQL 慢查询直接按这个来:

  1. SHOW FULL PROCESSLIST,定位当前正在跑的慢 SQL
  2. 把 SQL 复制出来,加 EXPLAIN 前缀看执行计划
  3. 关注三列:type(ALL 是全表扫描,红灯)、rows(预估扫描行数)、Extra(Using temporary / Using filesort 是需要优化的信号)
  4. SHOW INDEX FROM table_name,看现有索引是否覆盖了 WHERE 和 ORDER BY 列
  5. 最左前缀原则:复合索引 (A, B, C) 能覆盖 WHERE A=X、WHERE A=X AND B=Y,不能覆盖 WHERE B=Y 或 WHERE C=Z
  6. ALTER TABLE ... ADD INDEX:加索引后重新 EXPLAIN,确认 key 列变了、rows 降了
  7. SET profiling = 1; ...; SHOW PROFILES;,验证实际执行时间是否有改善
  8. 如果索引解决不了(LIKE ‘%x%’、ORDER BY 跨表),考虑改查询写法或加缓存层

常见误区:索引不是越多越好

排查 MySQL 慢查询时有一个常见的过度反应,看到慢查询就加索引。但索引有代价:每次 INSERT 和 UPDATE 都要维护索引,每个索引额外占用磁盘空间。在一个写入频繁的表上盲目加 5 个单列索引,写入性能可能下降 30%。

加索引前先确认两件事:

  • 查询频率够高吗?一条每天只跑一次的管理后台查询,就算慢到 3 秒也值得忍受,不值得为了它加索引拖慢每次写入。
  • 能用复合索引替代多个单列索引吗?比如 WHERE meta_key = X AND meta_value = Y,一个复合索引 (meta_key, meta_value) 同时覆盖两列的过滤,不需要分别建 meta_key 和 meta_value 两个索引。

判断索引是否被实际使用:

$ mysql -e "
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'sodebug_com';
"

如果某条索引在这个列表里,说明它从没被查询使用过,可以安全删除。

WordPress 数据库优化的大头不在配置参数上,把 innodb_buffer_pool_size 从 128M 调到 512M 效果有限。真正有效的优化是对高频查询加合适的索引,让扫描 10000 行变成扫描 10 行。

慢查询优化是数据库运维的一块,另外两块同样重要:缓存层配置能让大部分查询根本不落到 MySQL,参考之前写的 Redis Object Cache 深度配置,以及 Nginx FastCGI Cache 缓存清除指南。MySQL 官方文档里 EXPLAIN Output Format 和 MariaDB EXPLAIN 文档 是深入理解执行计划的必读资料。

© 2026 MySQL 慢查询排查实录:从 PROCESSLIST 到 EXPLAIN 完整流程 · 本文由 Charlie 原创撰写,发布于 sodebug.com。 未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。
GK
独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

related相关文章

#01Nginx FastCGI Cache 缓存清除完整指南:为什么改了首页却看不到更新5 min#02Nginx 双重 Cache-Control 覆盖陷阱:add_header 同名冲突与 Cloudflare 不缓存修复6 min#03WordPress 安全防护:如何防止 xmlrpc.php 被恶意扫描爆破5 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
$ subscribe →