本文由得物技术王卫强分享,为了更好的阅读体验,有较多的内容修订和排版优化。
客服IM的核心业务其实就是在线沟通,客服IM的好处是使得客服与用户通过实时沟通的方式可以在最短的时间内帮助用户解决问题。
为了快速支撑公司业务发展需求,我们客服IM在发展初期是基于第三方的云IM SDK进行二次开发而来。虽然提升了项目进展,但同时也埋下了问题定位困难、特殊功能实现成本高等隐患。
随着公司业务的高速发展,客服对IM聊天的性能和体验都有了更高的要求,在第三方云IM SDK消息通信上逐渐遇到了技术瓶颈。为解决租用第三方云IM SDK接入带来的潜在隐患、提升IM的稳定性和高扩展性,自研一套可控、稳定、灵活的IM系统已是迫在眉睫了。
本篇文章将基于工程实践,分享我们从0到1自研一套客服IM系统时在各种关键技术点上的设计思路和实践方法。
注:为了简化内容,本文分享的技术栈主要是以Web客服端为主。
相关文章:
学习交流:
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-4153-1-1.html)
客服与用户在聊天的过程中,直观上就是客服在输入文案,然后通过网络发送给用户。
但是IM聊天SDK该如何设计才能使客服在发送消息过程中感知不到卡顿?这一点是非常关键的,要避免卡顿就要设计合理的发送策略以及避免大量JS脚本执行。
举个客服与用户聊天的例子:
针对第1)点,SDK会先创建消息体,即把这个字符串封装成一个自定义的结构体model。
其聊天流程如下图所示:
从上图中可以清晰的看出一条消息发送和接收的完整流程链路。如果IM的SDK设计不合理,发送消息和接收消息流程出现了卡顿,将直接影响用户的体验。
下图是我们的自研IM系统架构原理图:
我们整体的技术改造主要是两个方面:
下面我们将针对主要的技术点进行详细地总结和分享。
在IM SDK自研开发过程中,如何解耦框架代码和业务代码,做到灵活的消息监听,前期调研之后使用了RxJS。
这里简单介绍几个RxJS的核心概念:
注:Subscription 有一个重要的方法,即 unsubscribe,它不需要任何参数,只是用来清理由 Subscription 占用的资源主要用于取消 Observable 的执行。
SDK底层在接收到数据后需要同步到业务侧,之前的做法是通过监听方式实现,这种方式不具备取消订阅的能力,维护成本相对较高。而使用RxJS可以清晰的梳理出数据流向,通过发布订阅的方式实现数据的通信。
RxJS在发布订阅的实现流程如下:
从上图可以看到消息处理的整个流向非常清晰,框架底层接收消息,订阅者消费消息。
在我们整个自研的IM消息通信框架中,主要结构分成三层:
具体如下图所示:
接下来我将详细分享各层的设计和实现思路。
网络层作为消息发送的最底层,负责TCP的连接、消息发送和接收。
网络协议我们选择的是TCP协议。我们为什么没有选择UDP呢?因为UDP是无连接的、不够安全、无法提供可靠传输的服务,通过TCP连接传送的数据可以无差别、不丢失、不重复且按序到达。
PS:尺有所短、寸有所长,TCP和UDP的优劣应该客观看待,感兴趣可以深入学习下面的文章:
《快速理解TCP和UDP的差异》
《一泡尿的时间,快速搞懂TCP和UDP的区别》
《快速理解为什么说UDP有时比TCP更有优势》
《深入地理解UDP协议并用好它》
《如何让不可靠的UDP变的可靠?》
我们整个IM SDK的通信方式采用的是 WebSocket + JSON、grpc + protobuf。(如果你对WebSocket和Protobuf不熟悉,可以详细学习《WebSocket从入门到精通,半小时就够!》、《Protobuf从入门到精通,一篇就够!》)
首先我们要做的就是建立Websocket连接:代码层面我们会先创建一个Connection的抽象类,主要处理网络连接相关配置、超时后重新连接的补偿实现,和一些继承类需要实现的抽象方法。
如上述代码所示:核心在处理超时重连,传统的重试策略是每隔一段时间重试一次,由于是固定的时间间隔重试,重试时又会有大量的请求在同一时刻涌入,会不断地造成限流。(这里使用了指数退避的方式,指数退避是一种通过反馈,成倍地降低某个过程的速率,以逐渐找到合适速率的算法,可根据时隙和重试尝试次数来决定延迟重试。)
其实现算法大致如下:
Websocket连接我们是通过继承Connect类实现的:
至此:网络层连接就已完成了,相对比较简单,都是一些socket api的封装,核心的点在用指数退避算法实现消息发送失败重连接。
数据链路层是IM SDK的核心层,主要涉及到用户信息、聊天消息、数据池等等,我们来一步步对每个模块进行分析。
首先梳理一下客服在登录到用户进线发送消息和接收消息的全过程。
过程有如下几个阶段:
7.1、协议类型
消息协议类型非常重要,是消息发送的基石。初始化协议数据体,可以用于后续各种消息、事件的发送。
在IM自研的SDK通信协议类型主要有如下几种:
具体解释一下:
对网络层消息链接实例化,实现消息的正常发送和接收。
其实现如下:
客服要发送一条消息,肯定有对应的消息结构体model,即需要对消息体进行设计,这里会设计一下message类,每次创建新的消息体都会new一个实例,通过对实例的操作可以更新消息状态等。
如下所示:
针对单个消息,我们也要定义好消息状态,用于聊天过程中消息状态的更新。
如下:
消息类创建好之后,就需要有消息数据池来存储。
消息池结构定义如下:
这里还涉及到消息体的一些基本操作方法对数据池中的数据进行操作,就不做过多的阐述。
上面都是在分析公共模块,但是客服和用户是一对多的关系存在,还需要设计一个用户维度模块,后续在业务侧的操作基本都是以用户维度来操作,需要从单个用户维度设计对应的订阅关系、消息发送、删除等等。
其实现大致如下:
针对客服发送消息,我们首先要站在客服角度考虑消息是否已发出去,优先展示的聊天页面,而不是等网关给了回复后在展示到聊天页面。
根据已往经验来看,只要回车消息就要立即展示到聊天页面,否则客服会认为出现了卡顿,体验效果不佳,鉴于这种场景的需求,在设计发送消息链路的时候就要充分考虑到这一点。
所以设计流程如下:
如上图所示:先在SDK内进行处理对应的消息,处理完成后返回到业务侧完成渲染后再进行消息发送到网关,正常情况下都在一帧之内,客服是感知不到有延迟的。这里要关注消息体序列化、反序列化的时机,避免无谓的性能浪费。
上述图中有个虚拟seq:主要是为了在未收到IM网关响应之前进行排序用的,比如图片、视频、断网发送消息、消息发送失败,或收到IM网关回复缺少seq(场景:敏感词)等情况都需要通过虚拟seq进行准确排序。
接收消息过程相对比较简单,收到消息进行反序列化后更新相关数据,然后在数据池中完成去重(重试机制)、排序后更新到业务侧渲染即可(如下图所示)。
IM消息的可靠投递主要是指:消息在发送接收过程中,能够做到不丢消息、消息不重复、消息顺序不错乱。
我们先来分析以下2种情况。
第一种情况:如果客服A在把消息发送到IM网关的过程中:
以上这些情况客服A都会被提示消息发送失败。
第二种情况:消息在IM网关存储完后,客服A被告知消息发送成功了,然后IM网关把消息推送给用户A的在线设备:
针对第2)点,具体场景比如:用户A的设备在把消息写入本地DB时,出现异常导致落库失败,这种情况下,由于网络层面实际上已经成功传输,但用户A却看不到消息。
我们客服IM对于消息丢失的处理方案主要是参考TCP协议的ACK机制,实现了一套基于业务层的ACK协议。
添加ACK之前消息发送的时序图如下:
7.5.3.1)ACK 机制:
在TCP协议中,默认提供了ACK机制,通过一个协议自带的标准的ACK数据包,来对通信方接收的数据进行确认,告知通信发送方已确认成功接收了数据。ACK机制也是类似,需要解决的是:IM网关推送后如何确认消息是否成功送达接收方并明确被接收方所接收。
具体实现的时序图如下:
客服或用户在发送消息的过程中都会携带一个msgid(32位的uuid,类似TCP的sequenceId),IM网关在接收到消息后,会根据msgid到数据库中查询是否存在该条消息,如果存在就不落库,如果不存在就落库。
然后再推送到接收方,接收方在收到消息后会回复ACK,ACK包中会携带上当前最新的seqid,IM网关收到ACK回复后会对最大的seqid进行更新。
这里为什么要更新最大seqid呢?这么设计肯定有一定道理的,IM网关在收到发送方发送的消息后除了到数据库中检测该消息是否存在外,还会对比当前接收到消息的seq和最大seqid两者之间的差值,会把 [seq, seqid) 之间的数据全部推到接收方,正常情况下都是 [n, n-1),如果IM网关没有收到接收方ACK,n-1就不会更新,推送的消息个数就大于1了。如果seq和seqid相等那就是发送方重复推送的消息,这个时候就不会向接收方推送。这里就涉及到了消息重试,继续向下分析吧。
PS:有关IM消息ID或序列号生成的专题文章可以阅读:
《IM消息ID技术专题(一):微信的海量IM聊天消息序列号生成实践(算法原理篇)》
《IM消息ID技术专题(三):解密融云IM产品的聊天消息ID生成策略》
《IM消息ID技术专题(四):深度解密美团的分布式ID生成算法》
《IM消息ID技术专题(五):开源分布式ID生成器UidGenerator的技术实现》
《IM消息ID技术专题(六):深度解密滴滴的高性能ID生成器(Tinyid)》
7.5.3.2)ACK机制中的消息重试:
消息推给A的过程中丢失了怎么办,比如:
解决这个问题也是参考了TCP协议的重传机制。我们会在客服端、IM网关、用户端都维护一个超时计时器,一定时间内如果没有收到对方回的ACK包,会重新取出该消息进行重推。在重试一定次数后,如果还是没有收到ACK,视为放弃。
前端代码结构和效果如下:
上述图片中的数据只是模拟消息重试,真实场景中执行频次肯定要比这个时间更久一些。
7.5.3.3)消息重复推送的问题:
如果在一定时间内没有收到ACK包,就会触发重试机制。收不到ACK的情况有两种,除了推送的消息真正丢失导致A不回ACK外,还可能是A回的ACK包本身丢了。
解决方案是:发送方在发送消息时携带一个msgid,msgid是全局唯一的,针对同一条重推的消息msgid不变,接收方根据这个唯一的msgid进行去重,这样经过去重后,对于A来说,在聊天界面是不会看到重复的消息,不影响使用体验。
7.5.3.4)保证消息不会乱序:
消息的一致性是非常重要的,在聊天过程中消息顺序不能错乱。
我们是这样考虑的:
通过以上的分析:客服IM消息的可靠性就是通过ACK机制、重试机制、去重机制、排序机制来确保每一条消息的完整触达和准确排序。
业务侧使用的时候直接实例化SDK即可,在消息链路发布订阅中已经提到了RxJS,此时在业务侧订阅使用即可。
需要注意的是在实例化SDK的时候传递了一个filterMsgItem方法,主要是为特殊业务场景提供使用的。
就拿我们客服业务来说:有些特定消息是不需要展示到聊天页面的(比如:用户发送消息被篡改等)。当然我们在业务侧重新对数据过滤或者渲染的时候也是可以做过滤的,这样操作是没什么问题,但是没有必要,如果不从源头过滤数据,后续参与二分、倒序查找的源数据也会增加。会有一些不必要的浪费。当然也可以不添加这个参数,SDK都是全兼容的。
至此我们就完成了整个SDK的实现以及在业务侧的使用,消息发送和接收也都正常。
效果如下:
自研IM SDK还是蛮有挑战的一件事情,从单纯的基于第三方SDK二次开发到自研SDK并与我们的实际业务场景相对完美的结合。
在SDK的整体设计以及和业务侧如何更完美的结合并不是一蹴而就的,都是在实际业务场景中不断积累经验,不断尝试才找到相对完美的解决方案。
这里列举一个简单的案例吧。
例如消息发送,需要考虑到断网场景下:
1)该如何进行消息显示、排序、重新发送?
2)发送失败的场景下重新发送再次失败后又该如何显示、排序?
3)弱网场景下发送消息触发重试机制该如何以最优的方式去重、排序?
4)发送消息触发敏感词该如何处理?
5)断网重连后对于发送失败和触发敏感词的消息又该如何处理?
6)如果在涉及到文件又该如何处理?
……
在自研过程中除了关注业务场景外,还调研了行业内比较好的一些Web应用在某些特殊场景的处理方式。很多优秀的方案也都只能是借鉴一些核心思想,还是要以业务为核心,真正通过技术手段解决业务痛点才是最重要的。
自研SDK收益还是非常大的,也积累了很多IM方面的经验,完成自研IM SDK也只是一个开始,后续我们将会在耗时任务、数据安全等方面持续深耕细作。
[1] 从零到卓越:京东客服即时通讯系统的技术架构演进历程
[2] 瓜子IM智能客服系统的数据架构设计(整理自现场演讲,有配套PPT)
[3] 从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路
[4] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践
[5] 浅谈IM系统的架构设计
[6] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端
[7] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
[8] 一套原创分布式即时通讯(IM)系统理论架构方案
[9] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等
[10] 从新手到专家:如何设计一套亿级消息量的分布式IM系统
[11] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等
[12] 阿里IM技术分享(三):闲鱼亿级IM消息系统的架构演进之路
[13] 基于实践:一套百万消息量小规模IM系统技术要点总结
[14] 跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)
[15] 一套十万级TPS的IM综合消息系统的架构实践与思考
[16] 直播系统聊天技术(八):vivo直播系统中IM消息模块的架构实践
[17] 融云技术分享:全面揭秘亿级IM消息的可靠投递机制
(本文已同步发布于:得物从0到1自研客服IM系统的技术实践之路-IM开发/专项技术区 - 即时通讯开发者社区!)