术→技巧, 研发

即时通讯系统之马蜂窝

钱魏Way · · 499 次浏览

移动互联网技术改变了旅游的世界,这个领域过去沉重的信息分销成本被大大降低。用户与服务供应商之间、用户与用户之间的沟通路径逐渐打通,沟通的场景也在不断扩展。这促使所有的移动应用开发者都要从用户视角出发,更好地满足用户需求。

论坛时代的马蜂窝,用户之间的沟通形式比较单一,主要为单纯的回帖回复等。为了以较小的成本快速满足用户需求,当时采用的是非实时性消息的方案来实现用户之间的消息传递。随着行业和公司的发展,马蜂窝确立了「内容+交易」的独特商业模式。在用户规模不断增长及业务形态发生变化的背景下,为用户和商家提供稳定可靠的售前和售后技术支持,成为电商移动业务线的当务之急。

IM 系统为用户与商家建立了直接沟通的渠道,帮助用户解答购买旅行产品中的问题,既促成了订单交易,也帮用户打消了疑虑,促成用户旅行愿望的实现。伴随着业务的快速发展,几年间,马蜂窝 IM 系统也经历了几次比较重要的架构演化和转型。

从商品复杂性来看,一个旅游商品可能会包括用户在未来一段时间的衣、食、住、行等方方面面;从消费金额来看,往往单次消费额度较大;对目的地的陌生、在行程中可能的问题,这些因素使用户在购买前、中、后都存在和商家沟通的强烈需求。可以说,一个好用的 IM 可以在一定程度上对企业电商业务的 GMV 起到促进作用。

IM架构的演化

IM 1.0:初期阶段

初期为了支持业务快速上线,且当时版本流量较低,对并发要求不高,IM 系统的技术架构主要以简单和可用为目的,实现的功能也很基础。

IM 1.0 使用 PHP 开发,实现了 IM 基本的用户/客服接入、消息收发、咨询列表管理功能。用户咨询时,会通过平均分配的策略分配给客服,记录用户和客服的关联关系。用户/客服发送消息时,通过调用消息转发模块,将消息投递到对方的 Redis 阻塞队列里。收消息则通过 HTTP 长连接调用消息轮询模块,有消息时即刻返回,没有消息则阻塞一段时间返回,这里阻塞的目的是降低轮询的间隔。消息收发模型如下图所示:

消息轮询模块优化

上图模型中消息轮询模块的长连接请求是通过 php-fpm 挂载在阻塞队列上,当该请求变多时,如果不能及时释放 php-fpm 进程,会对服务器性能消耗较大,负载很高。

为了解决这个问题,我们对消息轮询模块进行了优化,选用基于 OpenResty 框架,利用 Lua 协程的方式来优化 php-fmp 长时间挂载的问题。Lua 协程会通过对 Nginx 转发的请求标记判断是否拦截网络请求,如果拦截,则会将阻塞操作交给 Lua 协程来处理,及时释放 php-fmp,缓解对服务器性能的消耗。优化的处理流程见下图:

IM 2.0:需求定制阶段

伴随着业务的快速增长,IM 系统在短期内面临着大量定制需求的增加,开发了许多新的业务模块。面对大量的用户咨询,客服的服务能力已经招架不住。因此,IM 2.0 将重心放在提升业务功能体验上,比如在处理用户的咨询时,将从前单一的分配方式演变为采用平均、权重、排队等多种方式;为了提升客服的效率,客服的咨询回复也增加了可选配置,例如自动回复、FAQ 等。

以一个典型的用户咨询场景为例,当用户打开 App 或者网页时,会通过连接层建立长连接,之后在咨询入口发起咨询时,会携带着消息线索初始化消息链路,建立一条可复用、可检索的消息线;发送消息时,通过消息服务将消息存储到 DB 中,同时会根据消息线检索当前咨询是否被分配到客服,调用分配服务的目的是为当前咨询完善客服信息;最后将客服信息更新到链路关系中。

这样,一条完整的消息链路就建立完毕,之后用户/客服发出的消息通过转发服务传输给对方,处理流程如下图所示:

IM 3.0:服务拆分阶段

业务量在不断积累,随着模块增加,IM 系统的代码膨胀得很快。由于代码规范没有统一、接口职责不够单一、模块间耦合较多等种原因,改动一个需求很可能会影响到其它模块,使新需求的开发和维护成本都很高。

