导读:
网易云信新晋的 IM 顶流产品「圈组」出道后获取到了极大的关注,很多云信的客户在接入的同时对于「圈组」的底层技术细节和原理也非常关注,为此,我们决定推出云信「圈组」相关的系列技术文章,分享网易云信在「圈组」技术设计上的一些思考。
文|曹佳俊 网易云信资深服务器开发工程师
一、技术选型
在介绍「圈组」的技术细节之前,我们先分析一下圈组的技术特点(了解「圈组」可阅读下面两篇文章:行业洞见丨Discord 狂奔的背后与网易云信「圈组」的长期主义 或 正式出「圈」丨网易云信圈组的近谋与远虑),「圈组」产品最大的特点是什么?首先是 server/channel 的二级结构;其次是构建在二级结构之上的大规模社群(单个 server 数十万甚至上百万成员),以及使用复杂的身份组系统来管理如此规模的社群组织和成员。
那么对于这样一个新颖的 IM 系统,在技术上应该如何实现呢?
一种简单的思路是改造已有的 IM 系统,对于「圈组」这样的类 Discord 社群,第一个思路是拓展我们的群组功能,猛一看在很多方面确实挺像的,我们做了个简单的对比:
从上面的表格可以看到,「圈组」和群组最大的不同,一个是容量的区别,一个是二级结构。 其他的诸如身份组、个性化推送策略,似乎只要适配的做一下就可以了,那么是不是只要想办法提升一下群组的容量,再在业务层封装一下二级结构就可以了呢?答案显然是否定的,或者至少说基于群组去扩展不是一个很好的想法。
首先是二级结构,在类 Discord 的二级结构中,成员的管理在 server 层,而 channel 成员是继承自 server 的,而且在 channel 之上还有很多可见性的配置(云信「圈组」提供了黑白名单机制,Discord 则提供了查看频道权限),在这种机制之下,任何 server 层面的成员变动,都可能影响全部或者部分频道的成员列表;面对这种复杂的结构,群组有两种思路去实现,一种是 N 个群,逻辑上隶属于同一个 server;还有一种是一个群映射为一个 server。不管哪种方式,先不说消息投递这块的逻辑,仅成员管理上逻辑的耦合和交织的复杂性,足以劝退任何人。
其次是容量,常规的群组的容量一般只有数百,最多可以扩展到数千,对于群组成员的管理,我们一般采取全量+增量同步相结合的方案,客户端和服务器映射到相同的群组镜像(群信息+群成员等),此时很多操作,例如群成员的展示、检索,消息的艾特等,都可以基于纯客户端进行。而「圈组」要求几十万甚至上百万的容量,显然客户端无法一次性获取到所有成员,如果你一次性加入多个 server,那成员的数量将更加膨胀。因此在「圈组」这种大规模社群的设计中,很多逻辑都会转向云端,此时不管是 SDK 还是服务器,均需要修改原有的设计逻辑。
此外,大规模社群带来的是消息爆炸,在原有的群组设计中,假设一个人同时加入了 1000 个群,那么这 1000 个群内的所有消息均会在第一时间下发给给客户端,但是在一般的业务场景中,不会所有的群都同时活跃,假设这 1000 个群变成了 1000 个服务器/频道,作为一种社群组织,同时活跃的可能性将大大增加,而且每个服务器/频道的人数远远超过普通的群组,叠加之后带来的消息爆炸现象在原有的群组体系中将带来极大的压力,压力包括多方面:首先是海量消息的存储压力,其次是海量消息在线广播/离线消息推送带来的带宽和服务器压力,以及客户端在面对大量消息冲击时如何有效地接受和合理的展示。
除了容量和二级结构,包括身份组、成员管理、个性化推送策略等等,是否真的适合在群组中添加这些复杂逻辑呢,强行绑定在一起会不会既没有一个好用的类 Discord 平台,也使得原始的群组功能繁杂,反而降低了易用性呢?
经过上面的一些分析,我们基本可以得出一个结论,在已有的群组基础上扩展来实现一个类 Discord 功能的社群,显然不是一个很好的思路,那么还有其他“捷径”吗?聊天室也是一个潜在的选项,聊天室的一大特点就是支持超大规模同时在线(参考文章:网易实践|千万级在线直播弹幕方案),容量似乎已经不是问题,但是当考虑添加其他一些强社交关系的特性时(如成员、身份组等)就显得有点为难了,聊天室本身就是来去自如的一个开放空间,这个和圈组的产品本身定位互相冲突的,因此基于聊天室扩展的方案也基本 pass 掉了。
二、技术难点
基于上述种种的思考和讨论,最终网易云信选择脱离已有 IM 体系,从零研发一套全新的社群方案「圈组」,「圈组」不是一个简单的 IM 功能,而是一套可以独立运行的 IM 系统,经过上面的讨论,相信大家对「圈组」本身的技术特点和难点也有所理解,可以归纳为以下几点:
- 二级结构下成员无上限的社交关系系统设计。
- 超大社群下消息系统设计。
- 复杂高效的身份组系统设计。
三、「圈组」消息系统技术剖析
本文将针对上述三点之中的第二点:超大社群下的消息系统设计,分享我们的一些技术设计原理和经验。
(一) 「圈组」整体架构
上面展示了「圈组」服务整体的架构,可以看到整个「圈组」服务是一个分层的架构,首先是接入层,包括 LBS 服务和长链接服务器以及 API 网关,对应客户端 SDK 和用户服务器;后面是网络层,包括大网 WE-CAN 和协议路由服务;其次是服务层,划分了多个服务模块,每个模块都包括多个微服务;最后是基础设施。
(二)消息系统架构
这其中和消息系统相关联的包括接入层、网络层、以及后端的登录/订阅/消息/检索等模块,基本架构如下:
下文我们将对消息系统中各模块分别展开介绍。
(三)消息系统技术细节
消息系统中第一个要讨论的点就是消息的存储和分发方式,包括在线广播、离线推送、历史消息三个维度。
在线广播
对于一般的群组来说,在线广播的一般过程是这样的:依次查询群组里的所有人的在线状态,如果在线,则将消息发送给对应的长链接服务器。显然这种机制无法复制到「圈组」,因为在「圈组」的一个服务器里可能存在超过 100w 的人;此外,聊天室的广播模式也不能直接复用,因为在聊天室架构中,每个长链接映射到一个聊天室,因此当你登录到某个聊天室的时候,你只会收到该聊天室的消息,而对于「圈组」来说,每个用户会同时加入多个服务器/频道,而且会同时收到多个服务器/频道的消息。
针对「圈组」的上述特点,云信设计了消息订阅模式,也就是用户登录之后,需要订阅感兴趣的相关服务器/频道,服务器会记录下这个订阅信息,当有新消息的时候,服务器通过订阅关系(而不是在线状态)查询到需要广播的列表,通过这种方式就不再需要遍历服务器/频道里的所有用户;
但是当一个服务器/频道里在线人数非常多的时候,这个订阅关系仍然是巨大的,为此云信设计了一种两层订阅模型,所有的订阅关系会保存在长链接服务器上(QChatLink/QChatWebLink),同时长链接服务器会定时发送心跳给后端的订阅服务器,心跳信息相比原始的订阅信息会大大简化,比如长链接服务器上会记录账号 A 订阅了某个频道 A 的消息,如果有 1w 个账号,则有 1w 条订阅记录,而心跳信息里只会上报有 1w 个人订阅了某个频道 A 的消息,具体的账号列表则被精简掉了;当一条消息需要广播时,消息服务会访问订阅服务,获取到该服务器/频道被订阅的长链接服务器列表,并依次给该列表中的长链接服务器发送消息下发通知,长链接服务器收到通知后会根据订阅详情再广播给所有客户端。
此外,我们还提供了多种订阅类型,当你非常关心某个频道消息时(比如页面正停留在该频道),此时你可以订阅该频道的消息;对于其他频道,如果你仅仅需要知道该频道有多少条未读消息(或者有无未读消息),则可以选择订阅该频道的未读计数(或者未读状态),此时服务下发时仅会广播精简的消息体用于维护客户端未读计数,并且当未读计数达到一定阈值之后(比如 99+),服务器可以选择不再下发任何通知消息而不影响用户体验。
通过上文介绍的消息订阅模型,极大地提高了超大型的圈组频道/服务器消息在线广播的效率,降低了服务器压力;除此之外,我们还设计了针对小型频道的特殊策略,对于小型频道,即使不订阅,服务器也会下发消息通知给频道里所有人,从而减轻端侧消息订阅模型的维护成本;针对消息订阅机制本身,后续我们也会根据不同的业务场景,提供更多一站式的策略来帮助降低接入成本,提升整体的易用性。
离线推送
在强社交的场景下,离线推送对于维持用户粘性+提升产品体验有很大的作用。从技术角度看的话,主要解决2个问题,第一个是超大型服务器/频道的消息推送的效率问题; 另外一个是提供足够丰富的推送策略来帮助 C 端用户,避免被过量的推送消息给打扰。
针对第一个问题,云信「圈组」针对不同规模的服务器/频道采取了不同的策略,对于小型频道,采用类似于群组的消息推送模型;而对于大型频道,对于每一条需要推送的消息,会根据目标用户的 ID 进行任务分片,多个节点并行操作,提高推送效率。此外分片会采用一致性策略,保证单个用户固定为某些节点,从而提高缓存命中效率。
针对第二个问题,云信「圈组」的推送策略可以用以下几句话来描述:
- 既关注促活,又保证不打扰
- 大型 server 是游乐场,只推送与用户相关的重要消息(如 @消息)
- 小型 server 是与朋友相处的小天地,支持消息的全部推送
并且未来用户还可以自定义消息的高低优先级,并搭配不同的推送配置(如不同的免打扰配置等):
- 历史消息
历史消息的存储在「圈组」的场景中也需要一些特别的设计。同样以群组为例,一般来说消息的存储方式有两种,写扩散和读扩散,在小型的群组或者多人会话中,写扩散模式可以简化设计,但是当群组规模扩大到一定程度(如万人群),读扩散就成了选择,而对于「圈组」这种单个服务器可能上百万人的“群组”中,除了常规的读扩散之外,我们还设计了多级缓存的结构来应对海量的读请求,基本的存储架构大致如下:
消息的存储主要包括两部分,一部分是消息本身,还有一部分是未读计数。
首先是写入,对于上述两者,我们都会使用中心化的缓存服务器来存储最近的数据,并使用异步+批量+聚合等手段,通过 MQ 异步落库,从而平衡写入效率(单条写入性能低)和写入读取延迟(异步写入有延迟)的问题,并且针对不同数据类型的特点,我们也选择了不同的存储方案(历史消息使用分布式时间序列数据库,未读计数使用分布式 k-v 数据库),最大化地提升消息存储和查询的性能和效率。
有写就有读,针对读取操作,所有最近的消息和未读计数均会存储在中心化缓存中,并通过先进先出和缓存过期等不同的策略来确保缓存中存储的永远是最新和最热的数据;此外对于消息 ID 和消息内容本身,中心化缓存中也会有不同的数据结构和过期策略,来平衡缓存命中率和缓存容量消耗;当缓存过期了,如果有关联的读写请求,将会触发缓存的重建,以保证缓存的命中率始终保持在较高水位;最后,当有高频的读请求,还会触发热点 cache 的检测,并将一部分读请求下沉到各个计算节点的内存中,以应对突发流量的冲击。
上述针对「圈组」的特别设计,消息存储系统可以应对几十数百人的小型圈组频道,也可以从容应对上百万的超大型频道。
特色功能
说完了消息系统的核心存储和分发模式,「圈组」的消息系统还提供了很多额外的特色功能:
- 消息更新: 所谓的消息更新是指消息发送之后还允许修改,消息撤回和删除被认为是消息更新的一种特殊状态,这在管理一个大型社群中的消息时有着重要的作用。
- 消息互动(即将推出):「圈组」提供了比 Discord 的消息回复更强的 thread 聊天功能,不同于子区是单独开辟一块空间,thread 聊天可以让你在复杂的消息流中自动筛选出关于某一个话题的所有关联消息。「圈组」也提供了快捷评论的功能,你可以给一条消息添加各种自定义的表情,这在大型的频道中几乎是刚需,因为你再也不用忍受消息爆炸了。云信针对大型频道的快捷评论也做了特殊的优化,当一条消息获得大家的一致喜爱时,频道里所有人都可以给他点一个或者,数量不设上限。
- 消息检索(敬请期待):「圈组」的检索系统也是一大特色,消息检索自然是其中重要的一环,消息检索将助你在繁杂的消息流中寻找到你想要的消息
- 第三方回调和抄送: 这充分体现了「圈组」作为云信 PaaS 平台产品的一大特点,通过第三方回调和抄送,你可以在各种各样的操作(如发消息、拉人踢人、修改信息等)的 before 和 after,植入任何你想要的逻辑,譬如机器人、内容审计等(当然云信也有安全通一站式内容安全解决方案,可以按需选择)。
四、总结
说了这么多,云信「圈组」作为一款全新设计的产品,没有任何历史包袱的限制(但是却可以充分吸收历史优点),你可以使用它构建一个类 Discord 产品,或者任何你想得到的社交/娱乐/游戏产品,欢迎大家选择。
作者介绍
曹佳俊,网易云信资深服务器开发工程师,毕业于中国科学院,硕士毕业后加入网易,负责云信 IM/RTC 信令等业务的服务器开发。专注于即时通讯、RTC 信令以及相关中间件等技术,是云信开源项目 Camellia 的作者。