☰
Workerman主动推送消息到客户端:从连接管理到多进程实战
2026/10/15 4:01:35 网站建设 项目流程

1. 从"客户端拉"到"服务端推":为什么主动推送是实时通信的分水岭

很多人第一次接触 Workerman,都是从写一个简单的 TCP 或 WebSocket 服务端开始的:客户端连上来,发一句话,服务端回一句话。这种"请求—响应"模式跑通之后,心里会特别踏实,觉得实时通信不过如此。但真正到了业务场景里,你会发现一个致命的问题——服务端根本不知道"什么时候该说话"。用户 A 给用户 B 发了一条消息,服务端处理完存储逻辑之后,怎么把这条消息"塞"到用户 B 的浏览器里?如果只能等用户 B 主动来问,那实时性就无从谈起。

这就是"主动推送"要解决的核心问题。所谓主动推送,指的是服务端不再被动等待客户端发起请求,而是由服务端在某个业务事件发生时,主动把数据沿着已经建立好的长连接发送给指定的客户端。它和传统的 HTTP 轮询有本质区别:轮询是客户端每隔几秒问一次"有没有新消息",服务端永远是被动应答;而主动推送是连接一旦建立就保持住,服务端手里攥着这条连接的句柄,想什么时候发就什么时候发。

Workerman 之所以适合做这件事,是因为它本身就是一个常驻内存的 PHP 进程。传统的 PHP-FPM 模式下,每个请求结束进程就销毁了,你根本留不住任何连接状态;而 Workerman 把进程一直挂着,所有客户端连接都保存在内存里,这就为"服务端主动找到某个连接并写入数据"提供了物理基础。换句话说,主动推送不是 Workerman 的一个附加功能,而是它作为常驻进程天然就能做到的事情。

这篇文章我会围绕"Workerman 主动推送消息到客户端"这个主题,把整个链路拆开讲清楚:连接是怎么被保存和索引的、服务端在什么时机触发推送、推送时怎么精准找到目标客户端、以及实际跑起来之后那些文档里不会写的坑。适合已经能跑通 Workerman 基础 Demo、想往真实业务场景推进的开发者。如果你还没写过一行 Workerman 代码,建议先把基础的 onConnect、onMessage 回调跑通再来看这篇,否则有些概念会有点悬空。

2. 主动推送的物理前提:连接句柄到底存在哪里

2.1 每个客户端连接在服务端都是一个对象

要理解主动推送,先得理解 Workerman 是怎么看待一条连接的。当客户端通过 TCP 或 WebSocket 连上来时,Workerman 会在服务端为这条连接创建一个连接对象,这个对象里封装了底层的 socket 资源、客户端的地址信息、以及一个非常关键的属性——连接 ID。这个连接 ID 是 Workerman 自动分配的,在整个服务进程生命周期内唯一。

你可以把这个连接对象想象成一根电话线的"分机号"。客户端拨进来之后,服务端手里就多了一个分机号,只要这根线没断,服务端随时可以拿起这个分机号对应的听筒说话。主动推送的本质,就是服务端拿着这个分机号,往对应的连接里写数据。

在代码层面,这个连接对象会作为参数传进 onConnect 回调。很多新手会忽略这个参数,觉得它没什么用,但恰恰是它,是后续所有主动推送操作的入口。你必须在 onConnect 里把这个连接对象"存起来",否则连接建立之后你就再也找不到它了。

2.2 为什么必须自己维护一个连接容器

Workerman 本身并不会帮你把所有连接集中管理起来供你随时查询。它只负责在连接建立和断开时通知你,至于你要不要把这些连接记下来、怎么记,那是你的业务逻辑。这就意味着,如果你想让服务端能主动推送给"某个特定用户",你必须自己维护一个数据结构,把用户标识和连接对象对应起来。

最常用的做法是用一个全局数组或者静态属性来存。比如用用户 ID 作为键,连接对象作为值。当用户登录或者首次连接时,把映射关系写进去;当连接断开时,把对应的键删掉。这样当业务需要给某个用户推送消息时,直接拿用户 ID 去这个容器里查连接对象,查到就写数据,查不到说明用户不在线。

