Порівнюємо два формати серіалізації даних: Protobuf vs JSON

Привіт, мене звати Ярослав. Я займаюся розробкою в компанії Evrius. У цій статті ми порівняємо два формати серіалізації даних та ознайомимося з інструментами, які оптимізують її виконання. Інформація буде цікавою гоферам, які використовують серіалізацію для збереження та передачі даних.

Ця стаття є продовженням задачі, яку я розв’язував в офісі (тут ностальгійка, бо зараз працюю дистанційно).

Приклади коду доступні в репозиторії.

Історичні рішення, які треба переписати

На практиці це здається простим: з’явилася задача, її виконали швидко й легко, використовуючи стандартні інструменти, і всі задоволені. А з часом, хай за рік, змінились умови, збільшився трафік тощо, і те красиве рішення, що було спочатку, треба переписати. Знайомо?

JSON to Protobuf

У моєму робочому проєкті в одному з мікросервісів є операція, яка на кожен запит від користувачів зберігає JSON в key-value базу даних на три години. За рік користувачів стало більше, і ці операції збереження почали перевантажувати мережу (гарний початок для страшного оповідання).

Для зменшення трафіку і розміру БД ми вирішили замінити JSON на Protobuf. У результаті об’єм трафіку зменшився на третину, і це розв’язало проблему.

Але перед тим, як замінити, провели мікробенчмарки, якими й хочу поділитись далі.

JSON vs Protobuf, стандартна реалізація через рефлексію

У цьому мікробенчмарку Protobuf справді виграє у JSON. Але ми дамо JSON другий шанс у наступних порівняннях, щоб побачити можливості для розвитку у Protobuf.

Приклади структур буду скорочувати в ..., а повні можна глянути в репозиторії, де я проводив тести.

Для прикладу візьмемо GitHub API:

{
  "id": 23096959,
  "node_id": "MDEwOlJlcG9zaXRvcnkyMzA5Njk1OQ==",
  "name": "go",
  "full_name": "golang/go",
  "private": false,
  "owner": {
    // …
  },
  // …
  "license": {
    // …
  },
  // …
  "organization": {
    // …
  },
  "network_count": 10164,
  "subscribers_count": 3448
}

За допомогою онлайн-інструмента JSON to Go конвертуємо попередньо отримані дані в Go-структуру, яку будемо використовувати для серіалізації:

type Repository struct {
    ID               int          `json:"id"`
    // …
    Owner            Owner        `json:"owner"`
    // …
    License          License      `json:"license"`
    // …
    Organization     Organization `json:"organization"`
    // …
}

type Owner struct {
    Login             string `json:"login"`
    ID                int    `json:"id"`
    // …
}

type License struct {
    Key    string `json:"key"`
    // …
}

type Organization struct {
    Login             string `json:"login"`
    ID                int    `json:"id"`
    // …
}

Через інший, ще сирий інструмент JSON to Protobuf конверую в:

syntax = "proto3";

package protos;

message Repository {
  uint32 id = 1;
  // …
  Owner owner = 6;
  // …
  License license = 69;
  // …
  Organization organization = 75;
  // …
}

message Owner {
  string login = 1;
  uint32 id = 2;
  // …
}

message License {
  string key = 1;
  // …
}

message Organization {
  string login = 1;
  uint32 id = 2;
  // …
}

Я підготував і заповнив структури даними з JSON, які отримав з GitHub API раніше. Тепер можемо провести бенчмарки:

import (
    "encoding/json"
    "github.com/stretchr/testify/require"
    "gitlab.com/go-yp/proto-vs-json-research/models/fulljson"
    "testing"
)

func BenchmarkRepositoryMarshalJSON(b *testing.B) {
    var repository = &jsonExpectedRepository

    for i := 0; i < b.N; i++ {
        _, _ = json.Marshal(repository)
    }
}

func BenchmarkRepositoryUnmarshalJSON(b *testing.B) {
    var fixture = []byte(jsonRepositoryFixture)

    for i := 0; i < b.N; i++ {
        var repository = &fulljson.Repository{}

        json.Unmarshal(fixture, repository)
    }
}
import (
    "encoding/json"
    "github.com/golang/protobuf/proto"
    "github.com/stretchr/testify/require"
    "gitlab.com/go-yp/proto-vs-json-research/models/protos"
    "testing"
)

func BenchmarkRepositoryMarshalProto(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = proto.Marshal(protoExpectedRepository)
    }
}

