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

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-05 19:20 ·5 浏览 ·9 回复
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-711.html
转载请注明出处,版权归原作者所有。

全部回复 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 懒轮询兜底仍是默认解。