IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

Stella981
• 阅读 894

1、引言

好久没写技术文章了,今天这篇不是原理性文章,而是为大家分享一下由笔者主导开发实施的IM即时通讯聊天系统,针对大量离线消息(包括消息漫游)导致的用户体验问题的升级改造全过程。

文章中,我将从如下几个方面进行介绍:

  • 1)这款IM产品的主要业务及特点;
  • 2)IM系统业务现状和痛点;
  • 3)升级改造之路;
  • 4)消息ACK逻辑的优化。

下述内容都是根据笔者开发IM的亲身经历总结下来的宝贵经验,干货满满,期待你的点赞。

本文已同步发布于“即时通讯技术圈”公众号。

2、此IM产品的主要业务及特点

和传统互联网行业有所不同,笔者所在的公司(名字就不透露了)是一家做娱乐社交app的公司,包括小游戏、聊天、朋友圈feed等。

大家应该都有体会:游戏业务在技术上和产品形态上与电商、旅游等行业有着本质上的区别。

大部分做后端开发的朋友,都在开发接口。客户端或浏览器h5通过HTTP请求到我们后端的Controller接口,后端查数据库等返回JSON给客户端。大家都知道,HTTP协议有短连接、无状态、三次握手四次挥手等特点。而像游戏、实时通信等业务反而很不适合用HTTP协议。

原因如下:

  • 1)HTTP达不到实时通信的效果,可以用客户端轮询但是太浪费资源;
  • 2)三次握手四次挥手有严重的性能问题;
  • 3)无状态。

比如说,两个用户通过App聊天,一方发出去的消息,对方要实时感知到消息的到来。两个人或多个人玩游戏,玩家要实时看到对方的状态,这些场景用HTTP根本不可能实现!因为HTTP只能pull(即“拉”),而聊天、游戏业务需要push(即“推”)。

3、IM系统业务现状和痛点

3.1 业务现状

笔者负责整个公司的实时聊天系统,类似与微信、QQ那样,有私聊、群聊、发消息、语音图片、红包等功能。

下面我详细介绍一下,整个聊天系统是如何运转的。

首先:为了达到实时通信的效果,我们基于Netty开发了一套长链接网关gateway(扩展阅读:《Netty干货分享:京东京麦的生产级TCP网关技术实践总结》),采用的协议是MQTT协议,客户端登录时App通过MQTT协议连接到gateway(NettyServer),然后通过MQTT协议把聊天消息push给NettyServer,NettyServer与NettyClient保持长链接,NettyClient用于处理业务逻辑(如敏感词拦截、数据校验等)处理,最后将消息push给NettyServer,再由NettyServer通过MQTT push给客户端。

其次:客户端与服务端想要正常通信,我们需要制定一套统一的协议。拿聊天举例,我们要和对方聊天,需要通过uid等信息定位到对方的Channel(Netty中的通道,相当于一条socket连接),才能将消息发送给正确的客户端,同时客户端必须通过协议中的数据(uid、groupId等),将消息显示在私聊或者群聊的会话中。

协议中主要字段如下(我们将数据编码成protobuf格式进行传输):

{

    "cmd":"chat",

    "time":1554964794220,

    "uid":"69212694",

    "clientInfo":{

        "deviceId":"b3b1519c-89ec",

        "deviceInfo":"MI 6X"

    },

    "body":{

        "v":1,

        "msgId":"5ab2fe83-59ec-44f0-8adc-abf26c1e1029",

        "chatType":1,

        "ackFlg":1,

        "from":"69212694",

        "to":"872472068",

        "time":1554964793813,

        "msg":{

            "message":"聊天消息"

        }

    }

}

补充说明:如果你不了Protobuf格式是什么,请详读《Protobuf通信协议详解:代码演示、详细原理介绍等》。

如上json,协议主要字段包括:

IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

如果客户端不在线,我们服务端需要把发送的消息存储在离线消息表中,等下次对方客户端上线,服务端NettyServer通过长链接把离线消息push给客户端。

3.2 业务痛点

随着业务蓬勃发展,用户的不断增多,用户创建的群、加入的群和好友不断增多和聊天活跃度的上升,某些用户不在线期间,产生大量的离线消息(尤其是针对群聊,离线消息特别多)。

等下次客户端上线时,服务端会给客户端强推全部的离线消息,导致客户端卡死在登录后的首页。并且产品提出的需求,要扩大群成员的人数(由之前的百人群扩展到千人群、万人群等)。

这样一来,某些客户端登录后必定会因为大量离线消息而卡死,用户体验极为不好。

和客户端的同事一起分析了一下原因:

  • 1)用户登录,服务端通过循环分批下发所有离线消息,数据量较大;
  • 2)客户端登录后进入首页,需要加载的数据不光有离线消息,还有其他初始化数据;
  • 3)不同价位的客户端处理数据能力有限,处理聊天消息时,需要把消息存储到本地数据库,并且刷新UI界面,回复给服务端ack消息,整个过程很耗性能。

(庆幸的是,在线消息目前没有性能问题)。

所以针对上述问题,结合产品对IM系统的远大规划,我们服务端决定优化离线消息(稍微吐槽一下,客户端处理能力不够,为什么要服务端做优化?服务端的性能远没达到瓶颈。。。)。

4、升级改造之路

值得庆幸的是,笔者100%参与这次系统优化的全部过程,包括技术选型、方案制定和最后的代码编写。在此期间,笔者思考出多种方案,然后和服务端、客户端同事一起讨论,最后定下来一套稳定的方案。

4.1 方案一(被pass掉的一个方案)

▶ 【问题症状】:

客户端登录卡顿的主要原因是,服务端会强推大量离线消息给客户端,客户端收到离线消息后会回复服务端ack,然后将消息存储到本地数据库、刷新UI等。客户端反馈,即使客户端采用异步方式也会有比较严重的性能问题。

▶ 【于是我想】:

为什么客户端收到消息后还没有将数据存储到数据库就回复给服务端ack?很有可能存储失败,这本身不合理,这是其一。其二,服务端强推导致客户端卡死,不关心客户端的处理能力,不合理。

▶ 【伪代码如下】:

int max = 100;

//从新库读

while(max > 0) {

    List offlineMsgListNew = shardChatOfflineMsgDao.getByToUid(uid, 20);

    if(CollectionUtils.isEmpty(offlineMsgListNew)) {

        break;

    }

    handleOfflineMsg(uid, offlineMsgListNew, checkOnlineWhenSendingOfflineMsg);

    max--;

}

▶ 【初步方案】:

既然强推不合理,我们可以换一种方式,根据客户端不同机型的处理能力的不同,服务端采用不同的速度下发。

我们可以把整个过程当成一种生产者消费者模型,服务端是消息生产者,客户端是消息消费者。客户端收到消息,将消息存储在本地数据库,刷新UI界面后,再向服务端发送ack消息,服务端收到客户端的ack消息后,再推送下一批消息。

这么一来,消息下发速度完全根据客户端的处理能力,分批下发。但这种方式仍然属于推方式。

▶ 【悲剧结果】:

然而,理想很丰满,现实却很骨感。

针对这个方案,客户端提出一些问题:

  • 1)虽然这种方案,客户端不会卡死,但是如果当前用户的离线消息特别多,那么收到所有离线消息的时间会非常长;
  • 2)客户端每次收到消息后会刷新界面,很有可能客户端会发生,界面上下乱跳的画面。

so,这个方案被否定了。。。

4.2 方案二

▶ 【我的思考】:

既然强推的数据量过大,我们是否可以做到,按需加载?客户端需要读取离线消息的时候服务端给客户端下发,不需要的时候,服务端就不下发。

_▶ 【技术方案】:_针对离线消息,我们做了如下方案的优化

1)我们增加了离线消息计数器的概念:保存了每个用户的每个会话,未读的消息的元数据(包括未读消息数,最近的一条未读消息、时间戳等数据),这个计数器用于客户端显示未读消息的的红色气泡。这个数据属于增量数据,只保留离线期间收到的消息元数据。

消息格式如下:

{

    "sessionId1":{

        "count":20,

        "lastMsg":[

            "最后N条消息"

        ],

        "timestamp":1234567890

    },

    "sessionId2":{

    }

}

  IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

2)客户端每次登录时,服务端不推送全量离线消息,只推送离线消息计数器(这部分数据存储在redis里,并且数据量很小),这个数量用户显示在客户端消息列表的未读消息小红点上。

3)客户端拿到这些离线消息计数器数据,遍历会话列表,依次将未读消息数量累加(注意:不是覆盖,服务端保存客户端离线后的增量数据),然后通知服务端清空离线消息计数器的增量数据。

4)当客户端进入某会话后,上拉加载时,通过消息的msgId等信息发送HTTP请求给服务端,服务端再去分页查询离线消息返回给客户端。

5)客户端收到消息并保存在本地数据库后,向服务端发送ack,然后服务端删除离线消息表的离线消息。

▶ 【预期结果】:

客户端、服务端的技术人员认可这个方案。我们通过推拉结合的方式,解决了客户端加载离线消息卡顿的问题。(改造前是强推,改造后采用推拉结合的方式)

流程图如下:

IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

▶ 【新的问题】:

方案虽然通过了,但是引发了一个新问题:即客户端消息衔接问题。

问题描述如下:客户端登录后进入会话页面,因为客户端本身就保存着历史消息,那么客户端下拉加载新消息时,到底怎么判断要加载本地历史消息?还是要请求服务端加载离线消息呢?

经过一番思考,服务端和客户端最终达成了一致的方案:

  • 1)在未读消息计数器的小红点逻辑中,服务端会把每个会话的最近N条消息一起下发给客户端;
  • 2)客户端进入会话时,会根据未读消息计数器的最近N条消息展示首页数据;
  • 3)客户端每次下拉加载时,请求服务端,服务端按时间倒排离线消息返回当前会话最近一页离线消息,直到离线消息库中的数据全部返回给客户端;
  • 4)当离线消息库中没有离线消息后,返回给客户端一个标识,客户端根据这个标识,在会话页面下一次下拉加载时不请求服务端的离线消息,直接请求本地数据库。

