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

嘿,各位码农朋友们,搬好小板凳,把那杯刚泡好的枸杞咖啡放一边。今天我们不聊那些虚头巴脑的框架文档,咱们来点硬核的。

咱们的主角是 Laravel 队列,背景板是 Redis,业务场景是 大规模 SEO 任务分发

别打哈欠,SEO 听起来枯燥,但当你面对几百万个 URL 需要爬取、分析、去重、入库,而你的服务器只有两台这就有点尴尬了。这时候,同步执行?拜拜了您嘞,你的 CPU 会告诉你什么叫“心脏病发作”。

今天,咱们就扒开 Laravel 的外衣,看看底下的 Redis 是怎么玩转延迟队列的。这不仅是技术,更是一场关于“如何在老板催更之前把活干完”的战术研讨会。

第一章:同步地狱与异步正义

首先,咱们得搞清楚为什么要用队列。

假设你要写一个 SEO 工具,功能很简单:抓取 100 万个网页的标题和描述。你是个新手,你写了这样的代码:

foreach ($urls as $url) {
    // 调用第三方 API 或爬虫
    $data = ScrapeService::get($url);
    DB::table('seo_data')->insert($data);
    // 甚至可能还要 sleep(1) 限制频率
}

代码跑起来了,前端发个请求,然后你就去喝咖啡。等你回来,发现数据库锁死了,CPU 飙到 100%,网络带宽跑满了,而你的服务器因为内存溢出直接蓝屏。老板问你:“任务完成了吗?”你指着蓝屏说:“完成了,就是有点费电脑。”

这就是同步执行的代价——它太诚实了,它把所有的痛苦都一股脑地吐给你。

现在,咱们引入 Laravel 队列。Queue 的核心思想是 “解耦”“吞吐”

  • 解耦:用户发起请求 -> 把任务扔进队列 -> 立即返回“正在处理中”。用户的请求瞬间秒回,体验好得像飞一样。
  • 吞吐:后台有几十个 Worker(工人)在排队干活。你有 100 万个任务,你扔进队列,Worker 们就像饿狼扑食一样,一秒钟处理几百个。

第二章:Redis —— 队列的灵魂载体

咱们为什么选 Redis?MySQL 不好吗?MySQL 是个老实人,它擅长存数据,不擅长做这种高频的“取、放、取、放”的游戏。Redis 是个赛车手,内存操作,毫秒级延迟,而且它是单线程事件循环,稳得很。

在 Redis 的世界里,队列其实就是一个 List(列表)

LPUSH queue:seo-tasks 'url=http://google.com'

但这还不够。我们需要 延迟任务。SEO 任务有个特点:不能一股脑全发了。比如,你刚给竞争对手的网站爬了一遍,过 1 小时再爬,这就是重复劳动。通常我们需要在 10 分钟后、1 小时后、或者第二天再爬取。

这就是 延迟队列 的用武之地。

在物理层面上,Laravel 不只是把任务存进一个 List。它使用了两种 Redis 数据结构来大显身手:

  1. Sorted Set (ZSET):这是延迟队列的核心。ZSET 是一个有序集合,它的每个元素都有一个 score。我们可以利用这个 score 来存“预定执行的时间戳”。Redis 会自动帮你把时间最靠前的任务排在最前面。
  2. List (LPUSH/BRPOP):这是实际干活的地方。Worker 不停地盯着这个 List,只要有人往里面扔任务,就马上捡起来处理。

第三章:配置你的 Redis 战场

在动代码之前,得先把枪擦亮。Laravel 默认就支持 Redis 队列,配置都在 .env 里。

QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

咱们来调整一下 config/queue.php。这是咱们自定义战术的地方。

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'block_for' => null,
],

注意看 block_for。这个参数决定了 Worker 在没活干的时候是在空转 CPU,还是在睡大觉。设置为 null(默认)意味着“没活干就死等”。设置为 0 意味着“没活干就扔个空任务并销毁自己”。对于咱们这种大规模 SEO 任务,咱们希望 Worker 保持存活,所以保持默认,或者设个 6 秒。

