马蜂窝技术原创文章,更多干货请搜索公众号:mfwtechphp
即时通信(IM)功能对于电商平台来讲很是重要,特别是旅游电商。算法
从商品复杂性来看,一个旅游商品可能会包括用户在将来一段时间的衣、食、住、行等方方面面;从消费金额来看,每每单次消费额度较大;对目的地的陌生、在行程中可能的问题,这些因素使用户在购买前、中、后都存在和商家沟通的强烈需求。能够说,一个好用的 IM 能够在必定程度上对企业电商业务的 GMV 起到促进做用。数据库
本文咱们将结合马蜂窝旅游电商 IM 服务的发展历程,重点介绍基于 Go 的 IM 重构,但愿能够给有类似问题的朋友一些借鉴。编程
与广义上的即时通信不一样,电商各业务线有其特有业务逻辑,如客服聊天系统的客人分配逻辑、敏感词检测逻辑等,这些每每要耦合进通讯流程中。随着接入业务线愈来愈多,即时通信服务冗余度会愈来愈高。同时整个消息链路追溯复杂,服务稳定性很受业务逻辑的影响。json
以前咱们 IM 应用中的消息推送主要基于轮询技术,消息轮询模块的长链接请求是经过 php-fpm 挂载在阻塞队列上实现。当请求量较大时,若是不能及时释放 php-fpm 进程,对服务器的性能消耗很大。后端
为了解决这个问题,咱们曾用 OpenResty+Lua 的方式进行改造,利用 Lua 协程的方式将总体的 polling 的能力从 PHP 转交到 Lua 处理,释放一部 PHP 的压力。这种方式虽然能提高一部分性能,但 PHP-Lua 的混合异构模式,使系统在使用、升级、调试和维护上都很麻烦,通用性也较差,不少业务场景下仍是要依赖 PHP 接口,优化效果并不明显。浏览器
为了解决以上问题,咱们决定结合电商 IM 的特定背景对 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 请求把消息发给业务服务器,业务服务端对消息进行业务逻辑处理。
(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 来去重展现。
以上步骤的数据流转大体如图所示:
为了不消息丢失,咱们设置了超时重传机制。服务端会在推送给客户端消息后,等待客户端的 ACK,若是客户端没有返回 ACK,服务端会尝试屡次推送。
目前默认 18s 为超时时间,重传 3 次不成功,断开链接,从新链接服务器。从新链接后,采用拉取历史消息的机制来保证消息完整。
客户端现有 PC 浏览器、Windows 客户端、H五、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 继续发送请求,则限制服务的时间相应延长。
开始咱们使用官方的 JSON 编解码工具,但因为对性能方面的追求,改成使用滴滴开源的 Json-iterator,使在兼容原生 Golang 的 JSON 编解码工具的同时,效率上有比较明显的提高。如下是压测对比的参考图:
在压测的时候,咱们发现内存占用很高,因而使用 Go Tool PProf 分析 Golang 函数内存申请状况,发现有不断建立 time.After 定时器的问题,定位到是心跳协程里面。
原来代码以下:
优化后的代码为:
优化点在于 for 循环里不要使用 select + time.After 的组合。
在保存链接信息的时候会用到 Map。由于以前作 TCP Socket 的项目的时候就遇到过一个坑,即 Map 在协程下是不安全的。当多个协程同时对一个 Map 进行读写时,会抛出致命错误:fetal error:concurrent map read and map write,有了这个经验后,咱们这里用的是 sync.Map
基于对开发成本和服务稳定性等问题的考虑,咱们的 WebSocket 服务基于 Gorilla/WebSocket 框架开发。其中遇到一个问题,就是当读协程发生异常退出时,写协程并无感知到,结果就是致使读协程已经退出可是写协程还在运行,直到触发异常以后才退出。这样虽然从表面上看不影响业务逻辑,可是浪费后端资源。在编码时应该注意要在读协程退出后主动通知写协程,这样一个小的优化能够这在高并发下能节省不少资源。
举个例子,以前咱们在闲时心跳功能的开发中走了一些弯路。最初在服务器端的心跳发送是定时心跳,但后来在实际业务场景中使用时发现,设计成服务器读空闲时心跳更好。由于用户都在聊天呢,发一个心跳帧,浪费感情也浪费带宽资源。
这时候,建议你们在业务开发过程当中若是代码写不下去就暂时不要写了,先结合业务需求用文字梳理下逻辑,可能会发现以后再进行会更顺利。
日志模块在起初调研的时候基于性能考虑,肯定使用 Uber 开源的 ZAP 库,并且知足业务日志记录的要求。日志库选型很重要,选很差也是影响系统性能和稳定性的。ZAP 的优势包括:
显示代码行号这个需求,ZAP 支持而 Logrus 不支持,这个属于提效的。行号展现对于定位问题很重要。
ZAP 相对于 Logrus 更为高效,体如今写 JSON 格式日志时,没有使用反射,而是用内建的 json encoder,经过明确的类型调用,直接拼接字符串,最小化性能开销。
小坑:
天天写一个日志文件的功能,目前 ZAP 不支持,须要本身写代码支持,或者请求系统部支持。
上线生产环境并和业务方对接以及压测,目前定制业务已接通整个流程,写了一个 Client。模拟按期发心跳帧,而后利用 Docker 环境。开启了 50 个容器,每一个容器模拟并发起 2 万个链接。这样就是百万链接打到单机的 Server 上。单机内存占用 30G 左右。
同时并发 3000、4000、5000 链接,以及调整发送频率,分别对应上行:60万、80 万、100 万、200 万, 一个 6k 左右的日志结构体。
其中有一半是心跳包 另外一半是日志结构体。在不一样的压力下的下行延迟数据以下:
结论:随着上行的并发变大,延迟控制在 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 的开发和学习阵营中来,拓宽本身的技能图谱,拥抱云原生。
本文做者:Anti Walker,马蜂窝旅游网电商交易基础平台研发工程师。