【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

美图三年优化总结:Golang 实现单机百万长连接服务

  • 2019-09-20
  • 本文字数:5012 字

    阅读完需:约 16 分钟

美图三年优化总结:Golang实现单机百万长连接服务

美图长连接服务历时三年,在内存优化上积累比较丰富的实践经验,本文将会介绍我们团队这些年在内存优化道路上做的一些尝试。

美图长连接服务简介

随着科技的飞速发展,技术的日新月异,长连接的运用场景日益增多。不仅在后端服务中被广泛运用,比较常见的有数据库的访问、服务内部状态的协调等,而且在 App 端的消息推送、聊天信息、直播弹字幕等场景长连接服务也是优选方案。长连接服务的重要性也在各个场合被业界专家不断提及,与此同时也引起了更为广泛地关注和讨论,各大公司也开始构建自己的长连接服务。


美图公司于 2016 年初开始构建长连接服务,与此同时, Go 在编程语言领域异军突起,考虑到其丰富的编程库,完善的工具链,简单高效的并发模型等优势,使我们最终选择 Go 去作为实现长连接服务的语言。在通信协议的选择上,考虑到 MQTT 协议的轻量、简单、易于实现的优点,选择了 MQTT 协议作为数据交互的载体。其整体的架构会在下文中做相应地介绍。


美图长连接服务(项目内部代号为 bifrost )已经历时三年,在这三年的时间里,长连接服务经过了业务的检验,同时也经历了服务的重构,存储的升级等,长连接服务从之前支持单机二十几万连接到目前可以支撑单机百万连接。在大多数长连接服务中存在一个共性问题,那就是内存占用过高,我们经常发现单个节点几十万的长连接,内存却占用十几 G 甚至更多,有哪些手段能降低内存呢?


本文将从多个角度介绍长连接服务在内存优化路上的探索,首先会先通过介绍当前服务的架构模型,Go 语言的内存管理,让大家清晰地了解我们内存优化的方向和关注的重要数据。后面会重点介绍我们在内存优化上做的一些尝试以及具体的优化手段,希望对大家有一定的借鉴意义。

架构模型

一个好的架构模型设计不仅能让系统有很好的可扩展性,同时也能在服务能力上有很好的体现。除此之外,在设计上多考虑数据的抽象、模块的划分、工具链的完善,这样不仅能让软件具有更灵活的扩展能力、服务能力更高,也提高系统的稳定性和健壮性以及可维护性。


在数据抽象层面抽象 pubsub 数据集合,用于消息的分发和处理。模块划分层面我们将服务一分为三:内部通讯(grpcsrv)、外部服务(mqttsrv)、连接管理(session)。工具链的方面我们构建了自动化测试,系统 mock ,压测工具。美图长连接服务架构设计如下:



图一 架构图


从架构图中我们可以清晰地看到由 7 个模块组成,分别是:conf 、grpcsrv 、mqttsrv、session、pubsub、packet、util ,每个模块的作用如下:


  • conf :配置管理中心,负责服务配置的初始化,基本字段校验。

  • grpcsrv :grpc 服务,集群内部信息交互协调。

  • mqttsrv :mqtt 服务,接收客户端连接,同时支持单进程多端口 MQTT 服务。

  • session :会话模块,管理客户端状态变化,MQTT 信息的收发。

  • pubsub :发布订阅模块,按照 Topic 维度保存 session 并发布 Topic 通知给 session。

  • packet:协议解析模块,负责 MQTT 协议包解析。

  • util :工具包,目前集成监控、日志、grpc 客户端、调度上报四个子模块。

Go 的内存管理

众所周知,Go 是一门自带垃圾回收机制的语言,内存管理参照 tcmalloc 实现,使用连续虚拟地址,以页( 8k )为单位、多级缓存进行管理。针对小于 16 byte 直接使用 Go 的上下文 P 中的 mcache 分配,大于 32 kb 直接在 mheap 申请,剩下的先使用当前 P 的 mcache 中对应的 size class 分配 ,如果 mcache 对应的 size class 的 span 已经没有可用的块,则向 mcentral 请求。如果 mcentral 也没有可用的块,则向 mheap 申请,并切分。如果 mheap 也没有合适的 span,则向操作系统申请。


Go 在内存统计方面做的也是相当出色,提供细粒度的内存分配、GC 回收、goroutine 管理等统计数据。在优化过程中,一些数据能帮助我们发现和分析问题,在介绍优化之前,我们先来看看哪些参数需要关注,其统计参数如下:


  • go_memstats_sys_bytes :进程从操作系统获得的内存的总字节数 ,其中包含 Go 运行时的堆、栈和其他内部数据结构保留的虚拟地址空间。

  • go_memstats_heap_inuse_bytes:在 spans 中正在使用的字节。其中不包含可能已经返回到操作系统,或者可以重用进行堆分配,或者可以将作为堆栈内存重用的字节。

  • go_memstats_heap_idle_bytes:在 spans 中空闲的字节。

  • go_memstats_stack_sys_bytes:栈内存字节,主要用于 goroutine 栈内存的分配。


在内存监控中根据 Go 将堆的虚拟地址空间划分为 span ,即对内存 8K 或更大的连续区域进行统计。span 可能处于以下三种状态之一 :


  1. idle 不包含对象或其他数据,空闲空间的物理内存可以释放回 OS (但虚拟地址空间永远不会释放),或者可以将其转换为使用中或栈空间;

  2. inuse 至少包含一个堆对象,并且可能有空闲空间来分配更多的堆对象;

  3. stack span 用于 goroutine 栈,栈不被认为是堆的一部分。span 可以在堆和堆栈内存之间更改,但它从来不会同时用于两者。


此外有一部分统计没有从堆内存中分配的运行时内部结构(通常因为它们是实现堆的一部分),与堆栈内存不同,分配给这些结构的任何内存都专用于这些结构,这些主要用于调试运行时内存开销。


虽然 Go 拥有了丰富的标准库、语言层面支持并发、内置 runtime,但相比 C/C++ 完成相同逻辑的情况下 Go 消耗内存相对增多。在程序的运行过程中,它的 stack 内存会随着使用而自动扩容,但在 stack 内存回收采用惰性回收方式,一定程度的导致内存消耗增多,此外还有 GC 机制也会带来额外内存的消耗。


Go 提供了三种内存回收机制:定时触发,按量触发,手动触发。在内存垃圾少量的情况下,Go 可以良好的运行。但是无论采用哪种触发方式,由于在海量用户服务的情况下造成的垃圾内存是巨大的,在 GC 执行过程中服务都会感觉明显的卡顿。这些也是目前长连接服务面对的难题,在下文中我将会逐一介绍我们如何减少和解决问题的产生的具体实践。

优化之路

在了解架构设计、Go 的内存管理、基础监控后,相信大家已经对当前系统有了一个大致的认识,先给大家展示一下内存优化的成果,下表一是内存优化前后的对比表,在线连接数基本相同的情况下,进程内存占用大幅度降低,其中 stack 申请内存降低约 5.9 G,其次 heap 使用内存降低 0.9 G,other 申请内存也小幅下降。那么我们是如何做到内存降低的呢?那接下来我将会把我们团队关于进行内存优化的探索和大家聊一聊。



表一 内存优化前后的对比表 备注:进程占用内存 ≈ 虚拟内存 - 未归还内存


在优化前随机抽取线上一台机器进行分析内存,通过监控发现当前节点进程占用虚拟内存为 22.3 G,堆区使用的内存占用 5.2 G ,堆区未归还内存为 8.9 G,栈区内存为 7.25 G,其它约占用 0.9 G,连接数为 225 K。


我们简单进行换算,可以看出平均一个链接占用的内存分别为:堆:23K,栈:32K。通过对比业内长连接服务的数据可以看出单个链接占用的内存偏大,根据监控数据和内存分配原理分析主要原因在:goroutine 占用、session 状态信息、pubsub 模块占用,我们打算从业务、程序、网络模式三个方面进行优化。


业务优化


上文中提到 session 模块主要是用于处理消息的收发,在实现时考虑到在通常场景中业务的消息生产大于客户端消息的消费速度的情况,为了缓解这种状况,设计时引入消息的缓冲队列,这种做法同样也有助于做客户端消息的流控。


缓冲消息队列借助 chan 实现 ,chan 大小根据经验将初始化默认配置为 128 。但在目前线上推送的场景中,我们发现,消息的生产一般小于消费的速度,128 缓冲大小明显偏大,因此我们把长度调整为 16 ,减少内存的分配。


在设计中 按照 topic 对客户端进行分组管理的算法中,采用空间换时间的方式,组合 map 和 list 两种数据结构对于客户端集合操作提供 O(1)的删除、O(1)的添加、O(n)的遍历。数据的删除采用标记删除方式,使用辅助 slice 结构进行记录,只有到达预设阈值才会进行真正的删除。虽然标记删除提高了遍历和添加的性能,但也同样带来了内存损耗问题。