第四章:物理实现深度解析 —— ZSET 的魔法

这是今天的重头戏。Laravel 的 IlluminateQueueRedisQueue 类,就是那个在幕后挥舞指挥棒的魔术师。

当你要发布一个延迟任务时,Laravel 会这么做:

  1. 计算时间:假设你要延迟 10 分钟。
  2. 推送到 ZSET:它会把任务序列化(JSON),扔进一个名为 queue:default:delayed 的 Sorted Set 里,分数(score)就是当前时间 + 10分钟。
  3. 定时唤醒:后台有个进程(通常由 Supervisor 管理)专门盯着这个 ZSET。它使用 ZRANGEBYSCORE 命令,只取那些 score 小于等于“当前时间”的任务。

来,咱们看看 IlluminateQueueRedisQueue 的源码逻辑(伪代码版,便于理解):

// 这里的伪代码展示了 Laravel 内部是如何在 Redis 上跳舞的

public function pop($queue = null)
{
    $this->ensureExists($this->getQueue($queue));
    $this->releaseDelayedItems($this->getQueue($queue));

    // 核心逻辑:从 List 的头部(LPUSH的一端)弹出一个任务
    // BRPOP 是 Blocking RPOP,意思就是“如果没有任务,我就乖乖排队等,直到有任务来”
    $response = $this->redis->brpop($this->getQueue($queue), $this->options->block_for);

    if (!is_null($response)) {
        return $this->payload($response[1]);
    }
}

// 这里的伪代码展示了如何处理延迟队列
protected function releaseDelayedItems($queue)
{
    // 1. 查找所有“过期”的任务
    // 也就是 score <= 当前时间的任务
    $items = $this->redis->zrangebyscore(
        $queue . ':delayed', 
        '-inf', 
        $now = microtime(true)
    );

    if (count($items) > 0) {
        // 2. 从 ZSET 中移除它们(因为它们要“转正”了)
        $this->redis->zremrangebyscore(
            $queue . ':delayed', 
            '-inf', 
            $now
        );

        // 3. 把它们扔回工作队列(List)
        // 这样 pop() 方法就能在下一轮循环中把它们抓走
        foreach ($items as $item) {
            $this->redis->rpush($queue, $item);
        }
    }
}

各位看官,注意这个设计哲学!

这就是所谓的 “先放进去,再拿出来”。咱们不等着,咱们先把任务存进 ZSET 的保险箱里,然后 Worker 专门有一个时间间隔(默认 1 秒)去检查一次保险箱,把过期的任务拿出来插到工作队列里。

这就像你在超市排队结账。ZSET 是“过号补票区”,List 是“正在结账区”。收银员(Worker)一直看着 ZSET,谁过号了,就喊一声“补票!”,然后把他插到 List 的队尾。

第五章:实战代码 —— 你的 SEO 爬虫军团

好了,理论讲多了容易消化不良,咱们来点代码。假设你有个类叫 SeoCrawlerJob,它负责干苦力。

1. 定义任务类

<?php

namespace AppJobs;

use IlluminateBusQueueable;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
use IlluminateQueueInteractsWithQueue;
use IlluminateQueueSerializesModels;
use Exception;

class SeoCrawlerJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $url;
    public $tries = 3; // 重试次数:老板,我实在爬不到这个网页,能不能再给我一次机会?
    public $timeout = 120; // 超时时间:别卡在一个死循环里了

    /**
     * Create a new job instance.
     */
    public function __construct($url)
    {
        $this->url = $url;
    }

    /**
     * Execute the job.
     */
    public function handle()
    {
        // 模拟爬取逻辑
        // 实际上你可能用的是 Goutte, Guzzle 或者 Puppeteer
        echo "开始爬取: {$this->url}n";

        // 模拟耗时操作
        sleep(2); 

        // 模拟随机失败,测试重试机制
        // if (rand(0, 10) > 8) throw new Exception("网络抖动!");

        // 爬取成功,入库...
        // DB::table('seo_results')->insert(['url' => $this->url, 'status' => 'success']);

        echo "爬取完成: {$this->url}n";
    }

    /**
     * The job failed to execute.
     */
    public function failed(Exception $exception)
    {
        // 这里的代码在重试 3 次都失败后执行
        // 可以记录日志,或者发个钉钉通知给老板
        Log::error("爬虫挂了", ['url' => $this->url, 'error' => $exception->getMessage()]);
    }
}

