Серіалізація даних або діалектика спілкування: проста серіалізація

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
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.