搜索全站
性能优化 17 分钟阅读

插件拖慢后台列表:postmeta 查询优化与索引补全

后台文章列表要等十几秒?多半是插件按 meta 排序或筛选。本文分析 wp_postmeta 缺少 meta_value 索引的成因,给出 EXPLAIN 诊断、组合索引添加、改 taxonomy 查询与缓存查询结果的完整方案。

U
UFO_PF 2017-08-10 · 阅读 17 分钟
插件 性能优化
阅读提示
本文约 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,性能随条件数指数下降。

后台列表变慢的三类典型来源:

  1. 插件给列表加了可排序/可筛选的自定义列(如浏览量、评分、库存状态),对应 pre_get_posts 里注入 meta_query;
  2. 插件在列表页为每一行查 meta(get_post_meta 在循环里逐行调用),产生 N+1 查询;
  3. 插件注册了 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_%';

常见踩坑

  1. 一上来就加索引:索引只能救「前缀匹配 + 排序」,LIKE '%x%'、多条件 meta_query、OR 关系都救不了,先 EXPLAIN 再加。

  2. 给 meta_value 建全字段索引:longtext 全字段索引会导致 Specified key was too long; max key length is 3072 bytes 报错,必须用前缀长度。

  3. 案例: 某站装了浏览量统计插件,后台列表加了「浏览量」排序列。文章 8000 篇时列表 1.2 秒,涨到 62 万篇时变成 34 秒,编辑同事每次保存完回列表都要等半分钟,有人干脆反复刷新,把数据库打满,前台也跟着 502。最后方案是:加 (meta_key, meta_value) 组合索引 + 后台关闭该列的排序 + 前台热门榜改走缓存 transient,列表降到 0.6 秒。

  4. 忘了 is_main_query():在 pre_get_posts 里不加判断,会把前台导航菜单、小工具里的查询一起改掉,出现莫名其妙的结果缺失。

  5. SQL_CALC_FOUND_ROWS 在 MySQL 8.0.17 起已废弃:WordPress 会自动改用两次查询,但老插件手写 SQL 时仍在用,升级后反而更慢。

  6. N+1 查询:列表里每行调 get_post_meta(),20 行就是 20 次查询。用 update_meta_cache() 或一次性 get_post_meta 批量取。

  7. posts_per_page => -1:插件为了「全部列出」取全表,几万行直接把内存吃满,先 LIMIT 再分页。

  8. 索引加了没生效:表统计信息过期,执行 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 数据库性能优化

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。