Go语言内存泄漏问题排查总结

背景

我们使用Go语言开发了一个后台服务,在发布测试环境后,发现内存使用量会随着时间的推移持续增加。因此服务的Pod会隔一段时间重启一次,因此,需要排查一下该问题。此文是对排查过程的记录以及排查后的思考总结。

环境准备

本文假设开发机环境中已经安装了go、pprof、graphviz,并且后台服务中已经集成了pprof。

业务中内存泄漏的现象以及排查思路

内存泄漏的现象

在这里插入图片描述
我们将服务发布到测试环境中之后,可以从内存监控的看板中看到,内存使用量随着时间的推移会一直增加,而且会一直达到内存设置的限制并且重启Pod。这种情况的出现,就是内存出现了泄漏的问题。

排查思路

使用Go语言开发的后台服务,在遇到这种情况时,我们首先应该想到的是可能Goroutine出现了泄漏,也就是说,可能开启了大量Goroutine,但是没有进行回收导致。因为Go语言程序的基本运行单位就是Goroutine,因此大多数内存泄漏都是Goroutine的泄漏。我们按照重点来排查,可以节约时间和精力。

我先在开发机上运行起来服务,然后请求pprof来查看Goroutine的运行情况:

请求http://10.111.55.111:8081/debug/pprof/,(10.111.55.111为我的开发机IP)可以看到:
在这里插入图片描述
然后我们选择查看其中的Goroutine:
在这里插入图片描述
再过一段时间后,我们再次刷新一下,再次查看Goroutine的数量:
在这里插入图片描述
可以发现蓝框标记的Goroutine数量一直随着时间的推移而增加,这就是内存泄漏的Goroutine。如果发现变化比较缓慢,我们也可以进行压力测试后再观察。

按照展示的调用信息,我们定位到Redis线程池实现中的这行代码。可以看到,是这行代码发生阻塞,这行代码是做什么的呢?其实就是Redis线程池的定时回收空闲线程功能,只是我们有大量的空闲线程还没有到时间被回收。于是阻塞在了这里。
在这里插入图片描述
接着往上找调用函数,就可以发现是新建Redis连接池时调用的该函数。
在这里插入图片描述
在这里插入图片描述
最终可以定位到导致内存泄漏的原因:其实就是我们在很多地方新建了Redis连接池,但是设置的关闭空闲连接的时间又不合理,导致在大量请求过来时,就会不断的累计连接数量,于是也就有大量的连接未关闭并一直阻塞在定时器那里。
在这里插入图片描述
后续我们将Redis线程池做成了一个公共引用,只在初始化服务时初始化一些我们需要的连接量,于是该内存泄漏问题得到了解决。
在这里插入图片描述
可以看到,内存使用量不再随着时间的推移而不断增加。而且Goroutine的数量也不再异常增加了。
在这里插入图片描述

内存泄漏的拓展思考

Goroutine泄漏为什么会导致内存泄漏

排查了一个Goroutine泄漏导致的内存泄漏例子后,我们再思考一下,为什么Goroutine的泄漏会导致内存泄漏呢?
首先,我们需要清楚什么是Goroutine泄漏。

Goroutine泄漏是指,我们创建的Goroutine没有在我们预期的时刻关闭,导致Goroutine的数量在服务端一直累积增加,最终影响到服务的性能。

然后,为什么Goroutine的泄漏会导致内存泄漏呢?

有两点原因:

  1. Goroutine本身的堆栈大小是2KB,我们开启一个新的Goroutine,至少会占用2KB的内存大小。当长时间的累积,数量较大时,比如开启了100万个Goroutine,那么至少就会占用2GB的内存。
  2. Goroutine中的变量若指向了堆内存区,那么,当该Goroutine未被销毁,系统会认为该部分内存还不能被垃圾回收,那么就可能会占用大量的堆区内存空间。

Goroutine会发生泄漏的场景总结

1 从channel中读或写,但没有对应的写或读

我们都知道,channel分为两种类型,unbuffered channel和buffered channel,我们先讨论unbuffered channel。

在channel被创建后未被关闭前,我们若从channel中读取数据,但又一直没有数据写入channel中,那么channel就会进入等待状态,对应的Goroutine也就会一直阻塞着了。对应的,当我们往channel中写数据,但又一直没有从channel中读。那么也会出现被阻塞的情况。

以从channel中读,但没有写为例:

func ChannelLeak(w http.ResponseWriter, r *http.Request) {
    ch := make(chan int)

    go func() {
        value := <-ch
        fmt.Println("value is: ", value)
    }()
}

以上程序就会导致Goroutine的泄漏。
至于buffered channel,其实和unbuffered channel情况是类似的,只是buffered channel是读完缓存后,或写完缓存后会导致阻塞,这里就不再赘述了。

