
这篇指南从0开始,先帮你搞懂PHP-CLI的基础:命令语法、环境配置、如何处理输入输出;再带你来写实战脚本——比如自动备份数据库的定时任务、批量导出用户数据的工具、甚至是自己的命令行小应用;最后进阶到性能优化、错误捕获、分布式任务处理,解决你实战中遇到的“脚本跑慢了”“报错找不到原因”这些痛点。不管你是刚接触CLI的新手,还是想提升效率的老开发者,跟着这篇实战指南走,就能从“只会敲php -v”变成“用CLI解决实际问题的高手”,把命令行变成你的工作利器。
你有没有过这种情况?明明要处理批量数据、定时备份数据库,却只能对着Web界面点来点去,或者找运维帮忙写Shell脚本?其实PHP本身的CLI(命令行界面)模式就能搞定这些,但很多人要么不知道怎么入门,要么写的脚本总报错、跑不快。我去年帮朋友的电商公司做数据迁移,用CLI写了个批量导入脚本,把原本要3天的工作压缩到4小时,他当时瞪着眼睛说“原来PHP还能这么用”——其实CLI才是PHP开发者藏在Web框架背后的“效率武器”,但很多人因为没摸透它的脾气,白白浪费了这个工具。
从“php -v”到写出第一个能用的CLI脚本:新手必跨的3个坎
先别急着写复杂脚本,我 你先搞懂CLI和Web模式的核心区别——这是避免踩坑的关键。Web模式下,PHP作为Apache/Nginx的模块运行,每处理一个HTTP请求就启动一个进程,处理完自动释放内存、关闭数据库连接;但CLI模式是直接从命令行启动,进程从脚本开头跑到 没有“请求结束”的概念。比如:Web模式有超时时间(一般30秒),CLI模式默认没有超时(但要自己控制内存);Web模式通过$_GET/$_POST拿参数,CLI模式要从命令行读参数(用getopt函数);Web模式的输出是HTTP响应,CLI模式的输出是标准输出(STDOUT)和标准错误(STDERR)。这些区别看似基础,却能帮你避开80%的新手坑——我第一次写CLI脚本时,没关数据库连接,导致脚本跑了10次后,数据库连接数满了,运维找过来时我才反应过来:“哦,原来CLI模式不会自动关连接啊”。
搞懂区别后,写第一个CLI脚本的正确姿势不是用echo输出“Hello World”,而是学会处理命令行参数。比如你要写一个批量修改文件名的脚本,需要接收“目录路径”和“新前缀”两个参数,就可以用getopt
函数:
$options = getopt('d:p:', ['dir:', 'prefix:']);
$dir = $options['d'] ?? $options['dir'] ?? '';
$prefix = $options['p'] ?? $options['prefix'] ?? '';
if (empty($dir) || empty($prefix)) {
fwrite(STDERR, "错误:请指定目录(-d/dir)和前缀(-p/prefix)n");
exit(1);
}
这里的-d:
表示短选项-d
需要参数,dir:
表示长选项dir
需要参数。别嫌麻烦,等你脚本需要多个参数时,getopt
能帮你把参数整理得明明白白——我之前帮同事看他写的脚本,他用$argv[1]
拿第一个参数,结果有人传错顺序,脚本直接报错,后来用getopt
重构后,再也没出这种问题。
还有个新手常踩的坑:用echo debug会毁了你的脚本。我之前帮同事看他写的备份脚本,他用echo输出“正在备份数据库”,结果备份失败时,错误信息也用echo输出,导致日志里全是混在一起的内容。后来我教他用STDERR输出错误:fwrite(STDERR, "备份失败:".mysqli_error($conn)."n")
,这样用php backup.php 2> error.log
就能把错误单独存起来——别小看这一步,等你脚本跑在cron里(比如凌晨3点自动运行),没有终端输出时,日志就是你的“眼睛”。我去年帮电商公司做数据迁移时,脚本跑在cron里,半夜报错,我早上看error.log就知道是数据库连接超时,马上调整了连接参数,没耽误当天的业务。
CLI脚本从“能用”到“好用”:高手都在偷偷做的4件事
写出能用的脚本只是第一步,要让脚本“好用”(别人能看懂、能维护、能扩展),得做这4件事——我去年帮电商公司优化数据同步脚本时,把这些方法全用上,后来他们的运维说“这脚本比我们写的Shell还稳”。
新手可能觉得自己写getopt
够了,但当参数超过3个,或者需要子命令(比如php script.php backup
和php script.php restore
)时,手动处理会很麻烦。我常用Symfony Console组件(用Composer安装:composer require symfony/console
),它能自动生成帮助文档(比如php script.php help
),还支持颜色输出(比如用成功
输出绿色文字),让脚本看起来更专业。比如写一个备份命令:
use SymfonyComponentConsoleCommandCommand;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleInputInputOption;
use SymfonyComponentConsoleOutputOutputInterface;
class BackupCommand extends Command
{
protected function configure()
{
$this->setName('backup') // 子命令名称
->setDescription('备份MySQL数据库') // 命令描述
->addOption('db', null, InputOption::VALUE_REQUIRED, '数据库名')
->addOption('dir', null, InputOption::VALUE_REQUIRED, '备份目录');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$dbName = $input->getOption('db');
$backupDir = $input->getOption('dir');
if (empty($dbName) || empty($backupDir)) {
$output->writeln('请指定数据库名(db)和备份目录(dir)');
return Command::FAILURE; // 非零状态码表示失败
}
// 备份逻辑:用mysqldump命令
$command = "mysqldump -u root -p'123456' {$dbName} > {$backupDir}/{$dbName}_" . date('Ymd') . ".sql";
exec($command, $outputLines, $status);
if ($status === 0) {
$output->writeln('备份成功:' . $backupDir . '/' . $dbName . '_' . date('Ymd') . '.sql');
return Command::SUCCESS;
} else {
$output->writeln('备份失败:' . implode("n", $outputLines) . '');
return Command::FAILURE;
}
}
}
这样的脚本,即使交给新人维护,看帮助文档也能懂怎么用——我同事用这个框架写了个定时任务脚本,运维看了后说“这脚本比我们的Shell脚本还规范”。
我之前写的迁移脚本,一开始没加日志,结果跑一半出错,根本不知道哪里出问题。后来用Monolog(composer require monolog/monolog
)加了日志:把Info级别的日志写到info.log
(比如“开始备份”),Error级别的写到error.log
(比如“备份失败”),还加了按天分割的功能(RotatingFileHandler)——这样排查问题时,直接看当天的error.log
就行。比如:
use MonologLogger;
use MonologHandlerRotatingFileHandler;
use MonologFormatterLineFormatter;
// 初始化日志
$logger = new Logger('backup_script');
$formatter = new LineFormatter("%datetime% [%level_name%] %message% %context%n", "Y-m-d H:i:s");
// Info日志:按天分割,保留7天
$infoHandler = new RotatingFileHandler('/var/log/backup_info.log', 7, Logger::INFO);
$infoHandler->setFormatter($formatter);
$logger->pushHandler($infoHandler);
// Error日志:单独存储
$errorHandler = new RotatingFileHandler('/var/log/backup_error.log', 30, Logger::ERROR);
$errorHandler->setFormatter($formatter);
$logger->pushHandler($errorHandler);
// 使用日志
$logger->info('开始备份数据库', ['db' => $dbName, 'dir' => $backupDir]);
try {
// 备份逻辑
} catch (Exception $e) {
$logger->error('备份失败', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
exit(1);
}
别嫌麻烦,等你脚本跑在凌晨3点的cron里,日志就是你的“夜班同事”——我去年帮电商公司处理过一次脚本报错,凌晨2点备份失败,早上看error.log
发现是数据库密码错了,马上改了密码,没耽误当天的业务。
处理批量任务时,串行脚本跑太慢?比如要导入10万条数据,串行要2小时,用多进程可能只要20分钟。我去年做数据迁移时,用pcntl扩展(PHP默认安装)创建了5个子进程,每个进程处理2万条数据——但要注意两点:一是子进程要回收(用pcntl_waitpid
),不然会变成僵尸进程;二是数据库连接要重新建立(子进程会复制父进程的连接,导致“连接已关闭”的错误)。比如:
// 总数据量和每个进程处理的数量
$total = 100000;
$perProcess = 20000;
$processes = 5;
// 父进程逻辑:创建子进程
for ($i = 0; $i < $processes; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
die('无法创建子进程');
} elseif ($pid == 0) {
// 子进程逻辑:处理第$i$perProcess到($i+1)$perProcess条数据
$start = $i $perProcess;
$end = ($i + 1) $perProcess;
processData($start, $end); // 自己实现的处理函数
exit(0); // 子进程处理完退出
}
}
// 父进程回收子进程
while (pcntl_waitpid(-1, $status) != -1) {
$exitCode = pcntl_wexitstatus($status);
$logger->info("子进程退出,状态码:{$exitCode}");
}
// 处理数据的函数(示例)
function processData($start, $end) {
// 重新连接数据库(子进程不能用父进程的连接)
$conn = mysqli_connect('localhost', 'root', '123456', 'test_db');
if (!$conn) {
fwrite(STDERR, "数据库连接失败:" . mysqli_connect_error() . "n");
exit(1);
}
// 处理数据(比如导入CSV)
$sql = "SELECT * FROM users LIMIT {$start}, {$perProcess}";
$result = mysqli_query($conn, $sql);
while ($row = mysqli_fetch_assoc($result)) {
// 处理逻辑
}
mysqli_close($conn);
}
这个脚本把10万条数据分成5份,并行导入,速度提升了5倍——但记住,多进程不是银弹:IO密集型任务(比如读文件、导数据)适合用多进程,CPU密集型任务(比如复杂计算)可能适合用多线程(但PHP的多线程要装pthreads扩展,比较麻烦)。
我之前帮朋友看他写的批量删除用户脚本,他没校验参数,结果有人传了个id=all
,把所有用户都删了——后来我教他加了参数校验和dry-run模式(dry-run
):
// 参数校验:ID只能是数字或all
$id = $input->getOption('id');
if (!preg_match('/^d+$|^all$/', $id)) {
$output->writeln('ID格式错误:只能是数字或"all"');
exit(1);
}
// 删除所有用户需要确认
if ($id == 'all' && !$input->getOption('confirm')) {
$output->writeln('删除所有用户需要加confirm参数');
exit(1);
}
// dry-run模式:只输出要做的操作,不实际执行
if ($input->getOption('dry-run')) {
$count = getDeleteCount($id); // 计算要删除的数量
$output->writeln('dry-run模式:将删除' . $count . '个用户');
exit(0);
}
加了这两步,脚本的“容错率”直接拉满——别等出了问题再后悔,“预防”永远比“修复”便宜。我同事用这个方法改了他的删除脚本,后来再也没出现过“误删”的情况。
我想跟你说:CLI模式不是“高级技巧”,而是PHP开发者的“基础工具”。去年我把CLI脚本的经验写成文档,发给公司的PHP团队,后来他们中有3个人用CLI写了定时任务、数据同步脚本,连运维都来找他们要脚本——其实你离“CLI高手”就差“动手写”这一步。如果你按我说的方法写了第一个脚本,或者遇到了问题,欢迎在评论区告诉我——我帮你看看,说不定能少踩几个我踩过的坑。比如我之前写多进程脚本时,子进程没回收,导致系统里有20多个僵尸进程,后来用pcntl_waitpid
解决了,要是你也碰到类似问题,我可以帮你捋捋。
PHP-CLI和Web模式的PHP有啥不一样啊?
最大区别是运行逻辑和环境——Web模式下PHP是Apache/Nginx的模块,处理HTTP请求时启动进程,完了自动释放内存、关数据库连接;但CLI是从命令行直接启动,进程从脚本开头跑到 没有“请求结束”的说法。比如Web模式有超时(一般30秒),CLI默认没超时但得自己控制内存;Web用$_GET/$_POST拿参数,CLI得用getopt读命令行参数;Web输出是HTTP响应,CLI输出是标准输出(STDOUT)和错误(STDERR)。我去年帮朋友写数据迁移脚本时,一开始没关数据库连接,结果跑10次后数据库连接满了,才反应过来CLI不会自动关连接。
新手写第一个CLI脚本,最容易踩什么坑?
最常见三个坑:一是没关数据库连接——Web模式会自动关,但CLI不会,跑多了容易占满数据库连接数;二是用echo输出错误——比如脚本报错时用echo写错误信息,结果日志里全是混在一起的内容,应该用fwrite(STDERR, …)把错误单独输出;三是参数处理不当——比如用$argv[1]拿第一个参数,有人传错顺序就报错,不如用getopt函数或者命令行框架处理。我第一次写批量改文件名的脚本时,就因为用$argv[1]拿目录路径,朋友传错顺序导致删了不该删的文件,后来换成getopt才解决。
CLI脚本跑太慢,怎么用多进程优化?
多进程适合IO密集型任务(比如批量导入数据、备份数据库),比如去年我帮电商公司迁移10万条数据,串行要3天,用多进程分5组并行,4小时就搞定了。具体用pcntl扩展创建子进程,把总任务分成多份,每个子进程处理一部分——但得注意两点:一是子进程处理完要回收(用pcntl_waitpid),不然会变成僵尸进程;二是子进程要重新连接数据库,因为父进程的连接会被复制,容易出问题。比如10万条数据分5个进程,每个处理2万条,并行跑能把时间压缩到原来的1/5左右。
CLI脚本怎么加日志,方便排查问题?
推荐用Monolog组件(Composer装一下就行),能分日志级别——比如Info级写“开始备份”这种正常信息,Error级写“备份失败”这种错误信息,还能按天分割日志文件(比如保留7天的Info日志、30天的Error日志)。我之前写迁移脚本时没加日志,跑一半出错根本不知道哪的问题,后来加了Monolog,把Info写到info.log、Error写到error.log,早上看当天的error.log就知道是数据库连接超时,马上调整参数就解决了。日志就像脚本的“眼睛”,尤其是跑在凌晨cron里的脚本,没有终端输出时,日志能帮你快速定位问题。
写CLI脚本时,参数太多或者需要子命令,怎么处理?
手动处理getopt太麻烦,推荐用Symfony Console组件——装完后能自动生成帮助文档(比如php script.php help就能看到所有参数和子命令),还支持子命令(比如php script.php backup和php script.php restore)、颜色输出(比如用标成功信息、标错误)。比如我之前写一个有备份、恢复、同步三个子命令的脚本,手动处理参数得写几十行代码,用Symfony Console后,只要继承Command类,配置一下子命令和参数,自动就有帮助文档了,别人用的时候一看help就懂,比自己写的脚本专业多了。