Питання 4 · Розділ 15

Що таке ключ повідомлення і як він впливає на партиціонування

Уявіть аеропорт:

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Що таке ключ повідомлення?

Ключ повідомлення (Message Key) — це значення, яке визначає, в яку партицію потрапить повідомлення. Ключ — це «адреса» для маршрутизації повідомлення.

Навіщо: спосіб сказати Kafka: «усі події, пов’язані з цим користувачем/замовленням, мають оброблятися в одному місці». Без ключа події одного користувача розлетяться по різних партиціях і можуть прийти в неправильному порядку.

Аналогія

Уявіть аеропорт:

  • Ключ = код призначення рейсу (наприклад, «KYIV-BA101»)
  • Партиція = конкретний гейт
  • Усі пасажири з одним кодом йдуть на один гейт
  • Пасажири з різними кодами — на різні гейти
key="KYIV-BA101" → гейт 5 (партиція 1)
key="NYC-AA202"  → гейт 3 (партиція 0)
key="PARIS-AF303" → гейт 7 (партиція 2)

Краща аналогія: ключ — це номер справи в суді. Усі документи по одній справі потрапляють в одну папку і розглядаються у строгому порядку. Документи різних справ — у різних папках, порядок між ними не важливий.

Навіщо потрібен ключ?

  1. Гарантія порядку — повідомлення з одним ключем йдуть в одну партицію → зберігають порядок
  2. Групування — усі події одного користувача/об’єкта обробляються разом

Приклад

// З ключем — порядок гарантовано
ProducerRecord<String, String> record =
    new ProducerRecord<>("orders", "user-123", "Order created");
//                    topic         key          value

// user-123 завжди потрапить в одну й ту саму партицію
З ключем:
  user-123 → партиція 1 → порядок: login → browse → purchase

Без ключа:
  msg-1 → партиція 2
  msg-2 → партиція 0  // порядок не гарантовано
  msg-3 → партиція 1

Коли НЕ використовувати ключ повідомлення

  1. Порядок повідомлень не важливий і потрібна максимальна throughput — key=null
  2. Ключі мають сильний skew (нерівномірний розподіл) — hot partition
  3. Розмір ключа занадто великий (>1KB) — CPU overhead на хешування

🟡 Middle Level

Гарантії порядку

Партиція 0: user-456 події (строго по порядку)
Партиція 1: user-123 події (строго по порядку)
Партиція 2: user-789 події (строго по порядку)

Важливо: Порядок гарантовано тільки всередині однієї партиції! Між партиціями — немає гарантій.

Алгоритм хешування

partition = Math.abs(Utils.murmur2(keyBytes)) % numPartitions

Kafka використовує MurmurHash 2 для рівномірного розподілу ключів по партиціях.

Вибір хорошого ключа

Ключ Розподіл Коли використовувати
userId ✅ Рівномірне (багато користувачів) User events
orderId ✅ Рівномірне Order events
"all" ❌ Hot partition Не використовуйте для event-driven систем. Допустимо для broadcast (глобальна конфігурація), але усвідомлюйте hot partition risk.
null ✅ Sticky partitioning Коли порядок не важливий
aggregateId ✅ Рівномірне Event sourcing

Null Key

// key = null → sticky partitioning
// Порядок НЕ гарантується
ProducerRecord<String, String> record =
    new ProducerRecord<>("orders", "Order created");

Порівняння стратегій ключів

Стратегія Порядок Throughput Розподіл
Business Key (userId) ✅ Всередині партиції Середний Залежить від даних
Composite Key (userId_orderId) Середний Хороший
Salted Key (userId + random) ⚠️ Частковий Хороший Відмінний
Null Key Максимальний Рівномірний

Типові помилки

Помилка Наслідок Рішення
Null key коли потрібен порядок Події користувача перемішані Використовуйте userId як ключ
Один ключ для всіх Hot partition Різні ключі для різних сутностей
Великий ключ (JSON) CPU overhead на хешування Компактні ключі (UUID, Long)
Зміна кількості партицій Розподіл ключів змінюється Створюйте новий топик

🔴 Senior Level

Internal Implementation — MurmurHash 2

// org.apache.kafka.common.utils.Utils
public static int murmur2(byte[] data) {
    int length = data.length;
    int seed = 0x9747b28c;  // Kafka seed
    // ... MurmurHash2 algorithm ...
    return fmix(hash(length, seed, data));
}

// Partition calculation
int hash = Utils.murmur2(keyBytes);
int partition = Math.abs(hash) % numPartitions;

Чому MurmurHash 2:

  • Non-cryptographic, швидкий (бітові операції)
  • Avalanche effect — зміна 1 біта → ~50% бітів результату змінюються
  • Uniform distribution — мінімум колізій для реальних даних

Integer.MIN_VALUE edge case:

// Math.abs(Integer.MIN_VALUE) == Integer.MIN_VALUE (від'ємне!)
// Kafka використовує бітову маску:
int partition = (hash & 0x7fffffff) % numPartitions;

Key Ordering Guarantees — формальна модель

Всередині партиції: строго FIFO (total order)
Між партиціями: partial order (causal ordering тільки при явному дизайні)

Формально:
  ∀ messages m1, m2:
    key(m1) == key(m2) → partition(m1) == partition(m2) → order(m1, m2) preserved
    key(m1) != key(m2) → order(m1, m2) NOT guaranteed

Advanced Key Strategies

1. Composite Key для боротьби з hot partitions:

// Складений ключ: entity + sub-entity
String key = userId + "_" + orderId;
// Забезпечує більш рівномірний розподіл
// Але: порядок тільки всередині userId_orderId, не всередині userId

2. Key Salting (randomized partitioning всередині entity):

// N-ary salting: розподіляємо події одного користувача по N партиціях
int salt = ThreadLocalRandom.current().nextInt(10);
String key = userId + "_" + salt;
// Порядок втрачається, але throughput росте в N разів
// Використовуйте коли порядок НЕ критичний

3. Time-based Key Rotation:

// Ключ включає часовий bucket
String key = userId + "_" + (System.currentTimeMillis() / 3600000);
// Кожну годину ключ змінюється → повідомлення потрапляють в нову партицію
// Корисно для time-windowed aggregations

4. Consistent Hashing для динамічних партицій:

Ring-based approach: мінімізує переміщення ключів при зміні N
Бібліотеки: ketama, jmpmv/consistent-hashing
Застосування: multi-tenant системи, sharded databases

Key Evolution Problem — формальний аналіз

До: partition = hash(key) % 3
  key="user-1" → hash("user-1") % 3 = 1 → Partition 1

Після: partition = hash(key) % 6
  key="user-1" → hash("user-1") % 6 = 4 → Partition 4

Той самий ключ → інша партиція → порядок порушено!

Математично:
  P(new_partition != old_partition) = 1 - (old_N / new_N)
  Для 3 → 6: P = 1 - 3/6 = 50% ключів перемістяться
  Для 10 → 100: P = 1 - 10/100 = 90% ключів перемістяться

Рішення:

  1. Плануйте кількість партицій із запасом (×10 від поточної потреби)
  2. Використовуйте consistent hashing (кастомний партиціонер)
  3. Створюйте новий топик з потрібною кількістю партицій і мігруйте через dual-write

Key Size Impact on Performance

Розмір ключа Memory overhead CPU (hash) Network Рекомендація
Long (8 bytes) Мінімальний ~5 ns Мінімальний ✅ Оптимально
UUID (36 bytes) Середній ~20 ns Середній ✅ Прийнятно
String (100 bytes) Помітний ~50 ns Помітний ⚠️ Допустимо
JSON (10 KB) Значний ~500 µs Значний ❌ Уникайте

Memory footprint:

Ключ зберігається в:
- Producer RecordBatch → MemoryRecord (heap)
- Broker Log → MessageSet (page cache + heap)
- Consumer ConsumerRecord (heap)

Підсумок: 3 × розмір_ключа × throughput × retention
Для 10KB ключів при 10K msg/s = ~3 GB/s memory traffic

Edge Cases (3+)

  1. Key Null + Idempotent Producer: При enable.idempotence=true і key=null, Kafka генерує sequence number на рівні партиції. Оскільки sticky partitioning перемикає партиції, sequence numbers не конфліктують. Але якщо partitioner.class кастомний і недетермінований — можливі OutOfOrderSequenceException.

  2. Key Serialization Schema Mismatch: Продюсер серіалізує ключ як String (StringSerializer), консьюмер десеріалізує як ByteArray (ByteArrayDeserializer). Ключ «працює» для партиціонування, але бізнес-логіка консьюмера отримує byte[] замість очікуваного String. Тестуйте серіалізацію на обох кінцях.

  3. Unicode Keys і MurmurHash: MurmurHash працює на байтах. Ключ "café" в UTF-8 = [99, 97, 102, 195, 169], в Latin-1 = [99, 97, 102, 233]. Різні кодування → різні байти → різні партиції. Переконайтеся, що всі продюсери використовують однакове кодування (UTF-8 за замовчуванням в Kafka).

  4. Key Mutation в Processor: В Kafka Streams або ksqlDB, якщо processor змінює ключ повідомлення (map((key, value) -> new KeyValue<>(newKey, value))), повідомлення перерозподіляється в нову партицію. Це викликає shuffle — дорогу операцію. Використовуйте through() для явного контролю repartitioning.

  5. Empty Key (не null, але порожній рядок): key=""MurmurHash("") = константа → всі повідомлення з порожнім ключем в одну партицію. Це як hot partition, але складніше виявити (ключ «є», але він марний).