为了解决这种局面,IM 系统必须要进行架构升级,首要任务就是服务的拆分。目前,经过拆分后的 IM 系统整体分为 4 块大的服务,包括客服服务、用户服务、IM 服务、数据服务,如下图所示:

  • 客服服务:围绕提升客服效率和用户体验提供多种方式,如提供群组管理、成员管理、质检服务等来提升客服团队的运营和管理水平;通过分配服务、转接服务来使用户的接待效率更灵活高效;支持自动回复、FAQ、知识库服务等来提升客服咨询的回复效率等。
  • 用户服务:分析用户行为,为用户做兴趣推荐及用户画像,以及统计用户对马蜂窝商家客服的满意度。
  • IM 服务:支持单聊和群聊模式,提供实时消息通知、离线消息推送、历史消息漫游、联系人列表、文件上传与存储、消息内容风控检测等。
  • 数据服务:通过采集用户咨询的来源入口、是否咨询下单、是否有客服接待、用户咨询以及客服回复的时间信息等,定义数据指标,通过数据分析进行离线数据运算,最终对外提供数据统计信息。主要的指标信息有 30 秒、1 分钟回复率、咨询人数、无应答次数、平均应答时间、咨询销售额、咨询转化率、推荐转化率、分时接待压力、值班情况、服务评分等。

用户状态流转

现有的 IM 系统 中,用户咨询时一个完整的用户状态流转如下图所示:

用户点击咨询按钮触发事件,此时用户状态进入初始态。发送消息时,系统更改用户状态为待分配,通过调用分配服务分配了对应的客服后,用户状态更改为已分配、未解决。当客服解决了用户或者客服回复后用户长时间未说话,触发系统自动解决的操作,此时用户状态更改为已解决,一个咨询流程结束。

IM 服务的重构

在服务拆分的过程中,我们需要考虑特定服务的通用性、可用性和降级策略,同时需要尽可能地降低服务间的依赖,避免由于单一服务不可用导致整体服务瘫痪的风险。在这期间,公司其它业务线对 IM 服务的使用需求也越来越多,使用频次和量级也开始加大。初期阶段的 IM 服务当连接量大时,只能通过修改代码实现水平扩容;新业务接入时,还需要在业务服务器上配置 Openresty 环境及 Lua 协程代码,业务接入非常不便,IM 服务的通用性也很差。

考虑到以上问题,我们对 IM 服务进行了全面重构,目标是将 IM 服务抽取成独立的模块,不依赖其它业务,对外提供统一的集成和调用方式。考虑到 IM 服务对并发处理高和损耗低的要求,选择了 Go 语言来开发此模块,新的 IM 服务设计如下图:

其中,比较重要的 Proxy 层和 Exchange 层提供了以下服务:

  • 路由规则,例如 ip-hash、轮询、最小连接数等,通过规则将客户端散列到不同的 ChannelManager 实例上。
  • 对客户端接入的管理,接入后的连接信息会同步到 DispatchTable 模块,方便 Dispatcher 进行检索。
  • ChannelManager 与客户端间的通信协议,包括客户端请求建立连接、断线重连、主动断开、心跳、通知、收发消息、消息的 QoS 等。
  • 对外提供单发、群发消息的 REST 接口。这里需要根据场景来决定是否使用,例如用户咨询客服的场景就需要通过这个接口下发消息,主要原因在以下 3 点:
    • 发消息时会有创建消息线、分配管家等逻辑,这些逻辑目前是 PHP 实现,IM 服务需要知道 PHP 的执行结果,一种方式是使用 Go 重新实现,另外一种方式是通过 REST 接口调用 PHP 返回,这样会带来 IM 服务和 PHP 业务过多的网络交互,影响性能。
    • 转发消息时,ChannelManager 多个实例间需要互相通信,例如 ChannelManager1 上的用户 A 给 ChannelManager2 上的客服 B 发消息,如果实例间无通信机制,消息无法转发。当要再扩展 ChannelManager 实例时,新增实例需要和其它已存在实例分别建立通信,增加了系统扩展的复杂度。
    • 如果客户端不支持 WebSocket 协议,作为降级方案的 HTTP 长连接轮循只能用来收消息,发消息需要通过短连接来处理。其它场景不需要消息转发,只用来给 ChannelManager 传输消息的场景,可通过 WebSocket 直接发送。

改造后的 IM 服务调用流程

初始化消息线及分配客服过程由 PHP 业务完成。需要消息转发时,PHP 业务调用 Dispatcher 服务的发消息接口,Dispatcher 服务通过共享的 Dispatcher Table 数据,检索出接收者所在的 ChannelManager 实例,将消息通过 RPC 的方式发送到实例上,ChannelManager 通过 WebSocket 将消息推送给客户端。IM 服务调用流程如下图所示:

当连接数超过当前 ChannelManager 集群承载的上限时,只需扩展 ChannelManager 实例,由 ETCD 动态的通知到监听侧,从而做到平滑扩容。目前浏览器版本的 JS-SDK 已经开发完毕,其它业务线通过接入文档,就能方便地集成 IM 服务。

在 Exchange 层的设计中,有 3 个问题需要考虑:

多端消息同步

