Zsens Admin 插件性能深挖:我把 `wp_options` 查询从 47 次压到 3 次,靠的不是缓存而是"查询契约合并"
上周 profiler 里一组数据把我看懵了——插件后台首页光 `wp_options` 就查了 47 次,其中 31 次是 Zsens Admin 自己造的孽。不是慢查询,是高频短查询的累积毒性。今天把踩坑过程拆开,聊聊我怎么用"查询契约合并"替代惯性的"用到就查",顺带把缓存和静态资源加载的配套改造一起端上来。
一、病灶:配置读取的"散弹枪模式"
原先代码长这样,估计很多人写过类似:
// 旧写法:各处按需单查
function get_menu_config() {
return get_option('zsens_admin_menu');
}
function get_theme_config() {
return get_option('zsens_admin_theme');
}
function get_api_config() {
return get_option('zsens_admin_api'); // 又一条独立查询
}
三个配置项,三次独立 SQL。业务膨胀后变成 30+ 次。更坑的是有些场景嵌套调用——A 方法读配置,B 方法调 A,C 钩子再调 B,同一配置被重复反序列化。
我试过直接加 `wp_cache_get`,但发现两个问题:
- 对象缓存未开启时(比如某些廉价主机),缓存层直接穿透回数据库
- 多个配置项的缓存键分散,预热时需要逐个 `set`,原子性难看
二、改造:单契约查询 + 内存级暂存
核心思路是把同一命名空间下的配置项合并为一次查询,用 `autoload` 机制兜底,再在进程内做数组缓存。
class ZsensConfigContract {
private static $bucket = null; // 进程级暂存,请求内零重复查询
private static $loaded = false;
// 一次性拉取本插件所有配置
public static function load(): array {
if (self::$loaded) return self::$bucket ?? [];
// 优先对象缓存,其次数据库 LIKE 前缀批量查
$cached = wp_cache_get('zsens_admin_config_all', 'zsens_admin');
if ($cached !== false) {
self::$bucket = $cached;
self::$loaded = true;
return $cached;
}
global $wpdb;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT option_name, option_value FROM {$wpdb->options}
WHERE option_name LIKE %s",
'zsens_admin_%'
),
OBJECT_K // 以 option_name 为键,省掉二次循环
);
$parsed = [];
foreach ($rows as $name => $row) {
$parsed[$name] = maybe_unserialize($row->option_value);
}
wp_cache_set('zsens_admin_config_all', $parsed, 'zsens_admin', 300);
self::$bucket = $parsed;
self::$loaded = true;
return $parsed;
}
// 按需读取,零 SQL
public static function get(string $key, $default = null) {
$all = self::load();
return $all['zsens_admin_' . $key] ?? $default;
}
}
关键点:
- `OBJECT_K` 让数据库直接返回键值对,省掉 `array_column` 或循环重组
- `zsens_admin_%` 前缀查询 一次覆盖所有业务配置,比 `WHERE option_name IN (...)` 更灵活,新增配置项不改代码
- 进程级 `$bucket` 防同一请求内的重复反序列化开销,`maybe_unserialize` 也不便宜
调用方改造后:
// 旧写法:3 次查询
$menu = get_option('zsens_admin_menu');
$theme = get_option('zsens_admin_theme');
$api = get_option('zsens_admin_api');
// 新写法:1 次查询(首次),后续全内存
$menu = ZsensConfigContract::get('menu');
$theme = ZsensConfigContract::get('theme');
$api = ZsensConfigContract::get('api');
三、缓存层的"防穿透"补丁
批量查询有个隐患:如果某配置项不存在,`load()` 返回的数组里没有对应键,下次 `get()` 还是会触发逻辑判断。我加了显式空值占位:
public static function get(string $key, $default = null) {
$all = self::load();
$fullKey = 'zsens_admin_' . $key;
// 显式记录"查过但不存在",避免业务层反复回退到默认逻辑
if (!array_key_exists($fullKey, $all)) {
$all[$fullKey] = new ZsensNullValue(); // 空对象占位
// 不写入持久缓存,只保留在进程级 $bucket
}
$val = $all[$fullKey];
return ($val instanceof ZsensNullValue) ? $default : $val;
}
这个 `ZsensNullValue` 空对象是个小技巧,和 `null` 区分开——`null` 可能是合法配置值,而空对象明确表示"数据库里真没有"。
四、静态资源:从"全量阻塞"到"路由级按需"
查询优化后,瓶颈转到资源加载。原先 `admin_enqueue_scripts` 里一股脑塞:
// 旧写法:每个后台页面都加载 180KB 的 JS
add_action('admin_enqueue_scripts', function($hook) {
wp_enqueue_script('zsens-admin-bundle', ...); // 全量打包
wp_enqueue_style('zsens-admin-style', ...);
});
改造后按路由后缀分片,利用 Zsens Admin 自己的路由注册信息做匹配:
add_action('admin_enqueue_scripts', function($hook) {
// 只在自己的页面加载基础框架
if (strpos($hook, 'zsens-admin') !== 0) return;
// 读取当前路由标识,从配置契约取(零 SQL)
$currentRoute = $_GET['page'] ?? 'dashboard';
$routeAssets = ZsensConfigContract::get('asset_map', []);
// 基础样式始终加载(已做 HTTP 缓存)
wp_enqueue_style('zsens-admin-base', ZSENS_ADMIN_URL . 'assets/css/base.min.css', [], '3.2.1');
// 路由级 JS 按需
if (isset($routeAssets[$currentRoute]['js'])) {
foreach ($routeAssets[$currentRoute]['js'] as $handle => $file) {
wp_enqueue_script(
'zsens-' . $handle,
ZSENS_ADMIN_URL . 'assets/js/routes/' . $file,
['zsens-admin-vendor'], // 共享 vendor 不重复打包
null, // 用文件修改时间做版本号,开发期免刷缓存
true // footer 加载
);
}
}
});
配套把 `vendor`(Vue3 runtime + 通用工具)单独拆包做长期缓存,业务代码按路由懒加载。首屏 JS 从 180KB 降到 34KB(gzip 后 11KB)。
五、一个意外的副作用:升级脚本的竞态
批量查询改造后,升级时遇到个诡异问题:插件更新数据库结构后,前台请求可能读到旧缓存。原因是 `load()` 的 300 秒缓存和升级脚本的 `update_option` 不同步。
修复是在升级钩子中主动污染缓存标记:
// 升级脚本片段
function zsens_admin_upgrade_v320() {
// ... 表结构变更 ...
// 不是删缓存,是写一个新版本标记,让旧缓存"逻辑过期"
update_option('zsens_admin_cache_epoch', time());
// 下次 load() 时比对 epoch,不一致则强制刷新
}
`load()` 里加一行校验:
$epoch = (int)get_option('zsens_admin_cache_epoch', 0);
$cached = wp_cache_get('zsens_admin_config_all_' . $epoch, 'zsens_admin');
// 缓存键带 epoch,升级即失效,无需逐 key 删除
六、最终数据
| 指标 | 改造前 | 改造后 |
|---|---|---|
| `wp_options` 查询次数 | 47 |

