WordPress 内容采集系统(Collector)并发控制:实现高频率抓取时不影响 PHP-FPM 响应的负载均衡

(走上讲台,清了清嗓子,把两瓶冒着冷气的可乐放在桌子上)

各位下午好!

我是今天的讲师。既然你们来听这个讲座,我就假设你们都经历过那种令人头皮发麻的时刻:深夜两点,服务器报警,监控大屏上一片红。你冲进机房(或者打开监控面板),发现你的 WordPress 网站挂了,报错是“502 Bad Gateway”。

而罪魁祸首,就是你上个月为了“提升内容丰富度”写的那段代码。

来,看这里。你的代码大概长这样:

// 你写的那个“黑魔法”循环
$urls = get_all_collect_urls();
foreach ($urls as $url) {
    // 这一步要花 0.5 秒
    $content = wp_remote_get($url); 
    // 这一步要花 0.5 秒
    save_to_db($content);
    // 睡一秒,做人留一线
    sleep(1); 
}

你觉得这代码很完美对吧?每秒抓取一次,数据源源不断。但是,问题出在哪?问题出在PHP-FPM 上。

在 Web 环境下,PHP 是以进程池的方式运行的。当一个 PHP 进程在执行这段循环时,它就被“锁死”了。如果用户恰好在这个时候访问你的网站,他发起的请求会被塞进队列里排队,等着这个吃豆人把所有豆子都吃完。等它吃完了,你那个可怜的 PHP 进程可能因为处理了太多请求被杀掉,或者已经耗尽了内存。

所以,今天我们要讲的主题是:如何在 WordPress 里做一个“隐形人”——既能疯狂抓取全网内容,又不让用户觉得你把他们的网站搞崩了。 这就是传说中的并发控制与负载均衡

准备好了吗?我们要开始修仙了。


第一部分:把“大厨”赶出“前台”

想象一下,你开了一家米其林餐厅。

  • PHP-FPM 是前厅的服务员。他们负责把客人请进来,上菜,收盘子。他们的动作必须快,必须流畅。如果前厅的服务员在给客人倒咖啡的时候,突然转身去后厨帮厨炒了一盘红烧肉,那客人的咖啡肯定洒了一身,餐厅就会乱套。
  • 你的采集脚本 是后厨的厨师。厨师需要处理大量的食材(URL),需要火候(网络请求),需要把菜做好(保存数据)。

如果你把厨师赶到了前厅,让他在端盘子的同时炒菜,那餐厅就倒闭了。

解耦是第一步。 我们必须把采集任务从 Web 请求的上下文中剥离出来。

怎么做?我们不需要在前台搞什么复杂的负载均衡,我们要搞的是“消息队列”

在 PHP 的世界里,最适合做这个“传送带”的工具,就是 Redis。为什么?因为它快,支持队列操作,而且它是内存级的,读写速度像闪电一样。

1. 生产者:把任务推入传送带

首先,我们需要一个脚本,或者一个 WordPress 的定时任务(Cron Job),把那些成千上万的 URL 推送到 Redis 的列表里。我们称之为 task_queue

这个脚本不需要等所有 URL 都推完才开始抓取,它只需要负责把任务“倒”进去,然后潇洒地跑路。

<?php
// 生产者脚本: push_jobs.php
require_once 'wp-load.php';

// 假设这里有一个生成 URL 的函数
$urls = generate_urls(); 

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->select(0); // 选择数据库 0

$count = 0;
foreach ($urls as $url) {
    // LPush:把任务扔到队列最左边
    $redis->lPush('task_queue', $url);
    $count++;

    // 为了不让 Redis 压死,稍微停一下
    usleep(1000); 
}

echo "好了,我刚刚往队列里扔了 $count 个任务,现在我去睡觉了。n";

看到没?这个脚本跑完只需要几毫秒。此时此刻,Web 服务器(PHP-FPM)毫发无损,它还在正常响应你的博客首页。


第二部分:消费者:那个不知疲倦的 Worker

现在,队列里堆满了 URL。谁来做这些脏活累活?是那些在后台默默运行的小程序,我们称之为 Worker(消费者)。

Worker 必须是一个 CLI(命令行) 脚本。它不能通过浏览器访问,必须通过 SSH 或 Supervisor 在服务器上启动。

核心逻辑是什么?阻塞式弹出

<?php
// Worker 脚本: consumer.php
#!/usr/bin/env php

