На одном из наших сервисов мы обработали около 50 000 сообщений в секунду через Kafka. Профилировка показала, что 40% CPU уходило на JSON-сериализацию и десериализацию. Сервис на Jackson + Gson работал, но масштабировался плохо — каждое увеличение нагрузки било по процессору. Решили migrate на Protobuf, и вот что я узнал в процессе.
Проблема JSON при масштабировании
JSON — удобный формат, и для большинства случаев его более чем достаточно. Но у него есть фундаментальные недостатки при высоких нагрузках: строковые ключи повторяются в каждом сообщении, нет встроенной схемы типов, парсинг строк в числа и обратно — всё это стоит CPU-циклов. Когда у вас 50K msg/s на инстанс, каждый сэкономленный микросекунд на сообщении превращается в секунды процессорного времени в минуту.
Наши бенчмарки на реальных payload'ах (OrderEvent с 15 полями, вложенными объектами и списками) показали следующее:
Сериализация (1 000 000 итераций, OrderEvent ~2KB JSON):
Jackson: 4200 ms
kotlinx-serialization JSON: 3100 ms (на 26% быстрее Jackson)
kotlinx-serialization ProtoBuf: 680 ms (в 6.2x быстрее Jackson)
java-protobuf (GeneratedMessage): 520 ms (в 8x быстрее Jackson)
Размер serialized data:
JSON: 2048 bytes
ProtoBuf: 712 bytes (в 2.9x компактнее)
Десериализация (1 000 000 итераций):
Jackson: 5100 ms
kotlinx-serialization JSON: 3800 ms
kotlinx-serialization ProtoBuf: 720 ms
java-protobuf (GeneratedMessage): 580 ms
Разница в 6–8 раз по скорости и 3 раза по размеру — это не микрооптимизация. Это значит, что вместо 8 инстансов под нагрузкой вам достаточно 2–3.
Proto-схема и генерация кода
Начнём с определения схемы. Protobuf требует явного описания типов, что одновременно и ограничение, и преимущество:
syntax = "proto3";
package marketplace.events.v1;
option java_multiple_files = true;
option java_package = "com.example.marketplace.proto";
message OrderCreatedEvent {
string order_id = 1;
string customer_id = 2;
repeated OrderLineItem items = 3;
Money total = 4;
string delivery_address = 5;
int64 created_at = 6;
OrderMetadata metadata = 7;
}
message OrderLineItem {
string product_id = 1;
string sku = 2;
int32 quantity = 3;
Money unit_price = 4;
Money line_total = 5;
}
message Money {
string currency = 1;
int64 amount_minor = 2; // копейки/центы
}
message OrderMetadata {
string source = 1;
string correlation_id = 2;
map tags = 3;
}
Настройка Gradle
Мы используем плагин protobuf-gradle-plugin вместе с kotlinx-serialization. Вот рабочая конфигурация:
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
id("com.google.protobuf") version "0.9.4"
}
dependencies {
implementation("com.google.protobuf:protobuf-kotlin:4.28.3")
implementation("com.google.protobuf:protobuf-java-util:4.28.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3")
// Для Kafka-сериализации
implementation("io.confluent:kafka-protobuf-serializer:7.7.0")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.28.3"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("kotlin") {
option("lite")
}
}
}
}
}
Использование с kotlinx-serialization
Один из самых удобных аспектов — kotlinx-serialization-protobuf позволяет сериализовать обычные Kotlin data-классы в бинарный Protobuf без генерации кода из .proto. Это гибридный подход, который мы использовали для внутренних сообщений:
@Serializable
@ProtoNumber(1)
data class OrderCreatedEvent(
@ProtoNumber(1) val orderId: String,
@ProtoNumber(2) val customerId: String,
@ProtoNumber(3) val items: List,
@ProtoNumber(4) val total: Money,
@ProtoNumber(5) val deliveryAddress: String,
@ProtoNumber(6) val createdAt: Long = Clock.System.now().toEpochMilliseconds(),
@ProtoNumber(7) val metadata: OrderMetadata = OrderMetadata()
)
@Serializable
data class OrderLineItem(
@ProtoNumber(1) val productId: String,
@ProtoNumber(2) val sku: String,
@ProtoNumber(3) val quantity: Int,
@ProtoNumber(4) val unitPrice: Money,
@ProtoNumber(5) val lineTotal: Money
)
@Serializable
data class Money(
@ProtoNumber(1) val currency: String = "RUB",
@ProtoNumber(2) val amountMinor: Long
)
// Сериализация
val proto = ProtoBuf.encodeToByteArray(OrderCreatedEvent.serializer(), event)
// Десериализация
val event = ProtoBuf.decodeFromByteArray(proto)
Интеграция с Kafka
Для Kafka мы написали свой сериализатор поверх kotlinx-serialization. Это проще, чем тянуть Confluent Schema Registry для Protobuf (хотя он и поддерживается):
class ProtoBufSerializer : Serializer {
override fun serialize(topic: String, data: T?): ByteArray? {
if (data == null) return null
@Suppress("UNCHECKED_CAST")
val serializer = serializer() as KSerializer
return ProtoBuf.encodeToByteArray(serializer, data)
}
}
class ProtoBufDeserializer(private val clazz: KClass) : Deserializer {
override fun deserialize(topic: String, data: ByteArray?): T? {
if (data == null) return null
@Suppress("UNCHECKED_CAST")
val serializer = clazz.serializer() as KSerializer
return ProtoBuf.decodeFromByteArray(serializer, data)
}
}
// Использование в producer
val producer = KafkaProducer(
Properties().apply {
put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java)
put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ProtoBufSerializer::class.java)
}
)
Эволюция схемы и обратная совместимость
Protobuf отлично поддерживает эволюцию схемы. Правила простые: можно добавлять новые поля (они получат default values у старых клиентов), нельзя менять номера полей и типы. Мы один раз столкнулись с проблемой: переименовали amount в amount_minor, сохранив номер поля, — всё отработало корректно. Но когда кто-то удалил поле и переиспользовал его номер — получили corrupted данные у старых клиентов.
Мой совет: заведите правило — удалённые поля помечайте как reserved в proto-файле. И никогда не переиспользуйте номера.
Когда Protobuf — overkill
Protobuf не панацея. Для API, который потребляют фронтендеры, JSON останется стандартом. Protobuf имеет смысл, когда: объём данных большой, нагрузка высокая, взаимодействие — между бекенд-сервисами, важна бинарная совместимость. В нашем случае переход на Protobuf сократил CPU-нагрузку сервиса на 35% и уменьшил трафик между сервисами в 2.8 раза. Это окупило усилия на миграцию за первый месяц.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье