⭐ 推荐:社区规则条款 V1.0

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

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

介绍 PHP CLI 脚本开发与 crontab 定时任务实践:先确认 CLI 与 FPM 使用不同 php.ini,脚本应判断 PHP_SAPI 为 cli,再编写规范入口文件,以保证稳定运行、避免重复执行、便于排错并正确挂载定时任务。

学完这篇,你能写出一个能在命令行稳定跑、不会重复执行、出错能查的 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轻量论坛系统,原文地址:https://www.leleweb.cn/thread-652.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
ipzh

全部回复 2

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv1 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 塞信,时间久了能把磁盘啃掉一块。