贝壳 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直播系统
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消息送达保证机制实现(二):保证离线消息的可靠投递》
- 《如何保证IM实时消息的“时序性”与“一致性”?》
- 《IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?》
- 《IM群聊消息如此复杂,如何保证不丢不重?》
- 《一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)》
- 《移动端IM登录时拉取数据如何作到省流量?》
- 《通俗易懂:基于集群的移动端IM接入层负载均衡方案分享》
- 《浅谈移动端IM的多点登陆和消息漫游原理》
- 《IM开发基础知识补课(一):正确理解前置HTTP SSO单点登陆接口的原理》
- 《IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?》
- 《IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议》
- 《IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token》
- 《IM群聊消息的已读回执功能该怎么实现?》(本文)
另外,如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。
3、正文引言
首先我们需要了解一下群消息的设计、投递流程以及可达性保证机制,因不是本文要讨论的重点,所以尽量言简意赅,更详细的资料请见下方的推荐文章列表。
如您对聊天消息的投递和送达机制等尚无概念,可先读本系列文章的以下几篇,有助于您详细掌握这方面的内容:
- 《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》
- 《IM消息送达保证机制实现(二):保证离线消息的可靠投递》
- 《如何保证IM实时消息的“时序性”与“一致性”?》
- 《IM群聊消息如此复杂,如何保证不丢不重?》
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 群聊优化之路的主要内容,如果未能解决你的问题,请参考以下文章
im即时通讯开发:高可用易伸缩高并发的IM群聊单聊架构方案设计