近期有个同事跟我说遇到一件很奇怪的事情,时不时收到售后反馈说 部分用户无法接收到聊天室(WebSocket 服务)消息,然而在测试服以各种方式测试都无法复现这种现象。于是陷入沉思,因为这个问题必须解决,用户必须要退出聊天室再重新进去才能看到这些丢失的消息,已经严重影响到业务间客服与用户的正常沟通。
这到底是什么原因呢?而且没法在测服复现。
这个架构服务采用的是 php Swoole , 用户与客户端FD 的关系绑定是通过 Swoole Table (服务进程间内存共享) 实现, 同事反映说在各个环节确认了关系绑定都没问题情况下还出现 客户端FD 丢失,那么我想到 这可能是因为服务器被负载均衡 (SLB)了,无法测服复现是因为测试服是单机。
第二天一早, 为了验证猜测,同事查看了在阿里云上的负载均衡服务配置,果然破案了!!!这个项目此前一直是单机服务,也不知道从何时开始 变成多节点服务了。
我来描述下为什么分布式服务的 WebSocket 会存在这种现象,而分布式服务的 HTTP 却没有这样的问题呢?因为 WebSocket 有个用户与客户端标识(FD)关系需要绑定,而 HTTP 服务一般是不需要关注客户端标识(FD)的。
WebSocket 服务端需要推送消息到用户所连接的客户端时,例如A、B两台服务器,用户1连接到聊天室(服务器A),客服1也连接到聊天室(服务器B), 这种情况下 显然用户1发消息给客服1 是对牛弹琴了,因为用户1发送消息后,服务器A会遍历该服务器内的所有用户与客户端标识(FD),然后取出所有客服1的FD 进行消息推送,而客服1连接的是服务器B,则对于用户1来说 客服1是不在线的, 所以用户1推送消息是推了个寂寞啊!!! 再如 你的服务是支持用户多设备、多平台同时在线也是一样的道理,这种情况下也就意味着可能用户的客户端标识(FD)会同时分布在 服务器A、服务器B、服务器C …,那么用户在其中一台设备发送消息,在其他端登陆的该用户都应该要收到这条消息,单纯地根据用户所连接的服务去发送消息 那么其他端在线的该用户都无法收到此消息了,群发也是一样的道理。
在开始思考分布式会有什么问题时,先来回答一个问题: 服务端如何与客户端交流?
在 WebSocket 服务端,每当与客户端连接成功后,会生成一个 唯一的客户端标识符 FD,WebSocket 会维护一个与客户端所有连接的 Connections。在业务层,你需要将每个连接进来的客户端标识(FD)与项目的用户ID绑定起来,比如用 redis 将用户和客户端标识(FD) 保存起来,当客户端断开连接时解绑(删除掉对应的客户端标识(FD)),因为服务是用的PHP Swoole, 用 Swoole Table (服务进程间内存共享) 实现用户与客户端标识(FD)绑定关系。这样你就可以知道某个用户在不在线,并且这个用户的客户端标识(FD)有哪些,然后遍历 Swoole Table 把用户的所有客户端标识(FD)取出来循环推送消息给客户端。
那如何给所有人广播消息呢?
服务器只需要与它自身的所有客户端连接 Server.Connections 挨个发消息就是广播,所以它只是一个伪广播: 我要给群里所有人发消息,但我不能在群里发,只能挨个私发。
单节点
当单节点时,流程如下:
这时所有用户都能收到消息通知。
多节点
当多节点时,就会有部分用户无法正常收到通知 (就是我文中开头所描述的现象),从以下流程图中可以很清楚地看到问题所在:
负载到节点B 的所有用户都没有收到消息通知。
说了这么多,怎么解决这个问题呢?
网上的很多教程,有些是通过 WebSocket 中间服务转发器、网关转发器 等实现方案,但这些实现方式有局限性,因为这些方案大部分是需要判断用户在哪台服务器上(需要知道IP),然后转发层将请求转发到用户所在服务器上。这种方案用户单端登录还好,如果用户多端登录 请求被转发到多服务器上同时处理相关逻辑显然是有问题的,比如新增数据、修改数据…这些操作等,这种架构解决方案 用户多点平台登录时调整复杂度会变得较高。
将 Swoole Table (服务进程间内存共享) 改造为 Redis 哈希 来实现用户与客户端标识(FD)绑定关系,主要目的是在单节点处理逻辑的时候经常需要判断对端用户是否在线,单服务内的共享内存并不能知道其他服务内该用户是否在线,所以这个方案不可取了。改用 分布式缓存 就可以判断出对端用户是否在线了。
分布式缓存实现用户与客户端标识(FD)绑定关系大致做法为:
多节点服务器就会有分布式问题,解决分布式问题就找一个大家都能找到的地,比如说 MQTT、Kafka、RabbitMQ 等消息中间件,另外使用 Redis 的发布订阅(pubsub)功能 也一样可以实现,不过在此我选择的是用 RabbitMQ 来实现。
改进后流程图如下:
负载均衡(SLB) 内所有服务启动时都绑定同一个RabbitMQ Fanout(广播模式) 交换机, 如果该交换机不存在则创建。然后每个服务都生成一个唯一的该交换机队列(生成的交换机队列不能相同, 比如可以服务器1生成的队列名为 S1, 服务器2生成的队列名为 S2), 可以将生成的队列设置为 auto_delete: true, 这样就可以达到当 队列没有消费者的时候该队列会自动删除, 服务重启时又重新生成的效果。接下来就是每个服务都注册该交换机队列的监听消费,当队列的每一条息出栈时都会广播到该交换机下的所有队列(即所有服务的队列监听事件都能收到PUSH进来的消息)。客户端请求到 负载均衡(SLB) 任意一台服务器该服务器逻辑处理完后将要发送给客户端的消息推送至 RabbitMQ 消息队列消息队列将该消息广播到所有服务器的监听消费事件内所有服务器的监听消费事件内 Redis hScan 迭代遍历当前服务内所有客户端连接,取出所有符合用户ID对应的客户端标识(FD)进行推送消息。(并发高时对 Redis 冲击很大,需要预估支撑力,对缓存哈希的读要求随并发高低而上升 O(n))
这种 WebSocket 分布式架构解决方案同时 实现了支持单个用户多设备、多平台同时在线的场景,不需要知道有多少台服务器(也就是说服务器可以无限动态扩容),不需要知道用户对应哪些服务器,也不需要知道各个服务器的IP地址,只需要处理各自服务器内的监听消费队列即可。相对于一些通过搭建转发服务器、网关服务器等实现的 WebSocket 分布式架构 有着天然的优势,这些架构解决方案要复杂很多,特别是要实现多设备、多平台同时在线的场景时 更加、更加、更加复杂。
生活不易,如果您觉得这篇文章写得不错就动动手指帮忙点个赞吧!感恩各位~