PHP如何实现低延迟实时推送系统替代传统轮询方案

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聊天室。

前置条件

  1. 安装 Swoole 扩展(pecl install swoole)。
  2. 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是“线性执行”:

  1. 进来一个请求 -> 处理A -> 处理B -> 处理C -> 结束 -> 回应。
  2. 下一个请求来了。

如果在处理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_reusenet.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 的优点:

  1. 兼容性极好:原生支持,不需要特殊的扩展。
  2. 基于 HTTP:防火墙通常允许。
  3. 自动重连:如果断开,JS 的 onerror 会自动尝试重连。

SSE 的缺点:

  1. 单工:只能服务器推,客户端不能主动发。只能配合 Ajax/Fetch 来发消息。

结语:PHP 的新纪元

各位,通过今天的讲座,我们从一个“只会写轮询脚本”的 PHP 工程师,变成了一个“WebSocket 架构师”。

我们看到了:

  1. 轮询 的低效与痛苦。
  2. Swoole 如何让 PHP 变身高并发神兽。
  3. WebSocket 如何建立持久连接实现实时推送。
  4. Redis 如何解决集群通信难题。
  5. 长轮询SSE 作为备选方案的智慧。

PHP 从来不是一种落后的语言。它只是一个工具。在 Swoole 的加持下,PHP 在实时通讯、高并发网络编程领域,已经完全可以和 Go、Java 硬碰硬,甚至在某些场景下更加灵活。

现在,你可以去写那个困扰你已久的“实时协同文档”或者“百万级在线的坦克大战游戏”了。别再让用户干等了,让他们感受到消息落地的“叮”一声!

编程的世界,拒绝等待,拥抱实时!

谢谢大家!下课!

发表回复

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