【架构思考】IM架构

本文将总结关于如何构建一个IM架构相关的知识。

1. 将【接入服务】与【业务处理服务】独立拆分

理由有二,是任务分工不同,接入服务负责建立并保持与客户端的连接、消息的编解码、协议解析等一些IM前台服务(也可以叫做网关),是最接近用户的服务,而且要在流量高峰期进行快速的性能扩展;

而业务处理服务则是整个IM架构的核心,经常会随着业务需求不断变化而进行频繁的版本迭代,服务升级就意味着需要重启,如果将其与接入服务和在一起,这势必会导致已连接的客户端出现不稳定,断连重连的情况,甚至会导致消息推送不及时消息发送失败等严重问题,带来非常不好的用户体验。

是拆分后有助于提高业务开发效率,业务开发的开发者就不用关心连接层的事情,可以专注业务处理。

是向高内聚,低耦合的架构靠拢,这会对以后做系统扩展有很大的帮助,比如有另外一个完全不同的业务也需要进行消息推送,但只是推送,那就可以直接对接现有的接入服务,负责原来业务处理服务开发的同事就不用关心这块了,这样一来, 也将不同的业务隔离开了。

2. IM系统的特性

实时性,如果我和别人聊天,他的消息我1分钟后才收到,那还聊个啥。

保证消息实时性有几种方案,在后续探讨

可靠性,这是即时消息服务应用于各种社交、互动领域的基本条件。可靠性在这里分两点,一是不丢消息,而是消息不重复。

对于这两点,需要特别的处理才能实现完整的可靠性,后续探讨

一致性,指的是同一条消息,在多人、多终端都需要保证消息顺序展示的一致性。比如我先后在群里发了傻瓜、叫你两条消息,有些人先看到的是叫你,这就有问题了。不同终端也是一样的道理。这里也需要通过特别的处理才能实现一致性,后续探讨

安全性,即时消息已经被广泛应用于各种私密社交场景,所以用户对系统的隐私保护需求也就更高。从系统使用安全性的角度看,IM系统首先需要保证数据传输安全,其次是数据存储安全,最后是消息内容安全

省电省流量,作为一个优化,如果你的IM客户端也包括移动端,那显然,我们的IM系统考虑这方面的优化对用户体验无疑是锦上添花,所谓做就做到极致,后续探讨

待续。

3. IM系统几个必要的功能

3.1 消息未读数

      功能很简单,只要用户没有读这条消息,就算作一条未读消息。未读消息的作用,一是直接的告诉用户【哪个联系人发了几条消息】,用户很可能会根据未读数来决定先读取谁的消息。二是在APP的通知栏权限被限制以防止频繁干扰用户后*或者*用户刚登录/联网的情况下,就需要APP图标上的消息总未读数来提醒用户一共有多少条未读消息。

      未读数变更:当用户点击进入与联系人的聊天会话后,总消息未读数减一(假设这个联系人只有一条未读消息),单个聊天会话未读数减一。

4. 存储方面

4.1 对消息内容表/索引表进行分库分表存储,索引表以用户ID为主键进行哈希,保证用户的所有会话记录都在一张表上。

5. 如何保证消息投递的可靠性:ACK机制

5.1 先看看常见的服务器中转路由的IM架构下,消息如何流转的

 这里分两部分:

          1. 第一部分是1/2/3这三个环节都可能出错导致消息丢失,解决办法是发送方的超时重发+IM端去重机制(发送方生成msgid)

          2. 第二部分是4这个环节可能出现问题,如IM存完消息就宕机,或者网络原因未到达,或接收端处理失败等。解决办法是参考TCP的ACK机制实现一套业务层的ACK机制。

如何实现业务层的ACK机制

如图,服务端推送消息时,携带一个SID(seq-id)表示此消息的唯一序列ID,消息推出后,立即将消息添加到“待ACK消息列表”,接收方若成功收到并处理消息就给服务端发一个ACK消息(携带这条消息的SID),然后服务端从“待ACK消息列表”中清除这条消息,此时才算这条消息推送成功。

当然,这其中还有一些细节(重传+):

        1. 当服务端发送的消息因为某些原因导致B迟迟收不到,这个时候必须进行重传。服务端在维护“待ACK消息列表”的同时,也会维护一个超时计时器,当某条消息在一定时间内没有收到接收方的ACK,那么就会取出这条消息进行重传。

        2. 由发送方的ACK消息本身丢失的原因导致服务端重传的情况,这时接收方就需要在本次会话中缓存收到的消息SID,在处理接收到的消息时根据SID进行去重。

