任务调度

介绍

过去,您可能为每个需要在服务器上调度的任务编写了一个 cron 配置条目。然而,这很快就变得令人头疼,因为您的任务调度不再在源代码管理中,您必须通过 SSH 进入服务器才能查看现有的 cron 条目或添加新的条目。

Laravel 的命令调度器提供了一种全新的方法来管理服务器上的计划任务。调度器允许您在 Laravel 应用程序中流畅且富有表现力地定义您的命令调度。当使用调度器时,您的服务器上只需要一个 cron 条目。您的任务调度通常在应用程序的 routes/console.php 文件中定义。

定义计划任务

您可以在应用程序的 routes/console.php 文件中定义所有计划任务。让我们通过一个示例来开始。在这个示例中,我们将调度一个闭包任务,使其每天午夜执行一次。在闭包内部,我们将执行一个数据库查询来清空某个表:

<?php

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    DB::table('recent_users')->delete();
})->daily();

除了使用闭包调度任务,您还可以调度可调用对象。可调用对象是包含 __invoke 方法的简单 PHP 类:

Schedule::call(new DeleteRecentUsers)->daily();

如果您更倾向于将 routes/console.php 文件仅用于命令定义,您可以在应用程序的 bootstrap/app.php 文件中使用 withSchedule 方法来定义您的计划任务。这个方法接受一个闭包,该闭包接收调度器实例:

use Illuminate\Console\Scheduling\Schedule;

->withSchedule(function (Schedule $schedule) {
    $schedule->call(new DeleteRecentUsers)->daily();
})

如果您想查看您的计划任务的概览,以及它们下次何时计划执行,您可以使用 schedule:list Artisan 命令:

php artisan schedule:list

调度 Artisan 命令

除了调度闭包任务,您还可以调度 Artisan 命令和系统命令。例如,您可以使用 command 方法通过命令的名称或类来调度 Artisan 命令。

当使用命令的类名调度 Artisan 命令时,您可以传递一个额外的命令行参数数组,这些参数将在命令执行时提供:

use App\Console\Commands\SendEmailsCommand;
use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send Taylor --force')->daily();

Schedule::command(SendEmailsCommand::class, ['Taylor', '--force'])->daily();

调度 Artisan 闭包命令

如果您想调度一个由闭包定义的 Artisan 命令,您可以在命令定义之后链式调用与调度相关的方法:

Artisan::command('delete:recent-users', function () {
    DB::table('recent_users')->delete();
})->purpose('Delete recent users')->daily();

如果需要将参数传递给闭包命令,您可以在 schedule 方法中提供它们:

Artisan::command('emails:send {user} {--force}', function ($user) {
    // ...
})->purpose('Send emails to the specified user')->schedule(['Taylor', '--force'])->daily();

调度队列任务

job 方法可用于调度一个排队任务。此方法提供了一种方便的方式来调度排队任务,无需使用 call 方法定义闭包来排队任务:

use App\Jobs\Heartbeat;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new Heartbeat)->everyFiveMinutes();

可以提供可选的第二个和第三个参数来指定应该用于排队任务的队列名称和队列连接:

use App\Jobs\Heartbeat;
use Illuminate\Support\Facades\Schedule;

// 将任务调度到 "heartbeats" 队列,并使用 "sqs" 连接...
Schedule::job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();

调度 Shell 命令

exec 方法可用于向操作系统发出命令:

use Illuminate\Support\Facades\Schedule;

Schedule::exec('node /home/forge/script.js')->daily();

计划任务频率选项

我们已经看到了一些如何配置任务在指定时间间隔运行的例子。然而,还有许多其它的任务调度频率,您可以为任务指定:

方法 描述

→cron('* * * * *');

按自定义 cron 时间表运行任务。

→everySecond();

每秒运行任务。

→everyTwoSeconds();

每两秒运行任务。

→everyFiveSeconds();

每五秒运行任务。

→everyTenSeconds();

每十秒运行任务。

→everyFifteenSeconds();

每十五秒运行任务。

