Zsens Admin 插件性能小抄:三个让我从 2.3s 打到 180ms 的"微优化"——查询切片、缓存预热与 JS 懒加载的实战账本

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 52 浏览 0 回复

上周把插件后台的"数据统计看板"给同事内测,结果人家甩过来一张截图:首屏 2.3s,其中 1.8s 花在 TTFB 上。我当场血压就上来了——本地明明 400ms 啊。抓包一看,生产环境数据量翻了 40 倍,我之前写的"能跑就行"代码全现原形了。折腾了三天,记录三个改动最小、收益最猛的点,都是几行代码的事。

一、查询:别再用 `fetchAll` 硬撑了,"时间切片"比索引更急

我的看板要算"近 30 天每日订单趋势",之前直接:

$list = Db::name('order')
    ->whereTime('create_time', '-30 days')
    ->field('DATE(create_time) as day, COUNT(*) as num')
    ->group('day')
    ->select();

数据量小没问题,上了 50 万行,`DATE()` 函数导致索引失效,MySQL 开始全表扫描 + filesort。EXPLAIN 一看 `Using temporary; Using filesort`,经典死刑。

改了两处:

1. 预生成日期区间,改成 `BETWEEN` 命中索引:

$days = [];
for ($i = 29; $i >= 0; $i--) {
    $days[] = date('Y-m-d', strtotime("-{$i} days"));
}

// 先查边界,再内存聚合
$raw = Db::name('order')
    ->whereBetweenTime('create_time', $days[0] . ' 00:00:00', $days[29] . ' 23:59:59')
    ->field('create_time')  // 只取原始时间,不函数计算
    ->select();

// PHP 侧做统计,省掉 GROUP BY
$result = array_fill_keys($days, 0);
foreach ($raw as $row) {
    $d = date('Y-m-d', strtotime($row['create_time']));
    $result[$d]++;
}

2. 更狠的一招:超过 7 天的数据走"预聚合表"。建了个 `order_daily_stats` 的定时任务表,看板默认读这个,只有"今天"的数才查实时表合并。TTFB 直接从 1.8s 掉到 220ms。

核心思路:别让 MySQL 做它不适合的事(函数计算、大区间聚合),查询切片 + 预聚合才是插件场景的正解。

二、缓存:APCu 的"伪命中"坑了我一整晚

我明明开了 APCu,为啥压力测试下缓存没生效?翻源码才发现 Zsens 的缓存键带了插件前缀,但我当时在 CLI 定时任务里写缓存、FPM 进程里读缓存——两个 SAPI 的 APCu 不共享内存!

插件里很多场景是混合的:后台管理 FPM、定时任务 CLI、队列 Consumer。APCu 只在同 SAPI 内有效,跨进程就是两套空间。

我的折中方案:区分"热数据"层级

// 极热数据:同请求内多次读取,用 static 变量,零开销
function getPluginConfig() {
    static $config = null;
    if ($config === null) {
        $config = apcu_fetch('zsens:admin:config') ?: loadFromDb();
    }
    return $config;
}

// 跨 SAPI 共享:降级到 Redis,但加本地一层 5 秒短路缓存
function getCrossProcessData($key) {
    $localKey = 'local:' . $key;
    $data = apcu_fetch($localKey);
    if ($data !== false) return $data;
    
    $data = Redis::get($key);
    if ($data !== false) {
        apcu_store($localKey, $data, 5);  // 5 秒本地缓冲,防 Redis 被打穿
    }
    return $data;
}

另外踩了个小坑:APCu 的 `apcu_store` 默认 TTL 0 表示"直到重启",但我的测试环境 PHP-FPM 是 `max_requests=500`,子进程重启频繁,导致缓存"莫名其妙"失效。显式写了 TTL 后稳定多了。

三、静态资源:JS 别一把梭,`import()` 按需拆 + 预加载暗示

看板用了 ECharts,之前整个 `echarts.min.js` 1.2MB 塞在 layout 里,每个后台页面都加载,哪怕只是进个"用户管理"也要背这个包袱。

Zsens Admin 的模板继承结构是 `layout.html` → 各页面,我在 layout 里原来直接:

<script src="__STATIC__/echarts/echarts.min.js"></script>

改成按需加载,只在"数据看板"页面触发:

// 看板页面模板底部
<script type="module">
const chartDom = document.getElementById('trend-chart');
if (chartDom) {
    // 动态导入,其他页面不执行
    const echarts = await import('__STATIC__/echarts/echarts.esm.min.js');
    const myChart = echarts.init(chartDom);
    // ... 渲染逻辑
}
</script>

但这有个体验问题:点击菜单进看板时,JS 才开始下载,图表区域会白屏 200-500ms。加了预加载暗示:

// 在 layout 的 <head> 里,根据当前路由预判下一页可能资源
{if $controller == 'Index' && $action == 'dashboard'}
    <!-- 当前就在看板,直接加载 -->
    <script src="__STATIC__/echarts/echarts.esm.min.js" type="module"></script>
{else}
    <!-- 其他页面,闲时预加载,优先级低 -->
    <link rel="prefetch" href="__STATIC__/echarts/echarts.esm.min.js" as="script">
{/if}

`prefetch` 是浏览器空闲时下载,不进主线程竞争。用户从首页点进看板时,大概率已经缓存了。

还有个更骚的操作:把插件自己的 `admin.js` 拆成"通用交互"和"页面专属"两块,通用部分内联到 HTML(就 3KB,一次往返的事),页面专属走动态导入。首屏 HTML 体积下来后,浏览器解析 `` 到首字节渲染的阻塞时间也缩短了。

最后贴个对比账

优化前优化后改动量
趋势查询1.8s / 全表扫描45ms / 预聚合表+1 个定时任务 + 改 1 处查询
配置读取12ms / 每次查 DB0.05ms / APCu 命中加 static + 显式 TTL
JS 加载1.2MB 阻塞0KB(未访问看板)/ 已缓存改 `import()` + `prefetch`
首屏总耗时2.3s180ms

这三个点都不是什么高深架构,就是在插件开发的约束里做取舍:你没法改框架核心,但能控制查什么、缓哪里、什么时候加载。下次写插件后台,先把这三处过一遍,比后面上 Profiler 抓头发划算多了。

有类似场景的兄弟欢迎贴你们的数字,我尤其好奇大家在 Zsens 里是怎么处理"插件间共享缓存键冲突"的——我现在的前缀是硬编码 `zsens:admin:{plugin}:{key}`,感觉迟早要撞。

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