最坏的情况:IM服务端推出消息后就宕机了,这个时候消息又因为某些原因导致了接收方没有收到,一旦宕机,对接收端来说,本次会话就停止了(本地的缓存可能清空)。那么当IM恢复时,肯定会对这条消息进行重传,但是IM恢复时接收端不一定在线,那么就会将“待ACK消息列表”中的这个接收方的消息全部清空(不可能一直保持在缓存中,而是在磁盘中)。当下一次与接收方的会话开启时,如何获取那些“丢失“的消息呢?解决方法是消息的完整性检查

如何实现消息的完整性检查(时间戳):

        服务端发送消息时再带一个timestamp,接收方接收消息成功时在本地缓存时也存下这个时间戳,那么当IM宕机再恢复后,接收方带上本地的上次最后一条消息的时间戳向服务器拉取消息,IM就会将这个大于这个时间戳的消息全部推送(大量的话需分页)给接收方;若不带时间戳,服务器在pop“待ACK消息列表”的同时需要记录A用户与B用户的最后一条消息时间戳last_msg_ts,方便A用户更换设备上线不带时间戳时,把last_msg_ts用来计算未读消息数,并推给用户,大量消息仍然以分页展示。

注意点:当IM是集群/分布式部署时,需要同步全局时钟,否则会出现消息重复拉取/漏掉的问题,也可以使用全局自增序列来替换时间戳。

6. 如何保证消息不乱序?

6.1 即时通讯系统中,消息收发一致性是最需要保证的功能之一,如果乱序,在人看来就是语无伦次,说话令人懊恼。

6.2 如果是在后端服务器是完全单线程的场景中,那么保证消息顺序或许不是难题,但是这种模式部署的TPS和效率太低。实际应用中,我们需要多进程部署。那么在多进程部署场景中,要保证消息时序一致性就找到一个“时序可比较性”,有了这个基准,才能将多条消息串起来变成有序的。

6.3 如何找到时序基准

     首先要排除的就是发送方的本地时间,因为不同发送方的时间多半是不同步的(还可能不同时区),我们无法保证。

然后来考虑服务器本身的时间是否可以作为这个时序基准,当服务器集群部署时,即使使用NTP同步各服务器的时间仍然会存在误差,而且当规模变大的时候,连NTP同步这个机制都不会可靠。

既然本地化的时钟都不能作为时序基准,那么是不是可以专门设定一台时钟服务器或者序号服务器,所以的IM节点收发消息时都向这台服务器获取时间或序号,这样就不存在同步问题,而且全局序号生成器有多中方案可用,如redis的incr命令,db的自增id,snowflake算法,时间相关的分布式序号生成服务都可以。

6.4 解决“时序基准”的可用性问题

      当系统处于高并发访问场景下,这个“时序基准”服务要如何维持高可用性呢?

      1. 首先是集群或分布式部署,如redis集群。或者采用snowflake算法这样的分布式“序号生成器”。

      2. 其次是从业务层上考虑,对于群聊和单聊这种场景,没必要保证全局的跨群的绝对消息时序一致性,只需要保证每个群的消息时序一致性就可以了。这样一来,就可以给每个群分一个“ID生成器”,通过哈希规则把压力分散到多个服务实例上,就可以大大降低全局共用一个“ID生成器”的并发压力了,这样即使ID生成服务是集群部署,造成的误差也可以忽略。

     微信的聊天和朋友圈的消息时序就是通过一个递增的版本号服务来实现的,不过版本号是每个用户独立的。

6.5 其他误差

     现在消息在服务端本地已经保证了时序性,但是这就能保证消息一定按照正确的顺序显示在用户的手机上吗?明显是不一定的,有几个原因。

      1. IM服务器是集群化部署的,不同机器的性能和网络质量略有差异,即使两条消息同时离开服务器,先离开的消息有可能因为网络问题后到达用户终端。

      所以需要对消息进行本地整流,因为在有些场景中,需要IM服务器保证绝对的时序性。比如用户取关一个公众号,取关操作下达时,发送方先后生成两个消息:先是发送“XXX已取关”的消息给公众号,然后是真正的取关操作消息(虽然实际不会这么实现)。如果第二条消息先到达服务器被处理,那么“发送取关”的消息就会因为已取关无法发送给对方。

