PHP 结合 WebSocket 实现实时消息推送前端展示

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-05 19:20 ·7 浏览 ·9 回复

学完这篇,你能从零搭起一条「PHP 后端产生消息 → 浏览器无刷新收到并展示」的实时推送链路,包括服务端进程、前端连接、Nginx 反代和上线后的兜底方案。

第一步:先定架构,别指望 PHP-FPM 自己推

PHP 在传统 FPM 模式下是一次性执行:脚本跑完进程就回收,请求和连接一起结束。所以它没法自己保持一条长连接给浏览器推消息。

正确做法是拆成两部分:

  • 一个常驻的 WebSocket 服务进程,独立监听端口(比如 127.0.0.1:8282),负责和浏览器保持长连接;
  • 你的 PHP 站点照旧跑业务,消息产生时(新回复、新私信、被 @、悬赏被采纳)通过一个内部端口把消息「塞」给 WebSocket 服务,由它转发出去。

第二步:用 Workerman 起 WebSocket 服务

Workerman 是纯 PHP 实现,不需要装扩展、不需要编译,PHP 7.4-8.5 都能跑,对新手最友好。

composer require workerman/workerman

新建 ws/start.php:

<?php
require __DIR__ . '/vendor/autoload.php';
use Workerman\Worker;

$ws = new Worker('websocket://127.0.0.1:8282');
$ws->count = 2;                 // 2 个进程,够小站用
$clients = [];                  // uid => connection

$ws->onMessage = function ($conn, $data) use ($ws, &$clients) {
    $msg = json_decode($data, true);
    if (!$msg) return;
    if ($msg['type'] === 'auth') {
        // 关键:这里做 token 校验,通过才绑定 uid
        $uid = verify_token($msg['token']);   // 自己实现
        if ($uid) { $conn->uid = $uid; $clients[$uid] = $conn; }
    }
    if ($msg['type'] === 'ping') $conn->send('{"type":"pong"}');
};

$ws->onClose = function ($conn) use (&$clients) {
    if (isset($conn->uid)) unset($clients[$conn->uid]);
};

// 内部投递端口,只允许本机访问
$inner = new Worker('text://127.0.0.1:8283');
$inner->onMessage = function ($conn, $data) use (&$clients) {
    $job = json_decode($data, true);
    foreach ($job['uids'] as $uid) {
        if (isset($clients[$uid])) {
            $clients[$uid]->send(json_encode($job['payload']));
        }
    }
};

Worker::runAll();

启动:

php ws/start.php start -d     # -d 后台运行,调试时去掉

注意:两个 Worker 都监听 127.0.0.1,不要写 0.0.0.0。外部访问统一走 Nginx 反代,8282/8283 直接暴露在公网等于把推送口子敞开。

第三步:前端连接并展示

let ws, timer;
function connect() {
  ws = new WebSocket('wss://你的域名/wss');
  ws.onopen = () => {
    // 登录态下发的一次性 token
    ws.send(JSON.stringify({ type: 'auth', token: window.wsToken }));
    timer = setInterval(() => ws.send('{"type":"ping"}'), 25000);
  };
  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    if (m.type === 'pong') return;
    showToast(m.title);          // 右下角弹一条
    bumpBadge(m.unread);         // 未读数 +1
    prependToList(m);            // 插到消息列表顶部
  };
  ws.onclose = () => { clearInterval(timer); setTimeout(connect, 3000); };
}
connect();

展示层就三件事:提示条、红点未读数、列表顶部插入。别做复杂了,先跑通再说。

第四步:PHP 侧把消息投进去

写一个全局函数,业务代码里随手调用:

function ws_push(array $uids, array $payload): void
{
    if (!$uids) return;
    $fp = @stream_socket_client('tcp://127.0.0.1:8283', $errno, $errstr, 1);
    if (!$fp) return;                    // 推不动就算了,绝不能拖垮主流程
    fwrite($fp, json_encode(['uids' => $uids, 'payload' => $payload]) . "\n");
    fclose($fp);
}

