【Go】優雅的讀取http請求或響應的資料

qiyin發表於2019-01-26

原文連結:https://blog.thinkeridea.com/201901/go/you_ya_de_du_qu_http_qing_qiu_huo_xiang_ying_de_shu_ju.html

http.Request.Bodyhttp.Response.Body 中讀取資料方法或許很多,標準庫中大多數使用 ioutil.ReadAll 方法一次讀取所有資料,如果是 json 格式的資料還可以使用 json.NewDecoderio.Reader 建立一個解析器,假使使用 pprof 來分析程式總是會發現 bytes.makeSlice 分配了大量記憶體,且總是排行第一,今天就這個問題來說一下如何高效優雅的讀取 http 中的資料。

背景介紹

我們有許多 api 服務,全部採用 json 資料格式,請求體就是整個 json 字串,當一個請求到服務端會經過一些業務處理,然後再請求後面更多的服務,所有的服務之間都用 http 協議來通訊(啊, 為啥不用 RPC,因為所有的服務都會對第三方開放,http + json 更好對接),大多數請求資料大小在 1K~4K,響應的資料在 1K~8K,早期所有的服務都使用 ioutil.ReadAll 來讀取資料,隨著流量增加使用 pprof 來分析發現 bytes.makeSlice 總是排在第一,並且佔用了整個程式 1/10 的記憶體分配,我決定針對這個問題進行優化,下面是整個優化過程的記錄。

pprof 分析

這裡使用 https://github.com/thinkeridea/go-extend/blob/master/exnet/exhttp/expprof/pprof.go 中的 API 來實現生產環境的 /debug/pprof 監測介面,沒有使用標準庫的 net/http/pprof 包因為會自動註冊路由,且長期開放 API,這個包可以設定 API 是否開放,並在規定時間後自動關閉介面,避免存在工具嗅探。

服務部署上線穩定後(大約過了一天半),通過 curl 下載 allocs 資料,然後使用下面的命令檢視分析。

$ go tool pprof allocs
File: xxx
Type: alloc_space
Time: Jan 25, 2019 at 3:02pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 604.62GB, 44.50% of 1358.61GB total
Dropped 776 nodes (cum <= 6.79GB)
Showing top 10 nodes out of 155
      flat  flat%   sum%        cum   cum%
  111.40GB  8.20%  8.20%   111.40GB  8.20%  bytes.makeSlice
  107.72GB  7.93% 16.13%   107.72GB  7.93%  github.com/sirupsen/logrus.(*Entry).WithFields
   65.94GB  4.85% 20.98%    65.94GB  4.85%  strings.Replace
   54.10GB  3.98% 24.96%    56.03GB  4.12%  github.com/json-iterator/go.(*frozenConfig).Marshal
   47.54GB  3.50% 28.46%    47.54GB  3.50%  net/url.unescape
   47.11GB  3.47% 31.93%    48.16GB  3.55%  github.com/json-iterator/go.(*Iterator).readStringSlowPath
   46.63GB  3.43% 35.36%   103.04GB  7.58%  handlers.(*AdserviceHandler).returnAd
   42.43GB  3.12% 38.49%    84.62GB  6.23%  models.LogItemsToBytes
   42.22GB  3.11% 41.59%    42.22GB  3.11%  strings.Join
   39.52GB  2.91% 44.50%    87.06GB  6.41%  net/url.parseQuery

從結果中可以看出採集期間一共分配了 1358.61GB top 10 佔用了 44.50% 其中 bytes.makeSlice 佔了接近 1/10,那麼看看都是誰在呼叫 bytes.makeSlice 吧。

(pprof) web bytes.makeSlice

image-1

從上圖可以看出呼叫 bytes.makeSlice 的最終方法是 ioutil.ReadAll, (受篇幅影響就沒有擷取 ioutil.ReadAll 上面的方法了),而 90% 都是 ioutil.ReadAll 讀取 http 資料呼叫,找到地方先別急想優化方案,先看看為啥 ioutil.ReadAll 會導致這麼多記憶體分配。

func readAll(r io.Reader, capacity int64) (b []byte, err error) {
    var buf bytes.Buffer
    // If the buffer overflows, we will get bytes.ErrTooLarge.
    // Return that as an error. Any other panic remains.
    defer func() {
        e := recover()
        if e == nil {
            return
        }
        if panicErr, ok := e.(error); ok && panicErr == bytes.ErrTooLarge {
            err = panicErr
        } else {
            panic(e)
        }
    }()
    if int64(int(capacity)) == capacity {
        buf.Grow(int(capacity))
    }
    _, err = buf.ReadFrom(r)
    return buf.Bytes(), err
}

func ReadAll(r io.Reader) ([]byte, error) {
    return readAll(r, bytes.MinRead)
}

以上是標準庫 ioutil.ReadAll 的程式碼,每次會建立一個 var buf bytes.Buffer 並且初始化 buf.Grow(int(capacity)) 的大小為 bytes.MinRead, 這個值呢就是 512,按這個 buffer 的大小讀取一次資料需要分配 2~16 次記憶體,天啊簡直不能忍,我自己建立一個 buffer 好不好。

看一下火焰圖吧,其中紅框標記的就是 ioutil.ReadAll 的部分,顏色比較鮮豔。

image-2

優化讀取方法

自己建立足夠大的 buffer 減少因為容量不夠導致的多次擴容問題。

buffer := bytes.NewBuffer(make([]byte, 4096))
_, err := io.Copy(buffer, request.Body)
if err !=nil{
    return nil, err
}

恩恩這樣應該差不多了,為啥是初始化 4096 的大小,這是個均值,即使比 4096 大基本也就多分配一次記憶體即可,而且大多數資料都是比 4096 小的。

但是這樣真的就算好了嗎,當然不能這樣,這個 buffer 個每請求都要建立一次,是不是應該考慮一下複用呢,使用 sync.Pool 建立一個緩衝池效果就更好了。

以下是優化讀取請求的簡化程式碼:

package adapter

import (
    "bytes"
    "io"
    "net/http"
    "sync"

    "github.com/json-iterator/go"
    "github.com/sirupsen/logrus"
    "github.com/thinkeridea/go-extend/exbytes"
)

type Adapter struct {
    pool sync.Pool
}

func New() *Adapter {
    return &Adapter{
        pool: sync.Pool{
            New: func() interface{} {
                return bytes.NewBuffer(make([]byte, 4096))
            },
        },
    }
}

func (api *Adapter) GetRequest(r *http.Request) (*Request, error) {
    buffer := api.pool.Get().(*bytes.Buffer)
    buffer.Reset()
    defer func() {
        if buffer != nil {
            api.pool.Put(buffer)
            buffer = nil
        }
    }()

    _, err := io.Copy(buffer, r.Body)
    if err != nil {
        return nil, err
    }

    request := &Request{}
    if err = jsoniter.Unmarshal(buffer.Bytes(), request); err != nil {
        logrus.WithFields(logrus.Fields{
            "json": exbytes.ToString(buffer.Bytes()),
        }).Errorf("jsoniter.UnmarshalJSON fail. error:%v", err)
        return nil, err
    }
    api.pool.Put(buffer)
    buffer = nil

    // ....

    return request, nil
}

使用 sync.Pool 的方式是不是有點怪,主要是 deferapi.pool.Put(buffer);buffer = nil 這裡解釋一下,為了提高 buufer 的複用率會在不使用時儘快把 buffer 放回到緩衝池中,defer 之所以會判斷 buffer != nil 主要是在業務邏輯出現錯誤時,但是 buffer 還沒有放回緩衝池時把 buffer 放回到緩衝池,因為在每個錯誤處理之後都寫 api.pool.Put(buffer) 不是一個好的方法,而且容易忘記,但是如果在確定不再使用時 api.pool.Put(buffer);buffer = nil 就可以儘早把 buffer 放回到緩衝池中,提高複用率,減少新建 buffer