这里的解决方案可以是调整实现方式,比如用户只需发送1条取关消息,触发消息由服务端来操作;但是推送消息时也存在时序错乱问题,要解决就需要进行接收端整流

当携带不同序号的消息到达接收端后,可能会出现顺序错乱,业界常见的实现方式比较简单:

         1. 下推消息时,将序号随消息一起推给接收方

         2. 接收方收到消息时进行判定,若当前消息序号大于前一条消息序号,则直接追加。

         3. 若当前消息序号小于前一条消息序号,则向上逐一比对,将消息插入序号刚好大于的那条消息后面。

另外需要注意,只有离线推送消息时(聊天记录)一般才会需要整流,在线聊天消息做整流会很影响实时性。

7. 保证消息的安全性

     三个维度来保证消息安全。

     7.1 消息传输安全。“访问入口安全”和“传输链路安全”是基于互联网的即时消息场景下的重要防范点。针对“访问入口安全”可以通过 HttpDNS 来解决路由器被恶意篡改和运营商的 LocalDNS 问题;而 TLS 传输层加密协议是保证消息传输过程中不被截获、篡改、伪造的常用手段。

     7.2 消息存储安全。针对账号密码的存储安全可通过单向散列算法和加盐机制来保证安全;对于追求极致安全性的场景中,可采用E2EE即端到端加密的方式来提供传输保护,会话双方启动会话时会通过非对称加密算法各自生成本地秘钥对并进行公钥交换,私钥不在网络上传输,消息由接收方公钥加密,接收方再拿自己私钥解密。IM服务端仅进行数据的流转,亦无法查看明文。

再甚者就是消息完全不经过IM服务器,P2P的聊天,国外的telegram就支持这种,所以各种国内违法的东西盛行,采用这种方式是绝对的聊天自由了,因为消息对开发商来说已经不可控了,国内肯定是不行的。

     7.3 消息内容安全。可以依托敏感词库,图片/视频/语音识别服务,市面上已经有很多成熟的厂商提供这些服务;还可以加上一些“联动惩罚处置”进行风险识别后的闭环。

8. 分布式锁和原子性:确定消息未读数

     8.1 即时通讯系统中的最重要的几个特性是实时性、可靠性、一致性、安全性,除此之外,还有一个对用户非常重要的功能就是未读数提醒。如果我在手机桌面看到微信图标显示10条未读,进去只有1条,我一定会投诉微信app严重bug,对于强迫症患者来说,这个功能甚为重要。

     8.2 未读数提醒为两方面,会话未读和总未读,前者指的是和某个联系人/群组的消息未读数,后者就是前者的总和,如何来保证这两个数据的准确性显得尤为关键。

     8.3 成熟的方案中对于会话未读和总未读两个数据时单独维护的,虽然后者可以通过计算前者的和来得到,但是可能会因为超时未取到部分会话的未读导致出现一致性问题,而且多次获取累加的操作在性能上易出现瓶颈。

     如果单独维护,就要解决高并发场景中两者的一致性问题,即会话未读的和要==总未读,在下面的例举场景中就会发生数据不一致的问题:

    A给B发消息之前,B的A会话未读==总未读==0,A发送消息后,到达IM服务器时,为B执行A会话未读+1,然后执行B总未读+1;若第二个操作失败了,最终结果是:B不知道有新消息,从而漏掉查看这条消息。

    同样的,第一步操作失败造成的现象是用户看到总未读提示,点进去却发现没有一个未读会话。

   原因分析:都是因为会话未读和总未读的两次连续变更不是原子性的,从而导致各种问题。

   解决:IM服务通常都是分布式部署的,这里可以选择的方案:

     1. 分布式锁,如依赖于DB的唯一性(插入一条固定记录成功则获得锁否则重试),redis的setNX等

     1. redis的事务功能,但是它的watch机制实际是一种乐观锁策略,在高并发场景中失败率高,有一定的效率问题。更优的方案是Lua脚本。

     2. 原子化的脚本。redis支持嵌入Lua脚本来原子化的执行多条语句,这里实现两个操作的原子性的连续变更不在话下。还能实现更复杂的功能,比如有的未读数不希望一致存在干扰用户,如用户7天未查看则清除未读。这种业务逻辑可以很方便的使用lua脚本实现“读时判断过期并清楚”。

