在PHP开发中,我们经常会写一些需要长时间运行的脚本,比如数据导入、日志分析或消息队列消费。这类脚本通常希望在运行过程中就把进度或异常信息实时打印到浏览器或终端,而不是等全部跑完才看到结果。但很多人发现,一旦中途抛出异常,后续的输出就再也看不见了,脚本像是“卡死”或“停了”。其实这并不是输出功能坏了,而是PHP的异常传播路径和输出缓冲机制共同导致的结果。

为什么实时输出时异常会让程序“停”下来
PHP在正常执行时,如果抛出一个异常且没有对应的catch块,这个异常会沿着调用栈向上寻找处理器。若始终没被捕获,PHP内核会将其转化为一个未捕获的致命错误,并终止当前请求的生命周期。在FPM模式下,Web服务器收到终止信号后往往会直接关闭与客户端的连接,于是浏览器端之前flush出去的内容成了最后一段,后面本应继续输出的内容就彻底断了。
另一个容易被忽略的点是输出缓冲(output buffering)。即便你写了echo,数据也可能暂存在PHP的缓冲区或Web服务器(如Nginx)的缓冲里。如果没主动调用刷新函数,异常发生前的内容都未必真正到达客户端。因此“实时输出异常会停吗”的答案是:未捕获的异常一定会终止脚本,而是否“实时”看到前面的输出,取决于你有没有正确刷缓冲。
用set_exception_handler实现异常捕获并不中断输出
要让脚本在遇到异常时仍能继续运行并实时记录,第一步是注册全局异常处理器。set_exception_handler可以接管所有未被catch的异常,在函数内部你可以把异常信息打印出来,然后选择是否让脚本继续。如果不调用退出函数,脚本会在处理器结束后恢复执行流(仅针对异常,致命错误仍不可恢复)。
下面是一段基础示例,演示如何在循环中捕获异常并继续实时输出:
<?php
// 注册全局异常处理器
set_exception_handler(function ($e) {
echo "[异常] " . $e->getMessage() . PHP_EOL;
// 强制刷出缓冲
ob_flush();
flush();
});
// 关闭PHP输出缓冲,或手动控制
ob_implicit_flush(1);
for ($i = 0; $i < 5; $i++) {
echo "处理第 $i 条" . PHP_EOL;
flush();
if ($i == 2) {
// 故意抛异常,但被全局处理器接住,循环继续
throw new Exception("第 $i 条出错");
}
}
echo "全部处理完毕" . PHP_EOL;
上面的代码里,当$i等于2时抛出了异常,由于设置了异常处理器,它打印完信息后并没有终止程序,循环顺利走到结束。这就是“实时输出异常捕获法”的核心:把异常当日志,不让他变成致命终止。
配合ob_flush与flush确保真正实时
很多开发者在本地用PHP内置服务器测试时一切正常,放到Nginx+PHP-FPM后就又“卡住”,原因是多层缓冲。PHP自身可能有ob_start开启的缓冲,Web服务器也可能缓存。我们需要在输出关键点调用ob_flush()把PHP缓冲推给Web服务器,再调用flush()让服务器发给客户端。
更稳妥的做法是在脚本开头用ob_end_flush()或ob_implicit_flush(1)关掉或自动刷缓冲。注意在CLI模式下,flush通常直接写到标准输出,无需复杂处理;而在FPM模式下,如果前端还有代理,可能还要设置header('X-Accel-Buffering: no')关闭代理缓冲。示例如下:
<?php
// FPM模式下建议关闭代理缓冲
header('X-Accel-Buffering: no');
// 关闭PHP缓冲
while (ob_get_level() > 0) {
ob_end_flush();
}
ob_implicit_flush(1);
set_exception_handler(function ($e) {
echo "实时异常: " . $e->getMessage() . "n";
});
foreach (range(1, 3) as $n) {
echo "步骤 $n 开始n";
if ($n == 2) {
throw new RuntimeException("步骤 $n 失败");
}
}
这段结构在多数线上环境都能做到边跑边看。若你发现仍不实时,可用curl访问脚本并加--no-buffer参数验证,排除是终端问题。
ignore_user_abort与长时间任务的关系
实时输出脚本常配合ignore_user_abort(true)使用,它的作用是当用户关闭浏览器后,PHP仍继续运行。这对后台批处理很关键,否则客户端断开会导致脚本被中断,异常捕获也就失去意义。但需注意,如果客户端已断开,你的echo内容实际无处可去,此时应改为写文件或数据库日志。
推荐在异常处理器里判断连接状态:
<?php
ignore_user_abort(true);
set_time_limit(0);
set_exception_handler(function ($e) {
$msg = date('Y-m-d H:i:s') . ' ' . $e->getMessage() . "n";
if (connection_status() == 0) {
echo $msg;
flush();
} else {
file_put_contents('/tmp/worker.log', $msg, FILE_APPEND);
}
});
这样即便用户离开页面,异常也不会丢失,符合生产环境诉求。
常见误区与模式对比
有人习惯用try-catch包住整个循环,这当然能捕获异常,但容易把正常逻辑和错误处理混在一起。全局异常处理器更适合“记录后继续”的场景。下面用表格列出两种做法差异:
| 方式 | 是否中断 | 适用场景 |
|---|---|---|
| try-catch包裹循环体 | 不中断,但需手动continue | 单条失败跳过,逻辑清晰 |
| set_exception_handler | 默认不中断(异常场景) | 统一日志,减少侵入代码 |
| 不捕获 | 直接终止请求 | 调试阶段快速暴露问题 |
从表中可以看出,教程所指的“实时输出异常捕获法”偏向第二种,它能让老代码尽量少改动就具备容错输出能力。不过要记住,语法错误和内存耗尽这类致命错误仍无法被异常处理器接住,需要配合register_shutdown_function做最后兜底。
小结与落地建议
回到开头的问题:php实时输出异常会停吗?只要异常没被捕获,就一定会停;但只要用set_exception_handler配合flush机制,就能做到“边报边跑”。在写这类脚本时,建议统一在入口文件配置好缓冲关闭、异常处理器和中断忽略,再把业务循环里的throw当作普通事件。如此一来,无论是命令行还是Web长连接,你都能稳定地观测到每一步的异常现场。