→everyTwentySeconds();

每二十秒运行任务。

→everyThirtySeconds();

每三十秒运行任务。

→everyMinute();

每分钟运行任务。

→everyTwoMinutes();

每两分钟运行任务。

→everyThreeMinutes();

每三分钟运行任务。

→everyFourMinutes();

每四分钟运行任务。

→everyFiveMinutes();

每五分钟运行任务。

→everyTenMinutes();

每十分钟运行任务。

→everyFifteenMinutes();

每十五分钟运行任务。

→everyThirtyMinutes();

每三十分钟运行任务。

→hourly();

每小时运行任务。

→hourlyAt(17);

每小时的第17分钟运行任务。

→everyOddHour($minutes = 0);

每个奇数小时运行任务。

→everyTwoHours($minutes = 0);

每两小时运行任务。

→everyThreeHours($minutes = 0);

每三小时运行任务。

→everyFourHours($minutes = 0);

每四小时运行任务。

→everySixHours($minutes = 0);

每六小时运行任务。

→daily();

每天午夜运行任务。

→dailyAt('13:00');

每天13:00运行任务。

→twiceDaily(1, 13);

每天1:00和13:00运行任务。

→twiceDailyAt(1, 13, 15);

每天1:15和13:15运行任务。

→weekly();

每周日00:00运行任务。

→weeklyOn(1, '8:00');

每周一8:00运行任务。

→monthly();

每月的第一天00:00运行任务。

→monthlyOn(4, '15:00');

每月4号15:00运行任务。

→twiceMonthly(1, 16, '13:00');

每月1号和16号13:00运行任务。

→lastDayOfMonth('15:00');

每月最后一天15:00运行任务。

→quarterly();

每季度的第一天00:00运行任务。

→quarterlyOn(4, '14:00');

每季度4号14:00运行任务。

→yearly();

每年的第一天00:00运行任务。

→yearlyOn(6, 1, '17:00');

每年6月1日17:00运行任务。

→timezone('America/New_York');

设置任务的时区。

这些方法可以与其它约束条件结合,以创建更精细的调度,只在特定的星期几运行。例如,您可以安排一个命令每周在星期一运行:

use Illuminate\Support\Facades\Schedule;

// 每周在星期一下午1点运行一次...
Schedule::call(function () {
    // ...
})->weekly()->mondays()->at('13:00');

// 在工作日的上午8点到下午5点每小时运行一次...
Schedule::command('foo')
          ->weekdays()
          ->hourly()
          ->timezone('America/Chicago')
          ->between('8:00', '17:00');

以下是一些其它调度约束条件的列表:

方法 描述

→weekdays();

限制任务仅在工作日运行。

→weekends();

限制任务仅在周末运行。

→sundays();

限制任务仅在星期天运行。

→mondays();

限制任务仅在星期一运行。

→tuesdays();

限制任务仅在星期二运行。

→wednesdays();

限制任务仅在星期三运行。

→thursdays();

限制任务仅在星期四运行。

→fridays();

限制任务仅在星期五运行。

→saturdays();

限制任务仅在星期六运行。

→days(array|mixed);

限制任务仅在特定的星期几运行。

→between($startTime, $endTime);

限制任务仅在开始和结束时间之间运行。

→unlessBetween($startTime, $endTime);

限制任务不在开始和结束时间之间运行。

→when(Closure);

基于某个条件测试限制任务的运行。

→environments($env);

限制任务仅在特定的环境下运行。

星期几约束

days 方法可以用来限制任务仅在特定的星期几运行。例如,你可以安排一个命令每小时在星期天和星期三运行:

use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')
                ->hourly()
                ->days([0, 3]);

另外,你也可以使用 Illuminate\Console\Scheduling\Schedule 类中提供的常量来定义任务应运行的星期几:

use Illuminate\Support\Facades;
use Illuminate\Console\Scheduling\Schedule;

Facades\Schedule::command('emails:send')
                ->hourly()
                ->days([Schedule::SUNDAY, Schedule::WEDNESDAY]);