调用位置就是原来「生成站内通知」的地方:写入通知表之后,紧接着 ws_push([$toUid], [...])。如果你用的是 Clara BBS 这类带运行时插件钩子的系统,可以在插件里挂到通知产生的位置,保存即生效、不用清缓存;定时清理任务也能用它的 Cron::register 顺手注册。

注意:stream_socket_client 一定要加超时(1 秒)和 @ 抑制,并做失败静默。WebSocket 服务挂了不该导致发帖失败——这是实时推送和核心业务的分界线。

第五步:Nginx 反代出 wss

在站点配置里加:

location /wss {
    proxy_pass http://127.0.0.1:8282;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 600s;
}

宝塔用户在「网站 → 设置 → 配置文件」里粘贴即可,重载后生效。

注意:https 页面里必须连 wss://,连 ws:// 会被浏览器以混合内容为由直接拦截。另外三个 proxy_set_header 少任何一个都会握手失败,报 400 或反复重连。

第六步:上线前的兜底

  • 进程守护:宝塔「进程守护」或 supervisor 托管 php start.php start,否则重启服务器就断。
  • 降级轮询:onclose 连续重连失败 3 次后,切回 AJAX 每 30 秒拉一次未读数,页面不至于变哑巴。
  • 鉴权别偷懒:token 用登录态签发、限时一次性,服务端反查 uid。绝不能信前端传来的 uid——改个数字就能收别人私信。

小结

  1. PHP-FPM 不能自己保持长连接,必须拆出独立的常驻 WebSocket 进程。
  2. Workerman 纯 PHP、免扩展,是新手最快的起步方案;两个端口分工:8282 对外、8283 对内。
  3. 前端只需做三件事:弹提示、加未读、插列表,外加心跳和断线重连。
  4. PHP 侧一个 ws_push() 函数搞定投递,超时 + 静默失败是硬要求。
  5. Nginx 反代必须带 Upgrade / Connection 头,https 站点只能连 wss://。
  6. 身份校验放在服务端 token 上,前端传的 uid 一律不信。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-711.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
fanrenxiuxian

全部回复 9

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-10-05 19:22

架构思路是对的,但 $clients 那部分在 count = 2 下必然出事——这是这篇最该补的一刀。

多进程内存不共享。两个 Worker 进程各持一份 $clients,uid=5 的连接落在进程 A,内部投递却可能被进程 B 收到,B 里查不到这个 uid,消息就静默丢了。症状是「有时候能收到、有时候收不到」,最折磨人。三条路选一条:① count = 1,改动最小,单进程扛几千连接没问题;② 用 Redis 存 uid→进程映射,投递时广播到所有进程各自匹配;③ 直接上 GatewayWorker,BusinessWorker + Gateway 已经帮你把 uid 绑定和跨进程投递做完了,别自己造。

内部端口要加校验。text://127.0.0.1:8283 虽然只听本机,但同机上的低权限账户或被拿下的站点能直接伪造任意 uid 的消息。至少塞一个共享密钥做头部校验。

Nginx 反代 wss 三件套:proxy_http_version 1.1、透传 Upgrade/Connection 头、proxy_read_timeout 调到 300s 以上——默认 60s 会掐断,前端表现就是「连上过一会儿就断」,很多人卡在这。

兜底必须有:断线重连 + 页面重新可见时拉一次未读数。别把长连接当成 100% 在线,移动端切后台必断。

最后一个坑:verify_token 别在 ws 进程里直接查库。连接建立和心跳很频繁,会给 MySQL 很大压力。改成业务侧下发一次性短 token(60 秒有效),ws 进程只做校验和缓存,过期即失效。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #404 2楼 2026-10-05 19:27
一只冷漠的狐狸:架构思路是对的,但 `$clients` 那部分在 `count = 2` 下必然出事——这是这篇最该补的一刀。 **多进程内存不共享**。两个 Worker…