require_once 'wp-load.php';

// 初始化 Redis
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->select(0);

// 这里有个关键参数:10
// 意思是:如果没有任务了,我就等 10 秒。如果有任务,我秒回。
// 这保证了 Worker 不会因为空转而浪费 CPU
$redis->setOption(Redis::OPT_READ_TIMEOUT, 10);

echo "Worker 启动,准备开始干活...n";

while (true) {
    // BLPop:这是魔法的关键
    // 它会阻塞当前进程,直到队列里有一个元素被推入
    // 然后它把那个元素拿出来,放到数组里返回
    // ['task_queue', 'http://example.com/article/1']
    $result = $redis->blPop('task_queue', 10);

    if ($result) {
        $queueName = $result[0];
        $url = $result[1];

        // 进来了一个任务,开始处理
        echo "接收到任务: $urln";

        // 执行抓取
        process_url($url);

        echo "完成任务: $urln";
    }
}

function process_url($url) {
    // 调用你的抓取逻辑
    // 这里我们依然可以用 wp_remote_get,因为它在 CLI 环境下
    // 但要注意,不要在这里使用会阻塞整个 PHP 进程的 sleep
    // 或者使用非阻塞的 IO 扩展(如果有的话)
    $response = wp_remote_get($url);
    // ... 保存数据逻辑 ...
}

这段代码有什么好处?它是一个死循环。只要 Redis 里还有任务,它就一直抓。而且,因为是 blPop,如果没有任务,它会老老实实地“睡觉”,直到有人喊它。它不会去消耗 CPU 去做无意义的 while(true) 判断。


第三部分:并发控制——不要让服务器“爆炸”

既然 Worker 是一直跑的,那我们不就挂了?如果一个 Worker 跑完一个任务只要 1 秒,那它一秒钟就能处理 1 个 URL。如果你有 10 个 Worker,一秒钟就能处理 10 个。如果每个 URL 要处理 10 秒,那你有 10 个 Worker 就等于有 10 个“手臂”在疯狂抢夺任务。

但是,有一个致命的问题:网络限制

如果你有 100 个 Worker,每个 Worker 同时向同一个目标网站发起 10 个请求,那个目标网站的服务器会认为你是黑客,或者直接封掉你的 IP。这叫流量风暴

我们需要在 Worker 层面做并发控制

1. 硬件层面的并发:启动多少个 Worker?

这取决于你的服务器带宽和 CPU。

  • 1 核 1G 服务器:启动 1 个 Worker。
  • 4 核 8G 服务器:启动 4 个 Worker(每个 Worker 一个 PHP 进程)。
  • 10 核 32G 服务器:启动 10 个 Worker。

不要盲目开太多。你可以用 htop 命令监控你的服务器,看内存和 CPU 占用情况。如果 PHP 进程占用内存超过了你服务器内存的 80%,赶紧停掉几个。

2. 逻辑层面的并发:Guzzle 的并发设置

process_url 函数里,我们使用了 wp_remote_get。WordPress 封装了 Guzzle HTTP 客户端。我们可以通过配置 Guzzle 的连接池来限制对单个目标的并发连接。

这是一个高级技巧。我们修改 wp-load.php 加载之前的代码,或者直接在 wp_remote_get 的配置里写死参数。

function process_url($url) {
    // 自定义配置,控制并发
    $args = [
        'timeout' => 30,
        'limit_concurrency' => 5, // 这是一个伪参数,我们需要用 Guzzle 的配置
        // 实际上 wp_remote_get 不会直接识别这个,我们需要在底层处理
    ];

    // 为了实现真正的并发控制,我们需要自定义 http请求
    // 这里演示如何手动配置 Guzzle 客户端
    $client = new GuzzleHttpClient([
        // 默认并发数限制,即同一时间最多发起多少个连接
        'concurrency' => 5, 
        'timeout' => 30,
        'verify' => false, // 如果目标 SSL 证书有问题,设为 false
    ]);

    try {
        $response = $client->request('GET', $url);
        // 处理响应
    } catch (GuzzleHttpExceptionRequestException $e) {
        // 记录错误日志
        error_log("抓取失败: {$url} - " . $e->getMessage());
    }
}

注意看代码里的 'concurrency' => 5。这意味着,Guzzle 客户端不会一次性把任务全发出去。如果网络慢,它会保持 5 个连接在队列里,第 6 个任务在等待,直到有任务完成,第 6 个才能上。

