Z-BlogPHP 高并发优化实战:异步文件缓存替代实时 UPDATE,根治移动端白屏卡顿

19

一、问题现象

近期多个 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)保证并发安全。流程如下:

  1. 用户访问文章页 → 写入站点目录下的缓存子目录

  2. 页面显示浏览数 → 读取数据库值 + 文件缓存值,实时累加

  3. 计划任务每 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 站点,涵盖不同主题。通过自动化脚本完成:

  1. 备份各站点 include.php 及含 ViewNums 的模板文件

  2. 自动清理旧 APCu 代码碎片

  3. 注入统一的文件缓存函数

  4. 替换模板中的浏览数输出语法

  5. 创建站点内部缓存目录并修正权限

七、计划任务配置

在面板 → 计划任务中添加 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 高并发浏览计数优化的标准实践。

网友评论

访客信息

你已经3分钟没有访问该网站

这些可能是你需要的内容: