Golang在马蜂窝电商即时通讯服务建设中的实践
即时通讯(IM)功能对于电商平台尤其是旅游电商来说非常重要。
从产品复杂度来看,一款出行产品可能包含用户未来一段时间的衣、食、住、行等方方面面;从消费金额来看,个人消费金额往往较大;对于目的地行程中未知和可能出现的问题。这些因素意味着用户在购买前、购买中、购买后都非常需要与交易者进行沟通。可以说,一个好用的IM可以在一定程度上提升企业电商业务的GMV。
本文将结合马蜂窝旅游电商IM服务的发展历史,重点讨论基于Go的IM重构。希望能为有类似问题的朋友提供参考。
Part.1 技术背景与问题
与广义的即时通讯不同,电子商务的各个业务线都有自己独特的业务逻辑,比如客服聊天系统中的客户分配逻辑、敏感词检测逻辑等,这些往往需要链接到通信过程中。随着接入的业务领域越来越多,即时通讯服务的冗余度会越来越高。同时,整个消息链路溯源复杂,服务稳定性受业务逻辑影响较大。
以前我们IM应用中的消息推送主要是基于轮询技术。消息轮询模块的长连接请求是通过php-fpm在阻塞队列上实现的。当请求量较大时,如果php-fpm进程不能及时释放,将会消耗大量服务器性能。
为了解决这个问题,我们采用OpenResty+Lua进行改造,利用Lua协程的方式将PHP的整体轮询能力转移到Lua处理,释放了PHP的压力。这种方式虽然可以提升一些性能,但是PHP-Lua的混合异构模式使得系统使用、升级、调试和维护都比较困难,通用性也较差。在很多业务场景中,仍然需要依赖PHP接口。优化效果并不明显。
为了解决上述问题,我们决定基于电商IM的具体背景对IM服务进行重构。核心是实现业务逻辑与即时通讯服务的分离。 ?业务IM中的业务逻辑完全分离,保证服务稳定性。 ?考虑到现有业务的实际情况,我们希望IM系统能够提供HTTP和WebSocket两种接入方式,业务伙伴可以根据不同的场景灵活使用。
例如定制电商团队的待办事项系统连接并运行良好,定制游戏订单收集系统、投诉系统等下游相关系统等。这些公司没有明显的高并发要求,可以通过快速处理HTTP 访问无需熟悉稍微复杂的 WebSocket 协议,从而减少不必要的研发成本。
3。可扩展架构
为了应对业务对系统性能持续增长带来的挑战,我们考虑使用分布式架构来设计即时通讯服务,使系统具有持续扩展和改进的能力。 ?
根据IM的具体应用场景,我们选择Go的原因有:
1。性能
性能方面,尤其是网络通信等IO密集型应用场景。 Go系统的性能更接近C/C++。
2。开发效率
Go 易于使用、编码高效且快速上手。特别是对于有一定C++基础的开发者来说,一周就可以开始编写代码。 ?为客户在线咨询的作用
架构层:
- 表示层:提供HTTP和WebSocket两种访问方式。
- 业务层:负责初始化消息线和业务逻辑处理。如果客户端通过HTTP方式访问,消息会以JSON格式发送到企业服务器进行消息解码、客服分发、敏感词过滤,然后发送到消息分发模块,为下一步转换做准备;适用于通过 WebSocket 访问的公司。 WebSocket方式不需要消息分发,直接发送到消息处理程序。
- 服务层:由消息分发和消息处理两层组成。分布式部署多个Dispatcher和Worker节点。调度程序负责检索接收方的服务器位置,将消息发送到 RPC 中相应的 Worker,然后消息处理模块通过 WebSocket 将消息推送到客户端。
- 数据层:Redis集群,记录唯一密钥,由用户身份、连接信息、客户端平台(移动、Web、桌面)等组成。
2.4 服务流程
步骤1 如图上图右侧,用户客户端与消息处理模块建立WebSocket长连接。通过负载均衡算法,客户端连接到合适的服务器(消息处理模块中的worker)。连接成功后,用户连接信息,包括用户角色(访客或商户)、客户端平台(移动、Web、桌面)等,构成唯一密钥,注册到Redis集群中。 步骤2
如图左侧所示,当购买产品的用户想要向管家发送消息时,消息首先通过HTTP请求发送到企业服务器,企业服务器对消息进行业务逻辑处理。
(1) 这一步本身就是一个HTTP请求,因此可以连接到不同开发语言的客户端。消息以JSON格式发送到公司服务器。公司服务器首先对消息进行解码,然后获取用户想要将其发送到的零售商的客户服务信息。
(2) 如果该买家之前没有聊天过,那么业务服务器逻辑中一定有一个客服指派流程,即买家与商家客服之间建立连接。获取客服ID并用于传递业务消息;如果您之前已经聊天过,请跳过此步骤。
(3) 在公司服务器中,消息将异步输入到数据库中。确保消息不会丢失。
步骤3
企业服务器通过HTTP请求将消息发送到消息分发模块。这里分发模块的作用是将服务器的消息传送给指定的经销商。
步骤4
消息分发模块根据Redis集群中的用户连接信息,将消息转发至目标用户(消息处理模块中的worker)连接的WebSocket服务器
(1)分发模块使用RPC。 RPC方法将消息转发给目标用户连接的worker。 RPC方法执行速度更快,传输的数据更少,节省服务器成本。
(2) 当消息透传到worker时,有多种策略保证消息被传递到worker。
Step 5
消息处理模块通过WebSocket协议将消息推送到客户端:
(1) 消息交付后,接收方必须有ACK信息(回复)才能发送回Worker server,worker告诉Server已经收到接收者发送的消息。
(2) 如果接收方没有发送该ACK告诉Worker服务器,Worker服务器会在一定时间内再次向消息接收方发送该信息。
(3) 如果下发的信息已经发送给客户端,客户端也收到了,但由于网络抖动,ACK信息没有发送给服务器,那么服务器会重复下发给客户端,客户端就会通过。提供的消息 ID 来来去去又重新出现。
以上步骤的数据流程大致如图所示:

2.5 系统完整性设计
2.5.1 可靠性
(1) 不会丢失消息 ❝ 为了避免丢失,我们设置了 Timeout重传机制。服务器将消息推送给客户端后,会等待客户端的ACK。如果客户端没有返回ACK,服务器会尝试多次推送消息。 当前标准超时为 18 秒。如果重传3次失败,则断开与服务器的连接并重新连接。重连后,通过机制拉取历史消息,保证消息完整性。
(2)多端消息同步
客户端目前包括PC浏览器、Windows客户端、H5、iOS/Android。系统允许用户有多个终端同时在线,并且同一终端可以处于多种状态,这就需要保证多个终端同时在线。 、多用户、多状态消息同步。
我们使用Redis的Hash存储来记录用户信息、唯一连接匹配值、连接标识、客户端IP、服务器标识、角色、通道等,以便通过key(uid)可以在多个地方找到一个用户对于每个端到端连接,可以通过key+field来定位连接。
2.5.2 可用性
上面我们已经说了,因为是两层设计,所以涉及到两个服务器之间的通信。通道用于进程内通信,消息队列或 RPC 用于不同进程。考虑到广泛的性能和服务器资源的利用率,我们最终选择RPC进行服务器间通信。在选择基于 Go 的 RPC 时,我们对比了以下比较常见的技术方案:
- Go STDRPC:来自 Go 标准库的 RPC,性能最好,但没有治理
- RPCX:性能优势 2*GRPC + 服务治理
- GRPC:跨语言,但性能不如 RPCX
- TarsGo:跨语言,性能 5*GRPC,缺点是框架较大,集成困难
- Dubbo- Dubbo :性能稍逊一筹,更适合 Go 和 Java 之间的通信场景
最终我们选择了 RPCX,因为性能也很好,而且还有服务管理。
两个进程之间也需要通信。这里使用的是ETCD来实现服务注册发现机制。
当我们添加新的worker时,如果没有注册中心,就需要使用配置文件来管理配置信息,相当麻烦。当你添加一个新的时,分发模块应该立即找到它,没有延迟。
如果有新的业务,分发模块希望能够快速的注意到新的业务。利用Key的续租机制,如果在一定时间内Key上没有记录续租动作,则认为该服务失败,该服务将被移除。
选择注册中心时,我们主要考察了ETCD、ZK和Consul。三者的压力测试结果如下:


结果显示,ETCD 性能最好。另外,ETCD是阿里巴巴支持的,属于Go生态,我们公司内部的K8S集群也使用ETCD。
经过深思熟虑,我们选择使用ETCD作为服务注册和发现组件。并且我们使用ETCD的集群模式。如果一台服务器出现故障,集群中的其他服务器仍然可以正常提供服务。
通过保证服务和进程之间的正常通信,以及ETCD集群模式的设计,保证整体IM服务具有极高的可用性。
2.5.3 可扩展性
消息分发模块和消息处理模块都可以水平扩展。当整体业务负载较高时,可以通过添加节点来分担压力,保证消息即时性和服务稳定性。
2.5.4 安全
出于安全考虑,我们设置了黑名单机制,可以限制单个uid或ip。例如,同一uid下,一段时间内建立的连接数超过设定的阈值,则该uid可能被认为有风险,服务将被暂停。如果该uid在服务暂停期间继续发送请求,则限制服务的时间也会相应延长。
2.6 性能优化和陷阱
2.6.1 性能优化
(1)JSON编码和解码
首先我们使用了官方的JSON编码工具,但我们也使用了解码工具。改成了能够使用滴滴开源的Json迭代器,兼容原生Golang的JSON编解码工具,效率有显着提升。下面是压力测试对比的参考图:

(2)时间。之后
在压力测试过程中,我们发现内存消耗非常高,于是我们使用Go Tool PPof分析Golang函数内存申请情况,发现存在连续创建时间的问题。放置在心跳协程中。
原代码如下:

优化后的代码为:

优化点是在for循环中不要使用select + time.After的组合。
(3) 使用卡
卡用于存储连接信息。因为之前做TCP Socket项目的时候遇到过一个坑,就是Map在协程中不安全。当多个协程同时读写map时,会抛出致命错误:fetal error:同时读取map和写入map。经过这次经历,我们使用sync.Map
2.6.2 坑点经验
(1)协程异常
基于开发成本和服务稳定性的考虑,我们的WebSocket服务基于Gorilla框架/WebSock进行开发。遇到的一个问题是,当读协程异常终止时,写协程没有感知到。结果,读协程已经完成,但写协程仍在运行,直到抛出异常才会退出。虽然这表面上不影响业务逻辑,但却浪费了后端资源。编码时,注意在读协程完成后主动通知写协程。这样一个小小的优化可以在高并发时节省大量资源。
(2)心跳设计
比如我们在休闲心率功能的开发上就走了一些弯路。最初,服务器端发送的心跳是定时心跳,但后来在实际业务场景使用时发现,最好将心跳设计在服务器空闲时进行读取。因为用户都在聊天,发送心跳是一种情感和带宽资源的浪费。
这个时候,建议如果业务开发过程中写不出代码,就暂时停止编写。首先,您可以根据业务需求将逻辑整理成文字。你可能会发现后面会比较顺利。
(3)每天拆分日志

基于初探时的性能考虑,日志模块决定使用Uber开源ZAP库,满足企业日志需求。日志数据库的选择非常重要,选择不当也会影响系统的性能和稳定性。 ZAP 的优点包括:
- ZAP 支持显示代码行号的要求,但 Logrus 不支持。这是效率的提高。行号显示对于定位问题很重要。
- ZAP 比 Logrus 更有效。这体现在,在编写JSON格式日志时,没有使用反射,而是使用内置的json编码器,通过明确的类型调用直接拼接字符串,最大限度地降低性能开销。
小漏洞:
ZAP目前不支持每天写日志文件的功能。您必须编写自己的代码来支持它,或者请求系统部门的支持。 ?模拟定时发送心跳帧,然后使用Docker环境。启动了50个容器,每个容器模拟发起20000个连接。通过这种方式,数百万个连接被发送到单个服务器。单机内存占用30G左右。
压力测试2:
同时3000、4000、5000个连接,并调整发送频率,对应上行:60万、80万、200万、100万,一个日志结构。
一半是心跳包,另一半是日志结构。不同压力下的下游延迟数据如下:

