Laravel Scheduler: Tư duy thiết kế, trade-off và best practices cho hệ thống lớn

Nếu bạn đã đi làm vài năm, bạn sẽ thấy “Scheduler” không phải chuyện viết vài dòng dailyAt(). Nó là bài toán reliability + performance + observability. Sai một ly là: trùng charge, spam notify, kẹt DB lúc 9h, hoặc “đêm qua không chạy mà không ai biết”.

Bài này chia sẻ theo góc nhìn hệ thống lớn: ra quyết định kỹ thuật khi số job tăng nhanh, dữ liệu lớn, nhiều worker, CI/CD liên tục, thậm chí realtime.


1. Nguyên tắc số 1: Scheduler phải “nhẹ” — việc nặng đẩy qua queue

Tư duy: Scheduler là “orchestrator”, không phải “executor”.

Scheduler chỉ làm 2 việc:

  • Quyết định “đúng giờ thì trigger cái gì”
  • Dispatch nhanh, ghi dấu vết (batch metadata)

Việc nặng (DB scan, gọi API, gửi notify hàng loạt, reconcile, billing…) → chạy trong queued jobs + nhiều worker.

Trade-off:

  • ✅ Ưu: Scale bằng worker, retry/backoff chuẩn, tách tải khỏi scheduler
  • ⚠️ Nhược: Job có thể chạy trễ → phải thiết kế theo “batch time” (xem phần 4)

Sai lầm thường gặp: Nhét logic nặng vào scheduler (closure/command) rồi “đỡ nghẽn” bằng cách tăng CPU. Đến lúc 100 lịch/ngày thì scheduler thành single-point bottleneck.

Ví dụ:

<?php

// routes/console.php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;

// ❌ SAI: Logic nặng trực tiếp trong scheduler
Schedule::call(function () {
    DB::table('recent_users')->chunk(100, function ($users) {
        foreach ($users as $user) {
            // Process payment, send email, update DB...
            // Nếu có 1000 users, scheduler sẽ bị block rất lâu
        }
    });
})->dailyAt('09:00');

// ✅ ĐÚNG: Scheduler chỉ dispatch job
Schedule::job(new ProcessRecentUsersJob)
    ->dailyAt('09:00');

Scheduler chỉ trigger đúng giờ, không bao giờ chạy logic business trực tiếp. Worker sẽ xử lý job trong queue.


2. “Đúng một lần” theo nghĩa business > “đúng một lần” theo nghĩa kỹ thuật

Ở hệ thống lớn, “at least once” là mặc định. Scheduler/queue/worker/retry/deploy đều có thể làm job chạy lại.

Best practice: Thiết kế job theo idempotent:

  • Chạy 2 lần vẫn ra kết quả đúng
  • Side-effect (charge/send/update) phải có khóa logic hoặc unique key

Dùng lock ở đúng tầng:

  • Tầng scheduler: Chống chồng / chạy một server (khi HA)
  • Tầng job/business: Lock theo entity/time-window (mới là thứ bảo vệ cuối)

Trade-off:

  • Lock nhiều quá → Giảm throughput, dễ nghẽn nếu lock sai granularity
  • Lock ít quá → Trùng dữ liệu/side-effect

Sai lầm thường gặp: Tin tuyệt đối vào “chống chồng” của scheduler rồi bỏ qua idempotency. Một ngày deploy “đúng lúc”, bạn sẽ hiểu.

Ví dụ:

Job có idempotency key dựa trên entity_id + batch_time. Concept này áp dụng cho mọi job có side-effect quan trọng. Trong scheduler, bạn chỉ dispatch job với metadata:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new ProcessOrdersJob)
    ->dailyAt('09:00');

Job sẽ tự implement idempotency check trong handle() method để đảm bảo không xử lý trùng.


3. Khi nào dùng runInBackground() và khi nào “bắt buộc” dùng queue?

Tôi coi runInBackground() là giải pháp tactical, không phải chiến lược lâu dài.

Dùng runInBackground() khi:

  • Task là command/exec tương đối ngắn
  • Không cần scale theo tải
  • Bạn chỉ cần tránh scheduler bị giữ process quá lâu

Bắt buộc đưa qua queue khi:

  • Task có thể chạy vài chục giây/phút
  • Task có thể “nở” theo dữ liệu (data grows)
  • Task có side-effect quan trọng (payment/notify hàng loạt)
  • Cần retry/backoff, rate-limit, phân ưu tiên

Sai lầm thường gặp: “Task đang lâu → thêm runInBackground()” và coi như xong. Thực tế bạn chỉ chuyển nghẽn từ scheduler sang CPU/DB/connections của cùng máy.

Ví dụ:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// ✅ Dùng runInBackground cho command nhẹ, không cần scale
Schedule::command('cache:clear')
    ->hourly()
    ->runInBackground();

