Khi cùng một ứng dụng Laravel chạy trên ba replica, cả ba máy đều có thể nhận cùng một nhịp schedule:run. Một tác vụ gửi báo cáo lúc 17:00 vì thế có nguy cơ chạy ba lần nếu từng event không được khóa đúng cách. Laravel 13.35 thêm Schedule::alwaysOnOneServer() để áp dụng quy tắc “một server thắng lock” cho toàn bộ lịch, thay vì phải nhớ nối onOneServer() vào từng dòng.
Tính năng nhỏ nhưng đụng thẳng vào vận hành: cache phải dùng chung, closure cần có tên, và “một server” không đồng nghĩa với “không bao giờ chạy lặp”. Bài này đối chiếu tài liệu scheduler Laravel 13.x, release framework v13.35.0 và chạy fixture trên PHP 8.4.24 với illuminate/console v13.35.0. Fixture kiểm việc gắn cờ trên event; chưa mô phỏng hai process production tranh cùng một Redis lock.
Mục lục [Hiển thị]
Bật một lần trong AppServiceProvider
PR #61789 được merge ngày 30/9/2026 và đi vào Laravel 13.35.0 phát hành ngày 6/10. Cách bật theo docs là gọi static method trong boot() của AppServiceProvider:
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Schedule;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Schedule::alwaysOnOneServer();
}
}
Cờ này mặc định tắt, nên nâng package lên 13.35 không tự đổi cách scheduler hiện tại chạy. Sau khi bật, Laravel áp dụng onOneServer() khi lấy danh sách event. Command, job và callback có tên được đánh dấu; scheduled closure chưa đặt tên bị bỏ qua để tránh một mutex không có định danh ổn định.

Đây là chỗ dễ sót nhất khi chuyển từ cấu hình từng event sang cờ toàn cục. Đoạn dưới vẫn chạy trên mọi server vì callback chưa có name():
Schedule::call(fn () => TempFile::prune())
->hourly();
Nếu tác vụ này cần chạy duy nhất, đặt tên trước:
Schedule::call(fn () => TempFile::prune())
->name('temp-files:prune')
->hourly();
Tương tự, khi lên lịch cùng một job nhiều lần với tham số khác nhau, docs khuyên gán tên riêng cho từng biến thể. Tên không chỉ để schedule:list đẹp hơn; nó giúp Laravel phân biệt mutex của từng lịch.
Fixture v13.35 đã xác nhận điều gì?
Fixture cài đúng illuminate/console v13.35.0, tạo một Artisan command, một closure chưa đặt tên và một closure có tên. Trước khi bật cờ, cả ba đều có onOneServer = false. Sau lời gọi Schedule::alwaysOnOneServer(), command và closure có tên chuyển thành true; closure chưa tên vẫn là false. Một command được thêm sau khi bật cờ cũng thành true khi danh sách event được đọc.

