📨 Розділ 15 · Питання #7

Чи можна мати більше консьюмерів, ніж партицій

Проте практичної користі для швидкості обробки це не принесе:


🟢 Junior Level

30-секундна відповідь

Технічно — так, можна. Kafka не забороняє запустити в межах однієї Consumer Group будь-яку кількість консьюмерів (наприклад, 10 консьюмерів на топік з 3 партиціями).

Проте практичної користі для швидкості обробки це не принесе:

  1. Правило взаємного виключення: всередині однієї групи одна партиція може бути призначена максимум одному консьюмеру.
  2. Стан Idle (Простій): усі «зайві» консьюмери (у прикладі — 7 з 10) перебуватимуть у режимі очікування (Idle). Вони не отримують і не обробляють жодного повідомлення.
  3. Єдине застосування: створення «гарячого резерву» (Hot Standby). Якщо один із 3 активних консьюмерів аварійно впаде, один із простоюючих консьюмерів зможе підхопити звільнену партицію без очікування холодного запуску контейнера.

Наочна аналогія з життя

Уявіть супермаркет із 3 працюючими касовими апаратами (3 партиції):

  • Адміністратор вивів на зміну бригаду з 5 касирів (5 консьюмерів у групі).
  • Касири 1, 2 та 3 сідають за каси й обслуговують покупців.
  • Касири 4 та 5 стоять поруч зі складеними руками (режим Idle). Вони не можуть сісти за ту саму касу вдвох, бо плутатимуть чеки та решту.
  • Черга покупців від присутності касирів 4 та 5 швидше рухатися не стане! Але якщо Касир 1 піде на лікарняний, Касир 4 миттєво займе його місце.

Схема розподілу

Топік (3 партиції)       Consumer Group (5 інстансів)
┌─────────────┐          ┌──────────────────────────────────────┐
│ Partition 0 ├─────────►│ Consumer 1 [АКТИВНИЙ]                │
├─────────────┤          ├──────────────────────────────────────┤
│ Partition 1 ├─────────►│ Consumer 2 [АКТИВНИЙ]                │
├─────────────┤          ├──────────────────────────────────────┤
│ Partition 2 ├─────────►│ Consumer 3 [АКТИВНИЙ]                │
└─────────────┘          ├──────────────────────────────────────┤
                         │ Consumer 4 [IDLE - ПРОСТОЮЄ]         │
                         ├──────────────────────────────────────┤
                         │ Consumer 5 [IDLE - ПРОСТОЮЄ]         │
                         └──────────────────────────────────────┘

🟡 Middle Level

Поведінка та оверхед Idle-консьюмерів

Багато розробників вважають, що простоюючий консьюмер повністю «безкоштовний». Це помилка:

  • Постійний Heartbeat: фоновий потік кожного idle-консьюмера продовжує регулярно надсилати мережеві запити HeartbeatRequest координатору групи на брокері.
  • Споживання ресурсів JVM: кожен екземпляр застосунку утримує стек пам’яті, алокації буферів, файлові дескриптори для відкритих TCP-сокетів та потоки операційної системи.
  • Участь у протоколі ребалансування: при виникненні будь-якого ребалансу ВСІ idle-консьюмери зобов’язані надіслати запити JoinGroup та SyncGroup, створюючи додаткове навантаження на мережу та процесор брокера.

Як реально збільшити паралелізм без збільшення партицій?

Якщо топік має фіксовану кількість партицій (наприклад, 4), а лаг зростає, додавання 5-го пода консьюмера безглузде.

Для вирішення проблеми застосовуються такі підходи:

  1. Збільшення кількості партицій топіка на брокері:
    kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic orders --partitions 10
    

    Нюанс: збільшення партицій змінює хешування ключів (murmur2(key) % N), що може порушити порядок для старих сутностей.

  2. Багатопотокова обробка всередині консьюмера (In-JVM Parallelism): Один консьюмер вичитує повідомлення з партиції пачкою (poll()) і розподіляє завдання по пулу потоків.

Spring Boot 3: Налаштування паралелізму контейнера

У Spring Kafka параметр concurrency задає кількість потоків-консьюмерів у межах JVM:

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
            ConsumerFactory<String, String> consumerFactory) {
        ConcurrentKafkaListenerContainerFactory<String, String> factory = 
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory);
        // Задавати concurrency більше за кількість партицій топіка не має сенсу!
        factory.setConcurrency(3); 
        return factory;
    }
}

