百万级群聊的设计实践

作者:来自 vivo 互联网服务器团队- Cai Linfeng

本文介绍了服务端在搭建 Web 版的百万人级别的群聊系统时,遇到的技术挑战和解决思路,内容包括:通信方案选型、消息存储、消息有序性、消息可靠性、未读数统计。

一、引言

现在IM群聊产品多种多样,有国民级的微信、QQ,企业级的钉钉、飞书,还有许多公司内部的IM工具,这些都是以客户端为主要载体,而且群聊人数通常都是有限制,微信正常群人数上限是500,QQ2000人,收费能达到3000人,这里固然有产品考量,但技术成本、资源成本也是很大的因素。而笔者业务场景上需要一个迭代更新快、轻量级(不依赖客户端)、单群百万群成员的纯H5的IM产品,本文将回顾实现一个百万人量级的群聊,服务器侧需要考虑的设计要点,希望可以给到读者一些启发。

二、背景介绍

不同的群聊产品,采用的技术方案是不同的,为了理解接下来的技术选型,需要先了解下这群聊产品的特性。

  1. 单群成员需要支撑百万人,同时在线百万级。

  2. 功能、体验要接近纯客户端实现方案。

  3. 用户端完全用H5承载。

三、通信技术

即时通信常见的通信技术有短轮询、长轮询、Server-Sent Events(SSE)、Websocket。短轮询和长轮询适用于实时性要求不高的场景,比如论坛的消息提醒。SSE 适用于服务器向客户端单向推送的场景,如实时新闻、股票行情。Websocket 适用于实时双向通信的场景,实时性好,且服务端、前端都有比较成熟的三方包,如 socket.io,所以这块在方案选择中是比较 easy 的,前后端使用 Websocket 来实现实时通信。

四、消息存储

群聊消息的保存方式,主流有2种方式:读扩散、写扩散。图1展示了它们的区别,区别就在于消息是写一次还是写N次,以及如何读取。

图1

读扩散就是所有群成员共用一个群信箱,当一个群产生一条消息时,只需要写入这个群的信箱即可,所有群成员从这一个信箱里读取群消息。

优点是写入逻辑简单,存储成本低,写入效率高。缺点是读取逻辑相对复杂,要通过消息表与其他业务表数据聚合;消息定制化处理复杂,需要额外的业务表;可能还有IO热点问题。

举个例子:

很常见的场景,展示用户对消息的已读未读状态,这个时候公共群信箱就无法满足要求,必须增加消息已读未读表来记录相关状态。还有用户对某条消息的删除状态,用户可以选择删除一条消息,但是其他人仍然可以看到它,此时也不适合在公共群信箱里拓展,也需要用到另一张关系表,总而言之针对消息做用户特定功能时就会比写扩散复杂。

写扩散就是每个群成员拥有独立的信箱,每产生一条消息,需要写入所有群成员信箱,群成员各自从自己的信箱内读取群消息。优点是读取逻辑简单,适合消息定制化处理,不存在IO热点问题。缺点是写入效率低,且随着群成员数增加,效率降低;存储成本大。

所以当单群成员在万级以上时,用写扩散就明显不太合适了,写入效率太低,而且可能存在很多无效写入,不活跃的群成员也必须得有信箱,存储成本是非常大的,因此采用读扩散是比较合适的。

据了解,微信是采用写扩散模式,微信群设定是500人上限,写扩散的缺点影响就比较小。

五、架构设计

5.1 整体架构

先来看看群聊的架构设计图,如图2所示:

图2

从用户登录到发送消息,再到群用户收到这条消息的系统流程如图3所示:

图3

  1. 用户登录,通过负载均衡,与连接服务建立 Websocket 长连接。

  2. 连接服务管理会话,管理群与用户的映射关系,在本地内存里使用哈希表存储,key为groupId,value为List<SocketIOClient>,同一个群的用户可能会在不同的集群服务器上。

  3. 连接服务向群组服务上报群组路由,上报它的内网IP和它所管理的 groupIdList 的关系,这里需要2种同步策略并行保证群组路由信息的准确性:a.在用户建立、断开长连接时即刻上报;b.定时上报。

  4. 群组服务管理群组路由,使用远程中心缓存 Redis 管理 groupId 和连接服务器IP的关系,key 为 groupId,value 为 List,该IP为连接服务的内网IP地址,这里会做上报的心跳判断,超过3个心跳周期不上报,则认为已断线。

  5. 用户在群里发布一条消息,消息通过 Websokcet 送达连接服务,然后经过连接服务------>消息队列------>群组服务,消息在群组服务里经过频控、安全检查、格式转换等一系列流程后入库,持久化。

  6. 群组服务通过群组路由管理获取这条消息所属群的路由信息,即一组连接服务的IP地址,然后通过 HTTP 回调对应的连接服务,通知它们有新消息产生,这里只简单传递消息ID。

  7. 连接服务收到 HTTP 请求后,根据会话管理查询该群所有用户,给用户发送新消息提醒。

  8. 用户收到新消息提醒,通过 Websocket 来连接服务拉取该新消息具体详情,然后根据消息协议展示在信息流里。

5.2 路由策略

用户应该连接到哪一台连接服务呢?这个过程重点考虑如下2个问题:

  1. 尽量保证各个节点的连接均衡;

  2. 增删节点是否要做 Rebalance。

保证均衡有如下几个算法:

  1. 轮询:挨个将各个节点分配给客户端,但会出现新增节点分配不均匀的情况;

  2. 取模:类似于 HashMap,但也会出现轮询的问题。当然也可以像 HashMap 那样做一次 Rebalance,让所有的客户端重新连接。不过这样会导致所有的连接出现中断重连,代价有点大。由于 Hash 取模方式的问题带来了一致性 Hash 算法,但依然会有一部分的客户端需要 Rebalance;

  3. 权重:可以手动调整各个节点的负载情况,甚至可以做成自动的,基于监控当某些节点负载较高就自动调低权重,负载较低的可以提高权重;笔者是采用轮询 + 权重模式,尽量保证负载均衡。

5.3 重连机制

当应用在扩缩容或重启升级时,在该节点上的客户端怎么处理?由于设计有心跳机制,当心跳不通或监听连接断开时,就认为该节点有问题了,就尝试重新连接;如果客户端正在发送消息,那么就需要将消息临时保存住,等待重新连接上后再次发送。

5.4 线程策略

将连接服务里的IO线程与业务线程隔离,提升整体性能,原因如下:

  1. 充分利用多核的并行处理能力:IO线程和业务线程隔离,双方都可以并行处理网络IO和业务逻辑,充分利用计算机多核并行计算能力,提升性能;

  2. 故障隔离:业务线程处理多种业务消息,有IO密集型,也有 CPU 密集型,有些是纯内存计算,不同的业务处理时延和故障率是不同的。如果把业务线程和IO线程合并,就会有如下问题:某类业务处理较慢,阻塞IO线程,导致其他处理较快的业务消息响应不及时;

  3. 可维护性: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所示:

图4

这时B、C、D肯定会觉得A在胡言乱语了,这样的产品用户必定是不喜欢的,因此必须要保证所有接收方看到的消息展示顺序是一致的。

6.2 原因分析

所以先了解下消息发送的宏观过程:

  1. 发送方发送消息。

  2. 服务端接收消息。

  3. 服务端返回 ACK 消息。

  4. 服务端推送新消息或客户端拉取新消息。

在上面的过程中,都可能产生顺序问题,简要分析几点原因:

  1. 时钟不一致:多个客户端、服务端集群、DB集群,时钟不能保证完全一致,因此不能用本地时间来决定消息顺序。

  2. 网络传输:发送消息环节,先发后至,到达服务器的顺序可能是 msg2、msg1、msg3。

  3. 多线程:服务器考虑性能、吞吐量,往往会在多处环节采用线程池、异步去提升整体速度,因此也会产生顺序问题。

6.3 解决方案

6.3.1 单用户保持有序

通过上面的分析可以知道,其实无法保证或是无法衡量不同用户之间的消息顺序,那么只需保证同一个用户的消息是有序的,保证上下文语义,所以可以得出一个比较朴素的实现方式:以服务端数据库的唯一自增ID为标尺来衡量消息的时序,然后让同一个用户的消息处理串行化。那么就可以通过以下几个技术手段配合来解决:

  1. 发送消息使用 Websocket 发送,并且多次发送保持同一个会话,那么 tcp 协议就保证了应用层收到的消息必定是有序的。

  2. 在应用程序内部处理时,涉及相关多线程的模块,根据 uid 进行 hash,匹配一个单线程的线程池,即同一个 uid 的消息永远用同一个线程去处理,不同用户之间仍是并行处理。

  3. 在跨应用程序时,一般有2种处理方式:一是用 rpc 同步调用;二是利用消息中间件的全局有序。

  4. 用户端上做消息发送频率限制,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个消息中间出现一条消息。所以这里采用推拉结合方式来解决这个问题,具体步骤如下:

  1. 用户端发出消息,服务端将消息以群维度按照消息的入库顺序缓存在 Redis 有序 SET。

  2. 服务端推送给用户端新消息提醒,内容是该新消息的id。

  3. 用户端拉取消息,携带2个消息id,startId 和 endId,startId:本地最新的完整消息id;endId:服务端推送得到的新消息id。

  4. 服务端返回2个消息id区间内的消息列表。

图5

图6

举例,图5表示服务端的消息顺序,图6表示用户端拉取消息时本地消息队列和提醒队列的变化逻辑。

  1. t1时刻用户本地最新的完整消息是 msg1,即这条消息已经完整展示给用户。

  2. t2时刻收到服务端推送的 msg3 新消息提醒,放到提醒队列,此时用户看不到这条消息。

  3. t3时刻向服务端拉取消息详情,请求参数为 startId:msg1,endId:msg3,服务端会按顺序一起返回2个消息区间内的所有消息的详情即 msg2、msg4、msg3,将消息详情同步写入到消息队列,此时用户可以看到刷新出3条消息。

  4. t4时刻用户还会收到 msg2、msg4 的新消息提醒,用户端校验消息队列已经存在 msg2、msg4 的详情,忽略该新消息提醒。

通过推拉结合的方式可以保证所有用户收到的消息展示顺序一致。细心的读者可能会有疑问,如果聊天信息流里有自己发送的消息,那么可能与其他的人看到的不一致,这是因为自己的消息展示不依赖拉取,需要即时展示,给用户立刻发送成功的体验,同时其他人也可能也在发送,最终可能比他先入库,为了不出现信息流中间插入消息的用户体验,只能将他人的新消息追加在自己的消息后面。所以如果作为发送者,消息顺序可能不一致,但是作为纯接收者,大家的消息顺序都是一样的。

七、消息可靠性

在IM系统中,消息的可靠性同样非常重要,它主要体现在:

  1. 消息不丢失:对发送人来说,必须保证消息能入库;对接收者来说,不管是在线还是离线,都能保证收到。但是这里的不丢失,只是说以最大努力去保证,并不是说完全不丢失。

  2. 消息不重复:这很容易理解,同一条消息不能重复出现。

7.1 消息不丢失设计

  1. 传输协议保障:首先 TCP 是可靠的协议,能较大程度上保证消息不丢失。

  2. 增加ACK机制:服务端在执行完消息处理的所有流程后,给发送者发送 ACK;假如发送者在超时时间内没有收到 ACK 消息,则进行一定次数的重试,重新发送;当重发次数超过预设次数,就不再重发,消息发送失败。

  3. 最终一致性:这是对接收者而言,如果某条新消息提醒因网络等其他原因丢失,用户没有收到这条消息提醒,那么用户就不会去拉消息详情,在用户视角就是没有看到这条消息。但是当后续的新消息提醒送达时,可以依赖前面提到的拉取机制拿到一个区间内的消息列表,这里就包含了丢失的消息,因此能达到最终一致性。

7.2 消息不重复设计

  1. 增加UUID:每条消息增加 UUID,由客户端创建消息时生成,同一个用户的消息 UUID 唯一。

  2. 服务端:用户 ID+UUID 在数据库做联合唯一索引,保证数据层面消息不重复。

  3. 用户端:进行兜底,构造一个map来维护已接收消息的id,当收到id重复的消息时直接丢弃。

八、未读数统计

为了提醒用户有新消息,需要给用户展示新消息提醒标识,产品设计上一般有小红点、具体的数值2种方式。具体数值比小红点要复杂,这里分析下具体数值的处理方式,还需要分为初始打开群和已打开群2个场景。

已打开群:可以完全依赖用户端本地统计,用户端获取到新消息后,就将未读数累计加1,等点进去查看后,清空未读数统计,这个比较简单。

