Хочу поделиться приёмом, который не раз меня выручал. Есть консьюмер, который читает из Kafka сообщения в формате Avro. Его нужно нагрузочно протестировать. Но схемы Avro у меня нет, бизнес-логику сообщений я не знаю, а ждать, пока соседняя команда соберёт полноценный генератор тестовых данных, нельзя.

Решение оказалось простым: для replay-нагрузки не обязательно понимать Avro. Достаточно один раз сохранить реальное Kafka-сообщение в сыром байтовом виде и отправлять эти байты обратно в топик через ByteArraySerializer. Консьюмер получит ровно тот же wire-format, который получает в проде.

Идея в одной схеме

Схема replay-нагрузки Kafka Avro без знания схемы
Схема replay-нагрузки Kafka Avro без знания схемы

Важная граница: это не генератор новых бизнес-событий. Это replay-тест, который проверяет, как консьюмер, брокер и downstream выдерживают поток реальных сообщений.

Подходит

Не подходит

Проверить throughput консьюмера на реальном payload

Проверять эволюцию Avro-схем

Поймать рост consumer lag и tail latency

Генерировать разные значения бизнес-полей

Быстро устроить smoke/load replay

Проверять валидность новых данных

Прогнать одинаковое сообщение много раз

Нагрузить уникальными событиями с разными id

Почему Schema Registry здесь не нужен

Обычно Avro в Kafka живёт вместе со Schema Registry. Сериализатор кладёт в сообщение служебный префикс: magic byte, id схемы и дальше Avro payload. Консьюмер по этому id достаёт схему и десериализует сообщение.

Но если мы не создаём новое Avro-сообщение, а переигрываем уже существующее, нам не нужно заново сериализовать объект. Нам нужно сохранить байты как есть:

Слой

Что происходит при обычной Avro-сериализации

Что делаем в replay-тесте

Java-объект

Создаётся объект по схеме

Не нужен

Schema Registry

Схема регистрируется или ищется по subject

Не трогаем

Wire-format

Сериализатор добавляет magic byte и schema id

Уже есть в сохранённом сообщении

Kafka Producer

Отправляет байты в топик

Отправляет те же байты через ByteArraySerializer

Consumer

Десериализует штатным десериализатором

Видит обычное Avro-сообщение

Если сохранить payload не как текст, не как JSON и не как «красивое представление», а именно как byte array, консьюмер не заметит разницы.

Конфигурируем продюсер

Нам нужен обычный producer, у которого ключ и значение — byte[]. Конфигурацию удобнее держать явно в Java-коде: для нагрузочного клиента так проще видеть, какие параметры реально участвуют в тесте.

@Configuration
public class LoadProducerConfig {

    @Value("${app.kafka.bootstrap-servers}")
    private String bootstrapServers;

    @Bean
    public ProducerFactory<byte[], byte[]> producerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
        props.put(ProducerConfig.ACKS_CONFIG, "all");
        props.put(ProducerConfig.LINGER_MS_CONFIG, 5);
        props.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
        props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");

        // Если кластер за SASL/SSL, сюда добавляются обычные security.* и sasl.* свойства.
        return new DefaultKafkaProducerFactory<>(props);
    }

    @Bean
    public KafkaTemplate<byte[], byte[]> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

Здесь принципиально нет KafkaAvroSerializer и schema.registry.url. Мы не сериализуем Avro, а отправляем готовую последовательность байт.

Для сравнения, если бы мы генерировали новые Avro-сообщения, конфигурация была бы другой:

props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
props.put("schema.registry.url", "http://localhost:8081");

Но это уже другой класс задачи.

Как достать сырые байты

Самое важное — не испортить исходное сообщение при сохранении. Нужен инструмент, который умеет выгрузить record value в binary form.

Инструмент

Годится для этой задачи

Комментарий

Offset Explorer

Да

Можно сохранить сообщение в файл как bytes

Собственный consumer с ByteArrayDeserializer

Да

Самый контролируемый вариант

kcat / консольные утилиты

Иногда

Нужно внимательно проверить, что нет текстовой перекодировки

AKHQ / UI-просмотрщик

Обычно нет

Часто показывает уже декодированное представление

Надёжный вариант — написать маленький consumer, который читает одно сообщение и пишет record.value() в файл.

byte[] payload;

try (InputStream in = getClass().getResourceAsStream("/sample-message.bin")) {
    if (in == null) {
        throw new IllegalStateException("sample-message.bin not found");
    }
    payload = in.readAllBytes();
}

Файл лучше назвать .bin, а не .avro: внутри лежит не чистый Avro-файл, а Kafka wire-format сообщения.

Создаём нагрузку

Ниже минимальный вариант: задаём количество сообщений и целевой RPS, отправляем payload в цикле, считаем задержку callback-а.

int total = 100_000;
int targetRps = 1_000;
long intervalNanos = 1_000_000_000L / targetRps;

long startedAt = System.nanoTime();
Queue<Long> latencies = new ConcurrentLinkedQueue<>();
AtomicInteger errors = new AtomicInteger();

