一、问题现象
近期多个 Z-BlogPHP 站点出现移动端打开文章页白屏、卡顿严重的现象。通过排查发现 MySQL 慢查询日志中出现大量如下记录:
Query_time: 635.265231 Rows_examined: 1 UPDATE zbp_post SET log_ViewNums = '65' WHERE log_ID = '12765';
单条 UPDATE 语句执行时间长达 500~700 秒,且同一篇文章被多个请求重复更新。这意味着每次页面访问都会触发实时数据库写入,在高并发下形成严重的 InnoDB 行锁竞争,PHP 脚本挂起等待,浏览器表现为白屏。
二、根因分析
Z-BlogPHP 默认在 ViewPost 流程中执行以下逻辑:
$article->ViewNums += 1; $article->Save();
当多个用户或爬虫同时访问同一篇文章时,MySQL 会对该行数据加排他锁(X 锁)。后面的请求必须等待前一个 UPDATE 提交后才能执行。在并发稍高的场景下,等待时间呈指数级增长,直接拖垮整站响应速度。
三、优化目标
消除每次页面访问的实时 UPDATE 操作
浏览计数延迟不超过 5 分钟入库
对现有主题和插件无侵入性改造
支持多站点批量部署
四、方案演进
4.1 第一阶段:APCu 内存缓存尝试
最初计划使用 APCu 扩展实现内存级计数。原理是:页面访问时通过 APCu 原子递增,每 5 分钟由计划任务批量刷回数据库。
但在实际部署中发现一个致命问题:PHP-FPM 进程与 CLI 命令行进程之间的 APCu 内存不共享。网页端写入的缓存 key,在计划任务脚本中完全读取不到,导致回写永远失败。
结论:在面板默认的 PHP 运行环境下,APCu 不适合跨进程缓存场景。
4.2 第二阶段:文件缓存方案(最终采用)
将计数数据写入磁盘文件,利用文件锁(flock)保证并发安全。流程如下:
用户访问文章页 → 写入站点目录下的缓存子目录
页面显示浏览数 → 读取数据库值 + 文件缓存值,实时累加
计划任务每 5 分钟 → 扫描缓存目录 → 批量 UPDATE → 清空文件
4.3 第三阶段:open_basedir 权限陷阱
文件缓存部署后,发现网页端仍然无法生成缓存文件。排查发现服务器为每个站点配置了 open_basedir 安全限制:
open_basedir = /www/wwwroot/站点目录/:/tmp/
这意味着 PHP-FPM 进程无法写入外部日志目录。解决方案是将缓存目录迁移到站点根目录内部:
/站点根目录/zb_users/cache/viewnums/
该路径天然处于 open_basedir 允许范围内,无需修改服务器安全配置。
五、核心代码实现
5.1 主题 include.php 注入
在主题 include.php 末尾追加以下代码,拦截 Z-Blog 默认的实时 UPDATE:
define('VN_CACHE_DIR', '/站点根目录/zb_users/cache/viewnums');
function vn_add($post_id) {
$site = md5(parse_url($zbp->host, PHP_URL_HOST));
$dir = VN_CACHE_DIR . '/' . $site;
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$file = $dir . '/' . $post_id . '.txt';
$fp = @fopen($file, 'c+');
if (flock($fp, LOCK_EX)) {
$n = (int)stream_get_contents($fp);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)($n + 1));
flock($fp, LOCK_UN);
}
fclose($fp);
}
function vn_get($post_id, $db_val) {
$site = md5(parse_url($zbp->host, PHP_URL_HOST));
$file = VN_CACHE_DIR . '/' . $site . '/' . $post_id . '.txt';
return $db_val + (is_file($file) ? (int)file_get_contents($file) : 0);
}
Add_Filter_Plugin('Filter_Plugin_ViewPost_ViewNums', 'vn_hook_viewnums');
function vn_hook_viewnums($article) {
vn_add($article->ID);
return $article->ViewNums;
}5.2 文章页模板调用
在文章详情模板中,将原来的浏览数显示:
<span class="viewicon">{$article.ViewNums}</span>替换为:
<span class="viewicon">{php}echo vn_get($article->ID, $article->ViewNums);{/php}</span>同时在模板顶部加入计数触发(确保每次访问都执行):
{php}vn_add($article->ID);{/php}5.3 批量回写脚本
创建计划任务脚本,由面板每 5 分钟执行一次。脚本直接扫描缓存目录下的所有子目录,不再依赖 md5 站点 key 匹配,避免跨进程 key 不一致问题:
$base_dir = $site_path . '/zb_users/cache/viewnums';
$subdirs = glob($base_dir . '/*', GLOB_ONLYDIR);
foreach ($subdirs as $dir) {
$files = glob($dir . '/*.txt');
foreach ($files as $file) {
$post_id = (int)basename($file, '.txt');
$add = (int)file_get_contents($file);
// 执行 UPDATE ...
@unlink($file);
}
}六、多站点批量部署
本次优化涉及多个独立 Z-Blog 站点,涵盖不同主题。通过自动化脚本完成:
备份各站点
include.php及含 ViewNums 的模板文件自动清理旧 APCu 代码碎片
注入统一的文件缓存函数
替换模板中的浏览数输出语法
创建站点内部缓存目录并修正权限
七、计划任务配置
在面板 → 计划任务中添加 Shell 脚本,执行周期设为每 5 分钟:
#!/bin/bash php 回写脚本路径 站点根目录1 php 回写脚本路径 站点根目录2 # ... 其余站点依此类推
八、效果验证
优化完成后,通过以下方式验证效果:
慢查询监控:清空慢查询日志后观察 30 分钟,确认不再出现
UPDATE zbp_post SET log_ViewNums记录缓存文件检查:查看
/zb_users/cache/viewnums/可看到实时生成的计数文件回写验证:手动执行脚本后输出
已回写 N 篇文章的浏览数,数据库字段同步更新移动端体验:文章页打开速度恢复正常,白屏现象消失
九、总结
本次优化的核心在于将数据库行级写操作从实时同步改为异步批量。对于内容型站点,浏览数延迟 5 分钟入库对业务完全无影响,但能彻底释放 MySQL 行锁压力。
关键经验:
APCu 在 PHP-FPM 与 CLI 之间可能存在共享隔离,生产环境优先使用文件缓存
open_basedir 限制必须将缓存目录放在站点根目录内部
回写脚本应直接扫描目录,避免依赖跨进程生成的 md5 key
批量部署前务必做好文件级备份,防止主题代码被意外截断
该方案已稳定运行于多个生产站点,可作为 Z-BlogPHP 高并发浏览计数优化的标准实践。