初始打开群:由于用户端采用H5开发,用户端没有缓存,没有能力缓存最近的已读消息游标,因此这里完全需要服务端来统计,在打开群时下发最新的聊天信息流和未读数,下面具体讲下这个场景下该怎么设计。

既然由服务端统计未读数,那么少不了要保存用户在某个群里已经读到哪个消息,类似一个游标,用户已读消息,游标往前走。用户已读消息存储表设计如图7所示:

图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个策略来优化这个场景:

  1. 调整产品设计:未读数最大显示调整为99+。算是产品上的一个让步,有很多产品也采用这个方案,所以用户也是有这个心智的,99+表示"有很多新消息",至于具体多少,是几百、几千很多时候不是特别重要。所以问题就变得简单多了,只要计算游标是否在最新的100条消息以内还是以外。

  2. 合理利用数据结构:因为有群内有很多人,每个人登录的时候都需要统计,所以每次都去查 MySQL 是比较低效的,因此笔者的方案是在 Redis 里设计一个有界的ZSET结构。

图8

如上图8所示,每个群都会构建一个长度为100,score 和 member 都是消息ID,可以通过 zrevrank 命令得到某个 offset 的排名值,该值可以换算成未读数。比如:用户1在群89的未读消息数,'zrevrank 89 1022' = 2,也就是有2条未读数。用户2在群89的未读数,'zrevrank 89 890' = nil,那么未读数就是99+。同时消息新增、删除都需要同步维护该数据结构,失效或不存在时从 MySQL 初始化。

九、超大群策略

前面提到,设计目标是在同一个群里能支撑百万人,从架构上可以看到,连接服务处于流量最前端,所以它的承载力直接决定了同时在线用户的上限。

影响它的因素有:

  1. 服务器自身配置:内存、CPU、网卡、Linux 支持的最大文件打开数;

  2. 应用自身配置:应用本身启动需要的内存,如 Netty 依赖的堆外内存,大量的本地缓存;

  3. 性能要求:当连接数不断变大时,消息分发的整体耗时肯定在不断增加,因此要关注最慢的分发耗时要满足即时性要求;结合以上情况,可以测试出固定配置服务器单点能支持的最大用户连接数,假如单机能支持20000个用户连接,那么百万在线连接,在连接服务层用50个服务的集群就能解决。

9.1 消息风暴

当同时在线用户数非常多,例如百万时,会面临如下几个问题:

  1. 消息发送风暴:极端情况下,用户同时发送消息,假设服务端承载住了这些流量,那么瓶颈其实在用户端,第一用户端会经历网络风暴,网卡带宽能否支撑是一个大问题;第二假设网卡能通过这些流量,用户端上百万条消息该如何展示,要是瞬间刷出这些消息,用户端 CPU能否撑住又是个问题,即使能抗住用户体验也很糟糕,根本就看不清消息,一直在飞速刷屏。因此服务端可以在发送消息风暴时做好限流、丢弃策略,给到用户友好的提示。

  2. 消息提醒风暴:一条新消息的产生,就要推送提醒消息百万次,对服务器来说,要考量整体推送完成的时效性,如果时效性差,对有些用户来说,就是消息需要较长时间才刷出来,出现明显的延迟。新消息持久化后,群组服务 HTTP 回调一组连接服务,单群百万在线用户,需要50台连接服务集群,那么回调50次,为了保证时效性,因此这里要并发回调,并设置合理的线程池,然后连接服务收到回调后也需要并发完成对群用户的新消息提醒推送。

  3. 消息拉取风暴:连接服务收到拉取消息事件,需要去群组服务获取消息详情,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条消息。这样做的好处如下:

  1. 提升服务器性能:减少IO系统调用,减少用户态与内核态之前的切换;

  2. 减少传输量:合并消息后,可以减少传输多余的消息头,进一步压缩消息大小;

  3. 提升用户体验:一定程度上能减小消息风暴,消息渲染的节奏比较均匀,带给用户更好的体验;

十、总结

在本文中,笔者介绍了从零开始搭建一个生产级百万级群聊的一些关键要点和实践经验,包括通信方案选型、消息存储、消息顺序、消息可靠性、高并发等方面,但仍有许多技术设计未涉及,比如冷热群、高低消息通道会放在未来的规划里。IM开发业界没有统一的标准,不同的产品有适合自己的技术方案,希望本文能够带给读者更好地理解和应用这些技术实践,为构建高性能、高可靠性的群聊系统提供一定的参考。