Laravel 的消息队列剖析

在实际工作中也不止遇见过一次这个问题,我在想着以前是怎么处理的呢?我记得当初在上家公司的时候直接使用的是 laravel 的 queue 来实现的。当然,这里说的 laravel 的 queue 实际上也是基于 redis 的队列实现的。正好今天遇上这个问题,追下底层机制。

使用如下:http://learnku.com/docs/laravel/5.3/queues

// 创建10分钟后执行的任务

$job = (new ProcessPodcast($pocast))
            ->delay(Carbon::now()->addMinutes(10));

dispatch($job);
//启动队列命令
php artisan queue:work

分发部分

首先看 dispatch 这边做的事情:

dispatch 函数首先就是调用

return app(Dispatcher::class)->dispatch($job);
// Illuminate\Contracts\Bus\Dispatcher

首先需要理解这里的 Dispatcher::class 实际注入的是哪个类。

看到 vendor/laravel/framework/src/Illuminate/Bus/BusServiceProvider.php:26,有

public function register()
{
    $this->app->singleton('Illuminate\Bus\Dispatcher', function ($app) {
        return new Dispatcher($app, function ($connection = null) use ($app) {
            return $app['Illuminate\Contracts\Queue\Factory']->connection($connection);
        });
    });

    $this->app->alias(
        'Illuminate\Bus\Dispatcher', 'Illuminate\Contracts\Bus\Dispatcher'
    );

    $this->app->alias(
        'Illuminate\Bus\Dispatcher', 'Illuminate\Contracts\Bus\QueueingDispatcher'
    );
}

所以最后是实例化了 Illuminate\Bus\Dispatcher

看看它的 dispatch 函数做了啥?

public function dispatch($command)
{
    if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
        return $this->dispatchToQueue($command);
    } else {
        return $this->dispatchNow($command);
    }
}

假设我们的 dispatch 是基于队列的(ShouldQueue)。那么就是走 dispatchToQueue,最终,走的是 pushCommandToQueue

protected function pushCommandToQueue($queue, $command)
{
    ...

    if (isset($command->delay)) {
        return $queue->later($command->delay, $command);
    }
    ...
}

这里的 queue 就是队列的范畴了,假设我们用的队列是 redis。(队列的解析器就是 singleton 的时候传入的 Cluster)。最终这里落入的是 vendor/laravel/framework/src/Illuminate/Queue/RedisQueue.php:111 的

public function later($delay, $job, $data = '', $queue = null)
{
    $payload = $this->createPayload($job, $data);

    $this->getConnection()->zadd(
        $this->getQueue($queue).':delayed', $this->getTime() + $this->getSeconds($delay), $payload
    );

    return Arr::get(json_decode($payload, true), 'id');
}

这下就看清楚了:

laravel 的延迟队列,使用的是 zadd 命令,往 {$queue}:delayed,中插入一条 job 信息,它的 score 是执行时间。

(得到这条结论还真 tmd 是不容易)

队列监听部分

队列监听命令来自于: php artisan queue:work

命令行的入口就不追踪了,直接到 vendor/laravel/framework/src/Illuminate/Queue/Console/WorkCommand.php:29 类

protected function runWorker($connection, $queue)
{
    $this->worker->setCache($this->laravel['cache']->driver());

    return $this->worker->{$this->option('once') ? 'runNextJob' : 'daemon'}(
        $connection, $queue, $this->gatherWorkerOptions()
    );
}

这里的 daemon 和 runNextJob 是只跑一次还是持续跑的意思,我们当然假定是以 daemon 的形式在跑。

这里的 worker 是 vendor/laravel/framework/src/Illuminate/Queue/Worker.php:78

public function daemon($connectionName, $queue, WorkerOptions $options)
{
    $lastRestart = $this->getTimestampOfLastQueueRestart();

    while (true) {
        $this->registerTimeoutHandler($options);

        if ($this->daemonShouldRun($options)) {
            $this->runNextJob($connectionName, $queue, $options);
        } else {
            $this->sleep($options->sleep);
        }

        if ($this->memoryExceeded($options->memory) ||
            $this->queueShouldRestart($lastRestart)) {
            $this->stop();
        }
    }
}

这里的代码就值得我们自己写 deamon 的时候来参考了,它考虑了 timeout,考虑了 memory 的情况。

