Загрузка
Все статьи

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

На одном из наших сервисов мы обработали около 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 раза. Это окупило усилия на миграцию за первый месяц.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlin + gRPC: streaming и двусторонняя связь в продакшене

Реальный опыт использования gRPC streaming в продакшене на Kotlin: server-side, client-side и bidirectional стриминг для системы отслеживания статусов заказов в реальном времени.

9 июня 2026 12 мин