Pull to refresh
50.68

Сериализация данных или диалектика общения: простая сериализация

Reading time 13 min
Views 13K
image Доброго времени суток, уважаемые. В данной статье мы рассмотрим наиболее популярные форматы сериализации данных и проведем с ними небольшое тестирование. Это первая статья на тему сериализации данных и в ней мы рассмотрим простые сериализаторы, которые не требуют от разработчика больших изменений в коде для их интеграции.

Рано или поздно, но вы, как и наша компания, можете столкнуться с ситуацией, когда количество используемых в вашем продукте сервисов, резко возрастает, да и все они к тому же оказываются очень «говорливыми». Произошло ли это из-за перехода на «хайповую» нынче микросервисную архитектуру или вы просто получили пачку заказов на небольшие доработки и реализовали их кучкой сервисов — неважно. Важно то, что начиная с этого момента, ваш продукт обзавелся двумя новыми проблемами — что делать с увеличившимся количеством данных, гоняемых между отдельными сервисами, и как не допустить хаоса при разработке и поддержке такого количества сервисов. Немного поясню про вторую проблему: когда количество ваших сервисов вырастает до сотни или более, их уже не может разрабатывать и сопровождать одна команда разработчиков, следовательно, вы раздаете пачки сервисов разным командам. И тут главное, чтобы все эти команды использовали один формат для своих RPC, иначе вы столкнетесь с такими классическими проблемами, когда одна команда не может поддерживать сервисы другой или просто два сервиса не стыкуются между собой без обильного уплотнения места стыка костылями. Но об этом мы поговорим в отдельной статье, а сегодня мы обратим внимание на первую проблему возросших данных и подумаем, что мы можем с этим сделать. А делать нам в силу нашей православной лени ничего не хочется, а хочется добавить пару строчек в общий код и получить сразу профит. С этого мы и начнем в данной статье, а именно — рассмотрим сериализаторы, встраивание которых не требует больших изменений в нашем прекрасном RPC.

Вопрос формата, на самом деле, для нашей компании довольно болезненный, потому как наши текущие продукты для обмена информацией между компонентами используют xml-формат. Нет, мы не мазохисты, мы прекрасно понимаем, что использовать xml для обмена данными стоило лет 10 назад, но в этом как раз и причина — продукту уже 10 лет, и он содержит много legacy-архитектурных решений, которые довольно трудно быстро «выпилить». Немного поразмыслив и похоливарив, мы решили, что будем использовать JSON для хранения и передачи данных, но нужно выбрать какой-то из вариантов упаковки JSON, так как для нас критичен размер передаваемых данных (ниже я поясню, почему так).

Мы накидали список критериев, по которым будем выбирать подходящий нам формат:

  • Эффективность компрессии данных. Наш продукт будет обрабатывать огромное количество событий на вход из различных источников. Каждое событие вызвано некоторыми действиями пользователя. В основном, события небольшие и содержат метаинформацию о происходящем — послал письмо, чатнул что-то в Facebook и т.д. — но могут также содержать и данные, причем немаленького размера. Кроме того, количество таких событий очень большое, в сутки легко может быть передано несколько десятков ТБ, поэтому экономия размера событий критически важна для нас.

  • Возможность работы из различных языков. Поскольку наш новый проект написан с использованием C++, PHP и JS, нас интересовала поддержка только этих языков, но с учетом того, что микросервисная архитектура допускает гетерогенность среды разработки, поддержка дополнительных языков будет кстати. Скажем, для нас довольно интересен язык go, и не исключено, что часть сервисов будет реализовано на нем.

  • Поддержка версионности/эволюционирования структуры данных. Так как наши продукты довольно долго живут без обновления у клиентов (процесс обновления совсем не прост), то в какой-то момент на поддержке будет слишком много разных версий, и важно, чтобы мы смогли легко развивать формат хранения, не теряя при этом совместимость с уже упакованными данными.

  • Простота использования. У нас есть опыт использования протокола Thrift для построения общения между компонентами. Честно говоря, разработчикам не всегда просто разобраться, как работает RPC и как добавить что-то в уже существующий код, не сломав ничего в старом. Поэтому чем проще будет использовать формат сериализации, тем лучше, так как уровень С++ разработчика и JS разработчика в таких вещах совсем разный :)

  • Возможность произвольного чтения данных (Random-access reads/writes). Так как мы подразумеваем использование выбранного формата и для хранения данных, то было бы здорово, если бы он поддерживал возможность частичной десериализации данных, чтобы не вычитывать каждый раз весь объект, который зачастую бывает совсем не маленьким. Кроме чтения данных, большим плюсом была бы возможность изменения данных без вычитки всего содержимого.

Проанализировав приличное количество вариантов, мы отобрали для себя таких кандидатов:

  1. JSON
  2. BSON
  3. Message Pack
  4. Cbor

Данные форматы не требуют описание IDL схемы передаваемых данных, а содержат схему данных внутри себя. Это сильно упрощает работу и позволяет в большинстве случаев добавить поддержку, написав не более 10 строчек кода.

Также мы прекрасно отдаем себе отчет, что некоторые факторы протокола или сериализатора сильно зависят от его реализации. То, что отлично пакует на C++, может плохо паковать на Javascript. Поэтому для наших экспериментов будем использовать реализации для JS и Go и будем гонять тесты. JS реализацию для верности будем гонять в браузере и на nodejs.

Итак, приступим к рассмотрению.

JSON


Самый простой из рассматриваемых нами форматов взаимодействия. При сравнении других форматов мы будем использовать его как эталонный, так как в текущих наших проектах он и показал свою эффективность, и проявил все свои минусы.

Плюсы:

  • Поддерживает почти все необходимые нам типы данных. Можно было бы придраться к отсутствию поддержки бинарных данных, но тут можно обойтись base64.
  • Легко читаем человеком, что дает простоту отладки
  • Поддерживается кучей языков (хотя те, кто использовал JSON в Go поймут, что тут я лукавлю)
  • Можно реализовать версионирование через JSON Scheme

Минусы:

  • Несмотря на компактность JSON по сравнению с хml, в нашем проекте, где за сутки передаются гигабайты данных, он все же довольно расточителен для каналов и для хранения данных в нем. Единственный плюс нативного JSON нам видится только в использовании для хранения PostgreSQL (с её возможностями работы с jsob).
  • Нет поддержки частичной десериализации данных. Чтобы достать что-то из середины JSON-файла, придется сначала десериализовать все, что идет перед нужным полем. Также это не позволяет использовать формат для stream-обработки, что может быть полезно при сетевом взаимодействии.

Давайте посмотрим что у нас с производительностью. При рассмотрении мы сразу постараемся учесть недостаток JSON в его размере и сделаем тесты с запаковкой JSON с помощью zlib. Для тестов мы будем использовать следующие библиотеки:


Исходники и все результаты тестов вы можете найти по следующим ссылкам:

Go — https://github.com/KyKyPy3/serialization-tests
JS (node) — https://github.com/KyKyPy3/js-serialization-tests
JS (browser) — http://jsperv.com/serialization-benchmarks/5

Опытным путем мы установили, что данные для тестов нужно брать как можно более приближенные к реальным, потому что результаты тестов с разными тестовыми данными различаются кардинально. Так что если вам важно не промахнутся с форматом, всегда тестируйте его на наиболее приближенных к вашим реалиям данных. Мы же будем тестировать на данных, приближенных к нашим реалиям. Вы можете посмотреть на них в исходниках тестов.

Вот что мы получили для JSON по скорости. Ниже приведены результаты бенчмарков для соответствующих языков:
JS (Node)
Json encode 21,507 ops/sec (86 runs sampled)
Json decode 9,039 ops/sec (89 runs sampled)
Json roundtrip 6,090 ops/sec (93 runs sampled)
Json compres encode 1,168 ops/sec (84 runs sampled)
Json compres decode 2,980 ops/sec (93 runs sampled)
Json compres roundtrip 874 ops/sec (86 runs sampled)

JS (browser)
Json roundtrip 5,754 ops/sec
Json compres roundtrip 890 ops/sec

Go
Json encode 5000 391100 ns/op 24.37 MB/s 54520 B/op 1478 allocs/op
Json decode 3000 392785 ns/op 24.27 MB/s 76634 B/op 1430 allocs/op
Json roundtrip 2000 796115 ns/op 11.97 MB/s 131150 B/op 2908 allocs/op
Json compres encode 3000 422254 ns/op 0.00 MB/s 54790 B/op 1478 allocs/op
Json compres decode 3000 464569 ns/op 4.50 MB/s 117206 B/op 1446 allocs/op
Json compres roundtrip 2000 881305 ns/op 0.00 MB/s 171795 B/op 2915 allocs/op

А вот что получили по размерам данных:
JS (Node)
Json 9482 bytes
Json compressed 1872 bytes

JS (Browser)
Json 9482 bytes
Json compressed 1872 bytes

На данном этапе можно сделать вывод, что хоть сжатие JSON и дает отличный результат, потеря в скорости обработки просто катастрофическая. Еще один вывод: с JSON прекрасно работает JS, чего нельзя сказать, например, про go. Не исключено, что обработка JSON в других языках покажет результаты не сравнимые с JS. Пока откладываем результаты JSON в сторону и смотрим, как будет с другими форматами.

BSON


Этот формат данных пришел из MongoDb и активно ими продвигается. Формат изначально был разработан для хранения данных и не предполагался для их передачи по сети. Честно говоря, после недолгих поисков в интернете мы не нашли ни одного серьезного продукта, использующего внутри себя BSON. Но давайте посмотрим, что нам может дать данный формат.

Плюсы:

  • Поддержка дополнительных типов данных.
    Согласно спецификации формат BSON, помимо стандартных типов данных формата JSON, BSON поддерживает еще такие типы как Date, ObjectId, Null и бинарные данные (Binary data). Некоторые из них (например, ObjectId) чаще используются в MongoDb и не всегда могут быть полезны другим. Но некоторые дополнительные типы данных дают нам следующие бонусы. Если мы храним в нашем объекте дату, то в случае формата JSON у нас есть только один вариант хранения — это один из вариантов ISO-8601, и в строковом представлении. При этом, если мы хотим отфильтровать нашу коллекцию JSON-объектов по датам, при обработке нам нужно будет превратить строки в формат Date и только после этого их сравнивать между собой.
BSON же хранит все даты как Int64 (так же, как и тип Date) и берет на себя всю работу по сериализации/десериализации в формат Date. Поэтому мы можем сравнивать даты без десериализации — просто как числа, что явно быстрее, чем вариант с классическим JSON. Именно это преимущество активно используется в MongoDb.

  • BSON поддерживает так называемый Random read/write к своим данным.
    BSON хранит длины для строк и бинарных данных, позволяя пропускать атрибуты, которые нам не интересны. JSON же последовательно считывает данные и не может попускать элемент, не прочитав его значение до конца. Таким образом, если мы будем хранить большие объемы бинарных данных внутри формата, данная особенность может сыграть для нас важную роль.

Минусы:

  • Размер данных.
    Что касается размера конечного файла, то тут все неоднозначно. В каких то ситуациях размер объекта будет меньше, а в каких-то — больше, все зависит от того, что лежит внутри Bson объекта. Почему так получается — нам ответит спецификация, в которой сказано, что для скорости доступа к элементам объекта формат сохраняет дополнительную информацию, такую как размер данных для больших элементов.

Так например JSON объект

{«hello": "world»}

превратится вот в такое:

\x16\x00\x00\x00                  // total document size
\x02                               // 0x02 = type String
hello\x00                          // field name
\x06\x00\x00\x00world\x00          // field value
\x00                               // 0x00 = type EOO ('end of object')

В спецификации сказано, что BSON разрабатывался, как формат с быстрой сериализацией / десериализацией, как минимум, за счет того, что числа он хранит как тип Int, и не тратит время на парсинг их из строки. Давайте проверим. Для тестирования нами были взяты следующие библиотеки:


И вот какие результаты мы получили (для наглядности я добавил также и результаты для JSON):
JS (Node)
Json encode 21,507 ops/sec (86 runs sampled)
Json decode 9,039 ops/sec (89 runs sampled)
Json roundtrip 6,090 ops/sec (93 runs sampled)
Json compres encode 1,168 ops/sec (84 runs sampled)
Json compres decode 2,980 ops/sec (93 runs sampled)
Json compres roundtrip 874 ops/sec (86 runs sampled)
Bson encode 93.21 ops/sec (76 runs sampled)
Bson decode 242 ops/sec (84 runs sampled)
Bson roundtrip 65.24 ops/sec (65 runs sampled)

JS (browser)
Json roundtrip 5,754 ops/sec
Json compres roundtrip 890 ops/sec
Bson roundtrip 374 ops/sec

Go
Json encode 5000 391100 ns/op 24.37 MB/s 54520 B/op 1478 allocs/op
Json decode 3000 392785 ns/op 24.27 MB/s 76634 B/op 1430 allocs/op
Json roundtrip 2000 796115 ns/op 11.97 MB/s 131150 B/op 2908 allocs/op
Json compres encode 3000 422254 ns/op 0.00 MB/s 54790 B/op 1478 allocs/op
Json compres decode 3000 464569 ns/op 4.50 MB/s 117206 B/op 1446 allocs/op
Json compres roundtrip 2000 881305 ns/op 0.00 MB/s 171795 B/op 2915 allocs/op
Bson Encode 10000 249024 ns/op 40.42 MB/s 70085 B/op 982 allocs/op
Bson Decode 3000 524408 ns/op 19.19 MB/s 124777 B/op 3580 allocs/op
Bson Roundtrip 2000 712524 ns/op 14.13 MB/s 195334 B/op 4562 allocs/op

А вот что получили по размерам данных:
JS (Node)
Json 9482 bytes
Json compressed 1872 bytes
Bson 112710 bytes

JS (Browser)
Json 9482 bytes
Json compressed 1872 bytes
Bson 9618 bytes

Хоть BSON и дает нам возможность дополнительных типов данных и, что самое главное, возможности частичного чтения / изменения данных, в плане компрессии данных у него все совсем печально, поэтому мы вынуждены продолжить поиски дальше.

Message Pack


Следующий формат, который попал на наш стол, это Message Pack. Данный формат довольно популярен последнее время и лично я о нем узнал, когда ковырялся с tarantool.

Если заглянуть на сайт формата, то можно:

  • Узнать, что формат активно используется такими продуктам как redis и fluentd, что внушает доверии к нему.
  • Увидеть громкую надпись It’s like JSON. but fast and small

Придется проверить, насколько это правда, но сначала давайте посмотрим, что же нам предлагает формат.

По традиции начнем с плюсов:

  • Формат полностью совместим с JSON
    При конвертации данных из MessagePack в JSON мы не потеряем данные, чего нельзя сказать, например, про формат BSON. Правда, есть ряд ограничений, накладываемых на различные типы данных:

    1. Значение типа Integer ограничено от -(263) до (264)–1;
    2. Максимальная длина бинарного объекта (232)–1;
    3. Максимальный размер байт строки (232)–1;
    4. Максимальное количество элементов в массиве не больше (232)–1;
    5. Максимальное количество элементов в ассоциативном массиве не больше (232)–1;

  • Довольно неплохо жмет данные.
    Например, {«a»:1,«b»:2} занимает 13 байт в JSON, 19 байт в BSON и всего лишь 7 байт в MessagePack, что довольно неплохо.
  • Есть возможность расширять поддерживаемые типы данных.
    MsgPack позволяет расширять его систему типов собственными. Так как тип в MsgPack кодируется числом, а значения от –1 до –128 зарезервированы форматом (об этом сказано в спецификации формата), то для использования доступны значения от 0 до 127. Поэтому мы можем добавлять расширения, которые будут указывать на наши собственные типы данных.
  • Имеет поддержку у огромного количества языков.
  • Есть RPC пакет (но это не так важно для нас).
  • Можно использовать streaming API.

Минусы:

  • Не поддерживает частичное изменение данных.
    В отличие от формата BSON, даже при условии, что MsgPack хранит размеры каждого поля, изменять в нем данные частично не получится. Предположим, что у нас есть сериализованное представление JSON {«a»:1, «b»:2}. Bson использует для хранения значения ключа ‘a’ 5 байт, что позволит нам изменить значение с 1 на 2000 (занимает 3 байта) без проблем. А вот MessagePack для хранения использует 1 байт, и так как 2000 занимает 3 байта, то без сдвига данных о параметре ‘b’ мы не можем изменить значение параметра ‘a’.

