Zsens Admin 插件查询层"温水煮青蛙":我是怎么把一次 80ms 的慢查养成 2.3s 的"性能 debt",再用三层缓存梯度把它按回 12ms 的
上周 profiler 报警,有个后台列表页加载飙到 2.3 秒。查了一圈不是前端问题,是数据库在"温水煮青蛙"——单次查询从 80ms 慢慢爬到 2.3s,用了三个月才暴露。记录一下查询、缓存、静态资源这三块的"梯度优化"思路,不是一次性重写,是分层还债。
第一层:查询本身的"隐性 N+1"
先看原始代码,问题藏得很深:
// 列表页:取 20 条主数据
$items = $wpdb->get_results( "SELECT * FROM {$table} LIMIT 20" );
foreach ( $items as $item ) {
// 每条都要去关联表查"最后操作人"
$item->last_user = $wpdb->get_var( $wpdb->prepare(
"SELECT display_name FROM {$wpdb->users} WHERE ID = %d",
$item->last_user_id
) );
// 还要统计关联子项数量
$item->sub_count = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$sub_table} WHERE parent_id = %d",
$item->id
) );
}
20 条数据,1 次主查询 + 20 次用户查询 + 20 次子项统计 = 41 次查询。用户表有缓存还好,子项统计表没索引直接爆炸。
改成 JOIN + 子查询一次性拿完:
$items = $wpdb->get_results( "
SELECT
t.*,
u.display_name as last_user,
(SELECT COUNT(*) FROM {$sub_table} s WHERE s.parent_id = t.id) as sub_count
FROM {$table} t
LEFT JOIN {$wpdb->users} u ON t.last_user_id = u.ID
LIMIT 20
" );
这里有个坑:LEFT JOIN 后如果 display_name 为 NULL,之前代码里直接当字符串用没问题,现在要做空合并。更隐蔽的是子查询在 MySQL 5.7 里会被物化两次,5.8+ 优化器才聪明点。我加了 parent_id + status 的复合索引,子查询从全表扫变成 range scan。
这一步把 41 次查询压到 1 次,耗时从 2.3s 降到 180ms。但 180ms 还是慢,继续剥。
第二层:对象缓存的"伪命中"陷阱
上了 wp_cache_get/set,但发现命中率不对劲。排查发现两个问题:
问题一:缓存 key 没做"环境隔离"
// 错误示范:多站点共用 key
wp_cache_set( 'admin_items_list', $items, '', 300 );
多站点场景下,站点 A 的数据被站点 B 读走。改成带站点 ID 和语言前缀:
$cache_key = "zsens:items:{$GLOBALS['blog_id']}:" . get_locale() . ':' . md5( $query_hash );
问题二:缓存粒度太粗,更新时"连坐"
之前把整个列表塞一个 key,任何一条记录更新都要清整个缓存。改成"列表缓存 + 单条缓存"双层:
// 列表只存 ID 数组,轻量
$list_key = "zsens:list:{$page}";
wp_cache_set( $list_key, wp_list_pluck( $items, 'id' ), '', 60 );
// 单条数据各自缓存
foreach ( $items as $item ) {
wp_cache_set( "zsens:item:{$item->id}", $item, '', 600 );
}
列表 60 秒、单条 600 秒的 TTL 错开,更新时只清对应单条和列表 key,不用全量重建。这里 wp_cache_delete 要在 save_post 或自定义 hook 里挂,别漏了直接 SQL 更新的场景。
缓存层做完,降到 45ms。但静态资源还在拖感知速度。
第三层:静态资源的"请求瀑布"优化
后台列表页用了个日期选择器,原本这样加载:
// 每个依赖单独 enqueue
wp_enqueue_script( 'flatpickr', plugin_dir_url( __FILE__ ) . 'js/flatpickr.js', [], '4.6', true );
wp_enqueue_script( 'flatpickr-lang', plugin_dir_url( __FILE__ ) . 'js/flatpickr-zh.js', ['flatpickr'], '4.6', true );
wp_enqueue_style( 'flatpickr-css', plugin_dir_url( __FILE__ ) . 'css/flatpickr.css', [], '4.6' );
// 还有自己的逻辑文件
wp_enqueue_script( 'zsens-list', plugin_dir_url( __FILE__ ) . 'js/list.js', ['flatpickr-lang', 'jquery'], ZSENS_VERSION, true );
四个 HTTP 请求,虽然都开了 keep-alive,但瀑布里还是排成一列。更坑的是 flatpickr-zh.js 只有 2KB,却要等 30KB 的 flatpickr.js 先加载。
改成构建时打包 + 运行时条件加载:
// 构建阶段用 rollup 把 flatpickr + zh + list 打成一个 chunk
// 输出:dist/list-bundle.{hash}.js
// 运行时:只在需要日期选择的页面加载
add_action( 'admin_enqueue_scripts', function( $hook ) {
if ( $hook !== 'toplevel_page_zsens-items' ) return;
$asset_file = include plugin_dir_path( __FILE__ ) . 'dist/list-bundle.asset.php';
wp_enqueue_script(
'zsens-list',
plugin_dir_url( __FILE__ ) . 'dist/list-bundle.js',
$asset_file['dependencies'], // 自动提取的 wp 依赖
$asset_file['version'],
true
);
wp_set_script_translations( 'zsens-list', 'zsens-admin' );
} );
关键变化:
- 第三方库和业务代码合成一个文件,减少请求数
.asset.php是构建产物,自动跟踪@wordpress/i18n等运行时依赖的版本- hash 在文件名里,CDN/浏览器缓存不用猜
wp_set_script_translations把 JSON 翻译文件挂到脚本,不额外请求
CSS 同理,但有个 WordPress 特有的坑:wp_enqueue_style 的依赖如果也是插件注册的,加载顺序是对的;但如果依赖主题或核心样式,版本号对不上时会被重复加载。我的做法是构建时把 CSS 也打进 JS(style-loader 注入),只在首屏关键 CSS 单独提取。
一个没解决的纠结点
对象缓存我用的是 Redis Object Cache,但 wp_cache_flush() 会清整个 Redis DB。我的插件和其他插件共用一个 prefix,现在是在 key 里做命名空间隔离,flush 时用 glob 模式匹配删除。有更好的做法吗?比如给插件单独一个 Redis DB,但大多数主机商不让选。
另外静态资源我试了 script_loader_tag 加 async/defer,但后台 JS 很多依赖 wp.i18n 和 wp.apiFetch,defer 后时序乱了。目前只在非 WP 依赖的独立 chunk 上用 defer,有没有更干净的方案?
完整耗时变化:2.3s → 180ms(查询合并)→ 45ms(缓存分层)→ 12ms(缓存命中 + 资源合并)。但 12ms 是服务端时间,首屏感知还要加上资源下载,这个看网络了。