剖析 Laravel 佇列系統--Worker

Ryan發表於2017-07-17

譯文GitHub https://github.com/yuansir/diving-laravel-...

原文連結 https://divinglaravel.com/queue-system/wor...

現在,我們知道了Laravel如何將作業推到不同的佇列中,讓我們來深入瞭解workers如何運作你的作業。 首先,我將workers定義為一個在後臺執行的簡單PHP程式,目的是從儲存空間中提取作業並針對多個配置選項執行它們。

php artisan queue:work

執行此命令將指示Laravel建立應用程式的一個例項並開始執行作業,這個例項將一直存活著,啟動Laravel應用程式的操作只在執行命令時發生一次,同一個例項將被用於執行你的作業,這意味著:

  • 避免在每個作業上啟動整個應用程式來節省伺服器資源。
  • 在應用程式中所做的任何程式碼更改後必須手動重啟worker。

你也可以這樣執行:

php artisan queue:work --once

這將啟動應用程式的一個例項,處理單個作業,然後幹掉指令碼。

php artisan queue:listen

queue:listen 命令相當於無限迴圈地執行 queue:work --once 命令,這將導致以下問題:

  • 每個迴圈都會啟動一個應用程式例項。
  • 分配的worker將選擇一個工作並執行。
  • worker程式將被幹掉。

使用 queue:listen 確保為每個作業建立一個新的應用程式例項,這意味著程式碼更改以後不必手動重啟worker,同時也意味著將消耗更多的伺服器資源。

我們來看看 Queue\Console\WorkCommand 類的 handle() 方法,這是當你執行 php artisan queue:work 時會執行的方法:

public function handle()
{
    if ($this->downForMaintenance() && $this->option('once')) {
        return $this->worker->sleep($this->option('sleep'));
    }

    $this->listenForEvents();

    $connection = $this->argument('connection')
                    ?: $this->laravel['config']['queue.default'];

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

    $this->runWorker(
        $connection, $queue
    );
}

首先,我們檢查應用程式是否處於維護模式,並使用 --once 選項,在這種情況下,我們希望指令碼正常執行,因此我們不執行任何作業,我們只需要在完全殺死指令碼前讓worker在一段時間內休眠。

Queue\Workersleep() 方法看起來像這樣:

public function sleep($seconds)
{
    sleep($seconds);
}

為什麼我們不能在 handle() 方法中返回null來終止指令碼?

如前所述, queue:listen 命令在迴圈中執行 WorkCommand

while (true) {
     // This process simply calls 'php artisan queue:work --once'
    $this->runProcess($process, $options->memory);
}

如果應用程式處於維護模式,並且 WorkCommand 立即終止,這將導致迴圈結束,下一個在很短的時間內啟動,最好在這種情況下導致一些延遲,而不是通過建立我們不會真正使用的大量應用程式例項。

監聽事件

handle() 方法裡面我們呼叫 listenForEvents() 方法:

protected function listenForEvents()
{
    $this->laravel['events']->listen(JobProcessing::class, function ($event) {
        $this->writeOutput($event->job, 'starting');
    });

    $this->laravel['events']->listen(JobProcessed::class, function ($event) {
        $this->writeOutput($event->job, 'success');
    });

    $this->laravel['events']->listen(JobFailed::class, function ($event) {
        $this->writeOutput($event->job, 'failed');

        $this->logFailedJob($event);
    });
}

在這個方法中我們會監聽幾個事件,這樣我們可以在每次作業處理中,處理完或處理失敗時向使用者列印一些資訊。

記錄失敗作業

一旦作業失敗 logFailedJob() 方法會被呼叫

$this->laravel['queue.failer']->log(
    $event->connectionName, $event->job->getQueue(),
    $event->job->getRawBody(), $event->exception
);

queue.failer 容器別名在 Queue\QueueServiceProvider::registerFailedJobServices() 中註冊:

protected function registerFailedJobServices()
{
    $this->app->singleton('queue.failer', function () {
        $config = $this->app['config']['queue.failed'];

        return isset($config['table'])
                    ? $this->databaseFailedJobProvider($config)
                    : new NullFailedJobProvider;
    });
}

/**
 * Create a new database failed job provider.
 *
 * @param  array  $config
 * @return \Illuminate\Queue\Failed\DatabaseFailedJobProvider
 */
protected function databaseFailedJobProvider($config)
{
    return new DatabaseFailedJobProvider(
        $this->app['db'], $config['database'], $config['table']
    );
}

如果配置了 queue.failed ,則將使用資料庫佇列失敗,並將有關失敗作業的資訊簡單地儲存在資料庫表中的:

$this->getTable()->insertGetId(compact(
    'connection', 'queue', 'payload', 'exception', 'failed_at'
));

執行worker