时间段约束

between 方法可用于基于一天中的时间来限制任务的执行:

Schedule::command('emails:send')
                    ->hourly()
                    ->between('7:00', '22:00');

类似地,unlessBetween 方法可用于排除任务在某个时间段内执行:

Schedule::command('emails:send')
                    ->hourly()
                    ->unlessBetween('23:00', '4:00');

条件测试约束

when 方法可用于根据给定的条件测试来限制任务的执行。换句话说,如果给定的闭包返回 true,任务将会执行,只要没有其它约束条件阻止任务运行:

Schedule::command('emails:send')->daily()->when(function () {
    return true;
});

skip 方法可以看作是 when 方法的反向操作。如果 skip 方法返回 true,则任务不会执行:

Schedule::command('emails:send')->daily()->skip(function () {
    return true;
});

当使用链式 when 方法时,只有当所有的 when 条件都返回 true 时,调度的命令才会执行。

环境约束

environments 方法可用于仅在指定的环境下执行任务(由 APP_ENV 环境变量定义):

Schedule::command('emails:send')
            ->daily()
            ->environments(['staging', 'production']);

时区

使用 timezone 方法,您可以指定调度任务的时间应在给定的时区内进行解释:

use Illuminate\Support\Facades\Schedule;

Schedule::command('report:generate')
         ->timezone('America/New_York')
         ->at('2:00')

如果您反复为所有调度任务指定相同的时区,可以通过在应用程序的 app 配置文件中定义 schedule_timezone 选项来指定所有任务的时区:

'timezone' => env('APP_TIMEZONE', 'UTC'),

'schedule_timezone' => 'America/Chicago',

请记住,一些时区使用夏令时(DST)。当夏令时发生变化时,您的调度任务可能会执行两次,甚至根本不执行。因此,我们建议尽量避免使用时区调度。

防止任务重叠

默认情况下,调度任务即使上一个任务实例仍在运行,也会继续执行。为了防止这种情况,您可以使用 withoutOverlapping 方法:

use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')->withoutOverlapping();

在这个例子中,emails:send Artisan 命令会在每分钟执行一次,前提是它当前没有正在运行。withoutOverlapping 方法特别适用于那些执行时间差异较大的任务,因为这些任务的执行时间难以预测。

如果需要,您可以指定多少分钟后“无重叠”锁定会过期。默认情况下,锁定会在 24 小时后过期:

Schedule::command('emails:send')->withoutOverlapping(10);

在后台,withoutOverlapping 方法会利用您应用的缓存来获取锁定。如果需要,您可以使用 schedule:clear-cache Artisan 命令来清除这些缓存锁定。通常,只有当任务由于意外的服务器问题而被卡住时,才需要执行此操作。

在单台服务器上运行任务

要使用此功能,您的应用程序必须将数据库、Memcached、DynamoDB 或 Redis 作为默认的缓存驱动程序。此外,所有服务器必须与同一个中央缓存服务器进行通信。

如果您的应用程序的调度器运行在多台服务器上,您可以限制某个调度任务仅在单台服务器上执行。例如,假设您有一个每周五晚生成新报告的调度任务。如果任务调度程序在三台工作服务器上运行,那么该任务将在所有三台服务器上执行,导致报告被生成三次。这可不行!

为了确保任务仅在一台服务器上执行,可以在定义调度任务时使用 onOneServer 方法。第一个获得任务的服务器将获得该任务的原子锁,从而防止其它服务器同时执行相同的任务:

use Illuminate\Support\Facades\Schedule;

Schedule::command('report:generate')
                ->fridays()
                ->at('17:00')
                ->onOneServer();

为单台服务器的任务命名

有时,您可能需要调度相同的任务,但传递不同的参数,同时仍然指示 Laravel 在每个任务的不同变体上仅在单台服务器上运行。为此,您可以通过 name 方法为每个调度定义分配一个唯一名称:

Schedule::job(new CheckUptime('https://laravel.com'))
            ->name('check_uptime:laravel.com')
            ->everyFiveMinutes()
            ->onOneServer();