现在客户端有 PC 浏览器、Windows 客户端、H5、iOS/Android,如果一个用户登录了多端,当有消息过来时,需要查找出这个用户的所有连接,当用户的某个端断线后,需要定位到这一个连接。

上面提到过,连接信息都是存储在 DispatcherTable 模块中,因此 DispatcherTable 模块要能根据用户信息快速检索出连接信息。DispatcherTable 模块的设计用到了 Redis 的 Hash 存储,当客户端与 ChannelManager 建立连接后,需要同步的元数据有 uid(用户信息)、uniquefield(唯一值,一个连接对应的唯一值)、wsid(连接标示符)、clientip(客户端 ip)、serverip(服务端 ip)、channel(渠道),对应的结构大致如下:

这样通过 key(uid) 能找到一个用户多个端的连接,通过 key+field 能定位到一条连接。连接信息的默认过期时间为 2 小时,目的是避免因客户端连接异常中断导致服务端没有捕获到,从而在 DispatcherTable 中存储了一些过期数据。

用户在线状态同步

比如一个用户先后和 4 个客服咨询过,那么这个用户会出现在 4 个客服的咨询列表里。当用户上线时,要保证 4 个客服看到用户都是在线状态。

要做到这一点有两种方案,一种是客服通过轮询获取用户的状态,但这样当用户在线状态没有变化时,会发起很多无效的请求;另外一种是用户上线时,给客服推送上线通知,这样会造成消息扩散,每一个咨询过的客服都需要扩散通知。我们最终采取的是第二种方式,在推送的过程中,只给在线的客服推送用户状态。

消息的不丢失,不重复

为了避免消息丢失,对于采用长连接轮询方式的我们会在发起请求时,带上客户端已读消息的 ID,由服务端计算出差值消息然后返回;使用 WebSocket 方式的,服务端会在推送给客户端消息后,等待客户端的 ACK,如果客户端没有 ACK,服务端会尝试多次推送。

这时就需要客户端根据消息 ID 做消息重复的处理,避免客户端可能已收到消息,但是由于其它原因导致 ACK 确认失败,触发重试,导致消息重复。

IM 服务的消息流

上文提到过 IM 服务需要支持多终端,同时在角色上又分为用户端和商家端,为了能让通知、消息在输出时根据域名、终端、角色动态输出差异化的内容,引入了 DDD (领域驱动设计)的建模方法来对消息进行处理,处理过程如下图所示:

IM 4.0:Golang应用实践

技术背景和问题

与广义上的即时通讯不同,电商各业务线有其特有业务逻辑,如客服聊天系统的客人分配逻辑、敏感词检测逻辑等,这些往往要耦合进通信流程中。随着接入业务线越来越多,即时通讯服务冗余度会越来越高。同时整个消息链路追溯复杂,服务稳定性很受业务逻辑的影响。

之前我们 IM 应用中的消息推送主要基于轮询技术,消息轮询模块的长连接请求是通过 php-fpm 挂载在阻塞队列上实现。当请求量较大时,如果不能及时释放 php-fpm 进程,对服务器的性能消耗很大。

为了解决这个问题,我们曾用 OpenResty+Lua 的方式进行改造,利用 Lua 协程的方式将整体的 polling 的能力从 PHP 转交到 Lua 处理,释放一部 PHP 的压力。这种方式虽然能提升一部分性能,但 PHP-Lua 的混合异构模式,使系统在使用、升级、调试和维护上都很麻烦,通用性也较差,很多业务场景下还是要依赖 PHP 接口,优化效果并不明显。

为了解决以上问题,我们决定结合电商 IM 的特定背景对 IM 服务进行重构,核心是实现业务逻辑和即时通讯服务的分离。

基于Go的双层分布式IM架构

实现目标

  • 业务解耦。将业务逻辑与通信流程剥离,使 IM 服务架构更加清晰,实现与电商 IM 业务逻辑的完全分离,保证服务稳定性。
  • 接入方式灵活。之前新业务接入时,需要在业务服务器上配置 OpenResty 环境及 Lua 协程代码,非常不便,IM 服务的通用性也很差。考虑到现有业务的实际情况,我们希望 IM 系统可以提供 HTTP 和 WebSocket 两种接入方式,供业务方根据不同的场景来灵活使用。比如已经接入且运行良好的电商定制化团队的待办系统、定制游抢单系统、投诉系统等下行相关的系统等,这些业务没有明显的高并发需求,可以通过 HTTP 方式迅速接入,不需要熟悉稍显复杂的 WebSocket 协议,进而降低不必要的研发成本。
  • 架可扩展。为了应对业务的持续增长给系统性能带来的挑战,我们考虑用分布式架构来设计即时通讯服务,使系统具有持续扩展及提升的能力。

语言选择

目前,马蜂窝技术体系主要包括 PHP,Java,Golang,技术栈比较丰富,使业务做选型时可以根据问题场景选择更合适的工具和语言。

