Сериализация: JSON, Protobuf и война форматов

Сериализация: JSON, Protobuf и война форматов

Каждый запрос к серверу, каждое сообщение в очереди, каждая строка лога проходит один и тот же ритуал. Структура данных, живущая в памяти процесса, превращается в последовательность байт, путешествует по сети или ложится на диск, а затем на другом конце восстанавливается в новую структуру. Этот ритуал называется сериализацией, и от того, как именно он устроен, зависит почти всё: пропускная способность сервиса, счёт за трафик, скорость инструментов разработчика, возможность безболезненно выкатить новую версию протокола в среду из сотни сервисов.

JSON победил в интернете, но не в дата-центрах. Protobuf царит в микросервисах Google и Stripe, но плохо переживает встречу с обычным curl. FlatBuffers умеет читать данные напрямую из памяти без единого вызова malloc, но требует от инженера дисциплины, к которой привыкли далеко не все. CSV не имеет ни типов, ни схемы, ни внятного экранирования, но переживёт нас всех, потому что открывается в Excel. История форматов сериализации — это история компромиссов, и понимать эти компромиссы инженеру важнее, чем знать конкретный синтаксис.

В этом посте я разбираю основные форматы по порядку: от древних бинарных протоколов телекома до колоночных форматов современной аналитики. Цель — не выбрать лучший, а показать, какие задачи решает каждый из них и почему «универсального» формата не существует.

1. Что такое сериализация и какие у неё критерии

Сериализация — это преобразование структуры данных в линейную последовательность байт, пригодную для передачи или хранения. Обратная операция называется десериализацией. Без неё распределённые системы вообще невозможны: процесс не может «передать объект» по сети, он может передать только байты, а как эти байты интерпретировать, должны заранее договориться обе стороны.

У задачи сериализации есть несколько граней, и формат всегда оптимизируется по одним за счёт других:

  • читаемость человеком. Можно ли открыть пакет в редакторе и понять, что внутри?
  • объём передаваемых данных. Сколько байт уходит за один запрос?
  • скорость кодирования и декодирования. Сколько процессорных циклов уходит на один мегабайт?
  • наличие схемы. Проверяется ли структура заранее или только при чтении?
  • эволюция схемы. Что происходит, когда продюсер и консьюмер запущены в разных версиях?
  • прямое чтение из буфера. Можно ли работать с данными, не копируя их в новые объекты?
  • инструментарий. Есть ли удобные библиотеки в нужных языках, есть ли отладочные утилиты?
  • детерминированность. Получим ли мы байт-в-байт одинаковый результат при повторной сериализации?

Ни один формат не выигрывает по всем критериям сразу. Дальше я буду возвращаться к этой шкале, чтобы было видно, какие компромиссы делает каждый герой повествования.

2. Эпоха до JSON: ASN.1 и серьёзные бинарные протоколы

Может показаться, что бинарная сериализация — изобретение последних пятнадцати лет, ответ индустрии на проблемы JSON. Это не так. Задача упаковки структурированных данных в байты решалась задолго до появления веба, и решалась всерьёз.

ASN.1 (Abstract Syntax Notation One) появился в 1984 году в стандартах CCITT, ныне ITU-T. Это даже не формат, а язык описания схем, у которого есть несколько разных правил кодирования: BER (Basic Encoding Rules), DER (Distinguished Encoding Rules), PER (Packed Encoding Rules). Сама схема выглядит так:

Person ::= SEQUENCE {
    name      UTF8String,
    age       INTEGER (0..150),
    email     UTF8String OPTIONAL,
    isActive  BOOLEAN DEFAULT TRUE
}

Описание выглядит сухо и формально, но именно ASN.1 до сих пор работает в местах, о которых обычный веб-разработчик не задумывается. Сертификаты X.509, на которых стоит весь TLS, кодируются в DER. Протоколы SNMP, LDAP, Kerberos, сигнализация GSM, LTE и 5G, банковские EMV-карты, биометрические паспорта — всё это ASN.1.

Особенно показателен случай DER. Это правило кодирования специально сделано детерминированным: для одной и той же логической структуры существует ровно один корректный набор байт. Без этого свойства не было бы цифровых подписей. Если бы две корректные сериализации отличались хотя бы одним байтом, подпись от первой не сошлась бы для второй. Это первый важный урок: бинарный формат может давать гарантии, в принципе невозможные в текстовом формате.

Главное, что стоит унести из секции про ASN.1: проблемы эволюции схем, бинарной совместимости, опциональных полей и значений по умолчанию люди начали решать в восьмидесятых. Когда в нулевых появлялись Thrift и Protobuf, они во многом переоткрывали те же идеи, но с человеческим синтаксисом и нормальным инструментарием.

3. XML: когда человечество переусложнило текст

К девяностым в индустрии сложились две идеи. Первая: бинарные протоколы вроде ASN.1 или CORBA слишком сложны и слишком закрыты. Вторая: HTML показал, что разметку можно использовать для чего угодно. На пересечении этих идей вырос XML, и какое-то время казалось, что он станет универсальным языком обмена данными.

XML силён несколькими свойствами. Он самодокументированный: открыв файл, видишь иерархию и имена. У него есть пространства имён, которые позволяют смешивать словари из разных стандартов. У него есть XSD — язык схем, по которому можно валидировать документ. Есть XPath и XSLT для запросов и трансформаций. На XML построены SOAP, WSDL, XML-RPC и десятки enterprise-стандартов: SEPA в банках, HL7 в медицине, XBRL в финансовой отчётности.

И всё же XML стал почти ругательным словом, и причину можно понять на простом примере. Один и тот же объект:

<user>
  <id>42</id>
  <name>Алиса</name>
  <isActive>true</isActive>
  <roles>
    <role>admin</role>
    <role>editor</role>
  </roles>
</user>
{
  "id": 42,
  "name": "Алиса",
  "isActive": true,
  "roles": ["admin", "editor"]
}

XML занимает примерно вдвое больше байт. Но дело не только в размере. В XML нет различия между «массив из одного элемента» и «один элемент»: чтобы понять, что <role> повторяется, нужно смотреть в схему. Атрибуты и элементы решают примерно одну и ту же задачу разными способами, и команды годами спорили, что лучше. SOAP-конверты добавляли несколько слоёв обёрток, и десять полезных байт данных обрастали килобайтом служебной разметки. Парсеры были уязвимы к атакам типа billion laughs и XXE.

К середине нулевых индустрия устала. Новое поколение сервисов искало что-то более лёгкое, и оно лежало уже под рукой.

4. JSON: формат, который победил в вебе

JSON формализовал Дуглас Крокфорд в 2001 году, описав на одной странице подмножество синтаксиса литералов JavaScript. В этом и состоит главный фокус: JSON не был чем-то революционным, он просто зафиксировал то, что уже работало в браузере. Объект, массив, число, строка, булев, null. Никаких атрибутов, никаких пространств имён, никаких схем по умолчанию.

JSON выиграл не потому, что был лучшим. Он выиграл потому, что был достаточно хорошим и идеально совпал с эпохой. AJAX-запросы возвращали данные напрямую в JavaScript, REST-архитектура заменяла громоздкий SOAP, фронтенд массово писали на JS, и формат, родной для JS, экономил всем кучу преобразований. Когда серверы на Python, Ruby и Java начали отдавать JSON, оказалось, что и парсить его в этих языках тоже легко, и читать глазами приятно.

Сильные стороны JSON очевидны: минимальная когнитивная нагрузка, мгновенная отладка через браузерные DevTools, тривиальная интеграция с любым HTTP-клиентом. Слабые стороны не менее реальны, и их полезно перечислить отдельно.

Где JSON ломается

Числа. Спецификация не оговаривает диапазон и точность чисел. На практике большинство парсеров используют 64-битный double, и целые числа больше 2⁵³ теряют точность. Twitter в 2011 году пришлось добавлять id_str рядом с id, потому что новые идентификаторы твитов перестали помещаться в JS-число.

Бинарные данные. Формат текстовый, поэтому байты приходится кодировать в base64. Это +33 % к размеру плюс циклы CPU на оба преобразования.