这就好比你家里有 5 个水龙头,不管水压多大,同一时间只能有 5 只水杯在接水。再多接,就会洒出来。


第四部分:WordPress 上下文隔离——别把数据库搞挂了

Worker 脚本是运行在命令行下的。这意味着,它没有全局的 $_POST, $_GET,也没有全局的 WP_Query。它是一个纯净的世界。

但是,它需要访问 WordPress 的功能,比如数据库操作 wp_insert_post,或者用户数据 wp_get_current_user

我们之前用到了 require_once 'wp-load.php';。这一行代码干了什么?它加载了 WordPress 的核心文件,重新初始化了所有的全局变量,连接了数据库。

这是一个非常耗资源的操作! 如果你写了一个死循环,每循环一次都 require_once 'wp-load.php',那你的内存会像坐火箭一样飙升。

优化策略:只加载一次

上面的 consumer.php 示例代码里,我们只加载了一次。这是对的。

但是,WordPress 是基于“动作”和“过滤器”的架构。你在 Web 环境下用 wp_insert_post 插入一篇文章,可能会触发 save_post 钩子,触发垃圾评论检查,触发 send_mail(如果你开启了邮件通知)。

如果你的采集脚本正在疯狂插入文章,这些钩子会疯狂触发,导致你的 CPU 瞬间 100%。

关键代码:暂停动作

我们需要告诉 WordPress:“嘿,我现在在干活,别触发那些乱七八糟的通知和回调,除非你非要触发。”

WordPress 提供了一个函数:wp_suspend_actions_add_filter。这是一个神器。

// 在 process_url 之前
wp_suspend_actions_add_filter(true); 

// 抓取和处理逻辑...
process_url($url);

// 在 process_url 之后
wp_suspend_actions_add_filter(false); 

这样,WordPress 的各种后台任务(比如发送通知邮件、更新缓存)就会被完全挂起。只有核心的数据库操作能执行。

但还有一个坑: PHP 的 wp_suspend_actions_add_filter 其实有点像魔术贴,你需要在脚本结束时把它解开。不过对于 Worker 这种长生命周期的进程,我们只需要在循环的开始和结束暂停和恢复即可。


第五部分:故障恢复——别让 Worker 崩溃后变成孤儿

你启动了一个 Worker 进程。它跑了 2 小时,运行得很完美。突然,网络波动了,wp_remote_get 抛出了一个 Fatal Error(比如 Allowed memory size of ...),进程直接挂掉了。

此时此刻,Redis 队列里还有几万个任务没被处理。队列空了,但任务没做完。没人去管那个死掉的进程,也没人去启动新的进程。

你的采集工作停摆了。

解决方案:Supervisor

不要手动去写重启脚本。使用 Supervisor。它是 Linux 下的进程管理神兵利器。

你需要安装 Supervisor,然后配置一个 ini 文件,告诉它:“如果 consumer.php 崩了,就自动重启它;如果它挂了,就重启它。”

配置文件示例 (collector.ini):

[program:wp_collector]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/collector/consumer.php
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/html/logs/collector.log

解读一下这个配置:

  • numprocs=4: 这行代码直接帮你启动了 4 个 Worker 进程!你不需要手动开 4 个 SSH 窗口。
  • autorestart=true: 只要进程一死,Supervisor 会立马把它拉起来。就像僵尸复苏一样,杀不完。
  • stdout_logfile: 所有 Worker 的输出日志都统一记录到一个文件里,方便你排查问题。

第六部分:高级并发——异步与扩展

如果你觉得 PHP 的 CLI 脚本还是太慢,或者需要处理极度复杂的逻辑,我们可以引入更高级的选手:Swoole

Swoole 是一个 PHP 的扩展,它允许你在 PHP 中编写协程。什么是协程?你可以把它理解成“微线程”。

有了 Swoole,我们可以在一个 PHP 进程里同时处理成千上万个请求,而不需要开启成千上万个进程。

虽然 Swoole 的学习曲线比较陡峭,而且和 WordPress 的原生集成比较麻烦(因为 WordPress 很多代码假设在 Web 环境下运行),但如果你是一个追求极致性能的“架构狂魔”,这绝对值得研究。

不过,对于 90% 的 WordPress 采集需求,CLI + Redis + Supervisor 这个组合拳已经足够强大了。它简单、稳定、易维护。


