這篇來自於看到朋友轉的58沈劍的一篇文章:1分鐘實現“延遲訊息”功能
在實際工作中也不止遇見過一次這個問題,我在想著以前是怎麼處理的呢?我記得當初在上家公司的時候直接使用的是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等操作。也是防止了鎖的使用把。每一步操作失敗了,都會有後續的步驟繼續幫忙完成,記錄等行為的。
本作品採用《CC 協議》,轉載必須註明作者和本文連結