當 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
檔案鎖的許可權。
網站使用者一般為apache
或www
,命令列呼叫則是設定計劃任務的使用者。
如果是命令列指令碼的模式,Linux 環境中只需設定 cron
* * * * * /path/to/php /path/to/cron.php
優點
- 結構簡單,易於理解
- 系統資源需求低
- 不依賴 Redis、RDS 等佇列系統
- 不依賴特定系統,系統只提供計劃任務
缺點
- 需要進行 IO 讀寫檔案鎖
- 不適合叢集部署
- 無法解決系統 kill 程式的問題
- 意外上鎖後無法自己解鎖,需要人工刪除鎖檔案
適用場景
- 資源緊缺的 VPS,路由器等
- 需要方便在不同平臺部署
- 單指令碼或簡單的網站
本作品採用《CC 協議》,轉載必須註明作者和本文連結