Що станеться при додаванні нового консьюмера в групі
При підключенні нового консьюмера до вже працюючої Consumer Group в Apache Kafka запускається процес ребалансування (Rebalance).
🟢 Junior Level
30-секундна відповідь
При підключенні нового консьюмера до вже працюючої Consumer Group в Apache Kafka запускається процес ребалансування (Rebalance).
Що відбувається за кроками:
- Реєстрація: новий інстанс відправляє брокеру (Group Coordinator) запит на вступ до групи.
- Перерозподіл: координатор групи перераховує розподіл партицій, щоб виділити новачку частину роботи.
- Відкликання партицій: у деяких раніше працюючих консьюмерів відкликається частина партицій і передається новому учаснику.
- Відновлення вичитування: новий консьюмер починає читати призначені йому партиції зі зміщень, зафіксованих у топіку
__consumer_offsets.
Наочна аналогія з життя
Уявіть бригаду кур’єрів служби доставки:
- У місті 6 районів (6 партицій).
- На зміні працюють 2 кур’єри: Кур’єр А розвозить замовлення по районах 1, 2 і 3, а Кур’єр Б — по районах 4, 5 і 6.
- В офіс приходить новий співробітник (Кур’єр В). Бригадир оголошує коротку п’ятихвилинку:
- Кур’єр А віддає район 3 новенькому.
- Кур’єр Б віддає район 6 новенькому.
- Тепер навантаження розподілено порівну: у кожного по 2 райони. Усі кур’єри працюють швидше, доставка прискорюється!
Схема розподілу до і після додавання
Було (2 консьюмери на 4 партиції):
[Partition 0] ──┐
[Partition 1] ──┴──► [ Consumer 1 ]
[Partition 2] ──┐
[Partition 3] ──┴──► [ Consumer 2 ]
Підключився Consumer 3 ──► [ REBALANCE ] ──► Стало (3 консьюмери на 4 партиції):
[Partition 0] ──┐
[Partition 1] ──┴──► [ Consumer 1 ]
[Partition 2] ──────► [ Consumer 2 ]
[Partition 3] ──────► [ Consumer 3 ] <-- Новачок забрав партицію 3
🟡 Middle Level
Покроковий протокол підключення до групи
[ Новий Консьюмер C3 ] [ Group Coordinator (Брокер) ] [ Працюючі C1 і C2 ]
│ │ │
│ 1. JoinGroup (memberId="") │ │
├─────────────────────────────────────►│ │
│ 2. Помилка: MEMBER_ID_REQUIRED │ │
│◄─────────────────────────────────────┤ │
│ │ │
│ 3. JoinGroup (з виданим memberId) │ │
├─────────────────────────────────────►│ │
│ │ 4. Coordinator чекає завершення │
│ │ поточного poll() у C1 і C2 │
│ │ (в межах max.poll.interval) │
│ │◄────────────────────────────────┤
│ │ JoinGroup від C1 і C2 │
│ 5. Призначення Лідера (зазвичай C1) │ │
│ 6. Лідер рахує Assignor.assign() │ │
│ 7. SyncGroup Request │ 7. SyncGroup Request │
├─────────────────────────────────────►│◄────────────────────────────────┤
│ 8. SyncGroup Response (Новий план) │ 8. SyncGroup Response │
│◄─────────────────────────────────────┼────────────────────────────────►│
│ C3 отримує Partition 3 │ C2 віддала P3, залишилась з P2 │
Роль ConsumerRebalanceListener при відкликанні партиції
Коли брокер повідомляє консьюмеру, що в нього забирають партицію для передачі новому учаснику, викликається метод onPartitionsRevoked().
Критично важливо коректно обробити цю подію:
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
log.info("Партиції відкликаються брокером: {}", partitions);
// 1. Дочікуємося обробки поточного батча повідомлень
// 2. Синхронно фіксуємо зміщення, щоб новачок не прочитав дублікати!
consumer.commitSync();
// 3. Скидаємо проміжні буфери в БД
flushInternalBuffers(partitions);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
log.info("Призначено нові партиції: {}", partitions);
// Ініціалізуємо локальні кеші або виконуємо consumer.seek()
}
});
🔴 Senior Level
Чому в CooperativeStickyAssignor додавання консьюмера вимагає ДВОХ раундів ребалансування?
У застарілому протоколі Eager Rebalance всі консьюмери здавали всі свої партиції за 1 раунд із повною паузою (Stop-the-World).
У сучасному CooperativeStickyAssignor (KIP-429) ребалансування розбито на два швидких кооперативних кроки, щоб ніхто не зупиняв читання незачеплених партицій:
РАУНД 1: Визначення та відкликання мігруючих партицій
- C3 підключається. C1 і C2 продовжують вичитувати дані!
- Лідер бачить: C1 тримає {P0, P1}, C2 тримає {P2, P3}.
- Лідер вирішує: P3 потрібно віддати новачку C3.
- Рішення 1-го раунду: C1 залишає {P0, P1}, C2 відкликає P3 (залишається з P2), C3 поки отримує ПОРОЖНІЙ набір {}.
- C2 викликає onPartitionsRevoked({P3}), фіксує зміщення і відпускає партицію. C1 ні на секунду не зупиняється!
РАУНД 2: Передача звільненої партиції
- Координатор фіксує, що P3 звільнилася.
- Негайно ініціюється короткий другий раунд:
- C3 отримує {P3}, C1 зберігає {P0, P1}, C2 зберігає {P2}.
- Вичитування повністю збалансоване без жодної глобальної паузи!
Проблема зомбі-консьюмерів (Zombie Fencing & Epochs)
Що коли вузол C2 завис у важкому GC, брокер визнав його мертвим, віддав партицію P3 новачку C3, а потім C2 «відвис» і намагається дописати щось у БД або закомітити офсет?
- Split-Brain Risk: два консьюмери одночасно пишуть дані однієї й тієї ж партиції в базу даних.
- Захист поколіннями (Generation ID / Member Epoch):
- При кожній зміні складу групи брокер збільшує номер покоління
Generation ID(у KIP-848 —Member Epoch). - Будь-який мережевий запит до брокера (
OffsetCommitRequest,FetchRequest) містить поточнийGeneration ID. - Якщо брокер бачить, що
Generation IDконсьюмера C2 застарів, запит негайно відхиляється з помилкоюCommitFailedExceptionабоFencedMemberEpochException.
- При кожній зміні складу групи брокер збільшує номер покоління
4 Tricky Questions
1. Чому при найпершому вступі до групи новий консьюмер надсилає запит JoinGroup ДВІЧІ?
Відповідь:
- У ранніх версіях Kafka консьюмер генерував свій
member.idсам або брокер створював його одразу на старті ребалансу. Це породжувало вразливість: при рестарті консьюмерів брокер ініціював хибні ребаланси на фантомних учасників. - Починаючи з KIP-394, запроваджено суворий двофазний handshake:
- Новий консьюмер надсилає перший
JoinGroupRequestіз порожнім ідентифікаторомmember.id = "". - Брокер не починає ребалансування! Він лише реєструє намір, генерує унікальний ID (наприклад,
consumer-group-1-UUID) і повертає помилкуMEMBER_ID_REQUIREDіз прикріпленим згенерованим ID. - Клієнт зберігає отриманий ID і надсилає другий
JoinGroupRequest, що вже містить валіднийmember.id.
- Новий консьюмер надсилає перший
- Лише при отриманні другого запиту координатор офіційно включає учасника до групи та запускає процедуру перерозподілу. Це запобігає випадковим ребалансам через «мертві» мережеві запити.
2. Що станеться, якщо в момент додавання нового консьюмера працюючий інстанс виконує довгу бізнес-транзакцію (наприклад, 15 секунд), а max.poll.interval.ms дорівнює 30 секундам?
Відповідь:
- Брокер не може силою перервати виконання Java-коду всередині методу
consume(). Він лише встановлює прапорецьRebalanceInProgressу відповідях на Heartbeat. - Координатор групи буде терпляче чекати, доки працюючий інстанс закінчить свою 15-секундну транзакцію та прийде на наступну ітерацію
consumer.poll(). - Щойно інстанс викликає
poll(), клієнтська бібліотека Kafka бачить прапорець ребалансу, викликаєConsumerRebalanceListener.onPartitionsRevoked()і шле свійJoinGroup. - Час початку фактичного перерозподілу партицій затягується рівно на ці 15 секунд. Усі інші консьюмери (включаючи новачка) чекатимуть закінчення цієї транзакції у фазі
JoinGroup.
3. Новий консьюмер підключився до групи, ребалансування завершилося, йому призначено партицію 2. З якого саме зміщення він почне читати повідомлення?
Відповідь:
- Логіка визначення стартового офсету суворо регламентована:
- Консьюмер надсилає брокеру запит
OffsetFetchRequestдля призначеної партиції 2. - Брокер читає останнє підтверджене зміщення для цієї групи з внутрішнього топіка
__consumer_offsets. - Якщо в
__consumer_offsetsзнайдено запис (наприклад, офсет 45100), новий консьюмер починає вичитування суворо зі зміщення 45100. - Якщо зміщення в
__consumer_offsetsнемає (топік читається цією групою вперше або термін зберігання офсетівoffsets.retention.minutesминув): консьюмер дивиться на свій локальний параметрauto.offset.reset:earliest: почне читати із самого початку логу партиції (офсет 0 або найстаріший доступний на диску).latest: переміститься в самий кінець логу і чекатиме лише нових повідомлень.none: викине фатальний винятокNoOffsetForPartitionException.
- Консьюмер надсилає брокеру запит
4. Що станеться, якщо новий консьюмер вступив у групу, але кількість консьюмерів перевищила кількість партицій топіка (наприклад, 5-й консьюмер на топік з 4 партиціями)?
Відповідь:
- Процедура ребалансування виконається штатно.
- Стратегія розподілу (
PartitionAssignor) розподілить 4 наявні партиції між будь-якими 4 учасниками групи. - П’ятий консьюмер отримає порожній список призначень:
assignedPartitions = []. - Він перейде в режим Idle:
- Він продовжить надсилати Heartbeat координатору кожні
heartbeat.interval.ms, підтверджуючи свою життєздатність. - Метод
consumer.poll()миттєво повертатиме порожній список записівConsumerRecords.empty(). - Жодного навантаження з бізнес-логіки він не несе і чекає, доки хтось із 4 активних учасників впаде або залишить групу.
- Він продовжить надсилати Heartbeat координатору кожні
🎯 Шпаргалка для інтерв’ю
Головне про додавання нового консьюмера
- Що відбувається: Запускається Rebalance для рівномірного перерозподілу партицій.
- Двофазний handshake (KIP-394): Перший
JoinGroupотримуєmember.id, другий запускає ребаланс. - Старий Eager протокол: Повна зупинка всіх учасників групи (Stop-the-World на 5–30 секунд).
- Сучасний Cooperative Sticky (KIP-429): Проходить у 2 коротких етапи, незачеплені партиції читаються без зупинки.
- Обов’язок розробника: Реалізувати
ConsumerRebalanceListener.onPartitionsRevoked()і викликатиcommitSync(). - З якого офсету читає новачок: З останнього закоміченого зміщення з топіка
__consumer_offsets. - Захист від зомбі:
Generation ID/Member Epochблокує застарілі інстанси з помилкоюCommitFailedException.
Порівняння поведінки протоколів при масштабуванні
| Характеристика | Eager Assignor | CooperativeStickyAssignor | | :— | :— | :— | | Зупинка роботи | Усі інстанси зупиняють читання | Читання продовжується на незачеплених партиціях | | Кількість раундів | 1 раунд (із повною паузою) | 2 швидких раунди (відкликання $\to$ призначення) | | Поведінка при зростанні подів | Шторм ребалансувань, сплеск лагу | М’який інкрементальний перерозподіл |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Новий консьюмер одразу ж починає читати повідомлення паралельно зі старим консьюмером» — читання почнеться лише ПІСЛЯ завершення ребалансу і відкликання партиції у старого.
- ❌ «Новачок завжди читає повідомлення із самого початку топіка (з 0)» — він починає з останнього закоміченого офсету групи в
__consumer_offsets. - ❌ «Ребаланс миттєво перериває виконання потоку на працюючому сервері» — брокер чекає завершення поточної ітерації
poll()у межахmax.poll.interval.ms.