Laravel 队列调度(Queues)物理实现:基于 Redis 延迟任务队列实现大规模 SEO 任务的持久化分发

各位同学,大家好!

今天咱们不聊那些虚头巴脑的理论,咱们来点硬菜。假设你是个架构师,手里接了个大单子——给一个拥有百万级 URL 的电商平台做 SEO 优化。这可不是发发外链那么简单,这是要你用 PHP(Laravel)去“剥削”互联网的每一个角落。

这时候,如果你的代码同步运行,哪怕你把 CPU 点燃了,也没法在用户眼皮子底下把百万数据爬完。你会被后台弹窗骂死,被老板炒鱿鱼,最后只能含泪把键盘吃下去。

所以,咱们今天的主题是:Laravel 队列调度(Queues)物理实现:基于 Redis 延迟任务队列实现大规模 SEO 任务的持久化分发。

准备好了吗?拿起键盘,咱们开始吧。

一、 同步执行的诅咒:为什么你不能把所有事情都做完?

想象一下,你的 SEO 爬虫程序是个单线程的急性子。用户点击“开始全网优化”,你的代码就在那儿“嗷嗷”地跑,一个接一个地访问 google.comamazon.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 里,延迟任务的实现是这样的:

  1. 你把任务扔进一个 ZSET(有序集合)。
  2. 这个 ZSET 的 Score(分数)不是数字,而是 Unix 时间戳
  3. 当 Worker 运行时,它会检查 ZSET 里有没有 当前时间戳小于等于 Score 的任务
  4. 如果有,它就把这些任务弹出来,扔进真正的执行队列(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

这行配置的意思是:

  1. 启动 4 个进程(并发度 4)。
  2. 每个进程都监听 redis 队列。
  3. 如果一个任务执行了 3600 秒(1小时)还没结束,强制杀死它(防止死锁)。
  4. 如果任务执行失败,尝试重试 3 次。
  5. 如果任务卡住 3 秒没反应,就休息一下(--sleep=3),降低 Redis 压力。

六、 持久化与重试机制:容错的艺术

在 SEO 场景下,网络是不稳定的。你今天爬得好好的,明天隔壁服务器挂了,你的 Worker 调用 google.com 就会失败。

Laravel 的重试机制是指数退避的。

  1. 第 1 次:Worker 拿到任务,执行,失败。记录错误,任务回到队列末尾。
  2. 第 2 次:等待一段时间(比如 10 秒),重新执行。
  3. 第 3 次:如果还失败,再等待(比如 30 秒)。
  4. 第 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。它能让你在浏览器里看到:

  1. 当前的队列长度(有多少任务在排队)。
  2. 任务的执行速度(每秒处理多少个)。
  3. 失败的任务列表。
  4. 系统资源占用。

安装 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,看着那些黄色的方块跳动,你会很有成就感的。

发表回复

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