而 runNextJob 的命令实际上就很清晰了

public function runNextJob($connectionName, $queue, WorkerOptions $options)
{
    ...
        $job = $this->getNextJob(
            $this->manager->connection($connectionName), $queue
        );

        ...
            return $this->process(
                $connectionName, $job, $options
            );
    ...
}

这里的 Manager 对应的是 QueueManager, 这个类内部会创建一个 connector(vendor/laravel/framework/src/Illuminate/Queue/Connectors/RedisConnector.php:30)

public function connect(array $config)
{
    return new RedisQueue(
        $this->redis, $config['queue'],
        Arr::get($config, 'connection', $this->connection),
        Arr::get($config, 'retry_after', 60)
    );
}

看到这里就明白了,最后还是掉落到 RedisQueue 中。 很好,和我们前面的任务分发终于对上了,圈子差不多画完了,我们可以看到曙光了。

追到 RedisQueue 里面,看它的 pop 行为。

public function pop($queue = null)
{
    $original = $queue ?: $this->default;

    $queue = $this->getQueue($queue);

    $this->migrateExpiredJobs($queue.':delayed', $queue);

    if (! is_null($this->expire)) {
        $this->migrateExpiredJobs($queue.':reserved', $queue);
    }

    list($job, $reserved) = $this->getConnection()->eval(
        LuaScripts::pop(), 2, $queue, $queue.':reserved', $this->getTime() + $this->expire
    );

    if ($reserved) {
        return new RedisJob($this->container, $this, $job, $reserved, $original);
    }
}

这段就是精华了。它做了什么事情呢?

先看 migrateExpiredJobs:

public function migrateExpiredJobs($from, $to)
{
    $this->getConnection()->eval(
        LuaScripts::migrateExpiredJobs(), 2, $from, $to, $this->getTime()
    );
}

这里的 eval 就是对应 redis 的 eval 操作,https://redis.io/commands/eval,2 是说明后面有两个 key,最后一个 getTime () 获取的是 arg。
下面就看 lua 脚本了。

public static function migrateExpiredJobs()
{
    return <<<'LUA'
local val = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1])
if(next(val) ~= nil) then
redis.call('zremrangebyrank', KEYS[1], 0, #val - 1)
for i = 1, #val, 100 do
    redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val)))
end
end
return true
LUA;
}

结合起来看就是:

  • 使用 zrangebyscore 和 zremrangebyrank 从 {queue}:delayed 队列中,从 - inf 到 now 的任务拿出来。
  • 用 rpush 的方式存入到默认 queue 中(后续就是放入到 {queue}:reserved )

这个 zrangebyscore 就是判断延迟任务是否应该执行的操作了。

然后就进行的是

list($job, $reserved) = $this->getConnection()->eval(
    LuaScripts::pop(), 2, $queue, $queue.':reserved', $this->getTime() + $this->expire
);

这里的 LuaScripts::pop () 如下:

public static function pop()
{
    return <<<'LUA'
local job = redis.call('lpop', KEYS[1])
local reserved = false
if(job ~= false) then
reserved = cjson.decode(job)
reserved['attempts'] = reserved['attempts'] + 1
reserved = cjson.encode(reserved)
redis.call('zadd', KEYS[2], ARGV[1], reserved)
end
return {job, reserved}
LUA;
}

做了下面操作:

  • 把默认队列中的任务 lpop 出来
  • 将他的 attempts 次数 + 1
  • zadd 存入 {queue}:reserved 队列,score 为 now+60 (默认的过期时间)

最后,我就返回这个 job,这里结束了 getNextJob 的过程

process 过程就是调用了一下:vendor/laravel/framework/src/Illuminate/Queue/Worker.php:187

public function process($connectionName, $job, WorkerOptions $options)
{
    try {
        $this->raiseBeforeJobEvent($connectionName, $job);

        $this->markJobAsFailedIfAlreadyExceedsMaxAttempts(
            $connectionName, $job, (int) $options->maxTries
        );

        // Here we will fire off the job and let it process. We will catch any exceptions so
        // they can be reported to the developers logs, etc. Once the job is finished the
        // proper events will be fired to let any listeners know this job has finished.
        $job->fire();

        $this->raiseAfterJobEvent($connectionName, $job);
    } catch (Exception $e) {
        $this->handleJobException($connectionName, $job, $options, $e);
    } catch (Throwable $e) {
        $this->handleJobException(
            $connectionName, $job, $options, new FatalThrowableError($e)
        );
    }
}