🔴 Senior Level

Паралельна обробка на Java 21 Virtual Threads зі збереженням порядку за ключем

При передачі завдань у стандартний пул потоків (ExecutorService) суворий порядок повідомлень порушується: повідомлення 2 може завершитися раніше повідомлення 1. Для високопродуктивної паралельної обробки всередині однієї партиції без порушення порядку за бізнес-ключем використовується архітектура Key-based Worker Dispatcher на Віртуальних Потоках:

public class KeyOrderedVirtualThreadDispatcher {

    // Черги завдань, прив'язані до конкретних ключів сутностей
    private final ConcurrentMap<String, BlockingQueue<ConsumerRecord<String, String>>> keyQueues = 
            new ConcurrentHashMap<>();
    private final ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

    public void dispatch(ConsumerRecord<String, String> record) {
        String key = record.key() != null ? record.key() : "DEFAULT";
        
        keyQueues.compute(key, (k, queue) -> {
            if (queue == null) {
                BlockingQueue<ConsumerRecord<String, String>> newQueue = new LinkedBlockingQueue<>();
                newQueue.add(record);
                // Запускаємо віртуальний потік суворо для обробки черги цього ключа
                virtualExecutor.submit(() -> processQueue(newQueue));
                return newQueue;
            } else {
                queue.add(record);
                return queue;
            }
        });
    }

    private void processQueue(BlockingQueue<ConsumerRecord<String, String>> queue) {
        while (!queue.isEmpty()) {
            ConsumerRecord<String, String> record = queue.poll();
            if (record != null) {
                handleBusinessLogic(record);
            }
        }
    }

    private void handleBusinessLogic(ConsumerRecord<String, String> record) {
        // Бізнес-логіка виконується суворо послідовно для кожного конкретного ключа!
    }
}

Проблема асинхронного коміту офсетів (Sliding Window Acknowledgment)

Якщо обробка повідомлень іде в паралельні потоки, консьюмер не має права викликати commitSync(record.offset() + 1) одразу після віддачі завдання в потік!

  • Якщо потік, що обробляє повідомлення зі зміщенням 105, впаде з помилкою, а потік для зміщення 106 успішно завершиться і закомітить офсет 107 — повідомлення 105 буде втрачено назавжди!
  • Рішення: використання ковзного вікна підтверджень (Sliding Window Acknowledger). Зміщення комітяться на брокер суворо неперервним монотонно зростаючим префіксом (high watermark).

Hot Standby в Kubernetes: N+1 архітектура

У mission-critical фінансових системах втрата часу на запуск нового пода (Pull Docker Image, JVM warm-up, Spring Context Init = 30–60 секунд) неприпустима.

  • Архітектори свідомо запускають $N + 1$ або $N + 2$ репліки сервісу (наприклад, 10 подів на 8 партицій).
  • 8 подів вичитують трафік, а 2 поди перебувають у розігрітому стані (Spring Context завантажено, конекти до БД відкриті, кеші прогріті).
  • При загибелі активного пода координатор групи перепризначає партицію на готовий резервний под за 1–2 секунди, мінімізуючи простій SLA.

4 Tricky Questions

1. У топіку 4 партиції, працюють 4 консьюмери. Почався сплеск трафіку, зріс лаг. DevOps масштабує Deployment в Kubernetes з 4 до 8 реплік. Що станеться з лагом і швидкістю вичитування?

Відповідь:

  • Швидкість вичитування не збільшиться ні на одне повідомлення на секунду, а лаг у перші секунди стане рости ще швидше!
  • Поява 4 нових подів ініціює процедуру ребалансування (Rebalance). Усі 4 раніше працюючі консьюмери призупинять роботу (або втратять частину часу на кооперативну координацію), щоб включити новачків у групу.
  • Після ребалансу брокер зрозуміє, що партицій усього 4, і призначить їх будь-яким 4 інстансам. Решта 4 подів перейдуть у режим Idle.
  • У підсумку: корисна пропускна здатність групи залишиться суворо незмінною (обмеженою 4 партиціями), а час, витрачений на ребалансування, лише збільшить відставання від продюсерів.

2. Як за допомогою віртуальних потоків Java 21 масштабувати вичитування однієї партиції в 100 разів, і яка головна небезпека при цьому виникає з комітами офсетів?

