嘿,各位码农朋友们,搬好小板凳,泡好你的速溶咖啡。今天我们不聊怎么把屎盆子扣在 HR 头上,也不聊为什么你的 foreach 循环跑得比乌龟还慢。
今天我们聊点硬核的。想象一下,你要去抓取 50 万套房产的数据。这套房子有单价、有面积、有学区、有离地铁的距离。这数据量听起来像是在炒一盘“满汉全席”,但你手里只有一把勺子。
如果是传统 PHP,你可能得 50 万次刷新页面,等到天荒地老。但今天,我们要用 PHP,给它装上 Swoole 这双“火箭靴”,搞一个分布式爬虫系统。
准备好了吗?Let’s rock!
第一部分:PHP 的“洗心革面”
咱们得先承认,PHP 在很多人的印象里还是那个“老爹”语言,写写简单的增删改查,做个页面跳转还行。但分布式爬虫?那是 Go 和 Node.js 的地盘吧?
错!大错特错!
爬虫系统是什么?是典型的IO 密集型任务。大部分时间,CPU 都在等网络发回数据。传统的 PHP 是同步阻塞的,你发一个请求,就像寄一封信,得等信鸽回来才能发下一封。并发 50 个?不,50 个同时发,服务器直接给你个 502 Bad Gateway。
但是,Swoole(或者 Workerman)改变了这一切。Swoole 让 PHP 跑在了事件循环之上。这就好比,传统的 PHP 是排队挂号,Swoole PHP 是全自动分拣系统。
在这个架构里,PHP 不再是那个傻乎乎的 Web 脚本,它变成了一个能够同时处理成千上万个并发连接的分布式操作系统。
第二部分:架构蓝图——像造车一样造爬虫
既然要处理 50 万条数据,单机绝对搞不定。我们要搞一个“大工厂”模式。
1. 总调度员
这是大脑。它不干活,只负责看地图,规划路线。它从初始的种子 URL 开始,往队列里塞任务。
角色: PHP CLI 脚本,单实例运行。
2. 消息队列
这是传送带。50 万个任务如果瞬间全给 Worker,内存会爆。我们需要把任务分批,或者让 Worker 按需取。Redis 是最好的选择,因为它快,而且它是内存数据库,吞吐量那是杠杠的。
角色: Redis 的 List 结构,或者更高级的 Stream。
3. 螺丝钉
这是执行者。它们坐在队列旁边,等着任务来了就抓,抓完了洗,洗完了存。通常我们需要多台机器跑多个 PHP 进程(Swoole 进程),这就是“分布式”的真谛。
角色: 多个 PHP Swoole 进程(Worker),分布在多台服务器。
4. 数据库
这是仓库。MySQL 负责存结构化数据(怎么存取决于你的架构,一会说),Elasticsearch(ES)负责存非结构化数据和全文搜索。
第三部分:代码实战——让 PHP 变身并发怪兽
好,我们不扯淡了,上代码。我们要构建一个基于 Swoole 的 Worker。
3.1 基础的 Worker 模型
首先,你得明白,每个 Worker 都是一个独立的进程。我们启动的时候,要指定开启多少个 Worker 进程。
<?php
// worker.php
require_once 'vendor/autoload.php';
use SwooleProcess;
use SwooleServer;
use SwooleTimer;
class HouseCrawlerWorker
{
private $server;
private $redis;
private $host;
private $port;
public function __construct($host, $port)
{
$this->host = $host;
$this->port = $port;
$this->redis = new Redis();
// 连接 Redis,这可是咱们的大脑
if (!$this->redis->connect($host, 6379, 3)) {
die("无法连接 Redis,你是把服务器锁门外了吗?");
}
$this->redis->select(1); // 指定数据库,别把快递扔错屋了
}
public function start()
{
// 启动一个 Swoole Server,注意这里不是 HTTP Server,而是 TCP Server(或者更简单的,使用 Swoole Process + Redis)
// 为了简化,我们用 SwooleProcess 模拟多个 Worker 抢任务
$workerCount = 4; // 开启 4 个并发 Worker
$workers = [];
echo "系统启动,启动了 {$workerCount} 个并发收割机...n";
for ($i = 0; $i < $workerCount; $i++) {
$pid = Process::fork();
if ($pid > 0) {
// 父进程
$workers[$pid] = $pid;
} elseif ($pid == 0) {
// 子进程(真正的干活者)
$this->run();
exit(0);
} else {
die("Fork 失败,系统资源耗尽,老板,快给服务器加内存!");
}
}
// 父进程等待子进程
foreach ($workers as $pid) {
Process::wait();
}
}
private function run()
{
while (true) {
// 这是核心:从 Redis 队列里弹出一个任务(RPOP,队列尾部弹出,先进先出)
$url = $this->redis->rPop('crawler_task_queue');
if ($url) {
echo "Worker [PID:" . getmypid() . "] 拿到了任务: {$url}n";
$this->crawl($url);
} else {
// 如果队列空了,休息一下,别把 CPU 空转干烧了
usleep(500000); // 0.5秒
}
}
}
private function crawl($url)
{
// 在这里进行 HTTP 请求,使用 Swoole 的 Client
$client = new SwooleCoroutineHttpClient($url, 80);
$client->setHeaders([
'Host' => parse_url($url, PHP_URL_HOST),
'User-Agent' => 'Mozilla/5.0 (PHP Spider Bot 1.0; Swoole Powered)',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
]);
$client->get('/'); // 假设抓取首页,实际业务要根据 $url 动态构造
if ($client->statusCode == 200) {
$content = $client->body;
$this->parseAndSave($content);
} else {
echo "Worker [PID:" . getmypid() . "] 抓取失败,状态码: " . $client->statusCode . "n";
}
$client->close();
}
private function parseAndSave($html)
{
// 这里是数据清洗的地方,大家懂的,HTML 结构千奇百怪
// 使用正则或者 HTML 解析器(如 SimpleHTMLDom)
// ...
// 模拟保存
// $this->saveToMySQL($data);
echo "数据清洗完毕,准备入库...n";
}
}
$crawler = new HouseCrawlerWorker('127.0.0.1', 6379);
$crawler->start();
这段代码展示了最基础的架构。我们用 Process::fork(在 Swoole 环境下通常用 Server 的 task/workerd 模式,但为了演示原理,用 fork 更直观)创建了多个子进程。它们像饿狼一样盯着 Redis 的 crawler_task_queue。
如果有 50 万个 URL,Master 进程把它们 LPUSH 进去,4 个 Worker 进程(假设 4 核 CPU)就像 4 个人同时抢快递,速度瞬间提升 4 倍,甚至更多,因为 IO 等待的时间被复用了。
第四部分:分布式去重与并发控制——别抓同一块肉
50 万条数据,最怕的不是爬得慢,而是重复。你抓了 100 遍 A 房子的页面,不仅浪费流量,还容易被反爬虫 IP 封禁。
在分布式环境下,单机 array_unique 已经不管用了。我们必须依赖 Redis 的 Set 数据结构。
4.1 分布式去重算法
我们有一个 seen_urls 的 Redis Set。每次 Worker 抓取前,先问 Redis:“大哥,这个 URL 你见过吗?”
private function crawl($url)
{
// 分布式锁/去重
// 使用 Redis 的 SETNX (SET if Not eXists) 或者直接 SADD
// 这里演示 SADD + EXISTS 组合
if (!$this->redis->sIsMember('seen_urls', $url)) {
$this->redis->sAdd('seen_urls', $url);
// 只有没见过的才去抓
$client = new SwooleCoroutineHttpClient(parse_url($url, PHP_URL_HOST), 80);
// ... 原有的请求代码 ...
} else {
echo "Worker [PID:" . getmypid() . "] 已经抓过这个 URL 了,这是重复劳动,省省吧。n";
}
}
但是,如果我们在一瞬间(比如 1 秒钟)塞进去 1 万个 URL,而 Worker 只有 4 个怎么办?你会瞬间把 seen_urls 撑爆。
解决方案: 使用 Lua 脚本。把“检查 + 添加”变成原子操作。而且,我们可以给 Redis 加一个过滤层:只把 URL 压入队列的环节去重。
4.2 并发限制
如果我们不想让某一个热门小区的页面被无限刷,我们可以限制单 IP 或者单域名的并发数。
// 在爬取前
$domain = parse_url($url, PHP_URL_HOST);
$domainCount = $this->redis->incr("concurrent_{$domain}");
if ($domainCount > 5) {
// 如果该域名并发超过 5,先扔进一个等待队列
$this->redis->lPush('waiting_queue', $url);
$this->redis->decr("concurrent_{$domain}");
echo "等待队列已满,域名 {$domain} 暂时去休息一下吧。n";
return;
}
// ... 执行抓取 ...
// 抓取结束后
$this->redis->decr("concurrent_{$domain}");
这样就实现了流量削峰填谷,就像高速公路收费站,虽然车多,但只要收费站够多,就不会堵死。
第五部分:数据清洗——给数据做整容手术
抓下来的 HTML 是什么?是一团乱麻。里面夹杂着广告、虚假信息、空格和换行符。
对于房产数据,我们需要的是干净、标准的数据。
5.1 HTML 解析
PHP 里比较流行的是 simplehtmldom 库。虽然它不是最快的,但它是最好用的,对初学者最友好。
require_once 'simple_html_dom.php';
private function parseAndSave($html)
{
$html = str_get_html($html);
if (!$html) return;
// 拿到具体的房子数据
// 假设 HTML 结构是 <div class="house-card" data-price="10000">
foreach ($html->find('.house-card') as $item) {
$data = [
'title' => trim($item->find('.title', 0)->plaintext),
'price' => floatval($item->getAttribute('data-price')),
'area' => floatval($item->find('.area', 0)->plaintext),
'address' => trim($item->find('.address', 0)->plaintext),
];
// 清洗数据:比如把 '万' 字符去掉,把 '平米' 去掉
$data['price'] = str_replace('万', '', $data['price']);
// 入库
$this->save($data);
}
$html->clear();
}
5.2 数据入库策略
这里有个大坑。50 万条数据,你用 INSERT INTO 语句一条条插?Wait,那你是想让你的 MySQL 服务器当场升天吗?
批量插入(Batch Insert)才是王道。
private function save($data)
{
// 模拟批量入库
// 实际上可以使用 MySQL 的 LOAD DATA INFILE 或者事务批量提交
$this->batchBuffer[] = $data;
if (count($this->batchBuffer) >= 100) {
$this->flushBatch();
}
}
private function flushBatch()
{
$sql = "INSERT INTO houses (title, price, area, address, created_at) VALUES ";
$values = [];
foreach ($this->batchBuffer as $row) {
// SQL 注入防护是必须的,用 PDO::prepare 会更好
$values[] = "('" . addslashes($row['title']) . "', {$row['price']}, {$row['area']}, '{$row['address']}', NOW())";
}
$sql .= implode(',', $values);
// 执行 SQL...
// $this->pdo->query($sql);
$this->batchBuffer = [];
echo "一批数据已入库: " . count($values) . " 条n";
}
此外,对于 50 万+ 数据,MySQL 的单表索引可能会很大,影响查询速度。这时候,Elasticsearch (ES) 就派上用场了。我们可以把清洗后的 JSON 数据实时同步到 ES。
第六部分:架构的“五脏六腑”——Redis 与 MySQL 的配合
我们的系统架构看起来是这样的:
-
Master 脚本:
- 读取种子 URL(比如某个房产网站的列表页)。
- 解析列表页,提取出 50 个具体房源的详情页 URL。
- 把这 50 个 URL
LPUSH到 Redis 的crawler_task_queue。 - 循环往复。
-
Worker 脚本:
RPOP获取 URL。GET页面内容。- 解析出房源详情。
- 入库 MySQL:保存房源的基础信息(价格、面积)。
- 入库 ES:保存房源的全文信息(户型图、装修描述),方便用户搜索“三室一厅 近地铁”。
-
监控脚本:
- 定时检查 Redis 里的任务队列。如果队列为空,说明抓完了。
- 如果队列还有任务但 Worker 不动了,发个报警短信给老板。
第七部分:反爬虫——猫鼠游戏的高潮
在爬 50 万条数据的过程中,你肯定会遇到各种反爬虫手段。这时候,你的 PHP 爬虫得学会伪装。
- User-Agent:别总是
Mozilla/5.0,你可以随机生成一个:Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)...。 - IP 代理池:当你抓得多了,对方会封你的 IP。你需要一个代理池系统。Worker 请求时,随机从 Redis 里拿一个代理 IP。
- Cookies:保持会话状态。
这里有个小技巧:动态随机延迟。
不要用固定的 sleep(1)。如果对方检测到你 1 秒访问 10 次,你就随机 sleep 1-5 秒。让对方摸不着你的节奏。
private function randomSleep()
{
$seconds = rand(1, 5);
echo "正在休息 {$seconds} 秒以规避反爬虫检测...n";
usleep($seconds * 1000000);
}
第八部分:内存与性能的平衡
虽然 Swoole 很强,但如果你在 Worker 进程里一直积累数据不释放,内存迟早会爆。
- 及时释放资源:
curl句柄、HTML 解析器对象、大数组,用完马上unset。 - 数据库连接池:不要在 Worker 进程里频繁
new PDO()。应该在进程启动时建立连接,进程销毁时关闭。Swoole 的Worker事件中,连接对象是共享的。 - 断点续爬:如果爬到一半程序崩了,或者电脑关机了,明天开机怎么接着爬?把
seen_urls这个 Set 保存到文件或者 Redis 持久化。下次启动时,先从 Set 里加载一下,告诉 Worker:“兄弟们,这 100 个 URL 你们别再抓了,那是昨天的陈年旧货。”
结语
好了,各位。看看我们用 PHP 做了什么?一个基于 Redis 分布式队列,Swoole 异步并发,MySQL + ES 混合存储的房产数据采集系统。
50 万条数据,以前你觉得是个天文数字,现在看来,不过是几秒钟的数据流。PHP 并不老,它只是换了种活法。
记住,架构设计的核心不是技术有多牛,而是如何用最少的钱(服务器资源),解决最麻烦的问题。如果你用 Java 写个单线程爬虫爬这 50 万条,你可能得爬一周;用 PHP 加上 Swoole 和 Redis,可能只需要几个小时。
所以,别再说 PHP 只是“弱鸡”了。当你手里的代码能跑通整个分布式系统的时候,你就是最靓的仔。
现在,把你的 IDE 打开,去写你的爬虫吧!别让那些数据在互联网的某个角落沉睡,去把它们抓回来,存进你的数据库,让它们发光发热!
(完)