IM技术梳理

基础

在线消息

  • 模型

    IM是一个3方通信,TCP等是一个2方通信机制。

    IM也可以看做是一种网关。

  • 报文

    请求报文(Requst,简称为R):客户端主动发送给服务器的报文

    应答报文(Ack,简称A):服务器被动应答客户端的报文

    通知报文(Notify,简称N):服务器主动发送给另外一个客户端的报文

  • 基本流程

    sequenceDiagram autonumber participant ClientA participant Server participant ClientB ClientA ->> Server: R: msg Server ->> ClientA: A: msg Server ->> ClientB: N: msg ClientB ->> Server: R: ack Server ->> ClientB: A: ack Server ->> ClientA: N: ack

    这里包括着3端:

    1. ClientA -> Server的传输
    2. Server -> ClientB的传输
    3. ClientA -> ClientB的传输

    这个层次都包含着 R、A

    这里有个疑问:

    既然TCP能够保证2端的可靠性传输,为什么在应用层还需要控制?

    我想可能有2方面的考虑:

    1. 应用层的协议可以独立于传输层协议,既可以用TCP也可以用UDP;
    2. TCP不能提供某些特殊的业务需要,如业务层需要探测用户是否在线,只靠TCP的心跳就不能实现;
    3. TCP只能保证传输层以下的可靠性,无法保证应用层的可靠性,如完成传输层之后,到应用层时,死机;参考
  • 超时重传与去重

    超时重传需要发送端ClientA维护一个等待ack的队列,若一定时间没有收到ack,则进行重发;

    重发容易导致接收端ClientB收到重复的消息,这就需要一个去重机制,每个消息要有自己的id,这个msgId应该是发送端决定的;

    超时重传是在发起方控制,对于接收方ClientB所发送的ack并不需要队列的控制,这就很TCP了;

    PS:这两个与TCP中的概念是一致的,有了ACK,就需要超时重传;有了超时重传,就需要去重;

    参考TCP,这里可以追加一些规则,如:对于N: ack丢的情况,如果晚的ack到达,则早的message可以一起应答掉;

    既然是考应答与重发来提高可靠性,这样是否可行:

    sequenceDiagram autonumber participant ClientA participant Server participant ClientB ClientA ->> Server: R: msg Server ->> ClientB: N: msg ClientB ->> Server: R: ack Server ->> ClientA: N: ack

    与前者相比,这里保障的是ClientA -> ClientB之间的传输,在这里Server很像一个网关。

    上图中与原始的Server都可以是无状态的,很多时候在线消息也是有状态的。

    sequenceDiagram autonumber participant ClientA participant Server participant ClientB ClientA ->> Server: R: msg Server ->> ClientA: A: msg Server ->> ClientB: N: msg ClientB ->> Server: R: ack

    这样其实需要2个超时重发,即:发送端的超时重发,以及Server端的超时重发;

    这样还有一个好处,msgId的生成比较简单,如果只依靠ClientA的超时重传,则msgId需要ClientA来生成,在单聊领域还可以,但在群聊就不够看了。

离线消息

参考

离线消息是用户不在线时的发送流程。

IM离线消息-发送

sequenceDiagram autonumber participant ClientA participant Server participant Cache participant DB participant ClientB ClientA ->> Server: R: msg Server ->> Cache: get B state Server ->> DB: save msg Server ->> ClientA: A: msg
  1. ClientA发送一条消息给ClientB;
  2. Server从缓存Cache中查询B的状态;
  3. Server将此消息以离线方式存储到DB中;
  4. Server返回Ack给ClientA;
# 单聊离线消息
t_offline_msgs(msg_id, sender_id, receiver_id, send_time, msg_type, msg_content)

ClientB上线后,主动获取所有发给他的离线消息,然后在根据receiver_id来进行区分,流程如下:

IM离线消息-接收

