PHP如何实现数据实时推送并替代传统Ajax轮询方案

各位听众,大家好!

今天我们不讲那些花里胡哨的前端框架,也不扯那些深奥的算法设计,我们要聊一个让无数后端开发者在深夜里抱头痛哭的话题——PHP的实时推送

想当年,我们都是使用Ajax轮询的“苦行僧”。那时候,为了实现一个简单的“在线人数”或“新评论提醒”,我们的代码里充斥着这样的逻辑:

// 看看,这是多么熟悉的场景
setInterval(() => {
    fetch('/api/get_new_messages')
        .then(res => res.json())
        .then(data => {
            // 处理数据
        });
}, 3000); // 每3秒问一次

这就像什么?这就像你有个多疑的男朋友/女朋友,每隔3分钟就发微信问一句:“你在干嘛?”如果对方说“没干嘛”,你还得再问一遍。即使对方真的什么都没干,电话线依然在通,电费在烧,CPU在转,你的服务器还在为了这些“空问空答”而掉头发。

这就是Ajax轮询的本质:它不是在推数据,它是在乞求数据。 而且,最要命的是,你每隔3秒问一次,即使真相在1.1秒就更新了,你也要等2.9秒才能知道。这就像你在等红灯,明明绿灯已经亮了3秒了,你还得等3秒才能走。这不仅是浪费资源,这是在侮辱实时性。

那么,作为PHP专家,我们能不能换个活法?能不能让服务器主动把数据“塞”给客户端?

今天,我们就来深入探讨如何用PHP实现真正的实时推送,以及如何优雅地告别Ajax轮询。


方案一:长轮询 —— 假装是Ajax,其实是挂机

既然不能一直问,那就干脆别问。我们让客户端发个请求过去,然后像死了一样僵在那里不动,直到有新数据,或者等到时间到了才响应。

这招在技术圈叫“长轮询”。听起来挺高大上,其实就是把 setInterval 换成了 while(true),然后加了 sleep()

1. 原理演示

客户端发起请求后,服务器端开启一个死循环:

// server_long_poll.php
$timeout = 5; // 超时时间5秒
$hasUpdate = false;
$data = null;

// 模拟数据库查询,这里为了演示直接用sleep
// 在实际业务中,这里是查数据库、看Redis队列、看锁状态
if (file_exists('lock')) {
    // 如果有新数据(这里简单用文件锁模拟),或者到了超时时间
    $hasUpdate = true;
    $data = "老板,发工资了!";
} else {
    // 如果没数据,我们就睡一会儿,假装没收到请求
    sleep($timeout);
    $data = "老板,还是没发工资。";
}

echo json_encode([
    'has_update' => $hasUpdate,
    'data' => $data
]);

看起来很简单对吧? 但这只是在单机模式下能跑。一旦涉及到并发,PHP的噩梦就开始了。

2. 并发与Session的陷阱

PHP有个著名的特性:每一个请求都是一个新的进程(或者线程)。 当你的用户A发起长轮询时,服务器分配了一个进程A。如果这个进程A因为 sleep(5) 而进入休眠,那么:

  1. 进程A 挂起了。
  2. 进程B(用户B)进来了。
  3. 如果进程A持有 $_SESSION['user'] 锁,进程B就会死锁,或者直接报错。
  4. 更糟糕的是,如果进程A在休眠,进程B想修改数据,进程A一觉醒来,数据可能已经被覆盖了。

代码修正版(解决并发与Session):

// server_long_poll_concurrent.php
session_write_close(); // 关键!这一步是长轮询能支持并发的基础。释放Session锁。

$timeout = 3; 
$hasUpdate = false;
$message = "";

// 模拟业务逻辑
// 假设我们有一个全局的数组模拟共享内存(实际应用中是Redis或数据库)
// 注意:在PHP多进程环境下,这里需要加锁或用Redis
static $db = null;
if ($db === null) {
    $db = [];
}

// 这里只是为了演示,实际操作数据库时要极其小心锁
if (count($db) > 0) {
    $hasUpdate = true;
    $message = array_pop($db); // 取出最新一条
} else {
    // 主动休眠,但不阻塞其他请求处理
    sleep($timeout);
    $message = "Timeout: 暂无新消息";
}

echo json_encode([
    'status' => 'success',
    'data' => $message
]);

