PHP-FPM+Apache代理架构下浏览器断开后脚本不终止问题求助
PHP-FPM + Apache代理架构下SSE脚本无法感知客户端断开的解决方案
问题背景
- 架构:PHP-FPM 8.1 + Apache 2.4(mod_fcgi+代理模式),核心问题是
connection_aborted()和ignore_user_abort()完全无效 - 场景:基于Server-Sent Events(SSE)构建日志代理,客户端断开连接后PHP脚本仍持续运行,占用内存、阻塞Socket接收,甚至会占满FPM进程导致服务器无响应
- 附加问题:
max_execution_time超时后进程也不会终止,推测与Socket接收日志的逻辑有关
当前实现细节
响应头设置
header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: close');
日志输出逻辑
foreach($lines as $line) { echo "data: $line\n\n"; } flush();
Apache配置
<FilesMatch "\.php$"> SetHandler "proxy:unix:/usr/local/php81/var/run/website.sock|fcgi://localhost/" </FilesMatch> <Proxy "fcgi://localhost/"> ProxySet timeout=600 </Proxy>
完整Socket日志接收代码
<?php $host = "0.0.0.0"; $port = 1234; header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: close'); echo "data: creating socket. pid ".getmypid().", time limit: ".ini_get('max_execution_time')."\n\n"; flush(); $socket = socket_create(AF_INET, SOCK_STREAM, 0); if(empty($socket)) { $error = socket_strerror(socket_last_error()); die("data: could not create socket: $error\n\n"); } // Set the SO_REUSEADDR option if (!socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1)) { die('data: socket_set_option() failed: ' . socket_strerror(socket_last_error($this->socket))."\n\n"); } $success = @socket_bind($socket, $host, $port); if(!$success) { $error = socket_strerror(socket_last_error()); die("data: could not bind: $error\n\n"); } echo "data: listening for connections\n\n"; flush(); socket_listen($socket); $clientSocket = socket_accept($socket); echo "data: got a connection\n\n"; flush(); socket_set_nonblock($clientSocket); $read = [$clientSocket]; $write = null; $except = null; $buffer = ""; ignore_user_abort(false); // doesn't help while(true) { if (connection_aborted()) { // doesn't help either break; } $changedSockets = $read; $numChanged = socket_select($changedSockets, $write, $except, 0, 800*1000); // 5000 microseconds timeout if($numChanged === false) break; if ($numChanged <= 0) continue; $input = socket_read($clientSocket, 16484); if($input === false) { // script only quts when the connection with log client is broken break; } $buffer .= $input; $lines = explode("\n", $buffer); if(count($lines) > 1) { $buffer = array_pop($lines); //last line may be incomplete foreach($lines as $line) { echo "data: $line\n\n"; } flush(); } if (strpos($buffer, "\n") !== false) { list($line, $buffer) = explode("\n", $buffer, 2); if(empty($line)) echo "data: empty line\n\n"; echo "data: $line\n\n"; flush(); // fastcgi_finish_request(); } } socket_close($clientSocket); socket_close($socket);
解决方案思路
一、让connection_aborted()生效的配置调整
- 修改Apache Proxy配置:
- 移除
ProxySet timeout=600,或设置更短的超时值(如30秒),避免Apache长时间持有连接不通知PHP客户端断开 - 添加
ProxySet enablereuse=Off,禁用连接复用,确保每个请求对应独立连接,让断开信号及时传递到PHP-FPM
- 移除
- 调整PHP-FPM配置:
- 在
www.conf中设置request_terminate_timeout = 30,强制超时终止进程作为兜底 - 合理设置
pm.max_children,避免进程耗尽
- 在
二、代码层面主动检测客户端断开
由于代理架构下connection_aborted()失效,可通过尝试向客户端写数据并捕获错误判断连接状态:
while(true) { // 替代connection_aborted()的检测逻辑 @echo " "; $flushResult = @flush(); if (!$flushResult || connection_status() != CONNECTION_NORMAL) { // 客户端已断开,终止循环 break; } // 原有的Socket处理逻辑... }
- 确保
output_buffering = Off(php.ini或.htaccess中设置),避免输出缓冲导致检测失效 - 可降低检测频率(如每5秒一次),减少IO开销
三、轻量替代方案:改进版长轮询
若上述方案无效,可采用无需额外软件的改进版长轮询:
- 客户端每次获取日志后,等待几秒再发起下一次请求
- 服务端脚本设置较短执行超时(如10秒),仅返回新增日志,超时主动终止
- 用文件或内存缓存(如APC、Redis)存储日志队列,避免脚本持续持有Socket
// 长轮询示例 set_time_limit(10); $lastLogId = $_GET['last_id']; $cacheKey = 'log_queue'; while(true) { $logs = getNewLogsFromCache($cacheKey, $lastLogId); if (!empty($logs)) { echo json_encode($logs); exit; } sleep(1); // 检测连接 @echo " "; @flush(); if (connection_status() != CONNECTION_NORMAL) { exit; } }
四、Socket逻辑优化,避免进程无法终止
针对max_execution_time不生效的问题,在循环中主动检查运行时间:
$startTime = time(); $maxRuntime = ini_get('max_execution_time') ?: 30; while(true) { if (time() - $startTime > $maxRuntime) { break; } // 原逻辑... }
同时确保Socket操作不会阻塞进程,及时处理socket_read返回false的情况,终止循环。
内容的提问来源于stack exchange,提问作者NickSoft
相关产品推荐
相关产品推荐