Schedule::job(new CheckUptime('https://vapor.laravel.com'))
            ->name('check_uptime:vapor.laravel.com')
            ->everyFiveMinutes()
            ->onOneServer();

同样,计划的闭包也必须分配一个名称,如果它们计划在单台服务器上执行:

Schedule::call(fn () => User::resetApiRequestCount())
    ->name('reset-api-request-count')
    ->daily()
    ->onOneServer();

后台任务

默认情况下,多个在相同时间调度的任务将按它们在 schedule 方法中定义的顺序依次执行。如果您有长时间运行的任务,这可能会导致后续任务比预期的开始时间晚得多。如果您希望任务在后台运行,以便它们可以同时执行,可以使用 runInBackground 方法:

use Illuminate\Support\Facades\Schedule;

Schedule::command('analytics:report')
         ->daily()
         ->runInBackground();

runInBackground 方法仅在通过 commandexec 方法调度任务时使用。

维护模式

当应用程序处于维护模式时,您的调度任务将不会运行,因为我们不希望任务干扰您可能正在服务器上执行的任何未完成的维护操作。然而,如果您希望即使在维护模式下任务也能强制运行,可以在定义任务时调用 evenInMaintenanceMode 方法:

Schedule::command('emails:send')->evenInMaintenanceMode();

计划任务组

在定义多个具有相似配置的调度任务时,您可以使用 Laravel 的任务分组功能,以避免为每个任务重复相同的设置。任务分组简化了代码,并确保相关任务之间的一致性。

要创建一个调度任务组,调用所需的任务配置方法,然后使用 group 方法。group 方法接受一个闭包,负责定义共享指定配置的任务:

use Illuminate\Support\Facades\Schedule;

Schedule::daily()
    ->onOneServer()
    ->timezone('America/New_York')
    ->group(function () {
        Schedule::command('emails:send --force');
        Schedule::command('emails:prune');
    });

运行计划任务调度器

现在我们已经学习了如何定义调度任务,接下来让我们讨论如何在服务器上实际运行这些任务。schedule:run Artisan 命令将评估您所有的调度任务,并根据服务器当前的时间决定是否需要执行这些任务。

因此,在使用 Laravel 的调度器时,我们只需要在服务器上添加一个简单的 cron 配置条目,每分钟运行一次 schedule:run 命令。如果您不知道如何将 cron 条目添加到服务器中,可以考虑使用像 Laravel Forge 这样的服务,它可以为您管理 cron 条目:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

子分钟调度任务

在大多数操作系统中,cron 任务的执行频率限制为每分钟最多执行一次。然而,Laravel 的调度器允许您以更频繁的间隔安排任务,甚至可以每秒执行一次:

use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    DB::table('recent_users')->delete();
})->everySecond();

当您的应用程序中定义了子分钟级别的任务时,schedule:run 命令将继续运行直到当前分钟结束,而不是立即退出。这使得命令可以在整个分钟内调用所有需要的子分钟级任务。

由于子分钟任务如果运行时间比预期更长,可能会延迟后续子分钟任务的执行,因此建议所有子分钟任务将实际的任务处理委托给队列作业或后台命令:

use App\Jobs\DeleteRecentUsers;

Schedule::job(new DeleteRecentUsers)->everyTenSeconds();

Schedule::command('users:delete')->everyTenSeconds()->runInBackground();

中断子分钟任务

由于当定义了子分钟任务时,schedule:run 命令会在整个调用的分钟内运行,您可能有时需要在部署应用程序时中断该命令。否则,已经运行的 schedule:run 实例将继续使用您应用程序先前部署的代码,直到当前分钟结束。

为了中断正在进行的 schedule:run 调用,您可以将 schedule:interrupt 命令添加到您的应用程序部署脚本中。此命令应在应用程序部署完成后调用:

php artisan schedule:interrupt

本地运行调度器

