Laravel 將「點閱記錄」改成非同步 queue 寫入資料庫

認識 Laravel Queue Jobs

在建立 Web 應用程式時,可能會遇到一些任務,例如解析和儲存上傳的 CSV 文件,這些任務在典型的 Web 請求中執行起來可能耗時過長,或是網站文章流量大,可以改成非同步 queue 寫入資料庫,以減少對主流程的影響,並提升效能與延展性。

queue 是一種先進先出的資料結構,而 Laravel 的 queue job 是一個類似待辦清單的功能,以 queue 的方式儲存待辦的任務 (job),然後會有 Worker 依序執行,可以輕鬆建立背景處理的佇列作業。透過將耗時任務移至佇列,使得應用程式可以以極快的速度回應 Web 請求,並為用戶提供更佳的使用者體驗。

Laravel 的佇列設定選項儲存在應用程式的 config/queue.php 設定檔中。在 migrations: xxxx_xx_xx_xxxxxx_create_jobs_table.php 會建立相關的資料表(table),供此文件 config/queue.php 中包含框架自帶的每個佇列驅動程式(driver)的連接配置(connections),包括資料庫(database)、Amazon SQS、Redis 和 Beanstalkd 驅動程序,以及一個可立即執行任務的同步驅動程式(供本機開發使用)。此外,還包含一個空隊列驅動程序(job_batches),用於丟棄已排隊的任務(failed_jobs)。

Connections vs. Queues

在開始使用 Laravel 佇列之前,請務必先了解「連結(connections)」和「隊列(queues)」之間的差異。在 config/queue.php 設定檔中,有一個連線配置陣列。此選項定義了與後端佇列服務(例如 Amazon SQS、Beanstalk 或 Redis)的連線。但是,任何給定的佇列連線都可能有多個“佇列”,這些佇列可以被視為不同的佇列或排隊作業堆。程式碼舉例: 可立即執行任務:’sync’、 資料庫(database)、Redis 如下:

'connections' => [

	'sync' => [
		'driver' => 'sync',
	],
	'database' => [
		'driver' => 'database',
		'connection' => env('DB_QUEUE_CONNECTION'),
		'table' => env('DB_QUEUE_TABLE', 'jobs'),
		'queue' => env('DB_QUEUE', 'default'),
		'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
		'after_commit' => false,
	],
	'redis' => [
		'driver' => 'redis',
		'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
		'queue' => env('REDIS_QUEUE', 'default'),
		'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
		'block_for' => null,
		'after_commit' => false,
	],
]

請注意,佇列設定檔 config/queue.php 中的每個連線配置範例都包含一個佇列屬性。這是作業傳送到給定連線時將被調度到的預設佇列。換句話說,如果您調度作業時沒有明確指定要調度到哪個佇列,則該作業將被放置在連接配置的佇列屬性中定義的佇列中:

use App\Jobs\ProcessPodcast;

// This job is sent to the default connection's default queue...
ProcessPodcast::dispatch();

// This job is sent to the default connection's "emails" queue...
ProcessPodcast::dispatch()->onQueue('emails');

實作

文章 在 Laravel 中實作「文章點閱率統計」 中的 middle 方式來改成 改成非同步 queue 寫入資料庫,以減少對主流程的影響,並提升效能與延展性。

實作目標

  • 使用 Laravel 的 Queue 任務 寫入 post_views 表。
  • Middleware 或 Controller 中只派送任務(不阻塞請求)。
  • 每篇文章、每 IP、每天只記一次。

一、建立 Job 任務

新增一個 job class,在 handle 方法裡寫這個 job 具體要做的事。

php artisan make:job LogPostViewJob

程式碼撰寫如下:

// app/Jobs/LogPostViewJob.php
namespace App\Jobs;

use App\Models\Blog\BlogView;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\{InteractsWithQueue, SerializesModels};
use Illuminate\Foundation\Bus\Dispatchable;
use Carbon\Carbon;

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

    public $postId, $ip, $host, $userAgent;

    public function __construct($postId, $ip, $host, $userAgent)
    {
        $this->postId = $postId;
        $this->ip = $ip;
        $this->host = $host;
        $this->userAgent = $userAgent;
    }

    public function handle(): void
    {
        $today = Carbon::now()->toDateString();

        // 避免重複寫入
        $exists = BlogView::where('post_id', $this->postId)
            ->where('ip', $this->ip)
            ->where('view_date', $today)
            ->exists();

        if (!$exists) {
            BlogView::create([
                'post_id' => $this->postId,
                'ip' => $this->ip,
                'host' => $this->host,
                'user_agent' => $this->userAgent,
                'view_date' => $today,
            ]);
        }
    }
}

