返回

如何在 Golang API 中避免内存泄漏?

技术

发布时间:2019/08/29 10:47

标签:

摘要:建议你在将Golang API投入生成之前阅读此文,此文是基于真实的线上问题修复经历,如有巧合,纯属踩雷!

建议你在将Golang API投入生成之前阅读此文,此文是基于真实的线上问题修复经历,如有巧合,纯属踩雷!


   几个星期前,在修复我们的主服务器的漏洞时,我们尝试了很多方法来调试和修复它,因为它已经投入生产几个星期了。 但是我们总是需要通过我们的自动缩放机制来缓解,使其看起来似乎一切正常。直到后来我们才明白,这是coding中出现了问题。

01   架构


   我们在整个系统架构中使用了微服务模式。 有一个网关 API (我们称之为主 API )为我们的用户(移动和网络)提供 API。 它的角色类似于 API 网关,所以它的任务只处理来自用户的请求,然后调用所需的服务,并向用户构建响应。 此AP服务完全由 Golang 来编写。



基础架构


01   Problem:

    我们已经为我们的主API挣扎了很长一段时间,这些 API 总是被关闭且总是长时间处于无法响应的状态,有时导致我们的 API 无法访问,服务也处于无法使用状态。

    API监控仪表盘显示红色警报,老实说,当我们的 API监控仪表盘变成红色时,是一件非常非常危险的事情,会给我们的工程师带来压力、恐慌和崩溃。

    我们的 CPU 和内存使用率也正在变得越来越高。 如果发生这种情况,我们只需无助的手动重新启动服务,然后等待它再次重新运行。

对于单个请求,我们的 API 响应时间可达86秒




    这个 bug 真的让我们很沮丧,因为我们没有任何关于这个 bug 的日志。 我们只知道响应时间很长。 Cpu 和内存使用量不断增加。 这就像一场噩梦。


阶段1:   使用定制的 http.Client

    在开发这个服务时,我们真正学到的一件事是:“不要相信默认配置,切记”。我们使用一个内置的 http客户端,而不是使用默认的一个从 http 的包

    client:=http.Client{} //default

    我们根据需要添加一些配置。 因为我们需要重新连接,所以我们在参数中进行了一些配置,并控制了最大空闲可重用连接。

func main() {
   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,
   }
}复制代码



    这种配置可以帮助我们减少调用另一个服务的最长时间。



阶段2:   避免未关闭响应主体的内存泄漏

    我们从这个阶段学到的是: 如果我们想重用连接池到另一个服务,我们必须读取响应体并关闭它。

    因为我们的主 API 只是调用另一个服务,我们犯了一个致命的错误。 我们的主 API 应该重用来自 http 的可用连接,所以无论发生什么,我们必须读取响应体,即使我们不需要它。 我们也必须关闭响应体。 这两种方法都用于避免服务器中的内存泄漏。

    假如我们忘记在代码中关闭响应主体。 这些东西会给我们的生产带来巨大的灾难

    解决方案是: 我们关闭响应主体并读取它,即使我们不需要数据。


func Func()error {
   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
   }
}复制代码



     第一阶段和第二阶段,在自动缩放成功的帮助下,减少这个 bug。 好吧,说实话,从去年2017年开始,这种事情连三个月都没有发生过。



阶段3:   golang的超时控制

    经过几个月稳定运行,这个错误没有再次发生。但 在2018年1月的第一个星期,我们的一个服务被我们的主要 API 调用, 宕机了。 由于某些原因,它不能被访问。

    因此,当我们的内容服务关闭时,我们的主 API 将再次启动。 Api 仪表盘再次变红,API 响应时间变得越来越慢。 我们的 CPU 和内存使用率非常高,即使使用自动缩放。

    同样,我们试图再次找到根本问题。 嗯,在重新运行内容服务之后,我们再次运行良好。

    对于这种情况,我们很好奇,为什么会发生这种情况。 因为我们认为,我们已经在 http 中设置了超时截止时间。 所以正常来说这种情况,不可能再次发生。

   在我们在代码中check潜在的问题时,我们发现了一些非常危险的代码。


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

}复制代码


如果我们看看上面的代码,它看起来没有什么问题。 但是这个函数是访问量最大的函数,在我们的主 API 中调用最多。 因为这个函数将执行3个带有巨大处理的 API 调用。


超时控制

     为了改进这一点,我们在channel采用了超时控制的方法。 因为使用上述样式代码(使用 WaitGroup 将等待所有进程完成) ,我们必须等待所有 API 调用完成,这样我们才能处理并将响应返回给用户。

    这是我们最大的错误之一。 当我们的一个服务器死亡时,这段代码可能会造成巨大的灾难。 因为要等很长时间才能恢复dead服务。 当然,有了5K qps/s,这就是一场灾难。


第一次尝试的解决方案:



     我们通过添加超时来修改它。 所以我们的用户不会等这么长时间,他们只会得到一个内部服务器错误。

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;
}复制代码


阶段4: 使用上下文的超时控制

     在第三阶段之后,我们的问题仍然没有完全解决。 我们的主 API 仍然消耗高 CPU 和内存。

     这是因为,即使我们已经将 Internal Server Error 返回给 我们的用户,但是我们的 goroutine 仍然存在。 我们想要的是,如果我们已经返回响应,那么所有的资源也会被清除,没有例外。

     我们发现了一些有趣的功能,我们还没有意识到在golang中可以使用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 调用使用上下文。 这可以帮助我们释放内存并取消 goroutine 调用。 此外,为了获得更好的控制性和可靠性,我们还将上下文传递给 HTTP 请求。

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, &sample)
      if err != nil {
         return sampleChannle{
            Err: err,
         }
      }
      return sampleChannel{
         Err:  nil,
         Data: sample,
      }
   }
}复制代码



有了这些设置和超时控制,我们的系统更加安全和可控。

经验教训:


1,不要在在生产中使用默认选项.

2,不要在在生产中使用默认选项. 如果您正在构建一个大的并发 api,千万不要使用默认选项

3,大量阅读,大量尝试,大量失败,大量收获

4,我们从这个经验中学到了很多,这种经验只有在真实的案例和真实的用户中才能获得。 我很高兴能参与修复这个漏洞