这里有个容易踩的坑:Workerman 是多进程模型,默认情况下会启动多个 worker 进程,每个进程有自己独立的内存空间。你在进程 A 里存的连接容器,进程 B 是看不到的。如果你不做特殊处理,就会出现"消息推给了进程 A,但目标用户恰好连在进程 B 上"的情况,结果就是推送失败。这个问题在后面讲多进程推送时会专门展开,这里先埋个伏笔。

2.3 连接 ID 和用户 ID 的映射关系设计

连接容器怎么设计,直接决定了你后续推送代码好不好写。我见过两种常见做法,各有适用场景。

第一种是单层映射:直接用用户 ID 作为键,连接对象作为值。这种最简单,适合一个用户同时只允许一个连接在线的场景。用户重新登录时,把旧的连接踢掉,用新的连接覆盖即可。

第二种是双层映射:用户 ID 对应一个连接对象数组,因为同一个用户可能同时开着手机、电脑、平板多个端。这种适合多端同时在线的业务,推送时遍历数组,给每个连接都发一份。

选择哪种,取决于你的业务是否允许多端同时在线。如果允许多端,但你又用单层映射,后登录的端会把先登录的端顶掉,用户体验会很差。反过来,如果不允许多端,你却用了双层映射,就得额外处理"踢掉旧连接"的逻辑,否则消息会重复推送。这个取舍在项目初期就要想清楚,后期改起来牵一发动全身。

3. 推送时机的选择:业务事件如何触发一次主动下发

3.1 推送不是定时任务,而是事件驱动

很多人对主动推送有个误解,以为要写个定时器每隔几秒扫一遍数据库,看有没有新消息然后推出去。这种做法虽然能跑,但延迟高、数据库压力大,完全没发挥出长连接的优势。正确的思路是事件驱动:当某个业务动作发生时,立刻触发推送。

举个例子,用户 A 给用户 B 发消息。整个链路是这样的:用户 A 的消息通过自己的连接到达服务端,服务端在 onMessage 回调里解析出这条消息,先做业务处理(比如存库、校验权限),处理完成后,立刻调用推送逻辑,找到用户 B 的连接对象,把消息写进去。整个过程是同步触发的,用户 B 几乎在用户 A 发送的同时就能收到。

这种事件驱动的推送,触发点可能有很多种:收到某个客户端消息、定时任务到点、外部系统通过 HTTP 接口调用、甚至另一个进程通过内部通信发来指令。不管触发点是什么,最终都汇聚到同一个动作——拿到目标连接对象,调用它的 send 方法。

3.2 在 onMessage 里做推送的典型结构

onMessage 是主动推送最常用的触发位置。当服务端收到某个客户端的消息后,除了处理这条消息本身,往往还要把结果转发给其他客户端。这里的关键是把"处理"和"推送"分成两步:先处理完业务逻辑,确定要推给谁、推什么内容,再执行推送。

我习惯在 onMessage 里先做参数校验和业务处理,把要推送的目标用户 ID 和消息内容准备好,然后调用一个独立的推送函数。这样做的好处是推送逻辑被抽离出来,其他地方(比如定时任务、HTTP 接口)也能复用同一个推送函数,不用重复写查找连接的代码。

需要特别注意的是,onMessage 里拿到的连接对象是"发送方"的连接,不是"接收方"的。新手容易犯的错误是直接拿当前连接对象去 send,结果消息又发回给了发送者自己。要推给谁,就得去连接容器里查谁的对象,这个对象和当前回调里的连接对象通常是两个不同的东西。

3.3 推送内容的格式约定

推送的数据格式,建议在项目初期就定好一套约定,不然后期各种消息混在一起会非常乱。常见的做法是用 JSON,里面至少包含两个字段:一个是消息类型,一个是消息体。消息类型用来让客户端区分这条推送是聊天消息、系统通知还是状态变更;消息体则是具体的业务数据。

比如可以约定成这样的结构:类型字段标明这是聊天消息,数据字段里放发送者、内容、时间戳。客户端收到之后先解析类型,再根据类型决定怎么渲染。这样做的好处是客户端只需要一个统一的消息入口,内部再分发处理,扩展新消息类型时不用改连接层的代码。

