从 80ms 到 3ms:我把插件后台列表页 query 拆成了三份才摸到 WordPress 查询的"性能天花板"
上周接了个老项目维护,后台订单列表 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 没处理好)?想听听大家的踩坑记录。