大家一定好奇什么样的场景需要提供这样的复杂度,在实际中其场景有以下两种情况:


  1. 在实际的网络场景中,客户端随时都可能由于网络的不稳定断开或者重新建联,因此集合的增加和删除需要在常数范围内。

  2. 在消息发布的流程中,采用遍历集合逐一发布通知方式,但随着单个 topic 上的用户量的增加,经常会出现单个 topic 用户集合消息过热的问题,耗时太久导致消息挤压,因此针对集合的遍历当然也要求尽量快。


通过 benchamrk 数据分析,在标记回收 slice 长度在 1000 时,可以提供最佳的性能,因此默认配置阈值为 1000。在线上服务中,无特殊情况都是采用默认配置。但在当前推送服务的使用中,发现标记删除和延迟回收机制好处甚微,主要是因为 topic 和客户端为 1 : 1 方式,也就是不存在客户端集合,因此调整回收阈值大小为 2,减少无效内存占用。


上述所有优化,只要简单调整配置后服务灰度上线即可,在设计实现时通过 conf 模块动态配置,降低了服务的开发和维护成本。通过监控对比优化效果如下表,在优化后在线连接数比优化的在线连接更多的情况下, heap 使用内存使用数量由原来的 4.16G 下降到了 3.5G ,降低了约 0.66 G。



golang 代码优化


在实现上面展示的架构的时候发现在 session 模块 和 mqttsrv 模块之间存在很多共享变量,目前实现方式都是采用指针或者值拷贝的,由于 session 的数量和客户端数据量成正比也就导致消耗大量内存用于共享数据,这不仅仅增加 GC 压力,同样对于内存的消耗也是巨大的。就此问题思考再三,参考系统的库 context 的设计在架构中也抽象 context 包负责模块之间交互信息传递,统一分配内存。此外还参考他人减少临时变量的分配的优化方式,提高系统运行效率。主要优化角度参考如下:


  • 在频繁申请内存的地方,使用 pool 方式进行内存管理

  • 小对象合并成结构体一次分配,减少内存分配次数

  • 缓存区内容一次分配足够大小空间,并适当复用

  • slice 和 map 采用 make 创建时,容量可以通过预估大小指定

  • 调用栈避免申请较多的临时对象

  • 减少 []byte 与 string 之间转换,尽量采用 []byte 来字符串处理


目前系统具被完备的单元测试、集成测试,因此经过一周的快速的开发重构后灰度上线监控数据对比如下表:在基本相同的连接数上,heap 使用内存约占用降低 0.27G,stack 申请内存占用降低 3.81G。为什么 stack 会大幅度降低呢?


通过设置 stackDebug 重新编译程序追查程序运行过程,优化前 goroutine 栈的大多数在内存为 16K,通过减少临时变量的分配,拆分大函数处理逻辑,有效的减少触发栈的内存扩容(详细分析见参考文章),优化后 goroutine 栈内存降低到 8K 。一个连接需要启动两个 goroutine 负责数据的读和写,粗略计算一个连接减少约 16 K 的内存,23 w 连接约降低 3.68 G 内存。



网络模型优化


在 Go 语言的网络编程中经典的实现都是采用同步处理方式,创建两个 goroutine 分别处理读和写请求,goroutine 也不像 thread ,它是轻量级的。但对于一百万连接的情况,这种设计模式至少要创建两百万的 goroutine,其中一个 goroutine 使用栈的大小在 2 KB 到 8KB, 对于资源的消耗也是极大的。在大多数场景中,只有少数连接是有数据处理,大部分 goroutine 阻塞 IO 处理中。在因此可以借鉴 C 语言的设计,在程序中使用 epoll 模型做事件分发,在客户端成功完成 MQTT 连接后,将客户端的 FD 注册到 epoll 中,当有数据需要读或写,epoll 会通知上层创建对应的 goroutine 进行逻辑处理。


网络模型修改测试完成后开始灰度上线,通过监控数据对比如下表:在优化后比优化前的连接数多 10 K 的情况下,heap 使用内存降低 0.33 G,stack 申请内存降低 2.34 G,优化效果显著。


总结

在经过业务优化,临时内存优化,网络模型优化操作后,线上服务保证 21w 长连接在线实际内存占用约为 5.1 G。简单进行压测 100w 连接只完成建立连接,不进行其他操作约占用 10 G。长连接服务内存优化已经取得阶段性的成功,但是这仅仅是我们团队的一小步,未来还有更多的工作要做:网络链路、服务能力,存储优化等,这些都是亟待探索的方向。如果大家有什么好的想法,欢迎与我们团队分享,共同探讨。


bifrost 项目目前我们有开源计划,敬请大家期待。


作者介绍


王鸿佳,系统研发工程师,现任职于美图公司,主要从事通讯及存储相关领域的研发。参与了通用长连接通道、美图推送、分布式数据库(Titan 已开源)、路由分发器等项目研发。对基础研发技术及开源项目有浓厚的兴趣。


本文转载自公众号美图技术(ID:meitu_tech)


原文链接


https://mp.weixin.qq.com/s/pirZEXZ74wkQQ69RRXLGWw


2019-09-20 08:004398

评论 1 条评论

发布
用户头像
比如go routine ,它自身占用的内存大小怎么查看呢?
2021-09-02 23:04
回复
没有更多了
发现更多内容

项目管理常见问题系列(1)—资源不足

一叶而不知秋

项目管理

我是一个程序员,总想引导亲朋好友走上编程的伟大航路......

图灵教育

程序员 App Inventor

第一本 Compose 图书上市,联想大咖教你学会 Android 全新 UI 编程

图灵教育

Compose AndroidUI

手把手教你学Dapr - 2. 必须知道的概念

MASA技术团队

C# .net 微软 后端 dapr

开源数据库风起云涌,openGauss 恰逢其时

openGauss

#数据库

极光笔记丨关于数据大屏一比一还原设计稿这件事

极光JIGUANG

大前端 数据可视化

Python代码阅读(第58篇):压缩列表

Felix

Python 编程 列表 阅读代码 Python初学者

一招教你通过焱融 SaaS 数据服务平台+ELK 让日志帮你做决策

焱融科技

云计算 分布式 SaaS 公有云 文件存储

Nebula Graph 源码解读系列 | Vol.04 基于 RBO 的 Optimizer 实现

NebulaGraph

图数据库 源码解读

模块三作业——外包学生管理系统架构设计

覃飞

企业如何选择合适的低代码平台?这6点不得不考虑!

J2PaaS低代码平台

低代码 低代码开发 低代码平台 企业数字化

Web 用户体验设计提升实践

Shopee技术团队

大前端 web开发 用户体验 交互设计 可访问性

【云小课】如何初步定位GaussDB(for openGauss)慢SQL

华为云数据库小助手

GaussDB GaussDB(for openGauss) 华为云数据库

手把手教你学Dapr - 1. .Net开发者的大时代

MASA技术团队

C# .net 微软 后端 dapr

LevelDB Java&Go实践

FunTester

Java 自学 Go 语言 leveldb FunTester

如何穿透ToB客户生命周期的全链增长?

ToB行业头条

首次!统一调度系统规模化落地,全面支撑阿里巴巴双 11 全业务

阿里巴巴中间件

阿里云 云原生 中间件 双十一 统一调度

我所理解的社群—社群本质

sec01张云龙

社群 11月日更 社群运营

浅谈 RDMA 与无损网络

青云技术社区

云计算 云原生 存储

盲盒app开发

月薪3万的大厂测试工程师裸辞3个月,送外卖谋生背后的真实感悟

六十七点五

程序员 程序人生 软件测试 软件自动化测试 测试工程师

速来!开源中国首届飞算SoFlu组件开发悬赏赛来袭

SoFlu软件机器人

Java

前端的状态管理与时间旅行:San实践篇

百度开发者中心

大前端 san san-store 技术实践

openGauss支持国密SM3和SM4算法

openGauss

#数据库

经验分享|参与内部开源的心路历程

云智慧AIOps社区

大前端 数据可视化 知识分享 开源治理 flyfish

CSS布局之display:flex(二)

Augus

CSS 11月日更

就是简单,全球100多万读者,一起跑通前端HTML5与CSS3知识!

图灵教育

大前端 HTML5, CSS3

不要再重复造轮子了,Hutool这款开源工具类库贼好使

沉默王二

Java

拥抱智能,AI 视频编码技术的新探索

阿里云视频云

阿里云 视频编码 机器视觉 视频编解码 视频云

Nginx中间件渗透总结

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 漏洞挖掘

从 Linux源码 看 Socket(TCP)的accept

赖猫

c++ Linux 后端 服务器 epoll

美图三年优化总结:Golang实现单机百万长连接服务_软件工程_王鸿佳_InfoQ精选文章