📨 Раздел 15 · Вопрос #4

Что такое ключ сообщения и как он влияет на партиционирование

Если ключ не задан (key == null), брокер использует алгоритм Sticky Partitioning, упаковывая сообщения пачками в разные партиции без сохранения порядка.


🟢 Junior Level

30-секундный ответ

Ключ сообщения (Message Key) в Apache Kafka выполняет две фундаментальные функции:

  1. Маршрутизация и гарантия порядка (Partition Routing): ключ определяет, в какую именно партицию топика попадет сообщение. Все сообщения с одинаковым ключом (например, orderId="ord-99") всегда направляются в одну и ту же партицию, что обеспечивает строгую последовательность обработки FIFO (First-In, First-Out) для конкретной бизнес-сущности.
  2. Идентификатор для Log Compaction: в топиках со сжатием логов (cleanup.policy=compact) ключ служит первичным ключом (Primary Key). Брокер хранит только последнее состояние значения для каждого уникального ключа, удаляя устаревшие записи.

Если ключ не задан (key == null), брокер использует алгоритм Sticky Partitioning, упаковывая сообщения пачками в разные партиции без сохранения порядка.


Наглядная аналогия из жизни

Ключ сообщения — это номер банковского счёта на платежных квитанциях:

  • В банке работает 10 кассиров (10 партиций).
  • Клиент вносит деньги, оплачивает кредит и переводит остаток. Все эти операции имеют один номер счета (один ключ).
  • Банковская система направляет все операции по этому счёту строго к кассиру №3. Кассир обрабатывает их в порядке поступления: нельзя закрыть кредит до того, как внесены деньги!
  • Операции других клиентов имеют другие номера счетов и могут обрабатываться параллельно кассирами №1, №2 и №4.

Базовый пример отправки в Java 21 / Spring Boot 3

@Service
public class PaymentProducer {

    private final KafkaTemplate<String, PaymentEvent> kafkaTemplate;

    public PaymentProducer(KafkaTemplate<String, PaymentEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void sendPayment(String accountId, PaymentEvent event) {
        // accountId передаётся в качестве ключа (Key) сообщения
        // Все события одного аккаунта гарантированно попадут в одну партицию
        kafkaTemplate.send("account-transactions", accountId, event);
    }
}

🟡 Middle Level

Механизм хэширования MurmurHash2

Продюсер вычисляет номер партиции по формуле: \(\text{partition} = \text{Utils.toPositive}(\text{Utils.murmur2}(\text{keyBytes})) \pmod{\text{totalPartitions}}\)

  • Сериализация: объект ключа преобразуется в массив байт byte[] через настроенный key.serializer (например, StringSerializer $\to$ UTF-8 байты).
  • MurmurHash2: 32-битный алгоритм быстрого некриптографического хэширования с отличным лавинным эффектом.
  • Детерминизм: при неизменном числе партиций одинаковый ключ всегда возвращает одно и то же число.

Ловушка: key = null против key = "" (пустая строка)

Это критически важный нюанс, на котором часто сыплются на собеседованиях:

Поле ключа Поведение Partitioner Результат
key == null Включается Sticky Partitioning. Сообщения собираются в батч и отправляются в случайно выбранную партицию, затем партиция меняется. Нагрузка равномерно распределяется по всем партициям.
key == "" (пустая строка) Ключ НЕ null! Вычисляется MurmurHash2(new byte[0]), что даёт фиксированное число. Все сообщения со всего кластера попадают в ОДНУ конкретную партицию! Возникает жестокий Hot Partition.

Роль ключа в топиках с Log Compaction

В топиках с политикой cleanup.policy=compact ключ является обязательным:

  • Брокер периодически запускает сборщик мусора LogCleaner.
  • Если в партиции лежит 10 записей с ключом userId="42", сборщик удаляет первые 9 версий и оставляет только последнюю актуальную запись.
  • Удаление записи (Tombstone): для полного удаления ключа продюсер отправляет запись с данным ключом и телом value = null.

🔴 Senior Level

Проблема «Горячего ключа» (Hot Key Skew) и композитные ключи

В высоконагруженных системах часто возникает перекос нагрузки. Например, в системе логистики маркетплейса 95% заказов приходятся на склад WH-MOSCOW.

