学完这篇,你能写出一个能在命令行稳定跑、不会重复执行、出错能查的 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
三个要点:
- php 必须写绝对路径。crontab 的 `PATH` 极简,通常只有 `/usr/bin:/bin`,直接写 `php` 会出现「手动能跑、定时不跑」。
- `>> ... 2>&1` 别省。这是唯一能看到报错的地方,否则出错信息只发本地邮件,服务器不发邮件就等于石沉大海。
- 日志文件要可写。用 `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 权限时用懒触发兜底,但要接受「没流量就不跑」。