2. 发布任务 —— 瞬间分发

在你的 Controller 或者 Command 里,怎么把任务扔进去?

// 假设你有 10000 个 URL 需要处理
$urls = UrlList::all()->pluck('url');

foreach ($urls as $url) {
    // 核心魔法:delay()
    // 这里的意思是:把这个任务扔进队列,但告诉 Laravel:别动!等 10 分钟再动!
    AppJobsSeoCrawlerJob::dispatch($url)->delay(now()->addMinutes(10));
}

// 还没完!如果这些 URL 要分批爬,比如第一批 5000 个,第二批 5000 个
// 你可以在发布完第一批后,等待一段时间,再发布第二批

物理层面的解释:当你调用 delay() 时,Laravel 会把这个序列化后的 $url,扔进 queue:default:delayed 这个 ZSET 里,分数是 当前时间 + 600秒

第六章:大规模分发与并发控制

光有队列还不行,你还要解决“谁在干活”的问题。

1. 部署 Worker

你需要启动多个进程来抢任务。用 Supervisor 管理是最优雅的。

supervisord.conf 里,你可以启动 10 个 Worker 进程:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/your/project/artisan queue:work redis --sleep=3 --tries=3
autostart=true
autorestart=true
user=www-data
numprocs=10
redirect_stderr=true
stdout_logfile=/path/to/your/project/worker.log

注意那个 --sleep=3。当队列空的时候,Worker 不会死掉,但会休眠 3 秒再试。这对 SEO 任务很好,因为咱们不需要 100% 的 CPU 占用率,咱们需要的是“高并发、低功耗”。

2. 并发控制

有时候,你不能让 10 个 Worker 同时爬同一个域名,否则 IP 就被封锁了。虽然 Laravel 的队列本身没有跨进程的锁机制,但你可以利用 Priority Queues(优先级队列) 来模拟。

Redis 支持多个队列:
queue:high (VIP 任务)
queue:default (普通任务)
queue:low (垃圾任务)

调度器可以这么写:

// VIP 客户的任务,插到 high 队列,5 秒内执行
dispatch(new AnalyzeVipSite($url))->onQueue('high');

// 普通任务,插到 default 队列
dispatch(new CrawlUrl($url));

// 爬虫任务插到 low 队列
dispatch(new CrawlUrl($url))->onQueue('low');

然后启动不同数量的 Worker:

# 高优先级 Worker,10 个进程,疯狂干活
[program:high-worker]
command=php artisan queue:work redis --queue=high --sleep=1
numprocs=10

# 低优先级 Worker,2 个进程,慢悠悠干活
[program:low-worker]
command=php artisan queue:work redis --queue=low --sleep=5
numprocs=2

效果:即使你的 VIP 任务堆积如山,你的低优先级 SEO 任务也不会卡住 VIP 任务的通道。这就是架构的弹性。

第七章:Redis 命令行实战 —— 看不见的战场

作为一名资深专家,你不仅会写 PHP,还得会玩 Redis CLI。当你需要排查问题,或者手动干预时,这些命令就是你的武器。

1. 查看延迟队列里有多少任务在“等死”

redis-cli
> ZCARD queue:default:delayed
(integer) 45

45 个任务在等待执行,说明你的调度很合理。

2. 查看当前工作队列的长度

> LLEN queue:default
(integer) 1000

1000 个任务正在排队,你的 10 个 Worker 正在奋力拼搏。

3. 强制处理过期的任务(调试神器)

