融云近期推出直播 SDK,两步即可实现视频直播能力。在第二步“开始直播”阶段,调用一个接口就能发布视频流,其他用户便可加入房间观看直播并在公屏发送弹幕与主播互动。移步【融云全球互联网通信云】免费体验
在直播中,弹幕交互是用户和主播互动的主要方式,使用的就是 IM 中的聊天室功能。融云直播 SDK 完整封装了直播业务所需的全部功能,包括久经考验的无上限聊天室组件,让直播聊天室轻松应对亿级消息并发。
本文主要分享融云的直播聊天室高可用架构及平滑扩缩容实现方案,并详细介绍聊天室服务的两大瓶颈:人员无上限 & 海量消息并发的破解之法。
直播聊天室的主要功能
除了用户最可感可知的多类型消息发送和管理外,直播聊天室还要承担用户管理等任务。在万物皆可直播的当下,超大型直播场景屡见不鲜,更是对直播聊天室提出了人数无上限和海量消息并发的挑战。这两个进阶能力的支撑程度决定了直播聊天室服务的上限。具体如下:
丰富的聊天室消息类型和消息进阶功能:
发送文字、语音、图片等内置消息类型和可实现点赞、礼物等功能的自定义消息类型;
内容安全管理,包括敏感词设置,聊天内容反垃圾处理等;
聊天室消息管理功能,包括消息优先级、消息分发控制等等。
便捷细致的聊天室管理功能:
聊天室用户管理,包括创建、加入、销毁、禁言、查询、封禁(踢人)等;
聊天室用户白名单功能,白名单用户处于被保护状态不会被自动踢出,且发送消息优先级别最高;
聊天室实时统计及消息路由等功能。
聊天室人数无上限:
一些大型直播场景,如春晚、国庆大阅兵等,直播间累计观看动辄上千万人次,同时观看人数也可达数百万。
另外,直播间的一大特点是,用户进出聊天室非常频繁,高热度直播间的人员进出秒并发可能上万,这对服务支撑用户上下线以及用户管理的能力提出了非常大的挑战。
海量消息并发:
直播聊天室人数无上限,自然带来了海量并发消息的问题。一个百万人数的聊天室,消息的上行已是巨量,消息分发量更是几何级上升。
如果服务器只做消息的消峰处理,峰值消息的堆积会造成整体消息延时增大。延时的累积效应会导致消息与直播视频流在时间线上产生偏差,进而影响用户观看直播时互动的实时性。所以,服务器的海量消息分发能力十分重要。
直播聊天室的架构
高可用架构
高可用是分布式系统架构设计中必须考虑的因素之一,简单来说就是减少系统不能提供服务的时间。
高可用系统需要支持服务故障自动转移、服务精准熔断降级、服务治理、服务限流、服务可回滚、服务自动扩容 / 缩容等能力。
以服务的高可用为目标,融云直播聊天室的系统架构如下图 1:
这套系统架构主要分三层:
连接层: 主要管理服务跟客户端的长链接;
存储层:当前使用的是 Redis,作为二级缓存,主要存储聊天室的信息,比如人员列表、黑白名单、封禁列表等。服务更新或重启时,可以从 Redis 中加载出聊天室的备份信息;
业务层:这是整个聊天室的核心,为了实现跨机房容灾,融云将服务部署在多个可用区,并根据能力和职责,将其分为聊天室服务和消息服务。
聊天室服务主要负责处理管理类请求,比如聊天室人员的进出、封禁 / 禁言、上行消息处理审核等;消息服务主要缓存本节点需要处理的用户信息以及消息队列信息,并负责聊天室消息的分发。
在海量用户高并发场景下,消息分发能力将决定着系统的性能。以一个百万级用户量的聊天室为例,一条上行消息对应的是百万倍的分发。这种情况下,海量消息的分发,依靠单台服务器是无法实现的。
融云的优化思路是:将一个聊天室的人员分拆到不同的消息服务上,在聊天室服务收到消息后向消息服务扩散,再由消息服务分发给用户。以百万在线的聊天室为例,假设聊天室消息服务共 200 台,那平均每台消息服务管理 5000 人左右,每台消息服务在分发消息时只需要给落在本台服务器上的用户分发即可。
在聊天室服务中,聊天室的上行信令是依据聊天室 ID 使用一致性哈希算法来选择节点的;在消息服务中,依据用户 ID 使用一致性哈希算法来决定用户具体落在哪个消息服务。
一致性哈希选择的落点相对固定,可以将聊天室的行为汇聚到一个节点上,极大提升服务的缓存命中率。聊天室人员进出,黑 / 白名单设置以及消息发送时的判断等处理直接访问内存即可,无须每次都访问第三方缓存,从而提高了聊天室的响应速度和分发速度。
最后,Zookeeper 在架构中主要用来做服务发现,各服务实例均注册到 Zookeeper。
平滑扩缩容
作为安全、可靠的全球互联网通信云服务商,保证任何情况下服务的延续性和可用性是融云的使命。
随着直播这种形式被越来越多人接受,直播聊天室面对人数激增致使服务器压力逐步增大的情况越来越多。所以,在服务压力逐步增大 / 减少的过程中能否进行平滑的扩 / 缩容非常重要。
在服务的自动扩缩容方面,业内提供的方案大体一致。通过压力测试了解单台服务器的瓶颈点 → 通过对业务数据的监控来判断是否需要进行扩缩 → 触发设定的条件后报警并自动进行扩缩容。
鉴于直播聊天室的强业务性,具体执行中要保证在扩缩容中整体聊天室业务不受影响。
聊天室服务扩缩容:
聊天室服务在进行扩缩容时,可以通过 Redis 来加载成员列表、封禁 / 黑白名单等信息。需要注意的是:在聊天室进行自动销毁时,需先判断当前聊天室是否应该是本节点的。如果不是,跳过销毁逻辑,避免 Redis 中的数据因为销毁逻辑而丢失。细节如下图 2:
消息服务扩缩容:
消息服务在进行扩缩容时,大部分成员需要按照一致性哈希的原则路由到新的消息服务节点上。这个过程会打破当前的人员平衡,并做一次整体的人员转移。
在扩容时,融云根据聊天室的活跃程度逐步转移人员。
在聊天室有消息时,消息服务会遍历缓存在本节点上的所有用户进行消息的通知拉取,在此过程中判断此用户是否属于这台节点,如果不是,将此用户同步加入到属于他的节点。
在用户拉取消息时,如果本机缓存列表中没有该用户,消息服务会向聊天室服务发送请求确认此用户是否在聊天室中,如果在则同步加入到消息服务,不在则直接丢掉。
在消息服务缩容时,消息服务会从公共 Redis 获得全部成员,并根据落点计算将本节点用户筛选出来并放入用户管理列表中。
无限用户的管理和消息分发
用户的上下线和管理
聊天室服务管理了所有人员的进出,人员的列表变动也会异步存入 Redis 中。
消息服务则维护属于自己的聊天室人员,用户在主动加入和退出房间时,需要根据一致性哈希算出落点后同步给对应的消息服务。
聊天室获得消息后,聊天室服务广播给所有聊天室消息服务,由消息服务进行消息的通知拉取。消息服务会检测用户的消息拉取情况,在聊天室活跃的情况下,30s 内人员没有进行拉取或者累计 30 条消息没有拉取,消息服务会判断当前用户已经离线,然后踢出此人,并且同步给聊天室服务对此成员做下线处理。
亿级消息的分发策略
聊天室服务的消息分发及拉取方案如下图 3:
消息通知拉取:
在图 3 中,用户 A 在聊天室中发送一条消息,首先由聊天室服务处理,聊天室服务将消息同步到各消息服务节点,消息服务向本节点缓存的所有成员下发通知拉取,图中服务器向用户 B 和用户 Z 下发了通知。
在消息分发过程中,server 做了通知合并。
通知拉取的详细流程为:
① 客户端成功加入聊天,将所有成员加入到待通知队列中(如已存在则更新通知消息时间);
② 下发线程,轮训获取待通知队列;
③ 向队列中用户下发通知拉取。
通过这个流程可保障下发线程一轮只会向同一用户发送一个通知拉取,即多个消息会合并为一个通知拉取,有效提升了服务端性能且降低了客户端与服务端的网络消耗。
消息拉取:
用户的消息拉取流程如下图 4:
用户 B 收到通知后向服务端发送拉取消息请求,该请求最终将由消息节点 1 进行处理,消息节点 1 将根据客户端传递的最后一条消息时间戳,从消息队列中返回消息列表,参考下图 5:
用户端本地最大时间为 1585224100000,从 server 端可以拉取到比这个数大的两条消息。
消息控速:
服务器应对海量消息时,需要做消息的控速处理。这是因为,在直播聊天室中,大量用户在同一时段发送的海量消息,一般情况下内容基本相同。如果将所有消息全部分发给客户端,客户端很可能出现卡顿、消息延迟等问题,严重影响用户体验。
所以服务器对消息的上下行都做了限速处理。
服务器的限速控制策略如下:
服务器上行限速控制(丢弃)策略:针对单个聊天室的消息上行的限速控制,默认为 200 条 / 秒,可根据业务需要调整。达到限速后发送的消息将在聊天室服务丢弃,不再向各消息服务节点同步。
服务器下行限速(丢弃)策略:服务端的下行限速控制,主要是根据消息环形队列的长度进行控制,达到最大值后最“老”的消息将被淘汰丢弃。
每次下发通知拉取后服务端将该用户标记为拉取中,用户实际拉取消息后移除该标记。
如果产生新消息时用户有拉取中标记,且距设置标记时间在 2 秒内,则不会下发通知(降低客户端压力,丢弃通知未丢弃消息);超过 2 秒则继续下发通知(连续多次通知未拉取则触发用户踢出策略,不在此赘述)。
因此,消息是否被丢弃取决于客户端拉取速度(受客户端性能、网络影响),客户端及时拉取消息则没有被丢弃的消息。
聊天室的消息优先级
消息控速的核心是对消息的取舍,这就需要对消息做优先级划分:
白名单消息,这类消息最为重要,级别最高,一般系统类通知或者管理类信息会设置为白名单消息;
高优先级,仅次于白名单消息,没有特殊设置过的消息都为高优先级;
低优先级,最低优先级的消息,这类消息大多是一些文字类消息。
具体如何划分,开发者可以在后台或者通过接口进行设置。
服务器对三种消息执行不同的限速策略,在高并发时,低优先级消息被丢弃的概率最大。
服务器将三种消息分别存储在三个消息桶中,客户端在拉取消息时按照白名单消息 > 高优先级消息 > 低优先级消息的顺序拉取。
客户端的消息接收和渲染优化
在消息同步机制方面,如果聊天室每收到一条消息都直接下发到客户端,无疑会给客户端带来极大性能挑战。特别是在每秒几千或上万条消息的并发场景下,持续的消息处理会占用客户端有限的资源,影响用户其它方面的互动。
考虑到以上问题,融云为聊天室单独设计了通知拉取机制,由服务端进行一系列分频限速聚合等控制后,再通知客户端拉取。具体分为以下几步:
① 客户端成功加入聊天室;
② 服务端下发通知拉取信令;
③ 客户端根据本地存储的消息最大时间戳,去服务端拉取消息。
这里需要注意的是,首次加入聊天室时,本地并没有有效时间戳,此时会传 0 给服务拉取最近 50 条消息并存库。后续再次拉取时才会传递数据库里存储的消息的最大时间戳,进行差量拉取。
客户端拉取到消息后,会进行排重处理,然后将排重后的数据上抛业务层,以避免上层重复显示。
另外直播聊天室中的消息即时性较强,直播结束或用户退出聊天室后,之前拉取的消息大部分不需要再次查看,因此融云在用户退出聊天室时,会清除数据库中该聊天室的所有消息,以节约存储空间。
在消息渲染方面,客户端也通过一系列优化保证在直播聊天室大量刷屏的场景下仍有不俗的表现。
① 采用 MVVM 机制,将业务处理和 UI 刷新严格区分。每收到一条消息,都在 ViewModel 的子线程将所有业务处理好,并将页面刷新需要的数据准备完毕后,才通知页面刷新。
② 精确使用 LiveData 的 setValue() 和 postValue() 方法。已经在主线程的事件通过 setValue() 方式通知 View 刷新,以避免过多的 postValue() 造成主线程负担过重。
③ 减少非必要刷新。比如在消息列表滑动时,并不需要将接收到的新消息刷新出来,仅进行提示即可。
④ 通过谷歌的数据对比工具 DiffUtil 识别数据是否有更新,仅更新有变更的部分数据。
⑤ 控制全局刷新的次数,尽量通过局部刷新进行 UI 更新。
通过以上机制,从压测结果看,在中端手机、聊天室每秒 400 条消息时,消息列表表现流畅,没有卡顿。
面向海量并发的自定义属性
存储和分发优化
在直播聊天室业务中,除了正常的收发消息外,业务层经常需要设置自己的一些业务属性,如在语音直播聊天室场景中的主播麦位信息、角色管理等,还有狼人杀等卡牌类游戏场景中记录用户的角色和牌局状态等。
相对于聊天室消息,自定义属性有必达和时效的要求,比如麦位、角色等信息需要实时同步给聊天室的所有成员,然后客户端再根据自定义属性刷新本地的业务。
自定义属性的存储:
自定义属性是以 key 和 value 的形式进行传递和存储的,自定义属性的操作行为主要有两种:设置、删除。服务器存储自定义属性也分两部分,分别是全量的自定义属性集合,以及自定义属性集合变更记录。如下图 7 所示:
服务器存储的两份数据,提供了两种查询聊天自定义属性的接口,分别是查询全量数据和查询增量数据。这两种接口的组合应用极大地提升了聊天室服务的属性查询响应和自定义分发能力。
自定义属性的拉取:
内存中的全量数据,主要给从未拉取过自定义属性的成员使用。刚进入聊天室的成员,直接拉取全量自定义属性数据然后展示即可。
对于已经拉取过全量数据的成员来说,若每次都拉取全量数据,客户端想获得本次的修改内容,就需要比对客户端的全量自定义属性与服务器端的全量自定义属性,无论比对行为放在哪一端,都会增加一定的计算压力。
所以,为了实现增量数据的同步,构建一份属性变更记录集合十分必要。这样,大部分成员在收到自定义属性有变更来拉取时,都可以获得增量数据。
属性变更记录采用的是一个有序的 map 集合。key 为变更时间戳,value 里存着变更的类型以及自定义属性内容,这个有序的 map 提供了这段时间内所有的自定义属性的动作。
自定义属性的分发逻辑与消息一致,均为通知拉取。客户端在收到自定义属性变更拉取的通知后,带着自己本地最大自定义属性的时间戳来拉取,比如,如果客户端传的时间戳为 4,则会拉取到时间戳为 5 和时间戳为 6 的两条记录。客户端拉取到增量内容后在本地进行回放,然后对自己本地的自定义属性进行修改和渲染。
使用的最佳实践
基于聊天室自定义属性,可以非常方便地实现聊天室内一些业务的控制及刷新。
接下来,展示一下客户端在聊天室属性使用的主要步骤以及代码示例:
设置全局属性监听器
private void setKVListener() {
RongChatRoomClient.KVStatusListener kvStatusListener = new RongChatRoomClient.KVStatusListener() {
@Override
public void onChatRoomKVSync(String roomId) {
resetTimer();//清空计时器
}
@Override
public void onChatRoomKVUpdate(String roomId, Map chatRoomKvMap) {
updateSeatInfo(roomId,chatRoomKvMap, ActionType.UPDATE);//根据回调的 KV 信息刷新页面。
}
@Override
public void onChatRoomKVRemove(String roomId, Map chatRoomKvMap) {
updateSeatInfo(roomId,chatRoomKvMap, ActionType.DELETE);//根据回调的 KV 信息刷新页面。
}
};
RongChatRoomClient.getInstance().addKVStatusListener(kvStatusListener);
}
onChatRoomKVSync() 是成功加入聊天室后,客户端和服务完成属性同步时的回调。此处在回调内将计时器清零,否则在计时器到时后弹出 Toast 给用户提醒。
onChatRoomKVUpdate() 是聊天室属性发生变化时的回调,初次加入聊天室时,会回调聊天室内的全量属性。根据此回调里的信息直接更新 UI 即可。
onChatRoomKVRemove() 是聊天室内某个属性被删除时的回调,只需在回调方法里更新 UI 即可。
设置聊天室属性
private void setKV(String rooId, boolean isAutoDel, boolean overWrite) {
Map kvMap = new ArrayMap<>();
kvMap.put("s1", "user1"),
kvMap.put("s2", "user2"),
kvMap.put("s3", "user3"),
kvMap.put("s4", "user4"),
RongChatRoomClient.getInstance().setChatRoomEntries(rooId, kvMap, isAutoDel, overWrite, new IRongCoreCallback.SetChatRoomKVCallback() {
@Override
public void onSuccess() {
Log.e("ChatRoomStatusDeatil", "setChatRoomEntries===onSuccess");
updateUI(); //设置成功,更新 UI
}
@Override
public void onError(IRongCoreEnum.CoreErrorCode coreErrorCode, Map map) {
Log.e("ChatRoomStatusDeatil", "setChatroomEntry===onError" + coreErrorCode);
}
});
}