PHP 使用檔案鎖 避免同時執行一個指令碼

zylntxx發表於2020-03-27


當 PHP 處理一個不確定執行時間的工作的時候,很可能會發生第一個還沒完成,下一個就已經開始的情況。例如,我們執行一個定時傳送郵件任務,如果需要傳送的郵件數量超出了預期,就會導致兩個不同的 PHP 程式在同時執行,可能發生異常情況。

這篇文章裡不使用 Redis、RDS 等系統,因為它們不適用於一些功能簡單的獨立指令碼。如果你使用了諸如 Laravel 之類的框架,使用它們是更優解。

同步釋出在我的部落格 爆米花手冊 中,歡迎來踩一踩

使用 PHP 內部函式

2021/02/28 更新

看到了 @heguangyu5 朋友的回覆,非常感謝!

PHP 有內部函式已經實現了這個功能,既然如此請優先使用 PHP 內部函式

PHP 的內部函式 flock() 可以實現共享鎖或者獨佔鎖,同時也支援阻塞模式和非阻塞模式來執行

以下是一個簡單的例子

<?php
$fp = fopen('/tmp/lock.txt', 'r+');

// 使用獨佔鎖和非阻塞模式執行
if(!flock($fp, LOCK_EX | LOCK_NB)) {
    echo 'Unable to obtain lock';
    exit(-1);
}

/* Your code */

fclose($fp);

更多使用範例請參考 PHP:flock 官方手冊

同時推薦您繼續閱讀下面的小節來增強程式碼健壯性

問題分析和解決

我們以定時傳送郵件這個小功能為例子,來思考一下真實場景裡可能會出現的問題

1. 同時有多個不同程式在傳送郵件

PHP 程式在處理完傳送郵件這個動作後會把郵箱從待傳送列表中刪除。某個時段的使用者量大增,同時有多個程式先後被 Cron 呼叫來處理郵件,因為傳送郵件和修改列表之間有時間差,導致部分使用者收到了多封相同的郵件,浪費了錢,使用者體驗也不好。

解決方法:

使用檔案鎖,同一時間只允許一個程式執行。

<?php

// 設定檔案鎖的路徑和檔名
$lock = __DIR__ . '/cron.lock';

// 假如檔案鎖存在,退出
if(file_exists($lock)) {
    exit('Other cron task is running');
}

// 建立檔案鎖
touch($lock);

// 處理傳送郵件的邏輯

// 執行完畢,釋放檔案鎖
unlink($lock);

2. 郵件程式丟擲的異常未處理

我們發現有個使用者蜜汁操作,繞過了各種過濾措施設定了一個沒辦法解析的郵箱地址,當系統嘗試給這個郵箱傳送郵件時必報錯,導致 PHP 異常退出。退出就算了,退出的時候還沒釋放檔案鎖,後來的定時任務都沒辦法處理郵件,只能人工介入處理。

解決方法:

使用 try , catch , finally 三件套,保證不管是正常、異常,都能釋放檔案鎖。

<?php

$lock = __DIR__ . '/cron.lock';

if(file_exists($lock)) {
    exit('Other cron task is running');
}

try{
    touch($lock);

    // 處理傳送郵件的邏輯

}catch (Exception $exception){

    // 回顯報錯資訊
    echo $exception->getMessage();

}finally{

    unlink($lock);

}

3. 郵件伺服器當機,程式僵死

由於遠端的伺服器出問題,PHP 不斷嘗試傳送未果,陷入死迴圈,我們需要在 PHP 執行時間遠超預期的情況下,強行終止 PHP 程式,避免伺服器被死迴圈拖累了效能。

解決方法:

給設定 PHP 超時時間,當執行時間超出我們設定的時間後,程式會被強行終止。只需要在程式碼開頭呼叫 set_time_limit($time) 即可,$time 是超時的秒數,當 $time 的值為 0 時,程式永不超時。

// 設定 PHP 最大執行時間為600秒
set_time_limit(600);

4. 出現致命錯誤

這個錯誤是由上一個問題引出的,我們發現當指令碼超時後,會發生一個致命錯誤(Fatal error),由於致命錯誤不是異常(Exception),不能被捕獲,當PHP超時或者其他原因發生了致命錯誤,很可能發生沒有釋放檔案鎖的情況。

解決方法:

透過 register_shutdown_function() 註冊 PHP 結束時執行的函式,當 PHP 程式結束之前會呼叫註冊的函式。這個方法只能處理來自 PHP 程式內部的退出,不能處理系統結束程式的情況,所以手動 kill 掉程式還是不行的。

<?php

$lock = __DIR__ . '/cron.lock';

// 設定 PHP 超時時間(秒)
set_time_limit(600);

// 註冊 PHP 結束時執行的函式
register_shutdown_function('unlock');

if(file_exists($lock)) {
    exit('Other cron task is running');
}

touch($lock);

// 傳送郵件的邏輯

// 釋放檔案鎖的函式
function unlock(){
    unlink($lock);
}

總結

我們已經解決了上面這一大堆的坑,這個 PHP 指令碼總算是可堪一用,不會日常爆炸了。

使用時我們需要給 PHP 程式使用者讀寫 cron.lock 檔案鎖的許可權。

網站使用者一般為apachewww,命令列呼叫則是設定計劃任務的使用者。

如果是命令列指令碼的模式,Linux 環境中只需設定 cron

* * * * * /path/to/php /path/to/cron.php

優點

  • 結構簡單,易於理解
  • 系統資源需求低
  • 不依賴 Redis、RDS 等佇列系統
  • 不依賴特定系統,系統只提供計劃任務

缺點

  • 需要進行 IO 讀寫檔案鎖
  • 不適合叢集部署
  • 無法解決系統 kill 程式的問題
  • 意外上鎖後無法自己解鎖,需要人工刪除鎖檔案

適用場景

  • 資源緊缺的 VPS,路由器等
  • 需要方便在不同平臺部署
  • 單指令碼或簡單的網站
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章