百万级群聊的操持实践
作者:来自 vivo 互联网服务器团队- Cai Linfeng本文先容了服务端在搭建 Web 版的百万人级别的群聊体系时,遇到的技术挑衅和解决思路,内容包括:通信方案选型、消息存储、消息有序性、消息可靠性、未读数统计。
一、弁言
如今IM群聊产品多种多样,有国民级的微信、QQ,企业级的钉钉、飞书,还有许多公司内部的IM工具,这些都是以客户端为主要载体,而且群聊人数通常都是有限制,微信正常群人数上限是500,QQ2000人,收费能到达3000人,这里固然有产品考量,但技术成本、资源成本也是很大的因素。而笔者业务场景上必要一个迭代更新快、轻量级(不依赖客户端)、单群百万群成员的纯H5的IM产品,本文将回顾实现一个百万人量级的群聊,服务器侧必要思量的操持要点,希望可以给到读者一些开导。
二、背景先容
不同的群聊产品,接纳的技术方案是不同的,为了理解接下来的技术选型,必要先相识下这群聊产品的特性。
[*]单群成员必要支撑百万人,同时在线百万级。
[*]功能、体验要接近纯客户端实现方案。
[*]用户端完全用H5承载。
三、通信技术
即时通信常见的通信技术有短轮询、长轮询、Server-Sent Events(SSE)、Websocket。短轮询和长轮询适用于及时性要求不高的场景,比如论坛的消息提醒。SSE 适用于服务器向客户端单向推送的场景,如及时新闻、股票行情。Websocket 适用于及时双向通信的场景,及时性好,且服务端、前端都有比较成熟的三方包,如 socket.io,以是这块在方案选择中是比较 easy 的,前后端使用 Websocket 来实实际时通信。
四、消息存储
群聊消息的保存方式,主流有2种方式:读扩散、写扩散。图1展示了它们的区别,区别就在于消息是写一次还是写N次,以及怎样读取。
https://static001.geekbang.org/infoq/09/0973cd46db09cad4cd1dccfe5d2276b3.png
图1
读扩散就是所有群成员共用一个群信箱,当一个群产生一条消息时,只必要写入这个群的信箱即可,所有群成员从这一个信箱里读取群消息。
长处是写入逻辑简朴,存储成本低,写入效率高。缺点是读取逻辑相对复杂,要通过消息表与其他业务表数据聚合;消息定制化处理复杂,必要额外的业务表;大概还有IO热门问题。
举个例子:
很常见的场景,展示用户对消息的已读未读状态,这个时间公共群信箱就无法满足要求,必须增加消息已读未读表来记录相关状态。还有效户对某条消息的删除状态,用户可以选择删除一条消息,但是其他人仍然可以看到它,此时也不恰当在公共群信箱里拓展,也必要用到另一张关系表,总而言之针对消息做用户特定功能时就会比写扩散复杂。
写扩散就是每个群成员拥有独立的信箱,每产生一条消息,必要写入所有群成员信箱,群成员各自从本身的信箱内读取群消息。长处是读取逻辑简朴,恰当消息定制化处理,不存在IO热门问题。缺点是写入效率低,且随着群成员数增加,效率低落;存储成本大。
以是当单群成员在万级以上时,用写扩散就明显不太合适了,写入效率太低,而且大概存在许多无效写入,不活跃的群成员也必须得有信箱,存储成本黑白常大的,因此接纳读扩散是比较合适的。
据相识,微信是接纳写扩散模式,微信群设定是500人上限,写扩散的缺点影响就比较小。
五、架构操持
5.1 团体架构
先来看看群聊的架构操持图,如图2所示:
https://static001.geekbang.org/infoq/77/77718277c0cc3339d128dda89de7a552.png
图2
从用户登录到发送消息,再到群用户收到这条消息的体系流程如图3所示:
https://static001.geekbang.org/infoq/49/4992eb2f84daffbd292fd989f52c5352.png
图3
[*]用户登录,通过负载平衡,与连接服务建立 Websocket 长连接。
[*]连接服务管理会话,管理群与用户的映射关系,在当地内存里使用哈希表存储,key为groupId,value为List,同一个群的用户大概会在不同的集群服务器上。
[*]连接服务向群组服务上报群组路由,上报它的内网IP和它所管理的 groupIdList 的关系,这里必要2种同步计谋并行保证群组路由信息的正确性:a.在用户建立、断开长连接时马上上报;b.定时上报。
[*]群组服务管理群组路由,使用远程中心缓存 Redis 管理 groupId 和连接服务器IP的关系,key 为 groupId,value 为 List,该IP为连接服务的内网IP地址,这里会做上报的心跳判定,超过3个心跳周期不上报,则认为已断线。
[*]用户在群里发布一条消息,消息通过 Websokcet 送达连接服务,然后经过连接服务——>消息队列——>群组服务,消息在群组服务里经过频控、安全检查、格式转换等一系列流程后入库,长期化。
[*]群组服务通过群组路由管理获取这条消息所属群的路由信息,即一组连接服务的IP地址,然后通过 HTTP 回调对应的连接服务,通知它们有新消息产生,这里只简朴通报消息ID。
[*]连接服务收到 HTTP 请求后,根据会话管理查询该群所有效户,给用户发送新消息提醒。
[*]用户收到新消息提醒,通过 Websocket 来连接服务拉取该新消息具体详情,然后根据消息协议展示在信息流里。
5.2 路由计谋
用户应该连接到哪一台连接服务呢?这个过程重点思量如下2个问题:
[*]只管保证各个节点的连接平衡;
[*]增删节点是否要做 Rebalance。
保证平衡有如下几个算法:
[*]轮询:挨个将各个节点分配给客户端,但会出现新增节点分配不均匀的环境;
[*]取模:雷同于 HashMap,但也会出现轮询的问题。当然也可以像 HashMap 那样做一次 Rebalance,让所有的客户端重新连接。不过如许会导致所有的连接出现停止重连,代价有点大。由于 Hash 取模方式的问题带来了一致性 Hash 算法,但依然会有一部分的客户端必要 Rebalance;
[*]权重:可以手动调解各个节点的负载环境,乃至可以做成主动的,基于监控当某些节点负载较高就主动调低权重,负载较低的可以进步权重;笔者是接纳轮询 + 权重模式,只管保证负载平衡。
5.3 重连机制
当应用在扩缩容或重启升级时,在该节点上的客户端怎么处理?由于操持有心跳机制,当心跳不通或监听连接断开时,就认为该节点有问题了,就实行重新连接;如果客户端正在发送消息,那么就必要将消息临时保存住,等待重新连接上后再次发送。
5.4 线程计谋
将连接服务里的IO线程与业务线程隔离,提升团体性能,原因如下:
[*]充分利用多核的并行处理能力:IO线程和业务线程隔离,两边都可以并行处理网络IO和业务逻辑,充分利用计算机多核并行计算能力,提升性能;
[*]故障隔离:业务线程处理多种业务消息,有IO麋集型,也有 CPU 麋集型,有些是纯内存计算,不同的业务处理时延和故障率是不同的。如果把业务线程和IO线程合并,就会有如下问题:某类业务处理较慢,阻塞IO线程,导致其他处理较快的业务消息响应不及时;
[*]可维护性:IO线程和业务线程隔离之后,职责单一,有利于维护和定位问题。
5.5 有状态链接
在如许的场景中不像 HTTP 那样是无状态的,必要明白知道各个客户端和连接的关系。比如必要向客户端广播群消息时,首先得知道客户端的连接会话保存在哪个连接服务节点上,自然这里必要引入第三方中心件来存储这个关系。通过由连接服务主动上报给群组服务来实现,上报时机是客户端接入和断开连接服务以及周期性的定时任务。
5.6 群组路由
假想如许一个场景:必要给群所有成员推送一条消息怎么做?通过群编号去前面的路由 Redis 获取对应群的连接服务组,再通过 HTTP 方式调用连接服务,通过连接服务上的长连接会话进行真正的消息下发。
5.7 消息流转
连接服务直接接收用户的上行消息,思量到消息量大概非常大,在连接服务里做业务显然不合适,这里完全可以选择 Kafka 来解耦,将所有的上行消息直接丢到 Kafka 就不管了,消息由群组服务来处理。
六、消息顺序
6.1 乱序现象
为什么要讲消息顺序,来看一个场景。假设群里有效户A、用户B、用户C、用户D,下面以 ABCD 代替,假设A发送了3条消息,顺序分别是 msg1、msg2、msg3,但B、C、D看到的消息顺序不一致,如图4所示:
https://static001.geekbang.org/infoq/d3/d35e14d1fff89e5fc05bf97c862e723d.png
图4
这时B、C、D肯定会以为A在胡言乱语了,如许的产品用户必定是不喜欢的,因此必须要保证所有接收方看到的消息展示顺序是一致的。
6.2 原因分析
以是先相识下消息发送的宏观过程:
[*]发送方发送消息。
[*]服务端接收消息。
[*]服务端返回 ACK 消息。
[*]服务端推送新消息或客户端拉取新消息。
在上面的过程中,都大概产生顺序问题,简要分析几点原因:
[*]时钟不一致:多个客户端、服务端集群、DB集群,时钟不能保证完全一致,因此不能用当地时间来决定消息顺序。
[*]网络传输:发送消息环节,先发后至,到达服务器的顺序大概是 msg2、msg1、msg3。
[*]多线程:服务器思量性能、吞吐量,往往会在多处环节接纳线程池、异步去提升团体速率,因此也会产生顺序问题。
6.3 解决方案
6.3.1 单用户保持有序
通过上面的分析可以知道,实在无法保证或是无法衡量不同用户之间的消息顺序,那么只需保证同一个用户的消息是有序的,保证上下文语义,以是可以得出一个比较朴素的实现方式:以服务端数据库的唯一自增ID为标尺来衡量消息的时序,然后让同一个用户的消息处理串行化。那么就可以通过以下几个技术本领配合来解决:
[*]发送消息使用 Websocket 发送,并且多次发送保持同一个会话,那么 tcp 协议就保证了应用层收到的消息必定是有序的。
[*]在应用程序内部处理时,涉及相关多线程的模块,根据 uid 进行 hash,匹配一个单线程的线程池,即同一个 uid 的消息永远用同一个线程行止理,不同用户之间仍是并行处理。
[*]在跨应用程序时,一般有2种处理方式:一是用 rpc 同步调用;二是利用消息中心件的全局有序。
[*]用户端上做消息发送频率限制,2次发送必须间隔1秒,能大大低落乱序的大概性了。
6.3.2 推拉联合
到这里基本解决了同一个用户的消息可以按照他本身发出的顺序入库的问题,即解决了消息发送流程里第一、二步。
第三、四步存在的问题是如许的:
A发送了 msg1、msg2、msg3,B发送了 msg4、msg5、msg6,最终服务端的入库顺序是msg1、msg2、msg4、msg3、msg5、msg6,那除了A和B其他人的消息顺序必要按照入库顺序来展示,而这里的问题是服务端考量推送吞吐量,在推送环节是并发的,即大概 msg4 比 msg1 先推送到用户端上,如果按照推送顺序追加来展示,那么就与预期不符了,每个人看到的消息顺序都大概不一致,如果用户端按照消息的id大小进行比较插入的话,用户体验将会比较奇怪,突然会在2个消息中心出现一条消息。以是这里接纳推拉联合方式来解决这个问题,具体步骤如下:
[*]用户端发出消息,服务端将消息以群维度按照消息的入库顺序缓存在 Redis 有序 SET。
[*]服务端推送给用户端新消息提醒,内容是该新消息的id。
[*]用户端拉取消息,携带2个消息id,startId 和 endId,startId:当地最新的完整消息id;endId:服务端推送得到的新消息id。
[*]服务端返回2个消息id区间内的消息列表。
https://static001.geekbang.org/infoq/01/01986bf46dcbcde3bfeae297cbbf6abe.png
图5
https://static001.geekbang.org/infoq/36/36850413c7f9a241d9351bb49216364e.png
图6
举例,图5表示服务端的消息顺序,图6表示用户端拉取消息时当地消息队列和提醒队列的变化逻辑。
[*]t1时刻用户当地最新的完整消息是 msg1,即这条消息已经完整展示给用户。
[*]t2时刻收到服务端推送的 msg3 新消息提醒,放到提醒队列,此时用户看不到这条消息。
[*]t3时刻向服务端拉取消息详情,请求参数为 startId:msg1,endId:msg3,服务端会按顺序一起返回2个消息区间内的所有消息的详情即 msg2、msg4、msg3,将消息详情同步写入到消息队列,此时用户可以看到刷新出3条消息。
[*]t4时刻用户还会收到 msg2、msg4 的新消息提醒,用户端校验消息队列已经存在 msg2、msg4 的详情,忽略该新消息提醒。
通过推拉联合的方式可以保证所有效户收到的消息展示顺序一致。细心的读者大概会有疑问,如果聊天信息流里有本身发送的消息,那么大概与其他的人看到的不一致,这是由于本身的消息展示不依赖拉取,必要即时展示,给用户立刻发送成功的体验,同时其他人也大概也在发送,最终大概比他先入库,为了不出现信息流中心插入消息的用户体验,只能将他人的新消息追加在本身的消息后面。以是如果作为发送者,消息顺序大概不一致,但是作为纯接收者,大家的消息顺序都是一样的。
七、消息可靠性
在IM体系中,消息的可靠性同样非常紧张,它主要体如今:
[*]消息不丢失:对发送人来说,必须保证消息能入库;对接收者来说,不管是在线还是离线,都能保证收到。但是这里的不丢失,只是说以最大努力去保证,并不是说完全不丢失。
[*]消息不重复:这很容易理解,同一条消息不能重复出现。
7.1 消息不丢失操持
[*]传输协议保障:首先 TCP 是可靠的协议,能较大程度上保证消息不丢失。
[*]增加ACK机制:服务端在实行完消息处理的所有流程后,给发送者发送 ACK;如果发送者在超时时间内没有收到 ACK 消息,则进行一定次数的重试,重新发送;当重发次数超过预设次数,就不再重发,消息发送失败。
[*]最终一致性:这是对接收者而言,如果某条新消息提醒因网络等其他原因丢失,用户没有收到这条消息提醒,那么用户就不会去拉消息详情,在用户视角就是没有看到这条消息。但是当后续的新消息提醒送达时,可以依赖前面提到的拉取机制拿到一个区间内的消息列表,这里就包罗了丢失的消息,因此能到达最终一致性。
7.2 消息不重复操持
[*]增加UUID:每条消息增加 UUID,由客户端创建消息时生成,同一个用户的消息 UUID 唯一。
[*]服务端:用户 ID+UUID 在数据库做联合唯一索引,保证数据层面消息不重复。
[*]用户端:进行兜底,构造一个map来维护已接收消息的id,当收到id重复的消息时直接丢弃。
八、未读数统计
为了提醒用户有新消息,必要给用户展示新消息提醒标识,产品操持上一般有小红点、具体的数值2种方式。具体数值比小红点要复杂,这里分析下具体数值的处理方式,还必要分为初始打开群和已打开群2个场景。
已打开群:可以完全依赖用户端当地统计,用户端获取到新消息后,就将未读数累计加1,等点进去查看后,清空未读数统计,这个比较简朴。
初始打开群:由于用户端接纳H5开发,用户端没有缓存,没有能力缓存最近的已读消息游标,因此这里完全必要服务端来统计,在打开群时下发最新的聊天信息流和未读数,下面具体讲下这个场景下该怎么操持。
既然由服务端统计未读数,那么少不了要保存用户在某个群里已经读到哪个消息,雷同一个游标,用户已读消息,游标往前走。用户已读消息存储表操持如图7所示:
https://static001.geekbang.org/infoq/38/3823f9a44d4aa528a4d4f11f6799fb1d.png
图7
游标offset接纳定时更新计谋,连接服务会记录用户最近一次拉取到的消息ID,定时异步上报批量用户到群组服务更新 offset。
该表第一行表示用户1在 id=89 的群里,最新的已读消息是id=1022消息,那么可以通过下面的SQL来统计他在这个群里的未读数:select count(1) from msg_info where groupId = 89 and id > 1022。但是事情并没这么简朴,一个用户有许多群,每个群都要展示未读数,因此要求未读数统计的程序效率要高,否则用户体验就很差,很明显这个 SQL 的耗时波动很大,取决于 offset 的位置,如果很靠后,SQL 实行时间会非常长。笔者通过2个计谋来优化这个场景:
[*]调解产品操持:未读数最大显示调解为99+。算是产品上的一个让步,有许多产品也接纳这个方案,以是用户也是有这个心智的,99+表示“有许多新消息”,至于具体多少,是几百、几千许多时间不是特殊紧张。以是问题就变得简朴多了,只要计算游标是否在最新的100条消息以内还是以外。
[*]合理利用数据结构:由于有群内有许多人,每个人登录的时间都必要统计,以是每次都去查 MySQL 是比较低效的,因此笔者的方案是在 Redis 里操持一个有界的ZSET结构。
https://static001.geekbang.org/infoq/3f/3f37970becde65c0c638094f8a12b7af.png
图8
如上图8所示,每个群都会构建一个长度为100,score 和 member 都是消息ID,可以通过 zrevrank 命令得到某个 offset 的排名值,该值可以换算成未读数。比如:用户1在群89的未读消息数,'zrevrank 89 1022' = 2,也就是有2条未读数。用户2在群89的未读数,'zrevrank 89 890' = nil,那么未读数就是99+。同时消息新增、删除都必要同步维护该数据结构,失效或不存在时从 MySQL 初始化。
九、超大群计谋
前面提到,操持目标是在同一个群里能支撑百万人,从架构上可以看到,连接服务处于流量最前端,以是它的承载力直接决定了同时在线用户的上限。
影响它的因素有:
[*]服务器自身配置:内存、CPU、网卡、Linux 支持的最大文件打开数;
[*]应用自身配置:应用本身启动必要的内存,如 Netty 依赖的堆外内存,大量的当地缓存;
[*]性能要求:当连接数不断变大时,消息分发的团体耗时肯定在不断增加,因此要关注最慢的分发耗时要满足即时性要求;联合以上环境,可以测试出固定配置服务器单点能支持的最大用户连接数,如果单机能支持20000个用户连接,那么百万在线连接,在连接服务层用50个服务的集群就能解决。
9.1 消息风暴
当同时在线用户数非常多,例如百万时,碰面临如下几个问题:
[*]消息发送风暴:极端环境下,用户同时发送消息,假设服务端承载住了这些流量,那么瓶颈实在在用户端,第一用户端会经历网络风暴,网卡带宽能否支撑是一个大问题;第二假设网卡能通过这些流量,用户端上百万条消息该怎样展示,要是瞬间刷出这些消息,用户端 CPU能否撑住又是个问题,即使能抗住用户体验也很糟糕,根本就看不清消息,一直在飞速刷屏。因此服务端可以在发送消息风暴时做好限流、丢弃计谋,给到用户友爱的提示。
[*]消息提醒风暴:一条新消息的产生,就要推送提醒消息百万次,对服务器来说,要考量团体推送完成的时效性,如果时效性差,对有些用户来说,就是消息必要较长时间才刷出来,出现明显的延迟。新消息长期化后,群组服务 HTTP 回调一组连接服务,单群百万在线用户,必要50台连接服务集群,那么回调50次,为了保证时效性,因此这里要并发回调,并设置合理的线程池,然后连接服务收到回调后也必要并发完成对群用户的新消息提醒推送。
[*]消息拉取风暴:连接服务收到拉取消息事件,必要去群组服务获取消息详情,QPS 就非常高了,理论上集群到达 100wQPS,20台群组服务,那么每台群组服务就是 5wQPS。这里的计谋是在链路前端连接服务上进行流量过滤,由于用户都是请求同一个群的同一条消息或附近的消息,那么就可以在连接服务里操持群消息的当地缓存,所有效户都只从当地缓存里读,如果当地缓存里没有,就放一个线程去群组服务请求加载缓存,其他线程同步等待,如许就大大低落了打到群组服务的 QPS。
9.2 消息压缩
如果某一个时刻,推送消息的数量比较大,且群同时在线人数比较多的时间,连接服务层的机房出口带宽就会成为消息推送的瓶颈。
做个计算,百万人在线,必要5台连接服务,一条消息1KB,一般环境下,5台连接服务集群都是部署在同一个机房,那么这个机房的带宽就是1000000*1KB=1GB,如果多几个超大群,那么对机房的带宽要求就更高,以是怎样有效的控制每一个消息的大小、压缩每一个消息的大小,是必要思索的问题。
经过测试,使用 protobuf 数据交换格式,平均每一个消息可以节省43%的字节大小,可以大大节省机房出口带宽。
9.3 块消息
超大群里,消息推送的频率很高,每一条消息推送都必要进行一次IO体系调用,显然会影响服务器性能,可以接纳将多个消息进行合并推送。
主要思路:以群为维度,累计一段时间内的消息,如果到达阈值,就立刻合并推送,否则就以匀速的时间间隔将在这个时间段内新增的消息进行推送。
时间间隔是1秒,阈值是10,如果500毫秒内新增了10条消息,就合并推送这10条消息,时间周期重置;如果1秒内只新增了8条消息,那么1秒后合并推送这8条消息。如许做的好处如下:
[*]提升服务器性能:减少IO体系调用,减少用户态与内核态之前的切换;
[*]减少传输量:合并消息后,可以减少传输多余的消息头,进一步压缩消息大小;
[*]提升用户体验:一定程度上能减小消息风暴,消息渲染的节奏比较均匀,带给用户更好的体验;
十、总结
在本文中,笔者先容了从零开始搭建一个生产级百万级群聊的一些关键要点和实践履历,包括通信方案选型、消息存储、消息顺序、消息可靠性、高并发等方面,但仍有许多技术操持未涉及,比如冷热群、高低消息通道会放在将来的规划里。IM开发业界没有同一的尺度,不同的产品有恰当本身的技术方案,希望本文能够带给读者更好地理解和应用这些技术实践,为构建高性能、高可靠性的群聊体系提供一定的参考。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]