在 Linux 服務器上通過 Crontab 定時執行 PHP 腳本時,若上一次執行未結束就觸發下一次,會導致進程疊加、資源耗盡、數據重複處理等嚴重問題。本文介紹兩種純 PHP 實現的「單例運行」檢測方法:基於 ps 進程掃描和基於 PID 文件鎖,並分析優缺點與生產環境建議。
<?php
/**
* 檢測指定腳本是否正在運行
* @param string $scriptName 腳本文件名(不含路徑),如 'cron_sync.php'
* @param int $maxAllowed 允許的最大並發數(含當前),默認 1
* @return bool true=已在運行/超限,false=可安全執行
*/
function isScriptRunning($scriptName, $maxAllowed = 1)
{
// 獲取所有進程完整命令行
$cmd = "ps -eo cmd --no-headers";
$output = shell_exec($cmd);
if ($output === null) {
// shell_exec 失敗,保守策略:允許運行
return false;
}
// 統計匹配行數(排除 grep 自身)
$pattern = '/php.*' . preg_quote($scriptName, '/') . '/';
$matches = array();
preg_match_all($pattern, $output, $matches);
$count = count($matches[0]);
return $count >= $maxAllowed;
}
// --- 使用示例 ---
$script = 'pooy.php'; // 當前腳本文件名
if (isScriptRunning($script, 1)) {
die("[$script] 已在運行,退出。\n");
}
// 正常業務邏輯...
echo "開始執行任務...\n";
sleep(10); // 模擬耗時操作
echo "任務完成。\n";
?>
| 優點 | 缺點 |
|---|---|
| 無需額外文件/目錄權限 | ps 輸出格式因系統而異,正則可能不準 |
| 實現簡單,無依賴 | 高併發時竞態條件:檢測到執行之間可能已結束/新啟動 |
| 可檢測不同用戶/路徑下的同名腳本 | 無法區分「正常運行」與「僵屍/卡死」進程 |
<?php
/**
* 基於文件鎖的單例運行保護
* @param string $lockFile 鎖文件路徑(建議放在 /var/run/ 或 /tmp/)
* @return resource|false 成功返回文件句柄(腳本結束自動釋放),失敗返回 false
*/
function acquireLock($lockFile)
{
$fp = fopen($lockFile, 'c'); // 'c' 模式:打開寫入,指針在開頭,不截斷
if (!$fp) {
return false;
}
// 非阻塞獨佔鎖
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
return false;
}
// 寫入當前 PID,便於監控/調試
ftruncate($fp, 0);
fwrite($fp, getmypid() . "\n");
fflush($fp);
// 註冊關閉釋放(雖然腳本結束會自動釋放,但顯式釋放更規範)
register_shutdown_function(function() use ($fp, $lockFile) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile); // 可選:刪除鎖文件
});
return $fp;
}
// --- 使用示例 ---
$lockFile = '/var/run/my_cron_job.lock'; // 需寫入權限,建議專用目錄
$fp = acquireLock($lockFile);
if (!$fp) {
die("另一個實例正在運行,退出。\n");
}
echo "獲得鎖,開始執行...\n";
// 業務邏輯...
sleep(15);
echo "執行結束,釋放鎖。\n";
// 腳本結束自動釋放鎖
?>
| 優點 | 缺點 |
|---|---|
| 原子操作,無竞態條件 | 需文件系統支持 flock(NFS 可能不支持) |
| 進程異常退出自動釋放鎖(內核保證) | 需管理鎖文件目錄權限(/var/run 需 root 創建子目錄授權) |
| 可讀取 PID 進行監控/告警 | 若腳本被 kill -9,鎖文件殘留需清理邏輯 |
<?php
// cron_wrapper.php - 通用包裝腳本
$targetScript = $argv[1] ?? '';
$lockDir = '/var/lock/myapp';
$lockFile = $lockDir . '/' . basename($targetScript, '.php') . '.lock';
if (!$targetScript || !is_file($targetScript)) {
fwrite(STDERR, "用法: php cron_wrapper.php /path/to/script.php\n");
exit(1);
}
// 1. 嘗試獲取文件鎖
$fp = @fopen($lockFile, 'c');
if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
// 2. 兼容無 flock 環境:回退進程掃描
$cmd = "ps -eo pid,cmd --no-headers | grep -v grep | grep " . escapeshellarg($targetScript);
$count = count(explode("\n", trim(shell_exec($cmd))));
if ($count > 0) {
exit(0); // 靜默退出,不污染日誌
}
// 無鎖且無進程,可能是 flock 不支持,繼續執行
} else {
fwrite($fp, getmypid());
}
// 3. 執行目標腳本(同進程 include,或 exec/php 命令)
// 方式 A:同進程(輕量,注意變量隔離)
include $targetScript;
// 方式 B:子進程(隔離性好,推薦長耗時/大內存任務)
// passthru("php " . escapeshellarg($targetScript) . " >> /var/log/myapp/cron.log 2>&1");
// 4. 釋放鎖
if ($fp) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
}
?>
# 每分鐘檢查一次,由包裝腳本保證單例
* * * * * /usr/bin/php /opt/scripts/cron_wrapper.php /opt/scripts/sync_orders.php >> /dev/null 2>&1
* * * * * /usr/bin/php /opt/scripts/cron_wrapper.php /opt/scripts/cleanup_cache.php >> /dev/null 2>&1
/var/lock/ 或 /var/run/(需 root 預創建子目錄並 chown 給 PHP 運行用戶),避免 /tmp 被系統清理導致鎖丟失。cron_wrapper.php,業務腳本保持純粹,通過參數調用。find /var/lock -mmin +60),發現長期持有鎖的進程發送告警,防止死鎖。正確的單例保護機制是 Linux 定時任務穩定運行的基石。拒絕「靠運氣不重疊」,從今天起為每個 Crontab 腳本加上專業的鎖。