2 在使用select时,所有的case都阻塞

可以看一下这个例子:

func add(c, quit chan int) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x = x + y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}

func Add() {
    c := make(chan int)
    quit := make(chan int)

    go add(c, quit)

    for i := 0; i < 10; i++ {
        fmt.Println(<-c)
    }

    // close(quit)
}

我们可以看到,当Add函数for循环了10次之后,add函数就会一直阻塞了,也就出现了Goroutine泄漏。
正确的做法应该是在合适的时间将quit关闭,那么add协程就可以安全退出了。

3 Goroutine进入死循环

由于代码逻辑上的bug,Goroutine进入了死循环,则会导致资源一直无法释放。

如下例:

func loop() {
    for {
        fmt.Println("loop")
    }
}

go loop()

Goroutine泄漏的预防

以上总结了四个比较常见的Goroutine泄漏的场景,我在这次业务中碰到的内存泄漏问题就是由于开启了大量Goroutine,但定时器一直在等待channel数据的到来,导致长时间阻塞导致。就是以上介绍的常见场景的第一种情况。

至于Goroutine泄漏,应该是预防重于解决,预防Goroutine泄漏的方法如下:

  1. 在Goroutine中使用到了channel时,要考虑清楚channel何时可能会阻塞,以及阻塞时的具体情况
  2. 创建了一个Goroutine时,就要考虑清楚Goroutine应该如何结束
  3. 注意代码程序的逻辑,切忌在代码中出现死循环

总结

pprof不仅是一个可以用于做性能优化的工具,也是一个可以用来排查问题的好工具。善用这类工具对于Go开发者来说是非常重要的。另外,在写Go语言代码时,要重视Goroutine泄漏的问题,这种问题不出现则已,如果出现,就很可能会导致线上问题,后果是非常严重的。

Golang的值接收者与指针接收者

背景

Go语言中有着面向对象的思想,当我们创建了一个类型之后,可以给这个类型添加不同的方法,给类型添加方法的方式类似于创建一个函数,只是在func和函数名中添加一个(类型名 类型)。这个东西就是所谓的接收者,也就是类型作为接收者接收该函数为自己的方法。有时候我们希望可以更改类型中成员变量的值,而有时候我们不希望类型中成员变量的值被改变。这就是Golang中使用值接收者与指针接收者的区别。

一个例子

语言叙述比较繁琐,直接看这个简单的例子:

package main

import "fmt"

type user struct {
  name string
  email string
}

func (u user) changeEmail0(NewEmail string) {
  u.email = NewEmail
  fmt.Println("changeEmail0: userName is ",u.name," userEmail is ",u.email)
}

func (u *user) changeEmail1(NewEmail string) {
  u.email = NewEmail
  fmt.Println("changeEmail1: userName is ",u.name," userEmail is ",u.email)
}

func main() {
  usr0 := &user{"pf", "xxxxxxxxx@qq.com"}
  usr0.changeEmail0("xxxxxxxxx@163.com")
  fmt.Println("main: userName is ",usr0.name," userEmail is ",usr0.email)

  usr1 := &user{"pf", "xxxxxxxxx@qq.com"}
  usr1.changeEmail1("xxxxxxxxx@163.com")
  fmt.Println("main: userName is ",usr1.name," userEmail is ",usr1.email)
}

这个例子中第10行的func (u user) changeEmail0(NewEmail string)就是将user类型作为值接收者,在使用值接收者时,方法中使用的类型的值只是一个副本,也就是说,无论怎么更改类型中成员变量的值,都对原值不会有任何影响,仅仅是在方法内部有所改变而已。
15行的func (u *user) changeEmail1(NewEmail string)是将user类型作为指针接收者,方法中使用的是该类型的原值,也就是说,在方法中改变该值,那么原值也会有所改变。
该代码的结果

changeEmail0: userName is pf userEmail is xxxxxxxxx@163.com
main: userName is pf userEmail is xxxxxxxxx@qq.com
changeEmail1: userName is pf userEmail is xxxxxxxxx@163.com
main: userName is pf userEmail is xxxxxxxxx@163.com

可以看出值接收者与指针接收者的区别。

总结

使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。简单来说,如果给这个类型增加或者删除某个值,是需要创建一个新值,则选择值接收者。如果要修改当前的值,则选择指针接收者。

Golang的Mysql操作

背景

前面我已经写过一篇关于操作Redis的博客了。这一次写Golang如何操作Mysql。

基础:

  1. 安装并会用Golang
  2. 安装并配置了kubernetes
  3. kubernetes中已经运行了mysql服务

例子

