|
贝壳IM群聊优化之路
贝壳IM群聊优化之路
叶欢欢
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2022年01月27日 15:14
介绍贝壳IM为贝壳找房提供了70%以上的线上商机。为上百万的经纪人提供了快捷的线上获客渠道。日新增会话300万+。其既有互联网TO C产品的属性,又具有浓厚的房地产行业特色,是贝壳找房所属的产业互联网中重要的一环。IM系统相比其他服务有其特殊的特点,主要包括实时性、有序性、可靠性(不重复不丢失)、一致性(多端漫游同步)、安全性。为保证这些特性的实现,良好的性能是必不可少的。本文主要阐述了针对贝壳IM单聊群聊消息的优化思路,通过压测、寻找瓶颈点、提出优化方案、验证优化方案、代码实现的多轮次迭代,最终实现了20倍以上的性能提升。背景2020年底的时候有业务方提出使用群聊消息进行推广活动,该场景预估300人大群需要满足100QPS的性能要求。接到需求,我们首先对群聊场景进行了摸底压测,效果为300人大群QPS为15的时候系统内消息处理就会出现积压,投递能力到达瓶颈。因此拉开了优化的序幕。IM系统整体概览我们首先简要介绍下贝壳IM的整体架构,一是便于理解IM消息服务难在哪,二是便于看出IM消息服务的瓶颈点在哪。上图是IM消息发送的整体流程:发送者通过http接口发送消息,接口处理完成写入发送队列后返回给用户成功信息。此时后台的投递服务实时从队列获取待投递消息,然后进行如下四步操作:写入用户的收件箱个人信箱通过Redis的zset实现,只保存用户最近800条消息,历史消息需要通过消息库查询。注意此处是一个写扩散的过程,每个用户都有一个自己的收件箱,300人的大群,任意一人发送消息,在此处都会写入群中300人的收件箱,相当于1次写入扩散为300次,因此300人大群,如果要达到300QPS,那么写扩散后在Redis层就是一个300*300=90000的QPS,记住这个数字,下文会继续提到写入持久化的历史消息库通过长连接服务通知接收者拉取消息通过PUSH服务通知接收者有消息到达用户在线可以通过长连接通知,如果用户离线,则只能通过PUSH通知通过贝壳IM架构,可以看到消息发送到消息送达整体是一个异步服务,分为两个部分,一是接口层,二是投递服务,两者之间通过队列通信。接口层只需要写入队列即返回成功,无状态可以横向扩展,不会成为群聊的瓶颈。投递服务功能复杂并且会直接影响消息处理的及时性、可靠性。了解过IM的同学可能会问,是否可以将写扩散模式更改为读扩散,即300人大群中发送一条消息之后,不再写入每个人的收件箱,而是只记录到一个发件箱中,读取的时候每个人去发件箱中读取。读扩散方式下,假设一个人加入了100个群,那么读取时需要将100个群的发件箱都读取一遍,而之前的写扩散模式,只需要在收件箱中按序列号读取一次即可。两者各有优劣势,通过调研发现云厂商以及微信服务群聊均是在写扩散模式下实现,证明写扩散可行,并且对贝壳IM来说,继续使用写扩散成本可控。因此在整体架构不变的情形下我们进行了一系列优化,最终也达到了预期效果。优化措施一: 业务隔离IM的消息按照业务属性主要分为三种:单聊消息,即双人聊天会话,包括C2B和B2B,涉及商机获取,最核心的功能群聊消息,即多人聊天会话, 特点是参与人数多,涉及活动推广及消息通知公众号消息,即各种业务公众号群发消息,推送量大,已经做了隔离,并且历史消息库使用TiDB消息投递服务负责从redis队列消费用户发送的消息,进行处理。举个例子, A/B/C三人在同一会话中, A向会话中发送一条消息, 投递服务需要保证这条消息被写入A/B/C三人各自的收件箱和历史消息库, 并通过(push/长连接)通知A/B/C。第一次摸底压测出现redis队列积压,会导致用户不能及时收到消息,这个是不可接受的。在业务侧,针对我们公司的业务场景特点,最重要的是客户和经纪人的单聊消息。从业务隔离角度考量,进行的第一个优化措施,是对单聊和群聊进行垂直拆分,分级隔离后的效果图如下:优化措施二: 提高并发我们继续深入投递服务的细节,看看投递服务的代码实现逻辑。总体来看,投递服务消费一条消息后,会经历两个阶段,每个阶段分别由对应的goroutine来完成, 阶段一有256个goroutine, 阶段二 有1000个goroutine,两个处理流程之间通过channel进行通信。两阶段的流程图如下:还是以一个300人的群GroupA为例,假设发送者 SenderA发送了一个消息,阶段一获取到的消息是会话维度,即GroupA(会话ID标识)的SenderA(UCID标识)发送了一条内容为xxx的消息,阶段一的主要工作是根据会话ID获取到群内的所有成员UCID,然后将UCID哈希之后打散放到1000个channel中,此时channel中的消息是用户维度,即用户ReceiveB收到了一条内容为xxx的消息。注意阶段一此时还进行了一个扩散操作,即将每个收件人的未读数计数进行了更新操作。阶段二从channel中获取消息之后将其放入群内300个成员各自的收件箱,然后给每个人发送push以及长连接信息,通知收件人拉取消息。可以看到阶段一是会话消息维度的处理,IO操作较少,写扩散主要有两步,一是进行一个内存channel的写入,二是以收件人维度进行了未读数的更新。阶段二是收件人维度的消息处理,并发度更大(1000个goroutine),IO操作也较多,例如写入收件人收件箱,更新会话顺序,向每个收件人都发送push通知和长连接通知。优化措施压测时观察到只有发送Redis队列有积压,channel队列没有积压,并且阶段二并发能力更强,考虑将阶段一中比较耗时的更新用户未读数操作放在在阶段二进行。效果通过将更新用户未读数放到阶段二,其实相当于增大了处理未读数的并发能力,上线后进行压测,数据为300人群,群聊消息发送可以达到75QPS,有5倍的性能提升。当300人大群发送QPS达到70时,Redis侧QPS为21000,此时IM中使用的一组缓存redis cpu被打满,单核使用率达到92%(注意redis6.0版本之前只能使用单核,因此单核cpu使用率是redis一个很重要的观察维度)。优化措施三: 减少计算进行第二项优化后,300人的群聊可以达到75QPS,此时一组缓存的redis实例cpu基本被打满。该组redis主要负责存储用户关系, 在投递服务阶段二发送push的业务逻辑中会大量使用。问题以投递一条消息到300人的大群为例,假设群为GroupA,发送者 SenderA发送了一个消息,阶段二中给每个收件人发送push的业务逻辑中存在重复获取数据的情况,例如:SenderA的用户信息:会针对群内每个成员获取一次,即获取了300次SenderA的用户信息。GroupA的会话信息:会针对群内每个成员获取一次,即获取了300次会话信息。用户是否设置了免打扰:会针对群内每个成员获取一次,即获取了300次免打扰设置。由于群聊是从单聊代码改造而来,可以看到简单重复单聊的逻辑,会造成写扩散的放大。优化措施针对重复获取数据,进行相应的优化措施,SenderA的用户信息和GroupA的会话信息可以提前获取一次,发送push时直接使用。用户是否设置了免打扰,原来是从用户到群组的映射关系,通过增加一个群组到用户的反向映射关系,可以通过一个群组直接获取到全部设置了免打扰的用户,减少计算量。效果通过合并业务数据,相当于减少了业务处理的计算量,该改进上线后进行压测,300人群, 发消息150QPS,对比优化二有了2倍的性能提升,对比初始值有10倍的性能提升。此时写入信箱QPS可以达到45k。此时,IM使用的另一组Redis出现了告警,单核CPU使用率超出90%,并且有大量redis慢查和超时报警。通过监控发现,CPU被打满时,redis每秒建连数达到8k/s, 总连接数增长至20k,QPS为45k,通过和DBA同学沟通,这种情况下redis有一大部分cpu都被消耗在连接的建立与销毁。奇怪的是IM中使用了Redis连接池,为什么还会有大量的新建连接呢?Redis的极限QPS能够达到多少?优化措施四: Redis连接池优化排查过程贝壳IM中redis库是使用连接池,目的就是为了避免新建销毁连接带来的消耗。目前使用的redis库比较老,结合之前线上存在偶发的redis慢查报错,首先怀疑是连接池的实现问题。验证为了方便在测试环境模拟复现redis大量建连问题,开发了一个模拟投递程序可以指定发消息qps和参与会话人数。如下是im使用的redis库(简写为imredis)与业界比较成熟的goredis库数据对比通过测试数据发现如下三点:在发送端qps相同的情况下,如果没有大量的新建连接,则redis CPU使用率会大幅下降发送端qps到达120时,使用goredis,此时CPU使用率93%,接近打满,redis qps 144k。(注意此处的模拟程序只使用了一个redis实例。IM线上服务首先按功能分组,每组Redis一般是4个实例起步。分组和分实例的逻辑都在imredis代码中实现)通过模拟测试,可以证明imredis库连接池实现确实有问题,并且验证出redis在我们的场景下极限QPS可以达到140k。因此通过改造连接池,可以达到优化效果。连接池缺陷//Getretrievesanavailableredisclient.Iftherearenoneavailableitwill//createanewoneontheflyfunc(p*Pool)Get()(*redis.Client,error){select{caseconn:=
|
|