5、消息ACK逻辑的优化

最后,我们也对消息ack的逻辑进行了优化。

优化前:服务端采用push模型给客户端推消息,不论是在线消息还是离线消息,ack的逻辑都一样,其中还用到了kafka、redis等中间件,流程很复杂(我在这里就不详细展开介绍ack的具体流程了,反正不合理)。

离线消息和在线消息不同的是,我们不存储在线消息,而离线消息会有一个单独的库存储。完全没必要用在线消息的ack逻辑去处理离线消息,反而很不合理,不仅流程上有问题,也浪费kafka、redis等中间件性能。

优化后:我们和客户端决定在每次下拉加载离线消息时,将收到的上一批离线消息的msgId或消息偏移量等信息发送给服务端,服务端直接根据msgId删除离线库中已经发送给客户端的离线消息,再返回给客户端下一批离线消息。

另外:我们还增加了消息漫游功能,用户切换手机登录后仍然可以查到历史消息,这部分内容我就不展开详细介绍给大家了。(本文同步发布于:http://www.52im.net/thread-3036-1-1.html

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Wesley13 Wesley13
3年前
IM系统海量消息数据是怎么存储的?
一、与消息相关的主要场景1、存储和离线消息。现在的IM系统,消息都要落地存储。这样如果接收消息的用户不在线,等他下次上线时,能获取到消息数据。2、消息漫游消息漫游包括主要两种场景,(1)用户新安装IM软件,要能看到以前的聊天记录(2)聊天软件有PC版和App版,在App上聊的天,打开PC版要能够看到
Wesley13 Wesley13
3年前
IM开发干货分享:如何优雅的实现大量离线消息的可靠投递
1、点评IM聊天消息的可靠投递,是每个线上产品都要考虑的IM热点技术问题。IM聊天消息能保证可靠送达,对于用户来说,就好比把钱存在银行不怕被偷一样,是信任的问题。试想,如果用户能明显感知到聊天消息无法保证送达,谁还愿意来用你的APP?谁也不希望自已的话就像浮云一样随风飘逝。必竟用IM聊天,虽然很多时候是费话,但总有关键时刻存在——比如向
Wesley13 Wesley13
3年前
IM中的万人群聊技术方案实践总结(转)
1、引言在不了解IM技术的人眼里,群聊是再平常不过的功能而已,万人群聊?应该也不难实现吧?!确实,从前端功能界面上来看,群聊无非就是个循环向群员发送消息的一对多聊天消息分发模式而已,难在何处?真实的情况是,群聊是IM系统中的高难度技术点之一。难在哪?难在服务端!从某种角度上说,群聊功能的架构设计和技术实现的品质,可以代表这款IM软件
Stella981 Stella981
3年前
Android So动态加载 优雅实现与原理分析
背景:漫品Android客户端集成适配转换功能(基于目标识别(So库35M)和人脸识别库(5M)),导致apk体积50M左右,为优化客户端体验,决定实现So文件动态加载.!(https://oscimg.oschina.net/oscnet/00d1ff90e4b34869664fef59e3ec3fdd20b.png)点击上方“蓝字”关注我
Stella981 Stella981
3年前
IM消息ID技术专题(六):深度解密滴滴的高性能ID生成器(Tinyid)
1、引言在中大型IM系统中,聊天消息的唯一ID生成策略是个很重要的技术点。不夸张的说,聊天消息ID贯穿了整个聊天生命周期的几乎每一个算法、逻辑和过程,ID生成策略的好坏有可能直接决定系统在某些技术点上的设计难易度。有中小型IM场景下,消息ID可以简单处理,反正只要唯一就行,而中大型场景下,因为要考虑到分布式的性能、一致性等,所以要考虑的问题
Wesley13 Wesley13
3年前
IM 消息服务架构
IM消息架构主要有1、消息redis缓存队列及用户信息memcache2、消息的数据落地(入库mysql)3、消息的发送4、离线消息服务5、过期消息服务消息redis缓存队列服务端落地队列1.客户端通过HTTPS
Wesley13 Wesley13
3年前
IM开发宝典:史上最全,微信各种功能参数和逻辑规则资料汇总
1、引言IM应用的初学者们,在补全了各种基础技术知识后,在动手编码实践时,很多时候纠结的并不是功能该如何实现,而是这个功能该实现成什么样(没有经验,我特玛能找谁问问?)。比如,最常见的纠结有以下这些:1)离线聊天消息该保存多久?2)好友请求应该保存多久?3)短视频消息中的视频时长设为多大合适?
融云IM干货丨IM聊天室中客户端如何确保消息同步的准确性?
客户端确保消息同步的准确性主要依赖于以下几个关键技术和策略:全局唯一的消息ID生成策略:为了保证消息可以通过ID进行识别和排重,IM系统采用全局唯一的消息ID生成策略。这种策略可以确保每条消息都有一个唯一的标识符,从而在消息的发送和接收过程中避免重复。客户