Go 標準庫 encoding-json 真的慢嗎?

插圖來自於 “A Journey With Go”,由 Go Gopher 組織成員 Renee French 創作。

本文基於 Go 1.12。

關於標準庫 encoding/json 性能差的問題在很多地方被討論過,也有很多第三方庫在嘗試解決這個問題,比如 easyjson[1],jsoniter[2] 和 ffjson[3]。但是標準庫 encoding/json 真的慢嗎?它一直都這麼慢嗎?

標準庫 encoding/json 的進化之路

首先,通過一個簡短的 makefile 文件和一段基準測試代碼,我們看下在各個 Go 版本中,標準庫 encoding/json 的性能表現。以下爲基準測試代碼:

type JSON struct {
   Foo int
   Bar string
   Baz float64
}

func BenchmarkJsonMarshall(b *testing.B) {
   j := JSON{
      Foo: 123,
      Bar: `benchmark`,
      Baz: 123.456,
   }
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      _, _ = json.Marshal(&j)
   }
}

func BenchmarkJsonUnmarshal(b *testing.B) {
   bytes := `{"foo": 1, "bar""my string", bar: 1.123}`
   str := []byte(bytes)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      j := JSON{}
      _ = json.Unmarshal(str, &j)
   }
}

makefile 文件在不同的文件夾中基於不同版本的 Go 創建 Docker 鏡像,在各鏡像啓動的容器中運行基準測試。將從以下兩個維度進行性能對比:

第一個維度的對比可以得到在特定版本的 Go 與 1.12 版本的 Go 中 JSON 序列化和反序列化的性能差異;第二個維度的對比可以得到在哪次 Go 版本升級中 JSON 序列化和反序列化發生了最大的性能提升。

測試結果如下:

name           old time/op    new time/op    delta
JsonMarshall     1.91 µ s ± 2%    1.37 µ s ± 2%  -28.23%
JsonUnmarshal    2.70 µ s ± 2%    1.75 µ s ± 3%  -35.18%
name             old time/op    new time/op    delta
JsonMarshall-4     1.24 µ s ± 1%    0.90 µ s ± 2%  -27.65%
JsonUnmarshal-4    1.52 µ s ± 3%    0.91 µ s ± 2%  -40.05%
name             old alloc/op   new alloc/op   delta
JsonMarshall-4       208B ± 0%       80B ± 0%  -61.54%
JsonUnmarshal-4      496B ± 0%      368B ± 0%  -25.81%
name             old time/op    new time/op    delta
JsonMarshall-4      670ns ± 6%     569ns ± 2%  -15.09%
JsonUnmarshal-4     800ns ± 1%     747ns ± 1%   -6.58%

可以在這裏看到完整的測試結果 [4]。

如果對比 Go1.2 與 Go1.12,會發現標準庫 encoding/json 的性能有顯著提高,操作耗時減少了約 69%/68%,內存消耗減少了約 74%/29%:

name           old time/op    new time/op    delta
JsonMarshall     1.72 µ s ± 2%    0.52 µ s ± 2%  -69.68%
JsonUnmarshal    2.72 µ s ± 2%    0.85 µ s ± 5%  -68.70%

name           old alloc/op   new alloc/op   delta
JsonMarshall       188B ± 0%       48B ± 0%  -74.47%
JsonUnmarshal      519B ± 0%      368B ± 0%  -29.09%

該基準測試使用了較爲簡單的 JSON 結構。使用更加複雜的結構(例如:Map or Array)進行測試會導致各版本之間性能增幅與本文不同。

速讀源碼

想了解標準庫性能較差的原因的最好的辦法就是讀源碼,以下爲 Go1.12 版本中 json.Marshal 函數的執行流程:

在瞭解了 json.Marshal 函數的執行流程後,再來比較下在 Go1.10 和 Go1.12 版本中的 json.Marshal 函數在實現上有什麼變化。通過之前的測試,可以發現從 Go1.10 至 Go1.12 版本中的 json.Marshal 函數的內存消耗上有了很大的改善。從源碼的變化中可以發現在 Go1.12 版本中的 json.Marshal 函數添加了 encoder(編碼器)的內存緩存:

在使用了 sync.Pool 緩存 encoder 後,json.Marshal 函數極大地減少了內存分配操作。實際上 newEncodeState() 函數在 Go1.10 版本中就已經存在了 [5],只不過沒有被使用。爲驗證是添加了內存緩存帶來了性能提升的猜想,可以在 Go1.10 版本中修改 json.Marshal 函數後,再進行測試:

name           old alloc/op   new alloc/op   delta
CodeMarshal-4    4.59MB ± 0%    1.98MB ± 0%  -56.92%

可以直接在 Go 源碼 [6] 中,執行以下命令進行基準測試:

go test encoding/json -bench=BenchmarkCodeMarshal -benchmem -count=10 -run=^$

結果和我們的猜想是一致的。是 sync 包 [7] 給 json.Marshal 函數帶來了性能提升。同樣也給我們帶來一點啓發,當項目也有這種對同一個結構體進行大量的內存分配時,也可以通過添加內存緩存的方式提升性能。

以下爲 Go1.12 版本中,json.Unmarshal 函數的執行流程:

json.Unmarshal 函數同樣使用 sync.Pool 緩存了 decoder。對於 JSON 序列化和反序列化而言,其性能瓶頸是迭代、反射 JSON 結構中每個字段。

本文是 Go 語言中文網組織的 GCTT 翻譯,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

與第三方庫的性能對比

GitHub 上也有很多用於 JSON 序列化的第三方庫,比如 ffjson[8] 就是其中之一,ffjson 的命令行工具可以爲指定的結構生成靜態的 MarshalJSON 和 UnmarshalJSON 函數,MarshalJSON 和 UnmarshalJSON 函數在序列化和反序列化操作時會分別被 ffjson.Marshalffjson.Unmarshal 函數調用。以下爲 ffjson 生成的解析器示例:

func (j *JSONFF) MarshalJSON() ([]byte, error) {
   var buf fflib.Buffer
   if j == nil {
      buf.WriteString("null")
      return buf.Bytes(), nil
   }
   err := j.MarshalJSONBuf(&buf)
   if err != nil {
      return nil, err
   }
   return buf.Bytes(), nil
}

// MarshalJSONBuf marshal buff to JSON - template
func (j *JSONFF) MarshalJSONBuf(buf fflib.EncodingBuffer) error {
   if j == nil {
      buf.WriteString("null")
      return nil
   }
   var err error
   var obj []byte
   _ = obj
   _ = err
   buf.WriteString(`{"Foo":`)
   fflib.FormatBits2(buf, uint64(j.Foo), 10, j.Foo < 0)
   buf.WriteString(`,"Bar":`)
   fflib.WriteJsonString(buf, string(j.Bar))
   buf.WriteString(`,"Baz":`)
   fflib.AppendFloat(buf, float64(j.Baz)'g', -1, 64)
   buf.WriteByte('}')
   return nil
}

現在比較一下標準庫和 ffjson(使用了 ffjson.Pool())的性能差異:

standard lib:
name             time/op
JsonMarshall-4   500ns ± 2%
JsonUnmarshal-4  677ns ± 2%

name             alloc/op
JsonMarshall-4   48.0B ± 0%
JsonUnmarshal-4   320B ± 0%

ffjson:
name               time/op
JsonMarshallFF-4   538ns ± 1%
JsonUnmarshalFF-4  827ns ± 3%

name               alloc/op
JsonMarshallFF-4    176B ± 0%
JsonUnmarshalFF-4   448B ± 0%

對於 JSON 序列化 / 反序列化,標準庫與 ffjson 相比反而更加高效一些。

對於內存使用情況(堆分配),可以通過 go run -gcflags="-m" 命令進行測試:

:46:19: buf escapes to heap
:48:23: buf escapes to heap
:27:26: &buf escapes to heap
:22:6: moved to heap: buf

easyjson[9] 庫也使用了和 ffjson 同樣的策略,以下爲基準測試結果:

standard lib:
name             time/op
JsonMarshall-4   500ns ± 2%
JsonUnmarshal-4  677ns ± 2%

name             alloc/op
JsonMarshall-4   48.0B ± 0%
JsonUnmarshal-4   320B ± 0%

easyjson:
name               time/op
JsonMarshallEJ-4   349ns ± 1%
JsonUnmarshalEJ-4  341ns ± 5%

name               alloc/op
JsonMarshallEJ-4    240B ± 0%
JsonUnmarshalEJ-4   256B ± 0%

這次,easyjson 比標準庫更高效些,對於 JSON 序列化有 30% 的性能提升,對於 JSON 反序列化性能提升接近 2 倍。通過閱讀 easyjson.Marshal 的源碼,可以發現它高效的原因:

func Marshal(v Marshaler) ([]byte, error) {
   w := jwriter.Writer{}
   v.MarshalEasyJSON(&w)
   return w.BuildBytes()
}

通過 easyjson 的命令行工具生成的編碼器 MarshalEasyJSON 方法可用於 JSON 序列化:

func easyjson42239ddeEncode(out *jwriter.Writer, in JSON) {
   out.RawByte('{')
   first := true
   _ = first
   {
      const prefix string = ",\"Foo\":"
      if first {
         first = false
         out.RawString(prefix[1:])
      } else {
         out.RawString(prefix)
      }
      out.Int(int(in.Foo))
   }
   {
      const prefix string = ",\"Bar\":"
      if first {
         first = false
         out.RawString(prefix[1:])
      } else {
         out.RawString(prefix)
      }
      out.String(string(in.Bar))
   }
   {
      const prefix string = ",\"Baz\":"
      if first {
         first = false
         out.RawString(prefix[1:])
      } else {
         out.RawString(prefix)
      }
      out.Float64(float64(in.Baz))
   }
   out.RawByte('}')
}

func (v JSON) MarshalEasyJSON(w *jwriter.Writer) {
   easyjson42239ddeEncode(w, v)
}

正如我們所見,這裏沒有使用反射。整體流程也很簡單。而且,easyjson 也可以兼容標準庫:

func (v JSON) MarshalJSON() ([]byte, error) {
   w := jwriter.Writer{}
   easyjson42239ddeEncodeGithubComMyCRMTeamEncodingJsonEasyjson(&w, v)
   return w.Buffer.BuildBytes(), w.Error
}

然而,使用這種兼容標準庫的方式進行序列化會比直接使用標準庫性能更差,因爲在進行 JSON 序列化的過程中,標準庫依然會通過反射構造 encoder,且 MarshalJSON 中這一段代碼也會被執行。

結論

無論在標準庫上做多少努力,它都不會比通過對明確的 JSON 結構生成 encoder/decoder 的方式性能好。而通過結構生成解析器代碼的方式需要生成和維護此代碼,並且依賴於外部的庫。

在做出使用第三方序列化庫替換標準庫的決定前,最好先測試下 JSON 序列化和反序列化是否是應用的性能瓶頸點,提高 JSON 序列化的效率是否能改善應用的性能。如果 JSON 序列化和反序列化並不是應用的性能瓶頸點,爲了極少的性能提升,付出第三方庫的維護成本是不值得的。畢竟,在大多數業務場景下,Go 的標準庫 encoding/json 已經足夠高效了。

via: https://medium.com/a-journey-with-go/go-is-the-encoding-json-package-really-slow-62b64d54b148

作者:Vincent Blanchon[10] 譯者:beiping96[11] 校對:polaris1119[12]

本文由 GCTT[13] 原創編譯,Go 中文網 [14] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1]

easyjson: https://github.com/mailru/easyjson

[2]

jsoniter: https://github.com/json-iterator/go

[3]

ffjson: https://github.com/pquerna/ffjson

[4]

測試結果: https://gist.github.com/blanchonvincent/227b6691777a1de254ce75b304a36277

[5]

已經存在了: https://github.com/golang/go/commit/c0547476f342665514904cf2581a62135d2366c3#diff-e79d4db81e8544657cb631be813f89b4

[6]

Go 源碼: https://github.com/golang/go

[7]

sync 包: https://golang.org/pkg/sync/

[8]

ffjson: https://github.com/pquerna/ffjson

[9]

easyjson: https://github.com/mailru/easyjson

[10]

Vincent Blanchon: https://medium.com/@blanchon.vincent

[11]

beiping96: https://github.com/beiping96

[12]

polaris1119: https://github.com/polaris1119

[13]

GCTT: https://github.com/studygolang/GCTT

[14]

Go 中文網: https://studygolang.com/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UWmnI2kPRYbjlF8Kgh2-ew