golang api中避免内存泄露

golang api中避免内存泄露

你有必要在把golang api投入生产环境前阅读下这篇文章,基于我们的真实经历,因为没有使用正确的方法,我们在每个release都很遇到困难和挣扎。

就在几周之前,我们刚刚修复了一个奇怪的未发现的bug,我们试了很多方法去debug和修复它。这个bug并不是业务逻辑上的问题,因为它已经在生产环境跑了几周了,那是因为我们的自动释放机制,隐藏了这个问题,所以它看上去是正常。

直到最近,我们发现这个问题是因为代码并没有处理好。

架构

首先说下,我们在架构中使用了微服务模式。我们有一个gateway API – 我们叫“main API”,它服务于我们的用户(手机客户端和web页面)。由于它的角色很像API Gateway,所以它的任务就只是处理用户的请求,然后再请求道对应的服务上,然后将reponse返回给用户。这个“main API”完全是使用Golang开发的。为什么选择golang在这里就不详细介绍了。

我们系统的架构图大概如下:

问题

我们被main API困扰了好久,他经常down掉,并且返回给我们手机客户端一个很长的响应,并有时导致我们的API不能被访问。我们的API大盘监控就一下子报警。

这个bug让我们非常上火,因为没有任何log能看出这个bug到底是什么原因引起的。我们就是看到响应时间很长。CPU和内存使用率在增长。就像一场噩梦。

阶段一:使用标准http.Client

在开发这个服务中,我们真的学到一件事:千万别信任默认配置。

我们使用一个定制的http.Client,用以替代http包中的默认的。

client:=http.Client{} //default

我们添加了一些基于我们需要的配置。因为我们需要复用connection,我们配置了一些参数在transport,并控制最大的空闲可用connection。

keepAliveTimeout:= 600 * time.Second
timeout:= 2 * time.Second
defaultTransport := &http.Transport{
Dial:(&net.Dialer{
KeepAlive:keepAliveTimeout,
}).Dial,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
}

client := &http.Client{
Transport: defaultTransport,
Timeout: timeout,
}

这个配置可以帮助我们控制访问其他服务的最大时间。

阶段二:避免Unclosed Response Body带来的内存泄露

我们从这个阶段学到:如果我们想要复用连接池中的connection,我们必须读完响应body,然后close掉它。

因为我们的main API只是去访问其他服务,我们犯了一个致命的错误。我们main API支持复用http.Client中的可用connection,所以无论发生什么,我们都必须读取response body,即便是我们并不需要。而且我们也必须close response body,这些都是为了防止我们的服务器内存泄漏。

我们在代码中忘记了close response body。这个事情在我们生产环境引起了巨大的灾难。

解决办法也很简单:我们关掉响应body,并且读取body即便是并不需要。