如果推送的数据量比较大,或者对性能要求极高,也可以考虑用更紧凑的二进制协议,但对大多数业务来说,JSON 的可读性和开发效率优势更明显,没必要过早优化。

4. 精准投递:怎么在成百上千条连接里找到那一个

4.1 用用户标识做键的查找逻辑

主动推送最核心的一步,就是"找到目标连接"。前面说了要把连接对象存进容器,这里就讲怎么取出来。假设你用用户 ID 作为键存了连接对象,那么推送函数接收一个用户 ID 参数,直接去容器里查即可。

查找逻辑要处理三种情况:查到了连接对象、没查到、查到了但连接已经失效。第一种情况直接 send;第二种说明用户不在线,可以选择把消息存起来等用户上线再推(离线消息),也可以直接丢弃;第三种比较隐蔽,连接对象虽然还在容器里,但底层 socket 可能已经断了,send 会返回 false,这时候要顺手把这条脏数据从容器里清掉。

我建议在推送函数里对 send 的返回值做判断。Workerman 的 send 方法在连接正常时返回 true,连接已断开时返回 false。如果返回 false,说明这条连接已经不可用了,应该立即从容器里移除,避免下次还查到它做无用功。

4.2 群发场景下的遍历与性能考量

一对一推送好办,群发就稍微复杂一点。比如一个群里有五百人,要给这五百人每人推一条消息,你得遍历群成员列表,逐个查连接容器、逐个 send。这里有两个性能点要注意。

第一,不要在遍历过程中做耗时的业务操作。比如每推一个人就去查一次数据库,五百人就是五百次查询,延迟会很高。正确做法是先把要推的成员列表和消息内容一次性准备好,遍历时只做内存查找和 send,把数据库操作前置或后置。

第二,群发时如果某个成员不在线,不要阻塞整个流程。应该跳过不在线的成员,继续推下一个,最后统一记录哪些人没推到。如果因为一个人不在线就中断,后面的人就都收不到了。

对于超大群(比如几千人),还可以考虑分批推送,避免一次性占用太多 CPU 时间导致其他连接的消息处理被延迟。Workerman 是单进程内事件循环,一个回调执行太久会卡住整个进程的其他事件,这个特性一定要记在心里。

4.3 连接失效的检测与容器清理

连接容器如果只增不减,内存会一直涨,最后把进程撑爆。所以清理逻辑和推送逻辑一样重要。清理的时机主要有两个:一是 onClose 回调触发时,说明连接正常断开了,把对应的键删掉;二是 send 返回 false 时,说明连接异常失效了,也要删掉。

onClose 回调里能拿到连接对象,但拿不到你当初存它时用的用户 ID。所以要么在连接对象上挂一个自定义属性记录用户 ID,要么反过来用连接 ID 作为键、用户 ID 作为值再存一份反向映射。我一般倾向于在连接对象上直接挂属性,比如连接对象的某个自定义字段存上用户 ID,onClose 时读出来就能定位到容器里的键。

还有一种情况是客户端网络异常断开,服务端可能过一段时间才感知到。这段时间里容器里的连接对象是"僵尸"状态,send 会失败。所以定期做一次全量清理也不是坏事,但频率不用太高,靠 send 失败时的即时清理基本就够了。

5. 多进程下的推送难题:消息为什么"推丢"了

5.1 多进程内存隔离带来的根本矛盾

前面埋的伏笔这里展开。Workerman 默认启动的 worker 进程数通常大于一,每个进程独立处理一部分客户端连接。假设你开了四个进程,用户 B 的连接被分配到了进程三,但触发推送的业务逻辑跑在进程一。进程一的连接容器里根本没有用户 B 的连接对象,它去查只会查不到,于是判定用户 B 不在线,消息就丢了。

这个问题的根源是进程间内存不共享。你在进程一里存的容器,进程三看不见;进程三里存的,进程一也看不见。这不是 Workerman 的缺陷,而是所有多进程模型的共性。解决思路有两条:要么让推送请求能跨进程转发到持有目标连接的那个进程,要么干脆让所有连接都集中在同一个地方管理。

5.2 用内部通信把推送请求转发到正确的进程