  • Если использовать warehouseId как ключ сообщения, партиция склада Москва получит гигантскую нагрузку, а остальные партиции будут простаивать.

Архитектурные паттерны решения Hot Key Skew:

  1. Composite Key (Составной ключ): Если порядок критичен не в рамках склада, а в рамках конкретного заказа, ключ формируется как: \(\text{key} = \text{warehouseId} + \text{"\#"} + \text{orderId}\) Заказы одного склада равномерно распределятся по всем партициям, сохраняя строгий порядок событий по каждому конкретному заказу.
  2. Bounded Key Salting (Ограниченное соление): Если требуется параллельная обработка событий одной сущности:
    // Соль от 0 до 3 расщепляет один ключ ровно на 4 партиции
    int salt = ThreadLocalRandom.current().nextInt(4);
    String key = customerId + "_" + salt;
    

Накладные расходы на размер ключа (Memory & I/O Overhead)

Разработчики часто передают в качестве ключа сложные JSON-строки или составные объекты Avro размером 5–10 КБ. Это приводит к тяжелым последствиям для производительности:

  1. Тройной оверхед памяти: ключ дублируется в буфере продюсера (RecordBatch), на диске брокера в сегменте .log, в OS Page Cache и в памяти потребителя ConsumerRecord.
  2. Нагрузка на CPU: брокер при Log Compaction сравнивает ключи байт-в-байт. Сравнение 8-байтового Long в десятки раз быстрее, чем парсинг и сравнение 5-килобайтного JSON.
  3. Рекомендация: ключ должен быть максимально компактным (Long, UUID в бинарном формате или короткий суррогатный String).

Polyglot Data Serialization Discrepancies

Если в компании микросервисы написаны на разных языках (Java, Go, Python, C#):

  • В Java String.getBytes(StandardCharsets.UTF_8) преобразует строку в UTF-8.
  • Если сервис на Python случайно сериализует ключ в кодировке ASCII или UTF-16, либо если в Go ключ сериализуется с завершающим нулевым байтом \0:
  • Алгоритм MurmurHash2 от разных массивов байт вернет совершенно разные хэши!
  • В результате Go-продюсер и Java-продюсер отправят события по одной и той же сделке в разные партиции, полностью сломав порядок обработки!

4 Tricky Questions

1. Почему передача пустой строки key = "" в сотни раз опаснее для кластера, чем передача key = null?

Ответ:

  • Когда передается key = null, клиент Kafka понимает, что ключ отсутствует, и активирует алгоритм Sticky Partitioning. Сообщения крупными сжатыми пачками равномерно распределяются между всеми доступными партициями топика.
  • Когда передается пустая строка key = "", для клиента это валидный ключ ненулевой длины (массив из 0 байт).
  • Клиент честно вычисляет MurmurHash2(new byte[0]) % totalPartitions. Результат этого вычисления всегда является одной и той же константой (например, партиция 1).
  • В итоге абсолютно все продюсеры системы начинают направлять 100% входящего потока данных строго в одну-единственную партицию! Один брокер уходит в 100% утилизацию диска и процессора, консьюмер этой партиции накапливает катастрофический лаг, а остальные партиции простаивают.

2. Как передача тяжелого JSON-объекта (например, 20 КБ) в качестве ключа влияет на фоновый процесс Log Compaction на брокере Kafka?

Ответ:

  • Процесс LogCleaner на брокере выделяет фиксированный пул оперативной памяти (log.cleaner.dedupe.buffer.size, по умолчанию 128 МБ) для построения in-memory хэш-таблицы последних смещений ключей (Skimpy Offset Map).
  • Для каждого ключа в карте сохраняется 8-байтовый хэш ключа и 8-байтовый offset.
  • Если ключи имеют гигантский размер, брокер вынужден постоянно считывать из сегментов на диске огромные объемы данных только ради сравнения ключей на коллизии хэшей.
  • Это приводит к колоссальному всплеску дискового чтения (Disk Read I/O), вымыванию горячих страниц из Linux Page Cache и резкому падению пропускной способности всего брокера. Ключи всегда должны быть минимального размера (8-байтовые идентификаторы).

Ответ:

  • Операция .selectKey((k, v) -> newKey) изменяет ключ сообщения в потоке данных.
  • Поскольку старая партиция определялась по старому ключу, а новый ключ в 99% случаев будет хэшироваться в другую партицию, фреймворк обязан выполнить Repartitioning (Shuffle):
    1. Сообщение не может продолжать обработку в текущей локальной задаче потока (StreamTask).
    2. Kafka Streams автоматически создает внутренний скрытый топик перераспределения (repartition topic с суффиксом -repartition).
    3. Сообщение сериализуется, отправляется по сети в Kafka брокер, записывается на диск брокера в новую партицию, и затем вычитывается заново другим инстансом потока.
  • Это чрезвычайно дорогая сетевая и дисковая операция, резко увеличивающая задержки (latency) и требующая тщательного контроля разработчика.

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

Ответ:

  • Продюсер накапливает сообщения в памяти в буферах RecordAccumulator, разделенных по целевым партициям.
  • При отсутствии ключа (key == null) Sticky Partitioner направляет все сообщения подряд в один буфер до достижения batch.size (например, 16 КБ). Батч заполняется мгновенно, алгоритмы сжатия (LZ4/ZSTD) получают большой связный блок текста и сжимают его с коэффициентом 5x–8x.
  • При отправке с ключами сообщения случайным образом распределяются по разным буферам партиций. Если в топике 30 партиций, сообщения распыляются по 30 независимым буферам. Батчи накапливаются медленно, часто отправляются полупустыми по истечении linger.ms, сжатие на маленьких объемах работает неэффективно, а количество сетевых TCP-пакетов возрастает в десятки раз.

🎯 Шпаргалка для интервью

Главное о ключе сообщения

  • Роль 1: Маршрутизация. Обеспечивает детерминированное попадание в одну партицию: MurmurHash2(key) % Partitions.
  • Роль 2: Порядок FIFO. Гарантирует строгий порядок событий для конкретного ключа бизнес-сущности.
  • Роль 3: Log Compaction. Служит первичным ключом; при компактификации хранится только последняя версия каждого ключа.
  • key == null: Sticky Partitioning (пакетное равномерное распределение, максимальный throughput).
  • key == "" (пустая строка): жестокий антипаттерн! 100% трафика уйдет в одну партицию (Hot Partition).
  • Компактность: ключ должен быть легковесным (Long, UUID), иначе возникает деградация I/O и Page Cache.
  • Изменение числа партиций: ломает соответствие ключей партициям.
  • Tombstone: запись с ключом и пустым телом (value = null) для удаления ключа в сжатых топиках.

Стратегии выбора ключа

| Задача | Ключ | Плюсы | Минусы | | :— | :— | :— | :— | | Строгий порядок пользователя | userId | Порядок 100% соблюден | Риск hot partition у активных пользователей | | Равномерность мерчанта | merchantId + "#" + orderId | Равномерная загрузка всех нод | Порядок гарантирован только внутри заказа | | Максимальный Throughput | null | Идеальные большие батчи и сжатие | Порядок сообщений отсутствует |

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Ключ нужен только для того, чтобы консьюмер знал, от кого пришло сообщение» — ключ напрямую управляет физическим шардированием брокера.
  • ❌ «Пустая строка в качестве ключа работает так же, как null» — пустая строка является реальным ключом и вызовет Hot Partition.
  • ❌ «В качестве ключа лучше всего передавать весь JSON-объект целиком» — это убьет процессор и диск брокера при компактификации.

Связанные вопросы