IM系统技术挑战
可靠性
IM消息系统的可靠性,通常就是指消息投递的可靠性,即我们经常听到的“消息必达”,通常用消息的不丢失和不重复两个技术指标来表示。确保消息被发送后,能被接收者收到。由于网络环境的复杂性,以及用户在线的不确定性,消息的可靠性(不丢失、不重复)无疑是IM系统的核心指标,也是IM系统实现中的难点之一。总体来说,IM系统的消息“可靠性”,通常就是指聊天消息投递的可靠性(准确的说,这个“消息”是广义的,因为还存用户看不见的各种指令和通知,包括但不限于进群退群通知、好友添加通知等,为了方便描述,统称“消息”)。
从消息发送者和接收者用户行为来讲,消息“可靠性”应该分为以下几种情况:
(1)发送失败,对于这种情况IM系统必须要感知到,明确反馈发送方。如果此消息没有发送成功,发送方可以选择重试或者稍后再试。
(2)发送成功,如果接收方处在“在线”状态,应该立即收到此消息。如果接收方处在“离线”状态不能收到消息,一旦上线则立刻收到消息。
(3)消息不能重复,用数学术语表示:“有且仅有这条消息”,如果重复了,可能表达的意思就变了。总之,一个商用 IM系统,必须包含消息“可靠性”逻辑,才能谈基本可用,这是IM系统最基本也是最核心的逻辑。
有序性(一致性)
IM系统中,特别需要考虑消息时序问题,如果后发送的消息先显示,可能严重扰乱聊天消息所要表达的意义,会造成聊天语义不连贯,引起误会。消息的时序性,也称为消息收发一致性,主要目标是:保证聊天消息的绝对时序。IM系统中消息时序的一致性问题看似简单,实则是非常有难度的技术热点话题之一。为什么会出现时序问题 1、分布式系统的出现导致时序不一致。IM系统模块众多,接入层、消息逻辑层等、每层都分布式集群化,这些应用分布在不同的机器上,如何保证时序是个难点。2、网络传输延迟导致时序不一致。不同用户发送的消息到达服务器的延时差异较大,给消息时序性带来挑战。
消息时序是分布式系统架构设计中非常难的问题,一个分布式的IM系统必须要解决这个问题,如何高效、低成本解决这个问题,是我们OpenIM要考虑的方向。
实时性
实时性,即消息实时到达接收方,如果用户在线,则实时可达,如果用户不在线,则登录时可达。由于网络波动,以及移动端操作系统对应用前后台切换的管理,如何实现用户连接管理、消息实时推送,推送失败的处理方式,客户端重连机制,消息如何补齐等,都是需要IM系统考虑,同时要结合移动端的特点,兼顾耗电量,网络,性能等。由于TCP开发略微复杂,早期的基于HTTP短轮询、长轮询的低效的技术方案,也无法达到实时性的要求。
扩展性
一般来说互联网系统的扩展性包含多个含义,我们侧重讲解关于IM消息的扩展性。IM业务特性多,功能丰富,从聊天类型来看,分为:单聊、群聊,聊天室等;从消息类型来看,分为:文本、图片、视频、地理位置、自定义消息等;从消息功能来看,分为:撤回、在线状态、对方正在输入、阅后即焚等;从通知角度来看,分为:进群、退群、添加好友、验证好友等各种通知。如何有效支撑、扩展功能,高效实现,是考验IM扩展性的一个方面,也是对系统架构设计能力的考验。为了更好地提高数据通道对业务支撑的扩展性,我们首创了“一切皆消息”的消息模型,即通讯双方产生的所有消息、通知,服务端以消息统一处理,扮演了消息通道的角色,客户端针对不同消息类型做不同的UI展示,完美解决了扩展性问题。
IM系统术语以及本文档专有名词解释
conversationId:会话Id,会话是指用户和用户之间,以及用户和群之间,进行通讯后产生的关联。
userId:用户Id:注册使用IM的用户Id,从消息的发送和接收来看有两个身份:发送者和接收者
sendId:消息发送者Id
receiverId :消息接收者Id
msg:消息是指用户之间的沟通内容,一般指用户主动产生的。同时也包括用户看不见的各种指令和通知,包括但不限于进群退群通知、好友添加通知等
inbox:用户收件箱,给某人发送消息,实际上是往接收者“信箱”写入消息,这个信箱就是收件箱
seq:用户收件箱中消息序列号,分为local seq,和server seq,前者表示app本地消息seq,后者表示服务端消息seq,seq是连续且递增的。
conn:登录用户的连接信息,用于消息推送;
MQ:消息队列,一般用来解决应用解耦,异步消息,流量削峰等问题,实现高性能,高可用,可伸缩和最终一致性架构,本文采用kafka组件。
OpenIM的诞生
随着移动互联网的蓬勃发展, IM 作为一种通讯能力,已经成为互联网上的基础设施,也是许多 APP 不可或缺的功能。如何让每一个应用都具备IM功能,同时考虑企业的接入成本、服务器资源以及最重要的数据安全性和私密性。本人从微信离职后,创办了开源OpenIM,是全球首家100%开源、免费项目,并提供IMSDK,覆盖所有主流开发平台,iOS、Android、Flutter、react native、Windows、Linux、Unity、web、小程序等。
开源IM现状
github 上 IM 开源项目不少,但开发者却难以使用,主要有几点原因(1)个人项目居多,但近几年都无人维护,遇到问题无人解决,企业商业化产品不敢冒险使用(2)大部分项目不是 IM 技术专业团队完成的,技术实力和技术架构存疑,也没有经过大项目和海量用户检验;(3)只开源服务端或者客户端,只开源某一端,需要开发者实现另外一端,研发成本同样不小,另外,开源项目大部分都是以聊天app形式开源,开发者如何把 IM 集成到自身 app 中,同样存在大量的修改和适配成本。(4)部分项目打着开源的旗号,社区版免费,但核心功能缺失,商业版收费。
云服务商的弊端
IM 云服务商提供 IM SDK 和 API ,让开发者简单集成 IM 功能,当然这里也存在明显的问题(1)成本问题:企业每年额外支付上万乃至数十万的云服务费用,从长期来看是个不小的成本;(2)数据隐私问题:企业的用户数据、聊天记录等核心数据托管在 IM 云服务商,如何保证客户的数据隐私和安全性;(3)需求定制问题:IM 需求多样化,IM 功能只能由 IM 云服务商通过 SDK 的形式提供给大家使用,开发受限,所有功能都需要封装成接口;(4)捆绑问题:一旦使用 IM 云服务,形成捆绑关系,迁移成本高,受制于人。
自研的尴尬
IM 是一个看起来门槛很低的项目,网上有很多所谓的 IM 开发教程,甚至很多毕业设计也是做一个 IM 系统。由于这个误区的存在,很多企业盲目乐观组建 3-5 人的 IM 团队,历时一年半载,最后只完成了一个 demo 版本。由于架构设计不合理,demo 版本存在消息丢失、系统异常等 bug,无法达到商用的要求。IM系统除了面临互联网业务系统本身的挑战,还存在上文分析的可靠性、时序性、扩展性等问题,所以,自研IM,对于中小企业来说,可能是最糟糕的选择。
OpenIM的整体架构
OpenIM分为两大块
(一)Open-IM-SDK-Core 采用golang实现客户端逻辑,主要负责本地db存储及更新;断网重连及管理;消息及各种通知回调。本地消息、会话等数据存储,通过通知机制完成本地数据实时同步,同时兼顾客户端缓存的作用,有效缓解了服务端压力。另外,golang跨平台的特性,使得各移动平台都能无缝调用,开发者只需根据产品需求编写UI界面,通过回调机制和SDK完成数据交互和通知。
(二)Open-IM-Server 由接入层、逻辑层和存储层组成,好处在于各层能够依据业务特点专注于自己的事情,提高系统复用性,降低业务间的耦合。
(1)接入层:消息通过 websocket 协议接入,其他业务通过 http/https 协议提供REST API实现。消息是高频及核心功能,通过双协议路由,体现了轻重分离的设计思想。
(2)逻辑层:通过 rpc 实现无状态逻辑服务,易于平行扩展,模块通过 MQ 解耦。
(3)存储层:redis 存储 token 和 seq;mongodb 存储离线消息,并定时删除 14 天内(可自行配置)数据;mysql 存储全量历史消息以及用户相关资料。数据分层存储,充分利用不同存储组件的特性。
(4)Etcd:服务注册和发现、以及分布式配置中心。
消息网关msg_gateway
消息接入层,采用websocket协议接入,import gorilla具体实现,服务模块无状态,柔性伸缩,运维简单。通过MQ让业务模块之间解耦,消息写入MQ即表示发送成功。
(1)负责用户连接管理,保持长连接,存储uid->conn映射关系;
(2)负责消息接收落地,成功写入MQ后给客户端返回成功;
(3)负责把消息推送给在线状态的接收者;
下图是客户端发送消息流程
消息转发msg_transfer
消息处理rpc,作为消费者从MQ中消费(读取)消息,递增接收者收件箱seq,关联seq和msg,并存储到mongodb。全量历史消息无收件箱概念,消息作为流水记录落地mysql即可,两者通过协程独立处理,双方互不影响。msg作为无状态服务节点,如果消息量增加,可以启动冗余节点服务,加快消息处理流程。
(1)负责消费MQ中的消息,作为消费者,实时感知新信息达到,并触发回调逻辑;
(2)生成msgId作为全局消息Id;
(3)读取receiver userId,并通过redis的incr操作递增服务端对应的seq;
(4)关联seq和msgid,并存入以receiver userid为key的mongodb中,作为离线消息,一般在14天后会删除;
(5)同时,把消息作为历史记录存入mysql中,作为消息备份,或其他用途。
(4)和(5)是两个独立的协程并行执行的,mysql写入快慢不会影响mongodb的写入,这样既完成了冷热数据分离,也充分利用了机器资源。
下图是消息处理入库流程
消息推送push
msg_transfer完成存储消息到后,向push发起消息推送任务,msg_gateway查询本地userId->conn表,如果用户在线则推送给接收者,对于msg_gateway的推送架构设计,做成了“半状态”服务,即在节点本地存储了用户连接信息,作为局部信息,没有通过redis全局共享。push推送消息时,向所有msg_gateway发送推送请求,带来一定的“惊群效应”,由于msg_gateway节点不多,所以影响有限,带来的好处则是在不影响性能的前提下,msg_gateway设计和实现简单,运维也更简单。
(1)msg_transfer把消息写入mongodb后,发送push消息推送请求;
(2)push提供rpc推送服务,通过etcd找到所有注册的msg_gateway,并发送推送请求;
(3)msg_gateway从本节点内存中查询userId->conn,如果找到conn,则向客户端推送消息;
(4)如果消息接收者不在线,msg_gateway无法推送消息,但客户端网络重连时会及时同步历史消息,进行消息补齐;
下图是消息实时推送流程:
消息同步及对齐seq
由于网络的波动以及负责的网络环境,导致消息推送存在不确定性。OpenIM采用local seq和server seq消息对齐,同时结合拉取和推送的方式,简单高效地解决了消息的可靠性问题。这里分两种场景进行表述:
(1)客户端接收推送消息时,比如客户端收到推送消息的seq为100,如果local seq为99,因为seq递增且连续,所以消息正常显示即可。如果local seq大于100,说明重复推送了消息,抛弃此消息即可。如果local seq小于99,说明中间有历史消息丢失,拉取(local seq+1, 100)的消息,进行补齐即可;
(2)用户在登录、或者断网重连时,客户端会从服务端拉取最大seq(max seq),读取客户端本地seq(local seq),如果local seq 小于 max seq,说明存在历史消息未同步的情况,调用接口同步自身收件箱[local seq+1, max seq]的数据完成消息对齐。
下图是消息同步流程图
本文主要简单阐述了OpenIM的架构以及消息流程,让开发者对其有初步认识,在接下来的文章中,我们会详细讲解OpenIM服务端消息架构,OpenIM客户端架构,同时会详细分析OpenIM如何简单高效解决消息的可靠性、实时性、一致性和扩展性问题。
更多阅读
基于Tablestore Timeline的IM(即时通讯)消息系统架构 - 架构篇 OpenIM官网