Workerman 提供了一套进程间通信的机制,可以让一个进程给另一个进程发消息。基于这个机制,常见的做法是:每个 worker 进程启动时,除了监听客户端连接,再监听一个内部的通信端口。当进程一需要给用户 B 推送时,它不知道用户 B 在哪个进程,就把这条推送请求广播给所有 worker 进程,每个进程收到后检查自己的容器里有没有用户 B 的连接,有就推,没有就忽略。

这种广播方式实现简单,但进程数多的时候会有一定的无效开销。优化做法是维护一张"用户 ID 到进程编号"的路由表,推送时先查路由表确定目标进程,只给那一个进程发请求。路由表可以存在共享内存或者一个专门的路由进程里。不过对大多数中小规模应用来说,直接广播已经够用了,没必要一上来就搞路由表,增加复杂度。

5.3 单进程模式是不是更省心

如果你的连接量不大(比如几千以内),其实可以考虑把 worker 进程数设为一,也就是单进程模式。这样所有连接都在同一个进程里,连接容器天然全局可见,推送逻辑简单到不能再简单,完全不用考虑跨进程问题。

单进程的代价是无法利用多核 CPU,吞吐量有上限。但对于很多内部系统、中小型应用来说,这个上限远没到瓶颈。我的建议是:项目初期先用单进程把业务跑通,等真的遇到性能瓶颈了,再考虑改多进程加进程间通信。过早优化多进程推送,往往会把简单问题复杂化,调试起来也痛苦。

判断要不要上多进程,可以看两个指标:一是并发连接数是否长期超过单进程能扛的量,二是 CPU 是否成为瓶颈。如果两者都不明显,单进程就是最务实的选择。

6. 实战中那些文档不会告诉你的坑

6.1 心跳缺失导致的"假在线"

长连接最怕的就是连接看起来还在,实际上早就断了。客户端可能因为网络切换、休眠、进程被杀等原因断开,但服务端没有立刻感知,容器里还留着这条连接。这时候推送过去,send 返回 false,消息就丢了,而你以为用户在线。

解决办法是加心跳机制。客户端每隔一段时间发一个心跳包,服务端收到后更新这条连接的最后活跃时间。服务端再起一个定时器,定期扫描所有连接,把超过一定时间没收到心跳的连接主动关掉并清理。心跳间隔和超时时间要配合好,间隔太短浪费流量,太长则感知断开太慢。一般心跳间隔三十秒、超时九十秒是个比较稳妥的起点,具体还要根据业务对实时性的要求调整。

6.2 推送顺序与消息乱序

当同一个用户短时间内收到多条推送时,客户端收到的顺序不一定和服务端发送的顺序一致。这在单连接内通常没问题,但如果推送走了不同的路径(比如一部分走进程间通信、一部分直接推),就可能乱序。对于聊天这类对顺序敏感的业务,乱序会导致对话看起来很奇怪。

规避方法是在消息里带上服务端生成的递增序号或时间戳,客户端收到后按序号排序再展示。如果业务对顺序要求极高,就要保证同一个用户的所有推送都走同一条路径,不要一会儿直接推、一会儿转发推。

6.3 大消息推送阻塞事件循环

前面提过,Workerman 单进程内是事件循环,一个回调执行太久会卡住其他所有事件。如果你一次推送一个几兆的大消息,或者群发时遍历几千个连接,这个回调就会执行很久,期间其他客户端发来的消息都得不到处理,表现为"服务端卡住了"。

对策是把大消息拆小,或者把群发任务拆成多个小批次,用定时器分批执行,每批之间让出事件循环。Workerman 提供了在回调里延迟执行下一次任务的机制,可以把一个大的群发拆成每次推一百个、间隔几毫秒,这样既完成了推送,又不会长时间阻塞。

6.4 连接容器用静态属性还是全局变量

存连接容器时,用类的静态属性还是全局变量,效果上差不多,但静态属性在代码组织上更清晰,也更容易在多个类之间共享。我一般会单独写一个连接管理类,把容器作为这个类的静态属性,对外暴露添加、删除、查找三个方法。这样推送逻辑只依赖这个管理类,不直接操作容器本身,后期要换存储方式(比如换成共享内存)时改动面很小。

需要注意的是,静态属性在常驻进程里是跨请求保留的,这正好符合我们的需求。但在传统 PHP-FPM 里静态属性每个请求都会重置,所以从 FPM 转过来的人容易在这里犯迷糊,要意识到 Workerman 的进程模型完全不同。

7. 把推送能力沉淀成可复用的模块

7.1 推送接口的抽象设计

当项目里多处都需要推送时,把推送逻辑封装成一个统一的接口会省很多事。这个接口对外只需要接收"目标用户标识"和"消息内容"两个参数,内部负责查连接、判断在线、执行 send、处理失败。调用方完全不用关心连接是怎么存的、在哪个进程,只管调用。

接口内部可以根据配置决定是单进程直推还是多进程转发,这样业务代码不用因为部署模式变化而改动。比如开发环境用单进程,生产环境用多进程,同一套业务代码都能跑。

7.2 推送失败的重试与降级

推送失败分两种:一种是用户确实不在线,这种重试也没用,应该走离线消息逻辑,等用户下次上线再推;另一种是临时故障,比如网络抖动导致 send 失败,这种可以短暂延迟后重试一两次。

降级策略也要想好。如果推送通道整体不可用,是不是要退化成让客户端轮询?虽然轮询体验差,但至少保证消息不丢。这种降级开关最好做成配置项,出问题时能快速切换,不用改代码重新部署。

7.3 监控推送成功率

推送这件事,不出问题的时候感觉不到,一出问题就是用户投诉"收不到消息"。所以上线后要监控推送成功率:每次推送记录成功还是失败,定期统计失败率。如果失败率突然升高,可能是连接容器出了 bug,或者某个进程挂了,能第一时间发现。

监控数据还能帮你判断心跳参数是否合理。如果大量连接都是因为心跳超时被清理,说明心跳间隔可能设得太长,或者客户端心跳逻辑有问题,需要针对性调整。

8. 我踩过的几个真实教训

第一个教训是关于连接容器的键选择。我早期用连接 ID 作为键存容器,推送时却拿用户 ID 去查,结果永远查不到。后来才想明白,键和查询条件必须一致,要么都用连接 ID,要么都用用户 ID,混用就是自找麻烦。现在我会在连接对象上同时挂用户 ID,容器用用户 ID 做键,onClose 时通过连接对象上的用户 ID 反查删除,两边对得上。

第二个教训是关于多进程的。有次线上环境推送时好时坏,本地单进程测试完全正常。排查了半天才发现是生产环境开了多进程,消息推到了没有目标连接的进程上。后来加了进程间广播才解决。这件事让我养成了一个习惯:本地开发也尽量模拟生产的进程数,别等上线才发现问题。

第三个教训是关于心跳的。早期没做心跳,用户手机锁屏再解锁后,旧连接其实已经断了,但服务端不知道,推送全丢。加了心跳之后,锁屏超过超时时间的连接会被清理,用户解锁后重新连接,新连接进容器,推送就正常了。心跳这东西,做的时候觉得麻烦,不做的时候问题一堆。

第四个教训是关于 send 返回值的。我一开始没判断 send 的返回值,连接失效了容器里还留着,越积越多,最后内存告警。加上返回值判断和即时清理之后,内存曲线就平稳了。这个细节很小,但影响很大,建议一开始就写上。

9. 从能推到推得稳,中间差的是细节

主动推送这个功能,跑通一个 Demo 可能只要几十行代码,但要推到生产环境稳定运行,需要考虑的东西成倍增加。连接怎么存、进程怎么协调、心跳怎么做、失败怎么处理、内存怎么清理,每一个环节没处理好,都会在某个时刻以"用户收不到消息"的形式爆发出来。

我的建议是,先把单进程、单连接的推送跑通,理解连接对象和容器的关系;然后加上心跳和清理,让长时间运行不出问题;再根据实际连接量决定要不要上多进程,如果上,就把进程间通信补上;最后把推送封装成模块,加上监控和降级。一步一步来,每一步都验证过再往下走,比一上来就搭个大而全的框架要靠谱得多。

Workerman 的主动推送能力本身是很扎实的,它把底层的连接管理做好了,剩下的业务逻辑需要你自己根据场景去填。理解了连接的生命周期和进程的边界,剩下的就是耐心处理各种边界情况。这些边界情况处理好了,你的实时推送才算真正可用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询