$this->events->fire(new Events\JobProcessing(
    $connectionName, $job
));

这里的 raiseBeforeJobEvent 和 raiseAfterJobEvent 又是使用 event 和 listener 的形式来做处理的。这里的 $this->events 是 vendor/laravel/framework/src/Illuminate/Events/Dispatcher.php:197

这里就是触发了一个 Events\JobProcessing 事件,我们现在要找到对应的 lister:

答案是在 QueueManager 中定义的

/**
 * Register an event listener for the before job event.
 *
 * @param  mixed  $callback
 * @return void
 */
public function before($callback)
{
    $this->app['events']->listen(Events\JobProcessing::class, $callback);
}

/**
 * Register an event listener for the after job event.
 *
 * @param  mixed  $callback
 * @return void
 */
public function after($callback)
{
    $this->app['events']->listen(Events\JobProcessed::class, $callback);
}

换句话说,我们希望监听一个 job 开始和结束的时候,我们可以使用 QueueManager 的 before,after 来监听。比如发个邮件,唱唱小曲啥的。

那么这里我们,从 {queue}:reserved 中获取了 job 之后(这里的 job 是 RedisJob),我们是什么时候触发的 delete 呢?是在

$job->fire();

这个 fire 是 RedisJob (vendor/laravel/framework/src/Illuminate/Queue/Jobs/RedisJob.php) 但继承来自 vendor/laravel/framework/src/Illuminate/Queue/Jobs/Job.php:72, 经过调用 CallQueuedHandler,最终会落到
vendor/laravel/framework/src/Illuminate/Queue/RedisQueue.php:154

public function deleteReserved($queue, $job)
{
    $this->getConnection()->zrem($this->getQueue($queue).':reserved', $job);
}

这里就是将 job 从 {queue}:reserved 队列中删除。

至此,整个队列及延迟机制就处理完了。

实际
我们实际监听一下 redis 就可以验证结果:

// 使用dispatch
1489802272.491060 [0 127.0.0.1:63798] "SELECT" "0"
1489802272.491513 [0 127.0.0.1:63798] "ZADD" "queues:default:delayed" "1489802332" "{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\DelayTestJob\",\"command\":\"O:21:\\\"App\\\\Jobs\\\\DelayTestJob\\\":4:{s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";O:13:\\\"Carbon\\\\Carbon\\\":3:{s:4:\\\"date\\\";s:26:\\\"2017-03-18 01:58:52.000000\\\";s:13:\\\"timezone_type\\\";i:3;s:8:\\\"timezone\\\";s:3:\\\"UTC\\\";}}\"},\"id\":\"q7ss6fRgCbMNHhCv6gOXX0Or7B43blU9\",\"attempts\":1}"