sequenceDiagram autonumber participant ClientA participant Server participant Cache participant DB participant ClientB ClientB ->> Server: get offline msg Server ->> DB: select offline msg Server ->> ClientB: return offline msg ClientB ->> Server: Ack Server ->> DB: delete offline msg
  1. 离线消息过大可以分页获取的方式,慢慢来拉取
  2. 同样Ack可以是一页一页的进行确认,不用每条确认
  3. ClientB依旧要去重

这里在线与离线的发送流程上需要合并,缓存的读取;

群聊

参考参考2

群聊是很负载的场景,它既包括在线消息,又包括着离线消息。

基础方式:

数据:

# 群成员表:用来描述一个群里有多少成员
t_group_users(group_id, user_id)

# 群离线消息表:用来描述一个群成员的离线消息
t_group_msgs( msg_id, group_id, sender_id, time, msg_content)

# 离线消息
t_offline_msgs(receiver_id, group_id, msg_id)

流程:

群聊中有5人,X、A、B、C、D

IM群聊基础方式
  1. X在群中发送消息
  2. Server查询群中Users
  3. Server将消息存储到群消息表、将离线消息存储到各用户
  4. Server从缓存中读取User的在线状态
  5. 发送消息给在线的A、B
  6. 在线者应答Ack
  7. 将他们的离线消息删除

CD在线后的操作与的单聊的流程类似:

IM群聊基础方式拉取

增强版:

对于用户,离线期间所有消息都是收不到的,所以只需要知道它最后收到的消息(msg_id或者time),就可以查询到所有它该接受的消息,这样t_offline_msgs就没有必要的。

数据:

# 群成员表:用来描述一个群里有多少成员,以及每个成员最后一条ack的群消息的msg_id(或者``time``)
t_group_users(group_id, user_id, last_ack_msg_id(last_ack_msg_time))

# 群消息表:用来存储一个群中所有的消息内容,不变
t_group_msgs(msg_id, group_id, sender_id, time, msg_content)

流程:

IM群聊增强方式

步骤与基础版类似,不同在于:

  1. Server存储时,不需要离校消息

  2. ack后,更新last_ack_msg_id

离校消息的拉取,逻辑拉取last_ack_msg_id之后消息,ack后修改last_ack_msg_id即可。

问答:

这种方式如何做重发?

也是一样的,需要有超时队列。

群聊能否与单聊一致?

一致其实就是单聊的简化方式2,保证发送端到服务端、及服务端到接收端的可靠。

机制

时序

参考

  • 定义

    保证消息的时序,包括:

    1. 发送方发送顺序与接收方展现顺序一致(单聊)
    2. 保证所有接收方展现顺序一致(群聊)
    3. 保证同一个用户发起的请求在服务端执行序列一致。(如支付)
  • 难处

    1. 客户端时钟不一致
    2. 对于发送端,先发送的消息R未必先达到Server
    3. 对于Server,先达到的消息R,Server未必先发出
    4. 再者,Server先发出的N未必先到达接收端
  • 方案

    由此,绝对的时序无法保证,只能是相对的时序,那如何来保证相对的时序呢,就需要消息增加一个增长的seq来表征顺序,然后在接收方针对seq来进行一个排序。

    对于单聊,可以在发送方加上本地的绝对时序;

    对于群聊,由于各客户端时钟不一致,只能使用服务端的时序seq;

    IM的时序性优化方案

    注意:

    这里对网关有了要求,用gid进行负载均衡,保证同一群在统一Server上,这样Server就可以本地seq来序列化同一群的所有消息。

    只要保证在消息能见范围内的时序即可,在单聊的1对1之间,或者在群聊范围内的时序即可,不用保证全局的范围。

    1. 时间戳
    2. DB的自增序列
    3. 内存的自增算法

统一单聊与群聊,使用服务端的时序seq;

发送端消息增加唯一uuid,用于重发的去重;

服务端加上seq(最简单的是timestamp),用于时序对齐;

发送方怎么对齐?

=> ack 时增加 seq, ack之后才算成功

心跳