结合 IM 具体应用场景,我们选择 Go 的原因包括:

  • 性能。在性能上,尤其是针对网络通信等 IO 密集型应用场景。Go 系统的性能更接近 C/C++。
  • 开发效率。Go 使用起来简单,代码编写效率高,上手也很快,尤其是对于有一定 C++ 基础的开发者,一周就能上手写代码了。

架构设计

整体架构图如下:

名词解释:

  • 客户:一般指购买商品的用户
  • 商家:提供服务的供应商,商家会有客服人员,提供给客户一个在线咨询的作用
  • 分发模块:即 Dispatcher,提供消息分发的给指定的工作模块的桥接作用
  • 工作模块:即 Worker 服务器,用来提供 WebSocket 服务,是真正工作的一个模块。

架构分层:

  • 展示层:提供 HTTP 和 WebSocket 两种接入方式。
  • 业务层:负责初始化消息线和业务逻辑处理。如果客户端以 HTTP 方式接入,会以 JSON 格式把消息发送给业务服务器进行消息解码、客服分配、敏感词过滤,然后下发到消息分发模块准备下一步的转换;通过 WebSocket 接入的业务则不需要消息分发,直接以 WebSocket 方式发送至消息处理模块中。
  • 服务层:由消息分发和消息处理这两层组成,分别以分布式的方式部署多个 Dispatcher 和 Worker 节点。Dispatcher 负责检索出接收者所在的服务器位置,将消息以 RPC 的方式发送到合适的 Worker 上,再由消息处理模块通过 WebSocket 把消息推送给客户端。
  • 数据层:Redis 集群,记录用户身份、连接信息、客户端平台(移动端、网页端、桌面端)等组成的唯一 Key。

服务流程

步骤一

如上图右侧所示,用户客户端与消息处理模块建立 WebSocket 长连接。通过负载均衡算法,使客户端连接到合适的服务器(消息处理模块的某个 Worker)。连接成功后,记录用户连接信息,包括用户角色(客人或商家)、客户端平台(移动端、网页端、桌面端)等组成唯一 Key,记录到 Redis 集群。

步骤二

如图左侧所示,当购买商品的用户要给管家发消息的时候,先通过 HTTP 请求把消息发给业务服务器,业务服务端对消息进行业务逻辑处理。

  • 该步骤本身是一个 HTTP 请求,所以可以接入各种不同开发语言的客户端。通过 JSON 格式把消息发送给业务服务器,业务服务器先把消息解码,然后拿到这个用户要发送给哪个商家的客服的。
  • 如果这个购买者之前没有聊过天,则在业务服务器逻辑里需要有一个分配客服的过程,即建立购买者和商家的客服之间的连接关系。拿到这个客服的 ID,用来做业务消息下发;如果之前已经聊过天,则略过此环节。
  • 在业务服务器,消息会异步入数据库。保证消息不会丢失。

步骤三

业务服务端以 HTTP 请求把消息发送到消息分发模块。这里分发模块的作用是进行中转,最终使服务端的消息下发给指定的商家。

步骤四

基于 Redis 集群中的用户连接信息,消息分发模块将消息转发到目标用户连接的 WebSocket 服务器(消息处理模块中的某一个 Worker)

  • 分发模块通过 RPC 方式把消息转发到目标用户连接的 Worker,RPC 的方式性能更快,而且传输的数据也少,从而节约了服务器的成本。
  • 消息透传 Worker 的时候,多种策略保障消息一定会下发到 Worker。

步骤五

消息处理模块将消息通过 WebSocket 协议推送到客户端:

  • 在投递的时候,接收者要有一个 ACK(应答) 信息来回馈给 Worker 服务器,告诉 Worker 服务器,下发的消息接收者已经收到了。
  • 如果接收者没有发送这个 ACK 来告诉 Worker 服务器,Worker 服务器会在一定的时间内来重新把这个信息发送给消息接收者。
  • 如果投递的信息已经发送给客户端,客户端也收到了,但是因为网络抖动,没有把 ACK 信息发送给服务器,那服务器会重复投递给客户端,这时候客户端就通过投递过来的消息 ID 来去重展示。

以上步骤的数据流转大致如图所示:

系统完整性设计

可靠性

  • 消息不丢失。为了避免消息丢失,我们设置了超时重传机制。服务端会在推送给客户端消息后,等待客户端的 ACK,如果客户端没有返回 ACK,服务端会尝试多次推送。目前默认 18s 为超时时间,重传 3 次不成功,断开连接,重新连接服务器。重新连接后,采用拉取历史消息的机制来保证消息完整。
  • 多端消息同步。客户端现有 PC 浏览器、Windows 客户端、H5、iOS/Android,系统允许用户多端同时在线,且同一端可以多个状态,这就需要保证多端、多用户、多状态的消息是同步的。我们用到了 Redis 的 Hash 存储,将用户信息、唯一连接对应值 、连接标识、客户端 IP、服务器标识、角色、渠道等记录下来,这样通过 key(uid) 就能找到一个用户在多个端的连接,通过 key+field 能定位到一条连接。

