各位听众,大家好!
今天我们不讲那些花里胡哨的前端框架,也不扯那些深奥的算法设计,我们要聊一个让无数后端开发者在深夜里抱头痛哭的话题——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) 而进入休眠,那么:
- 进程A 挂起了。
- 进程B(用户B)进来了。
- 如果进程A持有
$_SESSION['user']锁,进程B就会死锁,或者直接报错。 - 更糟糕的是,如果进程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 最大的优点是实现极其简单,且天然支持断线重连(浏览器会自动重连)。但是,它有个致命的弱点:它是单工的(单向)。
这意味着:
- 客户端不能主动发消息给服务器(除非配合长轮询)。
- 如果你的应用是聊天室,需要用户A发消息给用户B,SSE 就完蛋了。
SSE 就像: 电台广播。你打开收音机,电台一直播,你听得津津有味。但如果你想对着收音机喊一嗓子“请点首歌”,收音机是听不见的。
方案三:WebSocket —— 终极形态,全双工通信
这是目前最流行的方案。WebSocket 是一个独立的协议(基于TCP),它建立了从客户端到服务器的持久连接,并且支持双向通信。
要实现 WebSocket,纯 PHP 脚本(即请求-响应模型)是做不到的。因为 PHP 脚本运行完(或超时)就结束了,它无法保持那个连接一直开着。
所以,我们有两个选择:
- 使用 PHP 扩展: 比如 Swoole 或 Workerman。这两个库让 PHP 拥有了多线程/多进程的能力,能处理长连接。
- 使用 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 是王道?
- 全双工:你发消息给服务器,服务器立刻发回来,不需要像 SSE 那样发个“心跳”再去拉取。
- 低开销:基于 TCP,不需要 HTTP 头的往返,帧的开销非常小。
- 持久连接:连接建立后,数据传输极其顺畅。
WebSocket 就像: 你们面对面坐着聊天。你一句,我一句,中间不需要喊“喂?听得见吗?”,也不需要等对方说完再轮到你。这才是真正的实时。
深入探究:PHP 与长连接的相爱相杀
说了这么多代码,我们还得聊聊 PHP 在处理长连接时的那些“渣男”行为。如果不搞清楚这些,你的生产环境随时会崩。
1. PHP-FPM 的超时机制
这是新手最容易踩的坑。默认情况下,PHP-FPM 的执行时间是 max_execution_time = 30。这意味着,如果一个 PHP 脚本跑了超过30秒,它会被强制杀死,连接断开。
如果你的 WebSocket 服务端代码里写了一个 while(true),没做 usleep 或 sleep,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_bind或sticky模块。
3. 数据同步
如果使用了多台服务器,两边的 PHP 进程是隔离的。A 服务器上的用户发消息,怎么广播到 B 服务器上?
方案: 消息队列。
- A 服务器收到消息 -> 推入 Redis 队列。
- B 服务器上的所有 WebSocket Worker 都在监听这个 Redis 队列。
- 一有新消息,Worker 拿到数据 ->
push给对应的客户端。
这又涉及到 Swoole 的 Channel 或者 Redis 扩展。
写在最后
其实,PHP 一直在进化。以前我们说 PHP 是“写网页的”,现在有了 Swoole,PHP 是“写后端服务的”。
从 Ajax 轮询到长轮询,再到 SSE,最后到 WebSocket,这是技术的进步,也是对用户体验的尊重。不要让你的用户在页面刷新中浪费时间,也不要让服务器在无意义的请求中枯萎。
如果你想走得更远,建议尽早学习 Swoole。这不仅是技术的升级,更是思维方式的转变——从“请求-响应”的短连接思维,转变为“事件驱动”的长连接思维。
好了,今天的讲座就到这里。下课!希望各位代码无 Bug,服务器不宕机!