本文做者潘唐磊,腾讯WXG(微信事业群)开发工程师,毕业于中山大学。内容有修订。html
本文总结了企业微信的IM消息系统架构设计,阐述了企业业务给IM架构设计带来的技术难点和挑战,以及技术方案的对比与分析。同时总结了IM后台开发的一些经常使用手段,适用于IM消息系统。前端
* 推荐阅读: 企业微信团队分享的另外一篇《企业微信客户端中组织架构数据的同步更新方案优化实战》也值得一读。后端
如下是本文内容中涉及到的技术名词缩写,具体意义以下:api
企业微信做为一款办公协同的产品,聊天消息收发是最基础的功能。消息系统的稳定性、可靠性、安全性尤为重要。安全
消息系统的构建与设计的过程当中,面临着较多的难点。并且针对toB场景的消息系统,须要支持更为复杂的业务场景。微信
针对toB场景的特有业务有:markdown
如上所示,总体架构分层以下。网络
1)接入层: 统一入口,接收客户端的请求,根据类型转发到对应的CGI层。客户端能够经过长连或者短连链接wwproxy。活跃的客户端,优先用长链接发起请求,若是长连失败,则选用短连重试。session
2)CGI层: http服务,接收wwproxy的数据包,校验用户的session状态,并用后台派发的秘钥去解包,如解密失败则拒绝请求。解密成功,则把明文包体转发到后端逻辑层对应的svr。架构
3)逻辑层: 大量的微服务和异步处理服务,使用自研的hikit rpc框架,svr之间使用tcp短连进行通讯。进行数据整合和逻辑处理。和外部系统的通讯,经过http协议,包括微信互通、手机厂商的推送平台等。
4)存储层: 消息存储是采用的是基于levelDB模型开发msgkv。SeqSvr是序列号生成器,保证派发的seq单调递增不回退,用于消息的收发协议。
企业微信的消息收发模型采用了推拉方式,这种方式可靠性高,设计简单。
如下是消息推拉的时序图:
PS: 如上图所示,发送方请求后台,把消息写入到接收方的存储,而后push通知接收方。接受方收到push,主动上来后台收消息。
不重、不丢、及时触达,这三个是消息系统的核心指标:
IM中消息分发的典型方式,通常有两种:
即: 每条消息只存一份,群聊成员都读取同一份数据。
优势: 节省存储容量。
缺点:
即: 每条消息存多份,每一个群聊成员在本身的存储都有一份。
优势:
同一条消息,在每一个人的视角会有不一样的表现。例如:回执消息,发送方能看到已读未读列表,接受方只能看到是否已读的状态。云端删除某条群消息,在本身的消息列表消失,其余人仍是可见。
缺点: 存储容量的增长。
企业微信采用了扩散写的方式,消息收发简单稳定。存储容量的增长,能够经过冷热分离的方案解决,冷数据存到廉价的SATA盘,扩散读体验稍差,协议设计也相对复杂些。
下图是扩散写的协议设计:
如上图所示:
企业微信做为一款to B场景的聊天im工具,用于工做场景的沟通,有着较为明显的高峰效应(以下图所示)。
正如上图所示: 工做时间上午9:0012:00、下午14:0018:00,是聊天的高峰,消息量剧增。工做日和节假日也会造成明显的对比。
高峰期系统压力大,偶发的网络波动或者机器过载,都有可能致使大量的系统失败。im系统对及时性要求比较高,没办法进行削峰处理。那么引入一些柔性的策略,保证系统的稳定性和可用性很是有必要。
具体的作法就是启动过载保护策略:当svr已经达到最大处理能力的时候,说明处于一个过载的状态,服务能力会随着负载的增高而急剧降低。若是svr过载,则拒绝掉部分正常请求,防止机器被压垮,依然能对外服务。经过统计svr的被调耗时状况、worker使用状况等,断定是否处于过载状态。过载保护策略在请求高峰期间起到了保护系统的做用,防止雪崩效应。
下图就是因过载被拒绝掉的请求:
上一小结中过载保护策略所带来的问题就是:系统过载返回失败,前端发消息显示失败,显示红点,会严重影响产品体验。
发消息是im系统的最基础的功能,可用性要求达到几乎100%,因此这个策略确定须要优化。
**解决方案思路就是:**尽管失败,也返回前端成功,后台保证最终成功。
为了保证消息系统的可用性,规避高峰期系统出现过载失败致使前端出红点,作了不少优化。
具体策略以下:
1)逻辑层hold住失败请求,返回前端成功,不出红点,后端异步重试,直至成功;
2)为了防止在系统出现大面积故障的时候,重试请求压满队列,只hold住半小时的失败请求,半小时后新来的请求则直接返回前端失败;
3)为了不重试加重系统过载,指数时间延迟重试;
4)复杂的消息鉴权(好友关系,企业关系,集团关系,圈子关系),耗时严重,后台波动容易形成失败。若是并不是明确鉴权不经过,则幂等重试;
5)为了防止做恶请求,限制单个用户和单个企业的请求并发数。例如,单个用户的消耗worker数超过20%,则直接丢弃该用户的请求,不重试。
优化后,后台的波动,前端基本没有感知。
如下是优化先后的流程对比:
因为产品形态的缘由,企业微信的消息系统,会依赖不少外部模块,甚至外部系统。
例如: 与微信消息互通,发送消息的权限须要放到ImUnion去作断定,ImUnion是一个外部系统,调用耗时较长。
再如: 金融版的消息审计功能,须要把消息同步到审计模块,增长rpc调用。
再如: 客户服务的单聊群聊消息,须要把消息同步到crm模块,增长rpc调用。为了不外部系统或者外部模块出现故障,拖累消息系统,致使耗时增长,则须要系统解耦。
咱们的方案: 与外部系统的交互,全设计成异步化。
思考点: 须要同步返回结果的请求,如何设计成异步化?
例如: 群聊互通消息需通过ImUnion鉴权返回结果,前端用于展现消息是否成功发送。先让客户端成功,异步失败,则回调客户端使得出红点。
若是是非主流程,则异步重试保证成功,主流程不受影响,如消息审计同步功能。那么,只须要保证内部系统的稳定,发消息的主流程就能够不受影响。
解耦效果图:
企业微信的消息类型有多种:
群聊按群人数,又分红3类:
业务繁多: 若是不加以隔离,那么其中一个业务的波动有可能引发整个消息系统的瘫痪。
重中之重: 须要保证核心链路的稳定,就是企业内部的单聊和100人如下群聊,由于这个业务是最基础的,也是最敏感的,稍有问题,投诉量巨大。
其他的业务: 互相隔离,减小牵连。按照优先级和重要程度进行隔离,对应的并发度也作了调整,尽可能保证核心链路的稳定性。
解耦和隔离的效果图:
企业微信的群人数上限是10000,只要群内每一个人都发一条消息,那么扩散量就是10000 * 10000 = 1亿次调用,很是巨大。10000人投递完成须要的耗时长,影响了消息的及时性。
既然超大群扩散写量大、耗时长,那么天然会想到:超大群是否能够单独拎出来作成扩散读呢。
下面分析一下超大群设计成单副本面临的难点:
综上所述: 单副本的方案代价太大。
如下将介绍咱们针对万人群聊扩散写的方案,作的一些优化实践。
万人群的扩散量大,为了是消息尽量及时到达,使用了多协程去分发消息。可是并非无限制地加大并发度。
为了不某个万人群的高频发消息,形成对整个消息系统的压力,消息分发以群id为维度,限制了单个群的分发并发度。消息分发给一我的的耗时是8ms,那么万人的整体耗时是80s,并发上限是5,那么消息分发完成须要16s。16s的耗时,在产品角度来看还、是能够接受的,大群对及时性不敏感。同时,并发度控制在合理范围内。
除了限制单个群id的并发度,还限制了万人群的整体并发度。单台机,小群的worker数为250个,万人群的worker数为30。
万人群的频繁发消息,worker数用满,致使队列出现积压:
因为并发限制,调用数被压平,没有请求无限上涨,系统稳定:
工做场景的聊天,多数是在小群完成,大群用于管理员发通知或者老板发红包。
大群消息有一个常见的规律: 平时消息少,会忽然活跃。例如:老板在群里发个大红包,群成员起哄,此时就会产生大量的消息。
消息量上涨、并发度被限制、任务处理不过来,那么队列天然就会积压。积压的任务中可能存在多条消息须要分发给同一个群的群成员。
此时: 能够将这些消息,合并成一个请求,写入到消息存储,消息系统的吞吐量就能够成倍增长。
在平常的监控中,能够捕获到这种场景,高峰能够同时插入20条消息,对整个系统很友善。
好比: 群人员变动、群名称变更、群设置变动,都会在群内扩散一条不可见的控制消息。群成员收到此控制消息,则向后台请求同步新数据。
举个例子: 一个万人群,因为消息过于频繁,对群成员形成骚扰,部分群成员选择退群来拒绝消息,假设有1000人选择退群。那么扩散的控制消息量就是1000w,用户收到控制消息就向后台请求数据,则额外带来1000w次的数据请求,形成系统的巨大压力。
控制消息在小群是颇有必要的,能让群成员实时感知群信息的变动。
可是在大群: 群信息的变动其实不那么实时,用户也感受不到。因此结合业务场景,实施降级服务,控制消息在大群能够直接丢弃、不分发,减小对系统的调用。
回执消息是办公场景常常用到的一个功能,能看到消息接受方的阅读状态。
一条回执消息的阅读状态会被频繁修改,群消息被修改的次数和群成员人数成正比。天天上亿条消息,读写频繁,请求量巨大,怎么保证每条消息在接受双方的状态是一致的是一个难点。
消息的阅读状态的存储方式两个方案。
方案一:
思路: 利用消息存储,插入一条新消息指向旧消息,此新消息有最新的阅读状态。客户端收到新消息,则用新消息的内容替换旧消息的内容展现,以达到展现阅读状态的效果。
优势: 复用消息通道,增量同步消息就能够获取到回执状态,复用通知机制和收发协议,先后端改造小。
缺点:
方案二:
思路: 独立存储每条消息的阅读状态,消息发送者经过消息id去拉取数据。
优势: 状态一致。
缺点:
企业微信采用了方案一去实现,简单可靠、改动较小: 存储冗余的问题能够经过LevelDB落盘的时候merge数据,只保留最终状态那条消息便可;一致性问题下面会介绍如何解决。
上图是协议流程 (referid:被指向的消息id,senderid:消息发送方的msgid):
1)每条消息都有一个惟一的msgid,只在单个用户内惟一,kv存储自动生成的;
2)接收方b已读消息,客户端带上msgid=b1请求到后台;
3)在接受方b新增一条消息,msgid=b2,referid=b1,指向msgid=b1的消息。并把msgid=b2的消息内容设置为消息已读。msgid=b1的消息体,存有发送方的msgid,即senderid=a1;
4)发送方a,读出msgid=a1的消息体,把b加入到已读列表,把新的已读列表保存到消息体中,生成新消息msgid=a2,referid=a1,追加写入到a的消息流;
5)接收方c已读同一条消息,在c的消息流走一样的逻辑;
6)发送方a,读出msgid=a1的消息体,把c加入到已读列表,把新的已读列表保存到消息体中,生成新消息msgid=a3,referid=a1,追加写入到a的消息流。a3>a2,以msgid大的a3为最终状态。
接受方已读消息,让客户端同步感知成功,可是发送方的状态不必同步修改。由于发送方的状态修改状况,接受方没有感知不到。那么,能够采用异步化的策略,下降同步调用耗时。
具体作法是:
客户端收到大量消息,并非一条一条消息已读确认,而是多条消息一块儿已读确认。为了提升回执消息的处理效率,能够对多条消息合并处理。
如上图所示:
1)X>>A:表示X发了一条消息给A;
2)A合并确认3条消息,B合并确认3条消息。那么只须要处理2次,就能标志6条消息已读;
3)通过mq分发,相同的发送方也能够合并处理。在发送方,X合并处理2条消息,Y合并处理2条消息,Z合并处理2条消息,则合并处理3次就能标志6条消息。
通过合并处理,处理效率大大提升。下图是采集了线上高峰时期的调用数据。能够看得出来,优化后的效果一共节省了44%的写入量。
发送方的消息处理方式是先把数据读起来,修改后从新覆盖写入存储。接收方有多个,那么就会并发写发送方数据,避免不了出现覆盖写的问题。
流程以下:
处理这类问题,无非就一下几种办法。
方案一: 由于并发操做是分布式,那么能够采用分布式锁的方式保证一致。操做存储以前,先申请分布式锁。这种方案过重,不适合这种高频多帐号的场景。
方案二: 带版本号读写。一个帐号的消息流只有一个版本锁,高频写入的场景,很容易产生版本冲突,致使写入效率低下。
方案三: mq串行化处理。能避免覆盖写问题,关键是在合并场景起到很好的做用。同一个帐号的请求串行化,就算出现队列积压,合并的策略也能提升处理效率。
企业微信采用了方案三,相同id的用户请求串行化处理,简单易行,逻辑改动较少。
“撤回消息” 至关于更新原消息的状态,是否是也能够经过referid的方式去指向呢?
回执消息分析过: 经过referid指向,必需要知道原消息的msgid。
区别于回执消息: 撤回消息须要修改全部接收方的消息状态,而不只仅是发送方和单个接收方的。消息扩散写到每一个接收方的消息流,各自的消息流对应的msgid是不相同的,若是沿用referid的方式,那就须要记录全部接收方的msgid。
分析: 撤回消息比回执消息简单的是,撤回消息只须要更新消息的状态,而不须要知道原消息的内容。接收方的消息的appinfo都是相同的,能够经过appinfo去作指向。
协议流程:
1)用户a、b、c,都存在同一条消息,appinfo=s,sendtime=t;
2)a撤回该消息,则在a的消息流插入一条撤回的控制消息,消息体包含{appinfo=s,sendtime=t};
3)客户端sync到撤回的控制消息,获取到消息体的appinfo与sendtime,把本地appinfo=s且sendtime=t的原消息显示为撤回状态,并删除原消息数据。之因此引入sendtime字段,是为了防止appinfo碰撞,加的双重校验;
4)接收方撤回流程和发送方一致,也是经过插入撤回的控制消息。
该方案的优势明显,可靠性高,协议简单。
撤回消息的逻辑示意图:
企业微信的IM消息架构与微信相似,可是在to B业务场景面临了一些新的挑战。结合产品形态、分析策略,经过优化方案,来确保消息系统的可靠性、稳定性、安全性。
企业微信的to B业务繁杂,有不少定制化的需求,消息系统的设计须要考虑通用性和扩展性,以便支持各类需求。例如:撤回消息的方案,能够适用于消息任何属性的更新,知足更多场景。(本文同步发布于:www.52im.net/thread-3631… )