Round
的功能,它最少支持常用的 Round half up
算法。而在 Go 语言中这似乎成为了难题,在 stackoverflow 上搜索 [go] Round
会存在大量相关提问,Go 1.10 开始才出现 math.Round
的身影,本以为 Round
的疑问就此结束,但是一看函数注释 Round returns the nearest integer, rounding half away from zero
,这是并不常用的 Round half away from zero
实现呀,说白了就是我们理解的 Round
阉割版,精度为 0 的 Round half up
实现,Round half away from zero
的存在是为了提供一种高效的通过二进制方法得结果,可以作为 Round
精度为 0 时的高效实现分支。
带着对 Round
的‘敬畏’,我在 stackoverflow 翻阅大量关于 Round
问题,开启寻求最佳的答案,本文整理我认为有用的实现,简单分析它们的优缺点,对于不想逐步了解,想直接看结果的小伙伴,可以直接看文末的最佳实现,或者跳转 exmath.Round 直接看源码和使用吧!
在 stackoverflow 问题中的最佳答案首先获得我的关注,它在 mathx.Round 被开源,以下是代码实现:
1 | //source: https://github.com/icza/gox/blob/master/mathx/mathx.go |
这个实现非常的简洁,借用了 math.Round
,由此看来 math.Round
还是很有价值的,大致测试了它的性能一次运算大概 0.4ns
,这非常的快。
但是我也很快发现了它的问题,就是精度问题,这个是问题中一个回答的解释让我有了警觉,并开始了实验。他认为使用浮点数确定精度(mathx.Round
的第二个参数)是不恰当的,因为浮点数本身并不精确,例如 0.05 在64位IEEE浮点数中,可能会将其存储为0.05000000000000000277555756156289135105907917022705078125
。
1 | //source: https://play.golang.org/p/0uN1kEG30kI |
以上代码可以在 Go Playground 上运行,得到结果并非如期望那般,这个问题主要出现在 math.Round(x/unit)
与 unit
运算时,math.Round
运算后一定会是一个精确的整数,但是 0.0001
的精度存在误差,所以导致最终得到的结果精度出现了偏差。
在这个问题中也有人提出了先用 fmt.Sprintf
对结果进行格式化,然后再采用 strconv.ParseFloat
反向解析,Go Playground 代码在这个里。
1 | source: https://play.golang.org/p/jxILFBYBEF |
这段代码中有点问题,第一是结果不对,和我们理解的存在差异,后来一看第二个参数传错了,应该是 0.01
,我想试着调整调整精度吧,我改成了 0.0001
之后发现一直都是保持小数点后两位,我细细研究了下这段代码的逻辑,发现 fmt.Sprintf("%.2f", rounded)
中写死了保留的位数,所以它并不通用,我尝试如下简单调整一下使其生效。
1 | package main |
确实获得了满意的精准度,但是其性能也非常客观,达到了 215ns/op
,暂时看来如果追求精度,这个算法目前是比较完美的。
很快我发现了另一个极简的算法,它的精度和速度都非常的高,实现还特别精简:
1 | package main |
这并不通用,除非像以下这么包装:
1 | func Round(x, unit float64) float64 { |
unit
参数和之前的概念不同了,保留一位小数 uint =10
,只是整数 uint=1
, 想对整数部分进行精度控制 uint=0.01
例如: Round(1555.15807659924030304, 0.01) = 1600
,Round(1555.15807659924030304, 1) = 1555
,Round(1555.15807659924030304, 10000) = 1555.1581
。
这似乎就是终极答案了吧,等等……
上面的方法够简单,也够高效,但是 api 不太友好,第二个参数不够直观,带了一定的心智负担,其它语言都是传递保留多少位小数,例如 Round(1555.15807659924030304, 0) = 1555
,Round(1555.15807659924030304, 2) = 1555.16
,Round(1555.15807659924030304, -2) = 1600
,这样的交互才符合人性啊。
别急我在 go-extend 开源了 exmath.Round,其算法符合通用语言 Round
实现,且遵循 Round half up
算法要求,其性能方面在 3.50ns/op
, 具体可以参看调优exmath.Round算法, 具体代码如下:
1 | //source: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go |
Round
功能虽简单,但是受到 float
精度影响,仍然有很多人在四处寻找稳定高效的算法,参阅了大多数资料后精简出 exmath.Round 方法,期望对其他开发者有所帮助,至于其精度使用了大量的测试用例,没有超过 float
精度范围时并没有出现精度问题,未知问题等待社区检验,具体测试用例参见 round_test。
Once is an object that will perform exactly one action
, 即 Once
是一个对象,它提供了保证某个动作只被执行一次功能,最典型的场景就是单例模式,Once
可用于任何符合 “exactly once” 语义的场景。在多数情况下,sync.Once
被用于控制变量的初始化,这个变量的读写通常遵循单例模式,满足这三个条件:
在标准库中不乏有大量 sync.Once
的使用案例,在 strings
包中 replace.go
里实现字符串批量替换功能时,需要预编译生成替换规则,即采用不同的替换算法并创建相关算法实例,因 strings.Replacer
实现是线程安全且支持规则复用,在第一次解析替换规则并创建对应算法实例后,可以并发的进行字符串替换操作,避免多次解析替换规则浪费资源。
先看一下 strings.Replacer
的结构定义:
1 | // source: strings/replace.go |
这里定义了 once sync.Once
用来控制 r replacer
替换算法初始化,当我们使用 strings.NewReplacer
创建 strings.Replacer
时,这里采用惰性算法,并没有在这时进行 build
解析替换规则并创建对应算法实例,而是在执行替换时( Replacer.Replace
和 Replacer.WriteString
)进行的, r.once.Do(r.buildOnce)
使用 sync.Once
的 Do
方法保证只有在首次执行时才会执行 buildOnce
方法,而在 buildOnce
中调用 build
解析替换规则并创建对应算法实例,在 buildOnce
中进行赋值。
1 | // source: strings/replace.go |
简单来说,once.Do
中的函数只会执行一次,并保证 once.Do
返回时,传入 Do
的函数已经执行完成。多个 goroutine
同时执行 once.Do
的时候,可以保证抢占到 once.Do
执行权的 goroutine
执行完 once.Do
后,其他 goroutine
才能得到返回。
once.Do
接收一个函数作为参数,该函数不接受任何参数,不返回任何参数。具体做什么由使用方决定,错误处理也由使用方控制,对函数初始化的结果也由使用方进行保存。
这给出了一种错误处理的例子 exec.closeOnce
,exec.closeOnce
保证了重复关闭文件,永远只执行一次,并且总是返回首次关闭产生的错误信息:
1 | // source: os/exec/exec.go |
Once
的实现非常的灵活、简洁、高效,排除注释部分 Once
仅用 17 行实现,且单次执行时间在 0.3ns 左右。这让我十分敬佩,对它可谓喜爱至极,但因为它的通用性,在使用 Once
时给我带来了一些小小的负担,这也成了我极少的使用它的原因。
Once
只保证调用安全性(即线程安全以及只执行一次动作函数),但是细心的朋友一定发现了我们往往需要配对定义 Once
和业务实例变量,极少使用的情况下(如上述两个例子)看起来并没有什么负担,但是如果我们项目中有大量实例进行管理时(一般是集中管理,便于解决依赖问题),这时就会变得有点丑陋。
一个实际的业务场景,我有一个 http
服务,它有数百个组件实例,我们创建了一个 APP
用来管理所有实例的初始化、依赖关系,从而保证各个组件依赖其接口,相互之间进行解耦,也使得每个组件的配置(初始化参数)、依赖易于管理,不过我们常常对单例实例在 http
服务启动时进行初始化,这样避免使用 Once
,且可以在 http
服务启动时暴露外部依赖问题(数据库、其它服务等)。
这个 http
服务需要很多辅助命令,每个命令负责极少的工作,如果我在命令启动时使用 APP
初始化所有组件,这造成了大量的资源浪费。我单独实现一个 Command
依赖管理组件,它大量使用 Once
保证各个组件只在第一次使用时进行初始化,这给我带来了一些困扰,我大量定义 Once
的实例,且它和具体的组件实例没有关联,我在使用时需要非常的小心。
使用过 go-extend/pool 中的 pool.BufferPool 的朋友如果留意其源码的话会发现其中定义了一些 sync.Once
的实例,这相对上诉场景却是相对少的,以下便是 pool.BufferPool 中的部分代码:
1 | // source: https://github.com/thinkeridea/go-extend/blob/v1.1.2/pool/buffer.go |
上诉代码中定义了 buff64One
到 buff8192One
7个 Once
的实例,且对应的存在 buff64
到 buff8192
的业务实例,我在 GetBuff64
中必须小心使用 Once
实例,避免错误使用导致对应的实例未被初始化,而且上诉的代码看起来还有一些丑陋。
鉴于我对 sync.Once
灵活、简洁、高效的喜爱,不能仅仅因为它的“吝啬”(极简的功能)便与之诀别,促使我开启了探寻缓和与 sync.Once
关系之路。
首先我想到的是对 sync.Once
的二次包装,使其可以保存一个数据,这样我就可以只定义 Once
的实例,由 Once
负责存储初始化的结果。exsync.Once 这是我的第一个实验,它的实现非常简洁:
1 | // source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go |
它嵌套一个 sync.Once
实例,并覆盖其 Do
函数,使其接收一个 func() interface{}
函数,它要求初始化函数返回其结果,结果保存在 Once.v
,每次调用 Do
它便返回自己保存的结果,这使用起来就变得简单许多,改造之前 exec.closeOnce
例子:
1 | type closeOnce struct { |
这减少了一个业务层的数据定义,如果包含多个数据,可以使用自定义 struct
或者 []interface{}
进行数据保存, 一个简单打开文件的例子:
1 | type openOnce struct { |
这看起来使初始化的代码变得复杂了一些,对多返回值的问题暂时没有更好的实现,我会在后续逐渐考虑这类问题的处理方式,单个值时它使我得到一些惊喜和便捷。即使这样我随后发现它相对 sync.Once
的性能大幅度下降,达到10倍之多,起初我认为是 interface
的带来的,我立刻实现了一个 exsync.OncePointer 以期许它可以在性能上给我一个惊喜:
1 | // source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go |
使用 unsafe.Pointer
存储实例,让其在编译时确定类型,来提升其性能,使用示例如下:
1 | type closeOnce struct { |
尴尬的是这并没有使其性能有极大提升,仅仅只是稍微提升一些,难道我要和 sync.Once
就此诀别,还是凑合过……
我本已放弃优化,即使其性能极大下降,但是它仍然可以在 3ns 内完成任务,这并不会形成瓶颈。但多少内心还是有些不甘,仅仅只是包装使其保存一个值不应该导致性能下降如此严重,究竟是什么导致其性能如此严重下降的,仔细做了分析发现由于 sync.Once
非常的高效,且代码简洁,我嵌套包装使其多了一层调用,且可能导致其无法内联,这对一些性能不高的组件影响极小,但是像 sync.Once
这样高效任何小小的损耗表现都十分明显。
我直接拷贝 sync.Once
中的代码到 exsync.Once 及 exsync.OncePointer 实现中,这让我得到与 sync.Once
接近的性能,exsync.OncePointer 的实现甚至总是好于 sync.Once
。
以下是性能测试的结果,其代码位于 exsync/benchmark/once_test.go:
1 | goos: darwin |
得到这个结果后我毫不犹豫、马不停蹄的改变了 pool.BufferPool 中的代码,这使 pool.BufferPool 变得简洁许多:
1 | package pool |
如此对 sync.Once
进行二次封装,使其通用性有所下降,并一定是一个好的方案,我乐于公开它,因为它在大多数时刻可以减少使用者的负担,使得代码变的简练。
后续的思考:
Once
永远只能执行一次,是否有安全快捷的方法可以使其重置。Do
函数。解决以上这些问题,可以使 sync.Once
应用在更多的场景中,但势必导致其性能有所下降,这需要一些实验和折中处理。
这正是 “hollowaykeanho” 给出的第一个方案,我想也是很多人想到的第一个方案,利用 go 的内置切片语法截取字符串:
1 | s := "abcdef" |
我们很快就了解到这是按字节截取,在处理 ASCII
单字节字符串截取,没有什么比这更完美的方案了,中文往往占多个字节,在 utf8
编码中是3个字节,如下程序我们将获得乱码数据:
1 | s := "Go 语言" |
“hollowaykeanho” 给出的第二个方案就是将字符串转换为 []rune
,然后按切片语法截取,再把结果转成字符串。
1 | s := "Go 语言" |
首先我们得到了正确的结果,这是最大的进步。不过我对类型转换一直比较谨慎,我担心它的性能问题,因此我尝试在搜索引擎和各大论坛查找答案,但是我得到最多的还是这个方案,似乎这已经是唯一的解。
我尝试写个性能测试评测它的性能:
1 | package benchmark |
我得到了让我有些吃惊的结果:
1 | goos: darwin |
对 69 个的字符串截取前 20 个字符需要大概 1.3 微秒,这极大的超出了我的心里预期,我发现因为类型转换带来了内存分配,这产生了一个新的字符串,并且类型转换需要大量的计算。
我想改善类型转换带来的额外运算和内存分配,我仔细的梳理了一遍 strings
包,发现并没有相关的工具,这时我想到了 utf8
包,它提供了多字节计算相关的工具,实话说我对它并不熟悉,或者说没有主动(直接)使用过它,我查看了它所有的文档发现 utf8.DecodeRuneInString
函数可以转换单个字符,并给出字符占用字节的数量,我尝试了如此下的实验:
1 | package benchmark |
运行它之后我得到了令我惊喜的结果:
1 | goos: darwin |
较 []rune
类型转换效率提升了 13倍,消除了内存分配,它的确令人激动和兴奋,我迫不及待的回复了 “hollowaykeanho” 告诉他我发现了一个更好的方法,并提供了相关的性能测试。
我有些小激动,兴奋的浏览着论坛里各种有趣的问题,在查看一个问题的帮助时 (忘记是哪个问题了-_-||) ,我惊奇的发现了另一个思路。
许多人似乎遗忘了 range
是按字符迭代的,并非字节。使用 range
迭代字符串时返回字符起始索引和对应的字符,我立刻尝试利用这个特性编写了如下用例:
1 | package benchmark |
我尝试运行它,这似乎有着无穷的魔力,结果并没有令我失望。
1 | goos: darwin |
它仅仅提升了13%,但它足够的简单和易于理解,这似乎就是我苦苦寻找的那味良药。
如果你以为这就结束了,不、这对我来只是探索的开始。
喝了 range
那碗甜的腻人的良药,我似乎冷静下来了,我需要造一个轮子,它需要更易用,更高效。
于是乎我仔细观察了两个优化方案,它们似乎都是为了查找截取指定长度字符的索引位置,如果我可以提供一个这样的方法,是否就可以提供用户一个简单的截取实现 s[:strIndex(20)]
,这个想法萌芽之后我就无法再度摆脱,我苦苦思索两天来如何来提供易于使用的接口。
之后我创造了 exutf8.RuneIndexInString 和 exutf8.RuneIndex 方法,分别用来计算字符串和字节切片中指定字符数量结束的索引位置。
我用 exutf8.RuneIndexInString 实现了一个字符串截取测试:
1 | package benchmark |
尝试运行它,我对结果感到十分欣慰:
1 | goos: darwin |
性能较 range
提升了 10%,让我很欣慰可以再次获得新的提升,这证明它是有效的。
它足够的高效,但是却不够易用,我截取字符串需要两行代码,如果我想截取 10~20之间的字符就需要4行代码,这并不是用户易于使用的接口,我参考了其它语言的 sub_string
方法,我想我应该也设计一个这个样的接口给用户。
exutf8.RuneSubString 和 exutf8.RuneSub 是我认真思索后编写的方法:
func RuneSubString(s string, start, length int) string
它有三个参数:
s
: 输入的字符串 start
: 开始截取的位置,如果 start 是非负数,返回的字符串将从 string 的 start 位置开始,从 0 开始计算。例如,在字符串 “abcdef” 中,在位置 0 的字符是 “a”,位置 2 的字符串是 “c” 等等。 如果 start 是负数,返回的字符串将从 string 结尾处向前数第 start 个字符开始。 如果 string 的长度小于 start,将返回空字符串。length
:截取的长度,如果提供了正数的 length,返回的字符串将从 start 处开始最多包括 length 个字符(取决于 string 的长度)。 如果提供了负数的 length,那么 string 末尾处的 length 个字符将会被省略(若 start 是负数则从字符串尾部算起)。如果 start 不在这段文本中,那么将返回空字符串。 如果提供了值为 0 的 length,返回的子字符串将从 start 位置开始直到字符串结尾。我为他们提供了别名,根据使用习惯大家更倾向去 strings
包寻找这类问题的解决方法,我创建了exstrings.SubString 和 exbytes.Sub 作为更易检索到的别名方法。
最后我需要再做一个性能测试,确保它的性能:
1 | package benchmark |
运行它,不会让我失望:
1 | goos: darwin |
虽然相较 exutf8.RuneIndexInString 有所下降,但它提供了易于交互和使用的接口,我认为这应该是最实用的方案,如果你追求极致仍然可以使用 exutf8.RuneIndexInString,它依然是最快的方案。
当看到有疑问的代码,即使它十分的简单,依然值得深究,并不停的探索它,这并不枯燥和乏味,反而会有极多收获。
从起初 []rune
类型转换到最后自己造轮子,不仅得到了16倍的性能提升,我还学习了utf8
包、加深了range
遍历字符串的特性 以及为 go-extend 仓库收录了多个实用高效的解决方案,让更多 go-extend 的用户得到成果。
go-extend 是一个收录实用、高效方法的仓库,读者们如果好的函数和通用高效的解决方案,期待你们不吝啬给我发送 Pull request
,你也可以使用这个仓库加快功能实现及提升性能。
需要掌握的技能会很多,如果你已经掌握,那么跟着教程可以很快的完成 ChatOps 的部署,如果还没有掌握,可以找时间简单学习一下。其中一些组件是可以替换的,比如 :Fabric 就有很多选择,例如:Ansible、Shell 等可以用来操作远程机器的工具。当然也可以使用机器人连接 Jenkins、Kubernetes 等支持自动部署的服务,本系列中使用 hubot-gitlab-deploy
结合 Fabric
来实现自动部署。
我下载的是 CentOS7 Minimal ISO 版本的镜像,如果对 linux 比较熟悉,那么安装任何版本系统都是可以的,这里并不详细讲解 Liunx 的全部安装过程,只讲解虚拟机配置及系统相关配置,供大家搭建时做参考。
VirtualBox 分配 4G 内存、500G 磁盘,同时添加两块网卡,一个是 Host-Only 模式,一个是 NAT 模式。
Host-Only 模式网卡可以给主机和虚拟机之间提供私有的虚拟网络,我们实验环境需要一个稳定的不受实际网络影响的虚拟网络,我是笔记本经常办公司到家里,网络环境会改变,但是我主机和虚拟机通信网络不会变化。
NAT 模式网卡是为了虚拟机可以上网,实际 Host-Only 的网卡也可以上网,但是它只能是使用有线连接时可以,我的笔记本大多数都是 WiFi 连接网络,这导致我没有办法上网,增加这块网卡可以实现上网,但是主机无法访问虚拟机,所以我需要两块网卡结合使用。
大家只需要能够让主机访问虚拟机,虚拟机可以访问虚拟机,虚拟机可以访问网络(我们需要安装一些东西)。
系统分区可以根据自己的喜好进行,我尽量模拟真实的生成环境,一个较小的系统盘,阿里云服务器是 40G,一块数据盘,我把其它容量都分配给 /data
数据盘。
1 | df -h |
安装完成系统,登陆之后需要给虚拟机设置一个固定 ip。使用 ip address
查看网卡信息,找到 192.168.*.*
的网卡,记录下网卡名称,我的是 enp0s3
,然后编辑网卡对应的配置文件 /etc/sysconfig/network-scripts/ifcfg-enp0s3
,具体如下:
1 | TYPE="Ethernet" |
重启网卡 systemctl restart network
使配置生效,然后在主机使用 ssh 登陆虚拟机,确定网络畅通,ssh root@192.168.56.6
。
之后我们就可以使用 “无界面” 启动虚拟机,使用 ssh 登陆虚拟机进行配置。
官网有完整的安装教程在:https://docs.docker.com/install/linux/docker-ce/centos/ ,有两种安装方式,我使用在线安装的方式,由于官方镜像无法访问,这里采用阿里云的镜像,只需要执行以下命令即可:
1 | yum install -y yum-utils device-mapper-persistent-data lvm2 |
Docker 已经安装完成了,但是还需要配置一下,首先建议去阿里云申请一个 Docker 镜像加速器地址,去 https://cr.console.aliyun.com/undefined/instances/mirrors 申请,然后配置 /etc/docker/daemon.json
文件:
1 | vi /etc/docker/daemon.json |
相关的文档在 https://docs.docker.com/compose/install/ ,安装比较简单,执行下面的命令即可:
1 | curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose |
基础环境配置还是比较简单的,配置过程中遇到任何问题多 google 或者在文末留言。
整个体系涉及的技术点比较宽泛,学习者需要多准备和学习。
]]>但很大程度上,DevOps 更多是指开发群体之间的一种协作模式(通常也在开发人员中实施),随着全行业的发展和人力成本的攀升,在团队所有角色间贯通的升级版「DevOps」逐渐登场,也就是我们将要重点介绍的 ChatOps。
ChatOps 的历史相对短暂,2013 年 GitHub 内部最早开始推行 ChatOps,希望能以聊天的方式更容易更快速的去完成 DevOps 承载的工作。
ChatOps 以聊天室,即沟通平台为中心,通过一系列的机器人去对接后台的各种服务,工作人员只需要在聊天窗口中与机器人对话,即可与后台服务进行交互,整个工作的展开就像是使唤一个智能助手那样简单自然。
ChatOps 站在巨人的肩膀上发展,也为工作带来了显而易见的好处:
ChatOps 主要由三个部分构成:聊天室(控制中心)、机器人(连接中心)、基础设施,基础设施主要是支撑我们业务运行的各种服务与工具,在构建 ChatOps 时主要需要选择聊天室和机器人,国外早期的工作沟通工具 HipChat,新秀 Slack 都是作为 ChatOps 承载平台的好选择,在中文的环境下,则可以选择 BearyChat(倍洽)等等。
聊天室选择:
机器人选择:
聊天室主要有:Slack、HipChat、BearyChat(倍洽)、Rocket.Chat、钉钉(机器人支持程度不够,不太支持)。机器人主要有:Hubot(javascript/CoffeeScript)、Lita(Ruby),Errbot(python)。
机器人我推荐使用 Hubot,后面所有的实验都使用 Hubot 展开。 GitHub 团队内部实现的 ChatOps 与一个叫做 Hubot 的机器人框架密切相关,Hubot 提供很多聊天机器人所需的基础设施,借助 Hubot 框架能比较方便的和自己编写的功能或自己的系统对接。目前,Hubot 已经发展出了较好的生态圈,有很多开源插件可以借用。
本系列专题主要讲两种实现,一种是基于 GitHub+Hubot+Slack 实现,一种基于开源体系的 GitLab + Hubot + Rocket.Chat,它们功能完善且有良好的扩展及丰富的API,只要爱倒腾一定会有意想不到的收获。
GitHub 体系:
GitLab 体系:
本系列会介绍各种设计服务安装、配置,以及各种服务组件之间连接配置等,同时会涉及到项目配置管理、密钥管理等相关知识。
本系列主要涉及 ChatOps 环境搭建、工具配置,以及项目的持续集成、持续部署的实现,持续部署过程会涉及到密钥管理、配置管理等等。
本系列计划一至两周一篇文章,每篇文章介绍一个点,两种体系都会讲到,大约20~30篇文章,优先讲解开源体系的 GitLab 篇。
在这期间会开发相关机器人脚本及相关服务组件,可以形成项目会发布在 GitHub 上,不能形成项目的会在文章中,最后所有实验相关代码均可在 GitHub 上 chatops_experiment 中获取。
测试中需要自动部署的项目会单独存储在一个账号/组织下面,具体详见每个章节。
]]>它是一个可以保证日志各列完整性且高效拼接字段的组件,支持任意列和行分隔符,而且还支持数组字段,可是实现一对多的日志需求,不用记录多个日志,也不用记录多行。它响应一个 []byte
数据,方便结合其它主键写入数据到日志文件或者网络中。
NewRecord(len int) Record
创建长度固定的日志记录
NewRecordPool(len int) *sync.Pool
创建长度固定的日志记录缓存池
ToBytes(sep, newline string) []byte
使用 sep 连接 Record,并在末尾添加 newline 换行符
ArrayJoin(sep string) string
使用 sep 连接 Record,其结果作为数组字段的值
ArrayFieldJoin(fieldSep, arraySep string) string
使用 fieldSep 连接 Record,其结果作为一个数组的单元
Clean()
清空 Record 中的所有元素,如果使用 sync.Pool 在放回 Pool 之前应该清空 Record,避免内存泄漏
UnsafeToBytes(sep, newline string) []byte
使用 sep 连接 Record,并在末尾添加 newline 换行符, 使用原地替换会破坏日志字段引用的字符串
UnsafeArrayFieldJoin(fieldSep, arraySep string) string
使用 fieldSep 连接 Record,其结果作为一个数组的单元, 使用原地替换会破坏日志字段引用的字符串
底层使用 type Record []string
字符串切片作为一行或者一个数组字段,在使用时它应该是定长的,因为数据日志往往是格式化的,每列都有自己含义,使用 NewRecord(len int) Record
或者 NewRecordPool(len int) *sync.Pool
创建组件,我建议每个日志使用 NewRecordPool
在程序初始化时创建一个缓存池,程序运行时从缓存次获取 Record
将会更加高效,但是每次放回 Pool
时需要调用 Clean
清空 Record
避免引用字符串无法被回收,而导致内存泄漏。
我们需要保证日志每列数据的含义一至,我们创建了定长的 Record
,但是如何保证每列数据一致性,利用go 的常量枚举可以很好的保证,例如我们定义日志列常量:
1 | const ( |
LogFieldNumber
就是日志的列数量,也就是 Record
的长度,之后使用 NewRecordPool
创建缓存池,然后使用常量名称作为下标记录日志,这样就不用担心因为检查或者疏乎导致日志列错乱的问题了。
1 | var w bytes.Buffer // 一个日志写组件 |
以上程序运行会输出:
因为分隔符是不可见字符,下面使用,代替字段分隔符,使用;\n代替换行符, 使用/代替数组字段分隔符,是-代替数组分隔符。
1 | 'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,;\n' |
即使我们没有记录 LogFriends
列的数据,但是在日志中它仍然有一个占位符,如果 user
是 nil
,LogUid
和 LogUserName
不需要特殊处理,也不需要写入数据,它依然占据自己的位置,不用担心日志因此而错乱。
使用 pool 可以很好的利用内存,不会带来过多的内存分配,而且 Record 的每个字段值都是字符串,简单的赋值并不会带来太大的开销,它会指向字符串本身的数据,不会有额外的内存分配,详细参见string 优化误区及建议。
使用 Record.Join
可以高效的连接一行日志记录,便于我们快速的写入的日志文件中,后面设计讲解部分会详细介绍 Join
的设计。
有时候也并非都是记录一些单一的值,比如上面 LogFriends 会记录当前记录相关的朋友信息,这可能是一组数据,datalog 也提供了一些简单的辅助函数,可以结合下面的实例实现:
1 | // 定义 LogFriends 数组各列的数据 |
以上程序运行会输出:
因为分隔符是不可见字符,下面使用,代替字段分隔符,使用;\n代替换行符, 使用/代替数组字段分隔符,是-代替数组分隔符。
1 | 'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,FUid/FUserName-FUid/FUserName;\n' |
这样在解析时可以把某一字段当做数组解析,这极大的极大的提高了数据日志的灵活性,
但是并不建议使用过多的层级,数据日志应当清晰简洁,但是有些特殊场景可以使用一层嵌套。
使用 ToBytes
和 ArrayFieldJoin
时会把数据字段中的连接字符串替换一个空字符串,所以在 datalog 里面定义了4个分隔符,它们都是不可见字符,极少会出现在数据中,但是我们还需要替换数据中的这些连接字符,避免破坏日志结构。
虽然组件支持各种连接符,但是为了避免数据被破坏,我们应该选择一些不可见且少见的单字节字符作为分隔符。换行符比较特殊,因为大多数日志读取组件都是用 \n
作为行分隔符,如果数据中极少出现 \n
那就可以使用 \n
, datalog 中定义 \x03\n
作为换行符,它兼容一般的日志读取组件,只需要我们做少量的工作就可以正确的解析日志了。
UnsafeToBytes
和 UnsafeArrayFieldJoin
性能会更好,和它们的名字一样,他们并不安全,因为它们使用 exbytes.Replace 做原地替换分隔符,这会破坏数据所指向的原始字符串。除非我们日志数据中会出现极多的分隔符需要替换,否者并不建议使用它们,因为它们只在替换时提升性能。
我在服务中大量使用 UnsafeToBytes
和 UnsafeArrayFieldJoin
,我总是在一个请求结束时记录日志,我确保所有相关的数据不会再使用,所以不用担心原地替换导致其它数据被无感知改变的问题,这也许是一个很好的实践,但是我仍然不推荐使用它们。
datalog 并没有提供太多的约束很功能,它仅仅包含一种实践和一组辅助工具,在使用它之前,我们需要了解这些实践。
它帮我们创建一个定长的日志行或者一个sync.Pool
,我们需要结合常量枚举记录数据,它帮我们把各列数据连接成记录日志需要的数据格式。
它所提供的辅助方法都经过实际项目的考验,考量诸多细节,以高性能为核心目标所设计,使用它可以极大的降低相关组件的开发成本,接下来这节将分析它的各个部分。
我认为值得说道的是它提供的一个 Join
方法,相对于 strings.Join
可以节省两次的内存分配,现从它开始分析。
1 | // Join 使用 sep 连接 Record, 并在末尾追加 suffix |
日志组件往往输入的参数是 []byte
类型,所以它直接返回一个 []byte
,而不像 strings.Join
响应一个字符串,在末尾是需要对内部的 buf
进行类型转换,导致额外的内存开销。我们每行日志不仅需要使用分隔符连接各列,还需要一个行分隔符作为结尾,它提供一个后缀 suffix
,不用我们之后在 Join
结果后再次拼接行分隔符,这样也能减少一个额外的内存分配。
这恰恰是 datalog 设计的精髓,它并没有大量使用标准库的方法,而是设计更符合该场景的方法,以此来获得更高的性能和更好的使用体验。
1 | // ToBytes 使用 sep 连接 Record,并在末尾添加 newline 换行符 |
ToBytes
作为很重要的交互函数,也是该组件使用频率最高的函数,它在连接各个字段之前替换每个字段中的字段和行分隔符,这里提前做了一个检查字段中是否包含分隔符,如果包含使用 []byte(l[i])
拷贝该列的数据,然后使用 exbytes.Replace 提供高性能的原地替换,因为输入数据是拷贝重新分配的,所以不用担心原地替换会影响其它数据。
之后使用之前介绍的 Join
方法连接各列数据,如果使用 strings.Join
将会是 []byte(strings.Join([]string(l), sep) + newline)
这其中会增加很多次内存分配,该组件通过巧妙的设计规避这些额外的开销,以提升性能。
1 | // UnsafeToBytes 使用 sep 连接 Record,并在末尾添加 newline 换行符 |
UnsafeToBytes
和 ToBytes
相似只是没有分割符检查,因为exbytes.Replace 中已经包含了检查,而且直接使用 exstrings.UnsafeToBytes 把字符串转成 []byte
这不会发生数据拷贝,非常的高效,但是它不支持字面量字符串,不过我相信日志中的数据均来自运行时分配,如果不幸包含字面量字符串,也不用太过担心,只要使用一个特殊的字符作为分隔符,往往我们编程字面量字符串并不会包含这些字符,执行 exbytes.Replace 没有发生替换也是安全的。
1 | // Clean 清空 Record 中的所有元素,如果使用 sync.Pool 在放回 Pool 之前应该清空 Record,避免内存泄漏 |
Clean
方法更简单,它只是把各个列的数据替换为空字符串,空字符串做为一个特殊的字符,会在编译时处理,并不会有额外的开销,它们都指向同一块内存。
1 | // ArrayJoin 使用 sep 连接 Record,其结果作为数组字段的值 |
ArrayFieldJoin
在连接各个字符串时会直接替换数组单元分隔符,之后直接使用 exstrings.Join 进行连接字符串,exstrings.Join 相对 strings.Join
的一个改进函数,因为它只有一次内存分配,较 strings.Join
节省一次,有兴趣可以去看它的源码实现。
datalog 提供了一种实践以及一些辅助工具,可以帮助我们快速的记录数据日志,更关心数据本身。具体程序性能可以交给 datalog 来实现,它保证程序的性能。
后期我会计划提供一个高效的日志读取组件,以便于读取解析数据日志,它较与一般文件读取会更加高效且便捷,有针对性的优化日志解析效率,敬请关注吧。
]]>随着业务扩展这台服务器压力越来越大,高峰时数据延迟越来越厉害,早期也是使用 Python 脚本 + awk 以及一些 shell 命令完成相关工作,在数据集不是很大的时候这种方案很好,效率也很高,随着数据集变大,发现服务器负载很高,经过分析是还是 io 阻塞,依旧采用对数据流进行处理的方案优化io,以下记录优化的过程。
服务器配置:4 核 8G; 磁盘:1T
分析前置服务会根据业务不同分为十分钟、一小时两个阶段拉取分析日志,每隔一个阶段会去 OSS 拉取日志回到服务器进行处理,处理过程因 io 阻塞,导致 CPU 和 load 异常高,且处理效率严重下降,这次优化主要就是降低 io 阻塞,提升 CPU 利用率 (处理业务逻辑而不是等待 io) 和处理效率。
后文中会详细描述优化前后的方案,并用 go 编写测试,使用一台 2 核4G的服务器进行测试,测试数据集大小为:
优化前日志处理流程:
导致 io 阻塞的部分主要是: 拉取 OSS 日志、解压缩日志文件及读取日志数据,优化也主要从这三块着手。
这里有一段公共的日志读取方法,该方法接收一个 io.Reader
, 并按行读取日志,并简单切分日志字段,并没有实质的处理日志数据,后面的优化方案也将使用这个方法读取日志。
1 | package main |
日志按 \r\r\n
分隔行,使用 \x01
切分字段,读取方法使用 bufio.ReadSlice
方法,避免内存分配,且当 bufio
缓冲区满之后使用 rwaBuffer
作为本地可扩展缓冲,每次扩展之后会保留最大的扩展空间,因为业务日志每行大小差不多,这样可以极大的减少内存分配,效率是 bufio.ReadLine
方法的好几倍。
1 | package main |
运行程序输出如下:
1 | 待处理文件数量:432 |
通过 iostat -m -x 5 10000
分析各个阶段结果如下:
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
通过 iostat
结果可以看出,在解压和读取日志时 io
阻塞比较严重,且运行时间较长,下载时 io
阻塞也存在,但还可以接受,通过下面两个方案逐渐消除掉 io
。
优化前的方案反应出在解压和读取日志时 io
阻塞比较严重,那么是否可以通过读取 lzo
压缩文件,以此来消除解压缩日志耗时太大、io
太高的问题呢?并且读取 lzo
压缩文件远比解压后文件小,来降低读取日志耗时太大、io
太高的问题呢?
优化后日志处理流程:
1 | package main |
这个方案消除了解压缩日志,并且直接读取压缩日志,使用 github.com/cyberdelia/lzo
包对压缩文件数据流进行边读取边解压,这次不用单独封装新的方法了,直接使用 lzo
包中的接口即可。
程序运行结果如下:
1 | 待处理文件数量:432 |
这个方案效果非常明显,总耗时从 1375.187261
降低到 418.942862
提升了 3 倍的效率,不仅消除了压缩的时间,还大大缩短了读取文件耗时,成果显著。
通过 iostat -m -x 5 10000
分析各个阶段结果如下:
下载时:
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
读取时:
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
通过 iostat
结果分析,下载时 io
阻塞和优化前波动不是很大,读取时的 io
优化已经非常好了,iowait
从 92.19%
降低到 14.5%
,CPU 更多的任务用来处理解压缩日志,而不是处理 io
阻塞。
本来优化到上面的效果已经非常满意了,不过既然开始做优化就不能草草结束了,仔细思考业务场景,需要 本地 lzo
文件?重新处理日志的频率高吗?本地 lzo
日志清理方便吗?
通过上面的几个问题发现,除非程序出现问题或者数据存储出现故障,否者极少需要重新处理日志,一年里面这种情况也是极少的,甚至不会发生。
那么思考一下,不下载日志,直接读取网络数据流,实现边下边解压边读取,这样岂不是没有 io
了吗?
优化后日志处理流程:
具体实现如下:
1 | package main |
优化后只有一个流程了,代码简洁了不少,看看效率如何吧!
程序运行结果如下:
1 | 待处理文件数量:432 |
天啊发生了什么,我使劲擦了擦眼睛,太不可思议了,居然只消耗了下载日志的耗时,较上一个方案总耗时从 418.942862
降低到 285.993717
,提升了近 2 倍的效率,让我们看看上个方案下载文件耗时 286.146603
,而新方案总耗时是 285.993717
居然只用了上个优化版本的下载时间,究竟发生了什么?
通过 iostat -m -x 5 10000
分析结果如下:
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
通过 iostat
结果分析,在程序运行期间没有任何 io
开销,CPU 居然还有一半的空闲,前面两个版本 CPU 是没有空闲的啊,由此看来之前 CPU 更多的消耗在 io
阻塞上了,并没有用来处理业务逻辑。
由此来看也就不足为奇了,为啥优化后只需要下载日志的时间就能处理完所有日志了,没有了 io
阻塞,CPU 更多了用来处理业务,把之前下载时写文件 io
的耗时,用来解压缩数据,读取数据,且还有更多的空闲,跑出这样的结果也就很正常了。
从优化前耗时 1375.187261
秒到 285.993717
秒,性能提升 80%, 从 iowait
92.19%
到 0.31%
提升近 100%
,从没有任何 CPU 空闲到有一半空闲,这个过程中有很多值得总结的事情。
io
对性能的影响非常大,对 CPU 占用非常严重,导致 CPU 处理业务逻辑的时间片非常少。从 io
转移到 CPU 对性能提升非常明显。CPU 计算效率十分的高,从 io
密集到密集计算,只要符合业务场景,往往能给我们带来意想不到的效果。
往往优化业务并不需要十分高大上的技术,只是转变一下思路,不仅代码更少,且程序更简短、好维护、逻辑更清晰。
一定要结合实际业务场景进行思考,减少理所当然和业务无关的中间环节,往往就可以极大的提升程序效率。
]]>服务器配置:4 核 8G; 磁盘:500G
每十分钟需要上传:18 个文件,高峰时期约 10 G 左右
业务日志为了保证可靠性,会先写入磁盘文件,每10分钟切分日志文件,然后在下十分钟第一分时备份日志到 OSS,数据分析服务会从在备份完成后拉取日志进行分析,日志备份需要高效快速,在最短的时间内备份完,一般备份均能在几十秒内完成。
备份的速度和效率并不是问题,足够的快,但是在备份时 io 阻塞严重导致的 CPU 和 load 异常,成为业务服务的瓶颈,在高峰期业务服务仅消耗一半的系统资源,但是备份时 CPU 经常 100%,且 iowait 可以达到 70 多,空闲资源非常少,这样随着业务扩展,日志备份虽然时间很短,却成为了系统的瓶颈。
后文中会详细描述优化前后的方案,并用 go 编写测试,使用一台 2 核4G的服务器进行测试,测试数据集大小为:
优化前日志备份流程:
lzop
命令压缩日志下面是代码实现,这里不再包含备份文件规则,仅演示压缩上传逻辑部分,程序接受文件列表,并对文件列表压缩上传至 OSS 中。
.../pkg/aliyun_oss
是我自己封装的基于阿里云 OSS 操作的包,这个路径是错误的,仅做演示,想运行下面的代码,OSS 交互这部分需要自己实现。
1 | package main |
程序运行时输出:
1 | 待备份文件数量:336 |
从运行结果中可以看出压缩文件耗时很久,实际通过 iostat
命令分析也发现,压缩时资源消耗比较高,下面是 iostat -m -x 5 10000
命令采集各个阶段数据。
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
从 iostat
的结果中发现,压缩时程序 r_await
和 w_await
都到了一百多,且 iowait
高达 80.88%
,几乎耗尽了所有的 CPU,上传时 iowait
是可以接受的,因为只是单纯的读取压缩文件,且压缩文件也很小。
上述结果中发现程序主要运行消耗在压缩日志,那优化也着重日志压缩的逻辑上。
压缩时日志会先压缩成 lzo
文件,然后再上传 lzo
文件到阿里云 OSS 上,这中间发生了几个过程:
lzo
文件lzo
文件http
发送读取的内容压缩时 r_await
和 w_await
都很高,主要发生在读取原始日志文件,写入 lzo
文件, 怎么优化呢?
先想一下原始需求,读取原始文件 -> 上传数据。但是直接上传原始文件,文件比较大,网络传输慢,而且存储费用也比较高,怎么办呢?
这个时候我们期望可以上传的是压缩文件,所以就有了优化前的逻辑,这里面产生了一个中间过程,即使用 lzop
命令压缩文件,而且产生了一个中间文件 lzo
文件。
读取原始文件和上传数据是必须的,那么可以优化的就是压缩的流程了,所以 r_await
是没有办法优化的,那么只能优化 w_await
,w_await
是怎么产生的呢,恰恰是写入lzo
时产生的,可以不要 lzo
文件吗?这个文件有什么作用?
如果我们压缩文件数据流,在 读取原始文件 -> 上传数据 流程中对上传的数据流进行实时压缩,把压缩的内容给上传了,实现边读边压缩,对数据流进行处理,像是一个中间件,这样就不用写 lzo
文件了,那么 w_await
就被完全优化没了。
lzo
文件有什么作用?我想只有在上传失败之后可以节省一次文件压缩的消耗。上传失败的次数多吗?我用阿里云 OSS 好几年了,除了一次内网故障,再也没有遇到过上传失败的文件,我想是不需要这个文件的,而且生成 lzo
文件还需要占用磁盘空间,定时清理等等,增加了资源消耗和维护成本。
根据之前的分析看一下优化之后备份文件需要哪些过程:
这个流程节省了两个步骤,写入 lzo
文件和 读取 lzo
文件,不仅没有 w_await
,就连 r_await
也得到了小幅度的优化。
优化方案确定了,可是怎么实现 lzo
对文件流进行压缩呢,去 Github
上找一下看看有没有 lzo
的压缩算法库,发现 github.com/cyberdelia/lzo
,虽然是引用 C 库实现的,但是经典的两个算法(lzo1x_1
和 lzo1x_999
)都提供了接口,貌似 Go 可以直接用了也就这一个库了。
发现这个库实现了 io.Reader
和 io.Writer
接口,io.Reader
读取压缩文件流,输出解压缩数据,io.Writer
实现输入原始数据,并写入到输入的 io.Writer
。
想实现压缩数据流,看来需要使用 io.Writer
接口了,但是这个输入和输出都是 io.Writer
,这可为难了,因为我们读取文件获得是 io.Reader
,http 接口输入也是 io.Reader
,貌似没有可以直接用的接口,没有办法实现了吗,不会我们自已封装一下,下面是封装的 lzo
数据流压缩方法:
1 | package lzo |
这个库会固定消耗 512k 内存,并不是很大,我们需要创建一个读取 buf 和一个压缩缓冲 buf, 都是256k的大小,实际压缩缓冲的 buf 并不需要 256k,毕竟压缩后数据会比原始数据小,考虑空间并不是很大,直接分配 256k 避免运行时分配。
实现原理当 http 从输入的 io.Reader
(实际就是我们上面封装的 lzo
库), 读取数据时,这个库检查压缩缓冲是否为空,为空的情况会从文件读取 256k 数据并压缩输入到压缩缓冲中,然后从压缩缓冲读取数据给 http 的 io.Reader
,如果压缩缓冲区有数据就直接从压缩缓冲区读取压缩数据。
这并不是线程安全的,并且固定分配 512k 的缓冲,所以也提供了一个 Reset
方法,来复用这个对象,避免重复分配内存,但是需要保证一个 lzo
对象实例只能被一个 Goroutine 访问, 这可以使用 sync.Pool
来保证,下面的代码我用另一种方法来保证。
1 | package main |
程序为每个 Goroutine 分配一个固定的 compress
,当需要压缩文件的时候判断是创建还是重置,来达到复用的效果。
该程序运行输出:
1 | 待备份文件数量:336 |
实际耗时比优化前提升了 28%, 实际通过 iostat
命令分析也发现,资源消耗也有了明显的改善,下面是 iostat -m -x 5 10000
命令采集各个阶段数据。
1 | avg-cpu: %user %nice %system %iowait %steal %idle |
通过 iostat
发现只有 r_await
, w_await
被完全优化,iowait
有明显的改善,运行时间更短了,效率更高了,对 io 产生影响的时间也更短了。
首先对找到的 lzo
算法库进行测试,确保压缩和解压缩没有问题,并且和 lzop
命令兼容。
在这期间发现使用压缩的数据比 lzop
压缩数据大了很多,之后阅读了源码实现,并没有发现任何问题,尝试调整缓冲区大小,发现对生成的压缩文件大小有明显改善。
这个发现让我也很为难,究竟多大的缓冲区合适呢,只能去看 lzop
的实现了,发现 lzop 默认压缩块大小为 256k, 实际 lzo 算法支持的最大块大小就是 256k,所以实现 lzo
算法包装是创建的是 256k 的缓冲区的,这个缓冲区的大小就是压缩块的大小,大家使用的时候建议不要调整了。
这个方案上线之后,由原来需要近半分钟上传的,改善到大约只有十秒(Go 语言本身效率也有很大帮助),而且 load 有了明显的改善。
优化前每当运行日志备份,CPU 经常爆表,优化后备份时 CPU 增幅 20%,可以从容应对业务扩展问题了。
测试是在一台空闲的机器上进行的,实际生产服务器本身 w_await
会有 20 左右,如果使用固态硬盘,全双工模式,读和写是分离的,那么优化掉 w_await
对业务的帮助是非常大的,不会阻塞业务日志写通道了。
当然我们服务器是高速云盘(机械盘),由于机械盘物理特征只能是半双工,要么读、要么写,所以优化掉 w_await
确实效率会提升很多,但是依然会对业务服务写有影响。
func ClientIP(r *http.Request) string
ClientIP 尽最大努力实现获取客户端 IP 的算法。 解析 X-Real-IP 和 X-Forwarded-For 以便于反向代理(nginx 或 haproxy)可以正常工作。func ClientPublicIP(r *http.Request) string
ClientPublicIP 尽最大努力实现获取客户端公网 IP 的算法。 解析 X-Real-IP 和 X-Forwarded-For 以便于反向代理(nginx 或 haproxy)可以正常工作。func HasLocalIP(ip net.IP) bool
HasLocalIP 检测 IP 地址是否是内网地址func HasLocalIPddr(ip string) bool
HasLocalIPddr 检测 IP 地址字符串是否是内网地址func RemoteIP(r *http.Request) string
RemoteIP 通过 RemoteAddr 获取 IP 地址, 只是一个快速解析方法。ClientIP
方法 与 ClientPublicIP
方法的实现类似,只是一个按照 http 协议约定获取客户端 ip, 一个按照约定格式查找到公网 ip。
在网络与服务架构、业务逻辑复杂的环境中,按照 http 协议约定的方式,并非总能获取到真实的 ip,在我们的业务中用户流量经由三方多层级转发(都是三方自己实现的http client) ,难免会出现一些纰漏,这时越往后的服务获取用户真实 ip 越加困难,你甚至不知道自己获取的 ip 是否是真实的。
但是我们的客户经由三方转发而来的流量,那么客户极大多数甚至排除测试之外都是公网用户,结合使用 ClientPublicIP
和 ClientIP
方法总能更好的获取用户的真实 ip。
1 | // var r *http.Request |
用上面的方法总能有效的获取用户真实的 ip 地址,下面分析下两个方法的具体实现。
1 | // ClientIP 尽最大努力实现获取客户端 IP 的算法。 |
ClientIP
首先读取 X-Forwarded-For
header 中用 ,
分隔的第一个ip地址,如果这个地址不存在,就会从 X-Real-Ip
header 中获取,如果还是不存在,说明流量并非是由反向代理转发而来,而是客户端直接请求服务,这时通过 http.Request.RemoteAddr
字段截取除去端口号的 ip 地址。
这个方法很简单,就是按照 http 约定的格式获取,其中 X-Forwarded-For
和 X-Real-Ip
header 由反向代理填充,例如 nginx 或 haproxy。
1 | // ClientPublicIP 尽最大努力实现获取客户端公网 IP 的算法。 |
ClientPublicIP
很简单,和 ClientIP
方法的读取顺序一样,只是试图中 X-Forwarded-For
列表中找到一个公网ip,如果没有检查 X-Real-Ip
是否是一个公网 ip,其次检查 http.Request.RemoteAddr
是否是公网ip,如果没有找到公网 ip 这返回一个空字符串。
这个方法可以让我们有机会优先获取到用户的公网 ip,往往公网 ip 对我们来说更有价值。
exnet 中还提供了检查 ip 地址是否是内网地址,这在有些情况下非常有用,比如:服务中有些接口只能内网访问,也就是只允许管理员访问(例如动态设定日志级别、查看服务 pprof 信息);我们想隐藏后端服务,只暴露给用户负载均衡(反向代理),用户无法直接访问我们的服务,这些方法及其有用,下面看看具体实现。
我的服务提供了动态设置日志级别,以便服务出现问题,可以第一时间查看调试日志分析具体原因,但是这个接口很危险,不应该暴露给公网,所以会用路由中间件检查请求是否来自公网,来自公网则返回 404。
该方法认为如下地址段都是内网地址:
1 | 10.0.0.0/8 |
1 | // HasLocalIPddr 检测 IP 地址字符串是否是内网地址 |
两个检查方法实现差异仅接受参数类型不一致,检查过程都是逐个对比内网 ip 段是否包含该ip地址,如果不包含则判断该地址是否是回环地址。
如何判断改地址来自反向代理服务器呢,不同的反向代理实现都有些差异,4 层反向代理甚至可以提供用户的真实 ip(http.Request.RemoteAddr
是用户的ip,而不是反向代理的), 而隐藏自己的ip,这里说一下常见的方法。
往往 http.Request.RemoteAddr
保存最后一个连接服务的客户端 ip,我们获取反向代理的ip地址,最简单有效的方法就是通过 http.Request.RemoteAddr
获取, exnet 中提供了 RemoteIP
的快捷方法,实现如下:
1 | // RemoteIP 通过 RemoteAddr 获取 IP 地址, 只是一个快速解析方法。 |
这是一个非常方便的脚手架,它仅仅切分 http.Request.RemoteAddr
的 ip 和端口,并返回有效的ip地址,但却可以简化我们的编写业务代码。
针对这一个问题我在 exnet 扩展包里面实现可两者的转换的快捷方法:
func IP2Long(ip net.IP) (uint, error)
IP2Long 把 net.IP 转为数值func Long2IP(i uint) (net.IP, error)
Long2IP 把数值转为 net.IPfunc IPString2Long(ip string) (uint, error)
IPString2Long 把 ip 字符串转为数值func Long2IPString(i uint) (string, error)
Long2IPString 把数值转为 ip 字符串使用示例:
1 | package main |
那么是如何将点分十进制的IP地址转为数字?
IPv4 地址有4个字节,样式如下:
MSB————–LSB
b4 b3 b2 b1
每个字节表示的范围:
通用公式:b4<<24 | b3<<16 | b2<<8 | b1
例如,222.173.108.86
转换方法:222<<24 | 173<<16 | 108<<8 | 86 = 3735907414
再例如,1.0.1.1
转换方法:1<<24 | 0<<16 | 1<<8 | 1 = 16777473
exnet 中实现如下:
1 | // IPString2Long 把ip字符串转为数值 |
把数值转换为字符串的逻辑翻转过来即可, exnet 中实现如下:
1 | // Long2IPString 把数值转为ip字符串 |
Go 的社区很活跃,国内 gopher 对 Go 的热情不会因为墙的存在而减少,从社区想到这么多翻墙方案就能看出来了。
上面的方法都是可行的,但是总有一些不尽人意,社区也一直在找更好的方法,我一直使用自动代理的方式获取墙外的包,可以支持所有 Go 原生拉取包操作,比如 go get、go mod、dep、godep、glide 等各种方法,只需要配置一次,只要在任何原生命令前加前缀运行命令即可,效率很高。
工具类就先不讲原理,想直接获取方法的同学看这一部分即可,想了解原理的同学可以看后面的原理部分。
首先通过 git 设置需要不代理的网站,以 Github 为例,执行 git config --global url.git@github.com:.insteadof https://github.com/
从 https 转到 ssh 协议,这样会使我们设置的 https 代理不作用在 ssh 协议上,如果有自建的服务只要更换地址就可以了。
新建一个脚本 (proxy),修改里面的代理地址为自己的代理地址,如下:
1 | #!/usr/bin/env bash |
给 shell 脚本设置可执行权限,然后放到 path 环境变量路径下。
测试 proxy curl https://www.google.com
和 curl https://www.google.com
第一个命令可以获取到结果,第二个命令不可以。
测试 proxy go get -v golang.org/x/text/…
可以正常下载包,其它任何拉取包命令都可以添加 proxy
前缀执行 ,比如 proxy dep ensure -v
截止当前你就配置了一个 go get
自动代理的环境,以后需要翻墙操作的指令运行时加 proxy
就可以了,该方法并不只适用于 go get
,任何需要命令行代理都可以使用。
实际原理简单,找到这种方法也是一种巧合,在入坑 Go 之前我经常用 linux,当时有一些需求需要命令行翻墙,找到了三个环境变量 http_proxy
、https_proxy
、ftp_proxy
,但是全局设置导致很多请求变慢,如果在一个窗口临时设置就导致需要记住那个窗口设置了代理,切换窗口成本也比较高,后来根据 shell 的特性,任何一个脚本都有自己独立的环境变量,所以用一个脚本设置代理环境变量,exec ${@:1}
可以执行脚本后面的指令,也就是我们实际需要运行的指令,这样在需要代理的命令前就加上这个脚本前缀就好了,单行命令代理就这么简单的配置好了。
前期我使用 go 的时候遇到下载不了的包时,就会在 go get
前加上 proxy
指令,但是我发现拉取 Github 包的效率非常低,本身国内现在访问 Github 已经很快了。也是一个巧合,当时我公司 Go 项目迁移到 Github 上,所有项目全部是私有项目,有同事提供了一个 git https 转 ssh 协议的操作,git config --global url.git@….:.insteadof https://…./
,这个操作让我看到一个隐性福利,之前的代理只会代理 https 并不能代理 ssh 协议,那么使用这个指令把不需要代理的网站全部转成 ssh 协议,然后加上 proxy
运行 go get
就成了自动代理了,尝试之后确实效率很高,至此一直使用到今天。
Go
内置很多种数值类型,往往初学者不知道编写程序如何选择,使用哪种数值类型更有优势。内置的数值类型有:uint8
、 uint16
、 uint32
、 uint64
、 uint
、 int8
、 int16
、 int32
、 int64
、 int
。
从类型名称上可以很好了解到类型的大小,这个非常直观,uint
和 int
这两种类型是不带大小的,那么它们的大小会根据编译参数 GOARCH=amd64
平台决定的。
我最早设计的一个go的项目,当时设计系统使用采用最小类型原则,几乎使用了大多数数值类型,很少使用 uint
和 int
类型,后来遇到很多问题,标准库和三方库函数都接收 int
、 uint
、 int64
、uint64
, 一些代码生成工具, 比如 protobuf
生成类型是 int32
,一些三方系统大多数也是 int
类型,这时候与其它组件件的交互就需要 类型转换, 类型转换成本是很高的,导致程序性能并没有预期的好。
上面一个小故事(事故)警醒大家不要一味的根据数据的大小选择数值类型,而要考虑数值的用来做什么,后面会有哪些交互,需要调用哪些函数等等,是不是选择数值具体使用什么类型很复杂呢?并不是这样,考虑的越少,选择越简单,下面有一些近些年的总结。
int32
、 int64
、 uint32
、uint64
。因为原子类型的操作包天生支持这些类型。int
(我们的程序大多数跑在64位系统上,如果运行在32系统,且类型可能会超过 int32
可以选择 int64
) 。uint
和 uint64
。json
、fmt
)交互式时,按数值使用范围选择最小类型。我现在写代码一些特殊场景如原子操作会针对使用的包选择具体类型,偶尔会使用uint64
,往往是一些按位做一些复杂计算的数据,也都局限在局部逻辑上,与其它模块或者系统交互的都会使用 int
类型,这样可以大幅度降低数值类型的类型转换问题,从而从空间换取时间,获得更好的程序性能。
不得不说说 Go
语言神奇的 int
类型,为什么需要这样一个编程是无法确定具体长度的类型呢,而需要在编译时确定呢,有什么好处呢。
往往我们写程序是不太关注数值类型的,或者说我们程序中很多数值不会超过 int32
的最大值(往往我们的程序运行在 32 或 64位平台上),这个时候很多三方库都可以使用 int
作为交互类型,不用把一个函数为每种类型数值都写一遍,能简化标准库。我们也能写出更容易维护、简洁的系统。
本文原标题为 《string 也是引用类型》,经过 郝林 大佬指点原标题存在诱导性,这里解释一下 “引用类型” 有两个特征:1、多个变量引用一块内存数据,不创建变量的副本,2、修改任意变量的数据,其它变量可见。显然字符串只满足了 “引用类型” 的第一个特点,不能满足第二个特点,顾不能说字符串是引用类型,感谢大佬指正。
初学 Go
语言的朋友总会在传 []byte
和 string
之间有着很多纠结,实际上是没有了解 string
与 slice
的本质,而且读了一些程序源码,也发现很多与之相关的问题,下面类似的代码估计很多初学者都写过,也充分说明了作者当时内心的纠结:
1 | package main |
虽然这样的代码并不是来自真实的项目,但是确实有人这样设计,单从设计上看就很糟糕了,这样设计的原因很多人说:“slice
是引用类型,传递引用类型效率高呀”,主要原因不了解两者的本质。
上面这个例子如果觉得有点基础和可爱,下面这个例子貌似并不那么容易说明其存在的问题了吧。
1 | package main |
指针效率高,我就用指针多好,可以减少内存分配呀,设计函数都接收指针变量,程序性能会有很大提升,在实际的项目中这种例子也不少见,我想通过这篇文档来帮助初学者走出误区,减少适得其反的优化技巧。
在之前 “【Go】深入剖析slice和array” 一文中说了 slice
在内存中的存储模式,slice
本身包含一个指向底层数组的指针,一个 int
类型的长度和一个 int
类型的容量, 这就是 slice
的本质, []byte
本身也是一个 slice
,只是底层数组存储的元素是 byte
。下面这个图就是 slice
的在内存中的状态:
看一下 reflect.SliceHeader
如何定义 slice
在内存中的结构吧:
1 | type SliceHeader struct { |
slice
是引用类型是 slice
本身会包含一个地址,在传递 slice
时只需要分配 SliceHeader
就好了, 而 SliceHeader
只包含了三个 int
类型,相当于传递一个 slice
就只需要拷贝 SliceHeader
,而不用拷贝整个底层数组,所以才说 slice
是引用类型的。
那么字符串呢,计算机中我们处理的大多数问题都和字符串有关,难道传递字符串真的需要那么高的成本,需要借助 slice
和指针来减少内存开销吗。
reflect
包里面也定义了一个 StringHeader
看一下吧:
1 | type StringHeader struct { |
字符串只包含了两个 int
类型的数据,其中一个是指针,一个是字符串的长度,从 StringHeader
定义来看 string
并不会发生拷贝的,传递 string
只会拷贝 StringHeader
而已。
借助 unsafe
来分析一下情况是不是这样吧:
1 | package main |
上面这段代码的输出如下:
1 | (reflect.StringHeader) { |
可以发现前三个输出的指针都是同一个地址,第四个的地址发生了一个字节的偏移,分析来看传递字符串确实没有分配新的内存,同时和 slice
一样即使传递字符串的子串也不会分配新的内存空间,而是指向原字符串的中的一个位置。
这样说来把 string
转成 []byte
还浪费的一个 int
的空间呢,需要分配更多的内存,真是适得其反呀,而且类型转换会发生内存拷贝,从 string
转为 []byte
才是真的把 string
底层数据全部拷贝一遍呢,真是得不偿失呀。
字符串还有两个小特性,针对字面量(就是直接写在程序中的字符串),会创建在只读空间上,并且被复用,看一下下面的一个小例子:
1 | package main |
从输出可以了解到,相同的字面量会被复用,但是子串是不会复用空间的,这就是编译器给我们带来的福利了,可以减少字面量字符串占用的内存空间。
1 | (reflect.StringHeader) { |
另一个小特性大家都知道,就是字符串是不能修改的,如果我们不希望调用函数修改我们的数据,最好传递字符串,高效有安全。
不过有了 unsafe
这个黑魔法,字符串的这一个特性也就不那么可靠了。
1 | package main |
从输出里面居然发现字符串被修改了, 我们没有办法直接修改字符串,但是可以利用 slice
和 string
本身结构的特性,创建一个 slice
让它的指针指向 string
的指针位置,然后借助 unsafe
把这个 SliceHeader
转成 []byte
来修改字符串,字符串确实被修改了。
1 | xxxxxxxxxx |
看了上面的例子是不是开始担心把字符串传给其它函数真的不会更改吗?感觉很不放心的样子,难道使用任何函数都要了解它的内部实现吗,其实这种情况极少发生,还记得之前说的那个字符串特性吗,字面量字符串会放到只读空间中,这个很重要,可以保证不是任何函数想修改我们的字符串就可以修改的。
1 | package main |
运行上面的代码发生了一个运行时不可修复的错误,就是这个特性其它函数不能确保输入字符串是否是字面量,也是不会恶意修改我们字符串的了。
1 | unexpected fault address 0x1095dd5 |
关于字符串转 []byte
在 go-extend 扩展包中有直接的实现,这种用法在 go-extend 内部方法实现中也有大量使用, 实际上因为原数据类型和处理数据的函数类型不一致,使用这种方法转换字符串和 []byte
可以极大的提升程序性能
[]byte
转为 string
。[]byte
转为 string
。上面这两个函数用的好,可以极大的提升我们程序的性能,关于 exstrings.UnsafeToBytes
我们转换不确定是否是字面量的字符串时就需要确保调用的函数不会修改我们的数据,这往常在调用 bytes
里面的方法十分有效。
之前分析了传递 slice
并没有 string
高效,何况转换数据类型本身就会发生数据拷贝。
那么在这篇文章的第二个例子,为什么说传递字符串指针也不好呢,要了解指针在底层就是一个 int
类型的数据,而我们字符串只是两个 int
而已,另外如果了解 GC
的话,GC
只处理堆上的数据,传递指针字符串会导致数据逃逸到堆上,阅读标准库的代码会有很多注释说明避免逃逸到堆上,这样会极大的增加 GC
的开销,GC
的成本可谓是很高的呀。
这篇文章说 “传递 slice
并没有 string
高效”,为什么还会有 bytes
包的存在呢,其中很多函数的功能和 strings
包的功能一致,只是把 string
换成了 []byte
, 既然传递 []byte
没有 string
效率好,这个包存在的意义是什么呢。
我们想一下转换数据类型是会发生数据拷贝,这个成本可是大的多呀,如果我们数据本身就是 []byte
类型,使用 strings
包就需要转换数据类型了。
另外我们对比两个函数来看下一下即使传递 []byte
没有 string
效率好,但是标准库实现上却会导致两个函数有很大的性能差异的。
strings.Repeat
函数:
1 | func Repeat(s string, count int) string { |
bytes.Repeat
函数:
1 | func Repeat(b []byte, count int) []byte { |
上面两个函数的实现非常相似,除了类型不同 strings
包在处理完数据发生了一次类型转换,使用 bytes
只有一次内存分配,而 strings
是两次。
我们可以借助 exbytes.ToString 函数把 bytes.Repeat
的返回没有任何成本的转换会我们需要的字符串,如果我们输入也是一个字符串的话,还可以借助 exstrings.UnsafeToBytes 来转换输入的数据类型。
例如:
1 | s := exbytes.ToString(bytes.Repeat(exstrings.UnsafeToBytes("x"), 10)) |
不过这样写有点太麻烦了,实际上 exstrings 包里面正在修改 strings
里面一些类似函数的问题,所有的实现基本和标准库一致,只是把其中类型转换的部分用 exbytes.ToString 优化了一下,可以提升性能,也能提升开发效率。
1 | func UnsafeRepeat(s string, count int) string { |
如果用上面的函数只需要下面这样写就可以了:
1 | s:=exstrings.UnsafeRepeat("x", 10) |
go-extend 里面还收录了很多实用的方法,大家也可以多关注。
[]byte
来优化 string
传递,类型转换成本很高,且 slice
本身也比 string
更大一些。string
还是 []byte
需要根据数据来源和处理数据的函数来决定,一定要减少类型转换。strings
还是 bytes
包的问题,主要关注点是数据原始类型以及想获得的数据类型来选择。GC
的开销,具体可以参考 大堆中避免大量的GC开销 一文。strings.Replace
这个函数自身的效率已经很好了,但是在特定情况下效率并不是最好的,分享一下我如何优化的吧。我的服务中有部分代码使用 strings.Replace
把一个固定的字符串删除或者替换成另一个字符串,它们有几个特点:
(len(old) >= len(new)
本博文中使用函数均在 go-extend 中,优化后的函数在 exbytes.Replace 中。
近期使用 pprof
分析内存分配情况,发现 strings.Replace
排在第二,占 7.54%
, 分析结果如下:
1 | go tool pprof allocs |
标准库中最常用的函数,居然……,不可忍必须优化,先使用 list strings.Replace
看一下源码什么地方分配的内存。
1 | (pprof) list strings.Replace |
从源码发现首先创建了一个 buffer
来起到缓冲的效果,一次分配足够的内存,这个在之前 【Go】slice的一些使用技巧 里面有讲到,另外一个是 string(t[0:w])
类型转换带来的内存拷贝,buffer
能够理解,但是类型转换这个不能忍,就像凭空多出来的一个数拷贝。
既然类型转换这里有点浪费空间,有没有办法可以零成本转换呢,那就使用 go-extend 这个包里面的 exbytes.ToString
方法把 []byte
转换成 string
,这个函数可以零分配转换 []byte
到 string
。 t
是一个临时变量,可以安全的被引用不用担心,一个小技巧节省一倍的内存分配,但是这样真的就够了吗?
我记得 bytes
标准库里面也有一个 bytes.Replace
方法,如果直接使用这种方法呢就不用重写一个 strings.Replace
了,使用 go-extend 里面的两个魔术方法可以一行代码搞定上面的优化效果 s = exbytes.ToString(bytes.Replace(exstrings.UnsafeToBytes(s), []byte{' '}, []byte{''}, -1))
, 虽然是一行代码搞定的,但是有点长,exstrings.UnsafeToBytes
方法可以极小的代价把 string
转成 bytes
, 但是 s
不能是标量或常量字符串,必须是运行时产生的字符串否者可能导致程序奔溃。
这样确实减少了一倍的内存分配,即使只有 47.46GB
的分配也足以排到前十了,不满意这个结果,分析代码看看能不能更进一步减少内存分配吧。
使用火焰图看看究竟什么函数在调用 strings.Replace
呢:
这里主要是两个方法在使用,当然我记得还有几个地方有使用,看来不在火焰图中应该影响比较低 ,看一下代码吧(简化的代码不一定完全合理):
1 | // 第一部分 |
通过分析我们发现前两个主要是为了删除一个字符串,第三个是为了把一个字符串替换为另一个字符串,并且源数据的生命周期很短暂,在执行替换之后就不再使用了,能不能原地替换字符串呢,原地替换的就会变成零分配了,尝试一下吧。
先写一个函数简单实现原地替换,输入的 len(old) < len(new)
就直接调用 bytes.Replace
来实现就好了 。
1 | func Replace(s, old, new []byte, n int) []byte { |
写个性能测试看一下效果:
1 | go test -bench="." -run=nil -benchmem |
使用这个新的函数和 bytes.Replace
对比,内存分配是少了,但是性能却下降了那么多,崩溃…. 啥情况呢,对比 bytes.Replace
的源码发现我这个代码里面 s = append(s[:i], s[i+len(old)-len(new):]...)
每次都会移动剩余的数据导致性能差异很大,可以使用 go test -bench="." -run=nil -benchmem -cpuprofile cpu.out -memprofile mem.out
的方式来生成 pprof
数据,然后分析具体有问题的地方。
找到问题就好了,移动 wid
之前的数据,这样每次移动就很少了,和 bytes.Replace
的原理类似。
1 | func Replace(s, old, new []byte, n int) []byte { |
在运行一下性能测试吧:
1 | go test -bench="." -run=nil -benchmem |
运行性能差不多,而且更好了,内存分配也减少,不是说是零分配吗,为啥有一次分配呢?
1 | var replaces string |
可以看到使用了 []byte(replaces)
做了一次类型转换,因为优化的这个函数是原地替换,执行过一次之后后面就发现不用替换了,所以为了公平公正两个方法每次都转换一个类型产生一个新的内存地址,所以实际优化后是没有内存分配了。
之前说写一个优化 strings.Replace
函数,减少一次内存分配,这里也写一个这样函数,然后增加两个性能测试函数,对比一下效率 性能测试代码:
1 | go test -bench="." -run=nil -benchmem |
运行效率上都相当,优化之后的 UnsafeStringsReplace
函数减少了一次内存分配只有一次,和 bytes.Replace
相当。
有了优化版的 Replace
函数就替换到项目中吧:
1 | // 第一部分 |
1 | go tool pprof allocs2 |
居然在 allocs
上居然找不到了,确实是零分配。
优化前 profile
:
1 | go tool pprof profile |
优化后 profile
:
1 | go tool pprof profile2 |
通过 profile
来分配发现性能也有一定的提升,本次 strings.Replace
和 bytes.Replace
优化圆满结束。
本博文中使用函数均在 go-extend 中,优化后的函数在 exbytes.Replace 中。
]]>非常感谢 “wxe” 网友的提问,让我在测试过程中发现一个 json
序列化的问题。
之前我们优化了两个部分,json
与 ioutil.ReadAll
, 先对比 ioutil.ReadAll
, 这里测试的代码分成两个部分做对比,一部分单纯对比 ioutil.ReadAll
和 io.Copy
+ sync.Pool
,另一部分增加 jsoniter.Unmarshal
来延迟 pool.Put(buffer)
的执行, 源码。
1 | package iouitl_readall |
测试代码如下源码:
1 | package iouitl_readall |
测试结果如下:
1 | goos: darwin |
结论:
可以发现 IoCopy
方法是 IouitlReadAll
方法效率的 40 倍,内存分配也很少,而 IoCopyAndJson
和 IouitlReadAllAndJson
的效率差异极小仅有 2407ns
,大约是 1.13倍,不过内存分配还是少了很多的,为什么会这样呢,这就是 sync.Pool
的导致的,sync.Pool
每次获取使用时间越短,命中率就越高,就可以减少创建新的缓存,这样效率就会大大提高,而 jsoniter.Unmarshal
很耗时,就导致 sync.Pool
的命中率降低了,所以性能下降极其明显.
使用 io.Copy
+ sync.Pool
表面上执行效率不会有很大提升,但是会大幅度减少内存分配,从而可以减少 GC
的负担,在单元测试中我们并没有考虑 GC
的问题,而 GC
能带来的性能提升会更有优势。
在看一下 json
使用 sync.Pool
的效果吧 源码
1 | package iouitl_readall |
性能测试代码源码:
1 | package iouitl_readall |
测试结果如下:
1 | goos: darwin |
这里使用了两个 json
包, 一个是标准库的,一个是 jsoniter
(也是社区反馈效率最高的),对比两个包使用 sync.Pool
和不使用之间的差异,发现标准库 json
包使用后内存有少量减少,但是运行效率稍微下降了,差异不是很大,jsoniter
包差异之所谓非常明显,发现使用 sync.Pool
之后不仅内存分配更多了,执行效率也大幅度下降,差了将近3倍有余。
是不是很奔溃,这是啥情况 jsoniter
本身就使用了 sync.Pool
作缓冲,我们使用 jsoniter.NewEncoder(buffer)
创建一个序列化实例,但是其内部并没有直接使用 io.Writer
而是先使用缓冲序列化数据,之后写入 io.Writer
, 具体代码如下:
1 | // Flush writes any buffered data to the underlying io.Writer. |
这样一来我们使用 buffer
做 json
序列化优化效果就大打折扣,甚至适得其反了。
再次感谢 “wxe” 网友的提问,这里没有使用实际的应用场景做性能测试,主要发现在性能测试中使用 http
服务会导致 connect: can't assign requested address
问题,所以测试用使用了函数模拟,如果有朋友有更好的测试方法欢迎一起交流。
http.Request.Body
或 http.Response.Body
中读取数据方法或许很多,标准库中大多数使用 ioutil.ReadAll
方法一次读取所有数据,如果是 json
格式的数据还可以使用 json.NewDecoder
从 io.Reader
创建一个解析器,假使使用 pprof
来分析程序总是会发现 bytes.makeSlice
分配了大量内存,且总是排行第一,今天就这个问题来说一下如何高效优雅的读取 http
中的数据。我们有许多 api
服务,全部采用 json
数据格式,请求体就是整个 json
字符串,当一个请求到服务端会经过一些业务处理,然后再请求后面更多的服务,所有的服务之间都用 http
协议来通信(啊, 为啥不用 RPC
,因为所有的服务都会对第三方开放,http
+ json
更好对接),大多数请求数据大小在 1K4K,响应的数据在 1K8K,早期所有的服务都使用 ioutil.ReadAll
来读取数据,随着流量增加使用 pprof
来分析发现 bytes.makeSlice
总是排在第一,并且占用了整个程序 1/10
的内存分配,我决定针对这个问题进行优化,下面是整个优化过程的记录。
这里使用 https://github.com/thinkeridea/go-extend/blob/master/exnet/exhttp/expprof/pprof.go 中的 API
来实现生产环境的 /debug/pprof
监测接口,没有使用标准库的 net/http/pprof
包因为会自动注册路由,且长期开放 API
,这个包可以设定 API
是否开放,并在规定时间后自动关闭接口,避免存在工具嗅探。
服务部署上线稳定后(大约过了一天半),通过 curl
下载 allocs
数据,然后使用下面的命令查看分析。
1 | $ go tool pprof allocs |
从结果中可以看出采集期间一共分配了 1358.61GB
top 10
占用了 44.50%
其中 bytes.makeSlice
占了接近 1/10
,那么看看都是谁在调用 bytes.makeSlice
吧。
1 | (pprof) web bytes.makeSlice |
从上图可以看出调用 bytes.makeSlice
的最终方法是 ioutil.ReadAll
, (受篇幅影响就没有截取 ioutil.ReadAll
上面的方法了),而 90% 都是 ioutil.ReadAll
读取 http
数据调用,找到地方先别急想优化方案,先看看为啥 ioutil.ReadAll
会导致这么多内存分配。
1 | func readAll(r io.Reader, capacity int64) (b []byte, err error) { |
以上是标准库 ioutil.ReadAll
的代码,每次会创建一个 var buf bytes.Buffer
并且初始化 buf.Grow(int(capacity))
的大小为 bytes.MinRead
, 这个值呢就是 512
,按这个 buffer
的大小读取一次数据需要分配 2~16 次内存,天啊简直不能忍,我自己创建一个 buffer
好不好。
看一下火焰图🔥吧,其中红框标记的就是 ioutil.ReadAll
的部分,颜色比较鲜艳。
自己创建足够大的 buffer
减少因为容量不够导致的多次扩容问题。
1 | buffer := bytes.NewBuffer(make([]byte, 4096)) |
恩恩这样应该差不多了,为啥是初始化 4096
的大小,这是个均值,即使比 4096
大基本也就多分配一次内存即可,而且大多数数据都是比 4096
小的。
但是这样真的就算好了吗,当然不能这样,这个 buffer
个每请求都要创建一次,是不是应该考虑一下复用呢,使用 sync.Pool
建立一个缓冲池效果就更好了。
以下是优化读取请求的简化代码:
1 | package adapter |
使用 sync.Pool
的方式是不是有点怪,主要是 defer
和 api.pool.Put(buffer);buffer = nil
这里解释一下,为了提高 buufer
的复用率会在不使用时尽快把 buffer
放回到缓冲池中,defer
之所以会判断 buffer != nil
主要是在业务逻辑出现错误时,但是 buffer
还没有放回缓冲池时把 buffer
放回到缓冲池,因为在每个错误处理之后都写 api.pool.Put(buffer)
不是一个好的方法,而且容易忘记,但是如果在确定不再使用时 api.pool.Put(buffer);buffer = nil
就可以尽早把 buffer
放回到缓冲池中,提高复用率,减少新建 buffer
。
这样就好了吗,别急,之前说服务里面还会构建请求,看看构建请求如何优化吧。
1 | package adapter |
这个示例和之前差不多,只是不仅用来读取 http.Response.Body
还用来创建一个 jsoniter.NewEncoder
用来把请求压缩成 json
字符串,并且作为 http.NewRequest
的 body
参数, 如果直接用 jsoniter.Marshal
同样会创建很多次内存,jsoniter
也使用 buffer
做为缓冲区,并且默认大小为 512
, 代码如下:
1 | func (cfg Config) Froze() API { |
而且序列化之后会进行一次数据拷贝:
1 | func (cfg *frozenConfig) Marshal(v interface{}) ([]byte, error) { |
既然要用 buffer
那就一起吧^_^,这样可以减少多次内存分配,下读取 http.Response.Body
之前一定要记得 buffer.Reset()
, 这样基本就已经完成了 http.Request.Body
和 http.Response.Body
的数据读取优化了,具体效果等上线跑一段时间稳定之后来查看吧。
上线跑了一天,来看看效果吧
1 | $ go tool pprof allocs2 |
哇塞 bytes.makeSlice
终于从前十中消失了,真的太棒了,还是看看 bytes.makeSlice
的其它调用情况吧。
1 | (pprof) web bytes.makeSlice |
从图中可以发现 bytes.makeSlice
的分配已经很小了, 且大多数是 http.Request.ParseForm
读取 http.Request.Body
使用 ioutil.ReadAll
原因,这次优化的效果非常的好。
看一下更直观的火焰图🔥吧,和优化前对比一下很明显 ioutil.ReadAll
看不到了
比较惭愧在优化的过程出现了一个过失,导致生产环境2分钟故障,通过自动部署立即回滚才得以快速恢复,之后分析代码解决之后上线才完美优化,下面总结一下出现的问题吧。
在构建 http
请求时我分了两个部分优化,序列化 json
和读取 http.Response.Body
数据,保持一个观点就是尽早把 buffer
放回到缓冲池,因为 http.DefaultClient.Do(req)
是网络请求会相对耗时,在这个之前我把 buffer
放回到缓冲池中,之后读取 http.Response.Body
时在重新获取一个 buffer
,大概代码如下:
1 | package adapter |
上线之后马上发生了错误 http: ContentLength=2090 with Body length 0
发送请求的时候从 buffer
读取数据发现数据不见了或者数据不够了,我去这是什么鬼,马上回滚恢复业务,然后分析 http.DefaultClient.Do(req)
和 http.NewRequest
,在调用 http.NewRequest
是并没有从 buffer
读取数据,而只是创建了一个 req.GetBody
之后在 http.DefaultClient.Do
是才读取数据,因为在 http.DefaultClient.Do
之前把 buffer
放回到缓冲池中,其它 goroutine
获取到 buffer
并进行 Reset
就发生了数据争用,当然会导致数据读取不完整了,真实汗颜,对 http.Client
了解太少,争取有空撸一遍源码。
使用合适大小的 buffer
来减少内存分配,sync.Pool
可以帮助复用 buffer
, 一定要自己写这些逻辑,避免使用三方包,三方包即使使用同样的技巧为了避免数据争用,在返回数据时候必然会拷贝一个新的数据返回,就像 jsoniter
虽然使用了 sync.Pool
和 buffer
但是返回数据时还需要拷贝,另外这种通用包并不能给一个非常贴合业务的初始 buffer
大小,过小会导致数据发生拷贝,过大会太过浪费内存。
程序中善用 buffer
和 sync.Pool
可以大大的改善程序的性能,并且这两个组合在一起使用非常的简单,并不会使代码变的复杂。
slice
是 Go
语言十分重要的数据类型,它承载着很多使命,从语言层面来看是 Go
语言的内置数据类型,从数据结构来看是动态长度的顺序链表,由于 Go
不能直接操作内存(通过系统调用可以实现,但是语言本身并不支持),往往 slice
也可以用来帮助开发者申请大块内存实现缓冲、缓存等功能。在 Go
语言项目中大量的使用 slice
, 我总结三年来对 slice
的一些操作技巧,以方便可以高效的使用 slice
, 并使用 slice
解决一些棘手的问题。
先熟悉一些 slice
的基本的操作, 对最常规的 :
操作就可玩出很多花样。
s=ss[:]
引用一个切片或数组s=s[:0]
清空切片s=s[:10]
s=s[10:]
s=s[10:20]
截取接片s=ss[0:10:20]
从切片或数组引用指定长度和容量的切片下标索引操作的一些误区 s[i:l:c]
i
是起始偏移的起始位置,l
是起始偏移的长度结束位置, l-i
就是新 slice
的长度, c
是起始偏移的容量结束位置,c-i
就是新 slice
的容量。其中 i
、l
、c
并不是当前 slice
的索引,而是引用底层数组相对当前 slice
起始位置的偏移量,所以是可超出当前 slice
的长度的, 但不能超出当前 slice
的容量,如下操作是合法的:
1 | package main |
其中 s1
是 []
;s2
是 [100 0 0 0 0 0 0 0 0 0]
, 这里并不会发生下标越界的情况,一个更好的例子在 csv reader 中的一个例子
创建 slice
创建切片的方法有很多,下面罗列一些常规的:
var s []int
创建 nil切片s := make([]int, 0, 0)
、 s=[]int{}
创建无容量空切片s:= make([]int, 0, 100)
创建有容量空切片s:=make([]int, 100)
创建零值切片s:=array[:]
引用数组创建切片内置函数
len(s)
获取切片的长度cap(s)
获取切片的容量append(s, ...)
向切片追加内容copy(s, s1)
向切片拷贝内容遇到过很多拼接字符串的方法,各种各样的都有 fmt
builder
buffer
+
等等,实际上 builder
和 buffer
都是使用 []byte
的切片作为缓冲来实现的,fmt
往往性能最差,原因是它主要功能不是连接字符串而是格式化数据会用到反射等等操作。+
操作在大量拼接时性能也是很差, 不过小字符串少量拼接效果很理想,builder
往往性能不如 buffer
特别是在较短字符串拼接上,实际 builder
和 buffer
实现原理非常类似,builder
在转成字符串时使用了 unsafe
减少了一次内存分配,因为小字符串因为扩容机制不如 buffer
灵活,所以性能有所不如,大字符串降低一次大的内存分配就显得很明显了。
经常遇到一个需求就是拼接 []int
中个各个元素,很多种实现都有人用,都是需要遍历转换 int
到 string
,但是拼接方法千奇百怪,以下提供两种方法对比(源码在GitHub)。
1 | package slice |
SliceInt2String1
使用原始的 +
操作,因为是较小的字符串拼接,使用 +
主要是因为在小字符串拼接性能优于其它几种方法,SliceInt2String2
与 SliceInt2String3
都使用了一个 256
容量的 []byte
作为缓冲, 唯一的区别是在返回时一个使用 string
转换类型,一个使用 unsafe
转换类型。
写了一个性能测试(源码在GitHub),看一下效果吧:
1 | goos: darwin |
明显可以看得出 SliceInt2String2
的性能是 SliceInt2String1
7倍左右,提升很明显,SliceInt2String2
与 SliceInt2String3
差异很小,主要是因为使用 unsafe
转换类型导致大内存无法释放,实际这个测试中连接字符串只需要 32
个字节,使用 unsafe
却导致 256
个字节无法被释放,这也正是 builder
和 buffer
的差别,所以小字符串拼接 buffer
性能往往更好。在这里简单的通过 []byte
减少内存分配次数来实现缓冲。
如果连续拼接一组这样的操作,比如输入 [][]int
, 输出 []string
(源码在GitHub):
1 | package slice |
SliceInt2String5
中使用 b = b[:0]
来促使达到反复使用一块缓冲区,写了一个性能测试(源码在GitHub),看一下效果吧:
1 | goos: darwin |
较 +
版本提升接近4倍的性能,这是使用 slice
作为缓冲区极好的技巧,使用非常方便,并不用使用 builder
和 buffer
, slice
操作非常的简单实用。
如果合并多个 slice
为一个,有三种方式来合并,主要合并差异来源于创建新 slice
的方法,使用 var news []int
或者 news:=make([]int, 0, len(s1)+len(s2)....)
的方式创建的新变量就需要使用 append
来合并,如果使用 news:=make([]int, len(s1)+len(s2)....)
就需要使用 copy
来合并。不同的方法也有差异,append
和 copy
在这个例子中主要差异在于 append
适用于零长度的初始化 slice
, copy
适用于确定长度的 slice
。
写了一个测试来看看两者的差异吧(源码在GitHub):
1 | func BenchmarkExperiment3Append1(b *testing.B) { |
测试结果如下:
1 | goos: darwin |
从结果上来看使用没有容量的 append
性能真的很糟糕,实际上不要对没有任何容量的 slice
进行 append
操作是最好的实践,在准备用 append
的时候应该预先给定一个容量,哪怕这个容量并不是确定的,像前面缓存连接字符串时一样,并不能明确使用的空间,先分配256个字节,这样的好处是可以减少系统调用分配内存的次数,即使空间不能用完,也不用太过担心浪费,append
本身扩容机制也会导致空间不是刚刚好用完的,而初始化的容量往往结合业务场景给的一个均值,这是很好的。
append
和 copy
在预先确定长度和容量时 append
效果更好一些,主要原因是 copy
需要一个变量来记录位置。 如果使用场景中没有强制限定长度,建议使用 append
因为 append
会根据实际情况再做内存分配,较 copy
也更加灵活一些, 而 copy
往往用在长度固定的地方,可以防止数据长度溢出的问题,例如标准库中 strings.Repeat
函数,它采用指数增长的方式快速填充指定数量的字符,但是如果使用 append
就会发生多余的内存分配,导致长度溢出。
1 | func Repeat(s string, count int) string { |
官方标准库 csv
的读取性能极高,其中 reader
里面有使用 slice
极好的例子,以下是简略的代码,如果想要全面了解程序需要去看标准库的源码:
1 | func (r *Reader) readRecord(dst []string) ([]string, error) { |
这里删除了极多的代码,但是能看懂大意,其中 line
是一段 bufio
中的一段引用,所以这块数据不能返回给用户,也不能进行并发读取操作。
r.recordBuffer
和 r.fieldIndexes
是 csv
的缓存,他们初始的时候容量是0,是不是会有些奇怪,之前还建议 slice
初始一个长度,来减少内存分配,csv
这个库的设计非常的巧妙,假设 csv
每行字段的个数一样,数据长度也相近,现实业务确实如此,所以只有读取第一行数据的时候才会发生大量的 slice
扩容, 之后其它行扩容的可能性非常的小,整个文件读取完也不会发生太多次,不得不说设计的太妙了。
r.recordBuffer
用来存储行中除了分隔符的所有数据,r.fieldIndexes
用来存储每个字段数据在 r.recordBuffer
中的索引。每次都通过 r.recordBuffer[:0]
这个的数据获取,读取每行数据都反复使用这块内存,极大的减少内存开销。
更巧妙的设计是 str := string(r.recordBuffer)
源代码中也有详细的说明,一次性分配足够的内存, 要知道类型转换是会发生内存拷贝的,分配新的内存, 如果每个字段转换一次,会发生很多的内存拷贝和分配,之后通过 dst[i] = str[preIdx:idx]
引用 str
中的数据达到切分字段的效果,因为引用字符串并不会拷贝字符串(字符串不可变,引用字符串的子串是安全的)所以其代价非常的小。
这段源码中还有一个很多人都不知道的 slice
特性的例子,dst = dst[:0]; dst = dst[:len(r.fieldIndexes)]
这两句话放到一起是不是感觉很不可思议,明明 dst
的长度被清空了,dst[:len(r.fieldIndexes)]
不是会发生索引越界吗,很多人认为 s[i:l]
这种写法是当前 slice
的索引,实际并非如此,这里面的 i
和 j
是底层引用数组相对当前 slice
引用位置的索引,并不受当前 slice
的长度的影响。
这里只是简单引用 csv
源码中的一段分析其 slice
的巧妙用法,即把 slice
当做数据缓存,也作为分配内存的一种极佳的方法,这个示例中的关于 slice
的使用值得反复推敲。
早些时间阅读 GitHub 上的一些源码,发现一个实现内存次的例子,里面对 slice
的应用非常有特点,在这里拿来分析一下(GitHub源码):
1 | func NewChanPool(minSize, maxSize, factor, pageSize int) *ChanPool { |
这里采用步进式分页,保证每页上的数据块大小相同,一次性创建整个页 make([]byte, pageSize)
,之后从页切分数据块 mem := c.page[i*chunkSize : (i+1)*chunkSize : (i+1)*chunkSize]
, 容量和数据块长度一致,创建一块较大的内存,减少系统调用,当然这个例子中还可以创建更大的内存,就是每页容量的总大小,避免创建更多页,所有的块数据都引用一块内存。
这里限制了每个块的容量,默认引用 slice
的容量是引用起始位置到底层数组的结尾,但是可以指定容量,这就保证了获取的数据块不会因为用户不遵守约定超出其大小导致数据写入到其它块中的问题,设定了容量用户使用超出容量后就会拷贝出去并创建新的 slice
实在的很妙的用法。
一次分配更大的内存可以减少内存碎片,更好的复用内存。
1 | func (pool *ChanPool) Alloc(size int) []byte { |
获取内存池中的内存就非常简单,查找比需要大小更大的块并返回即可,这不失为一个较好的内存复用算法。
1 | func (pool *ChanPool) Free(mem []byte) { |
当使用完释放内存时实现的并不是很好,应该判断释放的数据是否是当前内存的一部分,如果不是的就不能放回到内存池中,因为用户未按约定大小使用,导致大量扩容而使得内存池中的数据碎片化,当然用户一旦发生扩容就会导致内存池中的缓存块丢失,导致存在大块内存无法释放,却也没法使用的情况。
之所以分析这个例子主要是分析其使用 slice
的方法和技巧,并不推荐使用该方法管理内存。
更多关于 slice
应用的例子可以参考标准库 bytes
与 bufio
, buffer
与 bufio
的使用极其相似,两个包都是使用 slice
来减少内存分配及系统调用来达到实现缓冲和缓存的例子。
array
和 slice
看似相似,却有着极大的不同,但他们之间还有着千次万缕的联系 slice
是引用类型、是 array
的引用,相当于动态数组,slice
的特性,但是 slice
底层如何表现,内存中是如何分配的,特别是在程序中大量使用 slice
的情况下,怎样可以高效使用 slice
?Go
的 unsafe
包来探索 array
和 slice
的各种奥妙。slice
是在 array
的基础上实现的,需要先详细了解一下数组。
** 维基上如此介绍数组:**
在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储,利用元素的索引(index)可以计算出该元素对应的存储地址。
** 数组设计之初是在形式上依赖内存分配而成的,所以必须在使用前预先请求空间。这使得数组有以下特性:**
- 请求空间以后大小固定,不能再改变(数据溢出问题);
- 在内存中有空间连续性的表现,中间不会存在其他程序需要调用的数据,为此数组的专用内存空间;
- 在旧式编程语言中(如有中阶语言之称的C),程序不会对数组的操作做下界判断,也就有潜在的越界操作的风险(比如会把数据写在运行中程序需要调用的核心部分的内存上)。
根据维基的介绍,了解到数组是存储在一段连续的内存中,每个元素的类型相同,即是每个元素的宽度相同,可以根据元素的宽度计算元素存储的位置。
通过这段介绍总结一下数组有一下特性:
Go
中的数组如何实现的呢,恰恰就是这么实现的,实际上几乎所有计算机语言,数组的实现都是相似的,也拥有上面总结的特性。Go
语言的数组不同于 C
语言或者其他语言的数组,C
语言的数组变量是指向数组第一个元素的指针;
而 Go
语言的数组是一个值,Go
语言中的数组是值类型,一个数组变量就表示着整个数组,意味着 Go
语言的数组在传递的时候,传递的是原数组的拷贝。
在程序中数组的初始化有两种方法 arr := [10]int{}
或 var arr [10]int
,但是不能使用 make
来创建,数组这节结束时再探讨一下这个问题。
使用 unsafe
来看一下在内存中都是如何存储的吧:
1 | package main |
这段代码的输出如下 (Go Playground):
12
2
10
首先说 12
是 fmt.Println(unsafe.Sizeof(arr))
输出的,unsafe.Sizeof
用来计算当前变量的值在内存中的大小,12
这个代表一个 int
有4个字节,3 * 4
就是 12
。
这是在32位平台上运行得出的结果, 如果在64位平台上运行数组的大小是 24
。从这里可以看出 [3]int
在内存中由3个连续的 int
类型组成,且有 12
个字节那么长,这就说明了数组在内存中没有存储多余的数据,只存储元素本身。
size := unsafe.Sizeof(arr[0])
用来计算单个元素的宽度,int
在32位平台上就是4个字节,uintptr(unsafe.Pointer(&arr[0]))
用来计算数组起始位置的指针,1*size
用来获取索引为1的元素相对数组起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size))
获取索引为1的元素指针,*(*int)
用来转换指针位置的数据类型, 因为 int
是4个字节,所以只会读取4个字节的数据,由元素类型限制数据宽度,来确定元素的结束位置,因此得到的结果是 2
。
上一个步骤获取元素的值,其中先获取了元素的指针,赋值的时候只需要对这个指针位置设置值就可以了, *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10
就是用来给指定下标元素赋值。
1 | package main |
如上代码,动态的给数组设定长度,会导致编译错误 non-constant array bound n
, 由此推导数组的所有操作都是编译时完成的,会转成对应的指令,通过这个特性知道数组的长度是数组类型不可或缺的一部分,并且必须在编写程序时确定。
可以通过 GOOS=linux GOARCH=amd64 go tool compile -S array.go
来获取对应的汇编代码,在 array.go
中做一些数组相关的操作,查看转换对应的指令。
之前的疑问,为什么数组不能用 make
创建? 上面分析了解到数组操作是在编译时转换成对应指令的,而 make
是在运行时处理(特殊状态下会做编译器优化,make可以被优化,下面 slice
分析时来讲)。
因为数组是固定长度且是值传递,很不灵活,所以在 Go
程序中很少看到数组的影子。然而 slice
无处不在,slice
以数组为基础,提供强大的功能和遍历性。slice
的类型规范是[]T,slice
T元素的类型。与数组类型不同,slice
类型没有指定的长度。
** slice
申明的几种方法:**
s := []int{1, 2, 3}
简短的赋值语句var s []int
var
申明make([]int, 3, 8)
或make([]int, 3)
make
内置方法创建s := ss[:5]
从切片或者数组创建
** slice
有两个内置函数来获取其属性:**
len
获取slice
的长度cap
获取slice
的容量
slice
的属性,这东西是什么,还需借助 unsafe
来探究一下。
1 | package main |
这段代码的输出如下 (Go Playground):
c00007ce90
10
20
[0 0 100 0 0 0 0 0 0 200 0 0 0 0 0 0 0 0 0 0]
这段输出除了第一个,剩余三个好像都能看出点什么, 10
不是创建 slice
的长度吗,20
不就是指定的容量吗, 最后这个看起来有点像 slice
里面的数据,但是数量貌似有点多,从第三个元素和第十个元素来看,正好是给 slice
索引 2
和 10
指定的值,但是切片不是长度是 10
个吗,难道这个是容量,容量刚好是 20
个。
第二和第三个输出很好弄明白,就是 slice
的长度和容量, 最后一个其实是 slice
引用底层数组的数据,因为创建容量为 20
,所以底层数组的长度就是 20
,从这里了解到切片是引用底层数组上的一段数据,底层数组的长度就是 slice
的容量,由于数组长度不可变的特性,当 slice
的长度达到容量大小之后就需要考虑扩容,不是说数组长度不能变吗,那 slice
怎么实现扩容呢, 其实就是在内存上分配一个更大的数组,把当前数组上的内容拷贝到新的数组上, slice
来引用新的数组,这样就实现扩容了。
说了这么多,还是没有看出来 slice
是如何引用数组的,额…… 之前的程序还有一个输出没有搞懂是什么,难道这个就是底层数组的引用。
1 | package main |
以上代码输出如下(Go Playground):
[1 2 3 0 0 0 0 100 0 200]
———-s1———
c00001c0a0
c00001c0a0
10
10
[1 2 3 0 0 0 0 100 0 200]
[1 2 3 0 0 0 0 100 0 200]
———-s2———
c00001c0b0
c00001c0b0
6
8
[3 0 0 0 0 100]
[3 0 0 0 0 100 0 200]
这段输出看起来有点小复杂,第一行输出就不用说了吧,这个是打印整个数组的数据。先分析一下 s1
变量的下面的输出吧,s1 := arr[:]
引用了整个数组,所以在第5、6行输出都是10,因为数组长度为10,所有 s1
的长度和容量都为10,那第3、4行输出是什么呢,他们怎么都一样呢,之前分析数组的时候 通过 uintptr(unsafe.Pointer(&arr[0]))
来获取数组起始位置的指针的,那么第4行打印的就是数组的指针,这么就了解了第三行输出的是上面了吧,就是数组起始位置的指针,所以 *(*uintptr)(unsafe.Pointer(&s1))
获取的就是引用数组的指针,但是这个并不是数组起始位置的指针,而是 slice
引用数组元素的指针,为什么这么说呢?
接着看 s2
变量下面的输出吧,s2 := arr[2:8]
引用数组第3~8的元素,那么 s2
的长度就是 6。 根据经验可以知道 s2
变量输出下面第3行就是 slice
的长度,但是为啥第4行是 8
呢,slice
应用数组的指定索引起始位置到数组结尾就是 slice
的容量, 所以 所以从第3个位置到末尾,就是8个容量。在看第1行和第2行的输出,之前分析数组的时候通过 uintptr(unsafe.Pointer(&arr[0]))+size*2
来获取数组指定索引位置的指针,那么这段第2行就是数组索引为2的元素指针,*(*uintptr)(unsafe.Pointer(&s2))
是获取切片的指针,第1行和第2行输出一致,所以 slice
实际是引用数组元素位置的指针,并不是数组起始位置的指针。
** 总结:**
slice
是的起始位置是引用数组元素位置的指针。slice
的长度是引用数组元素起始位置到结束位置的长度。slice
的容量是引用数组元素起始位置到数组末尾的长度。经过上面一轮分析了解到 slice
有三个属性,引用数组元素位置指针、长度和容量。实际上 slice
的结构像下图一样:
slice
是如何增长的,用 unsafe
分析一下看看:
1 | package main |
以上代码的输出(Go Playground):
c000082e90
9 10
c000082e90
10 10
c00009a000
11 20
从结果上看前两次地址是一样的,初始化一个长度为9,容量为10的 slice
,当第一次 append
的时候容量是足够的,所以底层引用数组地址未发生变化,此时 slice
的长度和容量都为10,之后再次 append
的时候发现底层数组的地址不一样了,因为 slice
的长度超过了容量,但是新的 slice
容量并不是11而是20,这要说 slice
的机制了,因为数组长度不可变,想扩容 slice
就必须分配一个更大的数组,并把之前的数据拷贝到新数组,如果一次只增加1个长度,那就会那发生大量的内存分配和数据拷贝,这个成本是很大的,所以 slice
是有一个增长策略的。
Go
标准库 runtime/slice.go
当中有详细的 slice
增长策略的逻辑:
1 | func growslice(et *_type, old slice, cap int) slice { |
基本呢就三个步骤,计算新的容量、分配新的数组、拷贝数据到新数组,社区很多人分享 slice
的增长方法,实际都不是很精确,因为大家只分析了计算 newcap
的那一段,也就是上面注释的第一部分,下面的 switch
根据 et.size
来调整 newcap
一段被直接忽略,社区的结论是:”如果 selic
的容量小于1024个元素,那么扩容的时候 slice
的 cap
就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一” 大多数情况也确实如此,但是根据 newcap
的计算规则,如果新的容量超过旧的容量2倍时会直接按新的容量分配,真的是这样吗?
1 | package main |
以上代码的输出(Go Playground):
10 10
50 52
这个结果有点出人意料, 如果是2倍增长应该是 10 * 2 * 2 * 2
结果应该是80, 如果说新的容量高于旧容量的两倍但结果也不是50,实际上 newcap
的结果就是50,那段逻辑很好理解,但是switch
根据 et.size
来调整 newcap
后就是52了,这段逻辑走到了 case et.size == sys.PtrSize
这段,详细的以后做源码分析再说。
** 总结 **
slice
的长度超过其容量,会分配新的数组,并把旧数组上的值拷贝到新的数组slice
并操过其容量, 如果 selic
的容量小于1024个元素,那么扩容的时候 slice
的 cap
就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。slice
发生扩容,引用新数组后,slice
操作不会再影响旧的数组,而是新的数组(社区经常讨论的传递 slice
容量超出后,修改数据不会作用到旧的数据上),所以往往设计函数如果会对长度调整都会返回新的 slice
,例如 append
方法。slice
不发生扩容,所有的修改都会作用在原数组上,那如果把 slice
传递给一个函数或者赋值给另一个变量会发生什么呢,slice
是引用类型,会有新的内存被分配吗。
1 | package main |
这个例子(Go Playground)比较长就不逐一分析了,在这个例子里面调用函数传递 slice
其变量的地址发生了变化, 但是引用数组的地址,slice
的长度和容量都没有变化, 这说明是对 slice
的浅拷贝,拷贝 slice
的三个属性创建一个新的变量,虽然引用底层数组还是一个,但是变量并不是一个。
第二个创建 s1
变量,使用 s
为其赋值,发现 s1
和函数调用一样也是 s
的浅拷贝,之后修改 s1
的长度发现 s1
的长度发生变化,但是 s
的长度保持不变, 这也说明 s1
就是 s
的浅拷贝。
这样设计有什么优势呢,第三步创建 s2
变量, 并且 append
一个元素, 发现 s2
的长度发生变化了, s
并没有,虽然这个数据就在底层数组上,但是用常规的方法 s
是看不到第11个位置上的数据的, s1
因为长度覆盖到第11个元素,所有能够看到这个数据的变化。这里能看到采用浅拷贝的方式可以使得切片的属性各自独立,而不会相互影响,这样可以有一定的隔离性,缺点也很明显,如果两个变量都引用同一个数组,同时 append
, 在不发生扩容的情况下,总是最后一个 append
的结果被保留,可能引起一些编程上疑惑。
** 总结 **
slice
是引用类型,但是和 C
传引用是有区别的, C
里面的传引用是在编译器对原变量数据引用, 并不会发生内存分配,而 Go
里面的引用类型传递和赋值会进行浅拷贝,在32位平台上有12个字节的内存分配, 在64位上有24字节的内存分配。
* 传引用和引用类型是有区别的, slice
是引用类型。*
slice
有三种状态:零切片、空切片、nil切片。
所有的类型都有零值,如果 slice
所引用数组元素都没有赋值,就是所有元素都是类型零值,那这就是零切片。
1 | package main |
以上代码输出(Go Playground):
[0 0 0 0 0 0 0 0 0 0]
[]
[ ]
零切片很好理解,数组元素都为类型零值即为零切片,这种状态下的 slice
和正常的 slice
操作没有任何区别。
空切片可以理解就是切片的长度为0,就是说 slice
没有元素。 社区大多数解释空切片为引用底层数组为 zerobase
这个特殊的指针。但是从操作上看空切片所有的表现就是切片长度为0,如果容量也为零底层数组就会指向 zerobase
,这样就不会发生内存分配, 如果容量不会零就会指向底层数据,会有内存分配。
1 | package main |
以上代码输出(Go Playground):
–s—-s—-s—-s—-s—-s—-s—-s—-s—-s–
{0 0 0}
[]
–s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1–
{18349960 0 0}
[]
–s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2–
{18349960 0 0}
[]
–s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3–
{824634269696 0 100}
[]
–s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4–
{824633835680 0 10}
[]
以上示例中除了 s
其它的 slice
都是空切片,打印出来全部都是 []
,s
是nil切片下一小节说。要注意 s1
和 s2
的长度和容量都为0,且引用数组指针都是 18349960
, 这点太重要了,因为他们都指向 zerobase
这个特殊的指针,是没有内存分配的。
什么是nil切片,这个名字说明nil切片没有引用任何底层数组,底层数组的地址为nil就是nil切片。上一小节中的 s
就是一个nil切片,它的底层数组指针为0,代表是一个 nil
指针。
零切片就是其元素值都是元素类型的零值的切片。
空切片就是数组指针不为nil,且 slice
的长度为0。
nil切片就是引用底层数组指针为 nil
的 slice
。
操作上零切片、空切片和正常的切片都没有任何区别,但是nil切片会多两个特性,一个nil切片等于 nil
值,且进行 json
序列化时其值为 null
,nil切片还可以通过赋值为 nil
获得。
对数组和 slice
做了性能测试,源码在 GitHub。
对不同容量和数组和切片做性能测试,代码如下,分为:100、1000、10000、100000、1000000、10000000
1 | func BenchmarkSlice100(b *testing.B) { |
测试结果如下:
goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/array_slice/test
BenchmarkSlice100-8 20000000 69.8 ns/op 0 B/op 0 allocs/op
BenchmarkArray100-8 20000000 69.0 ns/op 0 B/op 0 allocs/op
BenchmarkSlice1000-8 5000000 318 ns/op 0 B/op 0 allocs/op
BenchmarkArray1000-8 5000000 316 ns/op 0 B/op 0 allocs/op
BenchmarkSlice10000-8 200000 9024 ns/op 81920 B/op 1 allocs/op
BenchmarkArray10000-8 500000 3143 ns/op 0 B/op 0 allocs/op
BenchmarkSlice100000-8 10000 114398 ns/op 802816 B/op 1 allocs/op
BenchmarkArray100000-8 20000 61856 ns/op 0 B/op 0 allocs/op
BenchmarkSlice1000000-8 2000 927946 ns/op 8003584 B/op 1 allocs/op
BenchmarkArray1000000-8 5000 342442 ns/op 0 B/op 0 allocs/op
BenchmarkSlice10000000-8 100 10555770 ns/op 80003072 B/op 1 allocs/op
BenchmarkArray10000000-8 50 22918998 ns/op 80003072 B/op 1 allocs/op
PASS
ok github.com/thinkeridea/example/array_slice/test 23.333s
从上面的结果可以发现数组和 slice
在1000以内的容量上时性能机会一致,而且都没有内存分配,这应该是编译器对 slice
的特殊优化。
从10000~1000000容量时数组的效率就比slice
好了一倍有余,主要原因是数组在没有内存分配做了编译优化,而 slice
有内存分配。
但是10000000容量往后数组性能大幅度下降,slice
是数组性能的两倍,两个都在运行时做了内存分配,其实这么大的数组还真是不常见,也没有比较做编译器优化了。
slice
和数组有些差别,特别是应用层上,特性差别很大,那什么时间使用数组,什么时间使用切片呢。
之前做了性能测试,在1000以内性能几乎一致,只有10000~1000000时才会出现数组性能好于 slice
,由于数组在编译时确定长度,也就是再编写程序时必须确认长度,所有往常不会用到更大的数组,大多数都在1000以内的长度。我认为如果在编写程序是就已经确定数据长度,建议用数组,而且竟可能是局部使用的位置建议用数组(避免传递产生值拷贝),比如一天24小时,一小时60分钟,ip是4个 byte
这种情况是可以用时数组的。
为什么推荐用数组,只要能在编写程序是确定数据长度我都会用数组,因为其类型会帮助阅读理解程序,dayHour := [24]Data
一眼就知道是按小时切分数据存储的,如要传递数组时可以考虑传递数组的指针,当然会带来一些操作不方便,往常我使用数组都是不需要传递给其它函数的,可能会在 struct
里面保存数组,然后传递 struct
的指针,或者用 unsafe
来反解析数组指针到新的数组,也不会产生数据拷贝,并且只增加一句转换语句。slice
会比数组多存储三个 int
的属性,而且指针引用会增加 GC
扫描的成本,每次传递都会对这三个属性进行拷贝,如果可以也可以考虑传递 slice
的指针,指针只有一个 int
的大小。
** 对于不确定大小的数据只能用 slice
,否则就要自己做扩容很麻烦, 对于确定大小的集合建议使用数组。**
我的程序中有一块缓存,数据会组织好放到内存中,会根据数据源(MySQL)更新而刷新缓存,是读多写少的应用场景。
内存中有一个很大数据列表,缓存模块会按数据维度进行分组,每次访问根据维度查找到这个列表里面的所有数据。
业务模块拿到数据后会根据业务需要再做一次筛选,选出N个符合条件的数据(具体多少个由业务模块的规则决定)。
以下是简化的代码:
1 | package cache |
这个方法返回的数据会很多,可实际业务需要的数据只有几个而已,那做一个优化吧,利用 go
的 chan
实现一个迭代生成器,每次只返回一个数据,业务端找到需要的数据后立即终止。
调整后的方法大致像下面这样:
1 | package cache |
调用端的代码类似下面这样:
1 | data := make([]int, 0, 10) |
这样调整后查看程序的内存分配显著降低,而且平安无事在生产环境运行了半个月^_^,当然截止当前还不会出现死锁的情况。
有一天业务调整了,在 cache
模块有另外一个方法,公用这个锁(实际我缓存模块为了统一,都使用一个锁,方便管理),下面的代码也写到这个 cache
组件里面。
以下代码只增加了改变的部分,....
保持原来的代码不变。
1 | package cache |
添加一个方法怎么就导致死锁了呢,主要是调用端的业务代码也发生变化了,更改如下:
1 | data := make([]int, 0, 10) |
修改后的代码上线存活了5天就挂了,实际是当时业务订单需求很少,只是有很多流量请求,并没有频繁访问这个方法,否者会在极短的时间导致死锁,
通过这块简化的代码,也很难分析出会导致死锁,真实的业务代码很多,而且调用关系比较复杂,我们通过代码审核并没有发现任何问题。
上线5天后突然接到服务无法响应的报警,事故发生立即查看了 grafana
的监控数据,发现在极段时间内服务器资源消耗极速增长,然后就立即没有响应了
通过业务监控发现服务在极端的时间打开近10万个 goroutine
之后持续了很长一段时间,cpu
占用和 gc
都很正常, 内存方面可以看出短时间内分配了很多内存,但是没有被释放,gc
没法回收说明一直被占用,
看到这里我心里在想可能是有个 goroutine
因为什么原因导致无法结束造成的事故吧,
然后我再往下看(实际页面是在需要滚动屏幕,第一屏只显示了上面6个模块),发现 open files 和 goroutine
的情况一致,并且之后的数据突然中断,
中断是因为服务无法影响,也就无法采集服务的信息了。
goroutine
并不会占用 open files,一个http服务导致这种情况大概只能是网络连接过多,我们遭受攻击了吗……
显然是没有的不然cpu不能很正常,那就是有可能请求无法响应,什么原因导致呢?
使用 lsof -n | grep dsp | wc -l
命令去服务器查找服务打开文件数,确实在六万五千多,
通过 cat /proc/30717/limits
发现 Max open files 65535 65535 files
,
配置的最大打开文件数只有 65535,使用 lsof -n | grep dsp |grep TCP | wc -l
发现数据和之前接近,只小了几个,那是日志文件占用的。
查看日志发现大量 http: Accept error: accept tcp 172.17.191.231:8090: accept4: too many open files; retrying in 1s
错误。
这些数据帮助我快速定位确实是有请求发送到服务器,服务器无法响应导致短时间内占用很多文件打开数,导致系统限制无法建立新的连接。
这里要说一下,即使客户端断开连接了,服务器连接还是没有办法关闭,因为 goroutine
没有办法关闭, 除非自己退出。
找到原因了,服务没法响应,没法通过现场查找问题了,先重新启动一下服务,恢复业务在查找代码问题。
接下来就是查找代码问题了,期间又出现了一次故障,立即重启服务,恢复业务。
通过几个小时分析代码逻辑,终于有了进展,发现上面的示例代码逻辑块导致读锁重入,存在死锁风险,这种死锁的碰撞概率非常低,
之前说过我们的缓存是读多写少的场景,如果只是读取数据,上面的代码不会有任何问题,我们一天刷新缓存的次数也不过百余次而已。
看一下究竟发生了什么导致的死锁吧:
cache.Get
获取一个 chan
, 在 cache.Get
里面有一个 goroutine
读取数据只有加了读写锁,只有 goroutine
关闭才会释放for i := range c.Get(next) {
遍历 chan
时 goroutine
不会结束,也就说读锁没有被释放c.XX(i)
方法,在该方面里面也加了读锁, 形成了读锁重入的场景,但是该放执行周期很短,执行完就会马上释放好吧,这样的流程并没有形成死锁,什么情况下导致的死锁呢,接着看一下一个场景:
cache.Get
获取一个 chan
, 在 cache.Get
里面有一个 goroutine
读取数据只有加了读写锁,只有 goroutine
关闭才会释放for i := range c.Get(next) {
遍历 chan
时 goroutine
不会结束,也就说读锁没有被释放c.XX(i)
方法,该方法申请读锁,因为写锁在等待,所以任何读锁都将等待写锁释放后才能添加成功cache.Get
里面的 goroutine
无法退出,无法释放读锁c.XX(i)
等待写锁释放重点看第三步,这里是关键,因为在两个嵌套的读锁中间申请写锁,导致死锁发生,找到原因修复起来很简单的,
调整 cache.Get
加锁的方法,把 c.data
赋值给一个临时变量 data
, 在这段代码前后加锁和释放锁,锁的代码块更小,时间更短
c.data
单独拷贝是安全的,那怕是指针数据,因为每次刷新缓存都会给 c.data
重新赋值,分配新的内存空间。
1 | package cache |
修复之后的业务状态:
用程序复现一下上面的场景可以吗,好像有点难,我写了一个简单的复现代码,如下:
1 | package main |
这段程序的输出(受 goroutine
运行时影响在输出数字3之前会有些许差异):
1 | 1 |
分析一下这个运行流程吧:
fmt.Println(1)
之前, 状态加读锁1goroutine
启动,fmt.Println(5)
, 状态加读锁1c <- 1
, 状态加读锁1<-c
fmt.Println(6)
, 状态加读锁1fmt.Println(2)
, 状态加读锁1goroutine
runtime.Gosched()
, 状态加读锁1l.Lock()
, 等待读锁1释放, 状态加读锁1、写锁等待goroutine
执行 fmt.Println(3)
与 b()
, 状态加读锁1、写锁等待fmt.Println(10)
, 申请读锁2,等待写锁释放, 状态加读锁1、写锁等待、读锁2等待1 | func (rw *RWMutex) RLock() { |
申请写锁时会在 rw.readerCount
读数量变量上自增加 1,如果结果小于 0,当前读锁进入修改等待读锁唤醒信号,
单独看着一个方法会比较懵,为啥读的数量会小于0呢,接着看写锁。
1 | func (rw *RWMutex) Lock() { |
申请写锁时会先加上互斥锁,也就是有其它写的客户端的话会等待写锁释放才能加上,具体实现看互斥锁的代码,
然后在 rw.readerCount
上自增一个极大的负数 1 << 30
, 读写锁这里也就限制了我们的同时读的进程不能超过这个值。
然后在结果上加上 rwmutexMaxReaders
也就是 atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
得到实际读客户端的数量
如果读的客户端不等于0,就在 rw.readerWait
自增读客户端的数量,之后陷入睡眠,等待 rw.writerSem
唤醒。
分析了这两段代码我们就能明白,写锁等待或者添加时,读锁没法添加上
1 | func (rw *RWMutex) RUnlock() { |
释放读锁,先在 rw.readerCount
减 1,然后检查读客户端是否小于0,如果小于0说明有写锁在等待,
在 rw.readerWait
上减1,这个变量记录的是写等待读客户端的数量,如果没有需要等待的读客户端了,就通知 rw.writerSem
唤醒写锁
1 | func (rw *RWMutex) Unlock() { |
写锁在释放时会给 rw.readerCount
自增 rwmutexMaxReaders
还原真实读客户端数量。for i := 0; i < int(r); i++ {
用来唤醒所有的读客户端,因为在写锁的时候,申请读锁的客户端会被计数,但是都会陷入睡眠状态。
以前特别强调过读锁重入导致死锁的问题,而且这个问题非常难在业务代码里面复现,触发几率很低,
编译和运行时都无法检测这种情况,所以千万不能陷入读锁重入的嵌套使用的情况,否者问题非常难以排查。
关于加锁的几个小经验:
defer
释放锁。