Performance Numbers

Метрика Значення Умови
MurmurHash latency ~5-50 ns Залежить від розміру ключа
Partition compute overhead < 100 ns Включаючи серіалізацію
Key serialization (String, 36 bytes) ~100 ns UTF-8 encoding
Impact of hot key on p99 3-10x latency increase 1 ключ = 50% трафіку

Production War Story

Ситуація: Ride-sharing компанія використовувала driverId як ключ для топика rides. При 10 000 активних водіїв розподіл був рівномірним (20 партицій). Але в годину пік топ-50 водіїв (найактивніші) генерували 40% усіх подій.

Проблема: P99 latency для замовлень виросла з 15ms до 500ms. Lag на «гарячих» партиціях досягав 30K повідомлень. Клієнти скаржилися на «завислі» замовлення.

Аналіз:

Top-10 keys by volume:
  driver-42: 15K msg/min → Partition 7
  driver-17: 12K msg/min → Partition 3
  driver-88: 11K msg/min → Partition 14
  Average: 50 msg/min per driver

Рішення:

  1. Ввели composite key: driverId + "_" + (timestamp / 60000) — ключ змінюється кожну хвилину
  2. Збільшили партиції з 20 до 60
  3. Для агрегацій (count rides per driver) використали Kafka Streams з .groupByKey() — автоматичний repartition
  4. Налаштували alert на per-partition throughput imbalance > 3x

Урок: Рівномірний розподіл ключів ≠ рівномірне навантаження. Аналізуйте frequency distribution ключів, а не тільки cardinality.

Моніторинг (JMX + Burrow)

JMX метрики:

kafka.producer:type=ProducerMetrics,client-id=producer-1,key=record-send-rate
kafka.producer:type=ProducerMetrics,client-id=producer-1,key=batch-size-avg
kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec

Custom monitoring:

// Перехоплювач для аналізу розподілу ключів
public class KeyDistributionInterceptor implements ProducerInterceptor {
    private final Map<String, Long> keyCounts = new ConcurrentHashMap<>();

    public ProducerRecord onSend(ProducerRecord record) {
        keyCounts.merge(String.valueOf(record.key()), 1L, Long::sum);
        return record;
    }
}

Highload Best Practices

  1. Ключ = компактний business entity ID (Long > UUID > String)
  2. Аналізуйте frequency distribution ключів перед production
  3. Composite/salted keys для боротьби з hot partitions (trade-off: порядок)
  4. Уникайте зміни кількості партицій — створюйте новий топик
  5. Тестуйте на production-like даних — реальні ключі мають skew
  6. Моніторте per-partition throughput — alert при imbalance > 3x
  7. Empty string != null — порожній ключ це hot partition
  8. Unicode consistency — усі продюсери повинні використовувати UTF-8

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Ключ визначає партицію: MurmurHash2(key) % numPartitions
  • Повідомлення з однаковим ключем завжди потрапляють в одну партицію → порядок гарантовано
  • Без ключа (null) — Sticky Partitioning, порядку немає
  • Композитний ключ (userId + "_" + orderId) — для рівномірного розподілу
  • Великий ключ (>1KB) — CPU overhead на хешування кожного повідомлення
  • При зміні кількості партицій той самий ключ → інша партиція
  • Empty string ≠ null — порожній ключ = hot partition (все в одну партицію)
  • Ключ = compact business entity ID (Long > UUID > String)

Часті уточнюючі запитання:

  • Що буде якщо key=null? — Sticky Partitioning, порядок не гарантовано.
  • Чому не використовувати JSON як ключ? — 10KB ключ × 10K msg/s = ~3 GB/s memory traffic.
  • Як забезпечити порядок при hot key? — Composite/salted key, trade-off: часткова втрата порядку.
  • Що таке Key Evolution Problem? — При зміні N в hash(key) % N 50-90% ключів переміщуються.

Червоні прапорці (НЕ говорити):

  • «Ключ гарантує глобальний порядок» — тільки всередині однієї партиції
  • «Empty string те саме що null» — empty = hot partition, null = sticky
  • «Можна змінювати кількість партицій без наслідків» — ключі переміщуються
  • «Ключ зберігається тільки у продюсера» — зберігається в RecordBatch, Log, ConsumerRecord (3×)

Пов’язані теми:

  • [[3. Як дані розподіляються по партиціях]]
  • [[2. Що таке партиція (partition) і навіщо вона потрібна]]
  • [[21. Що таке batch в Kafka producer]]
  • [[1. Що таке топiк (topic) в Kafka]]