這樣就好了嗎,別急,之前說服務裡面還會構建請求,看看構建請求如何優化吧。

package adapter

import (
    "bytes"
    "fmt"
    "io"
    "io/ioutil"
    "net/http"
    "sync"

    "github.com/json-iterator/go"
    "github.com/sirupsen/logrus"
    "github.com/thinkeridea/go-extend/exbytes"
)

type Adapter struct {
    pool sync.Pool
}

func New() *Adapter {
    return &Adapter{
        pool: sync.Pool{
            New: func() interface{} {
                return bytes.NewBuffer(make([]byte, 4096))
            },
        },
    }
}

func (api *Adapter) Request(r *Request) (*Response, error) {
    var err error
    buffer := api.pool.Get().(*bytes.Buffer)
    buffer.Reset()
    defer func() {
        if buffer != nil {
            api.pool.Put(buffer)
            buffer = nil
        }
    }()

    e := jsoniter.NewEncoder(buffer)
    err = e.Encode(r)
    if err != nil {
        logrus.WithFields(logrus.Fields{
            "request": r,
        }).Errorf("jsoniter.Marshal failure: %v", err)
        return nil, fmt.Errorf("jsoniter.Marshal failure: %v", err)
    }

    data := buffer.Bytes()
    req, err := http.NewRequest("POST", "http://xxx.com", buffer)
    if err != nil {
        logrus.WithFields(logrus.Fields{
            "data": exbytes.ToString(data),
        }).Errorf("http.NewRequest failed: %v", err)
        return nil, fmt.Errorf("http.NewRequest failed: %v", err)
    }

    req.Header.Set("User-Agent", "xxx")

    httpResponse, err := http.DefaultClient.Do(req)
    if httpResponse != nil {
        defer func() {
            io.Copy(ioutil.Discard, httpResponse.Body)
            httpResponse.Body.Close()
        }()
    }

    if err != nil {
        logrus.WithFields(logrus.Fields{
            "url": "http://xxx.com",
        }).Errorf("query service failed %v", err)
        return nil, fmt.Errorf("query service failed %v", err)
    }

    if httpResponse.StatusCode != 200 {
        logrus.WithFields(logrus.Fields{
            "url":         "http://xxx.com",
            "status":      httpResponse.Status,
            "status_code": httpResponse.StatusCode,
        }).Errorf("invalid http status code")
        return nil, fmt.Errorf("invalid http status code")
    }

    buffer.Reset()
    _, err = io.Copy(buffer, httpResponse.Body)
    if err != nil {
        return nil, fmt.Errorf("adapter io.copy failure error:%v", err)
    }

    respData := buffer.Bytes()
    logrus.WithFields(logrus.Fields{
        "response_json": exbytes.ToString(respData),
    }).Debug("response json")

    res := &Response{}
    err = jsoniter.Unmarshal(respData, res)
    if err != nil {
        logrus.WithFields(logrus.Fields{
            "data": exbytes.ToString(respData),
            "url":  "http://xxx.com",
        }).Errorf("adapter jsoniter.Unmarshal failed, error:%v", err)
        return nil, fmt.Errorf("adapter jsoniter.Unmarshal failed, error:%v", err)
    }

    api.pool.Put(buffer)
    buffer = nil

    // ...
    return res, nil
}

這個示例和之前差不多,只是不僅用來讀取 http.Response.Body 還用來建立一個 jsoniter.NewEncoder 用來把請求壓縮成 json 字串,並且作為 http.NewRequestbody 引數, 如果直接用 jsoniter.Marshal 同樣會建立很多次記憶體,jsoniter 也使用 buffer 做為緩衝區,並且預設大小為 512, 程式碼如下:

func (cfg Config) Froze() API {
    api := &frozenConfig{
        sortMapKeys:                   cfg.SortMapKeys,
        indentionStep:                 cfg.IndentionStep,
        objectFieldMustBeSimpleString: cfg.ObjectFieldMustBeSimpleString,
        onlyTaggedField:               cfg.OnlyTaggedField,
        disallowUnknownFields:         cfg.DisallowUnknownFields,
    }
    api.streamPool = &sync.Pool{
        New: func() interface{} {
            return NewStream(api, nil, 512)
        },
    }
    // .....
    return api
}

而且序列化之後會進行一次資料拷貝:

func (cfg *frozenConfig) Marshal(v interface{}) ([]byte, error) {
    stream := cfg.BorrowStream(nil)
    defer cfg.ReturnStream(stream)
    stream.WriteVal(v)
    if stream.Error != nil {
        return nil, stream.Error
    }
    result := stream.Buffer()
    copied := make([]byte, len(result))
    copy(copied, result)
    return copied, nil
}

既然要用 buffer 那就一起吧^_^,這樣可以減少多次記憶體分配,下讀取 http.Response.Body 之前一定要記得 buffer.Reset(), 這樣基本就已經完成了 http.Request.Bodyhttp.Response.Body 的資料讀取優化了,具體效果等上線跑一段時間穩定之後來檢視吧。

效果分析

上線跑了一天,來看看效果吧