Schedule::command('emails:send')
    ->dailyAt('02:00')
    ->runInBackground();

// ✅ Bắt buộc dùng queue cho job có side-effect quan trọng
Schedule::job(new SendOrderConfirmationJob)
    ->dailyAt('09:00'); // Không có runInBackground, vì đã là queued job

Chỉ có 2 loại command dùng runInBackground():

  1. Maintenance commands (cleanup, cache) — không ảnh hưởng business
  2. Monitoring commands — cần chạy nhanh, không cần retry

Tất cả job business đều qua queue để có retry/backoff/scale.


4. Bài toán lớn nhất của hệ queue: Job chạy trễ → lệch cửa sổ dữ liệu

Đây là điểm phân biệt hệ “chạy được” và hệ “chạy đúng”.

Vấn đề:

  • Scheduler trigger 09:00, worker chạy 09:04
  • Nếu job query theo now() lúc 09:04, bạn vừa:
    • Miss dữ liệu 09:00–09:04 (tùy window)
    • Hoặc duplicate với batch trước

Giải pháp: Scheduler đóng dấu batch time ngay lúc dispatch (tôi hay gọi scheduledAt/batchAt). Job luôn dùng scheduledAt để:

  • Tính window dữ liệu
  • Log/audit theo batch
  • Tạo idempotency key

Trade-off:

  • Tốn thêm metadata, thêm “kỷ luật” trong code
  • Đổi lại: Batch chạy trễ vẫn đúng cửa sổ, dễ đối soát, dễ replay theo batch

Ví dụ — Pattern batch timestamp:

Concept: Scheduler đóng dấu batch time ngay lúc dispatch. Job nhận batch time và dùng để tính time window, không dùng now().

Sử dụng trong Laravel 12:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new ProcessOrdersJob)
    ->dailyAt('09:00');

Job sẽ nhận scheduledAt từ scheduler event và dùng để query đúng time window.

Ví dụ với sub-minute scheduling trong Laravel 12:

Theo Laravel 12.x Sub-Minute Scheduled Tasks:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Laravel 12 hỗ trợ sub-minute scheduling
Schedule::call(function () {
    // Process tasks every 30 seconds
})->everyThirtySeconds();

Lợi ích:

  • Job chạy trễ 3 phút vẫn query đúng cửa sổ 09:00–09:01
  • Dễ đối soát: “Batch 09:00 đã xử lý bao nhiêu records?”
  • Dễ replay: “Chạy lại batch 09:00” → chỉ cần tạo job với jobCreatedAt = 09:00

5. Tách “workload shape”: Phân queue theo ưu tiên, tránh job “rác” ăn tài nguyên job “sống còn”

Một hệ thống lớn sẽ có job kiểu:

  • Critical: Billing, payment, cancel, inventory, security
  • Default: Vận hành
  • Low: Marketing, remind, digest

Best practice: Tách queue theo priority, scale worker theo queue. Đặt rule: Job low không bao giờ được làm chậm job critical.

Trade-off:

  • Nhiều queue → Config vận hành phức tạp hơn (supervisor/horizon)
  • Nhưng nếu không tách, bạn sẽ gặp kiểu “19h campaign bắn notify” làm nghẽn payment

Lưu ý: Trong scheduler, bạn có thể chỉ định queue khi schedule job:

Theo Laravel 12.x Scheduling Queued Jobs, method job() nhận queue name làm tham số thứ 2:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new SendOrderConfirmationJob, 'critical')
    ->dailyAt('09:00');

Hoặc nếu job class đã có property $queue, scheduler sẽ tự động dùng queue đó.


6. “Thundering herd” lúc tròn giờ: Kẻ thù của DB và API

Hệ thống lớn rất hay bị “dồn lịch” vào 00:00, 09:00, 12:00, 19:00…

Best practice:

  • Stagger: Dàn đều theo phút/giây
  • Nếu bắt buộc tròn giờ: Chia nhỏ thành nhiều job chunk, có rate-limit/backoff
  • Không quét toàn bảng: Dùng cursor/chunk theo index, watermark theo batch

Sai lầm thường gặp: Một job “tổng hợp cuối ngày” chạy 00:00 quét cả bảng 50 triệu dòng. Đêm đó DB chết, sáng không ai biết vì không có alert.

Ví dụ — Stagger pattern trong scheduler:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Dàn đều theo phút để tránh thundering herd
$times = ['9:03', '10:03', '11:03', '12:03', '13:03', '14:03', '15:03', '16:03', '17:03', '18:03', '19:03', '20:03', '21:03'];

foreach ($times as $time) {
    Schedule::job(new ProcessHourlyTaskJob)
        ->at($time);
}

7. Observability là bắt buộc: “Không ai biết scheduler fail” = thất bại hệ thống