要執行worker,我們需要收集兩條資訊:

  • worker的連線資訊從作業中提取
  • worker找到作業的佇列

如果沒有使用 queue.default 配置定義的預設連線。您可以為 queue:work 命令提供 --connection=default 選項。

佇列也是一樣,您可以提供一個 --queue=emails 選項,或選擇連線配置中的 queue 選項。一旦這一切完成, WorkCommand::handle() 方法會執行 runWorker()

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

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

在worker類屬性在命令構造後設定:

public function __construct(Worker $worker)
{
    parent::__construct();

    $this->worker = $worker;
}

容器解析 Queue\Worker 例項,在runWorker()中我們設定了worker將使用的快取驅動,我們也根據--once 命令來決定我們呼叫什麼方法。

如果使用 --once 選項,我們只需呼叫 runNextJob 來執行下一個可用的作業,然後指令碼就會終止。 否則,我們將呼叫 daemon 方法來始終保持程式處理作業。

在開始工作時,我們使用 gatherWorkerOptions() 方法收集使用者給出的命令選項,我們稍後會提供這些選項,這個工具是 runNextJobdaemon 方法。

protected function gatherWorkerOptions()
{
    return new WorkerOptions(
        $this->option('delay'), $this->option('memory'),
        $this->option('timeout'), $this->option('sleep'),
        $this->option('tries'), $this->option('force')
    );
}

守護程式

讓我看看 Worker::daemon() 方法,這個方法的第一行呼叫了 Worker::daemon() 方法

protected function listenForSignals()
{
    if ($this->supportsAsyncSignals()) {
        pcntl_async_signals(true);

        pcntl_signal(SIGTERM, function () {
            $this->shouldQuit = true;
        });

        pcntl_signal(SIGUSR2, function () {
            $this->paused = true;
        });

        pcntl_signal(SIGCONT, function () {
            $this->paused = false;
        });
    }
}

這種方法使用PHP7.1的訊號處理, supportsAsyncSignals() 方法檢查我們是否在PHP7.1上,並載入pcntl 副檔名。

之後pcntl_async_signals() 被呼叫來啟用訊號處理,然後我們為多個訊號註冊處理程式:

  • 當指令碼被指示關閉時,會引發SIGTERM
  • SIGUSR2是使用者定義的訊號,Laravel用來表示指令碼應該暫停。
  • 當暫停的指令碼繼續進行時,會引發SIGCONT

這些訊號從Process Monitor(如 Supervisor )傳送並與我們的指令碼進行通訊。

Worker::daemon() 方法中的第二行讀取最後一個佇列重新啟動的時間戳,當我們呼叫queue:restart 時該值儲存在快取中,稍後我們將檢查是否和上次重新啟動的時間戳不符合,來指示worker在之後多次重啟。

最後,該方法啟動一個迴圈,在這個迴圈中,我們將完成其餘獲取作業的worker,執行它們,並對worker程式執行多個操作。

while (true) {
    if (! $this->daemonShouldRun($options, $connectionName, $queue)) {
        $this->pauseWorker($options, $lastRestart);

        continue;
    }

    $job = $this->getNextJob(
        $this->manager->connection($connectionName), $queue
    );

    $this->registerTimeoutHandler($job, $options);

    if ($job) {
        $this->runJob($job, $connectionName, $options);
    } else {
        $this->sleep($options->sleep);
    }

    $this->stopIfNecessary($options, $lastRestart);
}

確定worker是否應該處理作業

呼叫 daemonShouldRun() 檢查以下情況:

  • 應用程式不處於維護模式
  • Worker沒有暫停
  • 沒有事件監聽器阻止迴圈繼續

如果應用程式在維護模式下,worker使用--force選項仍然可以處理作業:

php artisan queue:work --force

確定worker是否應該繼續的條件之一是:

$this->events->until(new Events\Looping($connectionName, $queue)) === false)

這行觸發 Queue\Event\Looping 事件,並檢查是否有任何監聽器在 handle() 方法中返回false,這種情況下你可以強制您的workers暫時停止處理作業。

如果worker應該暫停,則呼叫 pauseWorker() 方法:

protected function pauseWorker(WorkerOptions $options, $lastRestart)
{
    $this->sleep($options->sleep > 0 ? $options->sleep : 1);

    $this->stopIfNecessary($options, $lastRestart);
}

sleep 方法並傳遞給控制檯命令的 --sleep 選項,這個方法呼叫

public function sleep($seconds)
{
    sleep($seconds);
}

指令碼休眠了一段時間後,我們檢查worker是否應該在這種情況下退出並殺死指令碼,稍後我們看一下stopIfNecessary 方法,以防指令碼不能被殺死,我們只需呼叫 continue; 開始一個新的迴圈:

if (! $this->daemonShouldRun($options, $connectionName, $queue)) {
    $this->pauseWorker($options, $lastRestart);

    continue;
}

Retrieving 要執行的作業

$job = $this->getNextJob(
    $this->manager->connection($connectionName), $queue
);

getNextJob() 方法接受一個佇列連線的例項,我們從佇列中獲取作業

protected function getNextJob($connection, $queue)
{
    try {
        foreach (explode(',', $queue) as $queue) {
            if (! is_null($job = $connection->pop($queue))) {
                return $job;
            }
        }
    } catch (Exception $e) {
        $this->exceptions->report($e);

        $this->stopWorkerIfLostConnection($e);
    }
}

我們簡單地迴圈給定的佇列,使用選擇的佇列連線從儲存空間(資料庫,redis,sqs,...)獲取作業並返回該作業。

要從儲存中retrieve作業,我們查詢滿足以下條件的最舊作業:

  • 推送到 queue ,我們試圖從中找到作業
  • 沒有被其他worker reserved
  • 可以在給定的時間內執行,有些作業在將來被推遲執行
  • 我們也取到了很久以來被凍結的作業並重試

一旦我們找到符合這一標準的作業,我們將這個作業標記為reserved,以便其他workers獲取到,我們還會增加作業監控次數。

監控作業超時

下一個作業被retrieved之後,我們呼叫 registerTimeoutHandler() 方法:

protected function registerTimeoutHandler($job, WorkerOptions $options)
{
    if ($this->supportsAsyncSignals()) {
        pcntl_signal(SIGALRM, function () {
            $this->kill(1);
        });the

        $timeout = $this->timeoutForJob($job, $options);

        pcntl_alarm($timeout > 0 ? $timeout + $options->sleep : 0);
    }
}

再次,如果 pcntl 擴充套件被載入,我們將註冊一個訊號處理程式幹掉worker程式如果該作業超時的話,在配置了超時之後我們使用 pcntl_alarm() 來傳送一個 SIGALRM 訊號。

如果作業所花費的時間超過了超時值,處理程式將會終止該指令碼,如果不是該作業將通過,並且下一個迴圈將設定一個新的報警覆蓋第一個報警,因為程式中可能存在單個報警。

作業只在PHP7.1以上起效,在window上也無效 ¯_(ツ)_/¯

處理作業

runJob() 方法呼叫 process():

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

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

        $job->fire();

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

raiseBeforeJobEvent() 觸發 Queue\Events\JobProcessing 事件, raiseAfterJobEvent() 觸發 Queue\Events\JobProcessed 事件。 markJobAsFailedIfAlreadyExceedsMaxAttempts() 檢查程式是否達到最大嘗試次數,並將該作業標記為失敗:

protected function markJobAsFailedIfAlreadyExceedsMaxAttempts($connectionName, $job, $maxTries)
{
    $maxTries = ! is_null($job->maxTries()) ? $job->maxTries() : $maxTries;

    if ($maxTries === 0 || $job->attempts() <= $maxTries) {
        return;
    }

    $this->failJob($connectionName, $job, $e = new MaxAttemptsExceededException(
        'A queued job has been attempted too many times. The job may have previously timed out.'
    ));

    throw $e;
}

否則我們在作業物件上呼叫 fire() 方法來執行作業。

從哪裡獲取作業物件

getNextJob() 方法返回一個 Contracts\Queue\Job 的例項,這取決於我們使用相應的Job例項的佇列驅動程式,例如如果資料庫佇列驅動則選擇 Queue\Jobs\DatabaseJob

迴圈結束

在迴圈結束時,我們呼叫 stopIfNecessary() 來檢查在下一個迴圈開始之前是否應該停止程式:

protected function stopIfNecessary(WorkerOptions $options, $lastRestart)
{
    if ($this->shouldQuit) {
        $this->kill();
    }

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

shouldQuit 屬性在兩種情況下設定,首先listenForSignals() 內部的作為 SIGTERM 訊號處理程式,其次在 stopWorkerIfLostConnection()

protected function stopWorkerIfLostConnection($e)
{
    if ($this->causedByLostConnection($e)) {
        $this->shouldQuit = true;
    }
}

在retrieving和處理作業時,會在幾個try ... catch語句中呼叫此方法,以確保worker應該處於被幹掉的狀態,以便我們的Process Control可能會啟動一個新的資料庫連線。

causedByLostConnection() 方法可以在 Database\DetectsLostConnections trait中找到。
memoryExceeded() 檢查記憶體使用情況是否超過當前設定的記憶體限制,您可以使用 --memory 選項設定限制。

轉載請註明: 轉載自Ryan是菜鳥 | LNMP技術棧筆記

如果覺得本篇文章對您十分有益,何不 打賞一下

謝謝打賞

本文連結地址: 剖析Laravel佇列系統--Worker

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章