在将Golang API投入生产之前,您必须阅读此内容。 根据我们在Kurio的真实故事,我们会为每个版本而苦苦挣扎,因为我们没有以正确的方式来做。
几周前,我们在Kurio只是在主要服务中修复了我们怪异而未发现的错误。 我们尝试了许多调试和修复它的方法。 该错误与业务逻辑无关。 因为它已经投产了几个星期。 但是我们总是通过自动缩放机制来节省费用,因此就像运行良好一样。
直到后来,我们才弄清楚了,这是因为我们的代码做得不好。
架构
Fyi,我们在架构中使用了微服务模式。 我们有一个网关 API (我们称之为主API),可为用户(移动和网络)提供API。 由于其作用类似于API网关,因此它的任务仅处理来自用户的请求,然后调用所需的服务,并建立对用户的响应。 这个主要的API,完全用Golang编写。 选择golang的原因是另一个我不会在这里讲的故事。
如果绘制在图片中,我们的系统将更像这样。
问题
我们与主要API的斗争已经很长时间了,主要API一直崩溃,并且对我们的移动应用程序的响应很长,有时甚至导致无法访问我们的API。 我们的API仪表板监视器只是变成红色了-老实说,当我们的API仪表板监视器变成红色时,这是一件危险的事,并给我们带来压力,恐慌和疯狂,使工程师。
其他是,我们的CPU和内存使用率越来越高。 如果发生这种情况,我们只需手动重新启动它,然后等待它再次运行即可。
这个错误确实使我们感到沮丧,因为我们没有任何日志专门说明此错误。 我们只是有这么长的响应时间。 CPU和内存使用率增加。 就像一场噩梦。
阶段1:使用自定义的http.Client
开发此服务时,我们了解到并且真正了解到的一件事是,不要相信默认配置。
我们使用自定义的http.Client,而不使用http包中的默认值,
client:=http.Client{} //default
我们根据需要添加一些配置。 因为我们需要重用连接,所以我们在传输和控制max-idle可重用连接中进行一些配置。
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.Client的可用连接,因此无论发生什么情况,我们都必须阅读响应正文,即使我们不需要它。 而且,我们必须关闭响应主体。 两者都用于避免服务器中的内存泄漏。
我们忘记在代码中关闭响应主体。 这些事情可能给我们的生产造成巨大的灾难。
解决方案是:我们关闭响应主体并读取它,即使我们不需要数据也是如此。
req, err:= http.NewRequest("GET","#34;,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
}
我们在这里阅读了一篇很棒的文章后,对此进行了修复: / 11/21 /调整http客户端库以进行负载测试/
第1阶段和第2阶段,并在自动扩展成功的帮助下减少了此错误。 好吧,老实说,自去年以来三个月都没有发生这种情况:2017年。
阶段3:Golang Channel 中的超时控制
在运行几个月后,此错误再次发生。 在2018年1月的第一周,主要API调用的一项服务让我们说:已关闭。 由于某些原因,无法访问它。
因此,当我们的内容服务关闭时,我们的主要API会再次触发。 API仪表板再次变红,API响应时间越来越长。 即使使用自动缩放,我们的CPU和内存使用率也会很高。
同样,我们试图再次找到根本问题。 好了,在重新运行内容服务之后,我们再次运行良好。
对于这种情况,我们很好奇,为什么会这样。 因为我们认为,我们已经在http.Client中设置了超时期限,所以在这种情况下,这永远不会发生。
在我们的代码中搜索潜在的问题,然后我们找到了一些危险的代码。
为了更简单,代码看起来更像这样* ps:此函数只是一个示例,但与我们的模式相似
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调用。
为了改善这一点,我们使用通道上的超时控制进行了新的处理。 因为使用上述样式代码(使用WaitGroup的代码将等待直到所有过程完成),我们必须等待所有API调用都必须完成,这样我们才能处理并将响应返回给用户。
这是我们的重大错误之一。 当我们的一项服务死亡时,此代码可能会造成巨大的灾难。 因为将要等待很长的时间才能恢复服务。 当然,使用5K通话,这是一场灾难。
首次尝试解决方案:
我们通过添加超时来对其进行修改。 因此,我们的用户不会等待那么长时间,他们只会收到内部服务器错误。
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:使用上下文进行超时控制
完成第3阶段后,我们的问题仍然没有完全解决。 我们的主要API仍然消耗大量CPU和内存。
发生这种情况是因为,即使我们已经将内部服务器错误返回给我们的用户,但goroutine仍然存在。 我们想要的是,如果我们已经返回了响应,那么所有资源也将被清除,没有异常,包括在后台运行的goroutine和API调用。
稍后阅读本文后:http://dahernan.github.io/2015/02/04/context-and-cancellation-of-goroutines/
我们在golang中发现了一些我们尚未意识到的有趣功能。 那是使用上下文来帮助取消例程。
而不是使用时间。在使用超时之后,我们转到context.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;
}
因此,我们将Context用于代码中的每个goroutine调用。 这有助于我们释放内存并取消goroutine调用。
此外,为了获得更多控制和可靠性,我们还将上下文传递给我们的HTTP请求。
func ( u *usecase) getDataFromFacebook(ctx context.Context, id int64, param string) sampleChanel{
req,err := http.NewRequest("GET","#34;,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,
}
}
通过所有这些设置和超时控制,我们的系统更加安全和可控。
学过的知识:
· 从未在生产中使用默认选项,也从未使用过默认选项。 如果您要构建较大的并发A,请不要使用默认选项。
· 阅读很多,尝试很多,失败很多,收获很多。我们从这种经验中学到了很多,这种经验只有在实际案例和实际用户中才能获得。 在修复此错误时,我很高兴能参与其中。
*最后更新时间:2018年1月18日:修复了一些错字
(本文翻译自Iman Tumorang的文章《Avoiding Memory Leak in Golang API》,参考: