贝壳 Go 实现的 IM 群聊优化之路

Posted Go语言中文网

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了贝壳 Go 实现的 IM 群聊优化之路相关的知识,希望对你有一定的参考价值。

介绍


贝壳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。因此通过改造连接池,可以达到优化效果。

  • 连接池缺陷

    // Get retrieves an available redis client. If there are none available it will  
    // create a new one on the fly  
    func (p *Pool) Get() (*redis.Client, error)     
      select     
        case conn := <-p.pool:      
          return conn, nil    
        default:      
          return p.df(p.Network, p.Addr, p.Auth, p.Db, p.Timeout)    
        

    通过查看imredis连接池代码,发现虽然初始化时会指定最大连接数量,但当QPS升高并且接池中获取不到连接时,会新建连接进行处理。


    优化措施


    参考goredis和其他连接池实现, 采取以下优化措施:

    备用连接池。大小是默认连接池的10倍,如果默认连接池满了, 连接会暂时存放在备用连接池,每过固定时间对备用连接池内的连接进行释放。避免在大量并发时频繁新建和关闭连接。


    对新建连接通过令牌桶进行限制,避免短时间内无限制大量建立连接,导致拖垮服务。


    最终效果


    压测验证300人大群可以达到320qps左右。投递能力较优化三有两倍的性能提升,较去年年底有20倍的性能提升


    总结


    群聊300人大群达到320qps,已经能够满足未来两年内的业务需求。并且随着群聊的优化,尤其是Redis连接池的优化,单聊的性能也有了显著提升,从年初的2000达到了12000。未来如果有继续提高性能的需求,只需要对redis进行双倍扩容,并且将投递服务也进行扩容即可。


    通过本次优化,可以看到,大部分的性能优化只需要进行代码层面的改造,通过压测找到瓶颈点,摸清整个链路的短板并改造这个短板。此时QPS就只是一个数值。需要更高的QPS时只需要扩容、扩容、再扩容即可。



    推荐阅读

  • golang源码阅读:livego直播系统

  • 福利

    我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

    IM群聊消息的已读回执功能该怎么实现?

    本文引用了架构师之路公众号作者沈剑的文章,内容有改动,感谢原作者。

    1、前言


    我们平时在使用即时通讯应用时候,每当发出一条聊天消息,都希望对方尽快看到,并尽快回复,但对方到底有没有真的看到?我却并不知道。

    一个残酷的现实是,很多时候对方其实是早就已经看到了这条消息,但出出种种原因(大家都懂的),通常都是默默返回——假装没看见。

    像微信这样的熟人社交工具,在产品的设计理念上,为了保持使用者的隐私性,在线状态、已读回执等涉及隐私的功能,都没有提供。但很多时候,尤其商务、办公场合下,特别需要一种强反馈的工具,这对于打造高效的团队很有帮助(虽然员工很反感,但老板都喜欢这样的功能,哈哈)。

    目前市面上主流的移动端IM里,提供了已读回执的主要有阿里的钉钉、网易的易信、阿里的旺旺,如下图所示:
    <ignore_js_op>技术分享图片    <ignore_js_op>技术分享图片    <ignore_js_op>技术分享图片 
    ▲ 上图从左至右分别为:钉钉、易信、旺旺(千牛)

    以阿里的钉钉为例,钉钉的产品定位是用于商务交流,其“强制已读回执”功能,让职场人无法再“假装不在线”、“假装没收到”。更有甚者,钉钉的群聊“强制已读回执”功能,甚至能够知道谁读了消息,谁没有读消息(老板的福音啊)

    那么群聊消息的收发流程、消息的送达保证、已读回执机制,到底该怎么实现呢?这就是今天要讨论的话题。

     

    学习交流:

     

    - 即时通讯开发交流3群:185926912[推荐]

    - 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM

     

    (本文同步发布于:http://www.52im.net/thread-1611-1-1.html

    2、IM开发干货系列文章


    本文是系列文章中的第14篇,总目录如下:


    另外,如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。

    3、正文引言


    首先我们需要了解一下群消息的设计、投递流程以及可达性保证机制,因不是本文要讨论的重点,所以尽量言简意赅,更详细的资料请见下方的推荐文章列表。

    如您对聊天消息的投递和送达机制等尚无概念,可先读本系列文章的以下几篇,有助于您详细掌握这方面的内容:

     

    4、群消息怎么设计?


    大家一起跟着楼主的节奏,一步一步来看群消息怎么设计。

    核心问题1:群消息,只存一份?还是,每个成员存一份?
    答:存一份,为每个成员设置一个群消息队列,会有大量数据冗余,并不合适。

    核心问题2:如果群消息只存一份,怎么知道每个成员读了哪些消息?
    答:可以利用群消息的偏序关系,记录每个成员的last_ack_msgid(last_ack_time),这条消息之前的消息已读,这条消息之后的消息未读。该方案意味着,对于群内的每一个用户,只需要记录一个值即可。

    解答上述两个核心问题后,很容易得到群消息的核心数据结构。

    群消息表:记录群消息

    group_msgs(msgid, gid, sender_uid, time, content);


    各字段的含义为:消息ID,群ID,发送方UID,发送时间,发送内容。

    群成员表:记录群里的成员,以及每个成员收到的最后一条群消息

    group_users(gid, uid, last_ack_msgid);


    各字段的含义为:群ID,群成员UID,群成员最后收到的一条群消息ID。

    5、了解一下群消息发送的流程


    在核心数据结构设计完之后,一起来看看群消息发送的流程(本系列中的文章《IM群聊消息如此复杂,如何保证不丢不重?》详细讲解了这个过程,可以深入读一读)。

    业务场景:

    • 1)一个群中有A, uid1, uid2, uid3四名成员;
    • 2)A, uid1, uid2在线,期望实时收到在线消息;
    • 3)uid3离线,期望未来拉取到离线消息。


    <ignore_js_op>技术分享图片 

    其整个消息发送的流程1-4如上图:

    • 1)A发出群消息;
    • 2)server收到消息后,一来要将群消息落地,二来要查询群里有哪些群成员,以便实施推送;
    • 3)对于群成员,查询在线状态;
    • 4)对于在线的群成员,实施推送。


    这个流程里,只要第二步消息落地完成,就能保证群消息不会丢失

    核心问题3:如何保证接收方一定收到群消息?
    答:各个收到消息后,要修改各群成员的last_ack_msgid,以告诉系统,这一条消息确认收到了。

    在线消息,离线消息的last_ack_msgid的修改,又各有不同。

    <ignore_js_op>技术分享图片 
    对于在线的群友,收到群消息后,第一时间会ack、修改last_ack_msgid

    <ignore_js_op>技术分享图片 
    对于离线的群友,会在下一次登录时,拉取未读的所有群离线消息,并将last_ack_msgid修改为最新的一条消息

    核心问题4:如果ack丢失,群友会不会拉取重复的群消息?
    答:,可以根据msgid在客户端本地做去重,即使系统层面收到了重复的消息,仍然可以保证良好的用户体验。

    上述流程,只能确保接收方收到消息,发送方仍然不知道哪些人在线阅读了消息,哪些人离线未阅读消息,并没有实现已读回执,那已读回执会对系统设计产生什么样的影响呢?

    6、已读回执流程的设计


    前面的基础知识我们已经了解的差不多,本节来讨论本文的重点内容,即群聊已读回执流程到底该怎么设计。

    对于发送方发送的任何一条群消息,都需要知道,这条消息有多少人已读多少人未读,就需要一个基础表来记录这个关系

    消息回执表:用来记录消息的已读回执

    msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);


    各字段的含义为:发送方UID,消息ID,回执方UID,群ID,回执标记。

    增加了已读回执逻辑后,群消息的流程会有细微的改变,见下图:
    <ignore_js_op>技术分享图片 

    接着,server收到消息后,除了要:

    • 1)将群消息落地;
    • 2)查询群里有哪些群成员,以便实施推送;


    之外,还需要:

    • 3)插入每条消息的初始回执状态。


    <ignore_js_op>技术分享图片 

    接收方修改last_ack_msgid的流程,会变为:

    • 1)发送ack请求;
    • 2)修改last_ack_msgid,并且,修改已读回执if_ack状态;
    • 3)查询发送方在线状态;
    • 4)向发送方实时推送已读回执(如果发送方在线);


    如果发送方不在线,ta会在下次登录的时候:

    • 5)从关联表里拉取每条消息的已读回执。


    这里的初步结论是:

    • 如果发送方在线:会实时被推送已读回执;
    • 如果发送方不在线:会在下次在线时拉取已读回执。

     

    7、已读回执流程优化方案


    再次详细的分析下,群消息已读回执的“消息风暴扩散系数”,假设每个群有200个用户,其中20%的用户在线,即40各用户在线。

    那么,群用户每发送一条群消息,会有:

    • 40个消息,通知给群友;
    • 40个ack修改last_ack_msgid,发给服务端;
    • 40个已读回执,通知给发送方。


    可见,其消息风暴扩散系数非常之大

    同时:

    • 需要存储40条ack记录。


    群数量,群友数量,群消息数量越来越多之后,存储也会成为问题

    是否有优化方案呢?

    群消息的推送,能否改为接收方轮询拉取?
    答:不能,消息接收,实时性是核心指标。

    对于last_ack_msgid的修改,真的需要每个群消息都进行ack么?
    答:其实不需要,可以批量ack,累计收到N条群消息(例如10条),再向服务器发送一次last_ack_msgid的修改请求,同时修改这个请求之前所有请求的已读回执,这样就能将40个发送给服务端的ack请求量,降为原来的1/10。

    会带来什么副作用?
    答:last_ack_msgid的作用是,记录接收方最近新取的一条群消息,如果不实时更新,可能导致,异常退出时,有一些群消息没来得及更新last_ack_msgid,使得下次登陆时,会拉取到重复的群消息。但这不是问题,客户端可以根据msgid去重,用户体验不会受影响

    发送方在线时,对于已读回执的发送,真的需要实时推送么?
    答:其实不需要,发送方每发一条消息,会收到40个已读回执,采用轮询拉取(例如1分钟一次,一个小时也就60个请求),可以大大降低请求量。
    画外音:或者直接放到应用层keepalive请求里,做到0额外请求增加。

    会带来什么副作用?
    答:已读回执更新不实时,最坏的情况下,1分钟才更新回执。当然,可以根据性能与产品体验来折衷配置这个轮询时间。

    如何降低数据量?
    答:回执数据不是核心数据

    • 已读的消息,可以进行物理删除,而不是标记删除;
    • 超过N长时间的回执,归档或者删除掉。

     

    8、本文小结


    对于群消息已读回执,一般来说:

    • 如果发送方在线,会实时被推送已读回执;
    • 如果发送方不在线,会在下次在线时拉取已读回执。


    如果要对进行优化,可以:

    • 接收方累计收到N条群消息再批量ack;
    • 发送方轮询拉取已读回执。


    物理删除已读回执数据,定时删除或归档非核心历史数据。

    (本文同步发布于:http://www.52im.net/thread-1611-1-1.html





































































































































    以上是关于贝壳 Go 实现的 IM 群聊优化之路的主要内容,如果未能解决你的问题,请参考以下文章

    贝壳Go实现的多云对接存储网关建设

    im即时通讯开发:高可用易伸缩高并发的IM群聊单聊架构方案设计

    im即时通讯开发:高可用易伸缩高并发的IM群聊单聊架构方案设计

    im即时通讯开发:群聊消息的已读未读功能

    Golang的聊天服务器实践(群聊,广播)

    IM系统实战