3. 长轮询的优缺点

  • 优点:兼容性好,不需要专门的库(SSE或WebSocket),支持HTTP代理(防火墙问题解决)。
  • 缺点
    • 延迟:虽然比轮询好,但依然存在“从数据产生到被发现”的延迟(因为要等下一次请求)。
    • 服务器压力:虽然减少了请求次数,但依然是一直有连接挂起,占着连接池。
    • HTTP头开销:每次都要发完整的HTTP请求/响应。

长轮询就像是: 你去餐厅点菜,服务员让你坐下等。如果厨房做好了,服务员立刻叫你;如果没好,服务员就让你再等一会儿。虽然比一直问“好了没”强,但你还是得一直坐在那。


方案二:服务器发送事件 —— 单行道的真爱

如果你不想开那么多线程一直挂起,想要一种“轻量级”的服务器推送,那就得请出 SSE (Server-Sent Events)

SSE 是 HTML5 的新特性,全称叫“服务器发送事件”。简单来说,它就是 HTTP 协议的一个变种,专门用来让服务器单向(只能服务器->客户端)推流。

1. 核心代码实现

SSE 的核心在于 HTTP 响应头:

// server_sse.php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // 如果用了Nginx,这一行很重要,防止缓冲区把你的数据憋死

// 模拟一个实时日志推送
for ($i = 0; $i < 10; $i++) {
    echo "data: " . date('H:i:s') . " - 这是一个日志消息nn";

    // 强制刷新缓冲区,否则数据会在内存里攒到1KB才发出去,实时性就没了
    if (ob_get_level() > 0) {
        ob_flush();
    }
    flush();

    // 模拟工作间隔
    sleep(1);
}

注意那个 X-Accel-Buffering: no,这是给 Nginx 用的。Nginx 默认会对响应进行缓冲,为了实时性,必须禁用它。还有那个 ob_flush()flush(),PHP 默认有输出缓冲,不刷它,浏览器就永远收不到。

2. 浏览器端监听

const eventSource = new EventSource('/server_sse.php');

eventSource.onmessage = function(event) {
    console.log("收到新数据: ", event.data);
    // 把数据显示在页面上
    const div = document.createElement('div');
    div.innerText = event.data;
    document.body.appendChild(div);
};

// 某些特定事件
eventSource.addEventListener('customEvent', (e) => {
    console.log("自定义事件:", e.data);
});

3. SSE 的局限与突破

SSE 最大的优点是实现极其简单,且天然支持断线重连(浏览器会自动重连)。但是,它有个致命的弱点:它是单工的(单向)

这意味着:

  1. 客户端不能主动发消息给服务器(除非配合长轮询)。
  2. 如果你的应用是聊天室,需要用户A发消息给用户B,SSE 就完蛋了。

SSE 就像: 电台广播。你打开收音机,电台一直播,你听得津津有味。但如果你想对着收音机喊一嗓子“请点首歌”,收音机是听不见的。


方案三:WebSocket —— 终极形态,全双工通信

这是目前最流行的方案。WebSocket 是一个独立的协议(基于TCP),它建立了从客户端到服务器的持久连接,并且支持双向通信

要实现 WebSocket,纯 PHP 脚本(即请求-响应模型)是做不到的。因为 PHP 脚本运行完(或超时)就结束了,它无法保持那个连接一直开着。

所以,我们有两个选择:

  1. 使用 PHP 扩展: 比如 SwooleWorkerman。这两个库让 PHP 拥有了多线程/多进程的能力,能处理长连接。
  2. 使用 PHP 作为网关: 建一个 Node.js 服务处理 WebSocket,PHP 只负责传统的 HTTP 业务逻辑,两者通过 Redis 通讯。

作为资深专家,我强烈推荐大家直接拥抱 Swoole。Swoole 是 PHP 的“核武器”,它让 PHP 在高并发、长连接领域超越了 Node.js 和 Python。

1. Swoole 的入门极简版

假设你已经安装了 Swoole 扩展。

<?php
// server_websocket.php
use SwooleServer;
use SwooleWebSocketFrame;

// 创建一个 WebSocket 服务器,监听 0.0.0.0:9501
$server = new Server("0.0.0.0", 9501);

$server->on('open', function (Server $server, $request) {
    echo "客户端 {$request->fd} 连接成功n";
});

$server->on('message', function (Server $server, Frame $frame) {
    echo "收到来自 {$frame->fd} 的消息: {$frame->data}n";

    // 服务器回发消息给客户端
    $server->push($frame->fd, "服务器收到: {$frame->data}");
});

