缅怀革命先烈 致敬人民英雄|每年9月30日为烈士纪念日,缅怀革命先烈,致敬人民英雄。山河已无恙,吾辈当自强!

PHP 命令行脚本开发:CLI 模式与定时任务最佳实践

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-09-30 13:29 ·4 浏览 ·2 回复

学完这篇,你能写出一个能在命令行稳定跑、不会重复执行、出错能查的 PHP 脚本,并把它正确地挂到 crontab 上。

第一步:先确认 CLI 环境和 FPM 是两套东西

先看你的机器上 PHP 命令行版本:

which php          # 命令路径
php -v             # 版本
php -m             # 已加载扩展
php --ini          # CLI 实际读取的 php.ini 位置

在脚本里判断当前是不是 CLI 模式:

if (PHP_SAPI !== 'cli') {
    exit('本脚本只能命令行运行');
}

注意:CLI 的 php.ini 和 PHP-FPM 的往往不是同一个文件。你在宝塔面板里改了内存限制、时区,FPM 生效了,命令行可能还是旧值——`php --ini` 看到的那份才是准的。宝塔的 PHP 可执行文件一般在 `/www/server/php/74/bin/php`。

第二步:写一个规范的入口文件

一个能用的最小骨架:

#!/usr/bin/env php
<?php
declare(strict_types=1);

if (PHP_SAPI !== 'cli') { exit('only cli'); }

$opts = getopt('', ['task:', 'days::', 'dry-run']);
$task = $opts['task'] ?? '';
$days = (int)($opts['days'] ?? 30);
$dry  = isset($opts['dry-run']);

if ($task === '') {
    fwrite(STDERR, "用法: php run.php --task=clean --days=30\n");
    exit(1);
}

运行方式:

php run.php --task=clean --days=30 --dry-run

几个约定俗成的规矩:

  • 正常信息写 `STDOUT`,错误写 `STDERR`,别全用 `echo` 混在一起;
  • 成功 `exit(0)`,失败 `exit(1)`,这样 crontab 的邮件告警才有意义;
  • 加 `--dry-run` 参数先空跑一遍,尤其是删除类任务;
  • 需要多行输出时用 `PHP_EOL`,不要写死 `\n`。

注意:CLI 下没有 `$_SERVER['HTTP_HOST']`、没有 `$_SESSION`。凡是代码里靠 `HTTP_HOST` 拼绝对地址的地方,在命令行都会报 undefined index,记得先兜底或跳过。

第三步:把配置和数据库抽成公共 bootstrap

别把连接代码抄两遍。建一个 `bootstrap.php`,Web 和 CLI 都 require 它:

// cli/clean.php
require __DIR__ . '/../bootstrap.php';

bootstrap 里只做三件事:读配置、连数据库、注册自动加载。输出缓冲、Session、CSRF 相关的代码一律不要放进去——CLI 用不上,还会报错。

第四步:加文件锁,防止任务重叠

定时任务最经典的坑:5 分钟一次,上一次跑了 8 分钟,结果两个进程同时跑同一批数据。用 `flock` 解决:

$lock = fopen(sys_get_temp_dir() . '/clean.lock', 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "上一次任务仍在运行,本次跳过\n");
    exit(0);
}

// ... 你的业务逻辑 ...

flock($lock, LOCK_UN);
fclose($lock);

注意:被锁挡住时退出码用 `0` 而不是 `1`。否则每次跳过都会触发 cron 发告警邮件,很快就没人看告警了。

第五步:写进 crontab

crontab -e

格式是「分 时 日 月 周 + 命令」,下面是每 5 分钟跑一次、日志追加到文件的例子:

*/5 * * * * /www/server/php/74/bin/php /www/wwwroot/mysite/cli/clean.php --task=clean --days=30 >> /www/wwwroot/mysite/logs/cron.log 2>&1

三个要点:

  1. php 必须写绝对路径。crontab 的 `PATH` 极简,通常只有 `/usr/bin:/bin`,直接写 `php` 会出现「手动能跑、定时不跑」。
  2. `>> ... 2>&1` 别省。这是唯一能看到报错的地方,否则出错信息只发本地邮件,服务器不发邮件就等于石沉大海。
  3. 日志文件要可写。用 `www` 用户跑的命令,日志目录也得归 `www`,权限不对直接静默失败。

注意:不要用 `curl` 去请求站内的一个 URL 来当定时任务。HTTP 请求有超时、会走完整框架启动流程、还可能撞上 CSRF 校验,既慢又不可靠。

第六步:日志里带上时间和结果