可用性

上文我们已经说过,因为是双层设计,就涉及到两个 Server 间的通信,同进程内通信用 Channel,非同进程用消息队列或者 RPC。综合性能和对服务器资源利用,我们最终选择 RPC 的方式进行 Server 间通信。在对基于 Go 的 RPC 进行选行时,我们比较了以下比较主流的技术方案:

  • Go STDRPC:Go 标准库的 RPC,性能最优,但是没有治理
  • RPCX:性能优势 2*GRPC + 服务治理
  • GRPC:跨语言,但性能没有 RPCX 好
  • TarsGo:跨语言,性能 5*GRPC,缺点是框架较大,整合起来费劲
  • Dubbo-Go:性能稍逊一筹, 比较适合 Go 和 Java 间通信场景使用

最后我们选择了 RPCX,因为性能也很好,也有服务的治理。

两个进程之间同样需要通信,这里用到的是 ETCD 实现服务注册发现机制。

当我们新增一个 Worker,如果没有注册中心,就要用到配置文件来管理这些配置信息,这挺麻烦的。而且你新增一个后,需要分发模块立刻发现,不能有延迟。

如果有新的服务,分发模块希望能快速感知到新的服务。利用 Key 的续租机制,如果在一定时间内,没有监听到 Key 有续租动作,则认为这个服务已经挂掉,就会把该服务摘除。

在进行注册中心的选型时,我们主要调研了 ETCD,ZK,Consul,三者的压测结果参考如下:

结果显示,ETCD 的性能是最好的。另外,ETCD 背靠阿里巴巴,而且属于 Go 生态,我们公司内部的 K8S 集群也在使用。

综合考量后,我们选择使用 ETCD 作为服务注册和发现组件。并且我们使用的是 ETCD 的集群模式,如果一台服务器出现故障,集群其他的服务器仍能正常提供服务。

通过保证服务和进程间的正常通讯,及 ETCD 集群模式的设计,保证了 IM 服务整体具有极高的可用性。

扩展性

消息分发模块和消息处理模块都能进行水平扩展。当整体服务负载高时,可以通过增加节点来分担压力,保证消息即时性和服务稳定性。

安全性

处于安全性考虑,我们设置了黑名单机制,可以对单一 uid 或者 ip 进行限制。比如在同一个 uid 下,如果一段时间内建立的连接次数超过设定的阈值,则认为这个 uid 可能存在风险,暂停服务。如果暂停服务期间该 uid 继续发送请求,则限制服务的时间相应延长。

性能优化和踩过的坑

性能优化

(1) JSON 编解码。开始我们使用官方的 JSON 编解码工具,但由于对性能方面的追求,改为使用滴滴开源的 Json-iterator,使在兼容原生 Golang 的 JSON 编解码工具的同时,效率上有比较明显的提升。以下是压测对比的参考图:

(2) time.After

在压测的时候,我们发现内存占用很高,于是使用 Go Tool PProf 分析 Golang 函数内存申请情况,发现有不断创建 time.After 定时器的问题,定位到是心跳协程里面。

原来代码如下:

优化后的代码为:

优化点在于 for 循环里不要使用 select + time.After 的组合。

(3) Map 的使用

在保存连接信息的时候会用到 Map。因为之前做 TCP Socket 的项目的时候就遇到过一个坑,即 Map 在协程下是不安全的。当多个协程同时对一个 Map 进行读写时,会抛出致命错误:fetal error:concurrent map read and map write,有了这个经验后,我们这里用的是 sync.Map

踩坑经验

(1) 协程异常

基于对开发成本和服务稳定性等问题的考虑,我们的 WebSocket 服务基于 Gorilla/WebSocket 框架开发。其中遇到一个问题,就是当读协程发生异常退出时,写协程并没有感知到,结果就是导致读协程已经退出但是写协程还在运行,直到触发异常之后才退出。这样虽然从表面上看不影响业务逻辑,但是浪费后端资源。在编码时应该注意要在读协程退出后主动通知写协程,这样一个小的优化可以这在高并发下能节省很多资源。

(2) 心跳设计

举个例子,之前我们在闲时心跳功能的开发中走了一些弯路。最初在服务器端的心跳发送是定时心跳,但后来在实际业务场景中使用时发现,设计成服务器读空闲时心跳更好。因为用户都在聊天呢,发一个心跳帧,浪费感情也浪费带宽资源。

这时候,建议大家在业务开发过程中如果代码写不下去就暂时不要写了,先结合业务需求用文字梳理下逻辑,可能会发现之后再进行会更顺利。