req, err:= http.NewRequest(“GET”,”http://example.com?q=one”,nil)
if err != nil {
return err
}
resp, err:= client.Do(req)
//=================================================
// CLOSE THE RESPONSE BODY
//=================================================
if resp != nil {
defer resp.Body.Close() // MUST CLOSED THIS
}
if err != nil {
return err
}
//=================================================
// READ THE BODY EVEN THE DATA IS NOT IMPORTANT
// THIS MUST TO DO, TO AVOID MEMORY LEAK WHEN REUSING HTTP
// CONNECTION
//=================================================
_, err = io.Copy(ioutil.Discard, resp.Body) // WE READ THE BODY
if err != nil {
return err
}

我们是看了下面这篇伟大的文章:

http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/
http://tleyden.github.io/blog/2016/11/21/tuning-the-go-http-client-library-for-load-testing/

阶段一和阶段二以及自动释放机制的帮忙这个bug被缓解,但老实说,bug基本不再出现,维持了有3个月(去年2017年)

阶段三:golang channel中的timeout control

在运行完好几个月后,这个bug又再次出现,在2018年一月的最开始几周,我们的main API down掉了,由于某种原因,无法访问。

当我们的“content service”down掉了,我们的main API也挂了,API dashboard再次报警,API响应时间变长,CPU和内存使用率飙升,即便是我们有自动释放机制。

再次,我们尝试找到问题的根源,然后当我们重新跑起“content service”,一切有运行完好。

这个场景让我们很好奇,为什么它为什么会发生,因为我们认为,在http.Client中设置了超时时间,在这个场景应该不会有问题的。

走读我们的代码,发现一个很危险的代码段。

为了简化,代码像下面这样:
*ps:这个function只是一个例子,但是和我们问题模式基本类似

type sampleChannel struct{
  Data *Sample
  Err error
}

func (u *usecase) GetSample(id int64, someparam string, anotherParam string) ([]*Sample, error) {

	chanSample := make(chan sampleChannel, 3)
	wg := sync.WaitGroup{}

	wg.Add(1)
	go func() {
		defer wg.Done()
       		chanSample <- u.getDataFromGoogle(id, anotherParam) // just example of function
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
    		chanSample <- u.getDataFromFacebook(id, anotherParam)
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
    		chanSample <- u.getDataFromTwitter(id,anotherParam)
	}()

	wg.Wait()
	close(chanSample)

	result := make([]*Sample, 0)

	for sampleItem := range chanSample {
		if sampleItem.Error != nil {
			logrus.Error(sampleItem.Err)
		}
		if sampleItem.Data == nil { 
			continue
		}
         result = append(result, sampleItem.Data)
	}


	return result
}

我们看上面的代码,好像也没什么错误。但是这个方法被大量的访问,并且是main API中负载最高的调用。因为这个方法会调用3个API,每个API都有大量的处理。

我们用了一个新的方式,使用timeout-control用在channel上来解决,因为上面的代码中使用了waitgroup,他会等待所有的process都完成才会返回信息给我们的用户。

这是我们其中一个大的错误,这段代码会因为巨大的灾难,只是因为其中一个服务挂掉。因为这样会一直等到挂掉的服务恢复才行。5k qps的话,这就是个灾难。

第一次尝试的解决方案:

我们修改了下代码,增加了超时时间。以便我们的用户不用等待那么长时间,他们就可以拿到一个internal server的错误。


func (u *usecase) GetSample(id int64, someparam string, anotherParam string) ([]*Sample, error) {
	chanSample := make(chan sampleChannel, 3)
	defer close(chanSample)
 	go func() {
       		chanSample <- u.getDataFromGoogle(id, anotherParam) // just example of function
	}()

 
	go func() {
    		chanSample <- u.getDataFromFacebook(id, anotherParam)
	}()

   
	go func() {
   		chanSample <- u.getDataFromTwitter(id,anotherParam)
	}()
 

	result := make([]*feed.Feed, 0)
	timeout := time.After(time.Second * 2)
		for loop := 0; loop < 3; loop++ {
			select {
			case sampleItem := <-chanSample:
				if sampleItem.Err != nil {
					logrus.Error(sampleItem.Err)
					continue
				}
				if feedItem.Data == nil {
					continue
				}
				 result = append(result,sampleItem.Data)
			case <-timeout:
			      err := fmt.Errorf("Timeout to get sample id: %d. ", id)
			      result = make([]*sample, 0)
			      return result, err
			}
		}

	return result, nil;
}

阶段四:使用Context进行超时控制

在做完第三阶段,我们的问题还是没有完全的解决。我们的main API还是会消耗很高的内存和CPU。

这是因为,即使我们已经返回了“Internal Server Error”给我们的用户,但是我们的goroutine还是依然存在。我们想要的结果是:如果我们已经返回响应,那所有相应的资源应该被清理,当然就包括在后台跑着的请求API的goroutine。

后来我们看了这篇文章:
http://dahernan.github.io/2015/02/04/context-and-cancellation-of-goroutines/

我们发现了golang中一些有意思但我们没有注意到的功能,就是用Context来帮助我们在goroutine取消操作。

使用context.Context来代替time.After使用超时,有了这个方式,我们的服务变得更稳定了。

然后我们修改了代码,在function中增加context。


func (u *usecase) GetSample(c context.Context, id int64, someparam string, anotherParam string) ([]*Sample, error) {
	
  if c== nil {
    c= context.Background()
  }
  
  ctx, cancel := context.WithTimeout(c, time.Second * 2)
  defer cancel()
  
  chanSample := make(chan sampleChannel, 3)
  defer close(chanSample)
 	go func() {
       		chanSample <- u.getDataFromGoogle(ctx, id, anotherParam) // just example of function
	}()

 
	go func() {
    		chanSample <- u.getDataFromFacebook(ctx, id, anotherParam)
	}()

   
	go func() {
   		chanSample <- u.getDataFromTwitter(ctx, id,anotherParam)
	}()
 

	result := make([]*feed.Feed, 0)
	for loop := 0; loop < 3; loop++ {
	  select {
		case sampleItem := <-chanSample:
			if sampleItem.Err != nil {
				continue
			}
			if feedItem.Data == nil {
				continue
			}
			 result = append(result,sampleItem.Data)
		  // ============================================================
		  // CATCH IF THE CONTEXT ALREADY EXCEEDED THE TIMEOUT
		  // FOR AVOID INCONSISTENT DATA, WE JUST SENT EMPTY ARRAY TO 
		  // USER AND ERROR MESSAGE
		  // ============================================================
      		case <-ctx.Done(): // To get the notify signal that the context already exceeded the timeout
		      err := fmt.Errorf("Timeout to get sample id: %d. ", id)
		      result = make([]*sample, 0)
		      return result, err
		}
	}

	return result, nil;
}

然后我们在每个goroutine的代码调用中使用了Context,帮助我们释放内存并停掉goroutine的调用。

并且为了更加可控和稳定,我们同样把context传给我们的http request。

func ( u *usecase) getDataFromFacebook(ctx context.Context, id int64, param string) sampleChanel{
 
  req,err := http.NewRequest("GET","https://facebook.com",nil)
  if err != nil {
    return sampleChannel{
      Err: err,
    }
  }
  // ============================================================
  // THEN WE PASS THE CONTEXT TO OUR REQUEST.
  // THIS FEATURE CAN BE USED FROM GO 1.7
  // ============================================================
  if ctx != nil {
    req = req.WithContext(ctx) // NOTICE THIS. WE ARE USING CONTEXT TO OUR HTTP CALL REQUEST
  }
  resp, err:= u.httpClient.Do(req)
  if err != nil {
    return sampleChannel{
      Err: err,
    }
  }
  body,err:= ioutils.ReadAll(resp.Body)
  if err!= nil {
    return sampleChannel{
      Err:err,
    }
    sample:= new(Sample)
    err:= json.Unmarshall(body,&amp;sample)
    if err != nil {
      return sampleChannle{
        Err:err,
      }
    }
    return sampleChannel{
      Err:nil,
      Data:sample,
    }
  }

通过所有这些配置和超时控制,我们的系统更加安全和健壮。

复盘:

  • 永远不要在生产环境使用默认选。真的不要使用默认选项,如果你是在构建一个高并发的项目,记住永远别用默认的选项。
  • 多读,多试,失败也没关系,你会收获很多。我们在这次的经验中学到了很多,这次经验在真实场景和真实用户中得到,并且我非常开心能参与解决这个bug。

如果你认为这篇文章很有收获,请给我点掌声吧 🙂

文章转载:https://hackernoon.com/avoiding-memory-leak-in-golang-api-1843ef45fca8

One thought on “golang api中避免内存泄露

  1. 感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/f3ft60 欢迎点赞支持!使用开发者头条 App 搜索 67464 即可订阅《cultus》

发表评论

电子邮件地址不会被公开。 必填项已用*标注