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,&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中避免内存泄露”
感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/f3ft60 欢迎点赞支持!使用开发者头条 App 搜索 67464 即可订阅《cultus》