从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践

100 阅读24分钟
原文链接: www.52im.net

本文由马蜂窝技术团队电商交易基础平台研发工程师"Anti Walker"原创分享,原文链接见文末。


一、引言


即时通讯(IM)功能对于电商平台来说非常重要,特别是旅游电商。

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

本文我们将结合马蜂窝旅游电商IM系统的发展历程,单独介绍基于Go重构分布式IM系统过程中的实践和总结( 本文相当于《从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路 》一文的进阶篇),希望可以给有相似问题的朋友一些借鉴。

另外:如果你对Go在高并发系统中的应用感兴趣,即时通讯网的以下两篇也值得一读:


系列文章:


关于马蜂窝旅游网:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_202441mfyfm2gcf2fw3jef.jpg

马蜂窝旅游网是中国领先的自由行服务平台,由陈罡和吕刚创立于2006年,从2010年正式开始公司化运营。马蜂窝的景点、餐饮、酒店等点评信息均来自上亿用户的真实分享,每年帮助过亿的旅行者制定自由行方案。


二、相关文章


三、技术背景和问题


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

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

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

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

更多有关马蜂窝旅游网的IM系统架构的演进过程,请详读:《从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路》一文,在此不再赘述。

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


4.1、实现目标


1)业务解耦:

将业务逻辑与通信流程剥离,使 IM 服务架构更加清晰,实现与电商 IM 业务逻辑的完全分离,保证服务稳定性。

2)接入方式灵活:

之前新业务接入时,需要在业务服务器上配置 OpenResty 环境及 Lua 协程代码,非常不便,IM 服务的通用性也很差。考虑到现有业务的实际情况,我们希望 IM 系统可以提供 HTTP 和 WebSocket 两种接入方式,供业务方根据不同的场景来灵活使用。

比如已经接入且运行良好的电商定制化团队的待办系统、定制游抢单系统、投诉系统等下行相关的系统等,这些业务没有明显的高并发需求,可以通过 HTTP 方式迅速接入,不需要熟悉稍显复杂的 WebSocket 协议,进而降低不必要的研发成本。

3)架构可扩展:

为了应对业务的持续增长给系统性能带来的挑战,我们考虑用分布式架构来设计即时通讯服务,使系统具有持续扩展及提升的能力。

4.2、语言选择


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

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

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

4.3、架构设计


整体架构图如下:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_1.jpg

名词解释:

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

架构分层:

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

4.4、服务流程


步骤一:

如上图右侧所示:

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

步骤二:

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

1)该步骤本身是一个 HTTP 请求,所以可以接入各种不同开发语言的客户端。通过 JSON 格式把消息发送给业务服务器,业务服务器先把消息解码,然后拿到这个用户要发送给哪个商家的客服的。

2)如果这个购买者之前没有聊过天,则在业务服务器逻辑里需要有一个分配客服的过程,即建立购买者和商家的客服之间的连接关系。拿到这个客服的 ID,用来做业务消息下发;如果之前已经聊过天,则略过此环节。

3)在业务服务器,消息会异步入数据库。保证消息不会丢失。

步骤三:

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

步骤四:

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

1)分发模块通过 RPC 方式把消息转发到目标用户连接的 Worker,RPC 的方式性能更快,而且传输的数据也少,从而节约了服务器的成本。

2)消息透传 Worker 的时候,多种策略保障消息一定会下发到 Worker。

步骤五:

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

1)在投递的时候,接收者要有一个 ACK(应答) 信息来回馈给 Worker 服务器,告诉 Worker 服务器,下发的消息接收者已经收到了。

2)如果接收者没有发送这个 ACK 来告诉 Worker 服务器,Worker 服务器会在一定的时间内来重新把这个信息发送给消息接收者。

3)如果投递的信息已经发送给客户端,客户端也收到了,但是因为网络抖动,没有把 ACK 信息发送给服务器,那服务器会重复投递给客户端,这时候客户端就通过投递过来的消息 ID 来去重展示。

以上步骤的数据流转大致如图所示:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_2.jpg

4.5、系统完整性设计


4.5.1可靠性


(1)消息不丢失:

为了避免消息丢失,我们设置了超时重传机制。服务端会在推送给客户端消息后,等待客户端的 ACK,如果客户端没有返回 ACK,服务端会尝试多次推送。

目前默认 18s 为超时时间,重传 3 次不成功,断开连接,重新连接服务器。重新连接后,采用拉取历史消息的机制来保证消息完整。

(2)多端消息同步:

客户端现有 PC 浏览器、Windows 客户端、H5、iOS/Android,系统允许用户多端同时在线,且同一端可以多个状态,这就需要保证多端、多用户、多状态的消息是同步的。

我们用到了 Redis 的 Hash 存储,将用户信息、唯一连接对应值 、连接标识、客户端 IP、服务器标识、角色、渠道等记录下来,这样通过 key(uid) 就能找到一个用户在多个端的连接,通过 key+field 能定位到一条连接。

4.5.2可用性


上文我们已经说过,因为是双层设计,就涉及到两个 Server 间的通信,同进程内通信用 Channel,非同进程用消息队列或者 RPC。综合性能和对服务器资源利用,我们最终选择 RPC 的方式进行 Server 间通信。

在对基于 Go 的 RPC 进行选行时,我们比较了以下比较主流的技术方案:

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

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

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

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

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

在进行注册中心的选型时,我们主要调研了 ETCDZooKeeperConsul

三者的压测结果参考如下:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_3.jpg
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_4.jpg

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

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

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

4.5.3扩展性


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

4.5.4安全性


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

