这篇属于IM三剑客中的一篇,后面两篇主要着重介绍了MQTT协议以及MQTT Broker的Go语言实现,如果没有这类需求的话可以跳过。
每天用的微信,上班打卡的钉钉,都属于一个IM应用,虽然这些应用与我们息息相关,但是具体的实现对于很多人来说却是比较陌生的。 因此,我在这里专门写一篇文章来说明一下通用的结构与设计,权当是抛砖引玉了。
不同于常见的http应用,IM属于一个长链接应用,普通的http应用的请求模型是一问(客户端)一答(服务端),即使是HTTP2也是这个模型,对于IM的长链接模型,则是问可能没有答,没有问也可以有答,很多人就要问是怎么实现的了,说来也简单,只要在客户端初始化好后,不断开连接,之后所有的通信都用这条连接来完成即可。
下面将从底层网络选型,协议选型,到服务质量,再到服务端的设计来一一说明
Transport层
通常情况下,传输层一般有两种协议, TCP和UDP. 在一个可靠消息系统中,我们应该从底层开始就保证可靠,因此这里采用TCP模型。
当然传说QQ底层用的就是UDP,原因无外乎就是性能考虑,但是具体原因我也不是很清楚,如果基于了UDP的话,还要在业务中实现ack机制,相对来说比较复杂,因此不考虑UDP。
协议
有了底层的传输层,接下来我们还要有我们的协议,协议定义了该以什么样的结构来表示我们的数据。主要的聊天协议主要有如下几种:
- XMPP,老,且臃肿,第一个排除。
- MQTT,轻量,语义丰富,可以使用第三方的MQTT server(但是不推荐在IM中使用第三方的MQTT Server,原因后面会提及)
- 私有协议,定制化程度高,但是难度也大。
因此,我们更偏向与MQTT协议或者私有协议。
服务质量
- 至多一次(
AT MOST ONCE
)QOS0
- 至少一次(
AT LEAST ONCE
)QOS1
- 恰好一次(
EXACTLY ONCE
)QOS2
在IM系统中,消息的丢失几乎是无可忍受的,因此首先排除掉了QOS0
的服务质量,同时因为QOS2
需要至少四次通信(对于MQTT来说分别是:PUBLISH
, PUBREC
, PUBREL
, PUBCOMP
),成本相对来说比较大,因此一般情况下,也不会直接使用QOS2
,剩下的只有QOS1
,字面上看AT LEAST ONCE
至少一次,可以满足消息必达,同时最少只需要两次通信即可(一次Publish,一次Ack即可)。
服务端设计
认证服务器(Auth)
登录用户携带Cookie
, Session
或者Token
向认证服务器(HTTP
)发起认证请求。认证服务器通过用户携带的信息进行认证操作,认证成功则将接入层地址,Auth Token
返回给客户端,客户端向接入层发起连接请求。连接成功后,发送Auth
信息,认证成功后,马上发送一次Ping
消息。
接入层(Gateway)
接入层主要处理客户端的长链接,消息,服务端分发的消息等,同时向路由层更新用户信息。
用户接入
用户接入后,会马上发送Auth
信息过来,服务端通过对应的Auth
插件进行认证,如果认证成功,则Ack success
,如果失败,Ack failure
,并退出。然后尝试将用户的接入信息Report到路由层,同时loop循环读取客户端消息并处理。
用户加入房间
用户发送业务消息,QOS为1,类型为JoinRoom
,参数为房间ID,接入层保存有当前实例上的所有房间,根据房间ID找到对应的房间,并加入,同时给客户端ack success
并Report到路由层。该消息幂等,可以调用多次。
用户离开
客户端发送Disconnect消息,或者超时断连,服务端删除对应的记录同时Report到路由层。
用户状态保活
通常来说客户端是不可靠的,可能随时失联,失效,因此需要保活机制来确认客户端的状态,同时即使踢出失效的连接,避免服务器资源的浪费。服务端收到客户端的Ping
请求后,马上回复Pong
消息,因为客户端在收不到Pong
消息时,会认为服务端已经不可用,会尝试重连,这一步是不必的。
客户端消息处理
对于系统消息,获取后做一个计数。对于业务消息,获取后,并通过Bridge模块路由到对应的业务中。
为什么需要客户端主动发起
Ping
, 主要是因为客户端发ping消息的间隔可以方便的进行动态调整,发ping的作用主要是保证在一段时间内是活跃的,除了ping消息,其他的消息也可以提供ping的作用,如果这个机制服务端实现需要做大量的工作,同时对服务端性能压力比较大,需要有大量的计时器来完成,因此需要客户端发Ping请求。
服务端认为的超时时间为2倍Keepalive时间,连续两次Ping的时间间隔通常不大于运营商的缓存时间。
路由层(Router)
存储用户连接信息,主要存储以下三类信息
user_id => server_id
user_id => room_id
room_id => set<server_id>
接入层是无状态的,很难知道全局的连接状态,因此需要一个单独的路由层来存储状态信息。
状态存储(Store)
- 基于第三方的存储,比如基于Redis
- 优点是简单,可靠,省去了开发存储的时间精力。
- 缺点是需要一次网络请求,会造成一定的性能损耗。
- 基于Raft等一致性算法的本地存储。
- 优点是本地存储,且强一致,省去了网络的开销。
- 缺点是需要实现一致性算法,比较麻烦,不过好在已经有了成熟的
Raft
实现,比如ETCD
的实现,能省下不少事。
服务层(Service)
接收上游服务的请求,根据上有的请求向路由层请求状态信息,得到消息应当发送到哪些接入层实例上,然后向对应的接入层实例投递消息,起到了承上启下的作用。
服务层需要感知路由层与接入层的存在,可以通过Etcd
注册中心来实现服务的注册与发现。
服务层既可以作为一个单独的服务而存在,也可以作为Lib
存在(嵌入到用户的程序中),可以减少一次网络开销。
扩容
扩容是服务端服务通向高并发的一个必由之路。
接入层扩容
接入层是无状态的,所有的路由状态存储在了路由层组件上,因此可以直接通过加机器的方式扩容,在实例Ready之后,将实例信息注册到服务中心上即可。
路由层扩容
路由层存储了用户状态信息,因此在扩容上略微麻烦一些,根据存储的类型分为两类。
存储层是第三方时
路由层可以直接扩容,扩容的难点移动到了第三方的存储层上,如果是Redis的话,可以使用Redis的Cluter模式(Redis 3之后均支持)。
存储层是本地时
如果是本地存储扩容的话,
服务层扩容
服务层同接入层一样是无状态的,因此可以和接入层一样扩容。
重启
接入层
所有的组件中重启最麻烦的是接入层,接入层的重启意味着大量连接的失效以及大量的并发的connect
消息,因此必然不能直接重启整个集群,只能one by one
的重启。过程如下
- 从
nginx
中摘除对应的实例,新的连接不会接入进来 - 发送重启信号给接入层实例
- 接收到信号后,广播一条重连的消息给客户端,客户端断掉连接重连,因为第一步的操作,再次连接无法接入
- 等待一段时间或者剩余连接数小于阈值后,即可重启
- 在
nginx
加入实例,新的连接可以重新接入
路由层
基于状态存储的方式,具体的重启策略也不尽相同,如果是用的第三方的存储方案,则可以简单的进行重启。如果用的是本地存储的话,Raft算法可以保证在集群可用节点数超过一半时是可用的,因此可以采用一台一台的方式进行重启。
服务层
无任何状态,仅做消息的分发,可以直接重启,只需在重启前,向注册中心取消注册,重启后,重新向注册中心进行注册即可。
离线消息
当用户不在线时,投递给用户的消息主要有两种处理方式
客户端推拉结合+业务处理
离线消息拉,在线消息推,此时不需要处理离线消息,对应的业务去处理。对于一个群的消息,业务层需要对所有消息进行存储,当客户端再次登录时,先从业务方拉取历史消息,待消息拉取完毕后,开始处理长链接的消息。
接入层持久化离线消息
待到重新接入后,将离线期间的消息依次推送给客户端。对于每一个连接,都需要存在一个对应的队列来保存未接收消息,通常情况下是每个连接对应的一个结构体中有一个queue的变量。But,长链接总归是要有重启操作的,因此所有的消息都需要进行落盘操作,才能保证在接入层重启后消息不丢失,对应的结构为user_id, msg
二元组,结构比较简单,同时数据量也会比较大,因此我们可以采用分布式KV
的存储结构,TiKV
就是一个不错的选择,基于Rust
和RocksDB
构建,原生的分布式支持,适用于存储大量的数据。
接入层持久化消息将复杂度转移到了接入层,大幅度的简化了业务的开发与接入成本。推拉结合的方式将问题保留给客户端和业务方,但是接入层可以更加轻量。具体使用哪种方式还要根据具体场景进行深度的分析。
消息去重
上面说了我们选定了QOS1
(至少投递一次)的服务质量级别,但是也会出现一个问题:消息可能会重复,同时也给出了思路,那么具体如何实现呢,我们一一分析。
客户端发送消息
为了保证服务端肯定收到消息,我们需要多次尝试直至收到服务端的ack。下面又分了三种情况:
- 在重发消息之前收到ack,这时我们只需要简单的取消掉重发的任务即可。
- 在重发消息之后收到ack,这种情况下,我们又要分成两种情况来考虑。
- Publish消息丢失了,此时只要等待超时重传即可。
- Ack消息丢失了,这时服务端以收到消息,但是因为各种原因,ack消息丢失了,这种情况下,需要服务端对消息进行去重了,通过一个LRU Cache,保存最近的一部分消息,每次收到客户端消息后,先进行去重。
- 迟迟未能收到ack,有可能是客户端网络出现问题,也有可能服务端无法提供更多的服务能力了,在这种情况下,我们要取消发送,并进行相应的记录。
服务端发送消息
处理流程同客户端发送,只要将两端的关系逆转过来即可。
消息投递
整个系统基于RPC系统进行消息的投递,RPC的同步风格可以保证第一时间获知成功失败状态,同时相对于HTTP性能更高,相对于TCP更加方便。