二、Middleware 修改(派送 Job)

更新先前的 LogBlogPostView middleware:

// app/Http/Middleware/LogBlogPostView.php
use App\Jobs\LogBlogPostViewJob;

public function handle(Request $request, Closure $next)
{
    $postId = $request->route('id') ?? $request->route('post');

    if ($postId) {
        $ip = $request->ip();
        $host = gethostbyaddr($ip) ?? 'unknown';
        $userAgent = $request->header('User-Agent') ?? 'unknown';

        // 改為非同步任務派送
        LogPostViewJob::dispatch($postId, $ip, $host, $userAgent);
    }

    return $next($request);
}

dispatch 底層其實是把任務 push 到 driver

做 push 前,系統會先去看要推送到哪個 driverdriver 就是我們排隊的地方,可以是資料庫、Redis 等。有哪些 driver 和連線參數可以在config/queue.php 設定。

知道是要 push 到哪個 driver 後,會去看這個 driver 是否已建立連線;有就用,沒有就建立,最後才 push job 到 driver

可以將程式優化如下:

$ip = $request->ip();
$host = gethostbyaddr($ip) ?? 'unknown';
$userAgent = $request->header('User-Agent') ?? 'unknown';
$today = Carbon::now()->toDateString();

$cacheKey = "blog_viewed:{$postId}:{$ip}:{$today}";

// 先檢查 Cache 若有值,改為非同步任務派送
if (!Cache::has($cacheKey)) {
	// 放入table:cache裡
	Cache::put($cacheKey, true, now()->endOfDay());
	
	// 放入table:jobs裡, 下面兩行指令是相同, 擇一即可
	LogBlogPostViewJob::dispatch($postId, $ip, $host, $userAgent, $user_id);
	Queue::push(new LogBlogPostViewJob($postId, $ip, $host, $userAgent, $user_id));
}

當有點閱文章時,記錄不會馬上寫入 table: blog_view 中,而是先記錄在 table:jobs 裡

image 10

該 job 的 payload 內容類似下面這樣

{"uuid":"16e61136-acd8-446d-8278-3443e9831ec0","displayName":"App\\Jobs\\LogBlogPostViewJob","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,"data":{"commandName":"App\\Jobs\\LogBlogPostViewJob","command":"O:27:\"App\\Jobs\\LogBlogPostViewJob\":5:{s:6:\"postId\";s:1:\"3\";s:2:\"ip\";s:9:\"127.0.0.1\";s:4:\"host\";s:17:\"iccert.nhi.gov.tw\";s:9:\"userAgent\";s:111:\"Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/137.0.0.0 Safari\/537.36\";s:7:\"user_id\";i:1;}"},"createdAt":1750482781,"delay":null}

三、設定 Queue 驅動(使用資料庫或 Redis)

例如使用 database

//.env

QUEUE_CONNECTION=database

建立 jobs 表:

在 migrations: xxxx_xx_xx_xxxxxx_create_jobs_table.php 會建立相關的資料表(table),若沒有則執行下列指令

php artisan make:queue-table
php artisan migrate

啟動 queue worker:

php artisan queue:work

有起動 queue worker 時,才會觸發 logBlogPostViewJob

image 11

queue:worker 執行成功時,worker會寫入 table: blog_view 中,且將此筆工作(Job)從 table:jobs 移除

image 12

四、效能改善:改為 Redis

應用程式執行的某些資料檢索或處理任務可能會佔用大量 CPU 資源,或需要幾秒鐘才能完成。在這種情況下,通常會將檢索到的資料快取一段時間,以便在後續對相同資料的請求中快速檢索。快取的資料通常儲存在非常快速的資料儲存中,例如 Memcached 或 Redis。

  • 若流量大,推薦使用 Redis 為 QUEUE_CONNECTION=redis
  • 可搭配 Horizon 管理 queue 狀態。
  • 可進一步將「是否已記錄過」的判斷移至 Redis 快取,減少 DB 查詢。
$cacheKey = "blog_viewed:{$postId}:{$ip}:{$today}";
if (!Cache::has($cacheKey)) {
    Cache::put($cacheKey, true, now()->endOfDay());
    LogPostViewJob::dispatch(...);
}

設定 Redis 快取

Redis 需要有 Redis: predis/predis ~2.0 or phpredis PHP extension 的套件

composer require predis/predis

在 .env 作修正

//.env

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

大致上這樣就可以運作了

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *


內容索引