通常,您不会在本地开发机器上添加调度器的 cron 条目。相反,您可以使用 schedule:work Artisan 命令。此命令将在前台运行,并每分钟调用一次调度器,直到您终止该命令。当定义了子分钟任务时,调度器将在每分钟内继续运行以处理这些任务:

php artisan schedule:work

任务输出

Laravel 调度器提供了多个方便的方法,用于处理由调度任务生成的输出。首先,您可以使用 sendOutputTo 方法将输出发送到文件中以供稍后检查:

use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')
         ->daily()
         ->sendOutputTo($filePath);

如果您希望将输出附加到指定的文件中,可以使用 appendOutputTo 方法:

Schedule::command('emails:send')
         ->daily()
         ->appendOutputTo($filePath);

使用 emailOutputTo 方法,您可以将输出通过电子邮件发送到您选择的电子邮件地址。在通过电子邮件发送任务输出之前,您应该先配置 Laravel 的电子邮件服务:

Schedule::command('report:generate')
         ->daily()
         ->sendOutputTo($filePath)
         ->emailOutputTo('taylor@example.com');

如果您仅希望在调度的 Artisan 或系统命令以非零退出代码终止时才发送电子邮件输出,可以使用 emailOutputOnFailure 方法:

Schedule::command('report:generate')
         ->daily()
         ->emailOutputOnFailure('taylor@example.com');

emailOutputToemailOutputOnFailuresendOutputToappendOutputTo 方法仅适用于 commandexec 方法。

任务钩子

使用 beforeafter 方法,您可以指定在调度任务执行前后需要执行的代码:

use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')
         ->daily()
         ->before(function () {
             // 任务即将执行...
         })
         ->after(function () {
             // 任务已经执行完毕...
         });

onSuccessonFailure 方法允许您指定在调度任务成功或失败时执行的代码。失败表示调度的 Artisan 或系统命令以非零退出代码终止:

Schedule::command('emails:send')
         ->daily()
         ->onSuccess(function () {
             // 任务成功执行...
         })
         ->onFailure(function () {
             // 任务执行失败...
         });

如果命令有输出,您可以通过在 afteronSuccessonFailure 钩子的闭包定义中类型提示 Illuminate\Support\Stringable 实例作为 $output 参数,来访问输出内容:

use Illuminate\Support\Stringable;

Schedule::command('emails:send')
         ->daily()
         ->onSuccess(function (Stringable $output) {
             // 任务成功执行...
         })
         ->onFailure(function (Stringable $output) {
             // 任务执行失败...
         });

Pinging URLs

通过使用 pingBeforethenPing 方法,调度器可以在任务执行前后自动 ping 给定的 URL。这种方法对于通知外部服务(如 Envoyer)任务的开始或结束执行非常有用:

Schedule::command('emails:send')
         ->daily()
         ->pingBefore($url)
         ->thenPing($url);

pingOnSuccesspingOnFailure 方法仅在任务成功或失败时 ping 给定的 URL。失败表示调度的 Artisan 或系统命令以非零退出代码终止:

Schedule::command('emails:send')
         ->daily()
         ->pingOnSuccess($successUrl)
         ->pingOnFailure($failureUrl);

pingBeforeIfthenPingIfpingOnSuccessIfpingOnFailureIf 方法仅在某个条件为真时 ping 给定的 URL:

Schedule::command('emails:send')
         ->daily()
         ->pingBeforeIf($condition, $url)
         ->thenPingIf($condition, $url);

Schedule::command('emails:send')
         ->daily()
         ->pingOnSuccessIf($condition, $successUrl)
         ->pingOnFailureIf($condition, $failureUrl);

事件

Laravel 在调度过程中会触发多种事件。您可以为以下事件定义监听器:

事件名称

  • Illuminate\Console\Events\ScheduledTaskStarting

  • Illuminate\Console\Events\ScheduledTaskFinished

  • Illuminate\Console\Events\ScheduledBackgroundTaskFinished

  • Illuminate\Console\Events\ScheduledTaskSkipped

  • Illuminate\Console\Events\ScheduledTaskFailed