Golang内存优化实践指南

最近做了许多有关Go内存优化的工作,总结了一些定位、调优方面的套路和经验,于是,想通过这篇文章与大家分享讨论。 发现问题 性能优化领域有一条总所周知的铁律,即:不要过早地优化。编写一个程序,首先应该保证其功能的正确性,以及诸如设计是否合理、需求等是否满足,过早地优化只会引入不必要的复杂度以及设计不合理等各种问题。 那么何时才能开始优化呢?一句话,问题出现时。诸如程序出现频繁OOM,CPU使用率异常偏高等情况。如今,在这微服务盛行的时代,公司内部都会拥有一套或简单或复杂的监控系统,当系统给你发出相关告警时,你就要开始重视起来了。 问题定位 1. 查看内存曲线 首先,当程序发生OOM时,首先应该查看程序的内存使用量曲线,可以通过现有监控系统查看,或者prometheus之类的开源工具。 曲线一般都是呈上升趋势,比如goroutine泄露的曲线一般是使用量缓慢上升直至OOM,而内存分配不合理往往时在高负载时快速攀升以致OOM。 2. 问题复现 这块是可选项,但是最好能保证复现。如果能在本地或debug环境复现问题,这将非常有利于我们反复进行测试和验证。 3. 使用pprof定位 Go官方工具提供了pporf来专门用以性能问题定位,首先得在程序中开启pprof收集功能,这里假定问题程序已开启pprof。(对这块不够了解的同学,建议通过这两篇文章(1, 2)学习下pprof工具的基本用法) 接下来,我们复现问题场景,并及时获取heap和groutine的采样信息。 获取heap信息: curl http://loalhost:6060/debug/pprof/heap -o h1.out 获取groutine信息:curl http://loalhost:6060/debug/pprof/goroutine -o g1.out 这里你可能想问,这样就够了吗? 当然不是,只获取一份样本信息是不够的。内存使用量是不断变化的(通常是上升),因此我们需要的也是期间heap、gourtine信息的变化信息,而非瞬时值。一般来说,我们需要一份正常情况下的样本信息,一份或多份内存升高期间的样本信息。 数据收集完毕后,我们按照如下3个方面来排查定位。 排查goroutine泄露 使用命令go tool pprof --base g1.out g2.out ,比较goroutine信息来判断是否有goroutine激增的情况。 进入交互界面后,输入top命令,查看期间goroutine的变化。 同时可执行go tool pprof --base g2.out g3.out来验证。我之前写了的一篇实战文章,记录了goroutine泄露的排查过程。 排查内存使用量 使用命令go tool pprof --base h1.out h2.out,比较当前堆内存的使用量信息来判断内存使用量。 进入交互界面后,输入top命令,查看期间堆内存使用量的变化。 排查内存分配量 当上述排查方向都没发现问题时,那就要查看期间是否有大量的内存申请了,以至于GC都来不及回收。使用命令go tool pprof --alloc_space --base h1.out h2.out,通过比较前后内存分配量来判断是否有分配不合理的现象。 进入交互界面后,输入top命令,查看期间堆内存分配量的变化。 一般来说,通过上述3个方面的排查,我们基本就能定位出究竟是哪方面的问题导致内存激增了。我们可以通过web命令,更为直观地查看问题函数(方法)的完整调用链。 问题优化 定位到问题根因后,接下来就是优化阶段了。这个阶段需要对Go本身足够熟悉,还得对问题程序的业务逻辑有所了解。 我梳理了一些常见的优化手段,仅供参考。实际场景还是得实际分析。 goroutine泄露 这种问题还是比较好修复的,需要显式地保证goroutine能正确退出,而非以一些自以为的假设来保证。例如,通过传递context.Context对象来显式退出 go func(ctx context.Context) { for { select { case <-ctx....

一月 9, 2021 · 1 分钟 · wmingj

一次抓包排查实战记录

问题的发现 周五,本是一个风清气爽,令人愉悦的日子。我本还在美滋滋地等待着下班,然而天有不测,有用户反应容器日志看不到了,根据经验我知道,日志采集&收集链路上很可能又发生了阻塞。 登录目标容器所在机器找到日志采集容器,并娴熟地敲下docker logs --tail 200 -f <container-id>命令,发现确实阻塞了,阻塞原因是上报日志的请求500了,从而不断重试导致日志采集阻塞。 随后,我找到收集端的容器,查看日志,发现确实有请求报500了,并且抛出了Unknown value type的错误,查看相关代码。 业务代码: if _, err := jsonparser.ArrayEach(body, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { ... }); err != nil { return err // 错误抛出点 } jsonparser包中代码: 显然问题出在了对body的解析上,究竟是什么样的body导致了解析错误呢?接下来,就是tcpdump和wireshark上场的时候了。 使用Tcpdump抓包 首先,我们通过tcpdump抓到相关的请求。由于日志采集端会不断重试,因此最简单的方法便是登录采集端所在机器,并敲下如下命令tcpdump -i tunl0 dst port 7777 -w td.out ,并等待10-20秒。 熟悉tcpdump的小伙伴,对这条命令显然已经心领神会了。尽管如此,这里我还是稍微解释下。 -i tunl0:-i 参数用来指定网卡,由于采集器并没有通过eth0。因此,实战中,有时发现命令正确缺抓不到包的情况时,不妨指定下别的网卡。网络错综复杂,不一定都会通过eth0网卡。 dst port 777: 指定了目标端口作为过滤参数,收集端程序的端口号是7777 -w td.out: 表明将抓包记录保存在td.out文件中,这是因为json body是用base64编码并使用gzip加密后传输的,因此我得使用wireshark来抽离出来。(主要还是wireshark太香了:),界面友好,操作简单,功能强大) 接着,我用scp命令将td.out文件拷到本地。并使用wireshar打开它 使用Wireshark分析 打开后,首先映入眼帘的则是上图内容,看起来很多?不要慌,由于我们排查的是http请求,在过滤栏里输入HTTP,过滤掉非HTTP协议的记录。 我们可以很清楚地发现,所有的HTTP都是发往一个IP的,且长度都是59,显然这些请求都是日志采集端程序不断重试的请求。接下来,我们只需要将某个请求里的body提取出来查看即可。 很幸运,wireshark提供了这种功能,如上图所示,我们成功提取出来body内容。为bnVsbA==,使用base64解码后为null。 解决问题 既然body的内容为null,那么调用jsonparser.ArrayEach报错也是意料之中的了,body内容必须得是一个JsonArray。 然而,采集端为何会发送body为null的请求呢,深入源码,发现了如下一段逻辑。 func (e *jsonEncoder) encode(obj []publisher....

八月 2, 2020 · 1 分钟 · wmingj

Golang中的map实现

总所周知,大多数语言中,字典的底层是哈希表,而且其算法也是十分清晰的。无论采用链表法还是开放寻址法,我们都能实现一个简单的哈希表结构。对于Go来说,它是具体如何实现哈希表的呢?以及,采取了哪些优化策略呢? 内存模型 map在内存的总体结构如下图所示。 头部结构体hmap type hmap struct { count int // 键值对个数 flags uint8 B uint8 // 2^B = 桶数量 noverflow uint16 // 溢出桶的个数 hash0 uint32 // hash seed buckets unsafe.Pointer // 哈希桶 oldbuckets unsafe.Pointer // 原哈希桶,扩容时为非空 nevacuate uintptr // 扩容进度,地址小于它的桶已被迁移了 extra *mapextra // optional fields } hmap即为map编译后的内存表示,这里需要注意的有两点。 B的值是根据负载因子(LoadFactor)以及存储的键值对数量,在创建或扩容时动态改变 buckets是一个指针,它指向一个bmap结构 桶结构体bmap type bmap struct { // tophash数组可以看做键值对的索引 tophash [bucketCnt]uint8 // 实际上编译器会动态添加下述属性 // keys [8]keytype // values [8]valuetype // padding uinptr // overflow uinptr } 虽然bmap结构体中只有一个tophash数组,但实际上,其后跟着8个key的槽位、8个value的槽位、padding以及一个overflow指针。如下图所示...

二月 1, 2020 · 2 分钟 · wmingj

用Golang实现并理解Web中间件

在编写web应用中,我们常常会遇到这样的需求,比如,我们需要上报每个API的运行时间到运维监控系统。这时候你可以像下述代码一样将统计的逻辑写到每个路由函数中。 ...

十二月 20, 2019 · 1 分钟 · wmingj

Golang中的string实现

说到string类型,我们往往都能很熟练地对它进行各种处理,包括迭代、随机访问和匹配等等操作。然而在工作中,我发现迭代一个字符串产生的字符的类型与随机访问一个字符的类型却并不相同,为什么会这么奇怪呢?于是我决定一探究竟 ...

十二月 11, 2019 · 1 分钟 · wmingj