Як налаштувати exactly-once семантику
Для налаштування семантики Exactly-Once (EOS) в Apache Kafka необхідно узгоджено налаштувати всі три ланки системи: продюсера, брокери та консьюмера, а також використовувати спе...
🟢 Junior Level
30-секундна відповідь
Для налаштування семантики Exactly-Once (EOS) в Apache Kafka необхідно узгоджено налаштувати всі три ланки системи: продюсера, брокери та консьюмера, а також використовувати спеціальний Transaction API:
- Продюсер:
- Задати унікальний постійний
transactional.id. - Увімкнути
enable.idempotence = true(дефолт у Java 3.0+). - Виставити
acks = allтаretries = Integer.MAX_VALUE.
- Задати унікальний постійний
- Брокер:
- Гарантувати реплікацію журналу транзакцій:
transaction.state.log.replication.factor = 3таtransaction.state.log.min.isr = 2.
- Гарантувати реплікацію журналу транзакцій:
- Консьюмер:
- Виставити рівень ізоляції:
isolation.level = read_committed. - Вимкнути автокоміт:
enable.auto.commit = false.
- Виставити рівень ізоляції:
- Код (Read-Process-Write):
- Виконувати читання, обробку, запис у вихідний топік і фіксацію офсетів консьюмера в межах єдиної транзакції через
producer.sendOffsetsToTransaction().
- Виконувати читання, обробку, запис у вихідний топік і фіксацію офсетів консьюмера в межах єдиної транзакції через
Наочна аналогія з життя
Уявіть бухгалтерський переказ коштів:
- Ви переказуєте гроші з одного банківського рахунку на інший: потрібно списати суму з розрахункового рахунку А і зарахувати на розрахунковий рахунок Б.
- Якщо зняти гроші з рахунку А, але сервер банку впаде до зарахування на рахунок Б — гроші зникнуть.
- Якщо зарахувати на рахунок Б, але впасти до списання з А — виникнуть безкоштовні гроші з повітря.
- Транзакція (Exactly-Once) гарантує: або обидва записи з’являються в головній бухгалтерській книзі одночасно, або обидва скасовуються, повертаючи систему до початкового балансу.
Схема топології Read-Process-Write
┌────────────────────────────────────────────────────────┐
│ ЄДИНА АТОМАРНА ТРАНЗАКЦІЯ │
│ │
[ Топік A ] ─┼─► [ Consumer.poll() ] │
│ │ │
│ ▼ │
│ [ Обробка ] │
│ │ │
│ ▼ │
│ [ Producer.send() ] ────────────────► [ Топік B ] │
│ │ │
│ ▼ │
│ [ sendOffsetsToTransaction() ] ───────► [ __consumer_offsets ]
└────────────────────────────────────────────────────────┘
🟡 Middle Level
Реалізація Read-Process-Write через чистий Java API
У чистому Java SDK для узгодження вичитування та відправлення використовується метод sendOffsetsToTransaction():
Properties producerProps = new Properties();
producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
producerProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-processor-tx-1");
producerProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
producerProps.put(ProducerConfig.ACKS_CONFIG, "all");
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor-group");
consumerProps.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
// 1. Ініціалізація продюсера (викликається суворо 1 раз при старті застосунку!)
producer.initTransactions();
consumer.subscribe(List.of("input-orders"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
if (records.isEmpty()) continue;
try {
// 2. Початок транзакції
producer.beginTransaction();
Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();
for (ConsumerRecord<String, String> record : records) {
String processedValue = enrichOrder(record.value());
producer.send(new ProducerRecord<>("output-orders", record.key(), processedValue));
// Зберігаємо наступне зміщення для коміту
offsetsToCommit.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
}
// 3. Прив'язка офсетів консьюмера до транзакції продюсера (KIP-447)
producer.sendOffsetsToTransaction(offsetsToCommit, consumer.groupMetadata());
// 4. Атомарна фіксація (комітять і повідомлення, і офсети)
producer.commitTransaction();
} catch (ProducerFencedException | InvalidProducerEpochException e) {
log.error("Продюсер був витіснений новим інстансом (Zombie). Завершуємо роботу.", e);
producer.close();
break;
} catch (KafkaException e) {
log.warn("Помилка виконання транзакції. Відкочуємося...", e);
// 5. Відкат транзакції при будь-яких мережевих або програмних збоях
producer.abortTransaction();
// Скидаємо позицію консьюмера на останній закомічений офсет
resetToLastCommitted(consumer);
}
}
Налаштування у Spring Boot 3 (KafkaTransactionManager)
У Spring Boot налаштування EOS зводиться до оголошення транзакційного менеджера та анотації @Transactional:
@Configuration
public class KafkaTxConfig {
@Bean
public DefaultKafkaProducerFactory<String, Object> producerFactory() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
configs.put(ProducerConfig.ACKS_CONFIG, "all");
configs.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
DefaultKafkaProducerFactory<String, Object> factory = new DefaultKafkaProducerFactory<>(configs);
// Задання префікса вмикає режим транзакцій
factory.setTransactionIdPrefix("tx-order-");
return factory;
}
@Bean
public KafkaTransactionManager<String, Object> kafkaTransactionManager(
ProducerFactory<String, Object> producerFactory) {
return new KafkaTransactionManager<>(producerFactory);
}
}
@Service
@RequiredArgsConstructor
public class TransactionalOrderListener {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Transactional("kafkaTransactionManager")
@KafkaListener(topics = "input-orders", groupId = "spring-tx-group",
containerFactory = "kafkaListenerContainerFactory")
public void process(ConsumerRecord<String, OrderDto> record) {
OrderDto order = record.value();
ProcessedOrder result = transform(order);
// Відправлення у вихідний топік всередині відкритої транзакції
kafkaTemplate.send("output-orders", record.key(), result);
// Офсет консьюмера буде автоматично відправлений у транзакцію при виході з методу
}
}
🔴 Senior Level
Захист від зомбі через Producer Epoch (Zombie Fencing)
У розподіленій системі под із продюсером може зависнути в довгому GC (Stop-the-World pause). Kubernetes вирішить, що под помер, і підніме дублікат на сусідньому вузлі. Якщо старий под прийде до тями, виникне ситуація двох активних продюсерів з однаковим призначенням (Split-Brain).
Як Kafka вирішує цю проблему через transactional.id:
- При старті новий інстанс продюсера з тим самим
transactional.idвикликаєinitTransactions(). - Брокер Transaction Coordinator збільшує внутрішній лічильник епохи:
Producer Epoch = Epoch + 1. - Координатор негайно і примусово скасовує (ABORT) усі незавершені транзакції старої епохи.
- Коли «зомбі»-под відвисає і намагається викликати
commitTransaction()зі старою епохою, координатор повертає помилкуProducerFencedException. - Зомбі-процес отримує фатальний виняток і аварійно завершує роботу, не встигнувши зіпсувати дані.
Еволюція EOS: KIP-447 (EOS v2 у Kafka 2.5+)
- EOS v1 (до Kafka 2.5): вимагав окремого пулу транзакційних продюсерів для кожного набору партицій консьюмера. Якщо при ребалансі змінювалося призначення партицій, транзакції зависали або вимагали перезапуску всього контексту.
- EOS v2 (KIP-447): метод
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata())приймає метадані групи консьюмерів (включаючи поточнийgeneration.idтаmember.id). Брокер перевіряє офсети прямо в координаторі транзакцій, запобігаючи коміту офсетів, якщо відбувся ребаланс групи. - У Kafka Streams: достатньо виставити один рядок конфігурації:
processing.guarantee=exactly_once_v2
4 Tricky Questions
1. Навіщо в метод sendOffsetsToTransaction() починаючи з Kafka 2.5 передається аргумент consumer.groupMetadata(), і яку вразливість це закрило?
Відповідь:
- У ранніх версіях (EOS v1)
sendOffsetsToTransaction()приймав лише мапу зміщень іgroupId. - Вразливість: якщо в консьюмера завис потік, а координатор групи виключив його за
max.poll.interval.msі перерозподілив партицію іншому вузлу, старий консьюмер міг після відвисання закомітити офсети через продюсера в межах транзакції. Транзакційний координатор не знав, що консьюмер уже виключений із групи, і приймав офсети, перетираючи позицію нового власника партиції! ConsumerGroupMetadataпередає брокеру актуальніgenerationId,memberIdтаgroupInstanceId.- Координатор транзакцій валідує ці метадані в координатора групи: якщо покоління консьюмера застаріло, транзакція скидається з помилкою
CommitFailedExceptionабоFencedInstanceIdException, запобігаючи розсинхронізації групи.
2. Що станеться, якщо в кластері Kafka параметр брокера transaction.state.log.replication.factor залишено рівним 1, а вузол із координатором транзакцій вийшов із ладу?
Відповідь:
- Топік
__transaction_stateзберігає поточний стан усіх активних транзакцій і відповідністьtransactional.id -> PID / Epoch. - Якщо фактор реплікації дорівнює 1, то при фізичній аварії брокера, на якому розміщена партиція цього топіка, метадані транзакцій стають недоступними.
- Наслідки:
- Усі продюсери, чиї транзакції координувалися цим брокером, отримають критичну помилку
CoordinatorNotAvailableExceptionабоTimeoutException. - Ні закомітити, ні відкотити відкриті транзакції неможливо.
- Консьюмери з
read_committedна цільових топіках намертво заблокуються на зміщенні LSO (Last Stable Offset) і не зможуть читати навіть нові повідомлення від інших справних продюсерів!
- Усі продюсери, чиї транзакції координувалися цим брокером, отримають критичну помилку
- Вимога для Production:
transaction.state.log.replication.factor = 3таtransaction.state.log.min.isr = 2.
3. Чому розробникам категорично не рекомендується виставляти занадто великий transaction.timeout.ms (наприклад, 1 годину)?
Відповідь:
- Консьюмери в режимі
read_committedвичитують повідомлення суворо до позначки LSO (Last Stable Offset) — зміщення першої відкритої транзакції. - Якщо транзакційний продюсер відкрив транзакцію і аварійно завис (out of memory, мертве блокування потоків), брокер тримає цю транзакцію у статусі
ONGOINGаж до закінченняtransaction.timeout.ms. - Весь цей час LSO не зсувається! Жоден консьюмер кластера з
read_committedне отримає жодного повідомлення з цієї партиції, навіть якщо паралельно інші продюсери закомітять мільйони записів. - Downstream-сервіси отримають катастрофічне зростання consumer lag. Рекомендоване значення таймауту транзакції — від 10 до 60 секунд (
transaction.timeout.ms=30000).
4. Чи може виникнути витік пам’яті (Memory Leak) на брокері або в топіку через постійні відкати (abortTransaction())?
Відповідь:
- Ні, витоку пам’яті на JVM брокера не відбувається, але виникає дискове засмічення (Log Bloat).
- При виклику
abortTransaction()фізичні повідомлення з файлів сегментів логу.logне видаляються! Брокер лише дописує слідом за ними службовий маркер керуванняABORT. - Консьюмер
read_committedвичитує ці блоки мережею, звіряє їх зі списком скасованих транзакцій (збережених у пам’яті) і відкидає їх на стороні клієнта, витрачаючи мережу і CPU. - Скасовані повідомлення будуть видалені з диска лише тоді, коли сегмент цілком застаріє за часом (
retention.ms) або розміром (retention.bytes) і буде видалений збирачем логів брокера.
🎯 Шпаргалка для інтерв’ю
Головне про налаштування Exactly-Once
- Чекліст продюсера:
transactional.id,enable.idempotence=true,acks=all. - Чекліст консьюмера:
isolation.level=read_committed,enable.auto.commit=false. - Чекліст брокера:
transaction.state.log.replication.factor=3,transaction.state.log.min.isr=2. - Read-Process-Write: офсети консьюмера комітяться через
producer.sendOffsetsToTransaction(offsets, groupMetadata). - Zombie Fencing:
Producer Epochунеможливлює одночасну роботу старого й нового пода під однимtransactional.id. - LSO блокування: поки транзакція не завершена (
commitабоabort), консьюмериread_committedне побачать дані, що йдуть за нею. - Kafka Streams:
processing.guarantee=exactly_once_v2бере всю координацію на себе з коробки.
Порівняння поведінки продюсерів
| Параметр | Ідемпотентний Producer | Транзакційний Producer |
| :— | :— | :— |
| Ідентифікатор | Динамічний PID (змінюється при рестарті) | Постійний transactional.id (зберігається) |
| Область дії | 1 партиція одного топіка | Безліч топіків + __consumer_offsets |
| Захист від збою | Захист від дублів при мережевому retry | Атомарний мультипартиційний коміт/відкат |
| Складність налаштування | Увімкнений за замовчуванням | Вимагає ручної координації коду або Spring |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Для exactly-once достатньо просто ввімкнути idempotence на продюсері» — ідемпотентність захищає лише одну партицію при ретраях, для атомарності кількох партицій та офсетів потрібні транзакції.
- ❌ «Консьюмер у транзакції комітить свої зміщення через consumer.commitSync()» — це ламає атомарність! Офсети зобов’язані відправлятися через
producer.sendOffsetsToTransaction(). - ❌ «initTransactions() потрібно викликати перед кожним відправленим повідомленням» — він викликається суворо один раз при старті життєвого циклу продюсера.