第13章IM服务——13.9 高并发架构

高并发架构

即时通信是一个典型的读多写多的场景,在讨论完IM服务如何满足即时通信的基本功能需求后,我们再将其放到海量用户场景中看看它是如何应对高并发请求的。

13.9.1 发送消息

拥有上亿用户的即时通信软件,很容易发生大量用户同时发送消息的情况,尤其是在节假日期间,比如春节,可能有数百万用户同时发送拜年消息,所以我们需要设计一些策略来防止海量的发送消息请求击垮IM服务。

在 13.5.3节讨论过,为了保证会话中消息的有序性,我们为存储消息过程引入了消息队列,在IM服务内部由IM API服务接收消息,然后把消息投递到消息队列中就返回给用户了。当时的意图是保证消息基于会话ID有序和基于用户ID有序,其实使用消息队列还有另一层含义:消息队列可以对高并发发送消息的请求做削峰处理,IM服务内部可以按照自己的处理速度来平滑处理消息的存储与推送,在发送消息的过程中其实已经实现了异步写,可以较好地应对高并发发送消息请求的涌入。

接下来是消息的消费环节。如果消费者消费消息的速度较慢,或者消息队列的分区数量过少,则会造成消息在消息队列中堆积,无法被及时地存储到数据库中,进而导致消息迟迟无法触达接收者。为了尽最大可能防止消息队列中消息的堆积,我们可按如下所述进行操作。

  • 为消息队列的主题设置足够多的分区。受限于消息队列的机制,1个分区只能被1个消费者实例消费,所以分区数量就是消费者实例的数量,分区越多,可以消费消息的消费者实例就越多。例如,为conversation_im_topic设置10000个分区,那么就有10000个会话消息服务处理消息的存储。这就是应对高并发写场景的数据分片方案。
  • 消费者采用批量消费模式从消息队列中拉取消息。同一个会话的相关消息都会被投递到conversation_im_topic的同一个分区中,所以一次消费行为可以批量拉取如500条消息,再将属于同一个会话的消息聚合为一个写数据库的请求。对于消息队列user_im_topic来说也是一样的,批量消费一些消息并按照用户ID做聚合,再访问数据库。这就是写聚合,也是我们之前介绍的应对高并发写场景的解决方案。

此外,作为发送消息的源头,客户端也可以在本地对用户发送消息的频次进行限制。如果用户在短时间内疯狂地发送了大量消息,则大概率可以认定此用户是在恶意刷消息,或者发送无意义的消息,如广告。如果用户在一定时间内发送消息的数量超过某个阈值,那么客户端可以拒绝发送,防止恶意请求、无意义的请求冲击服务端。

13.9.2 数据缓存

IM服务的主要数据包括消息表、会话表、用户会话链、会话消息链和用户消息链。为了防止高并发读消息的请求击垮数据库,可能需要使用Redis缓存这些数据。另外,消息是一种时间属性很重的数据,对最近的消息数据会有更多的请求访问,所以可以将缓存的数据聚焦到最近的数据上。我们具体来分析如何缓存数据。

  • 消息表:最近的消息意味着有更大的可能被频繁访问,Redis可以使用String对象存储IM服务刚刚收到的消息,并设置数据过期时间为1天,这样就可以保证Redis中缓存的消息是来自最近1天的。
  • 会话表:最近创建的、最近有消息通信的会话更有可能被频繁访问,可以使用与缓存消息表同样的方式来缓存最近的会话。但是由于单聊会话只涉及两个用户,且没有什么可变的元信息,用户在同一个设备上基本上很少访问其会话信息,所以Redis可以只缓存群聊会话。
  • 用户会话链:其非常容易被频繁访问,IM服务下发消息时要在这里读取目标用户列表(基于会话ID查询),也要检查消息发送者在会话中发送消息的权限,客户端会周期性地更新与用户相关的会话设置(基于用户ID查询)。所以,对于最近活跃的会话来说,其用户会话链值得被缓存。Redis可以使用List存储每个会话的用户列表,使用String对象存储每个用户在某会话中的设置。
  • 会话消息链和用户消息链:我们将两者统称为消息链,只不过一个用于读扩散模式,一个用于写扩散模式。由于用户查看历史消息的概率远远低于读取最近消息的概率,所以可以存储最近收到的消息。消息链不需要把全部消息都存储起来,而是可以使用Redis的ZSET对象存储最近的消息ID列表,其中Score用Seq表示,以保证消息的有序性。

最终,数据库被作为存储系统,Redis被作为缓存,将两者结合起来存储IM服务数据,如图13-10所示。

image-20250503230737126

13.9.3 消息分级

