插件查询从 800ms 压到 12ms:我如何用"查询契约"把 WP_Query 的贪婪加载关进笼子里
上周接了个需求,要在后台展示"用户最近互动过的 20 个带特定 meta 的帖子",顺手写了段熟悉的代码:
$posts = get_posts([
'post_type' => 'any',
'posts_per_page' => 20,
'meta_query' => [
[
'key' => '_user_interaction',
'value' => $user_id,
],
],
'orderby' => 'modified',
]);
本地 200 条数据跑得飞快,上线后 8 万条帖子直接炸锅。 profiling 一看,SQL_CALC_FOUND_ROWS 全表扫,meta 表 JOIN 出笛卡尔积,更离谱的是 'post_type' => 'any' 把 revision、attachment 全拉进来了。
这不是 WP_Query 的锅,是我没给它立规矩。后来我给团队定了个"查询契约",核心就三条红线:
一、字段白名单:只许 SELECT 什么,不许碰什么
默认 get_posts 会 SELECT *,然后 post_content 里塞着 5MB 的 base64 图。现在强制加 'fields' => 'ids' 或自定义 WP_Query 的 posts_fields 钩子做投影裁剪:
add_filter( 'posts_fields', function( $fields, $query ) {
if ( $query->get( '_my_plugin_minimal' ) ) {
return "{$GLOBALS['wpdb']->posts}.ID, {$GLOBALS['wpdb']->posts}.post_title";
}
return $fields;
}, 10, 2 );
配合 'no_found_rows' => true 关掉计数,'update_post_meta_cache' => false 和 'update_post_term_cache' => false 禁用贪婪缓存预热。这三行组合拳,查询时间直接从 800ms 掉到 45ms。
二、meta 查询的"索引友好"改造
上面那个 meta_query 是 post_id + meta_key + meta_value 的组合,但 MySQL 只会用 meta_key 单列索引,value 上的 LIKE 或范围查询照样全表。我的解法是把"高频筛选维度"反范化到主表自定义列,或者建影子表。
这次选了轻量方案:给 wp_posts 加了个 last_interactor 列,用 wp_insert_post 的 post_modified 钩子同步维护。查询变成:
$posts = get_posts([
'post_type' => 'my_custom_type', // 明确类型,拒绝 any
'posts_per_page' => 20,
'orderby' => 'modified',
'date_query' => [ 'after' => '-30 days' ], // 额外时间窗口剪枝
'meta_key' => 'last_interactor', // 现在走覆盖索引
'meta_value' => $user_id,
'no_found_rows' => true,
'fields' => 'ids',
]);
但这里埋了个坑:meta_key + meta_value 的查询在 5.7 以下不会用覆盖索引,因为 meta_value 是 LONGTEXT。升级 8.0 或者改表结构,二选一。
三、结果缓存的"分层策略",不是无脑 transients
之前团队爱用 set_transient 一锤子缓存,结果用户 A 看了缓存,用户 B 的权限过滤没生效。现在拆三层:
1. 原始数据层(对象缓存):上面 get_posts 的结果用 wp_cache_set( "user_feed:{$user_id}", $ids, 'my_plugin', 300 ),TTL 5 分钟。关键:存的是 ID 数组,不是完整对象。
2. 渲染层缓存(页面片段):用 wp_cache_add 做 memoization,同一次请求内重复调用直接返回。
3. 静态资源指纹(长期不变):CSS/JS 用 filemtime 做版本号,配合 wp_enqueue_script 的 $ver 参数,CDN 缓存一年不带怕的。
wp_enqueue_style(
'my-plugin-admin',
plugins_url( 'assets/admin.css', __FILE__ ),
[],
filemtime( plugin_dir_path( __FILE__ ) . 'assets/admin.css' ) // 内容变,指纹变
);
静态资源加载的"懒到最后一刻"原则
后台插件最容易犯的错:每个页面都加载全量 JS。现在我的加载器长这样:
add_action( 'admin_enqueue_scripts', function( $hook ) {
$manifest = [
'toplevel_page_my_plugin' => ['dashboard.js', 'chart.js'],
'my-plugin_page_settings' => ['settings.js'],
];
if ( ! isset( $manifest[ $hook ] ) ) {
return; // 不匹配?一个字节都不加载
}
foreach ( $manifest[ $hook ] as $file ) {
wp_enqueue_script(
"my-plugin-{$file}",
plugins_url( "assets/{$file}", __FILE__ ),
[ 'wp-api', 'wp-element' ], // 只声明真实依赖
filemtime( plugin_dir_path( __FILE__ ) . "assets/{$file}" ),
true // 丢 footer
);
}
} );
chart.js 从首屏砍掉,只在 dashboard 页出现。settings 页少加载 180KB。
最后压到 12ms 的那一下
对象缓存上了 Redis 之后,发现 wp_cache_get 也有序列化开销。ID 数组改成逗号分隔字符串存,get 之后 explode 解析,比 serialize/unserialize 快 3 倍——这属于 PHP 层面的 micro-optimization,但累积起来就是从 45ms 到 12ms 的最后一脚。
你们插件里有哪种"看起来正常、跑起来要命"的查询模式?贴出来一起拆。