Раздел 11. Работа с файлами, I/O и сетью (NIO.2)
Глава 3. Сериализация и форматы обмена
Protocol Buffers (Protobuf) — бинарный формат сериализации от Google
Protocol Buffers (Protobuf) — это механизм нейтрального к платформе и языку расширяемого сериализации структурированных данных, разработанный Google. Первый публичный релиз состоялся в 2008 году, хотя внутри Google формат использовался с 2001 года. На момент 2026 года актуальная версия спецификации — proto3 (Protocol Buffers версии 3), выпущенная в 2016 году, с последующими обновлениями языка и runtime.
Protobuf решает фундаментальную проблему текстовых форматов вроде JSON и XML: они удобны для человека, но неэффективны для машины. Каждый символ в JSON занимает байт (или несколько в UTF-8), каждая фигурная скобка, запятая и кавычка — это накладные расходы. В высоконагруженных распределённых системах, где сервисы обмениваются миллионами сообщений в секунду, эти накладные расходы становятся bottleneck на уровне CPU, памяти и сетевой пропускной способности. Protobuf заменяет текстовое представление на компактное бинарное, при этом сохраняя строгую типизацию и возможность эволюции схемы.
Архитектура: схема как контракт
В основе Protobuf лежит идея schema-first (сначала схема). Разработчик описывает структуру данных в файле с расширением
Пример .proto файла
Ключевые элементы синтаксиса:
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
Глава 3. Сериализация и форматы обмена
Protocol Buffers (Protobuf) — бинарный формат сериализации от Google
Protocol Buffers (Protobuf) — это механизм нейтрального к платформе и языку расширяемого сериализации структурированных данных, разработанный Google. Первый публичный релиз состоялся в 2008 году, хотя внутри Google формат использовался с 2001 года. На момент 2026 года актуальная версия спецификации — proto3 (Protocol Buffers версии 3), выпущенная в 2016 году, с последующими обновлениями языка и runtime.
Protobuf решает фундаментальную проблему текстовых форматов вроде JSON и XML: они удобны для человека, но неэффективны для машины. Каждый символ в JSON занимает байт (или несколько в UTF-8), каждая фигурная скобка, запятая и кавычка — это накладные расходы. В высоконагруженных распределённых системах, где сервисы обмениваются миллионами сообщений в секунду, эти накладные расходы становятся bottleneck на уровне CPU, памяти и сетевой пропускной способности. Protobuf заменяет текстовое представление на компактное бинарное, при этом сохраняя строгую типизацию и возможность эволюции схемы.
Bottleneck — это узкое место в системе, которое ограничивает общую производительность. В контексте сериализации bottleneck часто проявляется в виде высокой нагрузки на CPU при парсинге текстовых форматов, избыточном потреблении памяти кучи и низкой пропускной способности сети из-за раздутого размера сообщений.
Архитектура: схема как контракт
В основе Protobuf лежит идея schema-first (сначала схема). Разработчик описывает структуру данных в файле с расширением
.proto на специальном языке описания интерфейсов (IDL — Interface Definition Language), а затем компилирует этот файл в код на целевом языке программирования. Этот подход кардинально отличается от JSON, где схема существует только в головах разработчиков или в отдельном документе (OpenAPI), но не является частью процесса сериализации.IDL (Interface Definition Language) — это язык формальной спецификации интерфейсов программных компонентов. В контексте Protobuf IDL описывает структуру сообщений, их поля, типы и правила версионирования.
Пример .proto файла
syntax = "proto3";
package library;
// Опция java_package переопределяет пакет для сгенерированных Java-классов
option java_package = "com.example.library.proto";
option java_multiple_files = true;
// Сообщение Book описывает структуру книги
message Book {
// Поле с номером тега 1. Тег — это уникальный идентификатор поля в пределах сообщения
string title = 1;
// Поле с номером тега 2
string author = 2;
// int32 — 32-битное целое со знаком
int32 year = 3;
// double — 64-битное число с плавающей точкой
double price = 4;
// bool — булево значение
bool available = 5;
// repeated — повторяющееся поле, аналог списка или массива
repeated string tags = 6;
// Вложенное сообщение
message Publisher {
string name = 1;
string country = 2;
}
Publisher publisher = 7;
}
Ключевые элементы синтаксиса:
syntax = "proto3" — указание версии языка. proto3 — современная версия, упрощённая по сравнению с proto2. В proto3 все поля технически optional, нет required-полей, нет значений по умолчанию для полей сообщений.message — единица структуры данных, аналог класса в ООП. Каждое сообщение компилируется в класс на целевом языке.field_type field_name = field_number — объявление поля. Тип, имя и номер тега. Номер тега — целое число от 1 до 536870911 (2^29 - 1), но рекомендуется использовать 1–15 для частых полей, так как они кодируются одним байтом.repeated — модификатор, указывающий, что поле является коллекцией. В proto3 repeated-поля кодируются как packed по умолчанию для скалярных типов, что экономит пространство.option — метаданные компиляции. java_package задаёт пакет Java-классов, java_multiple_files = true генерирует отдельный файл для каждого сообщения вместо одного большого файла с вложенными классами.Тег (field number) — это уникальный числовой идентификатор поля в пределах сообщения Protobuf. Тег, а не имя поля, записывается в бинарное представление. Это позволяет изменять имена полей в схеме, не ломая бинарную совместимость, так как парсер опирается на номера тегов.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Бинарное кодирование: wire format
Protobuf использует собственный бинарный формат передачи — wire format. Он основан на принципе TLV (Tag-Length-Value) для сложных типов и TV (Tag-Value) для скаляров переменной длины. Каждое поле сообщения кодируется независимо, и порядок полей в бинарном потоке не обязан совпадать с порядком объявления в .proto файле.
Структура поля в wire format
Каждое поле кодируется как:
Тег состоит из двух компонентов, упакованных в varint:
field number (номер поля, 3 бита wire type + оставшиеся биты на номер)
wire type (тип кодирования значения, 3 бита)
Wire types определены спецификацией:
0 — Varint (целые числа, булевы, enum)
1 — 64-bit (fixed64, sfixed64, double)
2 — Length-delimited (string, bytes, вложенные сообщения, packed repeated)
3 — Start group (устаревший, не используется в proto3)
4 — End group (устаревший)
5 — 32-bit (fixed32, sfixed32, float)
Varint: переменная длина целых чисел
Varint позволяет кодировать малые числа компактно: числа от 0 до 127 занимают 1 байт. Это главная причина, по которой рекомендуется использовать теги 1–15 для частых полей — теговый байт тоже кодируется как varint, и малые номера полей занимают 1 байт.
ZigZag: кодирование отрицательных чисел
Стандартный varint плохо подходит для отрицательных чисел в знаковых типах (sint32, sint64), так как в дополнительном коде (two's complement) отрицательные числа имеют установленные старшие биты и кодируются максимальной длиной (5 байт для sint32, 10 байт для sint64). Для решения этой проблемы Protobuf использует ZigZag encoding.
Таким образом,
Length-delimited: строки и вложенные сообщения
Для типов с переменной длиной (string, bytes, вложенные message) используется wire type 2.
Формат:
Где
Packed repeated fields
В proto3 скалярные числовые repeated-поля кодируются как packed по умолчанию. Это означает, что вместо отдельного тега для каждого элемента массива весь массив кодируется как один length-delimited блок:
Это значительно экономит пространство по сравнению с unpacked-форматом, где каждый элемент имел бы свой тег.
Компиляция: от .proto к Java
Компилятор
Для Java процесс выглядит так:
Флаг
Ключевые особенности сгенерированного кода:
Immutability (неизменяемость): после создания через
Builder pattern: создание объекта идёт через вложенный класс
LazyStringList: оптимизированная реализация
CodedOutputStream / CodedInputStream: низкоуровневые потоки для записи и чтения wire format. Работают напрямую с
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
Protobuf использует собственный бинарный формат передачи — wire format. Он основан на принципе TLV (Tag-Length-Value) для сложных типов и TV (Tag-Value) для скаляров переменной длины. Каждое поле сообщения кодируется независимо, и порядок полей в бинарном потоке не обязан совпадать с порядком объявления в .proto файле.
Wire format — это конкретное бинарное представление сообщения Protobuf при передаче по сети или записи на диск. Оно определяет, как типы данных, номера полей и значения упаковываются в последовательность байтов.
Структура поля в wire format
Каждое поле кодируется как:
[tag][value]
Тег состоит из двух компонентов, упакованных в varint:
field number (номер поля, 3 бита wire type + оставшиеся биты на номер)
wire type (тип кодирования значения, 3 бита)
Wire types определены спецификацией:
0 — Varint (целые числа, булевы, enum)
1 — 64-bit (fixed64, sfixed64, double)
2 — Length-delimited (string, bytes, вложенные сообщения, packed repeated)
3 — Start group (устаревший, не используется в proto3)
4 — End group (устаревший)
5 — 32-bit (fixed32, sfixed32, float)
Varint: переменная длина целых чисел
Varint (variable-length integer) — это способ кодирования целых чисел в виде последовательности байтов переменной длины. Каждый байт varint содержит 7 бит данных и 1 бит-флаг продолжения (most significant bit). Если флаг установлен, следует ещё один байт.
Значение 1:
Бинарное: 00000001
Varint: 00000001 (1 байт)
Значение 150:
Бинарное: 10010110
Varint: 10010110 00000001 (2 байта)
Разбор: младшие 7 бит первого байта = 1001010 (22), старший бит = 1 (продолжение)
второй байт = 0000001 (1), итого: 1 * 128 + 22 = 150
Varint позволяет кодировать малые числа компактно: числа от 0 до 127 занимают 1 байт. Это главная причина, по которой рекомендуется использовать теги 1–15 для частых полей — теговый байт тоже кодируется как varint, и малые номера полей занимают 1 байт.
ZigZag: кодирование отрицательных чисел
Стандартный varint плохо подходит для отрицательных чисел в знаковых типах (sint32, sint64), так как в дополнительном коде (two's complement) отрицательные числа имеют установленные старшие биты и кодируются максимальной длиной (5 байт для sint32, 10 байт для sint64). Для решения этой проблемы Protobuf использует ZigZag encoding.
ZigZag encoding — это схема отображения целых чисел со знаком на целые без знака, при которой малые по модулю числа (положительные и отрицательные) отображаются на малые положительные числа. Формула: (n << 1) ^ (n >> 31) для 32-битных чисел.
Исходное значение -> Закодированное
0 -> 0
-1 -> 1
1 -> 2
-2 -> 3
2 -> 4
Таким образом,
-1 кодируется как 1 и занимает 1 байт вместо 5. Для полей, которые могут содержать отрицательные значения, в .proto следует использовать sint32 или sint64 вместо int32/int64.Length-delimited: строки и вложенные сообщения
Для типов с переменной длиной (string, bytes, вложенные message) используется wire type 2.
Формат:
[tag][length][data]
Где
length — varint, указывающий количество байт в data. Это позволяет парсеру пропустить неизвестные поля (unknown fields) или поля с неправильным типом, не читая их содержимое байт за байтом.Packed repeated fields
В proto3 скалярные числовые repeated-поля кодируются как packed по умолчанию. Это означает, что вместо отдельного тега для каждого элемента массива весь массив кодируется как один length-delimited блок:
[tag][total_length][value1][value2][value3]...
Это значительно экономит пространство по сравнению с unpacked-форматом, где каждый элемент имел бы свой тег.
Компиляция: от .proto к Java
Компилятор
protoc (Protocol Buffers Compiler) трансформирует .proto файлы в исходный код на целевом языке.Для Java процесс выглядит так:
# Установка компилятора (например, через Maven plugin или скачивание бинарника)
protoc --java_out=./src/main/java library.proto
Флаг
--java_out указывает директорию для сгенерированных Java-файлов. Результат компиляции сообщения Book из примера выше:// Сгенерированный класс Book.java
package com.example.library.proto;
public final class Book extends GeneratedMessageV3 implements BookOrBuilder {
// Приватные поля — immutable объект
private volatile Object title_;
private volatile Object author_;
private int year_;
private double price_;
private boolean available_;
// Repeated поле — хранится как ProtocolStringList (оптимизированная реализация List<String>)
private LazyStringList tags_;
private Publisher publisher_;
// Приватный конструктор — объекты создаются через Builder
private Book() {
title_ = "";
author_ = "";
tags_ = LazyStringArrayList.EMPTY;
}
// Геттеры
public String getTitle() { ... }
public String getAuthor() { ... }
public int getYear() { ... }
public List<String> getTagsList() { ... }
public Publisher getPublisher() { ... }
// Сериализация в OutputStream
public void writeTo(CodedOutputStream output) throws IOException {
// Кодирование каждого поля в wire format
if (!getTitleBytes().isEmpty()) {
GeneratedMessageV3.writeString(output, 1, title_);
}
if (!getAuthorBytes().isEmpty()) {
GeneratedMessageV3.writeString(output, 2, author_);
}
if (year_ != 0) {
output.writeInt32(3, year_);
}
// ... и так далее для каждого поля
}
// Десериализация из InputStream
public static Book parseFrom(InputStream input) throws IOException {
return parseFrom(input, DEFAULT_INSTANCE);
}
// Вложенный класс Builder — реализация паттерна Builder
public static final class Builder extends GeneratedMessageV3.Builder<Builder> {
// Мутабельная копия полей для построения объекта
public Builder setTitle(String value) { ... }
public Builder setAuthor(String value) { ... }
public Builder setYear(int value) { ... }
public Builder addTags(String value) { ... }
public Book build() { ... } // создаёт immutable Book
}
// Singleton-экземпляр DEFAULT_INSTANCE для пустого сообщения
private static final Book DEFAULT_INSTANCE;
static {
DEFAULT_INSTANCE = new Book();
}
}
Ключевые особенности сгенерированного кода:
Immutability (неизменяемость): после создания через
build() объект Book не может быть изменен. Все поля private final (или private volatile для строк), сеттеров нет. Изменение требует создания нового объекта через toBuilder().Builder pattern: создание объекта идёт через вложенный класс
Builder, что позволяет конструировать объект пошагово и гарантирует валидность на момент build().LazyStringList: оптимизированная реализация
List<String> для repeated string-полей. Хранит строки как ByteString или byte[] до первого обращения как String, что экономит перекодирование UTF-8.CodedOutputStream / CodedInputStream: низкоуровневые потоки для записи и чтения wire format. Работают напрямую с
byte[], минуя промежуточные текстовые представления.Immutability (неизменяемость) — это свойство объекта, при котором его состояние не может быть изменено после создания. Неизменяемые объекты потокобезопасны по определению, так как их нельзя изменить из любого потока. В Protobuf immutability гарантирует, что сериализованное сообщение не изменится во время передачи.
Builder pattern (паттерн строитель) — это порождающий паттерн проектирования, который позволяет создавать сложные объекты пошагово. В Protobuf Builder изолирует мутабельное состояние на этапе конструирования, а финальный объект остаётся immutable.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Практический пример: сериализация и десериализация
Метод
Версионирование и эволюция схемы
Одно из ключевых преимуществ Protobuf — встроенная поддержка backward и forward compatibility через правила изменения схемы.
Правила безопасного изменения схемы
Добавление полей: можно добавлять новые поля с новыми тегами. Старый код проигнорирует неизвестные теги (wire parser пропускает их по length-delimiter или varint-размеру). Новый код получит default value для отсутствующих в старом сообщении полей.
Удаление полей: поле можно удалить, но его тег нельзя повторно использовать (чтобы избежать коллизий со старыми сообщениями, где это поле ещё может присутствовать). Рекомендуется помечать удалённые поля как
Изменение типа: нельзя менять wire type поля (например, int32 на string), так как это сломает бинарный парсинг. Но можно менять тип в пределах совместимых wire type: int32 на int64 (wire type 0), string на bytes (wire type 2).
Изменение имени: имя поля можно менять свободно, так как в wire format хранится только тег, а не имя.
Default values: в proto3 поля всегда имеют zero-value по умолчанию (0 для чисел, пустая строка, false, первое значение enum). Это означает, что невозможно отличить "поле не было установлено" от "поле было установлено в значение по умолчанию". Для явного контроля наличия поля в proto3 используется обёртка
Преимущества Protobuf
Компактность
Бинарное представление Protobuf значительно меньше JSON. Для сообщения
JSON с отступами: ~280 байт.
JSON minified: ~200 байт.
Protobuf binary: ~55-65 байт (в зависимости от длины строк).
Экономия достигается за счёт:
Отсутствия имён полей в бинарном потоке (только числовые теги).
Varint-кодирования малых целых чисел.
Packed repeated-полей.
Отсутствия запятых, скобок, кавычек.
Для высоконагруженных систем экономия 70-80% трафика критична. В микросервисной архитектуре, где сервисы обмениваются через сеть, снижение размера сообщений прямо пропорционально снижению задержек (latency) и стоимости сетевой инфраструктуры.
Скорость
Protobuf парсится быстрее JSON по нескольким причинам:
Отсутствие текстового разбора: не нужно искать кавычки, запятые, скобки, обрабатывать escape-последовательности. Парсер читает байты последовательно, используя заранее известную структуру.
Нет рефлексии при десериализации: сгенерированный код содержит жёстко заданные инструкции вида "тег 1 — строка, записать в поле title". В Jackson для JSON требуется рефлексия или runtime-генерация bytecode.
Zero-copy для строк:
Предсказуемость аллокаций: размер сообщения известен до сериализации (вызов
В бенчмарках (JMH — Java Microbenchmark Harness) сериализация/десериализация Protobuf в Java обычно в 3–10 раз быстрее Jackson JSON для типичных сообщений, и в 20+ раз быстрее для сложных вложенных структур.
Строгая типизация
Кроссплатформенность и межъязыковая совместимость
Один .proto файл компилируется в C++, Java, Python, Go, C#, JavaScript, Ruby, Objective-C, PHP, Dart, Kotlin и другие языки. Это означает, что бэкенд на Java, мобильное приложение на Kotlin и фронтенд на JavaScript могут использовать одну и ту же схему данных, гарантируя совместимость на уровне wire format.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
import com.example.library.proto.Book;
import com.example.library.proto.Book.Publisher;
import com.google.protobuf.ByteString;
import java.io.FileOutputStream;
import java.io.FileInputStream;
public class ProtobufExample {
public void createAndSerialize() throws Exception {
// Создание объекта через Builder
Book book = Book.newBuilder()
.setTitle("Clean Code")
.setAuthor("Robert C. Martin")
.setYear(2008)
.setPrice(42.50)
.setAvailable(true)
.addTags("programming")
.addTags("software engineering")
.setPublisher(
Publisher.newBuilder()
.setName("Prentice Hall")
.setCountry("USA")
.build()
)
.build();
// Сериализация в байтовый массив
byte[] bytes = book.toByteArray();
System.out.println("Serialized size: " + bytes.length + " bytes");
// Для сравнения: JSON-представление этого же объекта заняло бы ~250-300 байт
// Запись в файл
try (FileOutputStream fos = new FileOutputStream("book.pb")) {
book.writeTo(fos);
}
// Десериализация из файла
try (FileInputStream fis = new FileInputStream("book.pb")) {
Book restored = Book.parseFrom(fis);
System.out.println("Restored: " + restored.getTitle() + " by " + restored.getAuthor());
}
// Десериализация из байтового массива
Book fromBytes = Book.parseFrom(bytes);
}
}
Метод
toByteArray() сериализует сообщение в компактный byte[]. Метод parseFrom() выполняет обратную операцию. Обратите внимание: parseFrom() не выбрасывает checked-исключений при невалидном формате — вместо этого выбрасывается InvalidProtocolBufferException (unchecked, наследник IOException в старых версиях, но в proto3 runtime это RuntimeException).Версионирование и эволюция схемы
Одно из ключевых преимуществ Protobuf — встроенная поддержка backward и forward compatibility через правила изменения схемы.
Backward compatibility (обратная совместимость) — это свойство системы, при котором новая версия может корректно обрабатывать данные, созданные старой версией. Forward compatibility (прямая совместимость) — это свойство, при котором старая версия может корректно обрабатывать данные, созданные новой версией.
Правила безопасного изменения схемы
Добавление полей: можно добавлять новые поля с новыми тегами. Старый код проигнорирует неизвестные теги (wire parser пропускает их по length-delimiter или varint-размеру). Новый код получит default value для отсутствующих в старом сообщении полей.
Удаление полей: поле можно удалить, но его тег нельзя повторно использовать (чтобы избежать коллизий со старыми сообщениями, где это поле ещё может присутствовать). Рекомендуется помечать удалённые поля как
reserved.Изменение типа: нельзя менять wire type поля (например, int32 на string), так как это сломает бинарный парсинг. Но можно менять тип в пределах совместимых wire type: int32 на int64 (wire type 0), string на bytes (wire type 2).
Изменение имени: имя поля можно менять свободно, так как в wire format хранится только тег, а не имя.
Default values: в proto3 поля всегда имеют zero-value по умолчанию (0 для чисел, пустая строка, false, первое значение enum). Это означает, что невозможно отличить "поле не было установлено" от "поле было установлено в значение по умолчанию". Для явного контроля наличия поля в proto3 используется обёртка
google.protobuf.BoolValue, Int32Value и т.д. (well-known types).message BookV2 {
string title = 1;
string author = 2;
int32 year = 3;
// Новое поле — старый код проигнорирует тег 8
string isbn = 8;
// Зарезервированные теги и имена — нельзя использовать повторно
reserved 4, 5, 6;
reserved "price", "available";
}Преимущества Protobuf
Компактность
Бинарное представление Protobuf значительно меньше JSON. Для сообщения
Book из примера:JSON с отступами: ~280 байт.
JSON minified: ~200 байт.
Protobuf binary: ~55-65 байт (в зависимости от длины строк).
Экономия достигается за счёт:
Отсутствия имён полей в бинарном потоке (только числовые теги).
Varint-кодирования малых целых чисел.
Packed repeated-полей.
Отсутствия запятых, скобок, кавычек.
Для высоконагруженных систем экономия 70-80% трафика критична. В микросервисной архитектуре, где сервисы обмениваются через сеть, снижение размера сообщений прямо пропорционально снижению задержек (latency) и стоимости сетевой инфраструктуры.
Latency — это задержка между отправкой запроса и получением ответа. В распределённых системах latency складывается из времени сериализации, передачи по сети, десериализации и обработки. Компактные форматы снижают время передачи и десериализации.
Скорость
Protobuf парсится быстрее JSON по нескольким причинам:
Отсутствие текстового разбора: не нужно искать кавычки, запятые, скобки, обрабатывать escape-последовательности. Парсер читает байты последовательно, используя заранее известную структуру.
Нет рефлексии при десериализации: сгенерированный код содержит жёстко заданные инструкции вида "тег 1 — строка, записать в поле title". В Jackson для JSON требуется рефлексия или runtime-генерация bytecode.
Zero-copy для строк:
ByteString и LazyStringList позволяют избежать копирования строк при передаче между слоями приложения.Предсказуемость аллокаций: размер сообщения известен до сериализации (вызов
getSerializedSize()), что позволяет выделить точный byte[] без переаллокаций.В бенчмарках (JMH — Java Microbenchmark Harness) сериализация/десериализация Protobuf в Java обычно в 3–10 раз быстрее Jackson JSON для типичных сообщений, и в 20+ раз быстрее для сложных вложенных структур.
JMH (Java Microbenchmark Harness) — это фреймворк от OpenJDK для написания корректных микробенчмарков Java-кода. Он решает проблемы JIT-оптимизаций, warmup-фазы и статистической значимости результатов.
Строгая типизация
.proto файл является контрактом. Компилятор protoc гарантирует, что сгенерированный код соответствует схеме. Невозможно случайно записать строку в числовое поле — это будет ошибкой компиляции, а не runtime-ошибкой парсинга. Это снижает количество ошибок интеграции между сервисами.Кроссплатформенность и межъязыковая совместимость
Один .proto файл компилируется в C++, Java, Python, Go, C#, JavaScript, Ruby, Objective-C, PHP, Dart, Kotlin и другие языки. Это означает, что бэкенд на Java, мобильное приложение на Kotlin и фронтенд на JavaScript могут использовать одну и ту же схему данных, гарантируя совместимость на уровне wire format.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Интеграция с gRPC
gRPC использует .proto файлы для определения не только сообщений, но и сервисов с методами:
Недостатки Protobuf
Нечитаемость для человека
Бинарный wire format не поддаётся чтению без специальных инструментов. Для отладки требуется
Требование схемы и кодогенерации
Каждое изменение структуры данных требует:
Изменения .proto файла.
Перекомпиляции всех зависимых проектов.
Редеплоя артефактов.
Это создаёт friction в agile-разработке, где структуры данных часто меняются на ранних этапах. JSON позволяет добавить новое поле в ответ API, не трогая клиентский код. В Protobuf это тоже возможно (благодаря forward compatibility), но требует дисциплины: новое поле должно получить новый тег, а клиент должен быть перекомпилирован, если он хочет работать с новым полем типобезопасно.
Кривая обучения
Разработчику нужно освоить:
Синтаксис .proto и семантику proto3.
Правила версионирования и reserved fields.
Различия между scalar types и их wire-типами (int32 vs sint32 vs fixed32).
Особенности generated code (Builder pattern, immutability, default values).
Интеграцию protoc в build pipeline (Maven, Gradle, Bazel).
Ограниченные типы данных
Protobuf не поддерживает напрямую:
Большие целые числа без знака больше 64 бит (нет nativе BigInteger).
Даты и время (есть well-known type
Полиморфизм (есть
Графы с циклическими ссылками (Protobuf предназначен для деревьев, не для произвольных графов).
Сравнение Protobuf и JSON
По скорости
Protobuf значительно быстрее при сериализации и десериализации.
Jackson JSON требует:
Парсинга текстовой строки посимвольно.
Построения промежуточного дерева (
Обработки escape-последовательностей и Unicode.
Динамического поиска полей по имени.
Protobuf использует:
Последовательное чтение байтов с известной структурой.
Прямую запись в поля объекта без рефлексии (сгенерированный код).
Простые битовые операции для varint и zigzag.
Предсказуемость аллокаций: размер сообщения известен до сериализации (вызов
В типичных микросервисных нагрузках разница составляет 3–10x в пользу Protobuf. При этом стоит учитывать, что в современных JVM с JIT-компиляцией Jackson может достигать высокой производительности после warmup-фазы, но Protobuf остаётся предсказуемо быстрее из-за отсутствия текстового парсинга.
По размеру
Protobuf обычно в 2–5 раз компактнее minified JSON и в 5–10 раз компактнее pretty-printed JSON. Ключевые факторы:
В JSON имя каждого поля повторяется в каждом сообщении. В Protobuf имя заменено на 1–2 байта тега.
Числа в JSON — ASCII-текст.
Булевы значения в JSON:
JSON требует запятых, скобок, кавычек. Protobuf не имеет разделителей между полями.
По строгости
Protobuf требует схему на этапе компиляции.
Это:
Плюс: ошибки типов ловятся на этапе компиляции, а не в runtime.
Плюс: контракт между сервисами формализован и версионируется.
Минус: меньшая гибкость для ad-hoc структур и прототипирования.
JSON не требует схемы. Это:
Плюс: быстрое прототипирование и гибкость.
Минус: runtime-ошибки при несовпадении типов или отсутствии полей.
Минус: неявный контракт, который легко нарушить.
По отладке и observability
JSON превосходит Protobuf в читаемости. Логи, перехваченные HTTP-запросы, сообщения в Kafka можно сразу прочитать. Protobuf требует инструментов:
По интеграции с вебом
JSON нативно поддерживается браузерами и JavaScript.
Protobuf в браузере требует:
Использования protobuf-js для сериализации/десериализации.
Передачи бинарных данных через ArrayBuffer или base64.
gRPC-Web для взаимодействия с gRPC-сервисами (требует прокси, например Envoy).
Поэтому для публичных API, потребляемых браузерными клиентами, JSON остаётся стандартом. Protobuf доминирует во внутреннем межсервисном взаимодействии (backend-to-backend).
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
gRPC — это фреймворк удалённого вызова процедур (RPC — Remote Procedure Call), разработанный Google и построенный поверх HTTP/2 с Protobuf как форматом сериализации сообщений.
gRPC использует .proto файлы для определения не только сообщений, но и сервисов с методами:
service LibraryService {
rpc GetBook(GetBookRequest) returns (Book);
rpc ListBooks(ListBooksRequest) returns (stream Book);
rpc CreateBooks(stream Book) returns (CreateBooksResponse);
}RPC (Remote Procedure Call) — это парадигма межпроцессного взаимодействия, при которой вызов удалённой функции выглядит как вызов локальной. gRPC генерирует клиентский stub и серверный skeleton из .proto файла, скрывая детали сетевого взаимодействия.
Stub — это сгенерированный клиентский код, который предоставляет интерфейс, идентичный серверному сервису, но реализующий сетевое взаимодействие под капотом. Skeleton — серверная заглушка, принимающая сетевые запросы и делегирующая их реальной реализации.
Недостатки Protobuf
Нечитаемость для человека
Бинарный wire format не поддаётся чтению без специальных инструментов. Для отладки требуется
protoc --decode или специализированные декодеры. Это усложняет отладку сетевых проблем: нельзя просто посмотреть перехваченный TCP-пакет в текстовом виде, как с JSON.Требование схемы и кодогенерации
Каждое изменение структуры данных требует:
Изменения .proto файла.
Перекомпиляции всех зависимых проектов.
Редеплоя артефактов.
Это создаёт friction в agile-разработке, где структуры данных часто меняются на ранних этапах. JSON позволяет добавить новое поле в ответ API, не трогая клиентский код. В Protobuf это тоже возможно (благодаря forward compatibility), но требует дисциплины: новое поле должно получить новый тег, а клиент должен быть перекомпилирован, если он хочет работать с новым полем типобезопасно.
Кривая обучения
Разработчику нужно освоить:
Синтаксис .proto и семантику proto3.
Правила версионирования и reserved fields.
Различия между scalar types и их wire-типами (int32 vs sint32 vs fixed32).
Особенности generated code (Builder pattern, immutability, default values).
Интеграцию protoc в build pipeline (Maven, Gradle, Bazel).
Ограниченные типы данных
Protobuf не поддерживает напрямую:
Большие целые числа без знака больше 64 бит (нет nativе BigInteger).
Даты и время (есть well-known type
Timestamp, но это message, а не примитив).Полиморфизм (есть
oneof и Any, но они более ограничены, чем наследование в ООП).Графы с циклическими ссылками (Protobuf предназначен для деревьев, не для произвольных графов).
Сравнение Protobuf и JSON
По скорости
Protobuf значительно быстрее при сериализации и десериализации.
Jackson JSON требует:
Парсинга текстовой строки посимвольно.
Построения промежуточного дерева (
JsonNode) или рефлексии для маппинга на POJO.Обработки escape-последовательностей и Unicode.
Динамического поиска полей по имени.
Protobuf использует:
Последовательное чтение байтов с известной структурой.
Прямую запись в поля объекта без рефлексии (сгенерированный код).
Простые битовые операции для varint и zigzag.
Предсказуемость аллокаций: размер сообщения известен до сериализации (вызов
getSerializedSize()), что позволяет выделить точный byte[] без переаллокаций.В типичных микросервисных нагрузках разница составляет 3–10x в пользу Protobuf. При этом стоит учитывать, что в современных JVM с JIT-компиляцией Jackson может достигать высокой производительности после warmup-фазы, но Protobuf остаётся предсказуемо быстрее из-за отсутствия текстового парсинга.
JIT (Just-In-Time compilation) — это компиляция байткода Java в нативный машинный код во время выполнения. HotSpot JVM компилирует часто выполняемые методы в оптимизированный машинный код, что значительно ускоряет их выполнение после периода "разогрева" (warmup).
По размеру
Protobuf обычно в 2–5 раз компактнее minified JSON и в 5–10 раз компактнее pretty-printed JSON. Ключевые факторы:
В JSON имя каждого поля повторяется в каждом сообщении. В Protobuf имя заменено на 1–2 байта тега.
Числа в JSON — ASCII-текст.
12345 занимает 5 байт. В Protobuf 12345 как varint занимает 2 байта.Булевы значения в JSON:
true — 4 байта. В Protobuf: 1 байт (тег + значение).JSON требует запятых, скобок, кавычек. Protobuf не имеет разделителей между полями.
По строгости
Protobuf требует схему на этапе компиляции.
Это:
Плюс: ошибки типов ловятся на этапе компиляции, а не в runtime.
Плюс: контракт между сервисами формализован и версионируется.
Минус: меньшая гибкость для ad-hoc структур и прототипирования.
JSON не требует схемы. Это:
Плюс: быстрое прототипирование и гибкость.
Минус: runtime-ошибки при несовпадении типов или отсутствии полей.
Минус: неявный контракт, который легко нарушить.
По отладке и observability
JSON превосходит Protobuf в читаемости. Логи, перехваченные HTTP-запросы, сообщения в Kafka можно сразу прочитать. Protobuf требует инструментов:
protoc --decode, gRPC reflection, специализированные декодеры в Wireshark. В современных системах этот недостаток часто компенсируется: gRPC сервисы экспортируют схемы через reflection, а observability-платформы (Jaeger, Zipkin) умеют декодировать Protobuf на лету.Observability (наблюдаемость) — это свойство системы, позволяющее понимать её внутреннее состояние по внешним выходным данным: метрикам, логам и трассировкам. В распределённых системах observability критична для диагностики проблем.
По интеграции с вебом
JSON нативно поддерживается браузерами и JavaScript.
Protobuf в браузере требует:
Использования protobuf-js для сериализации/десериализации.
Передачи бинарных данных через ArrayBuffer или base64.
gRPC-Web для взаимодействия с gRPC-сервисами (требует прокси, например Envoy).
Поэтому для публичных API, потребляемых браузерными клиентами, JSON остаётся стандартом. Protobuf доминирует во внутреннем межсервисном взаимодействии (backend-to-backend).
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Путь байтов в памяти JVM при работе с Protobuf
1. Генерация кода и загрузка классов
.proto файл компилируется
В отличие от JSON, где парсер (Jackson) использует рефлексию для анализа POJO-классов во время выполнения, Protobuf-классы уже содержат весь необходимый код сериализации/десериализации. Это означает, что нет runtime-аллокаций объектов
2. Создание сообщения: Builder и аллокации
При вызове
Создаётся объект
Каждый вызов
При вызове
Важно:
3. Сериализация: от объекта к байтам
Вызов
Сначала вычисляется размер сериализованного сообщения через
Выделяется
Возвращается
Если
4. Десериализация: от байтов к объекту
Вызов
Создаётся
Парсер читает байты последовательно. Для каждого поля:
Читает тег (varint) — определяет field number и wire type.
По field number находит соответствующее поле в сгенерированном коде (switch по номеру).
Читает значение: для varint — декодирует число; для length-delimited — читает длину, затем
Устанавливает значение в Builder.
После прочтения всех полей вызывается
5. Работа GC с Protobuf-объектами
Protobuf спроектирован с учётом минимизации давления на GC:
Immutable объекты: после создания
Отсутствие рефлексии: нет постоянного создания объектов
Повторное использование Builder: в высоконагруженных системах можно использовать пул Builder-ов, чтобы избежать аллокаций на каждое сообщение. Однако стандартный generated code не поддерживает пулинг из коробки — это требует ручной реализации или использования фреймворков вроде grpc-java, который переиспользует буферы.
ByteString и пул строк:
Large messages: если сообщение содержит большое поле
6. Вложенные сообщения и глубина рекурсии
Вложенные сообщения в Protobuf создают дерево объектов в куче. Каждый уровень вложенности — это отдельная аллокация. Для глубоко вложенных сообщений (глубина 10+) это создаёт давление на Eden.
7. Сравнение аллокаций: Protobuf vs JSON
Для сообщения
Protobuf сериализация: аллокации =
Jackson JSON сериализация: аллокации =
Protobuf десериализация: аллокации = CodedInputStream + Book.Builder + Book + ByteString для каждой строки.
Jackson JSON десериализация: аллокации = JsonParser + JsonNode дерево или POJO + String для каждого поля + объекты рефлексии (при первом использовании класса).
Protobuf создаёт в 2–5 раз меньше объектов в куче при сериализации/десериализации, что напрямую снижает частоту Minor GC.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
1. Генерация кода и загрузка классов
.proto файл компилируется
protoc в .java файлы, которые затем компилируются javac в .class файлы. При загрузке классов JVM (например, Book.class) класс попадает в Metaspace — область памяти для метаданных классов. Book.class, Book.Builder.class, BookOrBuilder.class и внутренние классы занимают Metaspace. Эти классы остаются там до выгрузки ClassLoader.В отличие от JSON, где парсер (Jackson) использует рефлексию для анализа POJO-классов во время выполнения, Protobuf-классы уже содержат весь необходимый код сериализации/десериализации. Это означает, что нет runtime-аллокаций объектов
Method, Field, Constructor при каждой операции — всё решено на этапе кодогенерации.2. Создание сообщения: Builder и аллокации
Book book = Book.newBuilder()
.setTitle("Clean Code")
.setYear(2008)
.build();
При вызове
Book.newBuilder():Создаётся объект
Book.Builder в Young Generation (Eden). Builder содержит мутабельные поля, соответствующие всем полям сообщения.Каждый вызов
setTitle() создаёт внутреннее представление строки. В Protobuf строки хранятся как ByteString — обёртка над byte[], содержащая UTF-8 байты. При передаче Java-String происходит кодирование UTF-16 (внутреннее представление Java-строки) в UTF-8. Это создаёт временный byte[] в EdenByteString — это immutable обёртка над массивом байтов в Protobuf Java runtime. Она аналогична String, но для сырых байтов, и предоставляет zero-copy операции (например, substring() без копирования массива).
При вызове
build() Builder создаёт финальный immutable объект Book. Это новая аллокация в Eden. Builder проверяет валидность (например, required-поля в proto2; в proto3 такой проверки нет) и копирует состояние в Book. После build() объект Builder становится мусором (если на него не осталось ссылок).Важно:
Book — immutable. Все его поля либо примитивы (хранятся в самом объекте), либо ссылки на immutable объекты (ByteString, другие сообщения). Это означает, что Book безопасно передавать между потоками без синхронизации.3. Сериализация: от объекта к байтам
Вызов
book.toByteArray():Сначала вычисляется размер сериализованного сообщения через
getSerializedSize(). Этот метод рекурсивно обходит все поля, суммируя размеры тегов, значений и length-delimiters. Для вложенных сообщений вызывается их getSerializedSize(). Этот проход не создаёт новых объектов, только читает поля и выполняет арифметику на стеке.Выделяется
byte[] точного размера в Young Generation (Eden). Это единственная аллокация для всей сериализации (не считая временных объектов для строк, если они ещё не закодированы).CodedOutputStream оборачивает byte[] и пишет в него wire format. Каждое поле кодируется напрямую: примитивы через битовые операции, строки через копирование ByteString/byte[] в целевой массив.Возвращается
byte[]. Этот массив — единственный объект, который покидает метод сериализации. Все промежуточные вычисления происходят на стеке или с использованием существующих объектов.Если
byte[] передаётся в сетевой слой (Netty, gRPC), он может быть обёрнут в ByteBuf — абстракцию над байтовым буфером в Netty. ByteBuf может использовать прямую (direct) память вне кучи JVM, что позволяет передать данные в сокет через zero-copy без участия GC.Direct memory (прямая память, off-heap) — это область нативной памяти вне кучи JVM, выделяемая через ByteBuffer.allocateDirect(). Данные в direct memory не подлежат сборке мусора JVM и могут передаваться напрямую в системные вызовы ОС (например, запись в сокет), минуя копирование из кучи.
4. Десериализация: от байтов к объекту
Вызов
Book.parseFrom(byte[] data):Создаётся
CodedInputStream — лёгкий объект, оборачивающий byte[] и отслеживающий позицию чтения. Аллокация в Eden.parseFrom создаёт Book.Builder (аллокация в Eden).Парсер читает байты последовательно. Для каждого поля:
Читает тег (varint) — определяет field number и wire type.
По field number находит соответствующее поле в сгенерированном коде (switch по номеру).
Читает значение: для varint — декодирует число; для length-delimited — читает длину, затем
byte[], оборачивает в ByteString.Устанавливает значение в Builder.
После прочтения всех полей вызывается
build(), который создаёт immutable Book в Eden.CodedInputStream и Builder становятся мусором. Если сообщение большое и парсинг длительный, Builder может пережить Minor GC и попасть в Survivor Space.5. Работа GC с Protobuf-объектами
Protobuf спроектирован с учётом минимизации давления на GC:
Immutable объекты: после создания
Book не изменяется. GC не тратит время на отслеживание изменений состояния.Отсутствие рефлексии: нет постоянного создания объектов
Field, Method, аннотаций. Всё статически скомпилировано.Повторное использование Builder: в высоконагруженных системах можно использовать пул Builder-ов, чтобы избежать аллокаций на каждое сообщение. Однако стандартный generated code не поддерживает пулинг из коробки — это требует ручной реализации или использования фреймворков вроде grpc-java, который переиспользует буферы.
Object pooling (пулинг объектов) — это паттерн, при котором созданные объекты не уничтожаются, а возвращаются в пул для повторного использования. Это снижает нагрузку на GC, но требует аккуратного управления состоянием объектов.
ByteString и пул строк:
ByteString хранит byte[] напрямую. Если одна и та же строка встречается в тысячах сообщений, каждое сообщение содержит свой ByteString со своим byte[]. В отличие от Java-String, ByteString не интернируется автоматически. Для часто повторяющихся строковых значений можно использовать ByteString.copyFromUtf8(string).toStringUtf8() с ручным кэшированием, но это нестандартная практика.Large messages: если сообщение содержит большое поле
bytes (например, изображение 10 МБ), соответствующий byte[] создаётся в куче. Если это repeated поле с множеством больших объектов, они могут быстро заполнить Old Generation. В таких случаях используются потоковые API или разбиение на чанки.6. Вложенные сообщения и глубина рекурсии
Вложенные сообщения в Protobuf создают дерево объектов в куче. Каждый уровень вложенности — это отдельная аллокация. Для глубоко вложенных сообщений (глубина 10+) это создаёт давление на Eden.
CodedInputStream имеет лимит на глубину вложенности (по умолчанию 100), чтобы предотвратить stack overflow и утечки памяти при парсинге злонамеренных сообщений.7. Сравнение аллокаций: Protobuf vs JSON
Для сообщения
Book из примера:Protobuf сериализация: аллокации =
Book.Builder (если ещё не создан) + byte[] результата. Временных объектов минимум.Jackson JSON сериализация: аллокации =
StringWriter или byte[] + промежуточные char[]/byte[] для кодирования + объекты JsonGenerator. При сериализации в String — объект String + внутренний byte[].Protobuf десериализация: аллокации = CodedInputStream + Book.Builder + Book + ByteString для каждой строки.
Jackson JSON десериализация: аллокации = JsonParser + JsonNode дерево или POJO + String для каждого поля + объекты рефлексии (при первом использовании класса).
Protobuf создаёт в 2–5 раз меньше объектов в куче при сериализации/десериализации, что напрямую снижает частоту Minor GC.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Продвинутые возможности proto3
Well-known types
Proto3 предоставляет стандартные типы в пакете
Maps
Proto3 поддерживает нативные ассоциативные массивы:
На уровне wire format map кодируется как repeated message, где каждый элемент — message с двумя полями: key и value. В Java маппится на Map<String, Book>.
Oneof
oneof — это конструкция, гарантирующая, что только одно из перечисленных полей может быть установлено в один момент времени. Аналог union в C.
В Java oneof генерирует enum PayloadCase и методы hasBook(), hasError(), getPayloadCase().
Custom options
Protobuf позволяет определять собственные опции через расширения:
Эти опции не влияют на wire format, но доступны через reflection API Protobuf для генерации валидаторов, документации и т.д.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
Well-known types
Proto3 предоставляет стандартные типы в пакете
google.protobuf:Timestamp — точка во времени (секунды + наносекунды с эпохи Unix).Duration — временной интервал.Any — контейнер для произвольного сообщения (типа Object в Java).Empty — пустое сообщение для методов без параметров/результата.Struct, Value, ListValue — динамически типизированные структуры (аналог JSON-объектов).Wrapper types: Int32Value, StringValue, BoolValue — обёртки для скалярных типов, позволяющие отличать отсутствие поля от значения по умолчанию.import "google/protobuf/timestamp.proto";
message Event {
string name = 1;
google.protobuf.Timestamp occurred_at = 2;
}
Maps
Proto3 поддерживает нативные ассоциативные массивы:
message Library {
map<string, Book> books_by_isbn = 1;
}На уровне wire format map кодируется как repeated message, где каждый элемент — message с двумя полями: key и value. В Java маппится на Map<String, Book>.
Oneof
oneof — это конструкция, гарантирующая, что только одно из перечисленных полей может быть установлено в один момент времени. Аналог union в C.
message Result {
oneof payload {
Book book = 1;
string error = 2;
}
}В Java oneof генерирует enum PayloadCase и методы hasBook(), hasError(), getPayloadCase().
Custom options
Protobuf позволяет определять собственные опции через расширения:
extend google.protobuf.FieldOptions {
string validation_regex = 50001;
}
message User {
string email = 1 [(validation_regex) = "^[a-z]+@[a-z]+\\.[a-z]+$"];
}Эти опции не влияют на wire format, но доступны через reflection API Protobuf для генерации валидаторов, документации и т.д.
#Java #для_новичков #beginner #IO #NIO #Serialize #Protobuf
👍4
Раздел 11. Работа с файлами, I/O и сетью (NIO.2)
Глава 3. Сериализация и форматы обмена
Apache Avro — бинарный формат сериализации с динамической схемой
Apache Avro — это формат сериализации данных и протокол удалённого вызова процедур (RPC), разработанный в рамках проекта Apache Hadoop. Avro был создан Дугом Каттингом в 2009 году как ответ на необходимость иметь компактный, быстрый и расширяемый формат для хранения и передачи больших данных в распределённых системах. На момент 2026 года актуальная версия спецификации — Avro 1.12.x, а проект остаётся одним из столпов экосистемы Apache Big Data.
В отличие от Protocol Buffers и Thrift, где схема является внешним контрактом, разделяемым между producer и consumer, Avro следует принципу self-describing (самоописывающихся) данных: каждый блок данных несёт в себе свою схему или ссылку на неё. Это фундаментальное архитектурное решение определяет все сильные и слабые стороны Avro.
Архитектура Avro: схема + данные
Avro разделяет понятия схемы (schema) и данных (datum). Схема описывает структуру данных на языке JSON. Данные сериализуются в бинарный формат, который интерпретируется исключительно через схему. Без схемы бинарный поток Avro — это бессмысленная последовательность байтов, так как в нём отсутствуют теги полей и информация о типах.
Это кардинально отличается от Protobuf, где wire format содержит теги (field numbers), и парсер может прочитать сообщение, зная только .proto файл. В Avro парсер должен иметь схему, чтобы понять, где заканчивается одно поле и начинается другое. Например, для строки Avro записывает длину в varint, затем байты. Для массива — блоки элементов с указанием размера. Но чтобы понять, что следующее значение — это строка, а не int, нужна схема.
Почему это важно
Такой подход даёт две ключевые возможности:
Компактность: в бинарном потоке нет никаких метаданных на уровне полей — ни имён, ни тегов, ни типов. Это делает Avro ещё компактнее Protobuf для многих сценариев.
Динамическая типизация: любой потребитель, получивший данные вместе со схемой, может их десериализовать без предварительно сгенерированных классов.
Но есть и цена: схема должна быть доступна при чтении. В долгосрочном хранении (HDFS, S3) схема обычно записывается в заголовок файла. В потоковой передаче (Kafka) схема регистрируется в Schema Registry и передаётся по ссылке (ID схемы), а не целиком.
Схема Avro
Схема Avro — это JSON-документ, описывающий тип данных. Avro поддерживает примитивные типы, сложные типы и логические типы.
Примитивные типы
Сложные типы
Пример схемы
Ключевые элементы:
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
Глава 3. Сериализация и форматы обмена
Apache Avro — бинарный формат сериализации с динамической схемой
Apache Avro — это формат сериализации данных и протокол удалённого вызова процедур (RPC), разработанный в рамках проекта Apache Hadoop. Avro был создан Дугом Каттингом в 2009 году как ответ на необходимость иметь компактный, быстрый и расширяемый формат для хранения и передачи больших данных в распределённых системах. На момент 2026 года актуальная версия спецификации — Avro 1.12.x, а проект остаётся одним из столпов экосистемы Apache Big Data.
В отличие от Protocol Buffers и Thrift, где схема является внешним контрактом, разделяемым между producer и consumer, Avro следует принципу self-describing (самоописывающихся) данных: каждый блок данных несёт в себе свою схему или ссылку на неё. Это фундаментальное архитектурное решение определяет все сильные и слабые стороны Avro.
Self-describing data — это данные, которые содержат внутри себя или в непосредственной близости полное описание своей структуры (схему). Это позволяет любому потребителю, не имеющему предварительного знания о формате, корректно интерпретировать содержимое.
Big Data — это термин, обозначающий наборы данных, объём, скорость поступления и разнообразие которых настолько велики, что традиционные инструменты обработки не справляются с ними. Hadoop — это фреймворк для распределённой обработки Big Data на кластерах commodity-оборудования.
Архитектура Avro: схема + данные
Avro разделяет понятия схемы (schema) и данных (datum). Схема описывает структуру данных на языке JSON. Данные сериализуются в бинарный формат, который интерпретируется исключительно через схему. Без схемы бинарный поток Avro — это бессмысленная последовательность байтов, так как в нём отсутствуют теги полей и информация о типах.
Это кардинально отличается от Protobuf, где wire format содержит теги (field numbers), и парсер может прочитать сообщение, зная только .proto файл. В Avro парсер должен иметь схему, чтобы понять, где заканчивается одно поле и начинается другое. Например, для строки Avro записывает длину в varint, затем байты. Для массива — блоки элементов с указанием размера. Но чтобы понять, что следующее значение — это строка, а не int, нужна схема.
Почему это важно
Такой подход даёт две ключевые возможности:
Компактность: в бинарном потоке нет никаких метаданных на уровне полей — ни имён, ни тегов, ни типов. Это делает Avro ещё компактнее Protobuf для многих сценариев.
Динамическая типизация: любой потребитель, получивший данные вместе со схемой, может их десериализовать без предварительно сгенерированных классов.
Но есть и цена: схема должна быть доступна при чтении. В долгосрочном хранении (HDFS, S3) схема обычно записывается в заголовок файла. В потоковой передаче (Kafka) схема регистрируется в Schema Registry и передаётся по ссылке (ID схемы), а не целиком.
Schema Registry — это сервис (например, Confluent Schema Registry), который хранит версии схем Avro, Protobuf и JSON Schema. Producer регистрирует схему и получает уникальный ID. Consumer получает ID вместе с сообщением и запрашивает схему из реестра. Это избавляет от необходимости передавать полную схему в каждом сообщении.
Схема Avro
Схема Avro — это JSON-документ, описывающий тип данных. Avro поддерживает примитивные типы, сложные типы и логические типы.
Примитивные типы
null — отсутствие значения.boolean — true/false.int — 32-битное целое со знаком.long — 64-битное целое со знаком.float — 32-битное IEEE 754 float.double — 64-битное IEEE 754 double.bytes — последовательность байтов.string — строка в UTF-8.Сложные типы
record — именованная коллекция полей (аналог struct или class).enum — именованное множество значений.array — упорядоченная коллекция однотипных элементов.map — ассоциативный массив со строковыми ключами.union — объединение типов, записывается как JSON-массив. Например, ["null", "string"] означает nullable string.fixed — массив байтов фиксированного размера.Пример схемы
{
"type": "record",
"name": "Book",
"namespace": "com.example.library",
"doc": "Описание книги в библиотеке",
"fields": [
{
"name": "title",
"type": "string",
"doc": "Название книги"
},
{
"name": "author",
"type": "string"
},
{
"name": "year",
"type": ["null", "int"],
"default": null
},
{
"name": "price",
"type": "double",
"default": 0.0
},
{
"name": "tags",
"type": {
"type": "array",
"items": "string"
},
"default": []
},
{
"name": "metadata",
"type": {
"type": "map",
"values": "string"
},
"default": {}
}
]
}Ключевые элементы:
type: "record" — объявляет именованную запись.name и namespace — полное имя типа, аналог package + class в Java.doc — документация, которая может генерироваться в Javadoc при кодогенерации.fields — массив полей. Каждое поле имеет name, type и опционально default, doc, order (для сортировки).type: ["null", "int"] — union type, реализующий nullable int. Порядок в union важен: при сериализации Avro записывает индекс типа (varint), затем значение. null имеет индекс 0, int — индекс 1.Union type (объединение типов) — это тип данных, значение которого может принадлежать одному из нескольких указанных типов. В Avro union записывается как массив типов, и при сериализации перед значением записывается индекс выбранного типа.
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
👍4
Динамическая типизация: GenericRecord vs SpecificRecord
Avro предоставляет два основных API для работы с данными: Generic и Specific.
Generic API
Generic API не требует кодогенерации. Данные представляются как
В этом примере
Specific API
Specific API требует кодогенерации: из схемы Avro генерируются Java-классы через
Specific API быстрее Generic, так как доступ к полям идёт через
Версионирование: Reader Schema и Writer Schema
Avro имеет наиболее элегантную среди бинарных форматов систему schema evolution (эволюции схемы). Ключевая концепция — разделение схемы записи (writer schema) и схемы чтения (reader schema).
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
Avro предоставляет два основных API для работы с данными: Generic и Specific.
Generic API
Generic API не требует кодогенерации. Данные представляются как
GenericRecord — динамическая структура, аналогичная Map<String, Object>, но типобезопасная на уровне схемы.import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.GenericDatumWriter;
import org.apache.avro.generic.GenericDatumReader;
import org.apache.avro.io.DatumWriter;
import org.apache.avro.io.DatumReader;
import org.apache.avro.io.Encoder;
import org.apache.avro.io.Decoder;
import org.apache.avro.io.EncoderFactory;
import org.apache.avro.io.DecoderFactory;
import java.io.ByteArrayOutputStream;
import java.io.ByteArrayInputStream;
public class AvroGenericExample {
// Схема как JSON-строка
private static final String BOOK_SCHEMA = "{"
+ "\"type\":\"record\","
+ "\"name\":\"Book\","
+ "\"namespace\":\"com.example\","
+ "\"fields\":["
+ " {\"name\":\"title\", \"type\":\"string\"},"
+ " {\"name\":\"author\", \"type\":\"string\"},"
+ " {\"name\":\"year\", \"type\":[\"null\",\"int\"], \"default\":null},"
+ " {\"name\":\"price\", \"type\":\"double\", \"default\":0.0}"
+ "]}";
public byte[] serializeBook() throws Exception {
// Парсинг схемы из JSON-строки
Schema schema = new Schema.Parser().parse(BOOK_SCHEMA);
// Создание GenericRecord — динамического контейнера данных
// GenericRecord хранит значения полей в массиве Object[]
GenericRecord book = new GenericData.Record(schema);
book.put("title", "Clean Code");
book.put("author", "Robert C. Martin");
book.put("year", 2008); // Avro автоматически оборачивает в union
book.put("price", 42.50);
// GenericDatumWriter — сериализатор для GenericRecord
// DatumWriter — это интерфейс, абстрагирующий запись Avro-данных
DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema);
ByteArrayOutputStream out = new ByteArrayOutputStream();
// BinaryEncoder — кодировщик в бинарный формат Avro
// EncoderFactory — фабрика для создания encoder-ов (бинарных, JSON и др.)
Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
// Сериализация: writer читает поля из GenericRecord по схеме
// и записывает их в encoder в бинарном формате
writer.write(book, encoder);
encoder.flush(); // сброс буфера encoder в поток
return out.toByteArray();
}
public GenericRecord deserializeBook(byte[] data) throws Exception {
Schema schema = new Schema.Parser().parse(BOOK_SCHEMA);
// GenericDatumReader — десериализатор
DatumReader<GenericRecord> reader = new GenericDatumReader<>(schema);
ByteArrayInputStream in = new ByteArrayInputStream(data);
// BinaryDecoder — декодировщик из бинарного формата
Decoder decoder = DecoderFactory.get().binaryDecoder(in, null);
// Десериализация: reader читает байты через decoder
// и строит GenericRecord по схеме
return reader.read(null, decoder);
}
}
В этом примере
GenericRecord — это реализация интерфейса IndexedRecord, которая хранит значения полей в массиве Object[]. Поле title имеет позицию 0, author — 1, year — 2 и т.д. Метод put(String name, Object value) ищет позицию поля по имени через схему и записывает значение в массив. Это медленнее прямого доступа к полю, но даёт полную динамичность.Specific API
Specific API требует кодогенерации: из схемы Avro генерируются Java-классы через
avro-tools или Maven-плагин. Сгенерированные классы наследуют org.apache.avro.specific.SpecificRecordBase и предоставляют типизированные геттеры и сеттеры.// Сгенерированный класс (упрощённо)
public class Book extends org.apache.avro.specific.SpecificRecordBase {
private CharSequence title;
private CharSequence author;
private Integer year;
private double price;
// Avro требует конструктор по умолчанию
public Book() {}
// put(int field, Object value) — индексированный доступ
public void put(int field, Object value) {
switch (field) {
case 0: title = (CharSequence) value; break;
case 1: author = (CharSequence) value; break;
case 2: year = (Integer) value; break;
case 3: price = (Double) value; break;
default: throw new AvroRuntimeException("Bad index");
}
}
public Object get(int field) {
switch (field) {
case 0: return title;
case 1: return author;
case 2: return year;
case 3: return price;
default: throw new AvroRuntimeException("Bad index");
}
}
// Типизированные геттеры/сеттеры
public CharSequence getTitle() { return title; }
public void setTitle(CharSequence value) { this.title = value; }
}
Specific API быстрее Generic, так как доступ к полям идёт через
switch по индексу, а не через поиск по имени в HashMap. Однако он требует перекомпиляции при изменении схемы.Версионирование: Reader Schema и Writer Schema
Avro имеет наиболее элегантную среди бинарных форматов систему schema evolution (эволюции схемы). Ключевая концепция — разделение схемы записи (writer schema) и схемы чтения (reader schema).
Writer schema — это схема, которая использовалась при сериализации данных. Reader schema — это схема, которую ожидает потребитель при десериализации. Avro гарантирует совместимость, если reader schema может быть получена из writer schema через набор правил преобразования.
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
👍4
Правила резолюции схем
Avro определяет строгие правила, как reader schema может отличаться от writer schema:
Добавление поля с default: writer записывает без нового поля, reader читает и использует default. Это backward compatible.
Удаление поля с default: writer записывает с полем, reader игнорирует его. Это forward compatible.
Изменение типа: разрешено только между совместимыми типами (например, int -> long). Avro выполняет implicit promotion.
Изменение порядка полей: разрешено — поля идентифицируются по имени, а не по позиции.
Изменение имени поля: эквивалентно удалению старого и добавлению нового. Для сохранения совместимости используются алиасы (
При десериализации Avro сопоставляет поля writer и reader по имени. Поле
Implicit promotion (неявное повышение типа) — это правило Avro, позволяющее читать значение одного типа как другой без потери данных. Например,
Сжатие и блоки
Avro поддерживает сжатие на уровне блоков данных. При записи в файл данные группируются в блоки фиксированного размера (по умолчанию), и каждый блок сжимается независимо.
Codec (кодек) — это алгоритм сжатия/распаковки данных. В Avro кодек применяется к блокам записей, а не к каждой записи отдельно. Это повышает эффективность сжатия за счёт большего объёма данных на входе кодека.
Структура Avro-файла
Avro-файл (Object Container File) имеет следующую структуру:
Magic (4 байта):
Metadata (map): содержит
Sync marker (16 байт): случайная последовательность, разделяющая блоки.
Blocks: последовательность блоков, каждый из которых содержит:
Количество записей в блоке (long).
Размер сжатых данных (long).
Сжатые данные (сериализованные записи).
Sync marker.
Такая структура позволяет эффективно разбивать файлы на части (split) в Hadoop MapReduce: каждый mapper может начать чтение с ближайшего sync marker.
Использование в Kafka
Avro широко используется как формат сообщений в Apache Kafka, особенно в экосистеме Confluent. Интеграция работает через Schema Registry:
В сообщении Kafka с Avro первые 5 байт — служебные: 1 байт magic (
Это означает, что схема не передаётся в каждом сообщении — только 4-байтовый ID. Это критично для производительности: передача полной JSON-схемы (1–5 КБ) в каждом сообщении сделала бы Kafka непригодной для high-throughput сценариев.
Путь байтов в памяти JVM при работе с Avro
1. Парсинг схемы: от JSON к объекту Schema
Когда вы вызываете
JSON-строка схемы — это объект
Результат парсинга — объект
Полное имя типа (
Список полей (
Для каждого поля: имя, позиция, тип, default value, aliases.
Схема хранится в виде дерева объектов в куче.
Объект
2. Создание GenericRecord
При создании
Аллоцируется объект
Ссылку на
Массив
Массив
При вызове
Имя поля
Значение
Utf8 — это класс Avro, реализующий
Если используется
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
Avro определяет строгие правила, как reader schema может отличаться от writer schema:
Добавление поля с default: writer записывает без нового поля, reader читает и использует default. Это backward compatible.
Удаление поля с default: writer записывает с полем, reader игнорирует его. Это forward compatible.
Изменение типа: разрешено только между совместимыми типами (например, int -> long). Avro выполняет implicit promotion.
Изменение порядка полей: разрешено — поля идентифицируются по имени, а не по позиции.
Изменение имени поля: эквивалентно удалению старого и добавлению нового. Для сохранения совместимости используются алиасы (
aliases).// Writer schema (старая версия)
{
"type": "record",
"name": "Book",
"fields": [
{"name": "title", "type": "string"},
{"name": "author", "type": "string"}
]
}
// Reader schema (новая версия)
{
"type": "record",
"name": "Book",
"fields": [
{"name": "title", "type": "string"},
{"name": "author", "type": "string"},
{"name": "year", "type": ["null", "int"], "default": null}
]
}
При десериализации Avro сопоставляет поля writer и reader по имени. Поле
year отсутствует в writer schema, поэтому reader использует default: null. Это работает автоматически на уровне DatumReader, без участия прикладного кода.Implicit promotion (неявное повышение типа) — это правило Avro, позволяющее читать значение одного типа как другой без потери данных. Например,
int может быть прочитан как long, float как double. Обратное направление запрещено, так как может привести к потере точности.Сжатие и блоки
Avro поддерживает сжатие на уровне блоков данных. При записи в файл данные группируются в блоки фиксированного размера (по умолчанию), и каждый блок сжимается независимо.
import org.apache.avro.file.DataFileWriter;
import org.apache.avro.file.CodecFactory;
public void writeCompressedFile(Schema schema, List<GenericRecord> records) throws Exception {
// DataFileWriter — высокоуровневый writer для Avro-файлов с заголовком
DataFileWriter<GenericRecord> writer = new DataFileWriter<>(new GenericDatumWriter<>(schema));
// Установка кодека сжатия
// Snappy — быстрый кодек от Google, оптимизированный на скорость
// Deflate — стандартный zlib-сжатие
// Zstandard (zstd) — современный кодек с лучшим соотношением скорость/сжатие
writer.setCodec(CodecFactory.snappyCodec());
// Схема записывается в заголовок файла
writer.create(schema, new File("books.avro"));
for (GenericRecord record : records) {
writer.append(record);
}
writer.close();
}
Codec (кодек) — это алгоритм сжатия/распаковки данных. В Avro кодек применяется к блокам записей, а не к каждой записи отдельно. Это повышает эффективность сжатия за счёт большего объёма данных на входе кодека.
Структура Avro-файла
Avro-файл (Object Container File) имеет следующую структуру:
Magic (4 байта):
Obj\x01 — идентификатор формата.Metadata (map): содержит
avro.schema (JSON-схема в виде строки) и avro.codec (имя кодека).Sync marker (16 байт): случайная последовательность, разделяющая блоки.
Blocks: последовательность блоков, каждый из которых содержит:
Количество записей в блоке (long).
Размер сжатых данных (long).
Сжатые данные (сериализованные записи).
Sync marker.
Такая структура позволяет эффективно разбивать файлы на части (split) в Hadoop MapReduce: каждый mapper может начать чтение с ближайшего sync marker.
Split — это разбиение большого файла на части для параллельной обработки. В Hadoop InputFormat определяет, как файл разбивается на splits, которые затем обрабатываются отдельными mapper-ами.
Использование в Kafka
Avro широко используется как формат сообщений в Apache Kafka, особенно в экосистеме Confluent. Интеграция работает через Schema Registry:
import io.confluent.kafka.serializers.KafkaAvroSerializer;
import io.confluent.kafka.serializers.KafkaAvroDeserializer;
import org.apache.kafka.clients.producer.ProducerRecord;
public class KafkaAvroProducer {
public void sendBook(KafkaProducer<String, GenericRecord> producer, GenericRecord book) {
// KafkaAvroSerializer автоматически:
// 1. Регистрирует схему в Schema Registry (если ещё не зарегистрирована)
// 2. Получает schema ID
// 3. Записывает в сообщение: [magic byte (1)][schema ID (4)][payload]
ProducerRecord<String, GenericRecord> record =
new ProducerRecord<>("books-topic", book.get("title").toString(), book);
producer.send(record);
}
}
В сообщении Kafka с Avro первые 5 байт — служебные: 1 байт magic (
0x00), 4 байта ID схемы в Schema Registry (big-endian int). Остальное — бинарный payload Avro. Consumer получает ID, запрашивает схему из реестра и десериализует payload.Это означает, что схема не передаётся в каждом сообщении — только 4-байтовый ID. Это критично для производительности: передача полной JSON-схемы (1–5 КБ) в каждом сообщении сделала бы Kafka непригодной для high-throughput сценариев.
Throughput — это пропускная способность системы, измеряемая количеством операций или объёмом данных, обрабатываемых за единицу времени. В Kafka throughput измеряется в сообщениях в секунду или мегабайтах в секунду.
Путь байтов в памяти JVM при работе с Avro
1. Парсинг схемы: от JSON к объекту Schema
Когда вы вызываете
new Schema.Parser().parse(jsonString), происходит следующее:JSON-строка схемы — это объект
String в куче (Heap), обычно в Young Generation (Eden). Если схема загружается из файла, предварительно создаётся byte[] с содержимым файла, который декодируется в String через InputStreamReader.Schema.Parser использует Jackson (да, внутри Avro используется Jackson для парсинга JSON-схемы) для разбора JSON. Это создаёт временные объекты: JsonNode, ArrayNode, ObjectNode, Iterator. Все они аллоцируются в Eden.Результат парсинга — объект
Schema (или Schema.RecordSchema, Schema.ArraySchema и т.д.). Этот объект содержит:Полное имя типа (
name + namespace).Список полей (
List<Schema.Field>).Для каждого поля: имя, позиция, тип, default value, aliases.
Схема хранится в виде дерева объектов в куче.
Объект
Schema обычно кэшируется приложением (singleton) и живёт в Old Generation (Tenured), так как используется многократно. Промежуточные объекты Jackson (дерево JSON) становятся мусором и собираются при следующей Minor GC.2. Создание GenericRecord
GenericRecord record = new GenericData.Record(schema);
record.put("title", "Clean Code");
При создании
GenericData.Record:Аллоцируется объект
Record в Eden. Record содержит:Ссылку на
Schema (уже существует в Old Gen).Массив
Object[] values размером, равным количеству полей. Этот массив создаётся в Eden.Массив
int[] fieldFlags для отслеживания установленных полей.При вызове
put("title", value):Имя поля
"title" — строка в куче. Avro ищет позицию поля через Schema.getField(String). Schema кэширует Map<String, Field> (обычно HashMap), поэтому поиск выполняется быстро, но создаёт временный объект Integer для хэш-кода (автобоксинг).Значение
"Clean Code" — строка Java. Для поля типа string Avro ожидает CharSequence, а не обязательно java.lang.String. Это позволяет использовать Utf8 — оптимизированную реализацию CharSequence от Avro, которая хранит строку как byte[] в UTF-8, избегая двойного хранения (UTF-16 в Java String + UTF-8 в Avro).Utf8 — это класс Avro, реализующий
CharSequence и хранящий строку как изменяемый массив байтов в UTF-8. Это экономит память при сериализации, так как не требуется перекодирование из UTF-16 Java-строки в UTF-8 байты.Если используется
Utf8 вместо String, аллокация String избегается. Однако если прикладной код передаёт String, Avro может либо сконвертировать её в Utf8 (создав новый объект), либо оставить как String (в зависимости от настроек GenericData).#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
👍3
3. Сериализация: от GenericRecord к byte[]
Вызов
Читает значение из
Определяет тип поля из схемы.
Вызывает соответствующий метод
Для строк (
Для union-типов (
После завершения
Временные объекты сериализации — внутренние структуры
4. Десериализация: от byte[] к GenericRecord
Вызов
BinaryDecoder читает байты из InputStream. Для каждого поля схемы:
Читает значение согласно типу поля.
Для строк: читает длину (varint), затем выделяет byte[] нужного размера, читает байты, оборачивает в Utf8.
Для union: читает индекс типа (varint), затем значение соответствующего типа.
Записывает значение в Record.values[pos].
Важная особенность: если reader schema отличается от writer schema, GenericDatumReader выполняет schema resolution (разрешение схемы) на лету. Это означает, что для каждого поля reader сопоставляет его с полем writer по имени, проверяет совместимость типов, применяет promotion (например, int -> long) и default values. Schema resolution требует дополнительных вычислений и создаёт временные объекты (например, ResolvingDecoder), но не требует аллокаций на каждое поле — логика реализована через state machine в decoder.
После десериализации BinaryDecoder становится мусором. Если decoder создан с DecoderFactory.get().binaryDecoder(in, reuse), он может быть переиспользован, избегая аллокации.
5. Работа GC с Avro-объектами
Avro создаёт значительно больше временных объектов, чем Protobuf, но меньше, чем Jackson JSON:
Schema: хранится в Old Generation как singleton. Размер объекта Schema зависит от сложности схемы: простая схема с 5 полями — несколько сотен байт, сложная вложенная схема — несколько килобайт.
GenericRecord: создаётся для каждой записи. Содержит
Utf8: Avro предпочитает
DataFileWriter / DataFileReader: при работе с файлами Avro использует буферизацию.
Object reuse: Avro предоставляет механизмы повторного использования объектов для снижения давления на GC. При чтении файла можно передать существующий
6. Avro в Kafka: путь байтов
При работе с Kafka и Confluent Serializer:
Producer вызывает
Сериализатор извлекает схему из
Проверяет кэш схем (локальный
Если нет — отправляет HTTP-запрос в Schema Registry для регистрации. Получает ID (int).
Сериализует record в
Формирует итоговое сообщение:
Этот
На стороне Consumer:
Читает magic byte и schema ID.
Проверяет локальный кэш схем по ID. Если нет — запрашивает схему из Schema Registry по HTTP.
Использует схему для создания
Десериализует payload в
Кэширование схем на клиенте критично: без него каждое сообщение порождало бы HTTP-запрос к реестру. Кэш — это
Сравнение Avro и Protobuf
Кодогенерация
Protobuf требует кодогенерации: .proto файл компилируется в Java-классы через protoc. Avro предоставляет выбор: Generic API (без кодогенерации) и Specific API (с кодогенерацией через
Оверхед схемы
В Protobuf схема существует только как внешний контракт. В бинарном сообщении нет схемы — только теги полей. В Avro бинарное сообщение без схемы нечитаемо. Поэтому Avro-файлы содержат схему в заголовке, а Kafka-сообщения содержат ID схемы из реестра.
При записи в файл: оверхед схемы амортизируется — одна схема на миллионы записей.
При передаче по сети через Schema Registry: оверхед — 5 байт на сообщение (magic + ID).
Без Schema Registry: пришлось бы передавать полную JSON-схему в каждом сообщении, что сделало бы Avro непригодным для messaging.
Типизация
Protobuf — статически типизирован через сгенерированные классы. Avro Generic — динамически типизирован через
Schema evolution
Avro имеет более мощную и гибкую систему schema evolution благодаря разделению reader/writer schema. Protobuf поддерживает forward/backward compatibility через правила добавления/удаления полей, но не имеет встроенного механизма разрешения различий между схемами на уровне runtime — это должно быть реализовано прикладным кодом.
Производительность
Protobuf быстрее Avro по нескольким причинам:
Сгенерированный код Protobuf содержит жёстко закодированные инструкции сериализации (switch по тегу). Avro Generic использует рефлексию-подобный обход схемы.
Protobuf не требует schema resolution при чтении (если reader/writer schema совпадают). Avro всегда выполняет resolution, даже когда схемы идентичны.
Protobuf использует immutable generated объекты с zero-copy строками. Avro Generic использует mutable
Однако Avro Specific API сопоставим по производительности с Protobuf, а в некоторых сценариях (большие файлы с блоковым сжатием) может быть быстрее за счёт эффективной интеграции с Hadoop/S3.
Экосистема
Protobuf: доминирует в gRPC, микросервисах, мобильной разработке. Поддержка в Kubernetes (etcd), Envoy, многих облачных провайдерах.
Avro: доминирует в Hadoop, Spark, Kafka, data lakes (Delta Lake, Iceberg). Интеграция с Hive, Presto/Trino, Flink.
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
Вызов
writer.write(record, encoder):GenericDatumWriter получает схему и обходит поля в порядке их объявления в схеме. Для каждого поля:Читает значение из
Record.values[pos] через record.get(pos).Определяет тип поля из схемы.
Вызывает соответствующий метод
Encoder: writeString(), writeInt(), writeDouble() и т.д.BinaryEncoder (по умолчанию) пишет данные в буфер. Внутренне Avro использует BufferedBinaryEncoder, который аккумулирует данные в byte[] буфере (обычно 2 КБ) перед записью в целевой OutputStream. Этот буфер создаётся один раз и переиспользуется.Для строк (
writeString): Avro записывает длину в varint, затем байты UTF-8. Если значение — Utf8, байты копируются напрямую. Если String, выполняется перекодирование UTF-16 -> UTF-8, что создаёт временный byte[].Для union-типов (
["null", "int"]): сначала записывается индекс выбранного типа (varint: 0 для null, 1 для int), затем само значение. Для null записывается только индекс 0.После завершения
encoder.flush() копирует оставшиеся данные из внутреннего буфера в OutputStream. Возвращается byte[] (если использовался ByteArrayOutputStream).Временные объекты сериализации — внутренние структуры
BinaryEncoder, возможно временные byte[] для строк — создаются в Eden. BinaryEncoder может быть переиспользован, если передать его вторым аргументом в EncoderFactory.get().binaryEncoder(out, reuse).4. Десериализация: от byte[] к GenericRecord
Вызов
reader.read(null, decoder):GenericDatumReader создаёт новый GenericData.Record (аллокация в Eden + Object[] для значений).BinaryDecoder читает байты из InputStream. Для каждого поля схемы:
Читает значение согласно типу поля.
Для строк: читает длину (varint), затем выделяет byte[] нужного размера, читает байты, оборачивает в Utf8.
Для union: читает индекс типа (varint), затем значение соответствующего типа.
Записывает значение в Record.values[pos].
Важная особенность: если reader schema отличается от writer schema, GenericDatumReader выполняет schema resolution (разрешение схемы) на лету. Это означает, что для каждого поля reader сопоставляет его с полем writer по имени, проверяет совместимость типов, применяет promotion (например, int -> long) и default values. Schema resolution требует дополнительных вычислений и создаёт временные объекты (например, ResolvingDecoder), но не требует аллокаций на каждое поле — логика реализована через state machine в decoder.
После десериализации BinaryDecoder становится мусором. Если decoder создан с DecoderFactory.get().binaryDecoder(in, reuse), он может быть переиспользован, избегая аллокации.
5. Работа GC с Avro-объектами
Avro создаёт значительно больше временных объектов, чем Protobuf, но меньше, чем Jackson JSON:
Schema: хранится в Old Generation как singleton. Размер объекта Schema зависит от сложности схемы: простая схема с 5 полями — несколько сотен байт, сложная вложенная схема — несколько килобайт.
GenericRecord: создаётся для каждой записи. Содержит
Object[] values. Для потоковой обработки (Kafka consumer) каждое сообщение порождает новый GenericRecord. При обработке 10 000 сообщений/сек это 10 000 объектов GenericRecord + 10 000 массивов Object[] в Eden в секунду. Minor GC запускается часто, но эти объекты короткоживущие и собираются быстро.Utf8: Avro предпочитает
Utf8 вместо String для строковых полей. Utf8 — mutable объект, содержащий byte[]. В потоковой обработке GenericDatumReader может переиспользовать один и тот же экземпляр Utf8, если decoder настроен на reuse. Это снижает давление на GC.DataFileWriter / DataFileReader: при работе с файлами Avro использует буферизацию.
DataFileWriter хранит блок записей в памяти до достижения порога синхронизации (sync interval, обычно несколько мегабайт). Этот буфер — byte[] в куче, который может попасть в Old Generation, если файл большой.Object reuse: Avro предоставляет механизмы повторного использования объектов для снижения давления на GC. При чтении файла можно передать существующий
GenericRecord в reader.read(existingRecord, decoder) — в этом случае decoder перезапишет значения в существующем объекте вместо создания нового. Это критично для high-throughput обработки.// Переиспользование GenericRecord для снижения аллокаций
GenericRecord reuse = new GenericData.Record(schema);
while (fileReader.hasNext()) {
// read перезаписывает reuse вместо создания нового объекта
GenericRecord record = fileReader.next(reuse);
process(record);
}
6. Avro в Kafka: путь байтов
При работе с Kafka и Confluent Serializer:
Producer вызывает
KafkaAvroSerializer.serialize(topic, record).Сериализатор извлекает схему из
GenericRecord.getSchema().Проверяет кэш схем (локальный
Map<Schema, Integer>) — если схема уже зарегистрирована, использует кэшированный ID.Если нет — отправляет HTTP-запрос в Schema Registry для регистрации. Получает ID (int).
Сериализует record в
byte[] через GenericDatumWriter + BinaryEncoder.Формирует итоговое сообщение:
[magic(1)][schemaId(4)][payload(N)].Этот
byte[] передаётся в Kafka Producer, который копирует его в ByteBuffer (send buffer) и отправляет брокеру.На стороне Consumer:
KafkaAvroDeserializer получает сообщение.Читает magic byte и schema ID.
Проверяет локальный кэш схем по ID. Если нет — запрашивает схему из Schema Registry по HTTP.
Использует схему для создания
GenericDatumReader.Десериализует payload в
GenericRecord.Кэширование схем на клиенте критично: без него каждое сообщение порождало бы HTTP-запрос к реестру. Кэш — это
ConcurrentHashMap в Old Generation, живущий всё время жизни consumer/producer.Сравнение Avro и Protobuf
Кодогенерация
Protobuf требует кодогенерации: .proto файл компилируется в Java-классы через protoc. Avro предоставляет выбор: Generic API (без кодогенерации) и Specific API (с кодогенерацией через
avro-tools или Maven-плагин). Это делает Avro более гибким для динамических сценариев, где схема известна только во время выполнения.Оверхед схемы
В Protobuf схема существует только как внешний контракт. В бинарном сообщении нет схемы — только теги полей. В Avro бинарное сообщение без схемы нечитаемо. Поэтому Avro-файлы содержат схему в заголовке, а Kafka-сообщения содержат ID схемы из реестра.
При записи в файл: оверхед схемы амортизируется — одна схема на миллионы записей.
При передаче по сети через Schema Registry: оверхед — 5 байт на сообщение (magic + ID).
Без Schema Registry: пришлось бы передавать полную JSON-схему в каждом сообщении, что сделало бы Avro непригодным для messaging.
Типизация
Protobuf — статически типизирован через сгенерированные классы. Avro Generic — динамически типизирован через
GenericRecord. Avro Specific — статически типизирован, но сгенерированные классы менее удобны, чем в Protobuf (нет Builder pattern, поля — package-private или с простыми геттерами/сеттерами).Schema evolution
Avro имеет более мощную и гибкую систему schema evolution благодаря разделению reader/writer schema. Protobuf поддерживает forward/backward compatibility через правила добавления/удаления полей, но не имеет встроенного механизма разрешения различий между схемами на уровне runtime — это должно быть реализовано прикладным кодом.
Производительность
Protobuf быстрее Avro по нескольким причинам:
Сгенерированный код Protobuf содержит жёстко закодированные инструкции сериализации (switch по тегу). Avro Generic использует рефлексию-подобный обход схемы.
Protobuf не требует schema resolution при чтении (если reader/writer schema совпадают). Avro всегда выполняет resolution, даже когда схемы идентичны.
Protobuf использует immutable generated объекты с zero-copy строками. Avro Generic использует mutable
GenericRecord с Object[].Однако Avro Specific API сопоставим по производительности с Protobuf, а в некоторых сценариях (большие файлы с блоковым сжатием) может быть быстрее за счёт эффективной интеграции с Hadoop/S3.
Экосистема
Protobuf: доминирует в gRPC, микросервисах, мобильной разработке. Поддержка в Kubernetes (etcd), Envoy, многих облачных провайдерах.
Avro: доминирует в Hadoop, Spark, Kafka, data lakes (Delta Lake, Iceberg). Интеграция с Hive, Presto/Trino, Flink.
#Java #для_новичков #beginner #IO #NIO #Serialize #Avro
👍4