Теперь давайте посмотрим, насколько он производительный и как же он сжимает данные. Для тестов использовались следующие библиотеки:


Результаты мы получили следующие:
JS (Node)
Json encode 21,507 ops/sec (86 runs sampled)
Json decode 9,039 ops/sec (89 runs sampled)
Json roundtrip 6,090 ops/sec (93 runs sampled)
Json compres encode 1,168 ops/sec (84 runs sampled)
Json compres decode 2,980 ops/sec (93 runs sampled)
Json compres roundtrip 874 ops/sec (86 runs sampled)
Bson encode 93.21 ops/sec (76 runs sampled)
Bson decode 242 ops/sec (84 runs sampled)
Bson roundtrip 65.24 ops/sec (65 runs sampled)
MsgPack encode 4,758 ops/sec (79 runs sampled)
MsgPack decode 2,632 ops/sec (91 runs sampled)
MsgPack roundtrip 1,692 ops/sec (91 runs sampled)

JS (browser)
Json roundtrip 5,754 ops/sec
Json compres roundtrip 890 ops/sec
Bson roundtrip 374 ops/sec
MsgPack roundtrip 1,048 ops/sec

Go
Json encode 5000 391100 ns/op 24.37 MB/s 54520 B/op 1478 allocs/op
Json decode 3000 392785 ns/op 24.27 MB/s 76634 B/op 1430 allocs/op
Json roundtrip 2000 796115 ns/op 11.97 MB/s 131150 B/op 2908 allocs/op
Json compres encode 3000 422254 ns/op 0.00 MB/s 54790 B/op 1478 allocs/op
Json compres decode 3000 464569 ns/op 4.50 MB/s 117206 B/op 1446 allocs/op
Json compres roundtrip 2000 881305 ns/op 0.00 MB/s 171795 B/op 2915 allocs/op
Bson Encode 10000 249024 ns/op 40.42 MB/s 70085 B/op 982 allocs/op
Bson Decode 3000 524408 ns/op 19.19 MB/s 124777 B/op 3580 allocs/op
Bson Roundtrip 2000 712524 ns/op 14.13 MB/s 195334 B/op 4562 allocs/op
MsgPack Encode 5000 306260 ns/op 27.36 MB/s 49907 B/op 968 allocs/op
MsgPack Decode 10000 214967 ns/op 38.98 MB/s 59649 B/op 1690 allocs/op
MsgPack Roundtrip 3000 547434 ns/op 15.31 MB/s 109754 B/op 2658 allocs/op

А вот что получили по размерам данных:
JS (Node)
Json 9482 bytes
Json compressed 1872 bytes
Bson 112710 bytes
MsgPack 7628 bytes

JS (Browser)
Json 9482 bytes
Json compressed 1872 bytes
Bson 9618 bytes
MsgPack 7628 bytes

Конечно, MessagePack не так круто жмет данные, как нам бы хотелось, но по крайней мере, ведет себя довольно стабильно как в JS, так и в Go. Пожалуй, на текущий момент это наиболее привлекательный кандидат для наших задач, но осталось рассмотреть нашего последнего пациента.

Cbor


Честно говоря, формат очень похож на MessagePack по своим возможностям, и складывается впечатление, что формат разрабатывался как замена MessagePack. В нем также есть поддержка расширения типов данных и полная совместимость с JSON. Из различий я заметил только поддержку массивов/строк произвольной длины, но, на мой взгляд, это очень странная фича. Если Вы хотите узнать больше про данный формат, то по нему была отличная статья на Хабре — habrahabr.ru/post/208690. Ну а мы посмотрим, как у Cbor с производительностью и сжатием данных.

Для тестов были использованы следующие библиотеки:


И, конечно же, вот финальные результаты наших тестов с учетом всех рассматриваемых форматов:
JS (Node)
Json encode 21,507 ops/sec ±1.01% (86 runs sampled)
Json decode 9,039 ops/sec ±0.90% (89 runs sampled)
Json roundtrip 6,090 ops/sec ±0.62% (93 runs sampled)
Json compres encode 1,168 ops/sec ±1.20% (84 runs sampled)
Json compres decode 2,980 ops/sec ±0.43% (93 runs sampled)
Json compres roundtrip 874 ops/sec ±0.91% (86 runs sampled)
Bson encode 93.21 ops/sec ±0.64% (76 runs sampled)
Bson decode 242 ops/sec ±0.63% (84 runs sampled)
Bson roundtrip 65.24 ops/sec ±1.27% (65 runs sampled)
MsgPack encode 4,758 ops/sec ±1.13% (79 runs sampled)
MsgPack decode 2,632 ops/sec ±0.90% (91 runs sampled)
MsgPack roundtrip 1,692 ops/sec ±0.83% (91 runs sampled)
Cbor encode 1,529 ops/sec ±4.13% (89 runs sampled)
Cbor decode 1,198 ops/sec ±0.97% (88 runs sampled)
Cbor roundtrip 351 ops/sec ±3.28% (77 runs sampled)

JS (browser)
Json roundtrip 5,754 ops/sec ±0.63%
Json compres roundtrip 890 ops/sec ±1.72%
Bson roundtrip 374 ops/sec ±2.22%
MsgPack roundtrip 1,048 ops/sec ±5.40%
Cbor roundtrip 859 ops/sec ±4.19%

Go
Json encode 5000 391100 ns/op 24.37 MB/s 54520 B/op 1478 allocs/op
Json decode 3000 392785 ns/op 24.27 MB/s 76634 B/op 1430 allocs/op
Json roundtrip 2000 796115 ns/op 11.97 MB/s 131150 B/op 2908 allocs/op
Json compres encode 3000 422254 ns/op 0.00 MB/s 54790 B/op 1478 allocs/op
Json compres decode 3000 464569 ns/op 4.50 MB/s 117206 B/op 1446 allocs/op
Json compres roundtrip 2000 881305 ns/op 0.00 MB/s 171795 B/op 2915 allocs/op
Bson Encode 10000 249024 ns/op 40.42 MB/s 70085 B/op 982 allocs/op
Bson Decode 3000 524408 ns/op 19.19 MB/s 124777 B/op 3580 allocs/op
Bson Roundtrip 2000 712524 ns/op 14.13 MB/s 195334 B/op 4562 allocs/op
MsgPack Encode 5000 306260 ns/op 27.36 MB/s 49907 B/op 968 allocs/op
MsgPack Decode 10000 214967 ns/op 38.98 MB/s 59649 B/op 1690 allocs/op
MsgPack Roundtrip 3000 547434 ns/op 15.31 MB/s 109754 B/op 2658 allocs/op
Cbor Encode 20000 71203 ns/op 117.48 MB/s 32944 B/op 12 allocs/op
Cbor Decode 3000 432005 ns/op 19.36 MB/s 40216 B/op 2159 allocs/op
Cbor Roundtrip 3000 531434 ns/op 15.74 MB/s 73160 B/op 2171 allocs/op

А вот что получили по размерам данных:
JS (Node)
Json 9482 bytes
Json compressed 1872 bytes
Bson 112710 bytes
MsgPack 7628 bytes
Cbor 7617 bytes


JS (Browser)
Json 9482 bytes
Json compressed 1872 bytes
Bson 9618 bytes
MsgPack 7628 bytes
Cbor 7617 bytes

Комментарии, я думаю, тут излишни, все прекрасно видно из результатов — CBor оказался самым медленным форматом.

Выводы


Какие выводы мы сделали из этого сравнения? Немного подумав и посмотрев на результаты, мы пришли к выводу, что нас не удовлетворил ни один из форматов. Да, MsgPack показал себя совсем неплохим вариантом: он прост в использовании и довольно стабилен, но посоветовавшись с коллегами, мы решили свежее взглянуть на другие бинарные форматы данных, не на основе JSON: Protobuf, FlatBuffers, Cap’n proto и avro. О том, что у нас получилось и что же в конечном итоге мы выбрали, расскажем в следующей статье.

Автор: Роман Ефременко KyKyPy3uK
Tags:
Hubs:
+17
Comments 24
Comments Comments 24

Articles

Information

Website
www.infowatch.ru
Registered
Founded
Employees
201–500 employees
Location
Россия