9. 智能心跳机制:解决网络的不稳定性

在IM系统中,消息传输是很频繁也很普通的事情,通常会选择在服务端和客户端维护一条基于TCP的长连接通道来传输消息,长连接的好处是:

        1. 节省了多次交互的TCP3次握手时间 

        2. 节省了部分header开销,每次HTTP请求都会携带完整的头部,如认证信息。

        3. 让IM系统具有消息通信的强实时性(很重要)

对于大部分IM场景中,通信一方处于弱网络环境中时,长连接此时就不可用,但IM服务无法感知到其不可用。比如路由器掉线,拔掉网线,wifi异常断开,这些情况下客户端和IM服务端都无法实时感知。这会造成IM服务在内存中维护的一部分连接可能都是无效的连接,导致了资源浪费。而心跳机制可以让客户端和服务端及时知道连接通道的失效,做及时的资源清理;心跳的另一个好处就是:支持客户端及时断线重连,当连接不可用时,客户端执行及时的断线重连是很必要的;最后一个作用是:连接保活。在用户网络和中间网络都正常的情况下,长连接仍然有可能被杀死,原因如下:
         由于IPV4资源有限,移动运营商的上网卡实际上只是分配了一个其内网的IP,访问互联网时,运营商网关通过一个外网IP+端口 到 内网IP+端口 的双向映射表来让上网卡能够上网,懂网络的人应该知道这是用的NAT技术;这不是关键,关键是运营商为了节省资源和降低其网关压力,对于一段时间没有数据收发的连接会把他们从NAT映射表中清除,这个操作也不会被手机端和IM服务端感知,所以就更需要心跳机制了。

业界常用的三种心跳方法:

       A. TCP keepalive

           作为OS的TCP/IP协议栈实现的一部分,对于本机的TCP连接,会在连接空闲时按预定的频次自动发送不带body的传输层报文,来探测对方是否存活,OS默认关闭这个特性,需要应用层开启。Linux系统上默认是心跳周期2hour,失败后重试9次,超时时间75s,都可以修改。

           这种方法的优势在于,传输层的报文开销比应用层更小,可以提高发送频率。

           劣势就是,配置好了如果要改,就必须重启IM服务,灵活性差。还有就是传输层的正常并不代表应用层是可用的,IM服务的代码死锁、阻塞都会导致连接不可用,通过传输层的保活机制是无法发现的,此时就需要应用层的心跳才能发现问题。

      B. 应用层心跳

           通过在客户端应用程序中定时发送心跳来保活。这种方式可以弥补TCP保活机制的缺陷,小缺点就是开销稍大,但基本可以忽略。还有一个优势就是可以根据实际网络情况和消息通信情况灵活的控制下一次心跳的间隔,这可以节省更多流量。实际案例:WhatApps 的应用层心跳间隔有 30 秒和 1 分钟,微信的应用层心跳间隔大部分情况是 4 分半钟,目前微博长连接采用的是 2 分钟的心跳间隔。

           不同的IM客户端发送心跳的间隔也是不一样的,因为他们网络和消息通信情况的各不相同。大概策略是当客户端空闲时才会发送频次更高的心跳包。

            常见的客户端心跳机制:当连接空闲时间超过X,发送心跳包,连续N次收不到服务端回应判定连接无效。

            常见的服务端心跳机制:当连接空闲时间超过X,判定连接无效,断开并清理资源。

            另外,当连接无效时,应用层心跳无法区分到底是传输层还是应用层的问题,结合TCP的keepalive可以方便定位故障源。

      C. 智能心跳(极致优化)

           国内移动网络场景下,各地方运营商的NAT超时时间的差异性也很大,从几分钟到几小时不等。采用应用层固定频率的心跳固然简单,但对于设备CPU,电量,流量资源无法做到最大程度节约。为了做到极致优化,部分厂商会采用智能心跳方案。来平衡”NAT超时“和"设备资源节省"。所谓智能,就是让心跳能够根据网络环境来自动调整,通过不断自动调整心跳间隔的方式,逐步逼近NAT超时临街点。(在超时的边缘疯狂试探)

           这种方案虽然是好的,但是也增加了业务复杂度,带来的节省效果也有限,因为流量贵,电量不够用的时代已经过去了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章