从 80ms 到 3ms:我把插件后台列表页 query 拆成了三份才摸到 WordPress 查询的"性能天花板"

插件开发 15 浏览 0 回复 返回上级

上周接了个老项目维护,后台订单列表 2000 条数据分页加载要 80ms,用户天天骂。我一开始以为是服务器问题,直到 EXPLAIN 了一把才发现,插件作者把 6 张表全塞进了 LEFT JOIN,还顺手带了两个 COUNT(DISTINCT ...) 做聚合统计。

WordPress 的 WP_Query$wpdb->get_results 不是不能扛,但很多人写插件时有个惯性:一个接口把所有事办完。我的改造分了三步走,最后稳定到 3ms 左右。

第一步:把"列表数据"和"统计数字"拆开

原代码大概长这样:

SELECT o.*, u.display_name, COUNT(DISTINCT i.item_id) as item_count, SUM(i.amount) as total
FROM {$wpdb->prefix}my_orders o
LEFT JOIN {$wpdb->users} u ON o.user_id = u.ID
LEFT JOIN {$wpdb->prefix}my_items i ON o.order_id = i.order_id
WHERE o.status = 'paid'
GROUP BY o.order_id
LIMIT 20 OFFSET 0

问题很明显:GROUP BY 为了算聚合,MySQL 得先把全表符合条件的行都攒起来。更坑的是 COUNT(DISTINCT) 在 5.7 里会走临时表+文件排序。

我的拆法:列表只取 order_id 做分页,统计走单独接口、带缓存。

// 列表:极简,只查主表
$order_ids = $wpdb->get_col( $wpdb->prepare(
    "SELECT order_id FROM {$wpdb->prefix}my_orders 
     WHERE status = %s ORDER BY created_at DESC LIMIT %d OFFSET %d",
    'paid', 20, 0
) );

// 批量 IN 查关联,PHP 里组装
$users = $wpdb->get_results( "SELECT ... WHERE order_id IN (" . implode(',', $order_ids) . ")" );

这里有个细节:WordPress 的 IN 如果 id 数组为空会语法报错,我加了个 array_filter + 提前返回空数组的兜底。

第二步:用户信息的"懒加载"改"批预加载"

原代码在 foreach 循环里调 get_user_by( 'id', $order->user_id ),2000 条就是 2000 次查询。我换成 update_meta_cache( 'user', $user_ids ) 批量 warm cache,再 get_userdata 走对象缓存。

但这里踩了个坑:如果开了 Redis/Memcached 对象缓存,update_meta_cache 不会写外部缓存,只填充运行时内存。我的折中方案是手动 wp_cache_set 一批,key 按 user_meta:{$user_id} 规范命名,保证和 WordPress 原生缓存层兼容。

$user_meta = $wpdb->get_results( "SELECT user_id, meta_key, meta_value ..." );
$grouped = [];
foreach ( $user_meta as $row ) {
    $grouped[ $row->user_id ][ $row->meta_key ][] = $row->meta_value;
}
foreach ( $grouped as $uid => $meta ) {
    wp_cache_set( $uid, $meta, 'user_meta' );
}

第三步:静态资源从"随请求带"改成"延迟按需"

这个插件后台每个页面都 wp_enqueue_script 了一个 400KB 的图表库,只因为订单列表用到了。我加了条件判断:

add_action( 'admin_enqueue_scripts', function( $hook ) {
    if ( $hook !== 'toplevel_page_my-plugin-orders' ) {
        return;
    }
    // 只在订单列表页加载
    wp_enqueue_script( 'my-charts', ... );
} );

更隐蔽的是插件自己封的 ajax_url 全局变量,原代码直接 wp_localize_script 绑在 jquery 上,导致每个后台页面都多一次内联脚本输出。我把它挪到真正需要调 AJAX 的页面脚本依赖里,减少约 1KB 的 HTML 输出——别小看这个,Nginx gzip 后差别不大,但对象缓存命中时这 1KB 是实打实的内存占用。

最后的数据对比

同一台机器、同一套数据、开查询缓存的情况下:

  • 原方案:SQL 耗时 78ms,PHP 执行 120ms,总 TTFB 约 210ms
  • 拆查询后:SQL 2.1ms,PHP 执行 35ms,总 TTFB 约 55ms
  • 加上资源懒加载:首包再降 15ms 左右,主要是少了图表库解析阻塞

有个反直觉的点:拆成三次查询后,总查询次数从 1 变成了 3,但性能反而好了。MySQL 的 JOIN 优化器不是万能的,尤其插件表没做覆盖索引时,"多次简单查询 + PHP 组装"经常比"一次复杂 JOIN"更快。

你们插件里有类似的"全能查询"吗?或者有没有遇到过拆查询后反而更慢的情况(比如 N+1 没处理好)?想听听大家的踩坑记录。

评论0
回复 · 0
还没有回复
微信客服 微信客服