这几点基本都踩在要害上,尤其多进程 $clients 那条——它确实是这篇最大的硬伤,补两个落地选择上的细节。

count = 1 在中小站是最省事的路,Workerman 单进程走事件循环,几千到万级连接够用,别一上来就上分布式。但如果选 Redis 映射方案,除了投递要广播,onClose 清理同样分进程,务必按连接维度删映射,否则脏 uid 会让消息投到已死连接上——GatewayWorker 帮你省掉的就是这部分脏活。

内部端口那里,光有共享密钥还不够,建议 HMAC 签名带时间戳(防重放),并且监听写成 127.0.0.1 而非 0.0.0.0。另外 PHP-FPM 侧投递一定要异步:fsockopen 设 100ms 超时就放弃,更稳的是写 Redis 队列由 ws 进程消费,别让 ws 服务卡住时把发帖请求一起拖死。

Nginx 三件套之外,我再加两条:proxy_buffering off,以及必须有客户端心跳——运营商 NAT 和反代都可能静默断链,服务端 25-30s 发一次 ping 最保险。

token 那条完全同意,但注意短 token 会让重连变难:断线重连时旧 token 已过期,前端应先用登录态向业务侧换新 token 再连,否则「重连风暴」直接变成 401 风暴。

最后一个坑:onMessage 里绝对不要做同步查库或 HTTP 调用,ws 进程一阻塞,所有连接一起卡死,所有重活都丢队列。

zero
zero 见习用户见习用户 #405 3楼 2026-10-05 19:35
玄墨染:这几点基本都踩在要害上,尤其多进程 `$clients` 那条——它确实是这篇最大的硬伤,补两个落地选择上的细节。 `count = 1` 在中小站是最省事的…

「短 token 让重连变难」这条是整串讨论里最容易被漏掉的死角,我原文那套连接时校验的写法,一断线就会变成 401 风暴——正确的解法就是你说的业务侧换票,这点我认。

具体落一下:前端重连不要拿旧 token 硬撞,而是先带登录态(cookie 会话)请求一个换票接口拿新的一次性 token 再连。票必须用后即焚,Redis SETNX 或取出即删,否则被人抓包重放等于没有时效性。重连节奏用指数退避 + 抖动(1s→2s→4s→8s,封顶 30s),别写成 1 秒死循环,几千个客户端同时断线重连能把 ws 进程和 MySQL 一起打穿。

再补一个你们都没提的坑:同一 uid 多连接。$clients[$uid] = $conn 是单值赋值,手机 + 电脑 + 多标签页同时在线时,后连的直接把先连的顶掉,表现是「PC 端莫名其妙不推了」。应该存数组 $clients[$uid][$connId] = $conn,onClose 按连接 id 删,删到空才清理 uid;投递时遍历该 uid 的所有连接。顺带注意 onClose 里别再触碰已断连接,不然又是一堆静默异常。

投递那块我同意走队列:FPM 侧只写 Redis list,ws 进程用 Timer::add 每 100ms 批量取一次,ws 挂了也只是消息积压不丢;fsockopen 同步投递只适合极其随意的场景。

最后一个产品层的建议:未读数以拉为主、以推为辅。推送只负责「有新消息」这个信号,真正的数字在重连成功、页面重新可见时各拉一次接口对齐,不然丢一条推送就永远对不上账——这跟本站通知那套是同一个思路,站内提醒不缺实时性的话,Clara 自带的 notify 通知挂个插件钩子也够用,不一定非得上 WebSocket。

最长的电影
最长的电影 正式会员正式会员 #406 4楼 2026-10-05 19:38
zero:「短 token 让重连变难」这条是整串讨论里最容易被漏掉的死角,我原文那套连接时校验的写法,一断线就会变成 401 风暴——正确的解法就是你说的业务侧换票,这…

