自古以來,JSON序列化就是兵家必爭之地

部落格猿馬甲哥發表於2022-01-26

上文講到使用ioutil.ReadAll讀取大的Response Body,出現讀取Body超時的問題。

前人引路

Stackoverflowmorganbaz的看法是:

使用iotil.ReadAll去讀取go語言裡大的Response Body,是非常低效的; 另外如果Response Body足夠大,還有記憶體洩漏的風險。

data,err:=  iotil.ReadAll(r)
if err != nil {
  return err
}
json.Unmarshal(data, &v)

有一個更有效的方式來解析json資料,會用到Decoder型別

err := json.NewDecoder(r).Decode(&v)
if err != nil {
    return err
}

這種方式從記憶體和時間角度,不但更簡潔,而且更高效。

  • Decoder不需要分配一個巨大的位元組記憶體來容納資料讀取——它可以簡單地重用一個很小的緩衝區來獲取所有的資料並漸進式解析。這為記憶體分配節省了大量時間,並消除了GC的壓力
  • JSON Decoder可以在第一個資料塊進入時開始解析資料——它不需要等待所有東西完成下載。

後人乘涼

這裡我針對前人的思路補充兩點。

  1. 官方ioutil.ReadAll是通過初始大小為512位元組的切片來讀取reader,我們的response body大概50M, 很明顯會頻繁觸發切片擴容,產生不必要的記憶體分配,給gc也帶來壓力。

go切片擴容的時機:需求小於256位元組,按照2倍擴容;超過256位元組,按照1.25倍擴容。

  1. 怎麼理解morganbaz所說的帶來的記憶體洩漏的風險?

記憶體洩漏是指程式已動態分配的堆記憶體由於某種原因未釋放,造成系統記憶體浪費,導致程式執行速度減慢升職系統崩潰等嚴重後果。

ioutil.ReadAll讀取大的Body會觸發切片擴容,講道理這種做法只會帶來記憶體浪費,最終會被gc釋放,原作者為什麼會強調有記憶體洩漏的風險?

我諮詢了一些童靴,對於需要長時間執行的高併發伺服器程式,不及時釋放記憶體也可能導致最終耗盡系統所有記憶體,這是一種隱式記憶體洩漏。

自古以來,JSON序列化就是兵家必爭之地

morganbaz大佬提出使用標準庫encoding/json來邊讀邊反序列化,
減少記憶體分配, 加快反序列化速度。

自古以來,JSON序列化就是兵家必爭之地,各大語言均對序列化有不同的實現思路,效能相差較大。

下面我們使用高效能json序列化庫json-iterator與原生ioutil.ReadAll+ json.Unmarshal方式做對比。

順便也檢驗我最近實踐pprof的成果。

# go get "github.com/json-iterator/go"
package main

import (
	"bytes"
	"flag"
	"log"
	"net/http"
	"os"
	"runtime/pprof"
	"time"

	jsoniter "github.com/json-iterator/go"
)

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file.")
var memprofile = flag.String("memprofile", "", "write  mem profile to file")

func main() {
	flag.Parse()
	if *cpuprofile != "" {
		f, err := os.Create(*cpuprofile)
		if err != nil {
			log.Fatal(err)
		}
		pprof.StartCPUProfile(f)
		defer pprof.StopCPUProfile()
	}

	c := &http.Client{
		Timeout: 60 * time.Second,
		// Transport: tr,
	}
	body := sendRequest(c, http.MethodPost)
	log.Println("response body length:", body)

	if *memprofile != "" {
		f, err := os.Create(*memprofile)
		if err != nil {
			log.Fatal("could not create memory profile: ", err)
		}
		defer f.Close() // error handling omitted for example
		if err := pprof.WriteHeapProfile(f); err != nil {
			log.Fatal("could not write memory profile: ", err)
		}
	}
}

func sendRequest(client *http.Client, method string) int {
	   endpoint := "http://xxxxx.com/table/instance?method=batch_query"
	  expr := "idc in (logicidc_hd1,logicidc_hd2,officeidc_hd1)"
    var json = jsoniter.ConfigCompatibleWithStandardLibrary
	  jsonData, err := json.Marshal([]string{expr})

	  log.Println("開始請求:" + time.Now().Format("2006-01-02 15:04:05.010"))
	  response, err := client.Post(endpoint, "application/json", bytes.NewBuffer(jsonData))
	  if err != nil {
		   log.Fatalf("Error sending request to api endpoint, %+v", err)
	 }
	 log.Println("服務端處理結束, 準備接收Response:" + time.Now().Format("2006-01-02 15:04:05.010"))
	 defer response.Body.Close()

	  var resp Response
	  var records = make(map[string][]Record)
	  resp.Data = &records

	  err= json.NewDecoder(response.Body).Decode(&resp)
	  if err != nil {
		  log.Fatalf("Couldn't parse response body, %+v", err)
	  }
	  log.Println("客戶端讀取+解析結束:" + time.Now().Format("2006-01-02 15:04:05.010"))
	  var result = make(map[string]*Data, len(records))
	  for _, r := range records[expr] {
		   result[r.Ins.Id] = &Data{Active: "0", IsProduct: true}
	  }
	  return len(result)
}
# 省略了反序列化的object type

記憶體對比


--- json-iterator邊讀邊反序列化 ---

--- io.ReadAll + json.Unmarshal 反序列化

我們可以點進去看io.ReadAll + json.Unmarshal記憶體耗在哪裡?

  Total:     59.59MB    59.59MB (flat, cum)   100%
    626            .          .           func ReadAll(r Reader) ([]byte, error) { 
    627            .          .           	b := make([]byte, 0, 512) 
    628            .          .           	for { 
    629            .          .           		if len(b) == cap(b) { 
    630            .          .           			// Add more capacity (let append pick how much). 
    631      59.59MB    59.59MB           			b = append(b, 0)[:len(b)] 
    632            .          .           		} 
    633            .          .           		n, err := r.Read(b[len(b):cap(b)]) 
    634            .          .           		b = b[:len(b)+n] 
    635            .          .           		if err != nil { 
    636            .          .           			if err == EOF { 

從上圖也可以印證io.ReadAll  為儲存整個Response.Body對初始512位元組的切片不斷擴容, 產生常駐記憶體59M。


你還可以對比alloc_space 分配記憶體inuse_space常駐記憶體, 這兩者的差值可粗略理解為gc釋放的部分。

從結果看json-iterator相比io.ReadAll + json.Unmarshal 分配記憶體是比較小的。

我的收穫

1.ioutil.ReadAll 讀取大的response.body的風險:效能差且有記憶體洩漏的風險
2.隱式記憶體洩漏:對於高併發、長時間執行的web程式,不及時釋放記憶體最終也會導致記憶體耗盡。
3.json 序列化是兵家必爭之地, json-iterator 是相容標準encode/json api 用法的高效能序列化器
4.pprof 記憶體診斷的姿勢 & 除錯指標的意義。

相關文章