一、引言
很久没写技术文章了,今天这篇不是原理性文章,而是为你们分享一下由笔者主导开发实施的IM即时通信聊天系统,针对大量离线消息(包括消息漫游)致使的用户体验问题的升级改造全过程。html
文章中,我将从以下几个方面进行介绍:redis
- 1)这款IM产品的主要业务及特色;
- 2)IM系统业务现状和痛点;
- 3)升级改造之路;
- 4)消息ACK逻辑的优化。
下述内容都是根据笔者开发IM的亲身经历总结下来的宝贵经验,干货满满,期待你的点赞。数据库
本文已同步发布于“即时通信技术圈”公众号。json
二、此IM产品的主要业务及特色
和传统互联网行业有所不一样,笔者所在的公司(名字就不透露了)是一家作娱乐社交app的公司,包括小游戏、聊天、朋友圈feed等。后端
你们应该都有体会:游戏业务在技术上和产品形态上与电商、旅游等行业有着本质上的区别。浏览器
大部分作后端开发的朋友,都在开发接口。客户端或浏览器h5经过HTTP请求到咱们后端的Controller接口,后端查数据库等返回JSON给客户端。你们都知道,HTTP协议有短链接、无状态、三次握手四次挥手等特色。而像游戏、实时通讯等业务反而很不适合用HTTP协议。微信
缘由以下:session
- 1)HTTP达不到实时通讯的效果,能够用客户端轮询可是太浪费资源;
- 2)三次握手四次挥手有严重的性能问题;
- 3)无状态。
好比说,两个用户经过App聊天,一方发出去的消息,对方要实时感知到消息的到来。两我的或多我的玩游戏,玩家要实时看到对方的状态,这些场景用HTTP根本不可能实现!由于HTTP只能pull(即“拉”),而聊天、游戏业务须要push(即“推”)。app
三、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,协议主要字段包括:

若是客户端不在线,咱们服务端须要把发送的消息存储在离线消息表中,等下次对方客户端上线,服务端NettyServer经过长连接把离线消息push给客户端。
3.2 业务痛点
随着业务蓬勃发展,用户的不断增多,用户建立的群、加入的群和好友不断增多和聊天活跃度的上升,某些用户不在线期间,产生大量的离线消息(尤为是针对群聊,离线消息特别多)。
等下次客户端上线时,服务端会给客户端强推所有的离线消息,致使客户端卡死在登陆后的首页。而且产品提出的需求,要扩大群成员的人数(由以前的百人群扩展到千人群、万人群等)。
这样一来,某些客户端登陆后一定会由于大量离线消息而卡死,用户体验极为很差。
和客户端的同事一块儿分析了一下缘由:
- 1)用户登陆,服务端经过循环分批下发全部离线消息,数据量较大;
- 2)客户端登陆后进入首页,须要加载的数据不光有离线消息,还有其余初始化数据;
- 3)不一样价位的客户端处理数据能力有限,处理聊天消息时,须要把消息存储到本地数据库,而且刷新UI界面,回复给服务端ack消息,整个过程很耗性能。
(庆幸的是,在线消息目前没有性能问题)。
因此针对上述问题,结合产品对IM系统的远大规划,咱们服务端决定优化离线消息(稍微吐槽一下,客户端处理能力不够,为何要服务端作优化?服务端的性能远没达到瓶颈。。。)。
四、升级改造之路
值得庆幸的是,笔者100%参与此次系统优化的所有过程,包括技术选型、方案制定和最后的代码编写。在此期间,笔者思考出多种方案,而后和服务端、客户端同事一块儿讨论,最后定下来一套稳定的方案。
4.1 方案一(被pass掉的一个方案)
▶ 【问题症状】:
客户端登陆卡顿的主要缘由是,服务端会强推大量离线消息给客户端,客户端收到离线消息后会回复服务端ack,而后将消息存储到本地数据库、刷新UI等。客户端反馈,即便客户端采用异步方式也会有比较严重的性能问题。
▶ 【因而我想】:
为何客户端收到消息后尚未将数据存储到数据库就回复给服务端ack?颇有可能存储失败,这自己不合理,这是其一。其二,服务端强推致使客户端卡死,不关心客户端的处理能力,不合理。
▶ 【伪代码以下】:
int max = 100;
//重新库读
while(max > 0) {
List<OfflineMsgInfo> 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":{
}
}

2)客户端每次登陆时,服务端不推送全量离线消息,只推送离线消息计数器(这部分数据存储在redis里,而且数据量很小),这个数量用户显示在客户端消息列表的未读消息小红点上。
3)客户端拿到这些离线消息计数器数据,遍历会话列表,依次将未读消息数量累加(注意:不是覆盖,服务端保存客户端离线后的增量数据),而后通知服务端清空离线消息计数器的增量数据。
4)当客户端进入某会话后,上拉加载时,经过消息的msgId等信息发送HTTP请求给服务端,服务端再去分页查询离线消息返回给客户端。
5)客户端收到消息并保存在本地数据库后,向服务端发送ack,而后服务端删除离线消息表的离线消息。
▶ 【预期结果】:
客户端、服务端的技术人员承认这个方案。咱们经过推拉结合的方式,解决了客户端加载离线消息卡顿的问题。(改造前是强推,改造后采用推拉结合的方式)
流程图以下:

▶ 【新的问题】:
方案虽然经过了,可是引起了一个新问题:即客户端消息衔接问题。
问题描述以下:客户端登陆后进入会话页面,由于客户端自己就保存着历史消息,那么客户端下拉加载新消息时,到底怎么判断要加载本地历史消息?仍是要请求服务端加载离线消息呢?
通过一番思考,服务端和客户端最终达成了一致的方案:
- 1)在未读消息计数器的小红点逻辑中,服务端会把每一个会话的最近N条消息一块儿下发给客户端;
- 2)客户端进入会话时,会根据未读消息计数器的最近N条消息展现首页数据;
- 3)客户端每次下拉加载时,请求服务端,服务端按时间倒排离线消息返回当前会话最近一页离线消息,直到离线消息库中的数据所有返回给客户端;
- 4)当离线消息库中没有离线消息后,返回给客户端一个标识,客户端根据这个标识,在会话页面下一次下拉加载时不请求服务端的离线消息,直接请求本地数据库。
五、消息ACK逻辑的优化
最后,咱们也对消息ack的逻辑进行了优化。
优化前:服务端采用push模型给客户端推消息,不管是在线消息仍是离线消息,ack的逻辑都同样,其中还用到了kafka、redis等中间件,流程很复杂(我在这里就不详细展开介绍ack的具体流程了,反正不合理)。
离线消息和在线消息不一样的是,咱们不存储在线消息,而离线消息会有一个单独的库存储。彻底不必用在线消息的ack逻辑去处理离线消息,反而很不合理,不只流程上有问题,也浪费kafka、redis等中间件性能。
优化后:咱们和客户端决定在每次下拉加载离线消息时,将收到的上一批离线消息的msgId或消息偏移量等信息发送给服务端,服务端直接根据msgId删除离线库中已经发送给客户端的离线消息,再返回给客户端下一批离线消息。
另外:咱们还增长了消息漫游功能,用户切换手机登陆后仍然能够查到历史消息,这部份内容我就不展开详细介绍给你们了。(本文同步发布于:http://www.52im.net/thread-3036-1-1.html)