本文约 6,572 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:一个下拉筛选,卡了 14 秒
后台「文章」列表打开要 8–14 秒,翻页更慢;前台某个筛选页 TTFB 3 秒起。服务器 CPU 不高、内存够用,MySQL 的慢查询日志里躺着一条执行 12 秒的 SQL,粗略长这样:
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
WHERE 1=1
AND wp_postmeta.meta_key = '_views'
AND wp_posts.post_type = 'post'
ORDER BY wp_postmeta.meta_value+0 DESC
LIMIT 0, 20;
问题不在 WordPress,在 wp_postmeta 这张表的索引结构:它只有 post_id 和 meta_key 的索引,没有 meta_value 的索引。只要查询涉及「按 meta 值排序」或「按 meta 值筛选」,MySQL 就只能把匹配到的所有行捞出来在内存里排序(Using filesort)。数据量从 5000 涨到 50 万时,耗时是几十倍地涨。
原理分析:wp_postmeta 的索引结构
WordPress 默认的 wp_postmeta 表结构(以 MySQL 5.7+/MariaDB 10.x 为例):
| 索引名 | 字段 | 能否加速 meta_value 查询 |
|---|---|---|
| PRIMARY | meta_id |
不能 |
post_id |
post_id |
只能先定位某篇文章的元数据 |
meta_key |
meta_key(191) |
只能筛选「有哪些文章有这个 key」 |
注意 meta_value 的类型是 longtext,MySQL 对它建索引必须指定前缀长度;而且即使用 meta_value 筛选数值,因为字段是字符串,ORDER BY meta_value 是字符串排序(”9″ > “10”),所以 WordPress 提供了 meta_value_num 类型,生成 meta_value+0 的表达式排序——代价是索引彻底失效。
WordPress 的 WP_Meta_Query 生成多条件 meta 查询时,会为每一个条件做一次 INNER JOIN wp_postmeta AS mt1, wp_postmeta AS mt2...,三个条件的查询就是三表联查加 GROUP BY wp_posts.ID,性能随条件数指数下降。
后台列表变慢的三类典型来源:
- 插件给列表加了可排序/可筛选的自定义列(如浏览量、评分、库存状态),对应
pre_get_posts里注入meta_query; - 插件在列表页为每一行查 meta(
get_post_meta在循环里逐行调用),产生 N+1 查询; - 插件注册了
posts_orderby强制按 meta 排序,前台后台一起生效。
保姆级实操
第一步:用 EXPLAIN 确认瓶颈
先拿到慢 SQL(开启 MySQL 慢查询日志或用 Query Monitor 插件看后台列表页的查询),然后:
-- 打开慢查询(临时,生产环境用完记得关)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 复现一次后台列表页后,用 mysqldumpslow 汇总
-- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
EXPLAIN SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
WHERE 1=1
AND wp_postmeta.meta_key = '_views'
AND wp_posts.post_type = 'post'
ORDER BY wp_postmeta.meta_value + 0 DESC
LIMIT 0, 20;
-- 关注三列:type(最好 ref/range)、rows(扫描行数)、Extra
-- 出现 Using temporary; Using filesort 即命中本病
第二步:加组合索引(最小改动、收益最大)
-- 先看现有索引,避免重复建
SHOW INDEX FROM wp_postmeta;
-- 为 (meta_key, meta_value) 建组合索引,覆盖"某 key 下按值排序/筛选"
-- utf8mb4 下每字符 4 字节,前缀 191 不会超过旧版 767 字节限制
ALTER TABLE wp_postmeta ADD INDEX mmf_key_value ( meta_key(191), meta_value(64) );
-- 如果主要按数值排序,额外建 (meta_key, post_id) 可以减少回表
ALTER TABLE wp_postmeta ADD INDEX mmf_key_post ( meta_key(191), post_id );
-- InnoDB 大前缀(MySQL 5.7.9+ / MariaDB 10.2+ 默认开启),可用更长前缀
ALTER TABLE wp_postmeta ADD INDEX mmf_key_value_long ( meta_key(191), meta_value(191) );
ANALYZE TABLE wp_postmeta;
加完索引再跑一次 EXPLAIN,rows 应该从几十万降到几百,Extra 里的 Using filesort 也会消失(至少对排序部分)。
注意两点:meta_value 是 longtext,前缀索引只对「前缀匹配」有效,LIKE '%keyword%' 这种中间匹配加索引也没用,必须换方案;索引会让写入变慢、表体积变大,wp_postmeta 上不要堆叠超过 3–4 个自定义索引。
第三步:把 meta 查询改成 taxonomy 查询
如果筛选条件是有限的枚举值(状态、等级、地区、是否推荐),存成 meta 是错的设计。wp_term_relationships 有完整索引,查询快一个数量级:
// 慢:meta 筛选
$args = [
'post_type' => 'product',
'meta_query' => [
[ 'key' => '_stock_status', 'value' => 'instock', 'compare' => '=' ],
],
];
// 快:改用自定义分类法
register_taxonomy( 'stock_status', 'product', [
'public' => false,
'show_ui' => false,
'rewrite' => false,
'meta_box_cb' => false,
] );
$args = [
'post_type' => 'product',
'tax_query' => [
[
'taxonomy' => 'stock_status',
'field' => 'slug',
'terms' => 'instock',
],
],
];
第四步:只在必要时才让插件接管后台查询
很多插件的 pre_get_posts 没做 is_admin() 与 is_main_query() 判断,导致后台列表、前台导航菜单查询全被注入 meta_query。用更晚的优先级把它拦下来:
<?php
// mu-plugins/admin-query-guard.php
add_action( 'pre_get_posts', function ( WP_Query $q ) {
// 只处理后台主查询
if ( ! is_admin() || ! $q->is_main_query() ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || 'edit-post' !== $screen->id ) {
return;
}
// 1) 去掉按 meta 排序,改用已有的索引字段
if ( 'meta_value_num' === $q->get( 'orderby' ) ) {
$q->set( 'orderby', 'date' );
$q->set( 'order', 'DESC' );
}
// 2) 拆掉多余的 meta_query,只保留插件真正需要的那一条
$mq = $q->get( 'meta_query' );
if ( is_array( $mq ) && count( $mq ) > 2 ) {
$q->set( 'meta_query', array_slice( $mq, 0, 1 ) );
}
// 3) 后台列表不需要总数,SQL_CALC_FOUND_ROWS 是最大元凶之一
$q->set( 'no_found_rows', true );
}, 100 );
no_found_rows => true 能去掉 SQL_CALC_FOUND_ROWS,在大表上通常能省掉一半耗时(代价是分页总数要自己算或用游标分页)。
第五步:缓存查询结果
筛选条件有限时,用 transient 缓存结果 ID 列表:
function mmf_get_top_viewed_ids( $limit = 20 ) {
$key = 'mmf_top_viewed_' . (int) $limit;
$ids = get_transient( $key );
if ( false !== $ids ) {
return $ids;
}
$ids = get_posts( [
'post_type' => 'post',
'posts_per_page' => $limit,
'orderby' => 'meta_value_num',
'meta_key' => '_views',
'fields' => 'ids',
'no_found_rows' => true,
] );
set_transient( $key, $ids, 15 * MINUTE_IN_SECONDS );
return $ids;
}
注意 transient 也要设过期时间,否则会堆在 wp_options 里(WordPress transients 清理)。
第六步:清理已有的垃圾 meta
-- 看看哪个 meta_key 最占空间
SELECT meta_key, COUNT(*) AS cnt
FROM wp_postmeta
GROUP BY meta_key
ORDER BY cnt DESC
LIMIT 20;
-- 已被卸载的插件留下的孤儿 meta(确认无用后再删!)
SELECT COUNT(*) FROM wp_postmeta WHERE meta_key LIKE '_old_plugin_%';
-- 删除前务必先备份
-- DELETE FROM wp_postmeta WHERE meta_key LIKE '_old_plugin_%';
常见踩坑
-
一上来就加索引:索引只能救「前缀匹配 + 排序」,
LIKE '%x%'、多条件meta_query、OR关系都救不了,先EXPLAIN再加。 -
给
meta_value建全字段索引:longtext全字段索引会导致Specified key was too long; max key length is 3072 bytes报错,必须用前缀长度。 -
案例: 某站装了浏览量统计插件,后台列表加了「浏览量」排序列。文章 8000 篇时列表 1.2 秒,涨到 62 万篇时变成 34 秒,编辑同事每次保存完回列表都要等半分钟,有人干脆反复刷新,把数据库打满,前台也跟着 502。最后方案是:加
(meta_key, meta_value)组合索引 + 后台关闭该列的排序 + 前台热门榜改走缓存 transient,列表降到 0.6 秒。 -
忘了
is_main_query():在pre_get_posts里不加判断,会把前台导航菜单、小工具里的查询一起改掉,出现莫名其妙的结果缺失。 -
SQL_CALC_FOUND_ROWS在 MySQL 8.0.17 起已废弃:WordPress 会自动改用两次查询,但老插件手写 SQL 时仍在用,升级后反而更慢。 -
N+1 查询:列表里每行调
get_post_meta(),20 行就是 20 次查询。用update_meta_cache()或一次性get_post_meta批量取。 -
posts_per_page => -1:插件为了「全部列出」取全表,几万行直接把内存吃满,先LIMIT再分页。 -
索引加了没生效:表统计信息过期,执行
ANALYZE TABLE wp_postmeta;刷新。
总结
判断标准:只要慢查询里出现 wp_postmeta + ORDER BY meta_value / Using filesort,就是索引或数据模型的问题。
行动清单:开慢查询日志抓出真实 SQL 并 EXPLAIN;给 wp_postmeta 加 (meta_key, meta_value) 组合索引(前缀别超长,索引总数控制在 4 个以内);枚举型筛选条件迁到自定义分类法;后台列表用 pre_get_posts(优先级 100)去掉 meta 排序、精简 meta_query、开 no_found_rows;循环里的 get_post_meta 改成批量预取;高频榜单结果用 transient 缓存;清理被卸载插件遗留的孤儿 meta(先备份再删)。优化完记得用 Query Monitor 复核查询次数与耗时,别只看感觉。WordPress 数据库性能优化
