把 `wp_options` 当缓存用、把全表 `SELECT *` 当日常:插件性能自杀的三种"慢死"姿势与我的急救笔记

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

写插件久了,容易养成一种"数据库反正有缓存"的幻觉。直到某天我在一个 50 万用户的站里看到 `get_option('my_plugin_config')` 单次查询耗了 180ms,才意识到 WordPress 的选项缓存不是万能防弹衣——它只防重复读,不防你读的东西本身是个怪物。

这篇不聊架构,只聊三个我亲手埋过、又亲手挖出来的性能坑,附带当时用来救场的代码片段。都是"看起来没问题,量大了就暴雷"的典型。

---

一、选项表膨胀:我把 JSON 配置塞进了 `autoload=yes` 的棺材

早期版本图省事,所有配置打一个 JSON 数组存一条 option,顺手默认 `autoload=true`。WordPress 每次请求会把所有 `autoload=yes` 的选项一次性加载进内存。我的 JSON 后来膨胀到 120KB,意味着每个前台请求——包括 REST 心跳、AJAX 点赞——都要白吃这 120KB 的序列化开销。

排查时用了一段脏代码,直接看 `wp_options` 里谁最胖:

global $wpdb;
$fat_options = $wpdb->get_results(
    "SELECT option_name, LENGTH(option_value) as size 
     FROM {$wpdb->options} 
     WHERE autoload = 'yes' 
     ORDER BY size DESC 
     LIMIT 10"
);
// 当时我的 _zsens_config 排第二,仅次于 rewrite_rules

急救方案:拆。高频读取的小字段保持 `autoload=yes`,大块配置(比如报表模板、字段映射规则)改成 `autoload=no`,按需 `get_option` 并自己包一层对象缓存:

function z_get_heavy_config( $key ) {
    $cache_key = 'zsens_heavy_' . $key;
    $value = wp_cache_get( $cache_key, 'zsens' );
    if ( false === $value ) {
        $all = get_option( 'zsens_heavy_config', [] ); // autoload=no
        $value = $all[ $key ] ?? null;
        wp_cache_set( $cache_key, $value, 'zsens', HOUR_IN_SECONDS );
    }
    return $value;
}

关键教训:autoload 不是"要不要缓存",是"要不要每个请求都陪葬"。

---

二、查询层的"温水青蛙":从 `get_posts` 到自定义 SQL 的退化链

某个功能需要"找出最近 30 天有订单且未评价的用户"。第一版用 WP_Query,优雅;第二版发现要跨两个自定义表,改 $wpdb->get_results 手写 JOIN;第三版运营说要支持"按省份筛选",我在 WHERE 里动态拼接了条件……然后忘了给那个时间字段加索引。

慢查询日志里这条 SQL 在数据量 20 万时跑到 2.1s:

SELECT u.ID, u.user_email, o.order_total 
FROM wp_users u
JOIN wp_zsens_orders o ON u.ID = o.user_id
LEFT JOIN wp_zsens_reviews r ON o.ID = r.order_id
WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
  AND r.ID IS NULL
  AND o.province = '广东省'  -- 动态拼接,有时有有时无
ORDER BY o.created_at DESC;

问题不在 JOIN,在 created_at 的索引没覆盖 province 的筛选顺序。MySQL 选了全表扫描。修复不是加更多索引,而是重构查询目标:我不需要实时精确,只需要"最近 3 天内更新过的缓存结果"。

最终改成异步任务刷缓存 + 轻量查询:

// 定时任务里跑,写入瞬态缓存
function z_refresh_pending_review_users() {
    global $wpdb;
    // 允许 5 分钟内的数据延迟,索引压力骤降
    $results = $wpdb->get_results( /* 加了复合索引 (province, created_at) 的优化 SQL */ );
    set_transient( 'z_pending_review_users', $results, 6 * HOUR_IN_SECONDS );
}

// 前台读缓存, miss 时返回空数组不阻塞
function z_get_pending_review_users() {
    $users = get_transient( 'z_pending_review_users' );
    if ( false === $users ) {
        wp_schedule_single_event( time(), 'z_refresh_pending_review_hook' );
        return []; // 宁可空,不可慢
    }
    return $users;
}

这里偷了个懒:用 set_transient 而不是 wp_cache_set,因为对象缓存可能被 Memcached 逐出,而瞬态在有对象缓存时走对象缓存、没有时回退 option,兼容性更好。代价是 wp_options 又会被写入——所以我把 transient 前缀设成 _transient_timeout_z_ 可识别的短名,方便后续清理。

---

三、后台 JS/CSS 的"全量投毒":一个 wp_enqueue_script 拖垮所有 admin 页

最隐蔽的坑。我在插件主文件里写了:

add_action( 'admin_enqueue_scripts', 'zsens_load_admin_assets' );

function zsens_load_admin_assets() {
    wp_enqueue_script( 'zsens-vendor', plugin_dir_url( __FILE__ ) . 'dist/vendor.js', [], '1.0', true );
    wp_enqueue_script( 'zsens-app', plugin_dir_url( __FILE__ ) . 'dist/app.js', ['zsens-vendor'], '1.0', true );
    wp_enqueue_style( 'zsens-style', plugin_dir_url( __FILE__ ) . 'dist/app.css', [], '1.0' );
}

没有 $hook 判断。结果是 WordPress 后台每个页面——媒体库、小工具、别人插件的设置页——都加载了我的 400KB vendor + 200KB app。更惨的是 app.js 里有个 document.querySelector('#zsens-dashboard') 找不到就报错,污染了全局 console。

修复后的版本,带依赖分析和条件加载:

function zsens_load_admin_assets( $hook ) {
    // 只在自己的页面家族加载
    $my_pages = [ 'toplevel_page_zsens', 'zsens_page_zsens-reports', 'zsens_page_zsens-settings' ];
    if ( ! in_array( $hook, $my_pages, true ) ) {
        return;
    }

    // 设置页需要完整的 Vue 运行时,报表页只要图表库,仪表盘走精简包
    $manifest = json_decode( file_get_contents( plugin_dir_path( __FILE__ ) . 'dist/manifest.json' ), true );
    
    if ( $hook === 'zsens_page_zsens-settings' ) {
        wp_enqueue_script( 'zsens-settings', $manifest['settings.js'], [], null, true );
    } elseif ( $hook === 'zsens_page_zsens-reports' ) {
        wp_enqueue_script( 'zsens-reports', $manifest['reports.js'], [], null, true );
    } else {
        wp_enqueue_script( 'zsens-dashboard', $manifest['dashboard.js'], [], null, true );
    }
    
    // CSS 按需:只有需要 UI 组件的页面加载完整样式
    wp_enqueue_style( 'zsens-base', $manifest['base.css'], [], null );
}

配合构建时的 code splitting,把 vendor.js 拆成 vue.runtimechartutils 三个 chunk,利用 Webpack 的 splitChunks.cacheGroups 按路由聚合。最终仪表盘首屏从 1.2MB 降到 90KB,设置页因为功能重仍有 300KB,但只影响那一个页面。

---

附:我的"性能嗅觉"速查表

现在写新功能前会过一遍这个清单,不用严格执行,用来提醒自己别重蹈覆辙:

  • get_option 返回值超过 10KB?考虑 autoload=no 或拆多条
  • 自定义表查询有 WHERE 时间范围?确认 EXPLAIN 用了索引,没走 Using filesort
  • admin_enqueue_scripts 回调里用了 $hook 吗?没有就是全局投毒
  • JS 入口文件超过 200KB?检查有没有把用不到的图表库、编辑器
评论0
回复 · 0
还没有回复
微信客服 微信客服