前导
近期遇到一件很奇怪的事情,时不时收到同事反馈说 部分用户无法接收到聊天室(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)绑定关系大致做法为:
-
在服务启动时创建一个
全局唯一ID
,保证多服务下这个ID的唯一性
,比如启动5个服务时,每个服务的ID都不能有相同,目的是用来分布式缓存的客户端FD标识所在的服务ID,当然 你也可以使用IP作为唯一性(可能会更直观点)。 -
将
唯一ID_FD
作为哈希键存储,在某个事件或定时清除不活跃的哈希键。要当前某个服务的所有哈希键的时候可以使用hScan
循环迭代模糊匹配实现,必要时使用hGetAll
获取所有哈希键值(并发高服务 在此提醒谨慎使用哈)。
多节点服务器就会有分布式问题,解决分布式问题就找一个大家都能找到的地,比如说
MQTT
、Kafka
、RabbitMQ
等消息中间件,另外使用Redis
的发布\订阅(pub\sub)功能
也一样可以实现,不过在此我选择的是用RabbitMQ
来实现。
改进后流程图如下:
- 负载均衡(SLB) 内所有服务启动时都绑定同一个
RabbitMQ Fanout(广播模式) 交换机
, 如果该交换机不存在则创建。然后每个服务都生成一个唯一的该交换机队列(生成的交换机队列不能相同, 比如可以服务器1生成的队列名为 S1, 服务器2生成的队列名为 S2), 可以将生成的队列设置为auto_delete: true
, 这样就可以达到当 队列没有消费者的时候该队列会自动删除, 服务重启时又重新生成的效果。接下来就是每个服务都注册该交换机队列的监听消费,当队列的每一条息出栈时都会广播到该交换机下的所有队列(即所有服务的队列监听事件都能收到PUSH进来的消息)。- 客户端请求到 负载均衡(SLB) 任意一台服务器
- 该服务器逻辑处理完后将要发送给客户端的消息推送至
RabbitMQ
消息队列- 消息队列将该消息广播到所有服务器的监听消费事件内
- 所有服务器的监听消费事件内
Redis hScan
迭代遍历当前服务内所有客户端连接,取出所有符合用户ID对应的客户端标识(FD)进行推送消息。(并发高时对Redis
冲击很大,需要预估支撑力,对缓存哈希的读要求随并发高低而上升O(n)
)
这种 WebSocket
分布式架构解决方案同时 实现了支持单个用户多设备、多平台同时在线的场景,不需要知道有多少台服务器(也就是说服务器可以无限动态扩容),不需要知道用户对应哪些服务器,也不需要知道各个服务器的IP地址,只需要处理各自服务器内的监听消费队列即可。相对于一些通过搭建转发服务器、网关服务器等实现的 WebSocket
分布式架构 有着天然的优势,这些架构解决方案要复杂很多,特别是要实现多设备、多平台同时在线的场景时 更加、更加、更加复杂。
评论一下?