Вопрос 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 — дорогой operation. Используйте 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. Что такое топик (topic) в Kafka]]