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

Информация может храниться в базе данных или в виде файлов, сериализованных в стандартном формате и со схемой, согласованной с вашей командой по разработке данных. В зависимости от вашей информации и требований, это могут быть такие простые форматы, как CSV, XML или JSON, или форматы больших данных, такие как Паркет, Авро, ОРЦ, Стрелкаили форматы сериализации сообщений, такие как Буферы протокола, Плоские буферы, пакет сообщений, Бережливостьили же Капитан Прото.

Моя идея состоит в том, чтобы проанализировать некоторые из этих систем по коллекции статей, используя JSON в качестве эталона, чтобы сравнить все с чем-то, что всем известно.

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

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


Модель данных

Данный эта модель данных организаций и их атрибутов, используя новые записи Java:

record Org(String name, String category, String country, Type type,
           List<Attr> attributes) { }

record Attr(String id, byte quantity, byte amount, boolean active, 
            double percent, short size) { }

enum Type { FOO, BAR, BAZ }
Войти в полноэкранный режим

Выйти из полноэкранного режима

Я смоделирую тяжелый сценарий, в котором каждая организация будет случайным образом иметь от 40 до 70 различных атрибутов, и всего у нас будет около 400 тысяч организаций. Значения атрибутов также рандомизированы.



JSON

Это формат обмена данными по преимуществу и фактический стандарт связи веб-сервисов. Хотя он был определен как формат в начале 2000-х, только в 2013 г. Экма опубликовал первую версию, ставшую международным стандартом в 2017 году.

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


Сериализация

Использование библиотеки, например Джексон и без специальных аннотаций код такой очень простой:

var organizations = dataFactory.getOrganizations(400_000);