$server->on('close', function ($server, $fd) {
    echo "客户端 {$fd} 关闭连接n";
});

// 启动服务器
$server->start();

代码简单到让人想哭,对吧?不需要 Nginx 反向代理配置特殊(虽然推荐用),不需要 Node.js。

2. 构建一个简单的聊天室架构

我们用 Swoole 来模拟一个聊天室。这里为了简化,我们不用 MySQL 存聊天记录(太重了),直接在内存里存,但会演示如何广播消息。

服务端代码 (swoole_chat.php):

<?php
require_once 'vendor/autoload.php'; // 假设用了 Workerman,Swoole可能不需要

use SwooleWebSocketServer;

$ws = new Server("0.0.0.0", 9502);

$users = []; // 存储在线用户 fd 和昵称

$ws->on('open', function ($server, $req) use (&$users) {
    $userId = uniqid(); // 生成一个假ID
    $users[$req->fd] = [
        'id' => $userId,
        'name' => 'User_' . $userId
    ];
    echo "User {$req->fd} joined.n";

    // 通知大家有人加入了
    $msg = json_encode([
        'type' => 'system',
        'content' => "{$users[$req->fd]['name']} 加入了聊天室"
    ]);

    foreach ($users as $fd => $user) {
        $server->push($fd, $msg);
    }
});

$ws->on('message', function ($server, $frame) use (&$users) {
    $message = $frame->data;
    $user = $users[$frame->fd] ?? ['name' => 'Unknown'];

    // 构造广播消息
    $broadcastMsg = json_encode([
        'type' => 'chat',
        'user' => $user['name'],
        'content' => $message,
        'time' => date('H:i:s')
    ]);

    // 广播给所有用户
    foreach ($users as $fd => $u) {
        $server->push($fd, $broadcastMsg);
    }
});

$ws->on('close', function ($server, $fd) use (&$users) {
    if (isset($users[$fd])) {
        echo "User {$fd} left.n";
        unset($users[$fd]);

        // 广播下线消息
        $msg = json_encode(['type' => 'system', 'content' => '有人离开了聊天室']);
        foreach ($users as $f => $u) {
            $server->push($f, $msg);
        }
    }
});

$ws->start();

客户端代码 (html/chat.html):

<!DOCTYPE html>
<html>
<head>
    <title>PHP WebSocket 聊天室</title>
</head>
<body>
    <h2>聊天室</h2>
    <div id="messages"></div>
    <input type="text" id="msgInput" placeholder="输入消息...">
    <button onclick="send()">发送</button>

    <script>
        var ws = new WebSocket("ws://127.0.0.1:9502");

        ws.onopen = function() {
            console.log("连接成功");
        };

        ws.onmessage = function(e) {
            var data = JSON.parse(e.data);
            var div = document.createElement('div');

            if (data.type === 'system') {
                div.style.color = 'gray';
                div.innerText = '[系统] ' + data.content;
            } else {
                div.innerText = '[' + data.time + '] ' + data.user + ': ' + data.content;
            }

            document.getElementById('messages').appendChild(div);
        };

        function send() {
            var input = document.getElementById('msgInput');
            ws.send(input.value);
            input.value = '';
        }
    </script>
</body>
</html>

3. 为什么 WebSocket 是王道?

  1. 全双工:你发消息给服务器,服务器立刻发回来,不需要像 SSE 那样发个“心跳”再去拉取。
  2. 低开销:基于 TCP,不需要 HTTP 头的往返,帧的开销非常小。
  3. 持久连接:连接建立后,数据传输极其顺畅。

WebSocket 就像: 你们面对面坐着聊天。你一句,我一句,中间不需要喊“喂?听得见吗?”,也不需要等对方说完再轮到你。这才是真正的实时。


深入探究:PHP 与长连接的相爱相杀

说了这么多代码,我们还得聊聊 PHP 在处理长连接时的那些“渣男”行为。如果不搞清楚这些,你的生产环境随时会崩。

1. PHP-FPM 的超时机制

这是新手最容易踩的坑。默认情况下,PHP-FPM 的执行时间是 max_execution_time = 30。这意味着,如果一个 PHP 脚本跑了超过30秒,它会被强制杀死,连接断开。

如果你的 WebSocket 服务端代码里写了一个 while(true),没做 usleepsleep,30秒后,你的客户端就会收到一个 Connection reset 的错误。

解决方案: 在启动脚本里加上 set_time_limit(0);

2. Nginx 的缓冲

虽然我们在 PHP 里用了 flush(),但如果前面还有 Nginx,Nginx 会充当缓冲区。它会先把你的数据攒够一定大小(通常是 4KB)或者攒够时间(通常是 60秒)才转发给浏览器。

解决方案: 在 Nginx 配置里加:

location /ws/ {
    proxy_pass http://127.0.0.1:9502;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400; # 24小时不超时
    proxy_buffering off; # 关闭缓冲,关键!
}

3. 内存泄漏与 GC

PHP 是自动垃圾回收的。但在 WebSocket 这种长连接场景下,如果每个连接都创建一个新的对象、一个新的数组,当连接挂断时,GC 可能来不及回收,或者 GC 频繁触发导致卡顿。

最佳实践: 使用 引用计数 或者 弱引用。在 Swoole 中,它的对象管理已经做得很好了,但如果是原生 PHP 脚本,要注意清理大数组。

4. Session 锁

前面提到过长轮询里的 session_write_close()。在 WebSocket 里,一旦握手成功,整个连接期间都不应该再去读写 Session,否则会阻塞其他连接。直接使用 Redis 存 Session,并且不要开启文件锁。


性能对比:脸盲症患者的福音

为了让大家更直观地选择,我们来做个“体检”。

特性 Ajax 轮询 长轮询 SSE WebSocket (Swoole)
延迟 高 (N * RTT) 极低
服务器压力 极高 (短连接) 中 (长连接) 低 (单向) 低 (长连接)
双向通信
断线重连 需手动处理 需手动处理 浏览器自动 应用层处理
防火墙/代理 无障碍 无障碍 可能受限 (HTTPS) 需配置 HTTP/1.1 Upgrade
PHP 实现 简单 (原生) 简单 (原生) 简单 (原生) 需扩展 (Swoole/Workerman)

总结一下:

  • 如果你只是想做一个简单的状态更新(比如文件上传进度、股票大盘),且服务器主要给前端看,用 SSE,简单又好用。
  • 如果你需要聊天室、协作文档编辑,必须用 WebSocket
  • 如果你是传统 PHP 开发者,短期内不想换架构,用 长轮询 顶一顶,但心里要有数,这绝不是长久之计。

生产环境实战:从开发到部署的避坑指南

光有代码是不够的,我们来聊聊如何在真实环境中部署这些方案。

1. 守护进程

你绝对不能在命令行里 php server.php 然后按 Ctrl+C 离开。那样一断网,服务就没了。你需要使用 supervisor 来管理这些 PHP 进程。

[program:swoole_chat]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/swoole_chat.php
autostart=true
autorestart=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/supervisor/swoole_chat.log

2. 负载均衡

因为 WebSocket 是长连接,负载均衡器(如 Nginx 或 LVS)不能简单地把流量轮询分发到后端。如果连接 1 挂在了 Worker 1 上,负载均衡不能把发往连接 1 的数据包发到 Worker 2 上。

策略:

  • IP Hash:根据客户端 IP 确定发往哪个后端,这样同一个用户的连接就在同一个 Worker 上。
  • 连接保持:Nginx 的 proxy_bindsticky 模块。

3. 数据同步

如果使用了多台服务器,两边的 PHP 进程是隔离的。A 服务器上的用户发消息,怎么广播到 B 服务器上?

方案: 消息队列。

  1. A 服务器收到消息 -> 推入 Redis 队列。
  2. B 服务器上的所有 WebSocket Worker 都在监听这个 Redis 队列。
  3. 一有新消息,Worker 拿到数据 -> push 给对应的客户端。

这又涉及到 Swoole 的 Channel 或者 Redis 扩展。


写在最后

其实,PHP 一直在进化。以前我们说 PHP 是“写网页的”,现在有了 Swoole,PHP 是“写后端服务的”。

从 Ajax 轮询到长轮询,再到 SSE,最后到 WebSocket,这是技术的进步,也是对用户体验的尊重。不要让你的用户在页面刷新中浪费时间,也不要让服务器在无意义的请求中枯萎。

如果你想走得更远,建议尽早学习 Swoole。这不仅是技术的升级,更是思维方式的转变——从“请求-响应”的短连接思维,转变为“事件驱动”的长连接思维。

好了,今天的讲座就到这里。下课!希望各位代码无 Bug,服务器不宕机!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注