Bốn assertion đều pass. Kết quả khớp với implementation trong v13.35: events() duyệt danh sách, bỏ qua CallbackEvent chưa có description rồi gọi onOneServer() cho phần còn lại. Đây là test cấu trúc event, không phải benchmark và không chứng minh cache production của bạn đang chia sẻ đúng giữa các máy.
Cache dùng chung là điều kiện bắt buộc
onOneServer dựa vào atomic lock. Tài liệu Laravel hiện liệt kê bốn cache driver phù hợp: database, memcached, dynamodb và redis. Quan trọng hơn tên driver là việc mọi server phải nói chuyện với cùng một cache trung tâm. Ba container dùng ba Redis local khác nhau vẫn tạo ra ba thế giới lock riêng, rồi mỗi nơi đều tưởng mình thắng.
Mặc định scheduler dùng default cache store. Nếu ứng dụng cần store khác cho mutex, Laravel cho phép chọn rõ:
use Illuminate\Support\Facades\Schedule;
Schedule::useCache('database');
Schedule::alwaysOnOneServer();
Trước khi deploy, kiểm tra cấu hình đã cache trên từng replica, kết nối thực tế và namespace/prefix. Đừng chỉ so file .env: process cũ hoặc config cache cũ có thể khiến hai máy đọc store khác nhau. Bài Laravel 13 Cache::touch() giải thích một thay đổi cache khác; ở đây cache không giữ dữ liệu nghiệp vụ mà giữ mutex điều phối.
Khác gì với withoutOverlapping?
Hai method giải quyết hai câu hỏi khác nhau:
| Cơ chế | Câu hỏi nó trả lời | Khi nên dùng |
|---|---|---|
onOneServer | Trong nhiều scheduler cùng đến một nhịp, server nào được chạy event? | Ứng dụng chạy scheduler trên nhiều replica |
withoutOverlapping | Lần trước chưa xong thì lần kế tiếp có được bắt đầu không? | Event có thể chạy lâu hơn chu kỳ hoặc không được phép chồng lấn |
Một job có thể cần cả hai. Ví dụ báo cáo chạy mỗi năm phút trên ba replica nhưng đôi lúc mất tám phút:
Schedule::command('reports:generate')
->everyFiveMinutes()
->onOneServer()
->withoutOverlapping(15);
Khi đã bật alwaysOnOneServer(), dòng onOneServer() riêng lẻ không còn cần cho event thông thường, nhưng withoutOverlapping() vẫn giữ ý nghĩa. Cũng đừng bỏ idempotency: process có thể chết sau khi gọi dịch vụ ngoài nhưng trước khi ghi trạng thái hoàn tất; retry hoặc lần chạy kế tiếp vẫn cần khóa nghiệp vụ, unique key hay trạng thái đã xử lý để không gửi hóa đơn hai lần.
Kiểm tra trước khi bật toàn bộ
Cờ global tiện, nhưng phạm vi ảnh hưởng cũng rộng. Một runbook ngắn giúp tránh biến thay đổi một dòng thành buổi tối dài:
- Xác nhận version: kiểm
composer show laravel/frameworkhoặccomposer show illuminate/console; method này bắt đầu từ 13.35.0. - Kiểm cache trung tâm: xác nhận mọi replica dùng cùng driver, endpoint, database/table và prefix cần thiết.
- Kiểm kê closure: tìm các lời gọi
Schedule::call(); thêmname()cho callback cần chạy duy nhất. - Kiểm job lặp tham số: đặt tên riêng cho từng lịch cùng class job nhưng khác đối tượng hoặc URL.
- Đọc danh sách máy: v13.35 cũng thêm trường
on_one_servervào outputschedule:list --json, hữu ích để bắt event chưa được đánh dấu. - Chạy staging nhiều process: cho hai scheduler cùng tick, ghi một execution ID không nhạy cảm và xác nhận chỉ một process vào phần việc chính.
- Giữ đường lui: có thể tắt cờ bằng
Schedule::alwaysOnOneServer(false)hoặc rollback release nếu cache lock gây hành vi ngoài dự kiến.
php artisan schedule:list --json
php artisan schedule:run -vvv
Fixture standalone của bài không dựng full Artisan application nên chưa xác nhận JSON CLI trên dự án mẫu; thông tin trường on_one_server đến từ release note chính thức. Trên site thật, hãy đọc output của chính version đang deploy thay vì sao chép kết quả từ môi trường khác.
Negative control nên kiểm tra điều gì?
Muốn biết shared lock có hoạt động thật, đừng dùng ngay job gửi email hoặc trừ tiền. Tạo một command staging vô hại, chẳng hạn ghi event_name, phút dự kiến, hostname và execution ID vào một bảng riêng. Cho ít nhất hai replica cùng gọi schedule:run trong một phút. Khi bật cờ và dùng chung cache đúng cách, phần việc chính chỉ nên tạo một record; log của từng replica vẫn cần được giữ để biết máy nào vào command và máy nào không.
Sau positive control, cần một negative control có phạm vi. Trên môi trường tách biệt, tắt alwaysOnOneServer cho đúng command thử nghiệm rồi chạy lại cùng kịch bản. Nếu cả hai replica đều tạo record, fixture đã chứng minh được phép thử có khả năng phát hiện duplicate. Nếu lúc bật hay tắt đều chỉ có một record, có thể cron thực ra chỉ chạy trên một máy; kết quả đó chưa kiểm chứng distributed lock.
Một negative control khác là cố ý trỏ hai replica thử nghiệm tới hai cache namespace khác nhau. Đây chỉ nên làm trong staging và trên tác vụ vô hại. Kỳ vọng là cả hai đều thắng “lock” riêng của mình; nếu vẫn chỉ có một lần chạy, hãy kiểm lại đường gọi scheduler, thời điểm tick và điều kiện when(). Bài này chưa chạy thử hai process và không tuyên bố kết quả production; checklist trên là phép thử cần thực hiện trước khi rollout.
Đặt unique key cho event_name + scheduled_minute trong bảng bằng chứng cũng hữu ích: nếu duplicate xuất hiện, database sẽ biến lỗi âm thầm thành tín hiệu quan sát được. Nhưng unique key thử nghiệm không thay cho idempotency của tác vụ thật. Một job gọi API bên ngoài vẫn cần idempotency key hoặc trạng thái nghiệp vụ riêng, bởi side effect có thể xảy ra trước khi ứng dụng kịp ghi record local.
Một server thắng lock không phải high availability hoàn chỉnh
Nếu chỉ một VM duy nhất có cron và VM đó dừng, alwaysOnOneServer() không tự sinh scheduler thay thế. Mô hình chịu lỗi hợp lý hơn là để nhiều replica đều gọi schedule:run, dùng shared cache để chọn một winner, đồng thời theo dõi lần chạy cuối và báo động khi lịch im quá lâu. Lock xử lý tranh quyền; health check và monitoring xử lý việc không còn ai tranh.
Trước khi đưa thay đổi này vào một dự án đang ở Laravel 12, đừng nâng major chỉ vì một helper. Bài kiểm tra đường nâng cấp Laravel 12 có checklist về dependency, test và rollback; hãy hoàn thành phần đó rồi mới đổi hành vi scheduler.
Tóm lại, Schedule::alwaysOnOneServer() giảm một lỗi vận hành rất đời thường: thêm event mới rồi quên onOneServer(). Nó đáng bật khi scheduler chạy trên nhiều replica và cache thực sự dùng chung. Trước khi bật, đặt tên cho closure, phân biệt lock phân phối với chống overlap, giữ tác vụ idempotent và chạy một negative control trên staging. Một dòng cấu hình gọn là tốt, nhưng bằng chứng sau deploy mới là phần giúp ngủ ngon.