Дублирование ключей. Каждый объект массива из миллиона элементов везёт с собой полные имена своих полей. На потоке это превращается в гигабайты избыточных строк.

Отсутствие схемы. Без внешнего контракта (JSON Schema, OpenAPI) клиент и сервер свободно расходятся в трактовке полей. Поле, в котором «обычно строка, но иногда null или массив», — классическая боль продакшен-логов.

Стоимость парсинга. Об этом стоит поговорить отдельно.

Почему парсинг JSON дорог

Декодирование JSON — это не «прочитать байты», а целая цепочка операций. Парсер должен пройти UTF-8 валидацию, найти границы токенов, экранировать строки, аллоцировать в куче новые объекты под каждое значение, построить дерево. Для языков с garbage collector это превращается в серьёзное давление на сборщик: миллионы маленьких строк живут ровно один запрос и тут же становятся мусором.

Именно поэтому в последние годы появились библиотеки, делающие JSON-парсинг быстрее на порядок. Главная из них — simdjson Дэниела Лемира и Джеффа Лэнгдейла. Она использует SIMD-инструкции современных процессоров (AVX2, AVX-512, NEON), чтобы обрабатывать сразу 32 или 64 байта за такт, и в режиме On-Demand достигает скорости 7 ГБ/с на одном ядре. Архитектурно это два этапа: сначала выделяются структурные символы ({, }, [, ], :, ,), потом по этой разметке строится «лента» (tape) с указателями на значения. Похожие подходы используют Go-порт simdjson-go, китайский sonic от ByteDance, Rust-овский simd-json. ClickHouse, Node.js, Meta* Velox и ещё десятки систем уже встроили simdjson внутрь.

Это важный сдвиг в спорах о форматах. Долгое время JSON воспринимали как медленный по определению. Сегодня корректнее говорить, что JSON медленный в наивных реализациях, но при должном инженерном внимании текстовый формат можно разогнать до скоростей, сравнимых с бинарными. Память, правда, всё равно расходуется на построение объектов, и здесь бинарные форматы остаются впереди.

5. Когда экономия размера превращается в деньги

Прежде чем переходить к Protobuf, стоит остановиться на вопросе, который часто задают: ну хорошо, JSON чуть больше, чуть медленнее, а на практике это вообще важно?

Возьмём типичный объект:

{"userId":123,"premium":true,"tier":"gold","since":1700000000}

В JSON это 60 байт. В Protobuf тот же объект уместится в 12–15 байт: имена полей заменяются числовыми тегами по 1 байту, целые числа кодируются варинтами переменной длины, булевы значения занимают 1 байт. Разница примерно в четыре раза.

На одном запросе разница незаметна. Но представим сервис, который обслуживает 100 тысяч запросов в секунду. Это 8,6 миллиарда запросов в сутки. Экономия 45 байт на каждом — это 387 ГБ исходящего трафика в день. В облачных тарифах исходящий трафик стоит порядка 0,08–0,12 долларов за гигабайт, то есть около 30–45 долларов в сутки только на одном эндпоинте. Прибавьте к этому экономию на CPU при сериализации, на TLS-шифровании меньшего объёма, на сетевых задержках при передаче меньших пакетов. На масштабе крупного сервиса 20 % экономии размера действительно превращаются в миллионы долларов в год. Atlassian, например, в 2024 году публично рассказывал, как переход с JSON на Protobuf во внутренних API сократил трафик и латентность.

На одном запросе разница между JSON и Protobuf незаметна. На миллиардах запросов те же лишние байты и лишние циклы превращаются в реальные счета за трафик, в дополнительные сервера под нагрузкой и в миллисекунды задержки, видимые пользователю. Бинарные форматы существуют именно ради этой арифметики масштаба, а не ради эстетики.

6. Protocol Buffers: прагматичная инженерия Google

Protocol Buffers, или Protobuf, появился внутри Google в начале нулевых и был открыт миру в 2008 году. Это формат, описанный схемой, с компактным бинарным представлением и кодогенерацией под десятки языков. Сегодня Protobuf — это де-факто стандарт для внутренних API в крупных компаниях и фундамент gRPC.

Идея Protobuf начинается с файла схемы:

syntax = "proto3";

message User {
  int32  id    = 1;
  string name  = 2;
  string email = 3;
  bool   active = 4;
}

Эта схема компилируется в код на нужном языке: классы, билдеры, сериализаторы. Программист никогда не пишет байты руками, он работает с обычными объектами своего языка.

Почему номера полей важнее имён

Главная идея, которую нужно усвоить про Protobuf: в передаваемых байтах живут не имена, а числовые теги. Когда сериализатор пишет поле name = 2, в байтах оказывается тег 2, а строка "name" не передаётся вовсе. Это даёт сразу две вещи. Во-первых, сообщение получается компактным: вместо повторения ключей по сто раз идут однобайтовые числа. Во-вторых, имя поля можно безопасно переименовать на любой стороне, ничего не сломается, пока номер тот же.

Именно поэтому в .proto-файлах принято обращаться с номерами как со священной коровой: однажды занятый номер нельзя переиспользовать под другое поле никогда. Если поле удалили, его номер записывают в reserved:

message User {
  reserved 3;
  reserved "email";

  int32  id    = 1;
  string name  = 2;
  bool   active = 4;
}

Без этой дисциплины старый клиент, отправивший email под номером 3, выстрелит в новый сервер, который теперь ждёт по этому номеру что-то совсем другое.

Варинты: почему «один» весит один байт

Целые числа в Protobuf кодируются варинтами (varint) — переменной длиной. Каждый байт варинта несёт 7 бит полезной нагрузки, а старший бит указывает, есть ли продолжение. Маленькие числа влезают в один байт, большие — в несколько. Возьмём число 150:

150 в двоичном виде:    10010110

Разбиваем по 7 бит:     0000001 | 0010110
Меняем порядок (LE):    0010110 | 0000001
Дописываем биты продолжения:
  Байт 1: 1 0010110   ← старший бит = 1, есть продолжение
  Байт 2: 0 0000001   ← старший бит = 0, конец
В hex: 96 01

Два байта вместо четырёх для обычного int32. Для большинства реальных значений (идентификаторы пользователей, длины строк, размеры коллекций) экономия получается двукратной или тройной.

С отрицательными числами есть тонкость. Тип int32 использует двоичное дополнение, и любое отрицательное число занимает все 10 байт варинта, потому что у него установлен старший бит. Для полей, где отрицательные значения возможны, нужно использовать sint32 или sint64, в которых работает зигзаг-кодирование: -1 → 1, 1 → 2, -2 → 3, 2 → 4 и так далее. Так модуль числа определяет длину варинта, а не его знак. Различие в десять раз: -1 в int32 весит 10 байт, в sint32 — один.

Эволюция схемы: настоящая суперсила

Бинарная плотность — приятный бонус, но в продакшене Protobuf ценят за другое. За эволюцию схемы.

В микросервисной архитектуре сервисы выкатываются по очереди, иногда часами или днями. В кластере одновременно живут разные версии и продьюсера, и консьюмера, причём в обе стороны: новый клиент стучится к старому серверу, старый клиент — к новому. Формат должен это переживать, не ломаясь.

Допустим, был сервис:

message User {
  int32  id   = 1;
  string name = 2;
}

Стало:

message User {
  int32  id    = 1;
  string name  = 2;
  string email = 3;
  Address addr = 4;
}

message Address {
  string city = 1;
  string zip  = 2;
}

Что произойдёт в смешанном кластере?

  • Новый сервер получает сообщение от старого клиента. В сообщении нет полей 3 и 4. Десериализатор просто оставляет их пустыми (значения по умолчанию). Никакой ошибки.
  • Старый сервер получает сообщение от нового клиента. В сообщении есть поля 3 и 4, о которых сервер не знает. Десериализатор сохраняет их как unknown fields и спокойно работает с известными ему полями. Если сервер потом пересылает сообщение дальше, неизвестные поля едут с ним без потерь.

Это и есть прямая (forward) и обратная (backward) совместимость. Она работает при двух обязательных условиях: номера полей не переиспользуются, а добавляемые поля делаются необязательными (в proto3 это поведение по умолчанию для всех скалярных полей).