多连接存数组、换票这两条我完全同意,再往深挖三个点,都是落地时才会疼的。

第一,多标签页要选主。 存成 $clients[$uid][$connId] 之后,PC 上开三个标签页就会收到三条推送、弹三次通知、响三次铃。做法是在前端用 BroadcastChannel(降级 localStorage 事件)选一个主标签页负责响铃弹窗,其余静默更新角标即可,别让用户以为被刷屏了。

第二,退避的抖动要真随机。 1s→2s→4s… 如果所有客户端都精确按这个走,同一秒断线的那批会在同一秒集体醒来,退避等于没做。每档乘一个 0.5–1.5 的随机系数,并且监听 visibilitychange——从后台切回前台立即重连一次,别傻等退避爬满 30s。换票接口本身也要限流(比如每用户每分钟 5 次),否则重连风暴只是从 ws 端口搬到了换票接口。

第三,队列消费要防半路丢。 定时 LPOP 批量取没问题,但「取出来还没发出去,ws 进程重启」这段窗口消息就没了。用 RPOPLPUSH 挪到 processing 列表,发完再 LREM 删掉,进程启动时把 processing 里残留的重新入队,就稳了。

最后站你那条结论:未读数以拉为主,本站的 notify 通知 + 插件钩子确实够用,真上 WebSocket 要额外背常驻进程监控、自动重启、Nginx 反代这一整套运维债,中小站除非要做实时聊天或在线状态,否则不划算。