第七部分:实战演练——整合代码

让我们把前面所有的东西整合在一起,写一个稍微完整一点的 consumer.php。记住,这是你要在服务器上运行的真家伙。

#!/usr/bin/env php
<?php

// 1. 加载 WordPress 核心文件
// 注意:如果路径不对,这里会报错
require_once __DIR__ . '/wp-load.php';

// 2. 初始化 Redis
$redis = new Redis();
try {
    $redis->connect('127.0.0.1', 6379, 2.5); // 2.5 秒超时
    $redis->select(0);
} catch (Exception $e) {
    die("Redis 连接失败: " . $e->getMessage() . "n");
}

// 3. 配置 Guzzle 并发控制
// 这是一个全局的 HTTP 客户端,复用它比每次都 new 一个要高效
$client = new GuzzleHttpClient([
    'timeout' => 30,
    'connect_timeout' => 10,
    'verify' => false,
    // 核心并发限制:同一时刻最多 5 个连接
    'concurrency' => 5,
]);

// 4. 暂停 WordPress 的动作钩子
// 这非常重要,防止在抓取时触发 save_post 导致死循环
wp_suspend_actions_add_filter(true);

echo "[" . date('Y-m-d H:i:s') . "] Worker 启动,等待任务...n";

while (true) {
    // 5. 阻塞等待任务
    // 如果队列空了,这里会阻塞,不占 CPU
    $result = $redis->blPop('task_queue', 10); 

    if ($result) {
        $url = $result[1];

        // 每次处理一个任务,先打印日志
        echo "[" . date('Y-m-d H:i:s') . "] 开始处理: $urln";

        try {
            // 6. 执行抓取
            $response = $client->request('GET', $url);

            // 检查状态码
            if ($response->getStatusCode() == 200) {
                $body = $response->getBody()->getContents();

                // 7. 解析内容并保存
                // 这里是你具体的业务逻辑,比如解析 XML 或 HTML
                $post_id = collect_and_save_post($url, $body);

                if ($post_id) {
                    echo "成功: $url -> ID: $post_idn";
                } else {
                    echo "失败: $url (保存失败)n";
                }
            } else {
                echo "HTTP 错误: {$url} - Code: " . $response->getStatusCode() . "n";
            }

        } catch (GuzzleHttpExceptionRequestException $e) {
            // 网络错误,比如超时、连接被拒绝
            echo "网络错误: {$url} - " . $e->getMessage() . "n";

            // 可以选择把失败的 URL 重新放回队列,或者记入死信队列
            // $redis->lPush('task_queue_failed', $url);
        } catch (Exception $e) {
            // PHP 运行时错误
            echo "系统错误: {$url} - " . $e->getMessage() . "n";
        }
    }
}

// 你的具体采集函数
function collect_and_save_post($url, $content) {
    // 简单的解析示例,实际要写正则或用 DOM 解析器
    // 模拟保存
    // wp_insert_post(...);
    return rand(1, 9999); 
}

// 8. 脚本结束(虽然这是死循环,但这行代码永远不会被执行,为了语法完整性)
wp_suspend_actions_add_filter(false);

总结:这不仅仅是写代码

各位,这个方案的核心思想不是去“优化”你的循环,而是去改变你的架构

  1. 解耦:采集变成了后台任务,Web 前台保持纯粹。
  2. 队列:Redis 作为缓冲区,削峰填谷。
  3. 并发:CLI + Supervisor 让你拥有无限多的“手”,但通过 Guzzle 控制“手臂”数量,避免被封。
  4. 隔离wp_suspend_actions 保证了采集不会触发 WordPress 的复杂逻辑,导致死锁。

这就像是从手动驾驶(纯 PHP 循环)升级到了自动驾驶 + 自动配送系统(Worker + Queue)。

当你配置好 Supervisor,看着那 4 个 PHP 进程像永动机一样在后台嗡嗡作响,把 Redis 里的任务一个个消灭,而你自己的博客首页依然快如闪电地打开时,那种成就感,简直比喝了一口冰可乐还爽。

好了,今天的讲座就到这里。别光顾着看我的演示代码,赶紧去你的服务器上试试吧。如果有报错,别慌,看看 logs/collector.log。如果还是报错,… 哪怕是 Oracle 也会出 Bug 的,更何况是你写的 PHP 代码。

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注