各位同学,大家好!
今天咱们不聊那些虚头巴脑的理论,咱们来点硬菜。假设你是个架构师,手里接了个大单子——给一个拥有百万级 URL 的电商平台做 SEO 优化。这可不是发发外链那么简单,这是要你用 PHP(Laravel)去“剥削”互联网的每一个角落。
这时候,如果你的代码同步运行,哪怕你把 CPU 点燃了,也没法在用户眼皮子底下把百万数据爬完。你会被后台弹窗骂死,被老板炒鱿鱼,最后只能含泪把键盘吃下去。
所以,咱们今天的主题是:Laravel 队列调度(Queues)物理实现:基于 Redis 延迟任务队列实现大规模 SEO 任务的持久化分发。
准备好了吗?拿起键盘,咱们开始吧。
一、 同步执行的诅咒:为什么你不能把所有事情都做完?
想象一下,你的 SEO 爬虫程序是个单线程的急性子。用户点击“开始全网优化”,你的代码就在那儿“嗷嗷”地跑,一个接一个地访问 google.com、amazon.com……等到爬完 10,000 个页面,用户估计都把水喝干了,然后问你:“喂,怎么还没好?我要看数据!”
这时候,你的程序就像是一个在只有一扇门的房间里的送餐员,后面排了 10,000 个单子,每送一个单子还得给顾客鞠躬说“谢谢惠顾”,效率极低,还容易被人踩死。
异步执行就是解决方案。我们需要一个“分拣中心”。用户下单后,你把单子扔给分拣中心,然后转身去招呼下一位用户,说:“请稍等,系统正在处理中。” 分拣中心(队列)会在后台默默地把这 10,000 个单子处理完,处理完了再来叫你。
而在这个世界上,最好的分拣中心、最快的快递员、最耐用的仓库——没有比 Redis 更合适的了。Redis 是基于内存的,它的速度比你在双十一抢货的手速还快。
二、 Redis 队列:不仅仅是“排队”
在 Laravel 里,使用 Redis 做队列非常简单,一句 QUEUE_CONNECTION=redis 就搞定了。但作为资深专家,我们不能只停留在表面。你要知道 Redis 到底是怎么存这些数据的。
Laravel 默认使用的队列驱动是 sync(同步),这是单线程的。我们换成 redis。
配置魔法
首先,你得告诉 Laravel 你的 Redis 地址在哪里。
// config/database.php
'connections' => [
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'), // 或者是 phpredis
'options' => [
'prefix' => 'laravel_queue:', // 给你的队列加个前缀,防止污染全局 key
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
],
],
Redis 内部机制:幕后英雄 ZSET
当你往队列里塞一个任务时,Laravel 其实是在 Redis 里扔了一个 Hash。但如果你需要延迟任务,这就得用到 Redis 的另一个神器:有序集合。
通常情况下,我们的任务就是一个个字符串 task:1, task:2。但延迟任务不同,你有一个时间点 2023-10-27 10:00:00。
如果你用普通的 List,你怎么知道什么时候该拿出来?你得不停地跑 LPOP。
但在 Redis 里,延迟任务的实现是这样的:
- 你把任务扔进一个 ZSET(有序集合)。
- 这个 ZSET 的 Score(分数)不是数字,而是 Unix 时间戳。
- 当 Worker 运行时,它会检查 ZSET 里有没有 当前时间戳小于等于 Score 的任务。
- 如果有,它就把这些任务弹出来,扔进真正的执行队列(List)里,然后去执行。
这就像是你在发朋友圈,不是发给所有人,而是设置了“仅 3 小时后可见”。Redis 的 ZSET 就像是那个发条装置,时间一到,它就自动把帖子曝光到“正在浏览”的列表里。
三、 核心实战:SEO 爬虫的 Job 类
好了,废话少说,代码才是硬道理。我们设计一个 SeoCrawlerJob 类。
假设我们要爬取一个电商网站的商品详情页,提取关键词、价格和评论。
<?php
namespace AppJobs;
use IlluminateBusQueueable;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
use IlluminateQueueInteractsWithQueue;
use IlluminateQueueSerializesModels;
use IlluminateSupportFacadesLog;
use IlluminateSupportFacadesHttp; // 用于模拟 HTTP 请求
class SeoCrawlerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $url;
public $tries = 3; // 重试次数:失败了别急着扔,再给一次机会
public $timeout = 60; // 超时时间:爬一个页面最多给你 60 秒,别卡死整个队列
/**
* Create a new job instance.
*/
public function __construct(string $url)
{
$this->url = $url;
}
/**
* Execute the job.
*/
public function handle()
{
Log::info("开始爬取: " . $this->url);
// 模拟一个耗时操作,或者真实的 HTTP 请求
// 这里为了演示,我们直接 sleep 一下,模拟网络延迟
// 实际上你应该用 Guzzle 或 Laravel HTTP 客户端
try {
$response = Http::timeout(30)->get($this->url);
if ($response->successful()) {
$content = $response->body();
// 这里写你的 SEO 解析逻辑
// 比如:提取 Meta 标签、分析关键词密度...
$this->analyzeSeoContent($content);
Log::info("爬取成功: " . $this->url);
} else {
Log::warning("爬取失败: " . $this->url . " Status: " . $response->status());
}
} catch (Exception $e) {
// 如果发生异常,且不是最后一次重试,Laravel 会自动抛出异常
// 如果是最后一次(tries=3 失败了),会进入 failed 方法
throw $e;
}
}
/**
* 任务失败的处理方法。
* 这里的逻辑非常重要:如果你的爬虫因为 502 Bad Gateway 挂了,
* 你得知道是哪个 URL 失败了,而不是一团模糊的“任务失败”。
*/
public function failed(Throwable $exception)
{
Log::error("SEO 任务彻底失败: " . $this->url, [
'error' => $exception->getMessage()
]);
// 把失败的 URL 写入一个“失败池”表,供人工干预
AppModelsFailedSeoUrl::create([
'url' => $this->url,
'error' => $exception->getMessage(),
'attempts' => $this->attempts()
]);
}
private function analyzeSeoContent($content)
{
// 解析逻辑...
}
}
代码解析:
你看 failed 方法,这招叫“失败隔离”。如果你直接在 handle 里抛异常,可能会干扰后续任务。把失败信息详细记录下来,这叫“有始有终”。
四、 延迟分发:不要让用户等太久
现在的问题是,我们要爬 100 万个 URL,怎么办?直接 dispatch(new SeoCrawlerJob($url))?那你服务器 CPU 就会爆炸,且 HTTP 请求可能会被封禁。
我们需要延迟队列。比如,我们想在凌晨 3 点爬,或者每隔 1 分钟爬 100 个。
基础延迟
Laravel 提供了很方便的方法:
// 5 分钟后执行
SeoCrawlerJob::dispatch($url)->delay(now()->addMinutes(5));
// 指定时间执行
SeoCrawlerJob::dispatch($url)->delay(now()->addDays(1));
进阶延迟:基于时间戳的精确控制
如果你有一批数据,每个数据都有自己的“执行时间”,你不能一个个去 dispatch。你需要一个控制器,先把这批数据塞进 Redis 的 ZSET,然后让 Worker 去轮询。
让我们来写一个分发器:
public function distributeSeoTasks()
{
$urls = UrlList::where('status', 'pending')->take(1000)->get();
// 我们不直接 dispatch,而是手动操作 Redis ZSET
// Redis Key: seo:delayed:queue
// Score: Unix 时间戳
$redis = IlluminateSupportFacadesRedis::connection();
$key = 'seo:delayed:queue';
foreach ($urls as $url) {
// 假设每个 URL 的执行时间是它的创建时间 + 24 小时
$delayTimestamp = $url->created_at->addDay()->timestamp;
$redis->zAdd($key, $delayTimestamp, json_encode([
'url' => $url->url,
'id' => $url->id,
'data' => $url->data
]));
}
Log::info("已将 " . count($urls) . " 个 SEO 任务推送到延迟队列");
}
五、 Worker 守护进程:孤独的守夜人
现在数据进去了,但谁来取呢?这就是 Worker 的作用。
你需要启动一个“永动机”式的进程,不断地去 Redis 里捞任务。
启动命令
# 启动一个 Worker,处理默认队列
php artisan queue:work
# 启动两个 Worker(并发处理)
php artisan queue:work --queue=seo,default --tries=3
# 启动时处理延迟队列(需要配合延迟监听)
php artisan queue:work redis --queue=default --delay=60
Supervisor:永不掉线的 Worker
但是,php artisan queue:work 如果关闭了终端窗口,它就停了。为了保证 24 小时运行,你需要 Supervisor。
在 Supervisor 配置文件里:
[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/your/project/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/laravel-worker.log
这行配置的意思是:
- 启动 4 个进程(并发度 4)。
- 每个进程都监听
redis队列。 - 如果一个任务执行了 3600 秒(1小时)还没结束,强制杀死它(防止死锁)。
- 如果任务执行失败,尝试重试 3 次。
- 如果任务卡住 3 秒没反应,就休息一下(
--sleep=3),降低 Redis 压力。
六、 持久化与重试机制:容错的艺术
在 SEO 场景下,网络是不稳定的。你今天爬得好好的,明天隔壁服务器挂了,你的 Worker 调用 google.com 就会失败。
Laravel 的重试机制是指数退避的。
- 第 1 次:Worker 拿到任务,执行,失败。记录错误,任务回到队列末尾。
- 第 2 次:等待一段时间(比如 10 秒),重新执行。
- 第 3 次:如果还失败,再等待(比如 30 秒)。
- 第 4 次(最后一次):彻底失败,触发
failed()方法。
如何防止重复爬取?
这是 SEO 爬虫最大的坑。如果你的 Worker 崩溃了重启,它会重新执行任务。这意味着 google.com 可能会收到你 10 次请求,然后拉黑你。
解决方案:分布式锁。
在 Laravel 中,可以使用 Cache::lock 来实现。在 Job 的 handle 开始时加锁:
public function handle()
{
// 锁的名称:这个 URL 在 5 分钟内只能被一个 Worker 处理
$lock = Cache::lock('seo:lock:' . md5($this->url), 300);
if ($lock->acquire()) {
try {
// 执行爬虫逻辑
$this->crawl();
} finally {
// 无论成功失败,一定要释放锁
$lock->release();
}
} else {
// 如果拿不到锁,说明已经有别的 Worker 在爬这个 URL 了
// 直接忽略,或者记录日志说“正在处理中”
Log::info("任务正在处理中,跳过: " . $this->url);
return;
}
}
七、 监控与可视化:别当瞎子
你现在有成千上万个 Worker 在后台跑,你怎么知道它们是不是在摸鱼?你怎么知道有多少任务堆积了?
这时候,Laravel Horizon 登场了。
Horizon 是 Laravel 官方出的一个基于 Redis 的监控 Dashboard。它能让你在浏览器里看到:
- 当前的队列长度(有多少任务在排队)。
- 任务的执行速度(每秒处理多少个)。
- 失败的任务列表。
- 系统资源占用。
安装 Horizon 简单到令人发指:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
php artisan horizon
然后在浏览器访问 /horizon。
你会看到一个炫酷的仪表盘,里面有黄色的方块代表任务,红色的代表失败。你可以在这里手动重试失败的任务,甚至配置自动重试策略。
八、 性能调优:如何压榨 Redis 的极限
如果你的 SEO 任务量巨大,比如每秒需要处理 1000 个 Job,默认配置可能顶不住。
1. 连接池
确保你用的是 PHP Redis 扩展(phpredis)而不是 Predis。phpredis 的底层是 C 语言,性能高出好几倍。
2. 序列化
Laravel 默认使用 json 序列化 Job 数据。这会有轻微的性能损耗。如果你的 Job 数据量很大,可以配置使用 php 序列化,但这要求你的 Job 类必须能被反序列化且没有循环引用。
// config/queue.php
'connections' => [
'redis' => [
'serialize' => false, // 关闭 JSON 序列化,提高性能,但要注意数据完整性
],
],
3. 并发数
Supervisor 的 numprocs 设置多少合适?取决于你的机器内存。通常情况下,每个 Worker 占用内存 20-50MB。如果你的机器有 16G 内存,可以开 300 个 Worker。
4. 队列优先级
不要把“发邮件”和“爬核心数据”混在一个队列里。邮件是次要的,爬数据是核心的。
你可以配置多个队列:
// dispatch
SeoCrawlerJob::dispatch($url)->onQueue('seo-core');
EmailJob::dispatch($email)->onQueue('email');
// Worker 监听
php artisan queue:work redis --queue=seo-core --queue=default
// 只有 seo-core 队列的任务处理完了,才会去处理 default 队列的邮件
九、 SEO 场景下的特殊战术
最后,针对 SEO 这个特定领域,再给几个小贴士。
1. 速率限制
虽然队列天然隔离了并发,但你不能无脑发请求。建议在 Job 里加一个计数器。比如,限制每秒最多处理 10 个请求。
// 使用 Redis 计数器
$key = "seo:rate_limit:" . date('Y-m-d:H');
$count = Redis::incr($key);
if ($count === 1) {
Redis::expire($key, 60); // 1分钟过期
}
if ($count > 10) {
// 10 秒后再重试
$this->release(10);
return;
}
2. 断点续传
如果爬取到第 500 万条时服务器挂了,怎么恢复?
你可以把 Job 里的 URL 放在数据库里。Job 失败后,不要删除数据,只是标记为 pending。或者,在 Job 的 handle 开始前,检查数据库状态,如果已经被处理过,直接 return。
十、 总结与展望
好了,兄弟们,今天咱们把 Laravel 队列、Redis 延迟任务、Supervisor 守护进程,甚至 Horizon 监控都揉在一起讲了。
你会发现,这套组合拳的核心在于 “解耦” 和 “持久化”。
- 解耦:你的主业务代码不需要知道“爬虫”这事儿,你只需要
dispatch一个任务,然后去喝杯咖啡。 - 持久化:Redis 就像你的保险箱,就算你的 PHP 进程意外身亡,任务依然安全地躺在 Redis 的 ZSET 里,等 Worker 醒来继续干。
这就是大规模系统的工程美学。虽然看着代码多,但只要你搭好了这个架子,后面加多少个亿的 SEO 任务,也就是加几个 Worker 的事儿。
希望这篇讲座能让你在以后面对海量任务时,不再手抖,不再慌张。记住,队列是程序员的拐杖,但在分布式系统里,它是你的利剑。
现在,去把你的代码跑起来吧!别忘了配置好 Horizon,看着那些黄色的方块跳动,你会很有成就感的。