Scheduler fail nguy hiểm vì nó âm thầm.

Best practice:

  • Log theo batch (scheduledAt, job name, duration, result)
  • Có alert khi fail (hook/event/ping tùy hệ)
  • Có “schedule inventory”: Định kỳ audit lịch chạy (ít nhất kiểm lịch sau deploy)

Trade-off:

  • Thêm log/metrics → Tốn công + tốn storage
  • Nhưng thiếu nó thì khi sự cố xảy ra, bạn mất nhiều giờ “đào mộ”

Ví dụ — Scheduler hooks trong Laravel 12:

Theo Laravel 12.x Task Hooks:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')
    ->daily()
    ->before(function () {
        // The task is about to execute...
    })
    ->after(function () {
        // The task has executed...
    })
    ->onSuccess(function () {
        // The task succeeded...
    })
    ->onFailure(function () {
        // The task failed...
    });

Scheduler setup:

# Cron entry
* * * * * cd /path-to-app && php artisan schedule:run >> /dev/null 2>&1

Kiểm tra lịch chạy:

# Laravel 12: Xem tất cả scheduled tasks
php artisan schedule:list

8. CI/CD & Deploy: Đừng để deploy tạo ra double-run / half-run

Ở môi trường deploy liên tục, scheduler/worker cần “graceful”:

Best practice:

  • Tránh chạy song song 2 version job logic cho cùng một batch
  • Worker restart có kiểm soát; job quan trọng có idempotency key để chịu được retry/duplicate
  • Nếu hệ HA nhiều scheduler node: Phải có chiến lược “one server” + shared lock store

Sai lầm thường gặp: Deploy xong “mọi thứ xanh”, nhưng lịch 09:00 bị chạy 2 lần vì 2 scheduler node cùng tick.

Ví dụ — Laravel 12 onOneServer():

Theo Laravel 12.x Running Tasks on One Server:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Nếu có nhiều scheduler server, dùng onOneServer() để chạy chỉ trên 1 server
Schedule::command('emails:send')
    ->daily()
    ->onOneServer(); // Chỉ chạy trên 1 server, dùng cache để lock

Lưu ý: Job vẫn phải idempotent vì:

  • Retry khi fail
  • Deploy có thể làm job chạy lại
  • Worker restart có thể làm job chạy lại

9. Tổ chức code: Khi Kernel.php “phình to”

Khi số lượng lịch tăng, Kernel.php sẽ rất dài (có thể lên 300+ lines).

Best practice — Laravel 12: Tách helper functions theo domain để dễ maintain:

Theo Laravel 12.x Defining Schedules, bạn có thể định nghĩa trong routes/console.php và tách thành helper functions:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Tách theo domain để dễ maintain
schedulePaymentTasks();
scheduleNotificationTasks();
scheduleCleanupTasks();

function schedulePaymentTasks(): void
{
    Schedule::command('payments:process')
        ->dailyAt('09:00');

    Schedule::command('payments:retry')
        ->dailyAt('14:00');
}

function scheduleNotificationTasks(): void
{
    Schedule::command('notifications:send')
        ->hourly();

    Schedule::command('notifications:digest')
        ->dailyAt('20:00');
}

function scheduleCleanupTasks(): void
{
    Schedule::command('cache:clear')
        ->hourly()
        ->runInBackground();
}

Hoặc dùng withSchedule trong bootstrap/app.php và tách thành methods:

<?php

// bootstrap/app.php
use Illuminate\Console\Scheduling\Schedule;

->withSchedule(function (Schedule $schedule) {
    schedulePaymentTasks($schedule);
    scheduleNotificationTasks($schedule);
    scheduleCleanupTasks($schedule);
});

function schedulePaymentTasks(Schedule $schedule): void
{
    $schedule->command('payments:process')->dailyAt('09:00');
    $schedule->command('payments:retry')->dailyAt('14:00');
}

Lợi ích:

  • Dễ review: Mỗi function = 1 domain
  • Dễ test: Có thể test từng domain riêng
  • Dễ ownership: Team có thể own từng domain
  • Tránh Kernel.php phình to: Code nằm trong routes/console.php hoặc helper functions

Schedule Groups — Laravel 12:

Theo Laravel 12.x Schedule Groups, bạn có thể nhóm các tasks có cùng cấu hình để tránh lặp lại code:

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// ❌ SAI: Lặp lại cấu hình cho từng task
Schedule::command('emails:send')
    ->daily()
    ->onOneServer()
    ->timezone('America/New_York');

Schedule::command('emails:prune')
    ->daily()
    ->onOneServer()
    ->timezone('America/New_York');

// ✅ ĐÚNG: Dùng Schedule Groups để nhóm tasks có cùng cấu hình
Schedule::daily()
    ->onOneServer()
    ->timezone('America/New_York')
    ->group(function () {
        Schedule::command('emails:send');
        Schedule::command('emails:prune');
    });

Lợi ích của Schedule Groups:

  • Giảm lặp lại code: Không cần lặp lại onOneServer()timezone()withoutOverlapping() cho từng task
  • Dễ maintain: Thay đổi cấu hình một lần áp dụng cho cả nhóm
  • Tăng tính nhất quán: Đảm bảo tất cả tasks trong nhóm có cùng cấu hình

10. Timezone handling: Luôn nhất quán

Best practice: Cố định timezone theo business, không dùng server timezone. Nguyên tắc: Tất cả scheduled tasks phải dùng cùng một business timezone để đảm bảo tính nhất quán.

Ví dụ — Laravel 12 timezone:

Theo Laravel 12.x Scheduling Timezones, có 2 cách set timezone:

Cách 1: Set timezone trong config (khuyến nghị):

Khi tất cả tasks dùng cùng business timezone, thêm schedule_timezone vào config/app.php:

<?php

// config/app.php
return [
    'timezone' => 'UTC', // Application timezone

    'schedule_timezone' => 'Asia/Tokyo', // Business timezone cho scheduled tasks
];

Với cấu hình này, tất cả scheduled tasks sẽ tự động dùng timezone Asia/Tokyo trừ khi được ghi đè bởi method timezone() trong từng task cụ thể.

Cách 2: Set timezone cho từng task riêng:

Chỉ định timezone cho task cụ thể bằng method timezone():

<?php

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Set timezone cho task cụ thể (ghi đè config)
Schedule::command('report:generate')
    ->timezone('America/New_York')
    ->at('2:00');

Lưu ý quan trọng về DST (Daylight Saving Time):

Theo Laravel 12.x Scheduling Timezones, một số timezone sử dụng DST có thể ảnh hưởng đến thời gian thực thi. Khi DST thay đổi, task có thể chạy 2 lần hoặc không chạy. Nên tránh dùng timezone có DST khi có thể.

Nguyên tắc áp dụng:

  • ✅ Nên: Set timezone một lần trong config/app.php với key schedule_timezone nếu tất cả tasks dùng cùng business timezone
  • ⚠️ Tránh: Set timezone cho từng task riêng lẻ → dễ quên, dễ sai, khó maintain
  • ⚠️ Tránh: Dùng timezone có DST → có thể ảnh hưởng đến thời gian thực thi khi DST thay đổi

Kết luận: Framework cho quyết định kỹ thuật

Khi thêm một scheduled task mới, tôi tự hỏi 6 câu:

  1. Task này có side-effect quan trọng không? → Nếu có → Idempotent + audit batch
  2. Dữ liệu tăng thì task tăng theo tuyến nào? → Nếu O(N) theo bảng lớn → Phải có watermark/chunk/index
  3. Chạy trễ có làm sai cửa sổ dữ liệu không? → Nếu có → Cần scheduledAt/batchAt
  4. Có được phép chạy song song không? → Nếu không → Overlap guard + lock theo business key
  5. Nó thuộc priority nào? → Nếu không critical → Đừng để nó chạy chung queue với critical
  6. Fail thì ai biết, trong bao lâu? → Nếu câu trả lời mơ hồ → Bổ sung observability trước

Checklist áp dụng

  •  Scheduler chỉ dispatch queued jobs, không chạy logic nặng
  •  Tất cả job có side-effect đều idempotent
  •  Job dùng batchTime/scheduledAt để tính time window, không dùng now()
  •  Job dùng chunkById hoặc cursor pagination cho data lớn
  •  Job có stagger/rate-limit khi gọi external API
  •  Job log theo batch time để dễ đối soát
  •  Scheduler có helper methods theo domain, không phình to schedule()
  •  Timezone nhất quán: Business timezone, không dùng server timezone
  •  Queue được phân priority (critical/default/low)
  •  Có alert khi scheduler/job fail

Tài liệu tham khảoLaravel 12.x Task Scheduling

0 Shares:
Leave a Reply

Your email address will not be published. Required fields are marked *

You May Also Like
Read More

20 Laravel Eloquent Tips and Tricks (P1)

Eloquent ORM được sủ dụng rất nhiều trong 1 project Laravel, tuy nhiên để sử dụng được tối đa những gì Laravel cung cấp thì không phải ai cũng biết. Trong bài viết này mình sẽ chỉ cho các bạn một vài thủ thuật, hi vọng sẽ giúp ích cho các bạn trong một vài trường hợp cụ thể.
Read More

Có gì mới ở PHP 8.3?

Table of Contents Hide 1. Typed Class Constants2. Dynamic class constant fetch3. json_validate() function4. #[\Override] attribute5. Deep Cloning of readonly Properties6. Randomizer::getBytesFromString() method7. Randomizer::getFloat() and…