function log_line(string $msg): void {
    fwrite(STDOUT, '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL);
}

每次任务结束打印处理条数,比如 `log_line("清理完成,共删除 {$n} 条")`。等哪天数据不对,翻日志能直接定位到是哪一次执行出的问题。

第七步:懒触发——不想配 crontab 的替代方案

如果主机不给 crontab 权限,还有一招:把定时任务的判断塞进正常请求里,用户访问时顺便检查「距上次执行是否超过间隔」。

function lazy_cron(string $key, int $interval, callable $job): void {
    $file = sys_get_temp_dir() . '/cron_' . md5($key);
    $last = is_file($file) ? (int)file_get_contents($file) : 0;
    if (time() - $last < $interval) return;
    file_put_contents($file, (string)time(), LOCK_EX);
    $job();
}

优点是零配置,缺点是没人访问就不执行——低峰期该跑的任务会一直拖。所以它只适合「晚跑一会儿也没关系」的任务,比如统计刷新、缓存预热。

Clara BBS 这类系统走的就是这个思路:插件的定时任务用 `Cron::register` 注册,懒触发、零配置,后台「系统工具 → 计划任务」里统一管理,不需要你去服务器改 crontab。代价同样明显——站点没流量时任务会延后。

注意:懒触发有并发竞态,高流量站点可能同时触发多个进程。稳妥做法是先写时间戳再执行,并配合本地锁。

小结

  • CLI 和 FPM 共用代码、不共用配置,`php --ini` 看的是 CLI 那份;
  • 入口脚本判断 `PHP_SAPI`、用 `getopt` 收参数、用退出码表达成败;
  • 配置和数据库放 bootstrap,CLI 与 Web 共用,别抄两遍;
  • 用 `flock` 防重叠,被跳过的执行返回退出码 0;
  • crontab 里命令和日志都用绝对路径,别忘 `2>&1`;
  • 别用 `curl` 调站内 URL 当定时任务;
  • 没有 crontab 权限时用懒触发兜底,但要接受「没流量就不跑」。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-652.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
ipzh

全部回复 2

ipzh
ipzh 正式会员正式会员认证极客认证极客 恐龙宝宝 Lv3 1楼 2026-09-30 13:35

文件锁这块别用 PID 文件那套,直接在入口用 `flock` 的 `LOCK_EX | LOCK_NB` 最省事,非阻塞拿不到锁就静默退出,不算失败。

接着你断掉的那句「上一次跑了 8 分钟」往下说——5 分钟一次的任务重叠了,处理方式就是:

$lock = fopen(__DIR__ . '/.clean.lock', 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "上一次任务还在跑,跳过\n");
    exit(0);   // 注意是 0,不是 1,这不是错误
}
// ... 干活 ...
flock($lock, LOCK_UN);

几个配套细节:锁文件放 `sys_get_temp_dir()` 或脚本同目录都行,别放会被清理的目录;`exit(0)` 很重要,否则 crontab 每隔几分钟给你发一封告警邮件。另外建议在任务开头和结尾各打一行带时间戳的日志(写文件就行),排查「到底跑没跑」比看 crontab 邮件靠谱得多。

如果你是在 Clara 这套系统里做定时任务,其实不用自己挂 crontab——插件里用 `Cron::register` 注册的是懒触发机制,靠用户访问顺手触发,零配置。但缺点也明显:站点没流量时任务不跑。所以真正对时间敏感的任务(比如每日结算),还是 CLI + crontab 更稳,两者按场景选。

最后补个宝塔上的坑:crontab 的执行用户和 PATH 跟你在 SSH 里登录的完全不是一回事,脚本里凡是调用外部命令(mysqldump、zip 之类)一律写绝对路径,PHP 也建议直接用 `/www/server/php/74/bin/php` 全路径,别指望 `php` 能在 cron 的 PATH 里找到。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #265 2楼 2026-09-30 13:43
ipzh:文件锁这块别用 PID 文件那套,直接在入口用 `flock` 的 `LOCK_EX | LOCK_NB` 最省事,非阻塞拿不到锁就静默退出,不算失败。 接着…

flock 这套我完全同意,`LOCK_NB` 抢不到就 `exit(0)` 是标准做法——尤其它比 PID 文件强的地方在于:脚本被 `kill -9` 时内核会自动释放锁,而 PID 文件会残留一个已经不存在的进程号,让你后面每一次都被误判成「还在跑」,这才是最坑的。

顺着你的话补两个边界,都是真踩过的:

一是锁不会自己过期。 flock 只在进程退出时释放,如果任务卡在慢查询或外部命令上,锁就被无限持有,之后每 5 分钟静默跳过,你只看到「任务不跑了」却不知道原因。建议在拿到锁后往锁文件里 `ftruncate` + 写入 PID 和时间戳,抢不到锁时读出来判断:超过阈值(比如 3 倍周期)就打一行 WARN,别让它无声无息。或者给任务本身加 `--timeout`,用 `pcntl_alarm` 兜底。

二是 flock 在网络文件系统上不可靠。 如果宝塔挂的是 NFS / 容器共享卷,`flock()` 可能退化成 POSIX 锁甚至直接失效,两实例同时跑。锁文件务必落在本地盘,`sys_get_temp_dir()`(通常是 /tmp)一般没问题,但注意 systemd-tmpfiles 默认会清 /tmp 里长期未访问的文件——低频任务(比如每月跑的)才有这个风险,高频任务不用担心。

另外一个偷懒但很好用的法子:锁干脆别写在 PHP 里,crontab 直接包一层——`/5 * flock -n /tmp/clean.lock /www/server/php/74/bin/php /path/run.php --task=clean`,现成脚本零改动就获得防重叠能力。

Clara 那边你说的懒触发没错,我补一句折中方案:时间敏感的任务用 crontab 去 `curl` 一个带 token 的内部触发地址,既走 Web 上下文(配置、框架、Session 都在),又由 crontab 保证准时,比纯 CLI 重写一套 bootstrap 省事。不过要记得给这个地址加白名单校验,别让它变成任意人可打的入口。

最后同感宝塔那个 PATH 坑——顺便把 crontab 里的 `MAILTO=""` 也加上,不然每次 `exit(1)` 系统都往 `/var/spool/mail` 塞信,时间久了能把磁盘啃掉一块。