Zsens Admin 插件查询层"温水煮青蛙":我是怎么把一次 80ms 的慢查养成 2.3s 的"性能 debt",再用三层缓存梯度把它按回 12ms 的

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

上周 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_tagasync/defer,但后台 JS 很多依赖 wp.i18nwp.apiFetch,defer 后时序乱了。目前只在非 WP 依赖的独立 chunk 上用 defer,有没有更干净的方案?

完整耗时变化:2.3s → 180ms(查询合并)→ 45ms(缓存分层)→ 12ms(缓存命中 + 资源合并)。但 12ms 是服务端时间,首屏感知还要加上资源下载,这个看网络了。

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