ObjectMapper mapper = new ObjectMapper();
try (var os = new FileOutputStream("/tmp/organizations.json")) {
  mapper.writeValue(organizations, os);
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Метрики:

  • Время сериализации: 11 718 мс
  • Размер файла: 2 457 МБ
  • Размер сжатого файла: 525 МБ
  • Требуемая память: потому что мы сериализуем непосредственно в OutputStreamон ничего не потребляет, кроме необходимых внутренних буферов ввода-вывода.
  • Размер библиотеки (jackson-xxx.jar): 1 956 679 байт


Десериализация

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

Чтобы использовать возможности и сильные стороны каждого инструмента, мы сохраним представление объекта, созданного каждой библиотекой. В случае с JSON будет исходный класс, а в других библиотеках будет другой класс.

Джексон снова делает это очень легко и решает проблему только с помощью 3 линии кода:

try (InputStream is = new FileInputStream("/tmp/organizations.json")) {
  ObjectMapper mapper = new ObjectMapper();
  List<Org> organizations mapper.readValue(is, new TypeReference<List<Org>>() {});
  ....
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

  • Время десериализации: 20 410 мс
  • Требуемая память: поскольку восстанавливаются исходные структуры объектов, они потребляют 2 193 МБ.


Буферы протокола

Буферы протокола это система, разработанная Google для внутренней сериализации данных таким образом, чтобы эффективно использовать ЦП и хранилище, особенно если вы сравните с тем, как это делалось в те времена с XML. В 2008 году он был выпущен под лицензией BSD.

Он основан на предварительном определении того, какой формат будут иметь данные через IDL (язык определения интерфейса), и на его основе генерируется исходный код, который сможет как записывать, так и читать файлы с данными. Производитель и потребитель должны каким-то образом совместно использовать формат, определенный в IDL.

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

Информация, сгенерированная после сериализации, представляет собой массив байтов, нечитаемый для людей.

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

Google стандартизировал его и сделал его основой своего механизма связи между серверами: gRPCвместо обычного REST с JSON.

Хотя документация не поощряет его использование с большими наборами данных и предлагает разделить большие коллекции на «конкатенацию» сериализации отдельных объектов, я собираюсь оценить и попробовать.


IDL и генерация кода

Файл со схемой может быть это:

syntax = "proto3";

package com.jerolba.xbuffers.protocol;

option java_multiple_files = true;
option java_package = "com.jerolba.xbuffers.protocol";
option java_outer_classname = "OrganizationsCollection";

message Organization {
  string name = 1;
  string category = 2;
  OrganizationType type = 3;
  string country = 4;
  repeated Attribute attributes = 5;

  enum OrganizationType {
    FOO = 0;
    BAR = 1;
    BAZ = 2;
  }

  message Attribute {
    string id = 1;
    int32 quantity = 2;
    int32 amount = 3;
    int32 size = 4;
    double percent = 5;
    bool active = 6;
  }

}

message Organizations {
  repeated Organization organizations = 1;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Для генерации всех классов Java необходимо установить компилятор protoc и выполните его с некоторыми параметрами, указывающими, где находится файл IDL, и целевой путь к сгенерированным файлам:

protoc --java_out=./src/main/java -I=./src/main/resources ./src/main/resources/organizations.proto
Войти в полноэкранный режим

Выйти из полноэкранного режима

У вас есть все инструкции здесь и вы можете скачать утилиту компилятора с здесьно я предпочитаю использовать непосредственно докер с образом, готовым выполнить команду:

 docker run --rm -v $(pwd):$(pwd) -w $(pwd) znly/protoc --java_out=./src/main/java -I=./src/main/resources ./src/main/resources/organizations.proto
Войти в полноэкранный режим

Выйти из полноэкранного режима


Сериализация

Protocol Buffers не выполняет прямую сериализацию ваших POJO, но вам необходимо скопировать информацию в объекты, сгенерированные компилятором схемы.

Код, необходимый для сериализации информации из POJO, будет выглядеть так: это:

var organizations = dataFactory.getOrganizations(400_000)

var orgsBuilder = Organizations.newBuilder();
for (Org org : organizations) {
    var organizationBuilder = Organization.newBuilder()
        .setName(org.name())
        .setCategory(org.category())
        .setCountry(org.country())
        .setType(OrganizationType.forNumber(org.type().ordinal()));
    for (Attr attr : org.attributes()) {
        var attribute = Attribute.newBuilder()
            .setId(attr.id())
            .setQuantity(attr.quantity())
            .setAmount(attr.amount())
            .setActive(attr.active())
            .setPercent(attr.percent())
            .setSize(attr.size())
            .build();
        organizationBuilder.addAttributes(attribute);
    }
    orgsBuilder.addOrganizations(organizationBuilder.build());
}
Organizations orgsBuffer = orgsBuilder.build();
try (var os = new FileOutputStream("/tmp/organizations.protobuffer")) {
  orgsBuffer.writeTo(os);
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Код многословный, но простой. Если по какой-то причине вы решили заставить свою бизнес-логику работать непосредственно с классами, сгенерированными Protocol Buffers, весь этот код копирования будет ненужным.

  • Время сериализации: 5 823 мс
  • Размер файла: 1 044 МБ
  • Размер сжатого файла: 448 МБ
  • Требуемая память: для создания экземпляров всех этих промежуточных объектов в памяти перед их сериализацией требуется 1 315 МБ.
  • Размер библиотеки (protobuf-java-3.16.0.jar): 1 675 739 байт
  • Размер сгенерированных классов: 41 229 байт


Десериализация

Это также довольно просто, и достаточно пройти InputStream к восстановить в памяти весь граф объекта:

try (InputStream is = new FileInputStream("/tmp/organizations.protobuffer")) {
    Organizations organizations = Organizations.parseFrom(is);
    .....
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Объекты являются экземплярами классов, сгенерированных из схемы, а не из исходных записей.

  • Время десериализации: 4 535 мс
  • Требуемая память: восстановление структур объекта, определенных схемой, занимает 2 710 МБ.


Анализ и впечатления

JSONБуферы протокола
Время сериализации11 718 мс5 823 мс
Размер файла2 457 МБ1 044 МБ
Размер файла GZ525 МБ448 МБ
Сериализация памятиН/Д1,29 ГБ
Время десериализации20 410 мс4 535 мс
Десериализация памяти2 193 МБ2 710 МБ
Размер JAR-библиотеки1 910 КБ1 636 КБ
Размер сгенерированных классовН/Д40 КБ

Из данных и из того, что я смог увидеть, играя с форматами, мы можем сделать вывод:

  • JSON, несомненно, медленнее и тяжелее, но это очень комфортная и удобная система.
  • Protocol Buffers — хороший выбор: быстрая сериализация и десериализация, а также компактные и сжимаемые файлы. Его API прост и интуитивно понятен, а формат широко используется, став рыночным стандартом с множеством вариантов использования.
  • Оба формата все или ничего: вам нужно десериализовать всю информацию, чтобы получить доступ к ее части. В то время как в других инструментах вы можете получить доступ к любому элементу без необходимости просматривать и анализировать всю предшествующую ему информацию.
  • Protocol Buffers использует 32-битные целые числа для всех типов целых чисел (int, short, byte), но при сериализации представляет их со значением, которое занимает наименьшее количество байтовсэкономив несколько байтов.
  • По той же причине, когда Protocol Buffers генерирует классы из IDL, он определяет все как 32-битные целые числа и десериализует значения в int32. Вот почему потребление памяти десериализованными объектами на 23% выше, чем у JSON. То, что он получает, сжимая целые числа, он теряет в потреблении памяти.
  • Для скалярных значений протокольные буферы не поддерживает ноль. Если значение отсутствует, оно принимает значение по умолчанию соответствующего примитива (0, 0.0 или false), а нулевые строки десериализуются в «». эта статья предлагает различные стратегии для моделирования нулевых значений.
  • Хотя в исходном графе объектов названия стран или категорий представляют собой строки, которые присутствуют в памяти только один раз, при сериализации в двоичный формат они будут появляться столько раз, сколько у вас есть ссылок, а при десериализации у вас будет столько экземпляров, сколько ссылки у вас были. Именно поэтому в обоих случаях потребляемая память при десериализации занимает в два раза больше памяти, чем объекты, изначально сериализованные1.

1 Начиная с Java 8u20, благодаря ДАЙТЕ 192, у нас есть возможность в G1 дедуплицировать строки во время сборки мусора. Но по умолчанию он отключен, а когда он включен, мы не можем контролировать время его выполнения, поэтому мы не можем полагаться на эту оптимизацию для уменьшения размера десериализации.