Відповідь:

  • Основний потік консьюмера виконує consumer.poll(), отримує пачку з 500 записів і запускає на кожен запис окремий Virtual Thread через Executors.newVirtualThreadPerTaskExecutor(). Завдяки легковажності віртуальних потоків мережеві затримки та I/O зовнішніх API перестають блокувати читання партиції.
  • Головна небезпека: порушення неперервності офсетів (Offset Gaps). Kafka зберігає лише одне число — скалярний коміт-офсет для партиції. Якщо запис з офсетом 102 обробився за 5 мілісекунд, а запис 101 завис на 2 секунди, передчасний коміт офсету 103 призведе до того, що при аварійному рестарті консьюмер почне читати зі 103, а запис 101 залишиться необробленим (тиха втрата даних). Для вирішення необхідний трекінг неперервного вікна оброблених офсетів.

3. Якщо консьюмер перебуває у стані Idle і не читає жодних партицій, чи може він стати причиною раптового ребалансування всієї групи?

Відповідь:

  • Так, може!
  • Навіть перебуваючи в Idle-режимі, консьюмер зобов’язаний регулярно надсилати Heartbeat брокеру та опитувати цикл подій.
  • Якщо на вузлі, де запущено idle-консьюмер, стається довга пауза збирача сміття (Stop-the-World Major GC) або мережевий збій, що перевищує session.timeout.ms, координатор групи вважає цей інстанс загиблим і запускає ребаланс для всієї групи.
  • Аналогічно, якщо idle-консьюмер завершує роботу штатно (SIGTERM при деплої), брокер отримує мережевий LeaveGroupRequest і також запускає ребалансування решти працюючих учасників групи.

4. Чому при використанні кількох топіків кількість консьюмерів може законно перевищувати кількість партицій будь-якого окремо взятого топіка?

Відповідь:

  • Якщо Consumer Group підписується відразу на кілька топіків (наприклад, Топік A з 3 партиціями і Топік B з 4 партиціями), сумарна кількість партицій, доступних для групи, дорівнює $3 + 4 = 7$.
  • У такій конфігурації запуск 6 або 7 консьюмерів абсолютно виправданий: при використанні RoundRobinAssignor або CooperativeStickyAssignor кожен із 7 консьюмерів отримає рівно по одній активній партиції (хтось із топіка A, хтось із топіка B), і жоден інстанс не буде простоювати вхолосту.

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

Головне про співвідношення консьюмерів і партицій

  • Чи можна більше: Так, технічно дозволено, але зайві будуть у режимі Idle.
  • Інваріант: 1 партиція топіка $\leftrightarrow$ максимум 1 консьюмер у межах однієї групи.
  • Вплив на throughput: Додавання консьюмерів понад кількість партицій не збільшує швидкість обробки.
  • Оверхед Idle-консьюмерів: Споживають RAM JVM, тримають сокети, шлють Heartbeat, беруть участь у ребалансуваннях.
  • Навіщо потрібен N+1 (Hot Standby): Миттєва заміна впалого інстанса без холодного старту контейнера.
  • Як масштабувати: Або додати партицій у топік, або використовувати пул потоків (або Virtual Threads у Java 21) всередині консьюмера з контролем порядку за ключем.
  • Spring Boot concurrency: Не виставляти вище за реальну кількість партицій топіка.

Порівняння станів

| Параметр | Активний консьюмер | Idle-консьюмер (Hot Standby) | | :— | :— | :— | | Призначені партиції | $\ge 1$ | 0 | | Вичитування повідомлень | Так | Ні | | Відправлення Heartbeat | Так | Так | | Споживання Heap/CPU | Високе (бізнес-логіка) | Базове (JVM + мережевий клієнт) | | Участь у Rebalance | Так | Так |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Якщо lag зростає, потрібно просто накинути ще реплік пода в Kubernetes» — якщо реплік стане більше за партиції, нові поди будуть простоювати.
  • ❌ «Зайві консьюмери в Kafka допомагають активним швидше читати повідомлення з однієї партиції» — категорично неправильно, 1 партиція закріплена за 1 консьюмером.
  • ❌ «Idle-консьюмери сплять і взагалі не взаємодіють із кластером» — вони регулярно відправляють heartbeat-запити брокеру.

Пов’язані запитання