Порівнюємо два формати серіалізації даних: 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, а також зможете його використовувати як шаблон для своїх досліджень. У цій статті мені вдалось поєднати дві речі: обмін досвідом та можливість краще розібратись з інструментами.

Похожие статьи:
Восени Міністерство оборони планує запустити онлайн-рекрутинг на базі застосунку для військовозобовʼязаних «Резерв+». Про...
Компания Sony объявила дату и время своей пресс-конференции, которая пройдет в рамках международной выставки потребительской...
Организатор: SmartMe UniversityТренер: Фридман Виталий На данном мастер-классе Виталий Фридман, главный редактор Smashing Magazine,...
Contemporary art galleries otherwise known as commercial galleries are spaces where you get art for sale. In these galleries, different artists’ works are displayed. Regular exhibitions are also held in a...
В четвер, 23 березня, американська компанія Takeoff Technologies, що спеціалізується на автоматизації e-commerce процесів,...
Яндекс.Метрика