在讲Mysql的操作之前先上一个小例子

  1 package main
  2 
  3 import (
  4         "database/sql"
  5         _ "github.com/go-sql-driver/mysql"
  6         "log"
  7 )
  8 
  9 func main() {
 10         db, err := sql.Open("mysql", "root:123456@tcp(192.168.3.52:3306)/mysql?parseTime=true")
 11         if err != nil {
 12                 log.Fatal(err)
 13         }
 14 
 15         _, err1 := db.Exec("CREATE TABLE IF NOT EXISTS mysql.hello(world varchar(50))")
 16         if err1 != nil {
 17                 log.Fatal(err1)
 18         }
 19 
 20         rs, _ := db.Exec("INSERT INTO mysql.hello(world) VALUES ('hello world')")
 21 
 22         rowCount, err3 := rs.RowsAffected()
 23         if err3 != nil {
 24                 log.Fatal(err3)
 25         }
 26 
 27         rs1, _ := db.Query("SELECT world FROM mysql.hello")
 28         for rs1.Next(){
 29           var s string
 30           rs1.Scan(&s)
 31           log.Printf(s)
 32         }
 33 
 34 
 35         log.Printf("insert %d rows", rowCount)
 36 
 37         defer db.Close()
 38 
 39 }

连接

我们这里要使用到Mysql的标准库database/sql与驱动go-sql-driver/mysql。
连接使用标准库中的sql.Open返回数据库对象db,我们接下来就可以用db中的如Exec、Query等方法进行数据库的操作了。
现在来看看sql.Open的参数

sql.Open(Dbuser+":"+DbPasswd+"@tcp("+DbHost+")/"+DbName+"?parseTime=true")

参数就是这样,没什么好解释的,具体例子可以看上面的代码。

操作

操作也比较简单,
db.Exec(SQL操作)可以用来建表,插入数据
db.Query(查询操作)用来查询数据
上述例子中查询操作返回给rs1,然后将rs1中的值遍历出来进行显示,代码如下

for rs1.Next(){
           var s string
           rs1.Scan(&s)
           log.Printf(s)
}

Golang的Redis操作

背景

本篇博客主要讲解在kubernetes集群中,如何使用go语言对Redis与Mysql进行操作。
在此之前要有此基础:

  1. 安装并会使用go语言
  2. 配置好了kubernetes
  3. 在kubernetes中配置好了redis服务

连接

使用go连接Redis需要github.com/garyburd/redigo/redis这个包。这里的redigo是一个go语言的redis客户端实现。redigo没有其它别的依赖项,我们可以直接通过go get来安装它。

go get github.com/garyburd/redigo/redis

但是在安装时我们有可能会碰到如下bug:
这里写图片描述
这是因为我们没有配置GOPATH环境变量的原因。需要按如下命令配置:
这里写图片描述
当然,在命令行中设置环境变量并不是永久生效的。要永久生效,你可以在配置文件中去配置。
安装完成我们就可以进行连接了。
Redis中提供的有现成的方法
c,err := redis.Dial("tcp","192.168.3.3:6379")
Dial方法第一个参数是网络连接的方式,第二个参数是redis服务的入口地址。我们可以使用Node IP+端口的方式,也可以用service IP+端口的方式。我这里演示的是用service IP+端口的方法访问。这适用于在kubernetes集群内部进行访问,若是从外部访问就需要用node IP+端口的方式了。
这里写图片描述
这里我们可以看到redis-master这个service对象的IP是192.168.3.3,端口号为6379。

命令操作

Conn接口是与Redis协作的主要接口,我们刚才已经用Dial方法建立了连接了。现在我们就可以使用Conn借口来对Redis进行操作。
这里举一个简单的例子:

  1 package main
  2 
  3 import (
  4         "fmt"
  5         "github.com/garyburd/redigo/redis"
  6 )
  7 
  8 func main() {
  9         c, _ := redis.Dial("tcp", "192.168.3.3:6379")
 10         _ ,err := c.Do("SET","username","pingfan")
 11         if err != nil{
 12         fmt.Printf("ERROR",err)
 13         }
 14         username, _ := redis.String(c.Do("GET", "username"))
 15         fmt.Println("Got username ",username)
 16  }

9行之前已经讲过了
我们操作Redis,就是用Conn接口的Do方法,Do是一个通用方法,它能做许多事。
比如10与14行,分别表示写与读。
我这里用service IP访问redis-master服务。代码运行结果如下:
这里写图片描述
其它还有许多操作:
批量写入读取

MGET key [key ...]
MSET key value [key value ...]

批量写入读取对象(Hashtable)

HMSET key field value [field value ...]
HMGET key field [field ...]

检测值是否存在

EXISTS key

删除

DEL key [key ...]

设置过期时间

EXPIRE key seconds