结论:当上游并发量变大时,延迟控制在24-66毫秒之间。因此,下游服务略有延迟。另外,在上传60万个5k的同时,使用另一个脚本模拟打开50个协程同时下载1k的数据量。时延相比没有同时下行时有所改善,时延增加约40ms。
第四部分总结
基于Go重构的IM服务是基于WebSocket的。业务层设计为两层架构模型,配备消息分发模块和消息处理模块,提前处理业务逻辑。 、保证即时通讯服务的纯洁性和稳定性;同时消息分发模块中的HTTP服务方便多种编程语言的快速对接,让各业务线能够快速接入即时通讯服务。
最后,我要为Go点赞。很多人都知道,马蜂窝的技术体系主要基于PHP,一些核心公司也在向Java迁移。与此同时,Go 在越来越多的项目中发挥作用。现在,云原生概念已经逐渐成为主流趋势之一。我们可以看到,Go是构建云原生应用所需的很多核心项目中的主要开发语言,如Kubernetes、Docker、Istio、ETCD、Prometheus等,其中包括第三代开源分布式数据库TiDB。
所以我们可以称Go为云原生时代的原生语言。 “云原生时代是开发者最好的时代。”在这一波浪潮中,越早进入围棋,就能越早抢占新时代的关键赛道。希望更多的朋友加入我们的Go开发学习营,拓展自己的技能卡,拥抱云原生。
本文作者:Anti Walker,马蜂窝旅游网基础电商交易平台研发工程师。

