深入理解Laravel定時任務排程機制

2022-02-23 19:01:06
本篇文章給大家帶來了關於定時任務排程機制的相關知識,其中主要介紹了基本實現邏輯、後臺執行以及防止重複的相關問題,希望對大家有幫助。

【相關推薦:】

1. 基本實現邏輯

一個複雜的web系統後臺當中,一定會有很多定時指令碼或者任務要跑。

例如爬蟲系統需要定期去爬取一些網站資料,自動還貸系統需要每個月定時對使用者賬戶扣款結算,

會員系統需要定期檢測使用者剩餘會員天數以便及時通知續費等等。Linux系統中內建的crontab一般被廣泛地用於跑定時任務。其任務指令格式如下:

crontab指令解釋

命令列crontab -e進入crontab編輯,把自己要執行的指令編輯好之後儲存退出即可生效。

不過本文並不會過多討論crontab的內容,而是要深入分析一下PHP Laravel框架是如何基於crontab封裝出功能更加強大的任務排程(Task Scheduling)模組。

對於定時任務,我們當然可以每個任務設定一個crontab指令。只不過這樣做的話隨著定時任務的增加,crontab指令也線性增長。

畢竟crontab是一項系統級的設定,在業務中我們為了節約機器,往往對於量不大的多個專案會放在同一臺伺服器上,c

rontab指令多了就容易管理混亂,並且功能也不夠靈活強大(無法隨心所欲的停啟、處理任務間依賴關係等)。

對此Laravel的解決方案是隻宣告一條crontab,業務中的所有定時任務全都在這一條crontab中做處理和判斷,實現在程式碼層面管理任務:

* * * * * php artisan schedule:run >> /dev/null 2>&1

即php artisan schedule:run每分鐘跑一次(crontab的最高頻率),至於業務上的具體任務設定,則註冊於Kernel::schedule()中

class Kernel extends ConsoleKernel
{
    Protected function schedule(Schedule $schedule)
    {
        $schedule->command('account:check')->everyMinute(); // 每分鐘執行一次php artisan account:check 指令
        $schedule->exec('node /home/username/index.js')->everyFifteenMinutes(); //每15分鐘執行一次node /home/username/index.js 命令
        $schedule->job(new MyJob())->cron('1 2 3 10 *'); // 每年的10月3日凌晨2點1分向任務佇列分發一個MyJob任務
    }
}

上述例子中我們可以很清晰的看到系統中註冊了三項定時任務,並且提供了everyMinute, everyFifteenMinutes, daily, hourly等語意化的方法來設定任務週期。

本質上,這些語意化的方法只是crontab表示方式的一個別稱罷了,最終都會轉化為crontab中的表達方式(如 * * * * * 表示每分鐘執行一次)。

如此一來,每分鐘執行一次的php artisan schedule:run指令,會掃描Kernel::schedule中註冊的所有指令並判斷該指令設定的執行週期時候已經到期,

如果到期則推入待執行佇列。最後依次執行所有的指令。

// ScheduleRunCommand::handle函數
public function handle()
{
    foreach ($this->schedule->dueEvents() as $event) {
        if (! $event->filtersPass()) {
            continue;
        }
        $event->run();
    }
}

schedule task流程圖

這裡需要注意兩個點,第一、如何判斷指令是否已經Due了該執行了。第二、指令的執行順序問題。

首先,crontab表示式所指定的執行時間,是指絕對時間,而不是相對時間。所以僅僅根據當前時間和crontab表示式,

即可判斷出指令是否已經Due了該執行了。如果想要實現相對時間,那麼必須儲存上一次執行的時間,

然後才能進行推算下次執行應該是什麼時候。絕對時間和相對時間的區別可以用下面一幅圖概括(crontab的執行時間如圖中左側列表所示)。

Laravel中對於crontab表示式的靜態分析和判斷使用的是cron-expression庫(github.com/mtdowling/cron-expression),原理也比較直觀,就是靜態的字元分析比對。

crontab是絕對時間,而非相對時間

第二個問題是執行順序,前面的圖中我們可以看出,如果你在Kernel::schedule方法中註冊了多個任務,

正常情況下它們是順序依次執行的。也就是說必須要等到Task 1執行完成之後,Task 2才會開始執行。

在這種情況下,如果Task 1非常耗時,則會影響到Task 2的按時執行,這一點在開發中是尤其需要注意的。

不過在Kernel::schedule中註冊任務時加上runInBackground即可實現任務的後臺執行,這點我們下文詳細討論。

2. 後臺執行

前文提到的定時任務佇列順序執行的特性,前面的任務執行時間太長會妨礙後面任務的按時執行。

為解決此問題,Laravel中提供了使任務後臺執行的方法runInBackground。如:

// Kernel.php
protected function schedule(Schedule $schedule)
{
    $schedule->command('test:hello') // 執行command命令:php artisan test:hello
    ->cron('10 11 1 * *') // 每月1日的11:10:00執行該命令
    ->timezone('Asia/Shanghai') // 設定時區
    ->before(function(){/*do something*/}) // 前置hook,命令執行前執行此回撥
    ->after(function(){/*do something*/}) // 後置勾點,命令執行完之後執行此回撥
    ->runInBackground(); // 後臺執行本命令
    // 每分鐘執行command命令:php artisan test:world
    $schedule->command('test:world')->everyMinute();
}

後臺執行的原理,其實也非常簡單。我們知道在linux系統下,命令列的指令最後加個「&」符號,可以使任務在後臺執行。

runInBackground方法內部原理其實就是讓最後跑的指令後面加了「&」符號。不過在任務改為後臺執行之後,

又有了一個新的問題,即如何觸發任務的後置勾點函數。因為後置勾點函數是需要在任務跑完之後立即執行,

