Как данные распределяются по партициям
Распределение сообщений по партициям топика выполняется на стороне клиента-продюсера компонентом Partitioner по двум базовым сценариям:
🟢 Junior Level
30-секундный ответ
Распределение сообщений по партициям топика выполняется на стороне клиента-продюсера компонентом Partitioner по двум базовым сценариям:
- Сообщение с ключом (
key != null): брокер использует детерминированное хэширование. Хэш-код ключа (алгоритм MurmurHash2) делится по модулю на общее число партиций. Все сообщения с одинаковым ключом (например,orderId = "ord-100") всегда попадают строго в одну и ту же партицию, гарантируя порядок обработки FIFO. - Сообщение БЕЗ ключа (
key == null): начиная с Kafka 2.4+, используется Sticky Partitioning (липкое партиционирование). Продюсер упаковывает сообщения пачками (batches) в одну выбранную партицию до её наполнения, после чего переключается на следующую. Это максимизирует сжатие и пропускную способность сети.
Наглядная аналогия из жизни
Распределение сообщений — это сортировка посылок на почтовом складе:
- С ключом: на каждой коробке написан почтовый индекс региона (ключ). Сортировщик всегда отправляет коробки с индексом «101» строго в фургон №1. Сколько бы коробок с этим индексом ни пришло, они все поедут в одной машине и прибудут в порядке отправки.
- Без ключа: коробки без индекса (общий груз). Сортировщик не разбрасывает по одной коробке в разные фургоны (старый Round-Robin), а полностью загружает фургон №1 до потолка (Sticky Partitioning), закрывает двери и только затем переходит к фургону №2.
Базовый пример отправки в Java
// 1. Отправка С КЛЮЧОМ (детерминированная маршрутизация)
ProducerRecord<String, String> keyedRecord =
new ProducerRecord<>("orders", "customer-42", "{\"item\":\"phone\"}");
producer.send(keyedRecord); // Всегда одна и та же партиция для customer-42
// 2. Отправка БЕЗ КЛЮЧА (Sticky Partitioning)
ProducerRecord<String, String> unkeyedRecord =
new ProducerRecord<>("telemetry", null, "{\"temp\": 23.5}");
producer.send(unkeyedRecord); // Упаковывается в текущий активный батч
🟡 Middle Level
Алгоритм DefaultPartitioner и математическая ловушка
Под капотом стандартный DefaultPartitioner вычисляет целевую партицию по формуле:
\(\text{partition} = \text{Utils.toPositive}(\text{Utils.murmur2}(\text{keyBytes})) \pmod{\text{numPartitions}}\)
Почему не Math.abs(key.hashCode()) % numPartitions?
Это одна из любимых тем технических собеседований по Java:
// ❌ ГРУБАЯ ОШИБКА в самописных партиционерах:
int partition = Math.abs(key.hashCode()) % numPartitions;
В Java диапазон int от $-2^{31}$ до $2^{31}-1$. Для числа Integer.MIN_VALUE ($-2147483648$) в двухкомпонентном представлении нет положительного аналога!
Вызов Math.abs(Integer.MIN_VALUE) возвращает отрицательное число: Integer.MIN_VALUE!
Операция % numPartitions возвращает отрицательный индекс партиции, что приводит к падению приложения с ArrayIndexOutOfBoundsException!
Kafka решает эту проблему побитовой маской в служебном методе:
public static int toPositive(int number) {
return number & 0x7fffffff; // Обнуляет знаковый бит, делая число строго >= 0
}
Эволюция маршрутизации без ключа: Round-Robin vs Sticky Partitioning
[ Старый Round-Robin (до Kafka 2.4) ]
Запрос 1 ──► Партиция 0 (батч 100 байт) ──► Отправка в сеть
Запрос 2 ──► Партиция 1 (батч 100 байт) ──► Отправка в сеть
Запрос 3 ──► Партиция 2 (батч 100 байт) ──► Отправка в сеть
Итог: сеть забита микро-пакетами, сжатие не работает, высокий CPU оверхед.
[ Sticky Partitioning (Kafka 2.4+) ]
Запрос 1, 2, 3... 50 ──► Все пишутся в Партицию 0
(Батч достиг batch.size = 16KB или истек linger.ms) ──► Одна плотная сетевая посылка
Запрос 51... ──► Переключение на Партицию 1
Итог: экстремальная пропускная способность, отличное сжатие LZ4/ZSTD.
Написание Custom Partitioner
Если бизнес-требования требуют специальной маршрутизации (например, VIP-клиенты всегда идут в партицию 0, а остальные распределяются по партициям 1..N):
public class VipCustomerPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if (key instanceof String stringKey && stringKey.startsWith("VIP_")) {
return 0; // Выделенная партиция для премиальных клиентов
}
// Остальные партиции (1 .. numPartitions - 1)
int nonVipPartitions = numPartitions - 1;
int hash = Utils.toPositive(Utils.murmur2(keyBytes));
return 1 + (hash % nonVipPartitions);
}
@Override
public void configure(Map<String, ?> configs) {}
@Override
public void close() {}
}
🔴 Senior Level
Проблема «Горячего ключа» (Hot Key Skew) и техники борьбы
В крупных системах распределение ключей подчиняется закону Парето: 1% клиентов генерируют 90% нагрузки.
- Если ключ — это
merchantId, а крупный ритейлер генерирует 50 000 сообщений в секунду, его партиция захлебнётся, а консьюмер отстанет (Consumer Lag).
Архитектурные шаблоны устранения Skew:
- Compound Routing Key (Составной бизнес-ключ): Если внутри одного клиента нам нужен порядок только в рамках конкретного заказа, ключом назначается связка: \(\text{key} = \text{merchantId} + \text{"\#"} + \text{orderId}\) Заказы одного мерчанта равномерно распределяются по всем партициям, сохраняя при этом строгий порядок событий по каждому конкретному заказу!
- Key Salting с ограничением по модулю (Ограниченное соление):
// Расщепляем супер-тяжелый ключ ровно на 4 партиции: int salt = ThreadLocalRandom.current().nextInt(4); String routedKey = "SUPER_MERCHANT_" + salt;
KIP-794: Архитектурные изменения в Kafka 3.3+
Начиная с Apache Kafka 3.3.0, интерфейс Partitioner и конфигурация partitioner.class были признаны deprecated:
- В современных версиях логика выбора партиций встроена непосредственно в
KafkaProducerчерез оптимизированный классBuiltInPartitioner. - Это позволило устранить лишние аллокации объектов в памяти JVM на каждое отправляемое сообщение и реализовать адаптивный алгоритм Adaptive Partitioning: продюсер динамически отслеживает latency брокеров и начинает отдавать предпочтение тем партициям, лидеры которых отвечают быстрее, избегая отправки сообщений в перегруженные ноды.
Влияние linger.ms и batch.size на Sticky Partitioning
batch.size(по умолчанию 16 КБ): максимальный размер батча в байтах.linger.ms(по умолчанию 0 мс): время ожидания накопления батча перед принудительной отправкой в сокет.
Опасный пограничный случай (Edge Case):
Если в продюсере выставлены параметры linger.ms = 0 и batch.size = 16384, но поток событий низкий (1 сообщение раз в 50 мс):
- Каждое сообщение мгновенно отправляется в сеть отдельным сетевым запросом.
- Sticky Partitioner после отправки незаполненного батча производит ротацию партиции.
- В результате Sticky Partitioning фактически вырождается в неэффективный Round-Robin, генерируя огромное количество полупустых сетевых пакетов TCP. Для высоконагруженных продюсеров обязательна установка
linger.ms = 10..20мс.
4 Tricky Questions
1. Почему популярный код Math.abs(key.hashCode()) % partitions.size() периодически приводил к падению сервисов в Production с ошибкой ArrayIndexOutOfBoundsException?
Ответ:
- В Java метод
Math.abs(int a)для отрицательных чисел выполняет операцию-a. - Минимальное отрицательное 32-битное число
Integer.MIN_VALUEравно $-2147483648$. - Максимальное положительное число равно $2147483647$.
- Поскольку число $2147483648$ не помещается в положительный диапазон
int, при переполнении знаковый бит остаётся равным 1, и методMath.abs(Integer.MIN_VALUE)возвращает $-2147483648$! - В результате операция
% partitions.size()дает отрицательный остаток (например,-2), и при попытке обратиться кpartitions.get(-2)сервис выбрасываетArrayIndexOutOfBoundsException. - Решение: использовать побитовую маску
key.hashCode() & 0x7fffffffили утилиту KafkaUtils.toPositive().
2. Почему Kafka использует алгоритм MurmurHash2 для вычисления хэша ключа вместо стандартного String.hashCode() из Java SDK?
Ответ: По двум фундаментальным причинам:
- Языковая нейтральность (Polyglot Clients): Kafka — это распределенная платформа. Продюсеры могут быть написаны на Go, C++, Python, Rust или C#. Стандартный метод Java
String.hashCode()специфичен для JVM. Алгоритм MurmurHash2 является общепринятым индустриальным стандартом с единым байтовым алгоритмом во всех языках программирования. Благодаря этому Go-продюсер и Java-продюсер отправят сообщение с ключом"order-1"в одну и ту же партицию. - Равномерность распределения (Avalanche Effect):
String.hashCode()страдает от высокой частоты коллизий на коротких или похожих строках. MurmurHash2 обладает превосходным лавинным эффектом (изменение 1 бита в ключе меняет 50% бит в хэше), обеспечивая идеальное распределение по партициям без перекосов.
3. Как изменится маршрутизация сообщений, если отправлять записи с ключом типа byte[] по сравнению с ключом типа String?
Ответ:
- Если передаётся
String, стандартный сериализаторStringSerializerпреобразует строку в массив байт в кодировке UTF-8, и MurmurHash2 вычисляется от этих байт. - Если передаётся кастомный Java-объект без кастомного сериализатора, либо если в Java переопределен
hashCode(), Kafka Partitioner никогда не вызывает Java-метод.hashCode()у объекта! - Partitioner Kafka всегда работает исключительно с сериализованным бинарным массивом
byte[]. - Внимание: если сериализатор сериализует Java-объекты не детерминированно (например, используя стандартную сериализацию Java с разными метаданными полей или JSON с плавающим порядком полей), одинаковые по смыслу объекты получат разные байтовые массивы, разные хэши и разлетятся по разным партициям!
4. Что произойдет со Sticky Partitioner, если целевой брокер, на котором находится лидер текущей партиции, внезапно начинает отвечать с задержками (I/O Bottleneck)?
Ответ:
- В версиях Kafka до 3.2 продюсер продолжал упрямо слать сообщения в текущую «залипшую» партицию, пока не заполнится батч, что приводило к росту очередей в
RecordAccumulatorи исчерпанию памятиbuffer.memoryна стороне продюсера. - Начиная с Kafka 3.3+ (в рамках KIP-794), встроенный партиционер отслеживает время ожидания в очереди и готовность брокеров. Если партиция блокируется или брокер не отвечает в течение таймаута, партиционер принудительно переключает липкий батч (Unstick) на другую доступную партицию здорового брокера, защищая продюсер от каскадного отказа по таймаутам отправки.
🎯 Шпаргалка для интервью
Главное о распределении данных
- С ключом:
Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions$\to$ строгий детерминизм и FIFO. - Без ключа: Sticky Partitioning (с Kafka 2.4+) $\to$ батчинг в одну партицию до заполнения, затем ротация.
- Математика Java:
Math.abs(Integer.MIN_VALUE) < 0! В партиционерах использоватьhash & 0x7fffffff. - MurmurHash2: кросс-языковой алгоритм, гарантирующий идентичную маршрутизацию из Java, Go, Python.
- Hot Key Skew: перекос нагрузки от крупных клиентов; лечится солением ключей (Salting) или составными ключами.
- Изменение числа партиций: ломает соответствие
key -> partitionдля всех старых ключей!
Стратегии партиционирования
| Метод | Когда применяется | Порядок сообщений | Пропускная способность |
| :— | :— | :— | :— |
| Keyed (MurmurHash2) | key != null | ✅ Строгий FIFO в рамках ключа | Зависит от равномерности ключей |
| Sticky Partitioning | key == null | ❌ Глобального порядка нет | 🚀 Максимальная (крупные сжатые батчи) |
| Round-Robin | Устарел (до 2.4) | ❌ Порядка нет | 🔻 Низкая (мелкие неэффективные пакеты) |
| Custom Partitioner | Специфичные правила | Регулируется кодом | Зависит от реализации |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Если ключа нет, Kafka шлёт по одному сообщению по очереди в каждую партицию (Round-Robin)» — устарело с 2019 года (Kafka 2.4); по умолчанию работает Sticky Partitioning.
- ❌ «Kafka использует стандартный метод key.hashCode() для вычисления партиции» — нет, используется MurmurHash2 от сериализованного массива байт.
- ❌ «Чтобы починить отрицательный хэш в Java, я просто вызываю Math.abs()» — фатально для
Integer.MIN_VALUE.