如果你写了测试代码,想手动触发某个延迟任务,但又不想等 10 分钟,你可以用 ZRANGE 手动把它拿出来,再扔进 List。

# 1. 获取所有到期的任务(score <= 当前时间)
> ZRANGEBYSCORE queue:default:delayed -inf +inf

# 假设拿到了 ID: abc123

# 2. 删除它(从 ZSET 里)
> ZREM queue:default:delayed abc123

# 3. 强制推送到队列头部
> LPUSH queue:default abc123

现在你的 Worker 瞬间就能抓到这个任务了。

4. 监控队列(TL;DR 版)

> MONITOR

哇,你会看到成千上万条命令像弹幕一样刷屏。这能让你直观地看到 Laravel 和 Redis 的交互过程,虽然有点乱,但很刺激。

第八章:故障处理与“烂摊子”清理

在 SEO 领域,总有那么几个网址是挂掉的,或者服务器反爬策略太变态。如果任务一直失败怎么办?

Laravel 的 Retry 机制很强大,但不是无限的。

  • tries 属性:我们在代码里设置了 public $tries = 3;
  • retry_after 配置:在 config/queue.php 里,这是 90 秒。意思是,任务失败后,会进入“失败队列”,90 秒后系统会自动再试一次。

如果 90 秒后重试还是失败,任务就会变成“僵尸任务”,留在 Redis 里。

清理僵尸任务

Redis 里有两个关键键:
queue:default:failed (List)
queue:default:delayed (ZSET)

如果堆积了太多的失败任务,你可以写个脚本清理:

// 清理超过 100 个失败任务
$count = Redis::llen('queue:default:failed');
if ($count > 100) {
    Redis::ltrim('queue:default:failed', 100, -1);
    echo "清理了 $count 个失败任务";
}

// 或者干脆把整个失败队列清空(慎用!)
// Redis::del('queue:default:failed');

第九章:性能优化 —— 别让 Redis 成为瓶颈

当 SEO 任务达到千万级时,单个 Redis 实例扛不住怎么办?

  1. Pipeline(管道):在发布任务时,不要一个一个 dispatch。使用 dispatch_now 或者批量插入命令。虽然 Laravel 的 dispatch 是异步的,但如果数据量巨大,可以考虑直接操作 Redis:

    $payload = json_encode(['url' => $url]);
    Redis::lpush('queue:seo-tasks', $payload);
    Redis::lpush('queue:seo-tasks:delayed', $payload);

    这样比经过 Laravel 框架的 ORM 层要快得多。

  2. 分片:不要只用一个 default 队列。拆分成 queue:seo-crawl, queue:seo-index, queue:seo-notify。每个队列对应一个 Redis 实例。这样可以横向扩展,只要你有足够的 Redis 服务器和 Worker 进程。

  3. 内存清理:确保你的 Redis 配置了 maxmemory-policy。如果队列积压太多,Redis 会开始踢人,导致 Worker 报错。设置 maxmemory-policy allkeys-lru,让 Redis 自己决定删谁。

第十章:总结 —— 代码之外的哲学

好了,咱们聊了这么多。

从同步的“单线程悲剧”到异步的“多线程狂欢”,我们构建了一个基于 Redis 的延迟队列系统。

  • Laravel 提供了优雅的 API,让我们可以像写同步代码一样写异步逻辑。
  • Redis 提供了高性能的存储引擎,ZSET 让时间管理变得井井有条。
  • Supervisor 提供了稳定的运行环境,让 Worker 永远在线。

在 SEO 这个领域,数据是新的石油,但只有高效的管道(队列)才能把油运到炼油厂(数据库)。如果管道堵塞(同步执行),你的服务器就会爆炸。

所以,当你下一次面对“帮我爬一下这个网站的所有页面”的需求时,记得深吸一口气,微笑着说:“没问题,但我需要先配置一下队列。”

毕竟,让服务器 24 小时待命的不是你,是那些默默工作的 Worker 进程。祝你们的爬虫爬得快乐,数据库涨得飞快!

发表回复

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