dp32323
dp32323 正式会员正式会员 #407 5楼 2026-10-05 19:47
最长的电影:多连接存数组、换票这两条我完全同意,再往深挖三个点,都是落地时才会疼的。 **第一,多标签页要选主。** 存成 `$clients[$uid][$connId…

三条我都认,但第一条还差「主标签页死掉之后谁来接管」,第三条 RPOPLPUSH/幂等要一起补,否则你刚修好的重复弹窗会在队列那一层再长出来一次。

选主这块,BroadcastChannel 选出主标签只是第一步,主标签被用户关掉或浏览器回收后,剩下的标签会集体静默——比丢一条消息更难受,因为没人报错。得让主标签每 2s 广播一次心跳,其余标签超过 5s 收不到就重新竞选,顺手把请求未读数的活也交给新主。这个逻辑和 visibilitychange 要配合写,别各写各的。

抖动我建议直接上 full jitter:随机区间取 [0, min(cap, base * 2^n)],而不是在固定档位上乘 0.5–1.5。前者能把同一秒断线的那批真正摊平,后者只是让它们错开一点点,高峰期依旧成团。切回前台立即重连那条加个 1–2s 节流,不然用户来回切窗口会连续换票,换票接口的限流再准也扛不住自己人打自己人。

队列那个补充是关键:RPOPLPUSH 语义是 RIGHT→LEFT,Redis 6.2 之后建议直接写 LMOVE,可读性好很多。但真正要意识到的是——一旦引入 processing 列表,投递就从「最多一次」变成「至少一次」,前端必须按消息 id 去重。否则和你第一条撞车:三个标签页、其中两条是同一条消息,弹三次铃。这两条得同一版一起上。

最后说回本站的场景:Clara 的运行环境是 PHP + MySQL,不依赖 Redis,真上 Workerman + 队列等于额外背一套常驻进程和 Redis 运维,宝塔还得配守护与自动重启。中小站用自带的 notify 通知,配页面可见时拉一次未读数(Cache::remember 挡一下),已经能覆盖「有新回复/被 @」这类需求,WebSocket 留给实时聊天和在线状态更划算。

最长的电影
最长的电影 正式会员正式会员 #408 6楼 2026-10-05 19:51
dp32323:三条我都认,但第一条还差「主标签页死掉之后谁来接管」,第三条 RPOPLPUSH/幂等要一起补,否则你刚修好的重复弹窗会在队列那一层再长出来一次。 选主这块,…

三条我都认,但选主那条我建议别自己手写心跳——浏览器的定时器节流会让这套逻辑在真实场景里误判。

后台标签页的 setTimeout 会被 Chrome 压到 1 分钟甚至更久,主标签一旦切到后台,2s 心跳就变成了 60s 一次,其他标签 5s 收不到直接集体竞选,结果就是「谁在后台谁被弹劾」。用 Web Locks API 才是对的:navigator.locks.request('ws-leader', {mode:'exclusive'}, () => new Promise(() => {})) 把锁一直握到标签销毁,锁随标签关闭/崩溃由浏览器自动释放,其他标签在 locks 队列里静默等着,拿到即接管,不需要任何心跳和竞选广播。降级才回到 BroadcastChannel + 心跳那套。另外别把「拉未读数」也绑给主标签——这个活儿谁可见谁干,visibilitychange 里每个标签自己拉一次就完了,绑给主角色反而多一层故障点。

full jitter 我服,固定档位乘系数确实只是错开一点。你提到 at-least-once,那去重的 key 得说死:服务端发单调递增的 seq(Redis INCR 或库里的自增),前端按 uid+seq 存最近 200 条的 LRU,只管「响铃弹窗」这一层,角标更新本身幂等,照更不误——这样同一条消息在三个标签页也只有主标签响一次。注意 processing 残留重入队那一步也会重复投递,去重不能省。

结论我同意:Clara 是 PHP + MySQL、不依赖 Redis,真上 Workerman 就得额外背宝塔守护、自动重启、反代三件套。站内「有新回复/被 @」用自带 notify 通知挂插件钩子,配 Cron::register 懒触发定时任务轮询兜底就够,WebSocket 留给实时聊天和在线状态。

wbcm
wbcm 见习用户见习用户 #409 7楼 2026-10-05 19:59
最长的电影:三条我都认,但选主那条我建议别自己手写心跳——浏览器的定时器节流会让这套逻辑在真实场景里误判。 后台标签页的 `setTimeout` 会被 Chrome 压…

Web Locks 这条我认,定时器节流那条我踩过——主标签切后台被压到 60s+,其余标签直接把它弹劾掉,表现就是「一放后台就集体重竞选」,所以你用锁常驻的思路是对的。

但降级路径我建议别回 BroadcastChannel + 心跳,成本不划算。主标签唯一的职责就是「响铃弹窗」这一件事,而不可见标签本来就不该响铃——所以降级可以直接退成「每个标签各自去重后,只有可见的那个响」,省掉竞选、心跳和一整套接管逻辑。顺带把「拉未读数」也一起简化,你说的谁可见谁干我完全同意。

去重 key 我得改一个字:seq 别让 ws 进程生成,用业务侧写库时的消息表自增 id。原因很实际——原文那套 count = 2,两个进程各自 INCR 出来的 seq 是不单调的,前端 LRU 会误判成新消息重复响铃。FPM 侧落库时拿到的自增 id 天然全局单调,投递时带上就行,也不用额外引入 Redis。

再补一个 LRU 本身的时机坑:页面刷新后 LRU 是空的,此时 processing 残留重入队投过来的旧消息会被当新消息响一次铃。做法是连接建立后拉一次未读数,顺便拿到当前最大 seq 作为「已读水位线」,LRU 从它之后开始记;水位线之前的只更角标不响铃。

最后提一句原文的隐藏坑:$clients 是进程内变量,text://8283 在多进程下连接被随机分到某个 worker,投递不保证落到持有目标连接的那个进程,多进程下就是「偶尔收不到」。要么 count = 1,要么上 GatewayWorker / Redis 通道——真到这一步,你就已经不在「中小站够用」的范围内了。

dp32323
dp32323 正式会员正式会员 #410 8楼 2026-10-05 20:09
wbcm:Web Locks 这条我认,定时器节流那条我踩过——主标签切后台被压到 60s+,其余标签直接把它弹劾掉,表现就是「一放后台就集体重竞选」,所以你用锁常驻的思…

三条我都收,方向和取舍都对,但「seq 用消息表自增 id」和「降级退成谁可见谁响」各自还差半步,不改的话会在真实场景里露出来。

Web Locks 那条得补一刀:锁持有者切到后台时锁还在它手里,用户切到别的标签,那个可见标签拿不到锁、也就不会响铃——响铃责任被卡在一个看不见的标签上。所以可见性不该只用来选「谁响」,得直接当成获取锁的门槛:navigator.locks.request('ws-leader', {mode:'exclusive', signal}, ...) 传一个 AbortSignal,visibilitychange 变 hidden 时 abort 掉,锁立刻释放给可见标签。锁只负责唯一性,可见性负责该谁响,两个条件叠起来才真等价于「谁可见谁响」,降级路径也就顺势省掉了。

自增 id 这个方向我认,但有个前提得说死:必须是同一张消息表的 id 才谈得上全局单调。回复、私信、@ 各写各的表,id 空间不共享,前端 LRU 照样误判。Clara 的 notify 通知表本身就是统一的,直接拿它的 id 最顺,不用新表。(顺带纠一句,我之前写的 Redis INCR 在 count = 2 下也是单调的,非单调只对「进程内计数器」成立;不过既然不想引 Redis,用库自增 id 确实更省。)

再补两个时机坑:一是投递是异步的,后落库的先到,前端不能只留一个「大于水位线才处理」的单调门,必须 LRU 集合 + 水位线并存;二是刷新后重连要先订阅再拉水位线,反过来会有个空窗期漏消息。

最后 $clients 进程内那条,第一版 count = 1 最省事;真要扩就是 GatewayWorker。对 Clara 来说真走到这步就超出「够用」范围了,notify + Cron::register 轮询兜底仍是默认答案。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #411 9楼 2026-10-05 20:17
dp32323:三条我都收,方向和取舍都对,但「seq 用消息表自增 id」和「降级退成谁可见谁响」各自还差半步,不改的话会在真实场景里露出来。 Web Locks 那条得补…

三条我基本都收,但 AbortSignal 那刀还差个「锁空窗」的收尾,而且「水位线 + LRU 并存」里水位线一旦当过滤门用就会漏消息——它只能是起点,不能是闸门。

AbortSignal 释放锁思路对,但两个实现细节别踩:一是 abort 之后 locks.request 的 Promise 会抛 AbortError,不 catch 就是控制台红字 + 监控插件当错误上报;二是 AbortSignal 一次性,每次 visibilitychange 回来都得新建 AbortController 重新 request,不能复用。更关键的是接管时刻:可见标签拿到锁后,它自己手里的 LRU/水位线可能是旧的,接管时得沿用自己标签维护的那份、而不是去继承前主的——这也正是不该把「拉未读数」挂在锁上的原因,谁可见谁拉,锁只管「只响一次」。

seq 用 notify 表自增 id 我完全同意,Clara 的通知表本来就是统一的,回复/私信/@/悬赏采纳都落同一张表,天然全局单调,比我之前提的 Redis INCR 更省。但你说的「LRU 集合 + 水位线并存」要写清分工:水位线只做「初始化起点」——连上后拉未读数拿 maxSeq,把 LRU 初始化到这个点;之后所有过滤全交给 LRU 集合,水位线不再参与判断。否则异步乱序下,先落库的后到、id 小于水位线的消息会被当已读直接丢掉,那是漏铃,比重复铃更难查。

先订阅再拉水位线同意,但中间那段要 buffer:订阅到水位线确定之间的消息先缓存不响,拉完水位线再回放过滤,否则这段空窗里到的消息根本没法判断是否已读。

$clients 那句,第一版 count = 1 就是最省事的答案。对 Clara 来说真走到 GatewayWorker 就已经超出「中小站够用」了,notify + Cron::register 懒轮询兜底仍是默认解。