Що таке ключ повідомлення і як він впливає на партиціонування
Якщо ключ не задано (key == null), брокер використовує алгоритм Sticky Partitioning, пакуючи повідомлення пачками в різні партиції без збереження порядку.
🟢 Junior Level
30-секундна відповідь
Ключ повідомлення (Message Key) в Apache Kafka виконує дві фундаментальні функції:
- Маршрутизація та гарантія порядку (Partition Routing): ключ визначає, в яку саме партицію топіка потрапить повідомлення. Усі повідомлення з однаковим ключем (наприклад,
orderId="ord-99") завжди спрямовуються в одну й ту саму партицію, що забезпечує сувору послідовність обробки FIFO (First-In, First-Out) для конкретної бізнес-сутності. - Ідентифікатор для 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:
- Composite Key (Складений ключ): Якщо порядок критичний не в межах складу, а в межах конкретного замовлення, ключ формується як: \(\text{key} = \text{warehouseId} + \text{"\#"} + \text{orderId}\) Замовлення одного складу рівномірно розподіляться по всіх партиціях, зберігаючи суворий порядок подій за кожним конкретним замовленням.
- Bounded Key Salting (Обмежене соління):
Якщо потрібна паралельна обробка подій однієї сутності:
// Сіль від 0 до 3 розщеплює один ключ рівно на 4 партиції int salt = ThreadLocalRandom.current().nextInt(4); String key = customerId + "_" + salt;
Накладні витрати на розмір ключа (Memory & I/O Overhead)
Розробники часто передають як ключ складні JSON-рядки або складені об’єкти Avro розміром 5–10 КБ. Це призводить до важких наслідків для продуктивності:
- Потрійний оверхед пам’яті: ключ дублюється в буфері продюсера (
RecordBatch), на диску брокера в сегменті.log, в OS Page Cache та в пам’яті споживачаConsumerRecord. - Навантаження на CPU: брокер при Log Compaction порівнює ключі байт-у-байт. Порівняння 8-байтового
Longу десятки разів швидше, ніж парсинг і порівняння 5-кілобайтного JSON. - Рекомендація: ключ має бути максимально компактним (
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-байтові ідентифікатори).
3. Що відбувається, коли в топології Kafka Streams або Flink викликається операція .selectKey(...)?
Відповідь:
- Операція
.selectKey((k, v) -> newKey)змінює ключ повідомлення в потоці даних. - Оскільки стара партиція визначалася за старим ключем, а новий ключ у 99% випадків буде хешуватися в іншу партицію, фреймворк зобов’язаний виконати Repartitioning (Shuffle):
- Повідомлення не може продовжувати обробку в поточній локальній задачі потоку (StreamTask).
- Kafka Streams автоматично створює внутрішній прихований топік перерозподілу (repartition topic із суфіксом
-repartition). - Повідомлення серіалізується, відправляється мережею в 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-об’єкт цілком» — це вб’є процесор і диск брокера при компактифікації.