引子

这篇文章似乎拖得有点久了,好在现在在写了。Golang 的风潮尚未过去,大红大紫的 Docker 进一步提高了人们对这门语言的期待,但是当你把 Golang 用在线上生产环境的时候,就会面临一个比较严重问题:依赖管理。

对于依赖管理,Java 有 Maven,Python 有 pip,Nodejs 也有 npm,Golang 自己本身作为一种编译型的语言,依赖管理只能靠官方提供的 go-get 工具,大部分情况下,这个工具对我们在开发时还是相当友好的。

go get 简介

go-get是 Golang 自带的一个依赖管理工具,他可以从远程下载你需要的包,但是注意Golang并没有统一的仓库,所以你传给 go get 的参数其实是一个URL。对于 Github, Google Code, Bitbucket 这样的网站,go get 可以自动解析到 repo 地址,典型的 go get 大概会包含下面几个步骤:

  • fetch repo 到本地的 $GOPATH/src 下, 此过程会依赖 repo 对应版本管理软件,比如你要安装一个 Github 上的包,那么你本地必须安装了 git
  • 执行go install命令,对源码进行编译并把编译好的二进制文件拷贝到$GOPATH/pkg下面
  • 如果不是 Github 这样第三方托管网站,比如公司内的 Gitlab 系统,go get会解析 html 返回中名为go-importmeta标签,找到 repo 的类型和地址,然后重复上面的事情

不过这样的实现会有一点问题,假如我们用了类似Gitlab的系统,在公司内部的很多 repo 都是 Internal 的,go get无法直接访问到 repo 页面,自然谈不上解析 go-importgo get 还没只能到可以自动登录,所以一定是失败的,我们依赖的 Package 还是无法自动安装......

生产环境的挑战和 Godep 工具

不同于本地开发可以想到什么依赖就安装一下,少了东西可以再补,生产环境通常需要一套可靠的自动化流程来完成软件极其依赖的构建,比如像 Mavenbuildout 这样的工具,既可以做到自动构建,又可以做到环境隔离。

go get 工具能用但是我们经常会忘记在 go get install 列表里写上我们的依赖,上线的时候才发现:"啊,我这个包手贱忘记写进去了,怎么办!"

程序员的这种需求不可能不被照顾到,所以就有了 Godep 这个工具,Godep 使用还是很简单的,常用的命令就2个:

  • godep save, 扫描你的代码中的import,把所有依赖的包都写入一个 json 文件
  • godep restore, 读取上一步生成的 json 文件,调用 go get 安装每个依赖

所以很多行的go get命令变成了一行命令,妈妈再也不用担心我上学出门忘记背书包了。

但是文章写到这里,似乎还有几个问题没解决:

  • 三方库可以从 Github 或者 Bitbucket 这样的网站获取,公司内部的二方库该如何解决呢?我们的 Gitlab 仓库就是私有的怎么办?
  • Google Code 分明在天朝无法通过正常途径访问,Github 也经常抽风,你这工具是要闹咋样

对,线上的服务器无法和我们自己开发时的网络环境等同,所以不论是 Golang 自带 go get 还是 Godep 都有不完美的地方,所以还是本地大法好。

Vendor方式管理的依赖

这种方式就是来解决前面说的 2 个问题的,基本思路就是在项目中新建一个名为 vendor 的目录,把依赖放在该目录下,实践中我们一般会采用 git submodule 来做这件事情:

cd YOUR_PROJECT
mkdir vendor
git submodule add git://repo.git vendor/

然后在项目发布构建时:

cd YOUR_PROJECT
git submodule update --init

好处是不需要把依赖包的代码直接放入项目源代码中,并且可以实现自动化的构建。坏处也是有的,你必须修改代码中 import 的地址。

实践中,一般推荐 Godepvendor 结合的方式来管理项目中的依赖。

结论

上面已经写过了。

参考

  1. http://golang.org/doc/code.html#remote
  2. http://peter.bourgon.org/go-in-production/

最近线上的一个计数服务突然出现内存不足的情况,服务器 4G 的内存会在几分钟内被耗尽。计数服务是兼容 Redis Protocol 的,多方查找,最终确定问题出在 Redis Protocol 的解析上面。协议解析的实现部分借鉴了 docker/go-redis-server

简化的代码如下:

// parse redis protocol from bufio.Reader
func parse(r *bufio.Reader) ([][]byte, error) {
    line, err := r.ReadString('\n')
    var argsCount int
    if line[0] == '*' {
        fmt.Sscanf(line, "*%d\r", &argsCount)
    }
    args := make([][]byte, argsCount, argsCount)
    for i := 0; i < argsCount; i++ {
        if args[i], err = readArgument(r); err != nil {
            return nil, err
        }
    }
    return args, nil
}

func readArgument(r *bufio.Reader) ([]byte, error) {
    line, err := r.ReadString('\n')
    var argSize int
    _, err = fmt.Sscanf(line, "$%d\r", &argSize)
    if err != nil {
        return nil, err
    }
    data, err := ioutil.ReadAll(io.LimitReader(r, int64(argSize)))
    if err != nil {
        return nil, err
    }
    // check data size
    if len(data) != argSize {
        return nil, fmt.Errorf("error length of data.")
    }
    // check \r
    if b, err := r.ReadByte(); err != nil || b != '\r' {
        fmt.Printf("%s\n", string(b))
        return nil, fmt.Errorf("line should end with \\r\\n")
    }
    // check \n
    if b, err := r.ReadByte(); err != nil || b != '\n' {
        return nil, fmt.Errorf("line should end with \\r\\n")
    }
    return data, nil

}

然后调用 1w 次,看看系统总共分配的内存,这里我们会用到 Golang 的 pprof 工具,详细的用法可以参考这里 profiling-go-programs

// write heap profile to a tmp file.
func writeHeap(filename string) {
    f, err := os.Create(filename)

    if err != nil {
        log.Fatal(err)
    }
    pprof.WriteHeapProfile(f)

    defer f.Close()
}

func main() {
    s := []byte("*2\r\n$3\r\nGET\r\n$3\r\nKEY\r\n")
    for i := 0; i < 10000; i++ {
        buffer := bytes.NewReader(s)
        r := bufio.NewReaderSize(buffer, len(s))
        data, err := parse(r)
        if err != nil {
            panic(err)
        }
        fmt.Printf("%s\n", data)
    }
    writeHeap("bytes.mprof")
}

假设以上代码保存为 readBytes.go, 通过 go build 编译然后生成二进制文件( pprof 需要),运行一下,我们会得到 bytes.mprof 文件。在 shell 里执行 pprof 命令:

$ go tool pprof --alloc_space readBytes bytes.mprof
Adjusting heap profiles for 1-in-524288 sampling rate
Welcome to pprof!  For help, type 'help'.
(pprof) top
Total: 49.5 MB
    31.5  63.6%  63.6%     31.5  63.6% bytes.makeSlice
    13.0  26.3%  89.9%     44.5  89.9% io/ioutil.readAll
     1.5   3.0%  92.9%      1.5   3.0% fmt.newScanState
     0.5   1.0%  93.9%      0.5   1.0% allocg
     0.5   1.0%  94.9%      0.5   1.0% fmt.(*ss).scanNumber
     0.5   1.0%  96.0%      2.5   5.1% fmt.Sscanf
     0.5   1.0%  97.0%     47.5  96.0% main.readArgument
     0.5   1.0%  98.0%      0.5   1.0% reflect.unsafe_New
     0.5   1.0%  99.0%      0.5   1.0% runtime.convT2E
     0.5   1.0% 100.0%      0.5   1.0% runtime.mallocinit

一个简单的程序怎么会消耗掉 49.5 MB 内存?我们要解析的源字符串很小,只有 22 Bytes 。处理 1w 次加成自身复制的开销,估算一下内存开销大概是:

22 Bytes * 10000 * 2 = 440000 Bytes = 400 KB

那么这夸张的 49.5 MB从何而来?根据 pprof 的分析,大部分内存开销集中在 bytes.makeSliceio/ioutil.readAll 上面,我们的程序也确实有调用 ioutil.ReadAll()

ioutil.ReadAll实现

func readAll(r io.Reader, capacity int64) (b []byte, err error) {
    buf := bytes.NewBuffer(make([]byte, 0, capacity))
    defer func() {
        e := recover()
        if e == nil {
            return
        }
        if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge {
            err = panicErr
        } else {
            panic(e)
        }
    }()
    _, err = buf.ReadFrom(r)
    return buf.Bytes(), err
}

