You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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开销

三、轻量替代方案:改进版长轮询

若上述方案无效,可采用无需额外软件的改进版长轮询:

  1. 客户端每次获取日志后,等待几秒再发起下一次请求
  2. 服务端脚本设置较短执行超时(如10秒),仅返回新增日志,超时主动终止
  3. 用文件或内存缓存(如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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 16:59:51