(3) 每天分割日志

日志模块在起初调研的时候基于性能考虑,确定使用 Uber 开源的 ZAP 库,而且满足业务日志记录的要求。日志库选型很重要,选不好也是影响系统性能和稳定性的。ZAP 的优点包括:

  • 显示代码行号这个需求,ZAP 支持而 Logrus 不支持,这个属于提效的。行号展示对于定位问题很重要。
  • ZAP 相对于 Logrus 更为高效,体现在写 JSON 格式日志时,没有使用反射,而是用内建的 json encoder,通过明确的类型调用,直接拼接字符串,最小化性能开销。

小坑:每天写一个日志文件的功能,目前 ZAP 不支持,需要自己写代码支持,或者请求系统部支持。

性能表现

压测 1:

上线生产环境并和业务方对接以及压测,目前定制业务已接通整个流程,写了一个 Client。模拟定期发心跳帧,然后利用 Docker 环境。开启了 50 个容器,每个容器模拟并发起 2 万个连接。这样就是百万连接打到单机的 Server 上。单机内存占用 30G 左右。

压测 2:

同时并发 3000、4000、5000 连接,以及调整发送频率,分别对应上行:60万、80 万、100 万、200 万, 一个 6k 左右的日志结构体。

其中有一半是心跳包 另一半是日志结构体。在不同的压力下的下行延迟数据如下:

结论:随着上行的并发变大,延迟控制在 24-66 毫秒之间。所以对于下行业务属于轻微延迟。另外针对 60 万 5k 上行的同时,用另一个脚本模拟开启 50 个协程并发下行 1k 的数据体,延迟是比没有并发下行的时候是有所提高的,延迟提高了 40ms 左右。

基于 Go 重构的 IM 服务在 WebSocket 的基础上,将业务层设计为配有消息分发模块和消息处理模块的双层架构模式,使业务逻辑的处理前置,保证了即时通讯服务的纯粹性和稳定性;同时消息分发模块的 HTTP 服务方便多种编程语言快速对接,使各业务线能迅速接入即时通讯服务。

IM 移动端架构

设计思路与整体架构

我们结合 B2C,C2B,C2C 不同的业务场景设计实现了马蜂窝旅游移动端中的私信、用户咨询、用户反馈等即时通讯业务;同时为了更好地为合作商家赋能,在马蜂窝商家移动端中加入与会话相关的咨询用户管理、客服管理、运营资源统计等功能。

目前 IM 涉及到的业务如下:

为了实现马蜂窝旅游 App 及商家 IM 业务逻辑、公共资源的整合复用及 UI 个性化定制,将问题拆解为以下部分来解决:

  • IM 数据通道与异常重连机制,解决不同业务实时消息下发以及稳定性保障;
  • IM 实时消息订阅分发机制,解决消息定向发送、业务订阅消费,避免不必要的请求资源浪费;
  • IM 会话列表 UI 绘制通用解决方案,解决不同消息类型的快速迭代开发和管理复杂问题;

整体实现结构分为 4 个部分进行封装,分别为下图中的数据管理、消息注册分发管理、通用 UI 封装及业务管理。

技术原理和实现过程

通用数据通道

对于常规业务展示数据的获取,客户端需要主动发起请求,请求和响应的过程是单向的,且对实时性要求不高。但对于 IM 消息来说,需要同时支持接收和发送操作,且对实时性要求高。为支撑这种要求,客户端和服务器之间需要创建一条稳定连接的数据通道,提供客户端和服务端之间的双向数据通信。

数据通道基础交互原理

为了更好地提高数据通道对业务支撑的扩展性,我们将所有通信数据封装为外层结构相同的数据包,使多业务类型数据使用共同的数据通道下发通信,统一分发处理,从而减少通道的创建数量,降低数据通道的维护成本。

常见的客户端与服务端数据交互依赖于 HTTP 请求响应过程,只有客户端主动发起请求才可以得到响应结果。结合马蜂窝的具体业务场景,我们希望建立一种可靠的消息通道来保障服务端主动通知客户端,实现业务数据的传递。目前采用的是 HTTP 长链接轮询的形式实现,各业务数据消息类型只需遵循约定的通用数据结构,即可实现通过数据通道下发给客户端。数据通道不必关心数据的具体内容,只需要关注接收与发送。

客户端数据通道实现原理

客户端数据通道管理的核心是维护一个业务场景请求栈,在不同业务场景切换过程中入栈不同的业务场景参数数据。每次 HTTP 长链接请求使用栈顶请求数据,可以模拟在特定业务场景 (如与不同的用户私信) 的不同处理。数据相关处理都集中封装在数据通道管理中,业务层只需在数据通道管理中注册对应的接收处理即可得到需要的业务消息数据。

消息订阅与分发

