[TOC]
即时通讯IM技术领域基础篇
即时通讯IM技术领域提高篇
议题
- 准备工作(协议选型)
- 网络传输协议选择 和 数据通信协议选择
- xxx项目架构
- 架构优缺点
- 架构改进之路
- IM 关键技术点 & 策略机制
- 如何保证消息不丢/不乱序/不重复
- 心跳策略
- 重连策略
- 典型IM业务场景
- 用户A发送消息给用户B
- 用户A发送消息到群C
- 存储结构简析
准备工作(协议选型)
选用什么网络传输协议(TCP/UDP/HTTP) ?
- udp协议虽然实时性更好,但是如何处理安全可靠的传输并且处理不同客户端之间的消息交互是个难题,实现起来过于复杂. 目前大部分IM架构都不采用UDP来实现.
- 但是为啥还需要HTTP呢?
- 朋友圈
- 用户个人信息(好友信息,账号,搜索等..)
- 离线消息用拉模式,避免 tcp 通道压力过大,影响即时消息下发效率
- 等等...
- 核心的TCP长连接,用来实时收发消息,其他资源请求不占用此连接,保证实时性
- http可以用来实现状态协议(可以用php开发)
- IM进行图片/语言/大涂鸦聊天的时候: http能够很方便的处理 断点续传和分片上传等功能.
- TCP: 维护长连接,保证消息的实时性, 对应数据传输协议.
- 目的: 及时收发消息
选用什么数据通信协议?
- IM协议选择原则一般是:易于拓展,方便覆盖各种业务逻辑,同时又比较节约流量。节约流量这一点的需求在移动端IM上尤其重要 !!!
- xmpp: 协议开源,可拓展性强,在各个端(包括服务器)有各种语言的实现,开发者接入方便。但是缺点也是不少:XML表现力弱,有太多冗余信息,流量大,实际使用时有大量天坑。
- MQTT: 协议简单,流量少,但是它并不是一个专门为IM设计的协议,多使用于推送. 需要自己在业务上实现群,好友相关等等(目前公司有用MQTT实现通用IM框架).
- SIP: 多用于VOIP相关的模块,是一种文本协议. sip信令控制比较复杂
- 私有协议: 自己实现协议.大部分主流IM APP都是是使用私有协议,一个被良好设计的私有协议一般有如下优点:高效,节约流量(一般使用二进制协议),安全性高,难以破解。 xxx项目基本属于私有定制协议<参考了蘑菇街开源的TeamTalk>, 后期通用IM架构使用MQTT
- 协议设计的考量:
- 网络数据大小 —— 占用带宽,传输效率:虽然对单个用户来说,数据量传输很小,但是对于服务器端要承受众多的高并发数据传输,必须要考虑到数据占用带宽,尽量不要有冗余数据,这样才能够少占用带宽,少占用资源,少网络IO,提高传输效率;
- 网络数据安全性 —— 敏感数据的网络安全:对于相关业务的部分数据传输都是敏感数据,所以必须考虑对部分传输数据进行加密(xxx项目目前提供C 的加密库给客户端使用)
- 编码复杂度 —— 序列化和反序列化复杂度,效率,数据结构的可扩展性
- 协议通用性 —— 大众规范:数据类型必须是跨平台,数据格式是通用的
- 常用序列化协议
- 提供序列化和反序列化库的开源协议: pb,Thrift. 扩展相当方便,序列化和反序列化方便(xxx项目目前使用pb)
- 文本化协议: xml,json. 序列化,反序列化容易,但是占用体积大(一般http接口采用json格式).
xxx项目系统架构
前期架构
...
改进后架构
...
架构的优缺点
优点
- 同时支持TCP 和 HTTP 方式, 关联性不大的业务服务独立开来
- php server
- router server
- user center
- Access server
- oracle server
- 服务支持平行扩展,平行扩展方便且对用户无感知
- cache db层的封装,业务调用方直接调用接口即可.
- 除了Access server是有状态的,其他服务无状态
- 各个服务之间,通过rpc通信,可以跨机器.
- oracle里面都是模块化,有点类似MVC模式, 代码解耦, 功能解耦.
缺点
- oracle 太过庞大, 可以把某些业务抽取出来
- oracle里面耦合了apns server, 可以把apns 单独抽取出来. (xxx项目目前已经开始接入通用push推送系统了,类似把apns抽取出来).
- 业务太庞大,多人开发不方便,容易引起code冲突
- 如果某个小功能有异常,可能导致整个服务不可用
- 缺点
- 改进
- push server 没有业务,仅仅是转发Access和oracle之间的请求
- 把push server合并到Access中,减少一层rpc调用中间环节.减少运维成本还能提高效率(xxx项目新架构已经把push server干掉融合到Access里面)
- 需要单独维护一个比较鸡肋的服务,增加运维成本
- 缺点
- 改进
- Access server和用户紧密连接,维持长连接的同时,还有部分业务
- 把Access server 中维持长连接部分抽取出来一个connd server:
- 仅仅维持长连接,收发包. 不耦合任何业务(xxx项目目前正在改进这个架构,还未上线)
- 维持着长连接,如果升级更新的话,势必会影响在线用户的连接状态
- 偶尔部分业务,降低长连接的稳定性
- 缺点
- 改进:
IM 关键技术点
技术点一之: 如何保证消息可达(不丢)/唯一(不重复)/保序(不乱序)
最简单的保序(不乱序)
- 为什么有可能会乱序?
- 拉取的时候,一般会把离线的消息都一次性的拉取过来
- 多条消息的时候,就要保证收取到的消息的顺序性.
- 但是,如果收到消息的时候,突然网络异常了,收不到消息了呢?
- 服务端就会重发或者转离线存储(xxx项目的机制立即转离线存储)
- 对于在线消息, 一发一收,正常情况当然不会有问题
- 对于离线消息, 可能有很多条.
- 怎么保证不乱序?
- 每条消息到服务端后,都会生成一个全局唯一的msgid, 这个msgid一定都是递增增长的(msgid的生成会有同步机制保证并发时的唯一性)
- 针对每条消息,会有消息的生成时间,精确到毫秒
- 拉取多条消息的时候,取出数据后,再根据msgid的大小进行排序即可.
保证唯一性(不重复)
- 消息为什么可能会重复呢?
- 这种情况下,就可能会需要有重发机制. 客户端和服务端都可能需要有这种机制.
- 既然有重复机制,就有可能收到的消息是重复的.
- 移动网络的不稳定性,可能导致某天消息发送不出去,或者发送出去了,回应ack没有收到.
- 怎么解决呢? 保证不重复最好是客户端和服务端相关处理
- 消息meta结构里面增加一个字段isResend. 客户端重复发送的时候置位此字段,标识这个是重复的,服务端用来后续判断
- 服务端为每个用户缓存一批最近的msgids(所谓的localMsgId),如缓存50条
- 服务端收到消息后, 通过判断isResend和此msgid是否在localMsgId list中. 如果重复发送,则服务端不做后续处理.
- 因为仅仅靠isResend不能够准备判断,因为可能客户端确实resend,但是服务端确实就是没有收到......
保证可达(不丢且不重)
- 最简单的就是服务端每传递一条消息到接收方都需要一个ack来确保可达
- 但是ack也有可能在弱网环境下丢失.
- 服务端返回给客户端的数据,有可能客户端没有收到,或者客户端收到了没有回应.
- 因此,就一定要有完善的确认机制来告知客户端确实收到了. 有且仅有一次.
- 考虑一个账号在不同终端登录后的情况.
- 消息要能够发送到当前登录的终端,而且又不能重复发送或者拉取之前已经拉取过的数据.
技术点二之: msgID机制
这里提供两种方案供参考(本质思想一样,实现方式不同)
序列号msgid机制 & msgid确认机制(方案一):
- 每个用户的每条消息都一定会分配一个唯一的msgid
- 服务端会存储每个用户的msgid 列表
- 客户端存储已经收到的最大msgid
image.png
优点:
- 根据服务器和手机端之间sequence的差异,可以很轻松的实现增量下发手机端未收取下去的消息
- 对于在弱网络环境差的情况,丢包情况发生概率是比较高的,此时经常会出现服务器的回包不能到达手机端的现象。由于手机端只会在确切的收取到消息后才会更新本地的sequence,所以即使服务器的回包丢了,手机端等待超时后重新拿旧的sequence上服务器收取消息,同样是可以正确的收取未下发的消息。
- 由于手机端存储的sequence是确认收到消息的最大sequence,所以对于手机端每次到服务器来收取消息也可以认为是对上一次收取消息的确认。一个帐号在多个手机端轮流登录的情况下,只要服务器存储手机端已确认的sequence,那就可以简单的实现已确认下发的消息不会重复下发,不同手机端之间轮流登录不会收到其他手机端已经收取到的消息。
用户在不同终端登录的情况下获取消息情况
image.png
假如手机A拿Seq_cli = 100 上服务器收取消息,此时服务器的Seq_svr = 150,那手机A可以将sequence为[101 - 150]的消息收取下去,同时手机A会将本地的Seq_cli 置为150
image.png
手机A在下一次再次上来服务器收取消息,此时Seq_cli = 150,服务器的 Seq_svr = 200,那手机A可以将sequence为[151 - 200]的消息收取下去.
image.png
假如原手机A用户换到手机B登录,并使用Seq_cli = 120上服务器收取消息,由于服务器已经确认sequence <= 150的消息已经被手机收取下去了,故不会再返回sequence为[121 - 150]的消息给手机B,而是将sequence为[151 - 200]的消息下发给手机B。
序列号msgid机制 & msgid确认机制(方案二: xxx项目目前方案):
- 每个用户的每条消息都一定会分配一个唯一的msgid
- 服务端会存储每个用户的msgid 列表
- 客户端存储已经收到的最大msgid
- 对于单聊,群聊,匿名分别存储(某人对应的id,某群对应的id).
image.png
思考
这两种方式的优缺点?
- 方式二中,确认机制都是多一次http请求. 但是能够保证及时淘汰数据
- 方式一中,确认机制是等到下一次拉取数据的时候进行确定, 不额外增加请求, 但是淘汰数据不及时.
技术点三之: 心跳策略
心跳功能: 维护TCP长连接,保证长连接稳定性, 对于移动网络, 仅仅只有这个功能吗?
- 心跳其实有两个作用
- 运营商通过NAT(network adddress translation)来转换移动内网ip和外网ip,从而最终实现连上Internet,其中GGSN(gateway GPRS support Node)模块就是来实现NAT的过程,但是大部分运营商为了减少网关NAT的映射表的负荷,若一个链路有一段时间没有通信就会删除其对应表,造成链路中断,因此运营商采取的是刻意缩短空闲连接的释放超时,来节省信道资源,但是这种刻意释放的行为就可能会导致我们的连接被动断开(xxx项目之前心跳有被运营商断开连接的情况,后面改进了心跳策略,后续还将继续改进心跳策略)
- NAT方案说白了就是将过去每个宽带用户独立分配公网IP的方式改为分配内网IP给每个用户,运营商再对接入的用户统一部署NAT设备,NAT的作用就是将用户网络连接发起的内网IP,以端口连接的形式翻译成公网IP,再对外网资源进行连接。
- 从mobile 到GGSN都是一个内网,然后在GGSN上做地址转换NAT/PAT,转换成GGSN公网地址池的地址,所以你的手机在Internet 上呈现的地址就是这个地址池的公网地址
- 心跳保证客户端和服务端的连接保活功能,服务端以此来判断客户端是否还在线
- 心跳还需要维持移动网络的GGSN
- 最常见的就是每隔固定时间(如4分半)发送心跳,但是这样不够智能.
- 4分半的原因就是综合了各家移动运营商的NAT超时时间
- 心跳时间太短,消耗流量/电量,增加服务器压力.
- 心跳时间太长,可能会被因为运营商的策略淘汰NAT表中的对应项而被动断开连接
- 智能心跳策略
- 为了保证收消息及时性的体验,当app处于前台活跃状态时,使用固定心跳。
- app进入后台(或者前台关屏)时,先用几次最小心跳维持长链接。然后进入后台自适应心跳计算。这样做的目的是尽量选择用户不活跃的时间段,来减少心跳计算可能产生的消息不及时收取影响。
- 大部分移动无线网络运营商都在链路一段时间没有数据通讯时,会淘汰 NAT 表中的对应项,造成链路中断。NAT超时是影响TCP连接寿命的一个重要因素(尤其是国内),所以客户端自动测算NAT超时时间,来动态调整心跳间隔,是一个很重要的优化点。
- 维护移动网GGSN(网关GPRS支持节点)
- 参考微信的一套自适应心跳算法:
- 精简心跳包,保证一个心跳包大小在10字节之内, 根据APP前后台状态调整心跳包间隔 (主要是安卓)
技术点四之: 断线重连策略
掉线后,根据不同的状态需要选择不同的重连间隔。如果是本地网络出错,并不需要定时去重连,这时只需要监听网络状态,等到网络恢复后重连即可。如果网络变化非常频繁,特别是 App 处在后台运行时,对于重连也可以加上一定的频率控制,在保证一定消息实时性的同时,避免造成过多的电量消耗。
- 断线重连的最短间隔时间按单位秒(s)以4、8、16...(最大不超过30)数列执行,以避免频繁的断线重连,从而减轻服务器负担。当服务端收到正确的包时,此策略重置
- 有网络但连接失败的情况下,按单位秒(s)以间隔时间为2、2、4、4、8、8、16、16...(最大不超过120)的数列不断重试
- 为了防止雪崩效应的出现,我们在检测到socket失效(服务器异常),并不是立马进行重连,而是让客户端随机Sleep一段时间(或者上述其他策略)再去连接服务端,这样就可以使不同的客户端在服务端重启的时候不会同时去连接,从而造成雪崩效应。
典型IM业务场景流程
- 用户A发送消息给用户B
- A 通过账号密码获取token.
- A 拿着token进行login
- 服务端缓存用户信息并维持登录状态
- A 打包数据发送给服务端
- 服务端检测A用户是否风险用户
- 服务端对消息进行敏感词检查(这个重要)
- 服务端生成msgid
- 服务端进行好友检测(A/B)
- 服务端进行重复发送检测
- 服务端获取B的连接信息,并判断在线状态
- 如果在线,直接发送给B,并入cache和db
- 如果不在线,直接存储.如果是ios,则进行apns.
- 在线的B,收到消息后回应ack进行确认.
- 用户A发送消息到群C
存储结构
未读索引列表
- 未读消息索引存在的意义在于保证消息的可靠性以及作为离线用户获取未读消息列表的一个索引结构。
- 未读消息索引由两部分构成,都存在redis中:
- 记录用户每个好友的未读数的hash结构
- 每个好友对应一个zset结构,里面存着所有未读消息的id。
- 假设A有三个好友B,C,D。A离线。B给A发了1条消息,C给A发了2条消息,D给A发了3条消息,那么此时A的未读索引结构为:
- hash结构
- B-1
- C-2
- D-3
- zset结构
User | MsgId 1 | MsgId 2 | MsgId 3 |
---|---|---|---|
B | 1 | - | - |
C | 4 | 7 | - |
D | 8 | 9 | 10 |
- 消息上行以及队列更新未读消息索引是指,hash结构对应的field加1,然后将消息id追加到相应好友的zset结构中。
- 接收ack维护未读消息索引则相反,hash结构对应的field减1,然后将消息id从相应好友中的zset结构中删除。
消息下行(未读消息的获取)
该流程用户在离线状态的未读消息获取。
该流程主要由sessions/recent接口提供服务。流程如下:
- hgetall读取未读消息索引中的hash结构。
- 遍历hash结构,若未读数不为0,则读取相应好友的zset结构,取出未读消息id列表。
- 通过消息id列表到缓存(或穿透到数据库)读取消息内容,下发给客户端。
和在线的流程相同,离线客户端读取了未读消息后也要发送接收ack到业务端,告诉它未读消息已经下发成功,业务端负责维护该用户的未读消息索引。
和在线流程不同的是,这个接收ack是通过调用messages/lastAccessedId接口来实现的。客户端需要传一个hash结构到服务端,key为通过sessions/recent接口下发的好友id,value为sessions/recent接口的未读消息列表中对应好友的最大一条消息id。
服务端收到这个hash结构后,遍历它
- 清空相应缓存
- 通过zremrangebyscore操作清空相应好友的zset结构
- 将未读消息索引中的hash结构减掉zremrangebyscore的返回值
这样就完成了离线流程中未读消息索引的维护。
队列处理流程
- 如果消息标记为offline,则将消息入库,写缓存(只有离线消息才写缓存),更新未读消息索引,然后调用apns进行推送。
- 如果消息标记为online,则直接将消息入库即可,因为B已经收到这条消息。
- 如果消息标记为redeliver,则将消息写入缓存,然后调用apns进行推送。
讨论后的疑问
把连接层Access拆一层connd server出来的考量和目的,到底有没有必要?
- 拆分出来的目的:
- 连接层更稳定
- 减少重启,方便Access服务升级
- 真的能够起到这样的效果么?
- 拆分出来的connd server 还是有可能会需要重启的, 这时候怎么办呢 ?关键性问题还是没有解决
- 加一层服务,是打算通过共享内存的方式,connd 只管理连接。access 更新升级的时候,用户不会掉线。
- 目前Access服务不重, 拆分出来真有必要吗?
- 真要拆分, 那也不是这么拆分, 是在Oracle上做拆分, 类似微服务的那种概念
- 稳定性不是这么体现,原来 connd 的设计,更薄不承担业务,而现在的 access 还是有一些业务逻辑,那么它升级的可能性就比较高。
- access 拆分,目的就是让保持连接的那一层足够薄,薄到怎么改业务,它都不用升级代码(tcp 不会断)。
- 连接层更稳定 - - - 需要有硬性指标来判断才能确定更稳定,因为Access的服务不重,目前也不是瓶颈点.
- 减少重启,方便Access服务升级 - - - 不能通过增加一层服务来实现重启升级,需要有其他机制来确保服务端进行升级而不影响TCP长连接上的用户
- 增加一个服务,就多了一条链路, 就可能会导致服务链路过长,请求经过更多的服务,会导致服务更加不可用. 因为要保证每个服务的可用性都到99.999%(5个9)是很难的,增加一个服务,就会降低整个服务的可用性.
- 架构改进一定要有数据支撑, 要确实起到效果, 要有数据输出才能证明这个改进是有效果的,要不然花了二个月时间做改进,结果没有用,浪费人力和时间,还降低开发效率
- 每个阶段的架构可能都不一样,根据当前阶段的用户量和热度来决定
怎么保证接入层服务重启升级? 服务扩/缩容?
- 方案: 增加一条信令交互,服务端如果要重启/缩容, 告知连接在此Access上的所有客户端,服务端要升级了,客户端需要重连其他节点
- 这其实是属于一种主动迁移的策略,这样客户端虽然还是有重连,比我们直接断连接会好一些.
- 等确定当前Access节点上的所有客户端都连接到其他节点后, 当前Access节点再进行重启/下线/缩容.
- 怎么扩容? 如果需要扩容,则增加新的节点后,通过etcd进行服务发现注册.客户端通过router server请求数据后,拉取到相关节点.
- 如果当前3个节点扛不住了,增加2个节点, 这个时候,要能够马上缓解当前3个节点压力,需要怎么做?
- 服务端发送命令给当前节点上的客户端,让客户端连接到新增节点上.
- 服务端还需要确定是否有部分连接到其他节点了,然后再有相应的策略.
- 按照之前的方式,客户端重新登录请求router server,然后再进行连接的话,这是不能够马上缓解压力的,因为新增的节点后, 当前压力还是在之前几个节点
- 所以, 服务端需要有更好的机制,来由服务端控制
怎么防止攻击
- 线上机器都有防火墙策略(包括硬件防火墙/软件防火墙)
- 硬件防火墙: 硬件防火墙设备,很贵,目前有采购,但是用的少
- 软件防火墙: 软件层面上的如iptable, 设置iptable的防火墙策略
- TCP 通道层面上
- 要能够发送消息, 必须要先登录
- 要登录, 必须有token,有秘钥
- 收发消息也可以设置频率控制
- 目前设置的是独立ip建连速度超过100/s,则认为被攻击了,封禁此ip
- socket建连速度的频率控制, 不能让别人一直建立socket连接,要不然socket很容易就爆满了,撑不住了
- 收发消息频率控制, 不能让别人一直能够发送消息,要不然整个服务就挂掉了
目前市面上的开源/通用协议的比较选型
- 为啥xmpp不适合,仅仅是因为xml数据量大吗 ?
- 目前也有方案是针对xmpp进行优化处理的. 因此流量大并不是主要缺点
- 还有一点就是消息不可靠,它的请求及应答机制也是主要为稳定长连网络环境所设计,对于带宽偏窄及长连不稳定的移动网络并不是特别优化
- 因此设计成支持多终端状态的XMPP在移动领域并不是擅长之地
- 为啥mqtt不适合? 为啥xxx项目没有用mqtt ?
- mqtt 适合推送,不适合IM, 需要业务层面上额外多做处理, 目前已经开始再用
- xxx项目不用mqtt是历史遗留问题,因为刚开始要迅速开展,迅速搭建架构实现,因此用来蘑菇街的teamtalk.
- 如果后续选型的话, 如果没有历史遗留问题,那么就会选择使用mqtt
- 除了数据量大, 还要考虑协议的复杂度, 客户端和服务端处理协议的复杂度?
- 协议要考虑容易扩展, 方便后续新增字段, 支持多平台
- 要考虑客户端和服务端的实现是否简单
- 编解码的效率
跨机房, 多机房容灾
- 服务需要能够跨机房,尤其是有状态的节点.
- 需要储备多机房容灾,防止整个机房挂掉.
刚讨论说到接入层有哪些功能的:
- 维持TCP长连接,包括心跳/超时检测
- 收包解包
- 防攻击机制
- 等待接收消息回应(这个之前没有说到,就是把消息发送给接收方后还需要接收方回应)
思考点(考核关键点)
- 消息为什么可能会乱序? 怎么保证消息不乱序?
- 考虑离线
- 考虑网络异常
- 对于离线消息,存储方式/存储结构要怎么设计?
- 考虑会有多个人发送消息
- 考虑缓存 db的方式
- 如何保证消息不丢,不重? 怎么设计消息防丢失机制?
- 考虑同一账号可能会多终端登录
- 考虑弱网环境下,ACK也可能会丢失
- 对于长连接, 怎管理这些长连接?
- 后端数据来了, 怎么快速找到这个请求对应的连接呢
- 考虑快速查找
- 接入层节点有多个,而且是有状态的.通过什么机制保证从节点1下发的请求,其对应的响应还是会回到节点1呢?
- 或者说如果响应不回到节点1,而是回到节点2了会有什么弊端?