📨 Розділ 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-KYIV.

  • Якщо використовувати 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-об’єкт цілком» — це вб’є процесор і диск брокера при компактифікації.

Пов’язані запитання