参考1参考2

因为运营商有一个NAT超时:因为IP v4的IP量有限,运营商分配给手机终端的IP是运营商内网的IP,手机要连接Internet,就需要通过运营商的网关做一个网络地址转换(Network Address Translation,NAT)。简单的说运营商的网关需要维护一个外网IP、端口到内网IP、端口的对应关系,以确保内网的手机可以跟Internet的服务器通讯,大部分移动无线网络运营商都在链路一段时间没有数据通讯时,会淘汰NAT表中的对应项,造成链路中断。

有人会说 TCP 不是有 KeepAlive 机制么,通过这个机制来实现不就可以了吗?但是事实上,TCP KeepAlive 的机制其实并不适用于此。Keep Alive 机制开启后,TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。一般时间为 7200 s,失败后重试 10 次,每次超时时间 75 s。显然默认值无法满足我们的需求。

  1. TCP连接存在但不发送数据,但链路层断开,TCP是不能发现连接是否断开的
  2. 运营商由于无线资源的限制,在链路一段时间没有通信时,会断开链路
  3. 靠TCP自己的心跳机制也不能满足业务的需要
  4. 所以,需要业务层来发送心跳数据,即判断能判断连接是否存在,又能防止运营商给断开

引申:

NATTCP保活机制微信信令分析

在线状态如果是客户端定期拉取的话,一定程度上心跳是省略的;

特性

在线状态

参考

首先后端一定要维护在线状态,用于确定发送消息或者离线消息;

客户端看产品设计,像微信就没有在线状态而QQ就有在线状态;

在线状态的维护:

  1. Client登录后,Server将online状态存储到Cache中

  2. Client退出后,Server将状态设置为offline

客户端状态同步:

这里有2种方案,一种是由服务端“推”的方式,另一种是由客户端“拉”的方式

  1. “推”方式

    uid-B状态改变时(由登录、登出、隐身等动作触发),服务器不仅在缓存中修改uid-B的状态,还要将这个状体改变的通知推送给uid-B的“在线”的好友。

  2. “拉”方式

    Client向服务器轮询拉取自己的全部好友的状态,例如每1分钟一次。

    对于群聊而言,可以在用户真正进入群后,在拉取群友的在线状态,这样负载还会小一些。

前者实时,负载压力比较大;后者非实时,简单。如果对实时性要求较高,可以采用推送的方式同步;如果实时性要求不高,可以采用轮询拉取的方式同步;

从产品角度上看,什么时候需要在线状态?

对比QQ相比,微信、钉钉都不带在线状态;

=> 手机端普遍在线的情况,在线状态价值并不明显

=> QQ围绕在线状态产生的隐身、对谁隐身等功能又过于深

=> 在线状态使用户觉得自己需要随时应答别人的消息,从心理上产生了一种紧迫感,在IM领域在线状态并不适用了;

=> 其他应用场合:客服、游戏

回执

回执与在线消息讨论的6步的5、6步相同。

对回执的处理与在线状态有些类似,接收端收到消息,发送ack后,服务端既可以实时推送ack给接收端,也可以等着接收端来轮询回执。

数据:

# 群成员表:用来描述一个群里有多少成员,以及每个成员最后一条ack的群消息的msg_id(或者``time``)
t_group_users(group_id, user_id, last_ack_msg_id(last_ack_msg_time))

# 群消息表:用来存储一个群中所有的消息内容,不变
t_group_msgs(msg_id, group_id, sender_id, time, msg_content)

# 增加应答表,表明应答了哪些消息
t_msg_acks(receiver_id, group_id, sender_id, msg_id, if_ack)

从应答表来看,就是基础版t_offline_msgs有些相似,也可以看成一个队列

# 离线消息
t_offline_msgs(receiver_id, group_id, msg_id)
IM群聊回执方式

与增强版相比:

  1. 需要增加t_msg_acks的存储

  2. ack后除了需要修改last_ack_msgid外,需要if_ack

  3. 【试ack策略】查询发送方在线状态

  4. 【试ack策略】向发送方实时推送已读回执