4.6、性能优化和踩过的坑


4.6.1性能优化


1)JSON 编解码:

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

以下是压测对比的参考图:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_5.jpg

2)time.After:

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

原来代码如下:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_6.jpg

优化后的代码为:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_7.jpg

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

3)Map 的使用:

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

4.6.2踩坑经验


1)协程异常:

基于对开发成本和服务稳定性等问题的考虑,我们的 WebSocket 服务基于 Gorilla/WebSocket 框架开发。其中遇到一个问题,就是当读协程发生异常退出时,写协程并没有感知到,结果就是导致读协程已经退出但是写协程还在运行,直到触发异常之后才退出。

这样虽然从表面上看不影响业务逻辑,但是浪费后端资源。 在编码时应该注意要在读协程退出后主动通知写协程,这样一个小的优化可以这在高并发下能节省很多资源。

2)心跳设计:

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

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

3)每天分割日志:

从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_8.jpg

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

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

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

五、性能表现


压测 1:

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

压测 2:

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

其中有一半是心跳包 另一半是日志结构体。在不同的压力下的下行延迟数据如下:
从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践_9.jpg

结论:

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

六、本文小结


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

最后,我还想为 Go 摇旗呐喊一下。很多人都知道马蜂窝技术体系主要是基于 PHP,有一些核心业务也在向 Java 迁移。与此同时,Go 也在越来越多的项目中发挥作用。现在,云原生理念已经逐渐成为主流趋势之一,我们可以看到在很多构建云原生应用所需要的核心项目中,Go 都是主要的开发语言,比如 Kubernetes,Docker,Istio,ETCD,Prometheus 等,包括第三代开源分布式数据库 TiDB。

所以我们可以把 Go 称为云原生时代的母语。「云原生时代,是开发者最好的时代」,在这股浪潮下,我们越早走进 Go,就可能越早在这个新时代抢占关键赛道。希望更多小伙伴和我们一起,加入到 Go 的开发和学习阵营中来,拓宽自己的技能图谱,拥抱云原生。

原文链接:sa.sogou.com/sgsearch/sg…

附录:更多IM架构设计方面的文章


[1] 有关IM架构设计的文章:
浅谈IM系统的架构设计
简述移动端IM开发的那些坑:架构设计、通信协议和客户端
一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
一套原创分布式即时通讯(IM)系统理论架构方案
从零到卓越:京东客服即时通讯系统的技术架构演进历程
蘑菇街即时通讯/IM服务器开发之架构选择
腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT
微信后台基于时间序的海量数据冷热分级架构设计实践
微信技术总监谈架构:微信之道——大道至简(演讲全文)
如何解读《微信技术总监谈架构:微信之道——大道至简》
快速裂变:见证微信强大后台架构从0到1的演进历程(一)
17年的实践:腾讯海量产品的技术方法论
移动端IM中大规模群消息的推送如何保证效率、实时性?
现代IM系统中聊天消息的同步和存储方案探讨
IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?
IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议
IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token
WhatsApp技术实践分享:32人工程团队创造的技术神话
微信朋友圈千亿访问量背后的技术挑战和实践总结
王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等
IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?
腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面
以微博类应用场景为例,总结海量社交系统的架构设计步骤
快速理解高性能HTTP服务端的负载均衡技术原理
子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践
知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路
IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列
微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)
微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)
新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践
一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践
阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史
阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路
社交软件红包技术解密(一):全面解密QQ红包技术方案——架构、技术实现等
社交软件红包技术解密(二):解密微信摇一摇红包从0到1的技术演进
社交软件红包技术解密(三):微信摇一摇红包雨背后的技术细节
社交软件红包技术解密(四):微信红包系统是如何应对高并发的
社交软件红包技术解密(五):微信红包系统是如何实现高可用性的
社交软件红包技术解密(六):微信红包系统的存储层架构演进实践
社交软件红包技术解密(七):支付宝红包的海量高并发技术实践
社交软件红包技术解密(八):全面解密微博红包技术方案
社交软件红包技术解密(九):谈谈手Q红包的功能逻辑、容灾、运维、架构等
即时通讯新手入门:一文读懂什么是Nginx?它能否实现IM的负载均衡?
即时通讯新手入门:快速理解RPC技术——基本概念、原理和用途
多维度对比5款主流分布式MQ消息队列,妈妈再也不担心我的技术选型了
从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路
从游击队到正规军(二):马蜂窝旅游网的IM客户端架构演进和实践总结
IM开发基础知识补课(六):数据库用NoSQL还是SQL?读这篇就够了!
瓜子IM智能客服系统的数据架构设计(整理自现场演讲,有配套PPT)
阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处
>> 更多同类文章 ……

[2] 更多其它架构设计相关文章:
腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面
快速理解高性能HTTP服务端的负载均衡技术原理
子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践
知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路
新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践
阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史
阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路
达达O2O后台架构演进实践:从0到4000高并发请求背后的努力
优秀后端架构师必会知识:史上最全MySQL大表优化方案总结
小米技术分享:解密小米抢购系统千万高并发架构的演进和实践
一篇读懂分布式架构下的负载均衡技术:分类、原理、算法、常见方案等
通俗易懂:如何设计能支撑百万并发的数据库架构?
多维度对比5款主流分布式MQ消息队列,妈妈再也不担心我的技术选型了
从新手到架构师,一篇就够:从100到1000万高并发的架构演进之路
美团技术分享:深度解密美团的分布式ID生成算法
12306抢票带来的启示:看我如何用Go实现百万QPS的秒杀系统(含源码)
>> 更多同类文章 ……