当前标准超时为 18 秒。如果重传3次失败,则断开与服务器的连接并重新连接。重连后,通过机制拉取历史消息,保证消息完整性。
(2)多端消息同步
客户端目前包括PC浏览器、Windows客户端、H5、iOS/Android。系统允许用户有多个终端同时在线,并且同一终端可以处于多种状态,这就需要保证多个终端同时在线。 、多用户、多状态消息同步。
我们使用Redis的Hash存储来记录用户信息、唯一连接匹配值、连接标识、客户端IP、服务器标识、角色、通道等,以便通过key(uid)可以在多个地方找到一个用户对于每个端到端连接,可以通过key+field来定位连接。
2.5.2 可用性
上面我们已经说了,因为是两层设计,所以涉及到两个服务器之间的通信。通道用于进程内通信,消息队列或 RPC 用于不同进程。考虑到广泛的性能和服务器资源的利用率,我们最终选择RPC进行服务器间通信。在选择基于 Go 的 RPC 时,我们对比了以下比较常见的技术方案:
- Go STDRPC:来自 Go 标准库的 RPC,性能最好,但没有治理
- RPCX:性能优势 2*GRPC + 服务治理
- GRPC:跨语言,但性能不如 RPCX
- TarsGo:跨语言,性能 5*GRPC,缺点是框架较大,集成困难
- Dubbo- Dubbo :性能稍逊一筹,更适合 Go 和 Java 之间的通信场景
最终我们选择了 RPCX,因为性能也很好,而且还有服务管理。
两个进程之间也需要通信。这里使用的是ETCD来实现服务注册发现机制。
当我们添加新的worker时,如果没有注册中心,就需要使用配置文件来管理配置信息,相当麻烦。当你添加一个新的时,分发模块应该立即找到它,没有延迟。
如果有新的业务,分发模块希望能够快速的注意到新的业务。利用Key的续租机制,如果在一定时间内Key上没有记录续租动作,则认为该服务失败,该服务将被移除。
选择注册中心时,我们主要考察了ETCD、ZK和Consul。三者的压力测试结果如下:
结果显示,ETCD 性能最好。另外,ETCD是阿里巴巴支持的,属于Go生态,我们公司内部的K8S集群也使用ETCD。
经过深思熟虑,我们选择使用ETCD作为服务注册和发现组件。并且我们使用ETCD的集群模式。如果一台服务器出现故障,集群中的其他服务器仍然可以正常提供服务。
通过保证服务和进程之间的正常通信,以及ETCD集群模式的设计,保证整体IM服务具有极高的可用性。
2.5.3 可扩展性
消息分发模块和消息处理模块都可以水平扩展。当整体业务负载较高时,可以通过添加节点来分担压力,保证消息即时性和服务稳定性。
2.5.4 安全
出于安全考虑,我们设置了黑名单机制,可以限制单个uid或ip。例如,同一uid下,一段时间内建立的连接数超过设定的阈值,则该uid可能被认为有风险,服务将被暂停。如果该uid在服务暂停期间继续发送请求,则限制服务的时间也会相应延长。
2.6 性能优化和陷阱
2.6.1 性能优化
(1)JSON编码和解码
首先我们使用了官方的JSON编码工具,但我们也使用了解码工具。改成了能够使用滴滴开源的Json迭代器,兼容原生Golang的JSON编解码工具,效率有显着提升。下面是压力测试对比的参考图:
(2)时间。之后
在压力测试过程中,我们发现内存消耗非常高,于是我们使用Go Tool PPof分析Golang函数内存申请情况,发现存在连续创建时间的问题。放置在心跳协程中。
原代码如下:
优化后的代码为:
优化点是在for循环中不要使用select + time.After的组合。
(3) 使用卡
卡用于存储连接信息。因为之前做TCP Socket项目的时候遇到过一个坑,就是Map在协程中不安全。当多个协程同时读写map时,会抛出致命错误:fetal error:同时读取map和写入map。经过这次经历,我们使用sync.Map
2.6.2 坑点经验
(1)协程异常
基于开发成本和服务稳定性的考虑,我们的WebSocket服务基于Gorilla框架/WebSock进行开发。遇到的一个问题是,当读协程异常终止时,写协程没有感知到。结果,读协程已经完成,但写协程仍在运行,直到抛出异常才会退出。虽然这表面上不影响业务逻辑,但却浪费了后端资源。编码时,注意在读协程完成后主动通知写协程。这样一个小小的优化可以在高并发时节省大量资源。
(2)心跳设计
比如我们在休闲心率功能的开发上就走了一些弯路。最初,服务器端发送的心跳是定时心跳,但后来在实际业务场景使用时发现,最好将心跳设计在服务器空闲时进行读取。因为用户都在聊天,发送心跳是一种情感和带宽资源的浪费。
这个时候,建议如果业务开发过程中写不出代码,就暂时停止编写。首先,您可以根据业务需求将逻辑整理成文字。你可能会发现后面会比较顺利。
(3)每天拆分日志
基于初探时的性能考虑,日志模块决定使用Uber开源ZAP库,满足企业日志需求。日志数据库的选择非常重要,选择不当也会影响系统的性能和稳定性。 ZAP 的优点包括:
- ZAP 支持显示代码行号的要求,但 Logrus 不支持。这是效率的提高。行号显示对于定位问题很重要。
- ZAP 比 Logrus 更有效。这体现在,在编写JSON格式日志时,没有使用反射,而是使用内置的json编码器,通过明确的类型调用直接拼接字符串,最大限度地降低性能开销。
小漏洞:
ZAP目前不支持每天写日志文件的功能。您必须编写自己的代码来支持它,或者请求系统部门的支持。 ?模拟定时发送心跳帧,然后使用Docker环境。启动了50个容器,每个容器模拟发起20000个连接。通过这种方式,数百万个连接被发送到单个服务器。单机内存占用30G左右。
压力测试2:
同时3000、4000、5000个连接,并调整发送频率,对应上行:60万、80万、200万、100万,一个日志结构。
一半是心跳包,另一半是日志结构。不同压力下的下游延迟数据如下:
结论:当上游并发量变大时,延迟控制在24-66毫秒之间。因此,下游服务略有延迟。另外,在上传60万个5k的同时,使用另一个脚本模拟打开50个协程同时下载1k的数据量。时延相比没有同时下行时有所改善,时延增加约40ms。
第四部分总结
基于Go重构的IM服务是基于WebSocket的。业务层设计为两层架构模型,配备消息分发模块和消息处理模块,提前处理业务逻辑。 、保证即时通讯服务的纯洁性和稳定性;同时消息分发模块中的HTTP服务方便多种编程语言的快速对接,让各业务线能够快速接入即时通讯服务。
最后,我要为Go点赞。很多人都知道,马蜂窝的技术体系主要基于PHP,一些核心公司也在向Java迁移。与此同时,Go 在越来越多的项目中发挥作用。现在,云原生概念已经逐渐成为主流趋势之一。我们可以看到,Go是构建云原生应用所需的很多核心项目中的主要开发语言,如Kubernetes、Docker、Istio、ETCD、Prometheus等,其中包括第三代开源分布式数据库TiDB。
所以我们可以称Go为云原生时代的原生语言。 “云原生时代是开发者最好的时代。”在这一波浪潮中,越早进入围棋,就能越早抢占新时代的关键赛道。希望更多的朋友加入我们的Go开发学习营,拓展自己的技能卡,拥抱云原生。
本文作者:Anti Walker,马蜂窝旅游网基础电商交易平台研发工程师。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。