后两步试ack的策略,以上是Server推的方式,也可以等着发送端x轮询拉取;

如果发送方不在线,下次他登录时,需要拉取已读回执。

根据msg_id的单增特性,可以是批量的ack,第6部是批量的ack,而第9步也是如此,msg_id小鱼last_ack_msg_id的都认为是已读。

回执与消息的ack差别

从产品设计上看,已读回执与接收消息的Ack是有区别的,已读回执是用户已经阅读了消息,接收消息的Ack是消息已经送达客户端,但用户是否已经阅读并不能确认;

这从Google Mail的已读上能看出差异来,用户在阅读邮件几秒种之后才会改变已读状态;只快速的点击,并不能修改该状态;

由此看,回执是回执,消息的ack是消息的ack。

什么场景需要回执

钉钉有回执,而微信没有回执。钉钉是办公IM,微信是通用的IM;

已读能让发送方确认消息已经被接收方收到,在通用交互领域并不需要,犹如我们面对面交谈,对方并不会告诉我们他已听到讲话,一方面假设已经听到(可靠性高),另一方面可以从对方的反应可以得到回应。好的IM也应该如此。

已读给通信的双方带来一些噪音,即使在办公领域也不太需要回执;

回执适合漂流瓶等场景,发出的消息不一定被收到,当收到后给一定的回复。也适合广播的场景,众里寻他得到回应。

会话

参考

会话指的是通信的双方(单聊)或多方(群聊)消息的集合,会话一般是客户端自己来做,服务端也可以来做,

t_session(session_type, session_id, receiver_id,  last_message_time, new_msg_coung, last_msg_content)
  1. 离线消息保存后,更新该sesstion
  2. receiver上线后先获取session,点击后再获取具体的消息,这样的好处是当离线消息过多后,能够降低拉取的频率。

在离线消息不多的场景下,只客户端来做即可,当离线消息变多,尤其是群聊的离线消息,就可以考虑这种方法了。

整个流程如下:

IM服务端会话

会话的离线消息分页拉取时候,逆序拉取先拉取最近的消息,拉取直接放在存储中,显示存储的内容,拉取完成后就不再取。

端侧一定是按会话来组织消息的;

离线的群聊消息是造成需要会话的一个重要原因;

用户量达不到一定量不需要服务端的会话

多点登录

**多点登录:**以微信为例,可以PC端、phone端同时登录、同时收发消息。需要注意的是:一个端只能登录一个实例,同端互踢。

在存储上,将原来的user:state,变成user:terminate:state

在逻辑上,发送端的消息要推送给接收端全部端;更重要的发送端的消息也要发送给自己其他端。

IM多点登录

如上图所示,发送方A和接收方B都进行了多点登陆,当用户A(phone端)给用户B发送消息时,除了要投递给B的所有多点登录端,还需要投递给A自已多点登陆的其他端(pc端),如上图中步骤4与步骤5。只有这样,才能在所有用户的所有端,恢复与还原双方聊天的上下文。

适用在不同端有登录需要的场景;

一般是android、iOS、PC,还可能包括pad

办公领域还是很需要的

消息漫游

**消息漫游:**以QQ为例,在任何一个终端的任何一个实例登录qq,都能够拉取到所有历史聊天消息,就是消息漫游。

消息漫游下,消息发送完成后,不能直接删除,至少要保存一端时间(如3个月)。客户端本地需要保存一个last_msg_id(last_msg_time)。

如下图所示:

IM消息漫游方式

原本不在线的B(phone端),又重新登录了,他怎么拉取历史消息?只需要在客户端本地存储一个上一次拉取到的msg_id(time),到服务端重新拉取即可。

客户端有了last_msg_id,那服务端是否还需要last_ack_msg_id?