所以必須要有辦法監測到後臺執行的任務結束的一瞬間。我們從原始碼中一探究竟(Illuminate/Console/Scheduling/CommandBuilder.php)

// 構建執行在後臺的command指令
protected function buildBackgroundCommand(Event $event)
{
    $output = ProcessUtils::escapeArgument($event->output);
    $redirect = $event->shouldAppendOutput ? ' >> ' : ' > ';
    $finished = Application::formatCommandString('schedule:finish').' "'.$event->mutexName().'"';
    return $this->ensureCorrectUser($event,
        '('.$event->command.$redirect.$output.' 2>&1 '.(windows_os() ? '&' : ';').' '.$finished.') > '
        .ProcessUtils::escapeArgument($event->getDefaultOutput()).' 2>&1 &'
    );
}

$finished字串的內容是一個隱藏的php artisan指令,即php artisan schedule:finish <mutex_name>。

該命令被附在了本來要執行的command命令後面,用來檢測並執行後置勾點函數。

php artisan schedule:finish <mutex_name>的原始碼非常簡單,用mutex_name來唯一標識一個待執行任務,

通過比較系統中註冊的所有任務的mutex_name,來確定需要執行哪個任務的後置函數。程式碼如下:

// Illuminate/Console/Scheduling/ScheduleFinishCommand.php
// php artisan schedule:finish指令的原始碼
public function handle()
{
    collect($this->schedule->events())->filter(function ($value) {
        return $value->mutexName() == $this->argument('id');
    })->each->callAfterCallbacks($this->laravel);
}

3. 防止重複

有些定時任務指令需要執行很長時間,而laravel schedule任務最頻繁可以做到1分鐘跑一次。

這也就意味著,如果任務本身跑了1分鐘以上都沒有結束,那麼等到下一個1分鐘到來的時候,又一個相同的任務跑起來了。

這很可能是我們不想看到的結果。因此,有必要想一種機制,來避免任務在同一時刻的重複執行(prevent overlapping)。

這種場景非常類似多程序或者多執行緒的程式搶奪資源的情形,常見的預防方式就是給資源加鎖。

具體到laravel定時任務,那就是給任務加鎖,只有拿到任務鎖之後,才能夠執行任務的具體內容。

Laravel中提供了withoutOverlapping方法來讓定時任務避免重複。具體鎖的實現上,需要實現Illuminate\Console\Scheduling\Mutex.php介面中所定義的三個介面:

interface Mutex
{
    // 實現建立鎖介面
    public function create(Event $event);
    // 實現判斷鎖是否存在的介面
    public function exists(Event $event);
    // 實現解除鎖的介面
    public function forget(Event $event);
}

該介面當然可以自己實現,Laravel也給了一套預設實現,即利用快取作為儲存鎖的載體(可參考Illuminate\Console\Scheduling\CacheMutex.php檔案)。

在每次跑任務之間,程式都會做出判斷,是否需要防止重複,如果重複了,則不再跑任務程式碼:

// Illuminate\Console\Scheduling\Event.php
public function run()
{
    // 判斷是否需要防止重複,若需要防重複,並且建立鎖不成功,則說明已經有任務在跑了,這時直接退出,不再執行具體任務
    if ($this->withoutOverlapping && ! $this->mutex->create($this)) {
        return;
    }
    $this->runInBackground?$this->runCommandInBackground($container):$this->runCommandInForeground($container);
}

4. 如何實現30秒任務?

我們知道crontab任務最精細的粒度只能到分鐘級別。那麼如果我想實現30s執行一次的任務,

需要如何實現?關於這個問題,stackoverflow上面也有一些討論,有建議說在業務層面實現,自己寫個sleep來實現,範例程式碼如下:

public function handle()
{
    runYourCode(); // 跑業務程式碼
    sleep(30); // 睡30秒
    runYourCode(); // 再跑一次業務程式碼
}

如果runYourCode執行實現不太長的話,上面這個任務每隔1min執行一次,其實相當於runYourCode函數每30秒執行一次。

如果runYourCode函數本身執行時間比較長,那這裡的sleep 30秒會不那麼精確。

當然,也可以不使用Laravel的定時任務系統,改用專門的定時任務排程開源工具來實現每隔30秒執行一次的功能,

在此推薦一個定時任務排程工具nomad(https://github.com/hashicorp/nomad)。

如果你確實要用Laravel自帶的定時任務系統,並且又想實現更精確一些的每隔30秒執行一次任務的功能,那麼可以結合laravel 的queue job來實現。如下:

public function handle()
{
    $job1 = (new MyJob())->onQueue(「queue-name」);
    $job2 = (new MyJob())->onQueue(「queue-name」)->delay(30);
    dispatch($job1);
    dispatch($job2):
}

class MyJob implement Illuminate\Contracts\Queue\ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public function handle()
    {
        runYourCode();
    }
}

通過Laravel 佇列功能的delay方法,可以將任務延時30s執行,因此如果每隔1min,我們都往佇列中dispatch兩個任務,其中一個延時30秒。

另外,把自己要執行的程式碼runYourCode寫在任務中,即可實現30秒執行一次的功能。不過這裡需要注意的是,這種實現中scheduling的防止重合功能不再有效,

需要自己在業務程式碼runYourCode中實現加鎖防止重複的功能。

以上,就是使用Laravel Scheduling定時任務排程的原理分析和注意事項。作為最流行的PHP框架,Laravel大而全,

元件基本包含了web開發的各方面需求。其中很多元件的實現思想,還是很值得深入原始碼一探究竟的。

【相關推薦:】

以上就是深入理解Laravel定時任務排程機制的詳細內容,更多請關注TW511.COM其它相關文章!