for (int i = 0; i < total; i++) {
    long sentAt = System.nanoTime();

    kafkaTemplate.send("my-topic", payload)
            .whenComplete((result, error) -> {
                latencies.add(System.nanoTime() - sentAt);
                if (error != null) {
                    errors.incrementAndGet();
                }
            });

    long nextSendAt = startedAt + (long) i * intervalNanos;
    long sleepNanos = nextSendAt - System.nanoTime();
    if (sleepNanos > 0) {
        LockSupport.parkNanos(sleepNanos);
    }
}

kafkaTemplate.flush();

double seconds = (System.nanoTime() - startedAt) / 1e9;
List<Long> sorted = new ArrayList<>(latencies);
Collections.sort(sorted);

System.out.printf("sent=%d errors=%d duration=%.1fs throughput=%.0f msg/s%n",
        total, errors.get(), seconds, total / seconds);
System.out.printf("callback latency p50=%.1fms p95=%.1fms p99=%.1fms%n",
        percentile(sorted, 50) / 1e6,
        percentile(sorted, 95) / 1e6,
        percentile(sorted, 99) / 1e6);
static long percentile(List<Long> sorted, int p) {
    if (sorted.isEmpty()) {
        return 0;
    }
    int index = (int) Math.ceil(p / 100.0 * sorted.size()) - 1;
    return sorted.get(Math.max(0, Math.min(index, sorted.size() - 1)));
}

Что обязательно мерить

Если смотреть только на «сколько сообщений отправил producer», тест легко обманет. Producer может радостно отправлять, а консьюмер уже тонет.

Минимальный набор метрик:

Метрика

Где смотреть

Что показывает

Producer send errors

Callback send

Брокер или сеть уже не принимают нагрузку

Callback latency p95/p99

Нагрузочный клиент

Хвосты отправки начали расти

Consumer lag

Kafka consumer group lag

Консьюмер не успевает читать

DLQ / error topic

Сервисные топики

Данные стали падать в ошибки

CPU / GC консьюмера

JVM / контейнер

Упёрлись не в Kafka, а в обработку

Время обработки downstream

Метрики приложения

После Kafka узкое место ниже по цепочке

Обычно я гоняю несколько ступеней: 100, 500, 1000, 2000 msg/s и смотрю, где начинается нелинейный рост lag или p99.

Когда нужны настоящие Avro-сообщения

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

Настоящий Avro + Schema Registry нужен, если:

Нужно

Почему replay не подходит

Генерировать разные id, суммы, даты, статусы

Один и тот же payload не проверит бизнес-ветки

Проверять совместимость схем

Схема должна регистрироваться и эволюционировать

Тестировать producer-код

Мы обходим сериализатор

Проверять subject naming strategy

Replay использует уже готовый schema id

Тогда нужен полноценный генератор данных: Avro-классы, KafkaAvroSerializer, Schema Registry и сценарий изменения полей.

Чем ещё можно нагружать

Мой способ хорош тем, что его можно собрать за вечер. Но он не единственный.

Инструмент

Когда брать

kafka-producer-perf-test.sh

Быстро померить пропускную способность брокера на синтетическом payload

JMeter / Gatling / k6 + Kafka plugin

Нужны сценарии, ramp-up, отчёты и графики

Testcontainers

Интеграционные тесты с реальным Kafka в CI

Собственный replay-client

Нужны реальные Avro-байты и контроль над нагрузкой

В моём случае replay-client занимает нишу между штатным perf-test и полноценным генератором бизнес-событий.

Взгляд из 2026

С 2022 года Kafka-инфраструктура заметно изменилась. К 2026 году актуальная ветка Apache Kafka уже 4.x; в 4.0 ZooKeeper окончательно убрали, а KRaft стал единственным режимом управления кластером. Для эксплуатации это важно, но сам трюк с ByteArraySerializer от этого не меняется.

Spring-экосистема тоже обновилась: Spring for Apache Kafka 4.x продолжает давать KafkaTemplate как высокоуровневую обёртку для отправки сообщений. Для нашего replay-клиента это значит одно: код можно держать на современных версиях, но проверять надо не только producer API, а ещё и настройки безопасности, compression, batching и лимиты брокера.

Что я бы добавил в 2026 году:

Улучшение

Зачем

Несколько sample payload разного размера

Один маленький message даёт слишком оптимистичную картину

Режим ramp-up / steady / ramp-down

Видно, где система деградирует постепенно

Экспорт метрик в Prometheus / CSV

Потом можно сравнивать прогоны

Отдельный ключ сообщения

Можно проверить распределение по партициям

Проверка consumer lag в тесте

Producer throughput без lag почти бесполезен

Итог

Если задача — быстро нагрузить консьюмер на реальном Avro-потоке, не зная схемы, не надо насильно разбираться в Avro. Сохраните одно сообщение как bytes, отправляйте его через ByteArraySerializer, меряйте producer latency, consumer lag и ошибки downstream.

Главное — честно помнить границы метода: replay байтов проверяет устойчивость обработки, но не заменяет генератор валидных бизнес-событий.

Ссылки