需要:虽然客户端与服务端的last_ack_msg_id都是用于同步,但在多端情况下,首次同步与非首次同步是不同的,因为首次同步对于用户是新消息,页面上需要提示,而非首次同步则不需要。这个对客户端来说,它无法判断是否是首次同步,只有与服务端的last_ack_msg_id对比之后才能确定。

last_msg_id要求群聊与单聊的消息是存在一起的,否则就需要2个id了

换手机、多端都存在消息漫游的需求,微信在浏览器上并没有消息漫游

架构

消息集群

参考

单机:

单机架构比较简单,client直接与Server相连即可,如下增加了DNS:

  • client向tcp.daojia.com发起tcp请求;
  • DNS服务器将tcp.daojia.com解析为外网IP(1.2.3.4);
  • client通过外网IP(1.2.3.4)向tcp-im-server发起请求。
IM单机架构

Nginx负载均衡:

使用nginx来进行负载均衡,并使用ip_hash策略,保证客户端与同一服务端建立连接。

IM的Nginx集群方案

服务中心负载均衡:

这种称为服务中心负载均衡方式,与SpringCloud的方式类似。

IM的注册中心负载方案

这种方案是参考中最多的,

  1. 首先Client先从web-server获取IMServer的地址
  2. 然后web-server做负载均衡返回IMServer
  3. 最后Client在于IMServer建立连接

Web-Server起到服务中心的作用,它与IMServer集群之间通过拉或者推的方式来探活。

对比:

IM比较消耗内存与IO,Nginx方式能一定程度缓解内存,服务中心方式内存与IO都好一些

集群方式不同客户端与不同服务器建立的连接时,他们之间的通信问题。如:

如果client-A 挂在了server-1下面, clietn-B 挂在server-2下面 这个时候client-A和client-B之间通信,这时候server-1和server-2间进行消息的转发。

这样可以在Redis中存放状态,server从reids获取状态,如果本地在线就转发,集群在线通过点点(RPC)或者广播(MQ)通信先传递到目标Server,再有目标Server进行转发。

文件服务器

参考

这里图片服务器不仅包含图片,还包含文件、语音、视频等等。

IM中的文件,先传递到文件服务器上,返回地址,再有客户端将地址传导业务服务器上。

文件服务器架构如下图所示:

图片服务器
  1. 搭建文件服务集群,如MINIO

  2. web server可以是文件服务的访问接口,专门负责文件的管理,也可以是其他业务服务,上传文件。

结合IM,整个文件流程如下图所示:

IM文件流程

  1. 客户端上传文件到webServer

  2. WebServer上传到fileServer

  3. fileServer持久化到磁盘,并返回对应路径

  4. webserver将路径返回给客户端

  5. 客户端再讲文件路径和消息发送给IMServer

  6. 对于接收端,接受到带有文件的消息

  7. 从根据路径从WebServer请求文件

  8. webServer再从file请求文件

  9. 将文件传给客户端

MINIO

总结

目的:

这里是做一个基础版的IM,功能上包括:在线消息、离线消息、群聊、多点登录、消息漫游等部分

服务端

数据:

持久化:

# 群
t_group(id, name, creator_id, ...)

# 群成员表
t_group_users(group_id, user_id)

# 用户最近一次ack的信息
t_user_ack(user_id,  last_ack_msg_id, last_ack_time)


# 消息表:uuid是发送端携带的唯一标识,用于去重, type可以表示单聊或群聊
t_msgs(id, uuid, sender_id, type,  receiver_id,  time, content)

缓存/MQ:

# 在线状态
userId : clientType : status

# 重发队列,用MQ的延迟会更好一些
msgId: { receiverId,  senderTime/expireTime }

流程:

流程可以包括:

  1. 连接管理流程

    IM离线消息-连接状态

    上线login后,将user_id、终端类型、状态、连接的服务设置的cache;

    下线logout后,将相应连接状态设置为offline;

  2. 在线消息流程

    IM流程总结-在线消息
    1. 发送消息

    2. 群聊情况下查询群中成员

    3. 查询接收者的在线状态,以及连接所在server

    4. 放入延迟队列,等待ack,若没有ack则重发,重发需要计次数

    5. 推送消息,对于其他Server上的连接,先推送给相应Server,再有其他服务推送

    6. 返回N,Server更新Ack,以及last_ack信息

  3. 离线消息流程

    IM流程总结-离线消息
    1. 获取离线消息,携带客户端的last_msg_id;
    2. 获取用户的全部组织,获取用户的离线消息;
    3. DB返回消息给Server
    4. Server返回消息给客户端
    5. 返回应答
    6. 更新应答

客户端

与服务端对应,客户端包括信息的发送、接收,离线消息的主动获取,这里主要看看发送与接收。

我觉得大体应该分成2部分:View - Controller - DB; net - Contoller - DB。

这样用DB将View与Net分开,更好的保证一致性

发送:

IM客户端-发送
  1. 用户发送消息

  2. 先进行循序,unack_message

  3. 发送给服务端,若不成功则重发,有重试次数;成功后等待ack,若没有ack也重发;

  4. 等到ack,以及消息的id

  5. 更新相应的last_msg_id、message、unack_message

  6. 相应显示

接收:

IM客户端-接收
  1. 接收到网络推送的消息
  2. 入库,并更新相应的数据
  3. 返回ack
  4. 通知View

其他

技术点:

  1. 连接保持与查询
  2. Server之间的相互通讯
  3. 重发队列的实现方式

问题:

  1. 群聊与单聊消息是否要分开存储?
  2. 用多少个长连接?

微服务对照

结构

对照OpenIM:

这里有微服务的方式进行IMServer的设计,包括3个服务:msg_gateway、msg_transfer、msg_pusher。

msg_gateway服务与客户端的通信,即接收新消息,也推送消息;

msg_tansfer服务用于Server的逻辑表达,群聊、单聊消息的持久化等等;

msg_pusher推送消息,在线时发给gateway发送,离线使用三方推送;

另外还有离线消息的同步,同步使用REST的形式进行。

结构如下:

OpenIM服务端流程

对比之前的设计,相当于将之前的Server拆分成了3部分:msg_gateway负责连接;msg_transfer负责调度消息,决定消费发给谁;msg_pusher负责消息的推送;并使用MQ进行服务之间的解耦。

问答

  • 从协议上看,保持了发送端clientA -> Server的ACK,并没有接收端clientB的ACK设计,如何追加?

    这个需要在msg_gateway增加待应答消息队列,没有应答需要重新发送。

  • 这里用msg_gateway保存连接,也没有考虑到多个msg_gateway时存在的推送问题,如何处理?

    这时候需要pusher将消息推送给接收端连接所在的msg_gateway。

  • 这里seq是如何设计?

    全系统的自增没有意义且影响性能并带来一致性问题,基于会话的自增倒是不错的方式,这样带来的偏序特点便于发现消息的丢失。而且将这个seq写到redis中也实现了msg_transfer对连接的解耦,连接不用非要在用一个msg_transfer上进行处理。

    基于会话的缓存会比较大一些。

  • 显然seq用于消息的时序,但对发送方ack时并没有携带该seq,所以发送方客户端对自己发送的消息如何保证其时序?

    目前看,只能等到pusher推送时,也给发送方也推送一遍消息,这样用于时序的表达。

  • 这个seq直接用服务端的timestamp呢?

    这样没有严格的+1自增,却是自增的,可以用偏序来对齐,以及获取离线消息;

    丢失了+1自增对丢失、应答消息的敏感,多了对seq的存储与单点优势,感觉合算。

结论

对比之前设计,这种设计不需要Server之间的同步,难度系数降低,且更适合扩展,故采用微服务,这种设计更好。

原设计比较适合单体消息服务。

将原来的连接管理、消息收发交给msg_gateway,将决定哪个gateway发送交给pusher,将其他业务逻辑交给transfer即可完成微服务的改造。

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×