重构即时IM系统5:网关层(上)

0 阅读9分钟

我们在之前已经实现了ipconfigetcd服务发现的分析和代码,客户端经过ipconfig找到合适的网关机器然后在上面建立长连接,之后就通过这个网关机器发送和接收消息,网关服务器再转发给后台服务器去做业务层面的处理,比如说这个消息要发送给谁,从而找到对应的网关机器发送消息。

路由表

用户在长连接网关建立连接之后,就把请求直接转发给后端服务器做业务处理,但是后端服务器返回消息时,要知道这个用户在哪台网关机器上面,我们应该怎么做呢?

最暴力的方法就是全扇出,后端服务器将消息转发给所有的网关机器,由于网关机器内部维护了用户到长连接socket的映射关系,所以每个网关机器收到消息之后就先判断这个用户是否在自己的网关机器上,如果在的话就给对应socket发送消息,如果不在的话直接丢弃消息就好了。这种方式太暴力,网络扇出很大,但是非常适用于大群聊场景,因为大群聊的人数多,大概率均匀分配到每台网关机器上,这种情况下本来就是要推送到所有网关机器的,而且此时还不需要查路由表什么的,减少了通信复杂度。

我们还可以选择可拓展哈希去找到用户对应的网关服务器,此时ipconfig根据用户ID做可拓展哈希,为用户选择一个网关服务器,此时后端服务器使用相同的可拓展哈希算法,就可以知道对应要转发的用户ID在哪台网关机器上,直接单点发送就好了。但是这样的话ipconfig所作的负载均衡服务就没作用了,导致服务器负载不均衡,好处就是适合用户单聊点对点发送场景。

为了解决可拓展哈希带来的负载不均衡和热点问题,架构必须向动态路由演进,此时就需要维护一个全局的路由表来记录映射关系,于是就拆分出了state server这个服务,用于维护路由表,便于后端服务器发送消息。当用户连接上B网关机器时,B就会立即向state server报道说...用户在我这台网关机器上,后端服务器需要给...用户发消息时,直接把消息推送给state server就好了,由于state server里面维护了全局映射表,所以可以进行代理。这也体现了计算机中没有什么是加一层解决不了的问题,但是这种架构也有一些权衡,比如说增加了一次网络IO,但是state server可以部署在网关机器上减少通信成本,还有如果路由表太大,可能会根据用户ID进行哈希分片。

stateServer

这里我们再详细解释一下为什么拆分出state server层,其实可以只维护一个全局的路由表,每次后端服务器去查这个路由表然后找到对应的网关发送消息就好了,我们之所以拆分出一个state server层是因为这里可以做更多的处理。

首先就是拆分出state server可以减少后端服务器的压力,因为后端服务器通常承载了复杂的业务逻辑(JSON序列化反序列化、protobuf序列化反序列化,业务逻辑计算和数据库读写),假如不拆分的话,如果我想实现微信的显示正在输入中功能和抖音的显示已读功能,每秒都会有大量的“正在输入中”或者“已读”的小包涌入后端服务器,CPU 光是处理这些小包的序列化/反序列化就满了,导致真正的聊天消息(需要存库的重要消息)被堵在队列里发不出去。还有如果你打算实现在线状态订阅这个业务的话,每次 B 上线,Logic 都要去查 MySQL 好友表。如果B是一个明星,那么mysql就要进行大量扫描,服务器压力暴增。此时就会造成明显的收发消息卡顿。

由于state server处于中间人的位置而且拥有用户映射关系,state server只需要知道用户有哪些好友就可以通过维持的映射收发消息,得知用户有哪些好友可以由客户端请求携带或者启动的适合就去后端服务器查询,有了这些关系之后就可以很好的处理那些对时效性要求高,但是对于数据一致性要求不高的业务。这些业务由网关发送到state server就可以直接闭环,不要再发送给后端服务器走复杂的业务逻辑或者查表流程。