// 1分钟后
1489802333.957500 [0 127.0.0.1:63792] "EVAL" "local val = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1])\nif(next(val) ~= nil) then\n    redis.call('zremrangebyrank', KEYS[1], 0, #val - 1)\n    for i = 1, #val, 100 do\n        redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val)))\n    end\nend\nreturn true" "2" "queues:default:delayed" "queues:default" "1489802333"
1489802333.957563 [0 lua] "zrangebyscore" "queues:default:delayed" "-inf" "1489802333"
1489802333.957586 [0 lua] "zremrangebyrank" "queues:default:delayed" "0" "0"
1489802333.958628 [0 lua] "rpush" "queues:default" "{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\DelayTestJob\",\"command\":\"O:21:\\\"App\\\\Jobs\\\\DelayTestJob\\\":4:{s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";O:13:\\\"Carbon\\\\Carbon\\\":3:{s:4:\\\"date\\\";s:26:\\\"2017-03-18 01:58:52.000000\\\";s:13:\\\"timezone_type\\\";i:3;s:8:\\\"timezone\\\";s:3:\\\"UTC\\\";}}\"},\"id\":\"q7ss6fRgCbMNHhCv6gOXX0Or7B43blU9\",\"attempts\":1}"
1489802333.959572 [0 127.0.0.1:63792] "EVAL" "local val = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1])\nif(next(val) ~= nil) then\n    redis.call('zremrangebyrank', KEYS[1], 0, #val - 1)\n    for i = 1, #val, 100 do\n        redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val)))\n    end\nend\nreturn true" "2" "queues:default:reserved" "queues:default" "1489802333"
1489802333.959672 [0 lua] "zrangebyscore" "queues:default:reserved" "-inf" "1489802333"
1489802333.959866 [0 127.0.0.1:63792] "EVAL" "local job = redis.call('lpop', KEYS[1])\nlocal reserved = false\nif(job ~= false) then\n    reserved = cjson.decode(job)\n    reserved['attempts'] = reserved['attempts'] + 1\n    reserved = cjson.encode(reserved)\n    redis.call('zadd', KEYS[2], ARGV[1], reserved)\nend\nreturn {job, reserved}" "2" "queues:default" "queues:default:reserved" "1489802343"
1489802333.959938 [0 lua] "lpop" "queues:default"
1489802333.959965 [0 lua] "zadd" "queues:default:reserved" "1489802343" "{\"id\":\"q7ss6fRgCbMNHhCv6gOXX0Or7B43blU9\",\"attempts\":2,\"data\":{\"command\":\"O:21:\\\"App\\\\Jobs\\\\DelayTestJob\\\":4:{s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";O:13:\\\"Carbon\\\\Carbon\\\":3:{s:4:\\\"date\\\";s:26:\\\"2017-03-18 01:58:52.000000\\\";s:13:\\\"timezone_type\\\";i:3;s:8:\\\"timezone\\\";s:3:\\\"UTC\\\";}}\",\"commandName\":\"App\\\\Jobs\\\\DelayTestJob\"},\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\"}"
1489802333.963223 [0 127.0.0.1:63792] "ZREM" "queues:default:reserved" "{\"id\":\"q7ss6fRgCbMNHhCv6gOXX0Or7B43blU9\",\"attempts\":2,\"data\":{\"command\":\"O:21:\\\"App\\\\Jobs\\\\DelayTestJob\\\":4:{s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";O:13:\\\"Carbon\\\\Carbon\\\":3:{s:4:\\\"date\\\";s:26:\\\"2017-03-18 01:58:52.000000\\\";s:13:\\\"timezone_type\\\";i:3;s:8:\\\"timezone\\\";s:3:\\\"UTC\\\";}}\",\"commandName\":\"App\\\\Jobs\\\\DelayTestJob\"},\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\"}"

精简下路径就是:

// 第一步:先往delayed队列中插入job
1489802272.491513 [0 127.0.0.1:63798] "ZADD" "queues:default:delayed" "1489802332" {job}

// 第二步,将delayed队列中到期的job取出,并且rpush进default队列
1489802333.957563 [0 lua] "zrangebyscore" "queues:default:delayed" "-inf" "1489802333"
1489802333.957586 [0 lua] "zremrangebyrank" "queues:default:delayed" "0" "0"
1489802333.958628 [0 lua] "rpush" "queues:default" {job}

// 第三步,从default队列中lpop出job
1489802333.959938 [0 lua] "lpop" "queues:default"

// 第四步,zadd到default:reserved
1489802333.959965 [0 lua] "zadd" "queues:default:reserved" "1489802343" {job}

// 第五步,程序处理这个job

// 第六步,讲job从default:reserved中删除
1489802333.963223 [0 127.0.0.1:63792] "ZREM" "queues:default:reserved" {job}
符合预期。

总结
laravel 这边的延迟队列使用了三个队列。

queue:default:delayed // 存储延迟任务
queue:default // 存储 “生” 任务,就是未处理任务
queue:default:reserved // 存储待处理任务
任务在三个队列中进行轮转,最后一定进入到 queue:default:reserved,并且成功后把任务从这个队列中删除。

其间还使用了 lua 脚本,所以至少 laravel5.3(本文的 laravel 环境)在无 lua 脚本支持的 redis 版本是跑不了的。

它用三个队列把所有的步骤给原子了,所以并没有使用 multi 等操作。也是防止了锁的使用把。每一步操作失败了,都会有后续的步骤继续帮忙完成,记录等行为的。

————————————————
原文作者:轩脉刃
转自链接:https://learnku.com/articles/4169/analysis-of-laravel-message-queue
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请保留以上作者信息和原文链接。laravel 的消息队列剖析

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值