按照参与会话的用户数量,对消息有不同的触达成功率和实时性的要求,经过大致测算 ,我们把消息划分为如下优先级。

  • 单聊会话:两个用户都非常在意消息的顺利互动,单聊既要保证消息顺利触达,又要保证消息的实时性,消息优先级高。
  • 群聊会话,但是群成员人数少于100人:这属于小群,小群一般是好友圈子建立的群,或者是为特定话题建立的群,群成员也非常在意群消息互动,所以对小群消息有与单聊会话消息类似的触达成功率和实时性的要求,消息优先级高。
  • 群聊会话,但是群成员人数大于100人且小于1000人:这属于中等群组,常见于公司部门的重要消息通知群,日常群聊参与人数不多,重要的是要求群消息触达每个成员,但对实时性要求一般,消息优先级中。
  • 群聊会话,但是群成员人数大于1000人:这属于大型群组,类似于交流社区(如北京交友群 )。由于人多嘴杂,很多群成员大概率会屏蔽群消息,只有主动点击打开群会话才会真正阅读消息,所以并不要求群消息触达全部在线成员,且不要求实时性,消息优先级低。

将消息划分为不同的优先级,可以有目的地为单聊、小群、中群、大群分别创建专用的IM服务集群,集群之间不共享任何资源,如服务实例、消息队列、数据库、Redis,这样可以隔离不同优先级的消息,防止整个IM服务系统瘫痪,并且可以合理分配资源。

  • 单聊消息不会因为大群消息过多而被堆积在消息队列中。
  • 大群消息击垮数据库,不会影响单聊和小群的消息收发。
  • 把 Redis内存资源更多地留给单聊和小群的专用集群。
  • 推送系统优先保证单聊和小群的消息推送,防止大群消息过度占用网络带宽。

13.9.4 直播间弹幕模式

直播间弹幕是如今热度很高的一种即时通信场景,它是指在直播间(或房间)内,观众可以实时发送短文本消息,以在直播画面上滚动的形式呈现。弹幕可以是观众与主播之间的聊天,也可以是观众之间的互动。弹幕可以为直播注入更多的交互和互动体验,也可以为直播间的观众创造更多的参与感和乐趣。

直播间弹幕看起来像群聊,直播间相当于一个会话,看播用户和主播是群成员。不过,与传统的即时通信场景相比,直播间弹幕还有如下一些明显的特殊性。

  • 只有看播用户有会话,且只有一个会话,这个会话就是用户正在观看直播的直播间。不存在一个用户同时观看多个直播的情况。
  • 用户可以随时进出直播间,即群成员变动会比较频繁。
  • 对弹幕的实时性要求很高,因为主播可能会对弹幕进行答复,弹幕不实时下发会造成不好的用户体验。
  • 每个用户只需要接收他在看播期间的消息,不需要看历史弹幕。
  • 直播间极易产生大量收发弹幕的情况,即形成过热会话。
  • 允许在极端情况下丢失若干弹幕,发送者对其他用户是否能看到自己的发言不是很在意。
  • 弹幕数据不需要被长期持久化存储,主播在关播后整个直播间的弹幕就不需要再保留了。

这些特殊性使得直播间弹幕的IM服务设计应该有不同的思路。首先,弹幕的消息存储非常适合采用读扩散模式,即弹幕只被存储到会话消息链中。因为每个用户最多只有正在观看直播的直播间这一个会话,读扩散问题自然得到解决。

其次,看播用户的频繁变动意味着用户会话链不仅面临高并发读的情况,而且会被频繁修改。如果用户会话链仍然使用数据库存储和Redis缓存的组合,则会非常容易出现数据库数据与Redis数据不一致的问题,反倒是直接使用Redis存储更加简单、直接。例如,使用Hash对象作为用户会话链,其中Field表示用户ID,Value表示此用户在直播间内的配置,如是否被禁言、进房时间等信息,如图13-11所示。

image-20250503231222643

最后,所有的看播用户都会频繁地发送弹幕和读取弹幕列表。IM服务已有的消息队列对发送消息请求做削峰处理,因此在正常情况下发送弹幕问题不大;但是在发送弹幕的请求量超过消息队列处理能力的情况下,为了防止弹幕堆积,我们甚至可以直接按照比例丢弃弹幕,弹幕发送者的客户端只在本地展示这条弹幕,从发送者本人的视角来看,弹幕就像已经发送成功一样。

读取弹幕的请求量也很大。虽然会话消息链使用Redis ZSET缓存最近的消息列表,但是这里的数据毕竟只有消息ID,想获取到完整的弹幕,还需要批量获取消息数据,因此会话消息链缓存的ZSET对象的Member不再适合用消息ID表示,而是应该直接使用弹幕数据,如图13-12所示。

image-20250503231313564

这样一来,一个直播间的弹幕读取请求实际上只访问一个Redis ZSET对象,具备了高并发读取弹幕请求的能力。如果读取请求量超过Redis单机处理能力,则可以采用Redis主从架构来分散读取压力。