Zsens Admin 插件性能小抄:三个让我从 2.3s 打到 180ms 的"微优化"——查询切片、缓存预热与 JS 懒加载的实战账本
上周把插件后台的"数据统计看板"给同事内测,结果人家甩过来一张截图:首屏 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 / 每次查 DB | 0.05ms / APCu 命中 | 加 static + 显式 TTL |
| JS 加载 | 1.2MB 阻塞 | 0KB(未访问看板)/ 已缓存 | 改 `import()` + `prefetch` |
| 首屏总耗时 | 2.3s | 180ms | — |
这三个点都不是什么高深架构,就是在插件开发的约束里做取舍:你没法改框架核心,但能控制查什么、缓哪里、什么时候加载。下次写插件后台,先把这三处过一遍,比后面上 Profiler 抓头发划算多了。
有类似场景的兄弟欢迎贴你们的数字,我尤其好奇大家在 Zsens 里是怎么处理"插件间共享缓存键冲突"的——我现在的前缀是硬编码 `zsens:admin:{plugin}:{key}`,感觉迟早要撞。