$ go tool pprof allocs2
File: connect_server
Type: alloc_space
Time: Jan 26, 2019 at 10:27am (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 295.40GB, 40.62% of 727.32GB total
Dropped 738 nodes (cum <= 3.64GB)
Showing top 10 nodes out of 174
      flat  flat%   sum%        cum   cum%
   73.52GB 10.11% 10.11%    73.52GB 10.11%  git.tvblack.com/tvblack/connect_server/vendor/github.com/sirupsen/logrus.(*Entry).WithFields
   31.70GB  4.36% 14.47%    31.70GB  4.36%  net/url.unescape
   27.49GB  3.78% 18.25%    54.87GB  7.54%  git.tvblack.com/tvblack/connect_server/models.LogItemsToBytes
   27.41GB  3.77% 22.01%    27.41GB  3.77%  strings.Join
   25.04GB  3.44% 25.46%    25.04GB  3.44%  bufio.NewWriterSize
   24.81GB  3.41% 28.87%    24.81GB  3.41%  bufio.NewReaderSize
   23.91GB  3.29% 32.15%    23.91GB  3.29%  regexp.(*bitState).reset
   23.06GB  3.17% 35.32%    23.06GB  3.17%  math/big.nat.make
   19.90GB  2.74% 38.06%    20.35GB  2.80%  git.tvblack.com/tvblack/connect_server/vendor/github.com/json-iterator/go.(*Iterator).readStringSlowPath
   18.58GB  2.56% 40.62%    19.12GB  2.63%  net/textproto.(*Reader).ReadMIMEHeader

哇塞 bytes.makeSlice 終於從前十中消失了,真的太棒了,還是看看 bytes.makeSlice 的其它呼叫情況吧。

(pprof) web bytes.makeSlice

image-3

從圖中可以發現 bytes.makeSlice 的分配已經很小了, 且大多數是 http.Request.ParseForm 讀取 http.Request.Body 使用 ioutil.ReadAll 原因,這次優化的效果非常的好。

看一下更直觀的火焰圖吧,和優化前對比一下很明顯 ioutil.ReadAll 看不到了

image-4

優化期間遇到的問題

比較慚愧在優化的過程出現了一個過失,導致生產環境2分鐘故障,通過自動部署立即回滾才得以快速恢復,之後分析程式碼解決之後上線才完美優化,下面總結一下出現的問題吧。

在構建 http 請求時我分了兩個部分優化,序列化 json 和讀取 http.Response.Body 資料,保持一個觀點就是儘早把 buffer 放回到緩衝池,因為 http.DefaultClient.Do(req) 是網路請求會相對耗時,在這個之前我把 buffer 放回到緩衝池中,之後讀取 http.Response.Body 時在重新獲取一個 buffer,大概程式碼如下:

package adapter

import (
    "bytes"
    "fmt"
    "io"
    "io/ioutil"
    "net/http"
    "sync"

    "github.com/json-iterator/go"
    "github.com/sirupsen/logrus"
    "github.com/thinkeridea/go-extend/exbytes"
)

type Adapter struct {
    pool sync.Pool
}

func New() *Adapter {
    return &Adapter{
        pool: sync.Pool{
            New: func() interface{} {
                return bytes.NewBuffer(make([]byte, 4096))
            },
        },
    }
}

func (api *Adapter) Request(r *Request) (*Response, error) {
    var err error
    buffer := api.pool.Get().(*bytes.Buffer)
    buffer.Reset()
    defer func() {
        if buffer != nil {
            api.pool.Put(buffer)
            buffer = nil
        }
    }()

    e := jsoniter.NewEncoder(buffer)
    err = e.Encode(r)
    if err != nil {
        return nil, fmt.Errorf("jsoniter.Marshal failure: %v", err)
    }

    data := buffer.Bytes()
    req, err := http.NewRequest("POST", "http://xxx.com", buffer)
    if err != nil {
        return nil, fmt.Errorf("http.NewRequest failed: %v", err)
    }

    req.Header.Set("User-Agent", "xxx")

    api.pool.Put(buffer)
    buffer = nil

    httpResponse, err := http.DefaultClient.Do(req)

    // ....

    buffer = api.pool.Get().(*bytes.Buffer)
    buffer.Reset()
    defer func() {
        if buffer != nil {
            api.pool.Put(buffer)
            buffer = nil
        }
    }()
    _, err = io.Copy(buffer, httpResponse.Body)
    if err != nil {
        return nil, fmt.Errorf("adapter io.copy failure error:%v", err)
    }

    // ....

    api.pool.Put(buffer)
    buffer = nil

    // ...
    return res, nil
}

上線之後馬上發生了錯誤 http: ContentLength=2090 with Body length 0 傳送請求的時候從 buffer 讀取資料發現資料不見了或者資料不夠了,我去這是什麼鬼,馬上回滾恢復業務,然後分析 http.DefaultClient.Do(req)http.NewRequest,在呼叫 http.NewRequest 是並沒有從 buffer 讀取資料,而只是建立了一個 req.GetBody 之後在 http.DefaultClient.Do 是才讀取資料,因為在 http.DefaultClient.Do 之前把 buffer 放回到緩衝池中,其它 goroutine 獲取到 buffer 並進行 Reset 就發生了資料爭用,當然會導致資料讀取不完整了,真實汗顏,對 http.Client 瞭解太少,爭取有空擼一遍原始碼。

總結

使用合適大小的 buffer 來減少記憶體分配,sync.Pool 可以幫助複用 buffer, 一定要自己寫這些邏輯,避免使用三方包,三方包即使使用同樣的技巧為了避免資料爭用,在返回資料時候必然會拷貝一個新的資料返回,就像 jsoniter 雖然使用了 sync.Poolbuffer 但是返回資料時還需要拷貝,另外這種通用包並不能給一個非常貼合業務的初始 buffer 大小,過小會導致資料發生拷貝,過大會太過浪費記憶體。

程式中善用 buffersync.Pool 可以大大的改善程式的效能,並且這兩個組合在一起使用非常的簡單,並不會使程式碼變的複雜。

轉載:

本文作者: 戚銀(thinkeridea

本文連結: https://blog.thinkeridea.com/201901/go/you_ya_de_du_qu_http_qing_qiu_huo_xiang_ying_de_shu_ju.html

版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!

相關文章