func BenchmarkRepositoryUnmarshalProto(b *testing.B) {
    var repository = protoExpectedRepository
    var content, marshalErr = proto.Marshal(repository)

    require.NoError(b, marshalErr)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var repository = &protos.Repository{}

        _ = proto.Unmarshal(content, repository)
    }
}
Назва тестуСередній час ітераціїВиділення пам’яті
BenchmarkRepositoryMarshalJSON13172 ns/op6146 B/op 1 allocs/op
BenchmarkRepositoryUnmarshalJSON51246 ns/op6256 B/op 105 allocs/op
BenchmarkRepositoryMarshalProto8302 ns/op4208 B/op 8 allocs/op
BenchmarkRepositoryUnmarshalProto9357 ns/op5968 B/op 94 allocs/op

Як і очікували, Protobuf швидше серіалізує та потребує менше пам’яті.

JSON серіалізується в 5488 байтів, а Protobuf у 3811 байтів. У нашому прикладі на 30% менше пам’яті займає Protobuf.

Розглянемо «таємничний» 1 allocs/op при серіалізації JSON у бенчмарку BenchmarkRepositoryMarshalJSON. Стандартна бібліотека encoding/json має кеш sync.Pool, який перевикористовує раніше виділену пам’ять:

package json

// …

var encodeStatePool sync.Pool

func newEncodeState() *encodeState {
    if v := encodeStatePool.Get(); v != nil {
        e := v.(*encodeState)
        e.Reset()
        // …
        return e
    }
    return &encodeState{ptrSeen: make(map[interface{}]struct{})}
}

// …

func Marshal(v interface{}) ([]byte, error) {
    e := newEncodeState()

    err := e.marshal(v, encOpts{escapeHTML: true})
    if err != nil {
        return nil, err
    }
    buf := append([]byte(nil), e.Bytes()...)

    encodeStatePool.Put(e)

    return buf, nil
}

Таким чином отримуємо алокацію пам’яті buf := append([]byte(nil), e.Bytes()...) на всіх ітераціях циклу, окрім першої, де створюється encodeState.

Відсутня можливість виключити доданий кеш, щоб перевірити справжнє число алокацій.

JSON з кодогенерацією та перспективи серіалізації Protobuf

Коли тільки почав вчити Golang за уроками з «Техносфери», то дізнався, що стандартна JSON-серіалізація в Golang під капотом зроблена через рефлексію, яка використовує багато ресурсів у високонавантажених системах. Так розробники створили свою реалізацію JSON-серіалізації через кодогенерацію easyjson. Вона виконується швидше, потребує менше пам’яті та відбувається без рефлексії на момент виконання коду.

Для нашої структури Repository згенеруємо код, який буде серіалізувати в JSON.

Спершу встановимо easyjson:

go get -u github.com/mailru/easyjson/...

Тепер до структури Repository додамо службовий коментар easyjson:json. Він потрібний, щоб easyjson побачив, для якої структури треба згенерувати код:

//easyjson:json
type Repository struct {
    ID               int          `json:"id"`
    // …
    Owner            Owner        `json:"owner"`
    // …
    License          License      `json:"license"`
    // …
    Organization     Organization `json:"organization"`
    // …
}

type Owner struct {
    Login             string `json:"login"`
    ID                int    `json:"id"`
    // …
}

type License struct {
    Key    string `json:"key"`
    // …
}

type Organization struct {
    Login             string `json:"login"`
    ID                int    `json:"id"`
    // …
}

І запустимо кодогенерацію:

easyjson ./models/fulljson/repository.go

~/go/src/gitlab.com/go-yp/proto-vs-json-research
└── models
    └── fulljson
        ├── repository_easyjson.go [+]
        └── repository.go

У згенерованому файлі repository_easyjson.go нам будуть потрібні методи для серіалізації структури Repository:

// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.

// …            
            
// MarshalJSON supports json.Marshaler interface
func (v Repository) MarshalJSON() ([]byte, error) {
    // …
}
 
// …

// UnmarshalJSON supports json.Unmarshaler interface
func (v *Repository) UnmarshalJSON(data []byte) error {
    // …
}

Оновимо бенчмарки, які використовують згенеровані методи, і запустимо:

func BenchmarkRepositoryEasyMarshalJSON(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = jsonExpectedRepository.MarshalJSON()
    }
}