这时我们实现显示正在输入中的业务就好做很多了,此时客户端给网关发送控制信令,表示A正在给B发消息,网关服务器收到之后就把这个发送给state server,由于state server维护了用户到socket的映射表,所以可以知道B在哪台网关机器上,就可以给对应网关发送消息,截断这个流程。同样,用户在线状态订阅和消息已读回执这种也可以这样做,但是state server只是快速路径,用于给收发消息的双方快速显示的,如果要持久化状态,比如说消息已读到哪条了,这样离线可以直接看到,这种还是要走到后端服务器做业务处理,至于是异步还是同步后面实现了这个功能再讨论。

gateway分析

gateway维护了大量用户的长链接,对于网关的设计,我主要考量了以下两个目标,第一个就是稳定性,避免频繁重启,因为重启就会导致长连接的断开,此时大量用户都会重连,引起重试风暴,最坏情况可能会导致其他网关机器因为收到了大量重连请求导致也崩溃,引发服务器集群崩溃。这一点在将网关和后端服务器拆分之后其实就已经做到了,网关只负责转发请求和维持长连接就好,这部分没有业务逻辑,所以不需要频繁变更和重启。

第二个就是资源占用,一台网关机器应该存储多少个长连接呢,存储很多的话内存压力大,但是存储少的话网关机器数就更多,使用etcd做服务发现的话写请求就会更多,影响服务发现和负载均衡的性能。这里我们选择尽可能的去垂直拓展,每台网关机器上部署多一点的长连接,这样etcd做服务发现时压力就小一点,而且成本更低。

资源占用优化

现在这个IM项目使用的是非常暴力的协程监听方法,每次客户端登录之后建立一个连接,然后启动两个协程监听这个连接,一个读协程,一个写协程。这两个协程都是一个死循环,读协程阻塞在WebSocket的 ReadMessage() 方法上,等待客户端发来二进制数据,将数据解析为JSON对象,然后发送给服务端的chan走转发逻辑。写协程阻塞在client.SendBack通道上,等待其他协程往这个通道里面写入数据,一旦通道有数据,就立即调用WebScoket的WriteMessage()方法发送给客户端。

这里必须使用独立的读写协程,因为读socket的缓冲区是阻塞操作,读通道也是阻塞操作,如果没有数据发过来会一直挂起,直到有数据到达,就是因为这个挂起的时间是不确定的,所以需要启动协程去监听。这样的话每一个连接都得启动两个协程去监听,就算go协程的初始栈大小只有2kb,但是启动100w个长连接,也要额外消耗4GB的内存只存储go协程的协程栈,而且协程多了之后会影响GMP调度性能和增大GC压力,导致服务器压力变大。

由于长连接网关中socket的开销是绝对不可以省的,所以我们可以尝试去节省协程,怎么节省呢,答案就是使用IO多路复用epoll,一个线程可以监听许多socket,这样就不必要为每一个连接维护读写协程了。

epoll分析

要实现海量连接下的协程节省,核心思路就是将每个连接独占两个读写协程做全双工转变为事件驱动共享协程,我们不再为每个连接分配专职的读写协程,而是让少数几个协程通过epoll机制同时监控成千上万的状态。具体的说,当accept建立新连接并且拿到文件描述符之后,我们进行如下操作:

将这个文件描述符设为非阻塞模式,然后将FD注册到epoll实例中监听EPOLLINEPOLLOUT,线程进入epoll_wait循环,等待就绪的链接,一旦 epoll_wait 返回,意味着有一个或多个连接有数据了,此时,我们才临时分配一个协程(或者从协程池里取一个)去执行真正的 Read 和业务逻辑。处理完后,协程释放回池,继续服务其他连接。通过这种方式,100 万个空闲连接可能只需要几十个协程在跑 epoll_wait ,内存消耗从 4GB(协程栈)骤降到仅需维持 Socket FD 和应用层 Buffer 的开销,极大缓解了 GC 和调度压力。

下一节带来代码实现。