Golang 在电商即时通信服务建设中的实践

马蜂窝技术原创文章,更多干货请搜索公众号:mfwtechphp

​即时通信(IM)功能对于电商平台来讲很是重要,特别是旅游电商。算法

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

本文咱们将结合马蜂窝旅游电商 IM 服务的发展历程,重点介绍基于 Go 的 IM 重构,但愿能够给有类似问题的朋友一些借鉴。编程

 

Part.1 技术背景和问题

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

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

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

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

 

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

2.1 实现目标

1. 业务解耦

将业务逻辑与通讯流程剥离,使 IM 服务架构更加清晰,实现与电商 IM 业务逻辑的彻底分离,保证服务稳定性。性能优化

2. 接入方式灵活

以前新业务接入时,须要在业务服务器上配置 OpenResty 环境及 Lua 协程代码,很是不便,IM 服务的通用性也不好。考虑到现有业务的实际状况,咱们但愿 IM 系统能够提供 HTTP 和 WebSocket 两种接入方式,供业务方根据不一样的场景来灵活使用。服务器

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

3. 架可扩展

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

2.2 语言选择

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

结合 IM 具体应用场景,咱们选择 Go 的缘由包括:

1. 性能

在性能上,尤为是针对网络通讯等 IO 密集型应用场景。Go 系统的性能更接近 C/C++。

2. 开发效率

Go 使用起来简单,代码编写效率高,上手也很快,尤为是对于有必定 C++ 基础的开发者,一周就能上手写代码了。

2.3 架构设计

总体架构图以下:

 

名词解释:

  • 客户:通常指购买商品的用户

  • 商家:提供服务的供应商,商家会有客服人员,提供给客户一个在线咨询的做用

  • 分发模块:即 Dispatcher,提供消息分发的给指定的工做模块的桥接做用

  • 工做模块:即 Worker 服务器,用来提供 WebSocket 服务,是真正工做的一个模块。

架构分层:

  • 展现层:提供 HTTP 和 WebSocket 两种接入方式。

  • 业务层:负责初始化消息线和业务逻辑处理。若是客户端以 HTTP 方式接入,会以 JSON 格式把消息发送给业务服务器进行消息解码、客服分配、敏感词过滤,而后下发到消息分发模块准备下一步的转换;经过 WebSocket 接入的业务则不须要消息分发,直接以 WebSocket 方式发送至消息处理模块中。

  • 服务层:由消息分发和消息处理这两层组成,分别以分布式的方式部署多个 Dispatcher 和 Worker 节点。Dispatcher 负责检索出接收者所在的服务器位置,将消息以 RPC 的方式发送到合适的 Worker 上,再由消息处理模块经过 WebSocket 把消息推送给客户端。

  • 数据层:Redis 集群,记录用户身份、链接信息、客户端平台(移动端、网页端、桌面端)等组成的惟一 Key。

2.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 来去重展现。

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

 

2.5 系统完整性设计

2.5.1 可靠性

(1)消息不丢失

为了不消息丢失,咱们设置了超时重传机制。服务端会在推送给客户端消息后,等待客户端的 ACK,若是客户端没有返回 ACK,服务端会尝试屡次推送。

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

(2)多端消息同步

客户端现有 PC 浏览器、Windows 客户端、H五、iOS/Android,系统容许用户多端同时在线,且同一端能够多个状态,这就须要保证多端、多用户、多状态的消息是同步的。

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

2.5.2 可用性

上文咱们已经说过,由于是双层设计,就涉及到两个 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 服务总体具备极高的可用性。

2.5.3 扩展性

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

2.5.4 安全性

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

2.6 性能优化和踩过的坑

2.6.1 性能优化

(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

2.6.2 踩坑经验

(1) 协程异常

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

(2) 心跳设计

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

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

(3) 天天分割日志

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

  • 显示代码行号这个需求,ZAP 支持而 Logrus 不支持,这个属于提效的。行号展现对于定位问题很重要。

  • ZAP 相对于 Logrus 更为高效,体如今写 JSON 格式日志时,没有使用反射,而是用内建的 json encoder,经过明确的类型调用,直接拼接字符串,最小化性能开销。

小坑:

天天写一个日志文件的功能,目前 ZAP 不支持,须要本身写代码支持,或者请求系统部支持。

 

Part.3 性能表现

压测 1:

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

压测 2:

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

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

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

 

Part.4 总结

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

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

因此咱们能够把 Go 称为云原生时代的母语。「云原生时代,是开发者最好的时代」,在这股浪潮下,咱们越早走进 Go,就可能越早在这个新时代抢占关键赛道。但愿更多小伙伴和咱们一块儿,加入到 Go 的开发和学习阵营中来,拓宽本身的技能图谱,拥抱云原生。

本文做者:Anti Walker,马蜂窝旅游网电商交易基础平台研发工程师。

相关文章
相关标签/搜索