Самым болезненным уроком индустрии стали required-поля. В proto2 был модификатор required, который требовал, чтобы поле всегда присутствовало. На бумаге это удобно, на практике — бомба замедленного действия: добавили required-поле в новую версию, выкатили один сервис, и все старые клиенты, отправляющие сообщение без этого поля, получают ошибку парсинга. Откатить изменение нельзя, потому что новые клиенты теперь шлют поле, и без него они не работают. В proto3 модификатор required убрали целиком, и это одно из самых обсуждаемых архитектурных решений в истории формата.

Где Protobuf неудобен

Платой за компактность и строгость становится несколько неприятных вещей:

  • схема обязательна. Чтобы прочитать .proto-сообщение, нужен .proto-файл. Без него вы получите набор пар (тег, тип, байты) без какой-либо семантики.
  • отладка тяжелее. tcpdump показывает мусор. Нужны специальные утилиты вроде protoscope, grpcurl, kreya.
  • версионная дисциплина. Команда, не соблюдающая правила про номера полей, ломает совместимость регулярно и незаметно.
  • слабая интеграция с браузером. В JavaScript-экосистеме Protobuf терпим, но всё ещё чувствуется как чужой. REST + JSON остаётся комфортнее для публичных API.

7. MessagePack: JSON, но бинарный

Не всем нужны строгие схемы и кодогенерация. Иногда просто хочется тот же JSON, но компактнее и быстрее. Эту нишу занимает MessagePack.

MessagePack сохраняет ту же модель данных, что и JSON: словари, массивы, числа, строки, булевы значения, null, но кодирует их не в текст, а в компактные бинарные тэги. Простой пример на Python:

import msgpack

data = {"id": 42, "name": "Алиса", "active": True}

packed = msgpack.packb(data)
# b'\x83\xa2id*\xa4name\xa5\xd0\x90\xd0\xbb\xd0\xb8\xd1\x81\xd0\xb0\xa6active\xc3'

unpacked = msgpack.unpackb(packed)
# {'id': 42, 'name': 'Алиса', 'active': True}

Размер обычно на 20–50 % меньше JSON, парсинг быстрее, миграция почти бесплатна: API остаётся тот же, меняется только функция кодирования. Поэтому MessagePack любят там, где нужен «JSON побыстрее»: внутри Redis-протокола, в реальном времени игр, в межпроцессном взаимодействии.

Цена этой простоты — отсутствие схемы и эволюции. MessagePack, как и JSON, не знает о том, какие у вас поля. Это эволюция представления, а не модели данных.

8. FlatBuffers и идея zero-copy

Все форматы выше живут по одной схеме: прочитали байты, разобрали в дерево объектов, работаем с объектами. У этой схемы есть неприятная цена: каждое сообщение требует выделить память под десятки новых объектов и скопировать в них исходные байты. Для сервиса, обрабатывающего миллион пакетов в секунду, это становится узким местом.

FlatBuffers, разработанный в Google для игровой индустрии, заходит с другой стороны. Что если вообще не парсить сообщение, а читать поля напрямую из буфера, в котором оно пришло?

Аналогия

Обычная сериализация устроена как переписывание книги в новую тетрадь: чтобы что-то прочесть, нужно сначала всё перенести. FlatBuffers устроен иначе: данные уже разложены в памяти так, как с ними работают, и достаточно знать нужные смещения. Это похоже на чтение ламинированного оригинала: страница уже открыта, нужно только посмотреть в правильное место.

Технически это значит, что десериализация почти бесплатна. Структура Monster в FlatBuffers выглядит так:

namespace MyGame.Sample;

table Monster {
  pos:Vec3;
  mana:short = 150;
  hp:short = 100;
  name:string;
  inventory:[ubyte];
}

struct Vec3 {
  x:float;
  y:float;
  z:float;
}

root_type Monster;

В коде на C++ это выглядит так:

auto monster = MyGame::Sample::GetMonster(buffer);
auto hp = monster->hp();             // прямое чтение из памяти
auto name = monster->name()->str();  // строка читается как срез байт

Никаких выделений объектов, никакого парсинга, поле hp читается из памяти по фиксированному смещению. Бенчмарки показывают разницу в десятки раз: в одном из публичных сравнений FlatBuffers декодирует объект за 18 нс против 1179 нс у Protobuf на том же языке.

Цена zero-copy

К сожалению, ничего бесплатного не бывает. FlatBuffers платит за свою скорость заметным дискомфортом:

  • строить сообщение приходится «снизу вверх». Нельзя начать новый объект, пока не закрыт предыдущий, потому что размер объекта зависит от его содержимого.
  • изменять сообщение тяжело. Любое изменение «на месте» — риск нарушить смещения. На практике сообщения чаще пересобирают целиком.
  • схема жёстче по выравниванию. Часть байт занимает padding для корректного чтения по 4- или 8-байтным границам.
  • сообщение крупнее, чем у Protobuf. Варинтов (varint) нет, целые числа только фиксированной ширины. Зато есть прямой доступ к любому полю без чтения предыдущих.

FlatBuffers оптимизирует не удобство разработчика, а попадания в процессорный кэш. Это его место в индустрии: игровые движки (Cocos2d-x), мобильные приложения (Facebook*), хранение конфигов и ассетов, ML inference, межпроцессная коммуникация на одной машине через разделяемую память.

9. Cap’n Proto: сериализация почти бесплатная

Cap’n Proto разработал Кентон Варда — тот самый инженер, который раньше работал над Protobuf в Google. Идея ещё радикальнее: представление в памяти и представление в байтах для передачи — это одно и то же. Сериализация сводится к копированию буфера, десериализация — к указателю.

Схема выглядит похоже на Protobuf:

struct Person {
  id    @0 :UInt32;
  name  @1 :Text;
  email @2 :Text;
  phones @3 :List(PhoneNumber);

  struct PhoneNumber {
    number @0 :Text;
    type   @1 :Type;

    enum Type {
      mobile @0;
      home   @1;
      work   @2;
    }
  }
}

Использование (Rust):

let mut message = capnp::message::Builder::new_default();
let mut person = message.init_root::<person::Builder>();
person.set_id(42);
person.set_name("Алиса".into());

// "сериализация" - это просто отдать байты буфера
let bytes = capnp::serialize::write_message_to_words(&message);

При чтении на другой стороне библиотека просто оборачивает буфер в типизированную обёртку, не делая ни одной аллокации.

По сравнению с FlatBuffers Cap’n Proto предлагает несколько отличий: встроенный RPC-протокол, опциональная упаковка для уменьшения размера ценой потери zero-copy, более чистая модель типов. Платой остаётся слабая экосистема: реализаций в разных языках меньше, чем у Protobuf, и качество их разное. Для команды, готовой жить в C++/Rust/Go, это не проблема, для команды на трёх языках сразу — серьёзный ограничитель.

10. Avro: формат для потоков и Schema Registry

Когда речь заходит о Kafka, событийных архитектурах и озёрах данных (data lakes — централизованных хранилищах сырых данных в исходных форматах), на сцену выходит Apache Avro. Это формат из экосистемы Hadoop, сделанный с одной главной целью: пережить эволюцию схем в очень больших объёмах данных, которые могут жить годами.

Схема в Avro описывается в JSON, что многих сначала смущает:

{
  "type": "record",
  "name": "User",
  "namespace": "com.example",
  "fields": [
    {"name": "id",    "type": "long"},
    {"name": "name",  "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null}
  ]
}

Само сообщение записывается компактным бинарным форматом: без имён полей, в порядке схемы. Принципиальное отличие от Protobuf: имена полей вообще не передаются, и порядок имеет значение.

Это влечёт интересное последствие. Чтобы прочитать Avro-сообщение, нужны две схемы: схема, с которой записывали (writer schema), и схема, которой ждёт читающий (reader schema). При расхождении Avro-библиотека делает так называемое resolution: сопоставляет поля по имени, подставляет значения по умолчанию для отсутствующих, игнорирует лишние. Это даёт очень мощную модель эволюции, но требует доступа к обеим схемам.

В Kafka эту проблему решает Confluent Schema Registry. Каждое сообщение в топике несёт в себе не саму схему, а её четырёхбайтный идентификатор. Полная схема живёт в централизованном реестре, потребитель скачивает её один раз и кэширует. Реестр умеет проверять совместимость: при попытке зарегистрировать новую версию он отвергнет изменения, ломающие обратную или прямую совместимость по выбранной политике.

Формальная разница между политиками такая:

ПолитикаЗначение
BACKWARDновая схема может прочитать данные, написанные старой
FORWARDстарая схема может прочитать данные, написанные новой
FULLобе стороны взаимно совместимы

Это превращает Schema Registry из «ещё одной зависимости» в инструмент управления контрактами. Команды могут эволюционировать схемы независимо, а реестр гарантирует, что в продакшен не уедет изменение, которое сломает существующих потребителей.

11. Apache Arrow: колоночный формат для аналитики

Все форматы выше используют обмен сообщениями: один объект — один пакет. Аналитика устроена иначе. Нужно прочитать миллиард строк, посчитать сумму по одной колонке, и не платить за чтение остальных тридцати. Для этой задачи появился Apache Arrow.

Arrow — это спецификация колоночного представления данных в памяти. Вместо того, чтобы хранить таблицу как массив строк (record-oriented), Arrow хранит её как набор колонок: все значения первой колонки идут подряд, потом второй, потом третьей. Это даёт сразу три эффекта:

  • процессор читает только нужные колонки. Если запрос трогает три поля из тридцати, остальные просто не загружаются в кэш.
  • векторные операции работают эффективно. SIMD-инструкции хорошо едят непрерывный массив int64, плохо — массив объектов с перепутанными типами.
  • разные движки могут шарить данные без копирования. Это и есть знаменитая «zero-copy interop».

Пример на Python:

import pyarrow as pa
import duckdb
import polars as pl

table = pa.table({
    "user_id": [1, 2, 3, 4, 5],
    "amount":  [100, 250, 50, 800, 120],
    "country": ["RU", "DE", "RU", "US", "DE"],
})

# DuckDB читает Arrow-таблицу напрямую, без копирования
result = duckdb.sql("""
    SELECT country, SUM(amount) AS total
    FROM table
    GROUP BY country
""").arrow()

# Polars тоже работает с Arrow нативно
df = pl.from_arrow(result)

Все три библиотеки — PyArrow, DuckDB, Polars — оперируют одной и той же областью памяти. Передача результата из одного движка в другой не требует ни сериализации, ни копирования. На больших таблицах это даёт ускорения в десятки раз по сравнению с привычным циклом «выгрузить в DataFrame, передать в SQL-движок, забрать обратно».

Стоит отметить разницу между Arrow и Parquet. Parquet — это колоночный формат на диске, оптимизированный под сжатие и долгое хранение. Arrow — формат в памяти, оптимизированный под скорость доступа. Они отлично работают в паре: данные лежат в Parquet, читаются в Arrow, обрабатываются через DuckDB/Polars/Pandas, и каждый знает, как разговаривать с соседом без лишних копий.

12. CSV бессмертен

После всех разговоров о zero-copy, варинтах и schema registry хочется вернуться к самому простому формату на свете. CSV.

У CSV нет почти ничего, что есть у других форматов. Нет типов: всё строки, и 01 — это либо число один, либо строка из двух символов, угадывайте сами. Нет схемы: первая строка может быть заголовком, а может и нет. Нет нормального экранирования: если значение содержит запятую, его обрамляют кавычками; если внутри есть кавычка, её удваивают; правила немного разные у разных диалектов. Есть знаменитая «delimiter wars»: запятая в США, точка с запятой в Европе (потому что там запятая — десятичный разделитель), табуляция у любителей TSV. Кодировки: UTF-8, UTF-16, Windows-1251, и каждый второй файл из бухгалтерии оказывается не в той, в которой нужно.

И всё же CSV не умрёт. Причина простая: его открывает Excel. Его читает любой скрипт за пять минут. Его понимают аналитики, бухгалтеры, маркетологи и менеджеры, никогда не слышавшие слова «сериализация». К нему можно дописывать новые строки в конец, не трогая существующие; его легко резать по строкам; он не требует никаких библиотек и помещается в любой пайплайн, даже самый кустарный.

CSV — это минимальный общий язык, на котором умеют говорить вообще все: программы, операционные системы, бухгалтерии, аналитики, скрипты на коленке. Любой более «правильный» формат проигрывает ему в одной номинации: с CSV не нужно ни с кем ни о чём договариваться.

13. Война форматов по сценариям использования

Если суммировать всё сказанное в одну таблицу, получится примерно так:

СценарийЧто выбираютПочему
Публичные REST API, фронтендJSONчитается, дебажится, поддерживается всеми
Внутренние микросервисы, gRPCProtobufкомпактно, схематизировано, эволюционирует
Игровые движки, мобильные ассетыFlatBufferszero-copy, малый latency, mmap-friendly
Высокопроизводительный IPC, RPCCap’n Protoсериализация почти бесплатная
Kafka, событийные архитектурыAvro + Schema Registryформальная эволюция схем, контракты в реестре
Аналитика, dataframe-движкиApache Arrow / Parquetколоночное представление, zero-copy interop
Импорт/экспорт, обмен с людьмиCSVоткрывается в Excel, понимают все
Криптография, телеком, банкингASN.1 (DER)детерминированность, исторические стандарты
Realtime, JSON-совместимостьMessagePackкомпактно, миграция почти бесплатна
IoT, ограниченные устройстваCBORбинарный JSON для constrained-сред
Большие данные внутри HadoopAvro / Parquet / ORCсжатие, эволюция, колоночность
Старый enterprise, государствоXMLсуществующая база стандартов и тулинга

Реальные системы редко ограничиваются одним форматом на всё. Граница обычно проходит так: на периметре системы (наружу к пользователю и партнёрам) едет JSON, потому что важна совместимость; внутри, между сервисами, гуляет Protobuf, потому что важна производительность; в очередях событий лежит Avro со Schema Registry, потому что важна долговечность контрактов; аналитический контур работает с Parquet и Arrow, потому что важны колоночные сканы.

14. Главное инженерное правило

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

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

JSON выбирают не потому, что он быстрый, а потому, что он удобный. Protobuf — не потому, что он компактный, а потому, что он даёт строгие контракты и эволюцию. FlatBuffers — не потому, что он бинарный, а потому, что он позволяет читать поля прямо из буфера, не выделяя память под промежуточные объекты. Arrow — не потому, что он колоночный, а потому, что он позволяет разным инструментам делить одну память.

Выбор формата определяет не размер пакета на диске, а свойства всей распределённой системы: какие из них вы получаете и сколько за них платите.

15. Финал

История сериализации — это история одних и тех же компромиссов, которые индустрия проходит снова и снова. Человек против машины: первое требует читаемости, второе — плотности. Гибкость против производительности: первая нужна на ранних этапах продукта, вторая на поздних. Простота против контроля: первая ускоряет разработку, второй спасает в продакшене. Эволюция против эффективности: первая нужна, чтобы менять систему годами, вторая — чтобы экономить трафик прямо сейчас.

ASN.1 решал эти задачи в восьмидесятых для телекома. XML переоткрыл их в девяностых для энтерпрайза. JSON упростил их в нулевых для веба. Protobuf вернул им строгость в десятых для микросервисов. FlatBuffers и Cap’n Proto довели идею до предела для real-time-сценариев. Avro построил вокруг эволюции отдельную инфраструктуру для стримов. Arrow перепридумал саму идею межпроцессного обмена для аналитики.

Никто из них не выиграл войну, потому что войны не было. Была дюжина разных задач, и для каждой нашёлся подходящий ответ. Лучшее, что инженер может сделать с этим знанием, — перестать спорить, какой формат «правильный», и начать выбирать формат, подходящий конкретному сценарию.

JSON останется на фронтенде. Protobuf — между сервисами. Arrow — в аналитике. CSV — в почтовых вложениях бухгалтерии. И это норм.

Источники и дополнительное чтение:


* Facebook принадлежит компании Meta, признанной экстремистской и запрещённой на территории Российской Федерации.

Предыдущий
Наверх