3.4 缓存、队列与性能约定

该省的查询省掉,该挪到后台的活别占着前台。

动态缓存文件放 data/cache

Discuz! 的动态缓存文件统一放在 ./data/cache 目录,依据不同功用进行独立命名。命名独立是硬要求:内核自己也在这个目录里写 cache_xxx.php,撞名就会互相覆盖。X5.0 里建议用 DISCUZ_DATA 常量取目录,而不是拼字符串:

<?php

if(!defined('IN_DISCUZ')) {
    exit('Access Denied');
}

global $_G, $discuz;

// 按功用命名,一个用途一个文件
$file = DISCUZ_DATA.'cache/myplugin_rank.php';

// 读缓存:不存在时回源,然后把结果写回
if(file_exists($file) && filemtime($file) + 600 > TIMESTAMP) {
    $ranklist = file_get_contents($file);
} else {
    $ranklist = C::t('myplugin_log')->count(array('action' => 'checkin'));
    file_put_contents($file, $ranklist);
}

缓存文件比很多人想象的脆弱:更新缓存、换对象存储、目录权限被改,都会让它失效或写不进去。所以第一原则是——缓存永远只是加速器,不是唯一数据源。读不到就回源算一遍,站长看到的数字最多晚一点,不会看到空白。某些部署形态(企飞版)还禁止往 DISCUZ_DATA 写永久文件,永久性文件别放这里。

什么时候必须清缓存

缓存更新是集中的:后台 → 界面 → 更新缓存,走一遍会把各系统的缓存重建一遍。插件可以挂自己的缓存更新项目,脚本放 source/plugin/{标识}/cache/cache_name.php:

<?php

if(!defined('IN_DISCUZ')) {
    exit('Access Denied');
}

function build_cache_plugin_myplugin_rank() {
    // 重建你这个插件的缓存,函数名需与脚本名对应
    $ranklist = C::t('myplugin_log')->count(array('action' => 'checkin'));
    savecache('myplugin_rank', $ranklist);
}

savecache() 是 Discuz! 提供的缓存写入函数,文件同样落在 data/cache 下。加了这个模块,站长点「更新缓存」就会顺带重建你的缓存。

场景 该怎么做
改了模板文件 更新缓存(模板缓存)
改了语言包 更新缓存,否则 $_G['cache'] 里留着旧值
改了插件变量 更新缓存,插件配置缓存才重建
改了生成缓存的脚本 更新缓存

后两条是重点:改了「生成缓存的代码」必须更新一次缓存才生效,这和模板一样容易忘。

大查询的两个坑

分页别用大 offset。 LIMIT 100000, 20 会让数据库先把前十万行扫出来再丢掉,翻到深层分页时耗时成倍上升。改法是记住上一页最后一条的排序值,下一页从它往后取:

// 不好:offset 随页码线性增长
$sql = 'SELECT * FROM '.DB::table('myplugin_log').' ORDER BY logid DESC LIMIT '.intval($offset).', 20';

// 好:用上一页末值定位,扫描量与页码无关
$lastlogid = $offset ? intval($lastlogid) : PHP_INT_MAX;
$sql = 'SELECT * FROM '.DB::table('myplugin_log').' WHERE logid < '.$lastlogid.' ORDER BY logid DESC LIMIT 20';

一次插太多。 循环里插一万条,慢且容易撞上锁表。合并成一条多值 INSERT 更快:

// 不好:一万次往返
foreach($rows as $row) {
    C::t('myplugin_log')->insert($row);
}

// 好:一条语句插一千,循环十次
foreach(array_chunk($rows, 1000) as $chunk) {
    foreach($chunk as $row) {
        $inserts[] = DB::field('logid', 0, true).',('.DB::field('uid', $row['uid']).','.DB::field('action', $row['action']).','.DB::field('dateline', $row['dateline']).')';
    }
    if($inserts) {
        DB::query('INSERT INTO '.DB::table('myplugin_log').' (logid, uid, action, dateline) VALUES '.implode(',', $inserts));
        $inserts = array();
    }
}

分批大小按字段宽度调,一批太大一样会顶到 max_allowed_packet。别忘了这些表的索引在安装脚本里已经建好了。

数据量级 → 建议做法

不同量级的问题答案不一样。以一张日志表为例:

数据量级 建议做法
小(几千行以内) 直接查,不加缓存
中(几万行) 加缓存;有索引的前提下直接 COUNT 也还行
大(几十万行往上) 分页 + 索引,绝不用大 offset;列表只查必要字段
统计类数据 落缓存,由定时任务刷新
需要实时精确的数字 别缓存,要么建汇总表,要么接受慢

中间几档的边界看你站点规模,没有统一答案。真正的信号是页面耗时:先测再优化,别给一张几千行的表加三层缓存。

长时间任务别在前台跑

导入几万条数据、给全站用户发通知、重算统计——在 HTTP 请求里跑,代价是执行时间超限、MySQL 锁表、站长刷新页面时看到一堆失败。

正规答案是计划任务模块:脚本放 source/plugin/{标识}/cron/cron_name.php,插件安装时自动加进系统计划任务,卸载时自动移除。

<?php

//cronname:myplugin 计划任务名称,可写脚本语言包中的项目
//week:0   设置星期几执行,留空为不限制
//day:0     设置哪一日执行,留空为不限制
//hour:3    设置哪一小时执行,留空为不限制
//minute:0  设置哪些分钟执行,至多 12 个分钟值,留空为不限制

if(!defined('IN_DISCUZ')) {
    exit('Access Denied');
}

global $_G, $discuz;

// 每批处理一点,跑不完不丢活,下次接着做
$batch = C::t('myplugin_log')->fetch_all(
    'SELECT * FROM '.DB::table('myplugin_log').' WHERE logid > ? ORDER BY logid ASC LIMIT 500',
    array($cursor)
);

foreach($batch as $row) {
    // 每个任务动作
}

这些头部注释不是注释,是系统读它们来注册任务的,改写或删掉任务就没了。minute 那行最常用:填 0 表示整点跑一次,是「统计刷新」这类活的标准配置。调度表是 pre_common_cron(官方文档写作 cdb_common_cron)。

任务脚本建议分两层:重的活挂计划任务定时跑;轻的、能立刻出结果的做成后台管理模块里的按钮。

X5.1 的队列说明

企飞版 X5.1 对原生队列有专门支持,那是闭源授权版本的能力。开源版 X5.0 上作者能用的就是两件套:计划任务和后台手动触发。设计任务时按这两件套组织,不要假设站点一定提供队列。

一份落地自查表

  1. 缓存文件按功用独立命名,放在 data/cache(用 DISCUZ_DATA 取路径);
  2. 缓存只是加速器,读不到能回源;
  3. 改了生成缓存的代码就更新缓存;
  4. 分页不用大 offset,用末值定位;
  5. 批量写合并成多值 INSERT 并分批;
  6. 长时间任务挂计划任务,或做成后台按钮;
  7. 数据表都走了 C::t() 或 DB::table(),业务代码里没有写死的表前缀。

前三条开发时最容易忘,后四条上线前值得逐项过。