Порівнюємо два формати серіалізації даних: 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 { String Key `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) } }
Назва тесту | Середній годину ітерації | Виділення пам'яті |
BenchmarkRepositoryMarshalJSON | 13172 ns/op | 6146 B/op 1 allocs/op |
BenchmarkRepositoryUnmarshalJSON | 51246 ns/op | 6256 B/op 105 allocs/op |
BenchmarkRepositoryMarshalProto | 8302 ns/op | 4208 B/op 8 allocs/op |
BenchmarkRepositoryUnmarshalProto | 9357 ns/op | 5968 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 { String Key `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) } }
Назва тесту | Середній годину ітерації | Виділення пам'яті |
BenchmarkRepositoryMarshalJSON | 13172 ns/op | 6146 B/op 1 allocs/op |
BenchmarkRepositoryUnmarshalJSON | 51246 ns/op | 6256 B/op 105 allocs/op |
BenchmarkRepositoryEasyMarshalJSon | 9718 ns/op | 6867 B/op 8 allocs/op |
BenchmarkRepositoryEasyUnmarshaljson | 13996 ns/op | 4128 B/op 86 allocs/op |
BenchmarkRepositoryMarshalProto | 8302 ns/op | 4208 B/op 8 allocs/op |
BenchmarkRepositoryUnmarshalProto |
9357 ns/op | 5968 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) } }
Назва тесту | Середній годину ітерації | Виділення пам'яті |
BenchmarkRepositoryMarshalJSON | 13172 ns/op | 6146 B/op 1 allocs/op |
BenchmarkRepositoryUnmarshalJSON | 51246 ns/op | 6256 B/op 105 allocs/op |
BenchmarkRepositoryEasyMarshalJSon | 9718 ns/op | 6867 B/op 8 allocs/op |
BenchmarkRepositoryEasyUnmarshaljson | 13996 ns/op | 4128 B/op 86 allocs/op |
BenchmarkRepositoryMarshalProto | 8302 ns/op | 4208 B/op 8 allocs/op |
BenchmarkRepositoryUnmarshalProto | 9357 ns/op | 5968 B/op 94 allocs/op |
BenchmarkRepositoryMarshalProto | 8302 ns/op | 4208 B/op 8 allocs/op |
BenchmarkRepositoryUnmarshalProto | 9357 ns/op | 5968 B/op 94 allocs/op |
BenchmarkRepositoryFasterMarshalproto | 1705 ns/op | 4096 B/op 1 allocs/op |
BenchmarkRepositoryFasterUnmarshalproto |
3894 ns/op | 4784 B/op 89 allocs/op |
Як бачимо, результати стали кращими.
Вже після написання статті дізнався про інструмент, який мені потрібнен — protoc-gen-gogofaster . Сподіваюсь, цей простий мікробенчмарк стані корисним, коли захочете мігрувати з JSON-у на Protobuf, а також зможете його використовувати як шаблон для своїх досліджень. У цій статті мені вдалось поєднати дві речі: обмін досвідом та можливість краще розібратись з інструментами.
Опубліковано: 13/05/20 @ 10:00
Розділ Різне
Рекомендуємо:
Essential Factors to Consider When Writing an Essay
User Acceptance Testing: як організувати процес менеджеру
It is time for you waiting to quit and begin to work difficult to increase your publishing that is educational.
Як і навіщо IT-фахівці розвивають українськомовний YouTube
C++ дайджест #27: Continuous Integration