在软件系统中,订阅分发本质上是一种消息模式。非直接传递消息的一方被称为「发布者」,接受消息处理称为「订阅者」。发布者将不同的消息进行分类后分发给对应类型的订阅者,完成消息的传递。应用订阅分发机制的优势为便于统一管理,可以添加不同的拦截器来处理消息解析、消息过滤、异常处理机制及数据采集工作。

消息订阅

业务层只专注于消息处理,并不关心消息接收分发的过程。订阅的意义在于更好地将业务处理和数据通道处理解耦,业务层只需要订阅关注的消息类型,被动等待接收消息即可。

业务层订阅需要处理的业务消息类型,在注册后会自动监控当前页面的生命周期,并在页面销毁后删除对应的消息订阅,从而避免手动编写成对的订阅和取消订阅,降低业务层的耦合,简化调用逻辑。订阅分发管理会根据各业务类型维护订阅者队列用于消息接收的分发操作。

消息分发

数据通道的核心在于维护多消息类型各自对应的订阅者集合,并将解析的消息分发到业务层。

数据通道由多业务消息共用,在每次请求收到新消息列表后,根据各自业务类型重新拆分成多个消息列表,分发给各业务类型对应的订阅处理器,最终传递至业务层交予对应页面处理展示。

会话消息列表绘制

基于不同的场景,如社交为主的私信、用户服务为主的咨询反馈等,都需要会话列表的展示形式;但各场景又不完全相同,需要分析当前会话列表的共通性及可封装复用的部分,以更好地支撑后续业务的扩展。

消息在列表展示的组成结构

IM 消息列表的特点在于消息类型多、UI 展示多样化,因此需要建立各类型消息和布局的对应关系,在收到消息后根据消息类型匹配到对应的布局添加至对应消息列表。

消息类型与展示布局管理原理

对于不同消息类型及展示,问题的核心在于建立消息类型、消息数据结构、消息展示布局管理的映射关系。以上三者在实现过程中通过建立映射管理表来维护,各自建立列表存储消息类型/消息体封装结构/消息展示布局管理,设置对应关系关联 3 个列表来完成查找。

一次收发消息 UI 绘制过程

各类型消息在内容展示上各有不同,但整体会话消息展示样式可以分为 3 种,分别是接收消息、发送消息和处于页面中间的消息样式,区别只在于内部的消息样式。所以消息 UI 的绘制可以拆分成 2 个步骤,首先是创建通用的展示容器,然后再填充各消息具体的展示样式。

拆分的目的在于使各类型消息 UI 处理只需要关注特有数据。而如通用消息如头像、名称、消息时间、是否可举报、已读未读状态、发送失败/重试状态等都可以统一处理,降低修改维护的成本,同时使各消息 UI 处理逻辑更少、更清晰,更利于新类型的扩展管理。

收发到消息后,根据消息类型判断是「发送接收类型」还是「居中展示类型」,找到外层的布局样式,再根据具体消息类型找到特有的 UI 样式,拼接在外层布局中,得到完整的消息卡片,然后设置对应的数据渲染到列表中,完成整个消息的绘制。

细节优化 & 踩坑经验

在实现上述 IM 系统的过程中,我们遇到了很多问题,也做了很多细节优化。在这里总结实现时需要考虑的几点,以供大家借鉴。

消息去重

在前面的架构中,我们使用 msg_id 来标记消息列表中的每一条消息,msg_id 是根据客户端上传的数据,进行存储后生成的。

客户端 A 请求 IM 服务器之后生成 msg_id,再通过请求返回和 Polling 分发到客户端 A 和客户端 B。当流程成立的时候,客户端 A 和客户端 B 通过服务端分发的 msg_id 来进行本地去重。但这种方案存在以下问题:

当客户端 A 因为网络出现问题,无法接受对应发送消息的请求返回的时候,会触发重发机制。此时虽然 IM 服务器已经接受过一次客户端 A 的消息发送请求,但是因为无法确定两个请求是否来自同一条原始消息,只能再次接受,这就导致了重复消息的产生。解决的方法是引入客户端消息标识 id。因为我们已经依附旧有的 msg_id 做了很多工作,不打算让客户端的消息 id 代替 msg_id 的职能,因此重新定义一个 random_id。

random_id = random + time_stamp。random_id 标识了唯一的消息体,由一个随机数和生成消息体的时间戳生成。当触发重试的时候,两次请求的 random_id 会是相同的,服务端可以根据该字段进行消息去重。

本地化 Push

当我们在会话页或列表页的环境下,可以通过界面的变化很直观地观察到收取了新消息并更新未读数。但从会话页或者列表页退出之后,就无法单纯地从界面上获取这些信息,这时需要有其他的机制,让用户获知当前消息的状态。

系统推送与第三方推送是一个可行的选择,但本质上推送也是基于长链接提供的服务。为弥补推送不稳定性与风险,我们采用数据通道+本地通知的形式来完善消息通知机制。通过数据通道下发的消息如需达到推送的提示效果,则携带对应的 Push 展示数据。同时会对当前所处的页面进行判断,避免对当前页面的消息内容进行重复提醒。

通过这种数据通道+本地通知展示的机制,可以在应用处于运行状态的时间内提高消息抵达率,减少对于远程推送的依赖,降低推送系统的压力,并提升用户体验。

数据通道异常重连机制

当前数据通道通过 HTTP 长链接轮询 (Polling) 实现。不同业务场景下对 Polling 的影响如下图所示:

由于用户手机所处网络请求状态不一,有时候会遇到网络中断或者服务端异常的情况,从而终止 Polling 的请求。为能够让用户在网络恢复后继续会话业务,需要引入重连机制。

在重试机制 1.0 版本中,对于可能出现较多重试请求的情况,采取的是添加 60s 内连续 5 次报错延迟重试的限制。具体流程如下:

Polling 机制修改后请求量划分,相对之前请求分布比较均匀,不再出现集中请求的问题。

唯一会话标识

为何引入消息线 ID

消息线就是用来表示会话的聊天关系,不同消息线代表不同对象的会话,从 DB 层面来看需要一个张表来存储这种关系 uid + object_id + busi_type = 消息线 ID。

在 IM 初期实现中,我们使用会话配置参数(包含业务来源和会话参数)来标识会话 id,有三个作用:

  • 查找商家 id,获取咨询来源,进行管家分配
  • 查找已存在的消息线
  • 判断客户端页面状态,决定要不要下发推送,进行消息提醒

这种方式存在两个问题:

  • 通过业务来源和会话参数来解析对应的商家 id,两个参数缺失一个都会导致商家 id 解析错误,还要各种查询数据库才能得到商家 id,影响效率;
  • 通过会话类型切换接口标识当前会话类型,切换页面会频繁触发网络请求;如果请求接口发生意外容易引发消息内容错误问题,严重依赖客户端的健壮性

用业务来源和会话参数帮助我们进行管家分配是不可避免的,但我们可以通过引入消息线 ID 来绑定消息线的方式,替代业务来源和会话参数查找消息线的作用。另外针对下发推送的问题已通过上方讲述的本地推送通知机制解决。

何时创建消息线

  • 当进入会话页发消息时,检查 DB 中是否存在对应消息线,不存在则将这条消息 id 当作消息线 id 使用,存在即复用。
  • 当进入会话时,根据用户 id 、业务类型 id 等检查在 DB 中是否已存在对应消息线,不存在则创建消息线,存在即复用。

引入消息线目的

  • 减少服务端查询消息线的成本。
  • 移除旧版状态改变相关的接口请求,间接提高了推送触达率。
  • 降低移动端对于用户消息匹配的复杂度。

展望及近期优化

数据通道实现方式升级为 Websocket

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

与目前的 HTTP 轮询实现机制相比, Websocket 有以下优点:

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有 2 至 10 字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的 4 字节的掩码。相对于 HTTP 请求每次都要携带完整的头部,开销显著减少。
  • 更强的实时性。由于协议是全双工的,服务器可以随时主动给客户端下发数据。相对于 HTTP 需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和 Comet 等类似的长轮询比较,其也能在短时间内更多次地传递数据。
  • 保持连接状态。与 HTTP 不同的是,Websocket 需要先创建连接,这就使其成为一种有状态的协议,在之后通信时可以省略部分状态信息。而 HTTP 请求可能需要在每个请求都携带状态信息(如身份认证等)。
  • 更好的二进制支持。Websocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容。
  • 支持扩展。Websocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议,如部分浏览器支持压缩等。
  • 更好的压缩效果。相对于 HTTP 压缩,Websocket 在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。

为了进一步优化我们的数据通道设计,我们探索验证了 Websocket 的可行性,并进行了调研和设计:

近期将对 HTTP 轮询实现方案进行替换,进一步优化数据通道的效率。

业务功能的扩展

计划将 IM 移动端功能模块打造成通用的即时通讯组件,能够更容易地赋予各业务 IM 能力,使各业务快速在自有产品线上添加聊天功能,降低研发 IM 的成本和难度。目前的 IM 功能实现主要有两个组成,分别是公用的数据通道与 UI 组件。

随着马蜂窝业务发展,在现有 IM 系统上还有很多可以建设和升级的方向。比如消息类型的支撑上,扩展对短视频、语音消息、快捷消息回复等支撑,提高社交的便捷性和趣味性;对于多人场景希望增加群组,兴趣频道,多人音视频通信等场景的支撑等。

相信未来通过对更多业务功能的扩展及应用场景的探索,马蜂窝移动端 IM 将更好地提升用户体验,并持续为商家赋能。

参考链接:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注