(走上讲台,清了清嗓子,把两瓶冒着冷气的可乐放在桌子上)
各位下午好!
我是今天的讲师。既然你们来听这个讲座,我就假设你们都经历过那种令人头皮发麻的时刻:深夜两点,服务器报警,监控大屏上一片红。你冲进机房(或者打开监控面板),发现你的 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);
总结:这不仅仅是写代码
各位,这个方案的核心思想不是去“优化”你的循环,而是去改变你的架构。
- 解耦:采集变成了后台任务,Web 前台保持纯粹。
- 队列:Redis 作为缓冲区,削峰填谷。
- 并发:CLI + Supervisor 让你拥有无限多的“手”,但通过 Guzzle 控制“手臂”数量,避免被封。
- 隔离:
wp_suspend_actions保证了采集不会触发 WordPress 的复杂逻辑,导致死锁。
这就像是从手动驾驶(纯 PHP 循环)升级到了自动驾驶 + 自动配送系统(Worker + Queue)。
当你配置好 Supervisor,看着那 4 个 PHP 进程像永动机一样在后台嗡嗡作响,把 Redis 里的任务一个个消灭,而你自己的博客首页依然快如闪电地打开时,那种成就感,简直比喝了一口冰可乐还爽。
好了,今天的讲座就到这里。别光顾着看我的演示代码,赶紧去你的服务器上试试吧。如果有报错,别慌,看看 logs/collector.log。如果还是报错,… 哪怕是 Oracle 也会出 Bug 的,更何况是你写的 PHP 代码。
下课!