// bytes.MinRead = 512
func ReadAll(r io.Reader) ([]byte, error) {
    return readAll(r, bytes.MinRead)
}

可以看到,ioutil.ReadAll 每次都会分配初始化一个大小为 bytes.MinRead 的 buffer ,bytes.MinRead 在 Golang 里是一个常量,值为 512 。就是说每次调用 ioutil.ReadAll 都会分配一块大小为 512 字节的内存,由于我们要解析的 Redis Protocol 包含 2 个参数,所以每次解析会掉用 2 次 ioutil.ReadAll()。总共分配的内存大小为:

512 Bytes * 2 * 10000 = 10240000 Bytes = 10.24 MB

这个数字很接近 pprof 上给的 13 MB 了,那么剩下的就是 bytes.makeSlice 的内存开销了。搜索了一下,发现 ioutil.ReadAll() 里会调用 bytes.Buffer.ReadFrom, 而 bytes.Buffer.ReadFrom 会进行 makeSlice :

// ReadFrom reads data from r until EOF and appends it to the buffer, growing
// the buffer as needed. The return value n is the number of bytes read. Any
// error except io.EOF encountered during the read is also returned. If the
// buffer becomes too large, ReadFrom will panic with ErrTooLarge.
func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) {
    b.lastRead = opInvalid
    // If buffer is empty, reset to recover space.
    if b.off >= len(b.buf) {
        b.Truncate(0)
    }
    for {
        if free := cap(b.buf) - len(b.buf); free < MinRead {
            // not enough space at end
            newBuf := b.buf
            if b.off+free < MinRead {
                // not enough space using beginning of buffer;
                // double buffer capacity
                newBuf = makeSlice(2*cap(b.buf) + MinRead)
            }
            copy(newBuf, b.buf[b.off:])
            b.buf = newBuf[:len(b.buf)-b.off]
            b.off = 0
        }
        m, e := r.Read(b.buf[len(b.buf):cap(b.buf)])
        b.buf = b.buf[0 : len(b.buf)+m]
        n += int64(m)
        if e == io.EOF {
            break
        }
        if e != nil {
            return n, e
        }
    }
    return n, nil // err is EOF, so return nil explicitly
}

这个函数主要作用就是从 io.Reader 里读取的数据放入 buffer 中,如果 buffer 空间不够,就按照每次 2x + MinRead 的算法递增,这里 MinRead 的大小也是 512 Bytes ,由于我们每次调用 ioutil.ReadAll 读取的数据远小于 512 Bytes ,照理说不会触发 buffer grow 的算法,但是仔细看下实现发现不是这么回事,buffer grow 的算法大概是是这样子的:

  1. 计算 buffer 剩余大小 free;
  2. 如果 free < MinRead, 分配一个大小为 2 * cap + MinRead 的 newBuf, 把老 buffer 的数据拷贝到 newBuf;
  3. 如果 free >= MinRead, 读取数据到 buffer, 遇到错误就返回,否则跳转到第 1 步.

因为我们用了 io.LimitReader , 第一趟循环只会读取固定字节的数据,不会触发任何错误。但是第二次循环的时候,由于 buffer 的剩余空间不足,就会触发一次 buffer grow 的算法,再分配一个大小为 1536 Bytes 的 Buffer , 然后再次 Read() 的时候会返回 io.EOF 的错误。这就是为什么会有那么多次 makeSlice 调用的原因,继续估算一下 makeSlice 的消耗:

1536 Bytes * 2 * 10000 = 30.72 MB

把 2 次消耗的内存加起来, 2w 次 ioutil.ReadAll() 的时间内存消耗为 10.24 MB +
30.72 MB = 40.96 MB
。( 倒吸一口凉气

实际线上发生的情况,并没有这么多的请求,但是每次请求却比较大。就是说每个请求会包含上万个参数,等价的调用了上万次的 ioutil.ReadAll , 消耗了大量的内存。但是仅仅这样,只是程序执行时消耗了比较多的内存,并未有内存泄露的情况,那服务器又是如何内存不足的呢?这就不得不扯到 Golang 的 GC 机制。

Golang 的 GC

Golang 的 GC 采用经典的 Mark-and-Sweep, Mark-and-Sweep 分为 2 个阶段:

  • 第一阶段:从几个跟变量开始,扫描所有的 objects , 然后标记 isMarked = true
  • 第二阶段:扫描 heap ,对所有为标记的变量进行回收

这个 GC 的过程会终止程序的运行直到回收完毕,不过从 Golang 1.3 开始,在第一阶段结束后,程序就会恢复运行,然后单独起一个 goroutine 来进行 Sweep 的操作。忽略一些细节,Golang 的 GC 大致遵循以下几个原则:

  • 已分配的 Heap 到达某个阈值,会触发 GC, 该阈值由上一次 GC 时的 HeapAlloc 和 GCPercent 共同决定
  • 每 2 分钟会触发一次强制的 GC,将未 mark 的对象释放,但并不还给 OS
  • 每 5 分钟会扫描一个 Heap, 对于一直没有被访问的 Heap,归还给 OS

然后回头看下上面的场景,一个大请求会多次调用 ioutil.ReadAll 分配很多内存空间,并发的时候,会在很短时间内占用大量的系统内存,然后将 GC 阈值增加到一个很高的值,这个时候要 GC 就只有等 2 分钟一次的强制 GC。2 分钟内,无法阻止内存继续上涨,Golang 的 Heap 会吃光 OS 的内存,并且一直霸占着不归还给 OS, 造成 OS 整体性能下滑,程序的正常运行会变得很慢(包括负责 GC 的 goroutine),于是整台服务器就像多米诺骨牌一样倒下了。

怎么解决?

问题的根本原因还是,系统在合理的负载内,浪费了大量的内存空间,加上 GC 的延时,最终导致系统的崩溃。所以还是要解决内存浪费的问题,最终还是要避免不正确的使用 ioutil.ReadAll,稍微修改下上面的代码,我们用比较底层的 bytes.Buffer.Read 来读取固定的字节:

func readArgument(r *bufio.Reader) ([]byte, error) {
    line, err := r.ReadString('\n')
    var argSize int
    _, err = fmt.Sscanf(line, "$%d\r", &argSize)
    if err != nil {
        return nil, err
    }
    // look at here !!!!!!!!!!
    data := make([]byte, argSize)
    _, err = r.Read(data)
    if err != nil {
        return nil, err
    }
    // check data size
    if len(data) != argSize {
        return nil, fmt.Errorf("error length of data.")
    }
    // check \r
    if b, err := r.ReadByte(); err != nil || b != '\r' {
        fmt.Printf("%s\n", string(b))
        return nil, fmt.Errorf("line should end with \\r\\n")
    }
    // check \n
    if b, err := r.ReadByte(); err != nil || b != '\n' {
        return nil, fmt.Errorf("line should end with \\r\\n")
    }
    return data, nil

}

编译后再来看看 Heap 的分配情况,仍然使用 pporf 工具:

$ go tool pprof --alloc_space readBytes bytes.mprof
Adjusting heap profiles for 1-in-524288 sampling rate
Welcome to pprof!  For help, type 'help'.
(pprof) top
Total: 6.5 MB
     1.5  23.1%  23.1%      1.5  23.1% fmt.newScanState
     1.0  15.4%  38.5%      1.0  15.4% reflect.unsafe_New
     0.5   7.7%  46.2%      0.5   7.7% allocg
     0.5   7.7%  53.8%      0.5   7.7% bufio.(*Reader).ReadString
     0.5   7.7%  61.5%      0.5   7.7% bufio.NewReaderSize
     0.5   7.7%  69.2%      5.5  84.6% main.main
     0.5   7.7%  76.9%      3.0  46.2% main.parse
     0.5   7.7%  84.6%      1.5  23.1% main.readArgument
     0.5   7.7%  92.3%      0.5   7.7% runtime.convT2E
     0.5   7.7% 100.0%      0.5   7.7% runtime.mallocinit

如何?Golang 分配的内存显著减少了,问题至此就解决了,但是我还是想吐槽这种神坑.....

docker/go-redis-server 提了 Issue,至今没人回复, 呵呵,该睡觉了。