前言
记得我刚接触Go这门语言的时候,用到它的网络请求库的时候还不太理解,为什么函数的返回值不直接是请求的响应内容,而是一个http Response,并且,Body字段也并不是实际的响应内容,而是一个io.ReadCloser接口。想要拿到响应内容,需要手动做一次读取操作。刚开始的时候,我觉得这样十分的不便,不像诸如PHP语言的 curl_exec 函数那样来的直接。
Go语言读取一个网络请求的结果简化版大致需要这样:
//1.执行一个请求
response, err := http.Get(...)
//2.使用io包读取body
io.ReadAll(response.Body)
//3.关闭响应
response.Body.Close()
当时我不太明白为什么Go语言要这样设计,直到后来在一次业务场景中,在对性能化时才初见端倪,并不禁感叹这样的设计真是精妙!
这样的设计并不只有Go语言一家。
实际业务场景
在我为StarDots实现图像处理的过程中,我走了与以往相同的常规路线:将原图像获取到本地内存,读取元数据及必要信息,加工处理,然后响应给下游服务。
这一条线路看起来是再正常不过的,然而在实际的生产环境下,我发现服务器内存占用居高不下,且与请求量成正相关。这样一来,显然是一个差劲的设计,并且费用成本高昂。
于是,我开始反思设计的不合理之处。就在我review代码的时候,看到了io.ReadAll(response.Body)这句代码。这让我重新回想起了初遇Go语言的疑惑。接着,我开始思考,read到底操作了一些什么,它的行为是怎样的。
从实战回到理论
从现有代码来看,我是将响应全部读出,也就是使用io.ReadAll,这样一来,响应体有多大,那么就将占用多大的内存空间,并且,还有OOM的风险。
在即需要获取到响应内容,又要保证内存可控这样的既要又要的场景下,能不能保证两者都能满足呢。顺理成章地,我想到了,能不能“一点一点地读取响应内容”。
回到文章最前面提到的代码:
//1.执行一个请求
response, err := http.Get(...)
//2.使用io包读取body
io.ReadAll(response.Body)
//3.关闭响应
response.Body.Close()
这里不再进行io.ReadAll()操作了。反观http Response的定义设计:
type Response struct {
Status string
StatusCode int
Proto string
ProtoMajor int
ProtoMinor int
Header Header
Body io.ReadCloser
ContentLength int64
TransferEncoding []string
Close bool
Uncompressed bool
Trailer Header
Request *Request
TLS *tls.ConnectionState
}
我们已经能从此结构中获取到很大一部分有价值的信息,例如响应头。此时,我想到了Go语言的io包提供的一个函数Copy,这是它的源码定义:
func Copy(dst Writer, src Reader) (written int64, err error)
参数见名知意,可以将源拷贝到目标。这个操作,让我想起了这张图:

小鼹鼠修理工整准备将两根水管进行对接。
阅读了io.Copy源码之后,发现它调用了一个名叫copyBuffer的方法,下面是copyBuffer的具体实现:
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rf, ok := dst.(ReaderFrom); ok {
return rf.ReadFrom(src)
}
if buf == nil {
size := 32 * 1024
if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {
if l.N < 1 {
size = 1
} else {
size = int(l.N)
}
}
buf = make([]byte, size)
}
for {
nr, er := src.Read(buf)
if nr > 0 {
nw, ew := dst.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = errInvalidWrite
}
}
written += int64(nw)
if ew != nil {
err = ew
break
}
if nr != nw {
err = ErrShortWrite
break
}
}
if er != nil {
if er != EOF {
err = er
}
break
}
}
return written, err
}
其中,指定buf的大小,就能实现我想要的“一点一点地读”的需求。而默认的buf大小是32*1024,也是适用于我的需求,那么在此场景下使用io.Copy能够有效抑制内存疯涨,让转发操作变得简单清晰。
优化效果
在优化前,当有一定量的图像请求时,观察服务器监控就能发现内存上涨很快且十分不稳定。优化之后,内存占用变得平稳可控。
优化后其中一个pod内容占用情况:

优化后周期性的内存使用情况:

实际业务跑了一段时间,观察到这样的表现十分稳定。