func BenchmarkRepositoryEasyUnmarshalJSON(b *testing.B) {
    var fixture = []byte(jsonRepositoryFixture)

    for i := 0; i < b.N; i++ {
        var repository = fulljson.Repository{}

        _ = repository.UnmarshalJSON(fixture)
    }
}
Назва тестуСередній час ітераціїВиділення пам’яті
BenchmarkRepositoryMarshalJSON13172 ns/op6146 B/op 1 allocs/op
BenchmarkRepositoryUnmarshalJSON51246 ns/op6256 B/op 105 allocs/op
BenchmarkRepositoryEasyMarshalJSON9718 ns/op6867 B/op 8 allocs/op
BenchmarkRepositoryEasyUnmarshalJSON13996 ns/op4128 B/op 86 allocs/op
BenchmarkRepositoryMarshalProto8302 ns/op4208 B/op 8 allocs/op
BenchmarkRepositoryUnmarshalProto
9357 ns/op5968 B/op 94 allocs/op

Як бачимо, ефективність десеріалізації значно підвищилась. А ось серіалізація стала використовувати більше пам’яті через відсутність схожого кешу, як у стандартної бібліотеки.

Епілог

Коли завершив статтю і відіслав друзям, від Павла дізнався, що вже є інструмент protoc-gen-gogofaster, який працює без рефлексії.

Protobuf-серіалізація без рефлексії

Ми під’єднаємо protoc-gen-gogofaster, згенеруємо новий код для Protobuf-серіалізації, оновимо бенчмарки та порівняємо результати.

Під’єднуємо та генеруємо (Makefile):

gogofaster:
    go get github.com/gogo/protobuf/protoc-gen-gogofaster

proto:
    protoc -I . protos/*.proto --gogofaster_out=models

make gogofaster

make proto

У результаті файл repository.pb.go буде мати 6576 рядків коду замість 1192, які були згенеровані стандартним інструментом protoc.

Оновимо бенчмарки:

func BenchmarkRepositoryFasterMarshalProto(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = protoExpectedRepository.Marshal()
    }
}

func BenchmarkRepositoryFasterUnmarshalProto(b *testing.B) {
    var content, marshalErr = protoExpectedRepository.Marshal()
    require.NoError(b, marshalErr)

    {
        var repository = protos.Repository{}
        var unmarshalErr = repository.Unmarshal(content)

        require.NoError(b, unmarshalErr)

        require.Equal(b, protoExpectedRepository, &repository)
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var repository = protos.Repository{}

        _ = repository.Unmarshal(content)
    }
}
Назва тестуСередній час ітераціїВиділення пам’яті
BenchmarkRepositoryMarshalJSON13172 ns/op6146 B/op 1 allocs/op
BenchmarkRepositoryUnmarshalJSON51246 ns/op6256 B/op 105 allocs/op
BenchmarkRepositoryEasyMarshalJSON9718 ns/op6867 B/op 8 allocs/op
BenchmarkRepositoryEasyUnmarshalJSON13996 ns/op4128 B/op 86 allocs/op
BenchmarkRepositoryMarshalProto8302 ns/op4208 B/op 8 allocs/op
BenchmarkRepositoryUnmarshalProto9357 ns/op5968 B/op 94 allocs/op
BenchmarkRepositoryMarshalProto8302 ns/op4208 B/op 8 allocs/op
BenchmarkRepositoryUnmarshalProto9357 ns/op5968 B/op 94 allocs/op
BenchmarkRepositoryFasterMarshalProto1705 ns/op4096 B/op 1 allocs/op
BenchmarkRepositoryFasterUnmarshalProto
3894 ns/op4784 B/op 89 allocs/op

Як бачимо, результати стали кращими.

Уже після написання статті дізнався про інструмент, який мені потрібнен — protoc-gen-gogofaster. Сподіваюсь, цей простий мікробенчмарк стане корисним, коли захочете мігрувати з JSON-у на Protobuf, а також зможете його використовувати як шаблон для своїх досліджень. У цій статті мені вдалось поєднати дві речі: обмін досвідом та можливість краще розібратись з інструментами.

Похожие статьи:
Техногігант Google повідомив, що надає доступ до коду двох своїх технологій з дотримання конфіденційності. Як пише видання Engadget, йдеться...
На YouTube-каналі DOU вийшов новий випуск Книжкового клубу — шоу для тих, хто ніяк не почне читати. Цього разу обговорюємо книгу «Книга Netflix...
[Максим Почебут — директор образовательных программ ЕРАМ в Украине, вице-президент ассоциации «ИТ Украина». Прошел путь...
EPAM ліквідує одну зі своїх російських «доньок» «Эпам Решения» в межах виходу з ринку рф, який почався з повномасштабним...
Ми продовжуємо висвітлювати, як ІТ-індустрія живе в умовах повномасштабної війни. Сьогодні говоримо про оцифрування...
Яндекс.Метрика