PHP也能上天?告别轮询,一招教你用PHP实现低延迟实时推送
各位好!我是你们的PHP老司机。
今天我们要聊一个非常“刺激”的话题:实时推送。
在座的各位,做Web开发的应该都遇到过这种“吐血”场景:你需要实时更新数据,比如股票价格跳动、聊天室消息、或者在线状态。按照传统的“土办法”,我们写个定时脚本,每隔几秒钟就去数据库捞一下数据,看看有没有更新,有就推,没有就睡觉,醒了再捞。
这就是传说中的轮询。
如果你对轮询的印象还停留在“每隔3秒刷新一次页面”,那你可能已经落伍了。今天,我要带大家用PHP,去干掉这个低效的累赘,构建一个真正的低延迟实时推送系统。我们不讲虚的,直接上代码,上原理,上架构。
准备好了吗?系好安全带,我们要起飞了!
第一章:轮询的“便秘”与“过劳死”
首先,让我们来批判一下轮询。为什么我把它比作“便秘”?
想象一下,你是一个坐在办公室里的前台,老板(浏览器)每隔10分钟就问秘书(服务器):“今天有快递吗?”秘书打开那个陈旧的保险柜(数据库),翻了半天,说:“没有。”老板走了。
过10分钟,老板又来了:“快递到了吗?”秘书又翻了半天,说:“还是没有。”
这就是轮询。低效、低频、毫无诚意。
再说说它的另一个外号——“过劳死”。如果有一万个用户同时盯着你的网页,每隔1秒发一个请求。这意味着你的服务器每秒钟要处理一万次完整的HTTP握手、数据库查询、逻辑处理,然后吐出一个“无新数据”的空包。
服务器CPU直冒烟,带宽被无用的HTTP请求占满。老板看报表,QPS(每秒查询率)爆表,但他不知道这些请求其实全是废话。
传统轮询的代码长什么样?
<?php
// 这是一个典型的同步阻塞轮询脚本
while (true) {
// 1. 模拟从数据库查询
$newMessages = DB::query("SELECT * FROM messages WHERE created_at > '$lastTime'");
if (!empty($newMessages)) {
// 2. 有数据!渲染出来
echo renderMessages($newMessages);
// 更新时间戳,避免重复拿
$lastTime = $newMessages[0]['created_at'];
}
// 3. 没数据?睡觉!
sleep(1); // 这里是最大的坑,延迟就是这1秒!
// 4. 唤醒!继续循环!
}
各位看官,这个脚本最大的问题就是 sleep(1)。如果用户刚好在1秒的间隙发了一条消息,他得再等1秒才能看到。
这就是我们要解决的问题:如何消除这个等待时间?
第二章:HTTP的“假分手”与WebSocket的“真连接”
要实现实时,核心只有一个:服务器要能主动联系客户端。
HTTP协议是“一问一答”的:客户端问,服务器答。答完就断开。想再问?重来。
要解决这个问题,我们需要一个持久连接。这就好比谈恋爱,HTTP是“相亲,看对眼就分”,WebSocket是“同居,连着WiFi永不掉线”。
WebSocket协议 就是在TCP协议之上,跑的一层“交通规则”。它建立了连接后,服务器和客户端就可以在同一个通道里,随便发消息,谁也不需要问“请问方便发个包吗?”。
PHP在传统的PHP-FPM模式下(也就是你平时写Web的那种),天生就是同步阻塞的。一个请求进来了,处理完必须走人,不能占着茅坑不拉屎。
所以,如果我们想用PHP搞WebSocket,我们必须换一把锤子。
这把锤子就是 Swoole(或者 Workerman)。Swoole 是 PHP 的一个高性能异步、并行网络通信扩展。它把 PHP 从“脚本语言”变成了“网络编程语言”。它是我们今天的主角,真正的“外援”。
第三章:实战演练——用Swoole搭建聊天室
别废话了,代码才是硬道理。我们要写一个简单的WebSocket聊天室。
前置条件:
- 安装 Swoole 扩展(
pecl install swoole)。 - PHP 版本 7.2 以上。
3.1 服务器端代码 (server.php)
我们要监听一个端口,通常是 9501。
<?php
// 引入 Swoole
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 "新连接建立:FD = {$request->fd}n";
// 这里的 $server->connections 是所有连接的数组
});
// 收到消息时
$server->on('message', function (Server $server, Frame $frame) {
echo "收到来自 {$frame->fd} 的消息:{$frame->data}n";
// 广播消息给所有人
foreach ($server->connections as $fd) {
// 注意:这里有个安全检查,防止发给发消息的人自己两次
// 实际生产中要过滤掉 $fd == $frame->fd 的情况
$server->push($fd, "广播:{$frame->data}");
}
});
// 连接关闭时
$server->on('close', function (Server $server, $fd) {
echo "连接 {$fd} 已关闭n";
});
// 启动服务器
$server->start();
运行方式:
php server.php
你会看到服务端开始监听。现在,如果我们在浏览器里写个JS连上去,就能发送消息了。
3.2 客户端代码 (client.html)
我们要用原生的 WebSocket API。
<!DOCTYPE html>
<html>
<head>
<title>PHP WebSocket 聊天室</title>
</head>
<body>
<h1>我是客户端A (FD: 1)</h1>
<div id="msg"></div>
<input type="text" id="input" placeholder="输入消息...">
<button onclick="send()">发送</button>
<script>
// 连接服务器
const ws = new WebSocket("ws://127.0.0.1:9501");
// 监听连接打开
ws.onopen = function(e) {
console.log("连接已打开");
};
// 监听收到消息
ws.onmessage = function(e) {
const div = document.createElement('div');
div.innerText = e.data;
document.getElementById('msg').appendChild(div);
};
// 监听连接关闭
ws.onclose = function(e) {
console.log("连接已关闭");
};
// 发送函数
function send() {
const val = document.getElementById('input').value;
ws.send(val);
}
</script>
</body>
</html>
把浏览器打开这个HTML,你会发现,当你输入“你好”,服务器收到后,不仅回复你,还会广播给所有打开的连接。哪怕你开了3个浏览器标签页,它们都会收到消息。
这就是“实时推送”。延迟?在局域网里,延迟低到可以忽略不计,基本上是“所见即所得”。
第四章:深入灵魂——PHP异步协程的奥秘
很多同学可能会问:“PHP不是单线程的吗?既然是单线程,怎么还能同时处理那么多连接?”
这是个好问题!这正是 Swoole 的厉害之处,也是我们要讲的协程。
传统的PHP是“线性执行”:
- 进来一个请求 -> 处理A -> 处理B -> 处理C -> 结束 -> 回应。
- 下一个请求来了。
如果在处理A的时候卡住了(比如在等数据库结果),后面的请求就必须排队等着。这就导致并发能力极低。
而 Swoole 是事件驱动 + 协程。
通俗解释:
想象一个食堂打饭的阿姨。
- 传统PHP:你是1号顾客,阿姨打饭给你,直到你走人,2号顾客才能进来。如果阿姨在数勺子,2号顾客就在门口干等。
- Swoole协程:阿姨手里有个手机。你是1号顾客,阿姨给你一份菜(事件触发),然后阿姨没干等,而是拿起手机回了个微信(协程挂起/切换)。这时候2号顾客进来了,阿姨给他打饭。当阿姨处理完微信回到打饭台,发现你1号顾客已经走了,再去招呼3号顾客。
在代码层面,Swoole 允许你使用 Co::run() 包裹代码,或者使用 Go 关键字。
看这段代码,完全同步的写法,却能并发执行:
<?php
// 定义一个简单的协程函数
go(function () {
// 模拟网络请求(虽然这里用的是假数据,但原理是一样的)
$result = file_get_contents("http://www.baidu.com");
var_dump("百度首页内容长度:" . strlen($result));
});
go(function () {
$result = file_get_contents("http://www.google.com");
var_dump("Google首页内容长度:" . strlen($result));
});
echo "主线程继续执行...";
这就是 Swoole 能支撑高并发、低延迟推送的根本原因。它在一个线程内模拟了多线程的效果,但是没有多线程的“锁”的痛苦。
第五章:真实世界的挑战——多实例与Redis
聊了半天单机版,咱们来聊聊“集群”和“压力测试”。
如果你买了3台服务器,部署了3个 PHP WebSocket 进程,用户A连的是服务器1,用户B连的是服务器2。用户A发了条消息,服务器1怎么知道服务器2上有个用户B在聊天?
这时候,Redis 就要登场了。
我们需要一个“消息中心”。机制是:发布/订阅。
架构图(脑补):
[用户A] <–> [WebSocket 服务器1] <–> [Redis (发布消息)]
^
|
[用户B] <–> [WebSocket 服务器2] <–> [Redis (订阅消息)]
修改一下我们的代码,加入 Redis 支持。
<?php
use SwooleServer;
use SwooleWebSocketFrame;
// 初始化 Redis 客户端
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->subscribe(['chat_channel']); // 订阅频道
$server = new Server("0.0.0.0", 9501);
$server->on('message', function (Server $server, Frame $frame) use ($redis) {
// 1. 收到消息后,先把消息发回给自己
$server->push($frame->fd, "你说的:{$frame->data}");
// 2. 然后把消息发布到 Redis,告诉所有其他服务器
$redis->publish('chat_channel', "来自 {$frame->fd} 的消息:{$frame->data}");
});
$server->on('open', function (Server $server, $request) use ($redis) {
// 新连接进来,通常不需要做啥,除非你想做在线人数统计
});
// 开启 Redis 订阅的协程读取
go(function () use ($redis) {
while (true) {
// 这是一个阻塞读取,但是是在协程里跑的
$result = $redis->recv();
if ($result) {
// 收到 Redis 推送的消息
echo "收到 Redis 广播: " . $result->message . "n";
// 遍历所有连接,广播这个消息
foreach ($server->connections as $fd) {
$server->push($fd, "广播:" . $result->message);
}
}
}
});
$server->start();
这段代码的玄机:
注意 go(function () use ($redis) { ... })。这里开启了一个独立的协程去监听 Redis。
WebSocket 服务器本身也在处理连接。因为它们在同一个进程(或者同一个协程上下文)里,通过 Swoole 的调度,它们可以同时干活。
当一个连接进来,我们在监听 Redis 的协程里,就把消息发给所有连接。这就实现了跨服务器的消息同步。
这就是完整的架构了。Redis 负责解耦,Swoole 负责处理高并发,PHP 负责业务逻辑。
第六章:HTTP长轮询——作为“备胎”的优雅
虽然 WebSocket 很强,但也不是银弹。有时候,你受限于环境,不能用 WebSocket(比如你只能用 HTTP 代理),或者你只需要单向推送(服务器推给浏览器)。
这时候,HTTP 长轮询 是一个很好的妥协方案。
原理:
客户端发起一个请求,告诉服务器:“我要数据,但没数据我就别把连接关了,等我5分钟,5分钟内有了你再叫我。”
服务器收到请求后,挂起(不返回 Response),直到有数据或者超时。
如果有数据,立即返回。
如果没有数据,5秒后返回“无数据”,客户端马上发起下一个请求。
用 Swoole 实现长轮询:
<?php
use SwooleCoroutineHttpClient;
go(function () {
// 模拟一个客户端
$client = new Client("127.0.0.1", 80);
// 设置超时为 5 秒
$client->set([
'timeout' => 5
]);
// 发起长轮询请求
$client->get('/long-poll.php?last_id=100');
echo "收到数据: " . $client->body . "n";
});
服务端代码 (long-poll.php):
<?php
use SwooleHttpRequest;
use SwooleHttpResponse;
$server = new SwooleHTTPServer("0.0.0.0", 80);
$server->on('request', function (Request $request, Response $response) {
$last_id = $request->get['last_id'] ?? 0;
// 阻塞等待,直到有新数据,或者超时
$new_data = wait_for_data($last_id);
// 立即返回
$response->header("Content-Type", "text/plain");
$response->end($new_data);
});
$server->start();
// 模拟一个函数,在协程环境中等待
function wait_for_data($last_id) {
// 这里应该连接数据库查 last_id > ?
// 但为了演示,我们模拟一个事件
// 在真实场景中,这可能是一个 Redis Pub/Sub 的订阅或者 MySQL 的 Binlog 监听
// 模拟:直接返回旧的,假装没新数据(除非你真的有新数据)
return "已经是最新数据了,ID: $last_id";
}
为什么说它是“备胎”?
它的延迟是 轮询间隔 + 网络延迟。比如你设置10秒,那延迟就是10秒。而且握手依然是 HTTP,虽然连接是持久的,但比 WebSocket 多了一层 HTTP 协议的负担。
但在某些微服务架构中,长轮询因为基于 HTTP,可以完美穿越防火墙和代理,非常稳定。
第七章:心跳保活与断线重连
在 WebSocket 世界里,网络是不稳定的。客户端可能会断电,网线会被拔掉,或者服务器突然重启了。
这时候,连接就会断开。如果服务器还不知道,它还在给这个断掉的连接发消息,那是浪费资源。
所以,心跳检测 是必须的。
原理:
服务器每隔 60 秒发一个“心跳包”(比如 “ping”),客户端收到后回复 “pong”。如果 3 次收不到回复,就判定为断线。
修改我们的服务器代码:
$server->on('message', function (Server $server, Frame $frame) {
// 1. 判断是不是心跳包
if ($frame->data == 'ping') {
// 收到 ping,回复 pong
$server->push($frame->fd, 'pong');
return;
}
// 2. 处理业务消息
// ...
});
$server->on('close', function (Server $server, $fd) {
echo "连接断开:$fdn";
});
同时,客户端 JS 也要有自动重连机制。
function connect() {
const ws = new WebSocket("ws://127.0.0.1:9501");
// 心跳定时器
let timer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send("ping");
} else {
clearInterval(timer);
}
}, 30000); // 30秒发一次
ws.onclose = function() {
console.log("连接断开,3秒后重连...");
setTimeout(connect, 3000); // 断线后3秒自动重连
};
}
记得要清理定时器! 当连接断开时,clearInterval(timer),防止内存泄漏。这可是资深工程师的修养。
第八章:性能优化与“黑科技”
聊了这么多基础,我们来点进阶的。
8.1 TCP 参数调优
Swoole 建议你修改 Linux 内核参数。默认的 TCP 参数在连接数爆炸时会卡顿。
你需要调整 net.ipv4.tcp_tw_reuse 和 net.core.somaxconn。这能让服务器在处理数万并发连接时依然保持低延迟,不至于出现“Accept队列溢出”的悲剧。
8.2 协程调度优化
在处理大量并发时,Swoole 内部的协程调度器是单线程的。如果你在代码里写了死循环,或者非常耗时的阻塞操作(比如没有用 Swoole 的数据库驱动,而是用了原生 PDO,且没有开启 PDO 的协程支持),就会导致整个服务器卡死。
最佳实践:
- 少用阻塞库:尽量用 Swoole 官方提供的 Redis、MySQL 协程驱动。
- 避免死循环:不要在事件循环里写
while(true)除非你确信自己在做定时任务。
8.3 二进制协议 vs JSON
JSON 很好用,但它是文本,有开销。如果我们追求极致的性能,传输二进制数据。
比如,我们定义一个协议头:[1字节命令][4字节长度][N字节数据]。
在 Swoole 中,我们可以直接操作底层的 Buffer 来发送二进制数据。
对于实时游戏、高频交易,JSON 的解析开销可能会成为瓶颈。这时候,Protobuf 或者直接传输二进制是必须的。
// Swoole 二进制发送示例
$server->push($fd, pack('a1A4a*', 'MSG', '100', 'Hello World'));
(这里简单演示,实际需要更复杂的协议定义)
第九章:Server-Sent Events (SSE)——单行道的王者
最后,提一下 SSE。
如果你觉得 WebSocket 建立握手需要两次握手(HTTP -> Upgrade),配置麻烦,或者你只需要服务器告诉浏览器“新数据来了”,而浏览器不需要告诉服务器任何事。
SSE 就是为此生的。
HTML5 的 EventSource 接口。它本质就是基于 HTTP 的,长连接,单向数据流。
PHP 实现超简单:
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
// 模拟实时推送
while (true) {
// 1. 检查数据库/Redis 有没有新消息
$data = getNewData();
if ($data) {
// 2. 格式化 SSE 消息
// data: 后面跟内容,双换行结束
echo "data: " . json_encode($data) . "nn";
// 3. 刷新缓冲区,强制发送给浏览器
ob_flush();
flush();
// 清空消息,避免重复发送
$data = null;
}
// 4. 没消息?歇会儿,0.5秒
sleep(0.5);
}
SSE 的优点:
- 兼容性极好:原生支持,不需要特殊的扩展。
- 基于 HTTP:防火墙通常允许。
- 自动重连:如果断开,JS 的
onerror会自动尝试重连。
SSE 的缺点:
- 单工:只能服务器推,客户端不能主动发。只能配合 Ajax/Fetch 来发消息。
结语:PHP 的新纪元
各位,通过今天的讲座,我们从一个“只会写轮询脚本”的 PHP 工程师,变成了一个“WebSocket 架构师”。
我们看到了:
- 轮询 的低效与痛苦。
- Swoole 如何让 PHP 变身高并发神兽。
- WebSocket 如何建立持久连接实现实时推送。
- Redis 如何解决集群通信难题。
- 长轮询 和 SSE 作为备选方案的智慧。
PHP 从来不是一种落后的语言。它只是一个工具。在 Swoole 的加持下,PHP 在实时通讯、高并发网络编程领域,已经完全可以和 Go、Java 硬碰硬,甚至在某些场景下更加灵活。
现在,你可以去写那个困扰你已久的“实时协同文档”或者“百万级在线的坦克大战游戏”了。别再让用户干等了,让他们感受到消息落地的“叮”一声!
编程的世界,拒绝等待,拥抱实时!
谢谢大家!下课!