Черги й Kafka
Як сервіси обмінюються повідомленнями асинхронно: чим черга відрізняється від логу подій, як влаштована Kafka, які гарантії доставки реальні та як не загубити подію між базою і брокером. Без цих понять не обговорити жодну інтеграцію між сервісами, тому їх питають майже на кожній співбесіді з бекенду.
Перед цією темою: Стилі інтеграції, Транзакції та блокування.
Чим черга повідомлень (RabbitMQ, Azure Service Bus) відрізняється від логу подій (Kafka)?
Section titled “Чим черга повідомлень (RabbitMQ, Azure Service Bus) відрізняється від логу подій (Kafka)?”Що це
- Черга повідомлень — поштова скринька: одержувач забирає повідомлення, підтверджує обробку, і брокер його видаляє.
- Лог подій — журнал, у який події лише дописуються. Читачі йдуть ним кожен у своєму темпі, а події зберігаються заданий строк.
Як це проявляється
| Ознака | Черга | Лог |
|---|---|---|
| Після обробки | повідомлення видаляється | лишається до кінця строку зберігання |
| Старі повідомлення | перечитати не можна | можна з будь-якої позиції |
| Новий одержувач | бачить лише нові | може прочитати історію |
Навіщо взагалі брокер
Він розв’язує сервіси: відправник не чекає одержувача, а повідомлення не губляться, поки одержувач перезапускається.
Типова помилка
Вважати, що Kafka видаляє повідомлення після читання. Воно лишається в топіку, а «прочитано» — це лише збережена позиція конкретного читача.
Як влаштовано
- Point-to-point. Кілька екземплярів сервісу читають одну чергу й конкурують: кожне повідомлення дістається одному. Так масштабують обробку.
- Pub/sub у черзі. Exchange у RabbitMQ копіює повідомлення в усі прив’язані черги, топік у Service Bus — в усі підписки. Кожен підписник має свою копію.
- Pub/sub у лозі. Запис один, а читають його незалежні групи споживачів, у кожної своя позиція. Усередині групи екземпляри ділять між собою партиції — частини топіка.
Коли що обирати
| Завдання | Вибір |
|---|---|
| Команда одному виконавцю, фонове завдання | черга |
| Повтор із затримкою, пріоритет, TTL повідомлення | черга |
| Факт «сталося» для багатьох систем, перечитування | лог |
| Великий потік подій з порядком за ключем | лог |
Пастки
- Kafka як черга завдань. Одне повільне повідомлення затримує всю партицію, а обробників у групі не більше, ніж партицій.
- Черга як історія. Новий споживач не прочитає минулі події: їх уже видалено.
Правило
Команди й завдання — у чергу, події-факти для багатьох споживачів — у лог.
Під капотом
- Черга зберігає стан кожного повідомлення: кому видано, скільки разів доставлялося, коли спливе блокування. Підтверджене видаляється, тому черга коротка, поки споживачі встигають.
- Лог — файли-сегменти, які брокер пише й читає послідовно. Прогрес споживача — одне число на партицію, і брокер не витрачає ресурси на облік окремих повідомлень.
- Дані з логу видаляє не читання, а політика зберігання: за часом, за розміром або ущільнення за ключем.
Компроміси
| Властивість | Черга | Лог |
|---|---|---|
| Облік доставки | на повідомлення: повтор, затримка, пріоритет | на партицію: одна позиція |
| Масштабування читання | додати обробників | не більше споживачів, ніж партицій |
| Порядок | губиться за конкурентних одержувачів | гарантований усередині партиції |
| Історія | немає | перечитування, нові споживачі |
Межі розмиваються
- RabbitMQ Streams — лог усередині RabbitMQ: повідомлення не видаляються під час читання, їх можна перечитати.
- У Kafka 4.2 share groups (KIP-932, «черги для Kafka») стали production-ready: кілька споживачів читають одну партицію й підтверджують записи поодинці.
Як перевірити в продакшені
- Черга: довжина й вік найстарішого повідомлення показують, що обробники не встигають.
- Лог: відставання групи (consumer lag) — скільки записів між кінцем партиції та позицією групи.
Підсумок: вибір між чергою й логом — вибір, хто відповідає за доставку: брокер за кожне повідомлення чи споживач за свою позицію в потоці.
using Confluent.Kafka;
// Дві групи читають один топік незалежно: кожна отримає всі подіїusing var billing = CreateConsumer("billing");using var analytics = CreateConsumer("analytics");billing.Subscribe("orders");analytics.Subscribe("orders");
// Лог можна перечитати: стаємо на початок партиції 0 без участі відправникаusing var replay = CreateConsumer("replay");replay.Assign(new TopicPartitionOffset("orders", 0, Offset.Beginning));var first = replay.Consume(TimeSpan.FromSeconds(5));Console.WriteLine(first?.Message.Value); // найстаріша подія, яка ще зберігається
static IConsumer<string, string> CreateConsumer(string groupId) => new ConsumerBuilder<string, string>(new ConsumerConfig { BootstrapServers = "localhost:9092", GroupId = groupId, AutoOffsetReset = AutoOffsetReset.Earliest // нова група починає з найстарішої події }).Build();У черзі так не вийде: оброблене повідомлення вже видалено, а нова підписка бачить лише те, що надійшло після її створення.
- Чи можна видалити з Kafka одне повідомлення після обробки? — Ні: дані видаляє лише політика зберігання або ущільнення, а «прочитано» — це позиція групи.
- Як розіслати повідомлення всім підписникам у RabbitMQ? — Fanout- або topic-exchange і окрема черга на кожного підписника.
- Чому не можна пришвидшити читання Kafka, просто додавши екземпляри? — У групі одну партицію читає один споживач, зайві простоюють (крім share groups у Kafka 4.2+).
- Що обрати для завдань на кшталт «сформувати звіт»? — Чергу: конкурентні обробники, повтори й dead letter з коробки.
- Що дає сесія в Azure Service Bus? — Порядок FIFO та одного одержувача для повідомлень з одним
SessionId.
Як влаштована Kafka?
Section titled “Як влаштована Kafka?”Що це
- Топік — іменований потік подій, наприклад
orders. - Партиція — частина топіка, окремий упорядкований лог. Кожен запис у партиції отримує номер — offset.
- Producer пише записи, consumer читає їх.
- Група споживачів — екземпляри одного сервісу зі спільним
GroupId. Партиції топіка діляться між ними.
Як користуватися
- Producer надсилає запис із ключем, наприклад з id замовлення.
- Kafka обирає партицію за гешем ключа: події одного замовлення завжди потрапляють в одну партицію й ідуть по порядку.
- Consumer читає свої партиції й зберігає offset — докуди обробив.
Типова помилка
Чекати порядку в усьому топіку. Порядок є лише всередині партиції, а записи різних партицій читаються впереміш.
Як влаштовано
- Ключ. Запис із ключем потрапляє в партицію за гешем ключа, без ключа — розподіляється для балансу.
- Група. Одну партицію в групі читає один споживач; якщо споживачів більше, ніж партицій, зайві простоюють.
- Ребалансування. Партиції перерозподіляються, коли учасник прийшов, пішов, не відповів за
session.timeout.msабо не викликавConsumeдовше заmax.poll.interval.ms. - Offset. Група комітить offset наступного непрочитаного запису у внутрішній топік
__consumer_offsets. - Репліки. Партиція має лідера й послідовників на інших брокерах. Синхронні репліки (ISR) не відстають від лідера.
- Зберігання.
cleanup.policy=deleteвидаляє старі сегменти за часом або розміром (за замовчуванням через 7 днів),compactлишає останній запис кожного ключа — ущільнення логу.
Коли що обирати
Налаштування acks |
Що дає |
|---|---|
0 |
максимум швидкості, втрати можливі |
1 |
підтверджує лідер; втрата, якщо він впаде до реплікації |
all і min.insync.replicas=2 |
запис переживе падіння брокера |
Пастки
Довга обробка між викликами Consume викидає споживача з групи: партиції переходять іншому, і незакомічені записи обробляються повторно.
Правило
Ключ — сутність, для якої важливий порядок; партицій — із запасом під майбутній паралелізм.
Під капотом
- Партиція на диску — набір файлів-сегментів. Запис дописується в кінець, читання йде послідовно й спирається на page cache ОС, тому Kafka тримає великий потік на звичайних дисках.
- Репліка, що відстала довше за
replica.lag.time.max.ms, випадає з ISR. Якщо ISR менше заmin.insync.replicas, запис зacks=allотримує помилкуNotEnoughReplicas. - Із Kafka 4.0 метадані зберігає кворум контролерів KRaft, режиму ZooKeeper більше немає.
- Протокол груп KIP-848 (GA у Kafka 4.0, у librdkafka — з 2.12) переносить призначення партицій на брокер. Вмикається
group.protocol=consumer, за замовчуванням у librdkafka покиclassic.
Ціна
- Кількість партицій не можна зменшити. Збільшення змінює відповідність ключ → партиція: нові події ключа підуть в іншу партицію раніше, ніж дочитано старі.
- Ключ із великою часткою трафіку, наприклад великий магазин, перевантажує одну партицію.
Confluent.Kafka(librdkafka) за замовчуванням гешує ключ CRC32, а Java-клієнт — murmur2. Producer-и різними мовами розкладають один ключ по різних партиціях; для сумісності ставлятьPartitioner.Murmur2Random.
Як перевірити в продакшені
kafka-consumer-groups.sh --describe --group billingпоказує offset групи й відставання по кожній партиції.- Часті ребалансування в логах клієнта — ознака довгої обробки або нестабільних екземплярів.
Підсумок: Kafka швидка, бо брокер лише зберігає й реплікує лог; ключ, кількість партицій і acks — рішення застосунку, і помилки в них дорого виправляти.
using Confluent.Kafka;
var config = new ProducerConfig{ BootstrapServers = "localhost:9092", Acks = Acks.All, // чекати всі синхронні репліки Partitioner = Partitioner.Murmur2Random // ті самі партиції, що в Java-клієнта};
using var producer = new ProducerBuilder<string, string>(config).Build();
foreach (var status in new[] { "Created", "Paid", "Shipped" }){ // Один ключ — одна партиція: події замовлення 42 буде прочитано по порядку var result = await producer.ProduceAsync("orders", new Message<string, string> { Key = "order-42", Value = status }); Console.WriteLine($"{status}: partition {result.Partition.Value}, offset {result.Offset.Value}");}// Created: partition 3, offset 120 — номери для прикладу, партиція в усіх трьох одна// Paid: partition 3, offset 121// Shipped: partition 3, offset 122using Confluent.Kafka;
var config = new ConsumerConfig{ BootstrapServers = "localhost:9092", GroupId = "billing", // екземпляри з одним GroupId ділять партиції AutoOffsetReset = AutoOffsetReset.Earliest // звідки читати, якщо в групи ще немає offset};
using var consumer = new ConsumerBuilder<string, string>(config) .SetPartitionsAssignedHandler((_, parts) => Console.WriteLine($"Assigned: {string.Join(", ", parts)}")) .SetPartitionsRevokedHandler((_, parts) => Console.WriteLine($"Revoked: {string.Join(", ", parts)}")) .Build();
consumer.Subscribe("orders");var result = consumer.Consume(TimeSpan.FromSeconds(10)); // Assigned: orders [[0]], orders [[1]]if (result is not null) Console.WriteLine($"{result.Message.Key}: {result.Message.Value} at {result.TopicPartitionOffset}");
consumer.Close(); // вийти з групи одразу, а не чекати session.timeout.msЗапустіть другий екземпляр з тим самим GroupId — перший надрукує Revoked і отримає половину партицій.
- Що буде, якщо споживачів у групі більше, ніж партицій? — Зайві не отримають партицій і простоюватимуть.
- Як гарантувати порядок подій одного замовлення? — Один ключ (id замовлення) — одна партиція; і не збільшувати кількість партицій на ходу.
- Чим небезпечний
acks=1? — Лідер підтвердив і впав до реплікації — запис втрачено. - Де зберігається offset групи? — У внутрішньому топіку
__consumer_offsets. - Що таке compacted topic і навіщо він? — Зберігає останній запис на ключ: довідники, стан; запис із
nullвидаляє ключ.
Які бувають гарантії доставки і як обробляти збої?
Section titled “Які бувають гарантії доставки і як обробляти збої?”Що це
| Гарантія | Втрата | Дублі |
|---|---|---|
| at-most-once | можлива | немає |
| at-least-once | немає | можливі |
| exactly-once | немає | немає |
Звідки беруться дублі
- Споживач обробив повідомлення, наприклад списав гроші.
- Він упав раніше, ніж підтвердив обробку брокеру.
- Після перезапуску брокер видає це повідомлення знову.
Як користуватися
- Підтверджуйте повідомлення після обробки, а не до.
- Робіть обробку ідемпотентною: перевіряйте, чи не оброблено вже повідомлення з цим id.
- Повідомлення, яке падає раз у раз, після кількох спроб відкладайте в dead letter queue — окрему чергу для розбору.
Типова помилка
Підтверджувати повідомлення одразу після отримання. Якщо обробка впаде, повідомлення втрачено.
Як влаштовано
- At-most-once. Підтвердження або коміт offset до обробки. Падіння посеред обробки — повідомлення втрачено.
- At-least-once. Підтвердження після обробки. Падіння між обробкою й підтвердженням — повтор.
- Exactly-once у Kafka. Ідемпотентний producer відкидає дублі від повторних надсилань. Транзакції атомарно об’єднують запис у вихідні топіки й коміт offset, а споживачі з
read_committedне бачать скасованих записів. Зовнішня база до транзакції не входить.
Як обробляти збої
- Тимчасова помилка (таймаут, дедлок) — кілька повторів із паузою просто в обробнику.
- Не допомогло — відкладена повторна доставка або retry-топік зі зростаючою затримкою.
- Спроби вичерпано або помилка постійна (битий JSON, порушено контракт) — у DLQ з причиною помилки.
Пастки
- У
Confluent.Kafkaза замовчуванням увімкненоEnableAutoCommitіEnableAutoOffsetStore: offset вважається обробленим, щойноConsumeповернув повідомлення. Для at-least-once ставлятьEnableAutoOffsetStore = falseі викликаютьStoreOffsetпісля обробки. - Без DLQ poison message у Kafka зупиняє партицію: споживач повторює його нескінченно.
Правило
At-least-once, ідемпотентний обробник, обмежена кількість повторів і DLQ з алертом.
Під капотом
- Ідемпотентний producer отримує producer id, а брокер пам’ятає номери послідовності по кожній партиції й відкидає повтор. Порядок зберігається при
max.in.flight.requests.per.connectionдо 5. - У Java-клієнті ідемпотентність увімкнена за замовчуванням із Kafka 3.0, у librdkafka та
Confluent.Kafka— ні:EnableIdempotence = trueставлять явно. - Транзакційний producer з
TransactionalIdпише записи й offset групи (SendOffsetsToTransaction) атомарно. librdkafka за замовчуванням читає зread_committed, Java-клієнт — зread_uncommitted.
Компроміси
| Повтор | Плюс | Мінус |
|---|---|---|
| На місці, в обробнику | порядок збережено | партиція стоїть, поки йдуть повтори |
| Retry-топік і DLQ | потік не блокується | порядок за ключем порушується |
| Паркування ключа | порядок за ключем збережено | треба зберігати «застряглі» ключі |
Транзакції Kafka додають затримку й маркери коміту в лог, а зовнішні ефекти все одно потребують дедуплікації. Тому частіше обирають at-least-once та ідемпотентного споживача.
Як перевірити в продакшені
- Розмір і вік DLQ — метрика з алертом: DLQ, яку ніхто не розбирає, — та сама втрата даних, лише відкладена.
- Лічильники повторів і відкинутих дублів показують, де обробник не справляється.
Підсумок: exactly-once існує лише всередині Kafka; на межі із зовнішнім світом надійність збирають з at-least-once, ідемпотентності, обмежених повторів і DLQ.
using System.Text;using Confluent.Kafka;
var consumerConfig = new ConsumerConfig{ BootstrapServers = "localhost:9092", GroupId = "billing", EnableAutoCommit = true, // коміт за таймером... EnableAutoOffsetStore = false // ...лише тих offset, що збережені явно};var producerConfig = new ProducerConfig { BootstrapServers = "localhost:9092", EnableIdempotence = true };
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();using var dlq = new ProducerBuilder<string, string>(producerConfig).Build();consumer.Subscribe("orders");
while (true){ var result = consumer.Consume(); for (var attempt = 1; ; attempt++) { try { Handle(result.Message); break; } catch (Exception) when (attempt < 3) { await Task.Delay(200 * attempt); } // тимчасовий збій catch (Exception ex) { // Poison message: відкладаємо в DLQ з причиною, щоб не стояти на місці await dlq.ProduceAsync("orders.dlq", new Message<string, string> { Key = result.Message.Key, Value = result.Message.Value, Headers = new Headers { { "error", Encoding.UTF8.GetBytes(ex.Message) } } }); break; } } consumer.StoreOffset(result); // оброблено або в DLQ — лише тепер offset можна комітити}
static void Handle(Message<string, string> message) { /* ідемпотентна обробка */ }Упаде процес до StoreOffset — повідомлення надійде знову: це at-least-once, тому Handle зобов’язаний бути ідемпотентним.
- Чому exactly-once Kafka не рятує від подвійного списання в базі? — Транзакція Kafka не охоплює зовнішню базу; потрібна дедуплікація на боці споживача.
- Що робить
enable.idempotence? — Брокер відкидає повторні надсилання за producer id і номером послідовності, порядок зберігається. - Чим poison message відрізняється від тимчасової помилки? — Його повтор ніколи не допоможе: биті дані або порушений контракт.
- Як переобробити повідомлення з DLQ? — Виправити причину й перенадіслати в основний топік чи чергу інструментом або скриптом; обробник ідемпотентний, тому це безпечно.
- Що станеться з порядком, якщо повідомлення, що впало, піде в retry-топік? — Наступні події того самого ключа оброблять раніше за нього.
Як надійно опублікувати подію після зміни в БД?
Section titled “Як надійно опублікувати подію після зміни в БД?”Що це
Сервіс зберігає замовлення й публікує подію OrderCreated. Це два записи в різні системи — базу й брокер, спільної транзакції в них немає.
Що може піти не так
- Замовлення збережено, а процес упав до надсилання — подію втрачено.
- Подію надіслано, а транзакцію в базі відкочено — інші сервіси дізналися про неіснуюче замовлення.
Як користуватися outbox
- У тій самій транзакції, що й замовлення, вставте рядок у таблицю
OutboxMessages: тип події та JSON. - Фоновий процес читає ненадіслані рядки й публікує їх у брокер.
- Після підтвердження брокера рядок позначається надісланим.
Типова помилка
Надсилати подію всередині транзакції БД і чекати, що відкат її скасує. Брокер до транзакції не входить.
Як влаштовано
- Бізнес-зміна й запис в outbox — одна локальна транзакція. В EF Core це один
SaveChanges. - Relay —
BackgroundServiceабо окремий процес — вибирає ненадіслані записи по порядку. - Публікує кожен у брокер і чекає підтвердження.
- Позначає запис надісланим. Упав між кроками 3 і 4 — запис піде повторно, це at-least-once.
Inbox на боці споживача
В одній транзакції споживач вставляє id повідомлення в таблицю InboxMessages з первинним ключем за id і застосовує зміни. Повтор порушить ключ: повідомлення вже оброблено, його підтверджують і пропускають.
Коли що обирати
| Доставка з outbox | Коли |
|---|---|
| Опитування таблиці | за замовчуванням: помірний потік, затримка в секунди припустима |
| CDC (Debezium) | великий потік, потрібна мала затримка, Kafka Connect уже є |
Пастки
- Кілька екземплярів relay без блокувань публікують ті самі записи. Рядки захоплюють через
UPDLOCK, READPAST(SQL Server) абоFOR UPDATE SKIP LOCKED(PostgreSQL) чи лишають один екземпляр relay. - Таблиця outbox росте нескінченно, якщо не видаляти надіслані записи.
Правило
Outbox — у відправника, inbox або ідемпотентний обробник — в одержувача.
Під капотом
- Outbox не прибирає розподілену транзакцію, а замінює її локальною плюс доставкою at-least-once. Між сервісами лишається узгодженість у кінцевому підсумку: підписники дізнаються про зміну із затримкою.
- CDC читає журнал транзакцій: конектор Debezium для SQL Server спирається на вбудований CDC, для PostgreSQL — на логічне декодування. Outbox Event Router перетворює рядки outbox на записи Kafka з ключем агрегата.
Компроміси
| Relay | Плюс | Мінус |
|---|---|---|
| Опитування таблиці | лише база й свій код | навантаження на базу, затримка до інтервалу опитування |
| CDC | порядок комітів, мала затримка | Kafka Connect і доступ до журналу |
Паралельний relay або повтор одного запису змінює порядок подій. Якщо порядок за агрегатом важливий, публікують послідовно з ключем агрегата, а споживач звіряє версію агрегата й відкидає застаріле.
Готові реалізації
- MassTransit: bus outbox для публікації поза споживачем і consumer outbox з inbox. Із v9 MassTransit — комерційний продукт, v8 лишається open source.
- NServiceBus Outbox: дедуплікація вхідних і відкладене надсилання вихідних в одній транзакції; продукт комерційний.
- Wolverine: durable inbox та outbox поверх EF Core, Marten, SQL Server або PostgreSQL.
Як перевірити в продакшені
Вік найстарішого ненадісланого запису outbox — головна метрика: зростання означає, що relay стоїть або не встигає.
Підсумок: outbox та inbox замінюють розподілену транзакцію локальними; лишається обрати доставку — опитування чи CDC — і вирішити, як зберегти порядок.
// NuGet: Microsoft.EntityFrameworkCore.SqlServer, Confluent.Kafkausing System.Text.Json;using Confluent.Kafka;using Microsoft.EntityFrameworkCore;
await using var db = new ShopDb();
// 1. Замовлення й подія — одна транзакція: збережуться обидва або жоднеvar order = new Order { Id = Guid.NewGuid(), Total = 150m };db.Orders.Add(order);db.Outbox.Add(new OutboxMessage{ MessageId = Guid.NewGuid(), AggregateId = order.Id.ToString(), Type = "OrderCreated", Payload = JsonSerializer.Serialize(new { order.Id, order.Total })});await db.SaveChangesAsync();
// 2. Relay: публікує ненадіслане по порядку й позначає надісланимusing var producer = new ProducerBuilder<string, string>( new ProducerConfig { BootstrapServers = "localhost:9092", EnableIdempotence = true }).Build();var pending = await db.Outbox.Where(m => m.SentAt == null).OrderBy(m => m.Id).Take(100).ToListAsync();foreach (var message in pending){ await producer.ProduceAsync("orders", new Message<string, string> { Key = message.AggregateId, // ключ агрегата — порядок подій одного замовлення Value = message.Payload, Headers = new Headers { { "message-id", message.MessageId.ToByteArray() } } }); message.SentAt = DateTime.UtcNow; await db.SaveChangesAsync(); // упадемо до цього рядка — подія піде повторно}
public class Order { public Guid Id { get; set; } public decimal Total { get; set; } }
public class OutboxMessage{ public long Id { get; set; } // identity: задає порядок public Guid MessageId { get; set; } // для дедуплікації у споживача public required string AggregateId { get; set; } public required string Type { get; set; } public required string Payload { get; set; } public DateTime? SentAt { get; set; } // null — ще не надіслано}
public class ShopDb : DbContext{ public DbSet<Order> Orders => Set<Order>(); public DbSet<OutboxMessage> Outbox => Set<OutboxMessage>(); protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True;TrustServerCertificate=True");}-- Inbox: первинний ключ не дасть обробити повідомлення двічіCREATE TABLE InboxMessages ( MessageId uniqueidentifier NOT NULL PRIMARY KEY, ProcessedAt datetime2 NOT NULL DEFAULT SYSUTCDATETIME());
BEGIN TRAN;INSERT INTO InboxMessages (MessageId) VALUES (@MessageId); -- дубль: помилка 2627, відкат і ackUPDATE Accounts SET Balance = Balance - @Amount WHERE Id = @AccountId;COMMIT;
-- Relay у кількох екземплярах (усередині транзакції): кожен бере свої рядки, зайняті пропускаєSELECT TOP (100) Id, MessageId, AggregateId, PayloadFROM OutboxMessages WITH (UPDLOCK, READPAST, ROWLOCK)WHERE SentAt IS NULLORDER BY Id;READPAST дозволяє relay працювати паралельно, але порядок між екземплярами вже не гарантований.
- Чому не можна надіслати подію одразу після
SaveChanges? — Процес може впасти між комітом і надсиланням — подія загубиться. - Що гарантує outbox — exactly-once чи at-least-once? — At-least-once: relay може опублікувати запис повторно, дублі прибирає inbox.
- Чим CDC кращий за опитування таблиці? — Менші затримка й навантаження на базу, порядок комітів; ціна — Kafka Connect і доступ до журналу.
- Як зберегти порядок подій одного агрегата? — Послідовний relay, ключ агрегата в Kafka й перевірка версії у споживача.
- Коли чистити outbox та inbox? — Outbox — після надсилання; inbox — за строком, більшим за вікно можливих повторів.
Як виконати бізнес-операцію через кілька сервісів без розподіленої транзакції?
Section titled “Як виконати бізнес-операцію через кілька сервісів без розподіленої транзакції?”Що це
Оформлення замовлення зачіпає три сервіси: замовлення, оплату й склад. Кожен має свою базу, спільної транзакції немає. Сага розбиває операцію на кроки, і кожен крок — локальна транзакція в одному сервісі.
Як це працює
- Сервіс замовлень створює замовлення зі статусом
Pending. - Сервіс оплати списує гроші.
- Сервіс складу резервує товар.
- Сервіс замовлень переводить замовлення в
Confirmed.
Що як крок не вдався
Товару немає — крок 3 не виконано. Тоді у зворотному порядку йдуть компенсації: оплата повертає гроші, замовлення отримує статус Cancelled.
Типова помилка
Вважати компенсацію відкатом, який «стирає» крок. Списання вже було, клієнт міг отримати SMS — компенсація це нова бізнес-дія зі своїми наслідками.
Як влаштовано
| Ознака | Хореографія | Оркестрація |
|---|---|---|
| Хто веде процес | ніхто: сервіси реагують на події | оркестратор розсилає команди |
| Де видно стан | розмазаний по сервісах | в одному записі саги |
| Підходить | 2–3 кроки, простий потік | багато кроків, розгалуження, таймаути |
Замовлення з оркестратором
- Оркестратор створює запис саги в стані
AwaitingPaymentі надсилає командуChargePayment. - Отримавши
PaymentCompleted, переходить вAwaitingStockі надсилаєReserveStock. - Отримавши
StockReserved, підтверджує замовлення — сагу завершено. - Отримавши
StockUnavailable, надсилає компенсаціюRefundPayment, а після повернення скасовує замовлення. - Відповіді немає довше за таймаут — повторює команду або запускає компенсації.
Пастки
- Повідомлення надходять at-least-once: кроки й компенсації мають бути ідемпотентними.
- Компенсація теж може впасти — її повторюють до успіху.
- Зміну стану саги й надсилання команди пов’язують через outbox, інакше сага зависне між кроками.
Правило
Кожен крок до точки неповернення має компенсацію, кожне очікування — таймаут.
Чому не 2PC
- Двофазний коміт тримає блокування в усіх учасників, доки координатор не оголосить результат. Упав координатор між фазами — ресурси лишаються заблокованими в стані in doubt.
- Kafka, RabbitMQ і HTTP-API в MSDTC не беруть участі, а розподілені транзакції в .NET 7+ працюють лише на Windows.
- Кожен учасник стає обов’язковим для коміту, і доступність операції падає.
Під капотом
- Стан саги — запис у базі оркестратора: id, поточний крок, дані, строк очікування. Його оновлюють з оптимістичним блокуванням: події однієї саги можуть надійти паралельно.
- Таймаути — заплановані повідомлення або опитування записів зі сплилим строком.
- Кроки діляться на компенсовні, точку неповернення й повторювані після неї. Незворотне, наприклад відвантаження, ставлять після всіх перевірок.
Компроміси
Ізоляції немає: інший запит побачить списані гроші в ще не підтвердженого замовлення. Azure Architecture Center пропонує контрзаходи: семантичне блокування (статус Pending, який враховують інші операції), комутативні оновлення, перечитування значення перед зміною.
Як перевірити в продакшені
- Саги, що застрягли в одному стані довше за SLA, — головна метрика й алерт.
- Кореляційний id саги в логах і трейсах пов’язує кроки різних сервісів.
Підсумок: сага міняє ізоляцію розподіленої транзакції на доступність і вимагає проєктувати компенсації, таймаути та видимі проміжні стани як частину бізнес-процесу.
var saga = new OrderSaga(Guid.NewGuid());Print(saga.Start()); // ChargePaymentPrint(saga.Handle(new PaymentCompleted())); // ReserveStockPrint(saga.Handle(new StockUnavailable())); // RefundPayment — компенсаціяPrint(saga.Handle(new StockUnavailable())); // (порожньо) — дубль ігноруєтьсяPrint(saga.Handle(new PaymentRefunded())); // CancelOrderConsole.WriteLine(saga.State); // Cancelled
static void Print(object[] commands) => Console.WriteLine(string.Join(", ", commands.Select(c => c.GetType().Name)));
public enum SagaState { New, AwaitingPayment, AwaitingStock, Compensating, Completed, Cancelled }
public record PaymentCompleted; public record PaymentFailed; public record PaymentRefunded;public record StockReserved; public record StockUnavailable;public record ChargePayment(Guid OrderId); public record RefundPayment(Guid OrderId);public record ReserveStock(Guid OrderId); public record ConfirmOrder(Guid OrderId);public record CancelOrder(Guid OrderId);
public class OrderSaga(Guid orderId){ public SagaState State { get; private set; } = SagaState.New;
public object[] Start() => Go(SagaState.AwaitingPayment, new ChargePayment(orderId));
// (поточний стан, подія) → новий стан і команди учасникам public object[] Handle(object @event) => (State, @event) switch { (SagaState.AwaitingPayment, PaymentCompleted) => Go(SagaState.AwaitingStock, new ReserveStock(orderId)), (SagaState.AwaitingPayment, PaymentFailed) => Go(SagaState.Cancelled, new CancelOrder(orderId)), (SagaState.AwaitingStock, StockReserved) => Go(SagaState.Completed, new ConfirmOrder(orderId)), (SagaState.AwaitingStock, StockUnavailable) => Go(SagaState.Compensating, new RefundPayment(orderId)), (SagaState.Compensating, PaymentRefunded) => Go(SagaState.Cancelled, new CancelOrder(orderId)), _ => [] // дубль або застаріла подія: нічого не робимо };
private object[] Go(SagaState next, params object[] commands) { State = next; return commands; }}У реальній системі State і дані саги зберігаються в базі, команди йдуть через outbox, а таймаути надходять як відкладені повідомлення.
- Чим компенсація відрізняється від відкату? — Відкат стирає незафіксоване, компенсація — нова дія поверх зафіксованого, і її видно.
- Що робити, якщо компенсація не вдалася? — Повторювати (вона ідемпотентна), а після вичерпання спроб — алерт і ручний розбір.
- Коли хореографія стає проблемою? — Коли кроків багато: процес не видно цілком, з’являються циклічні залежності між сервісами.
- Що таке точка неповернення? — Крок, після якого компенсацій немає, а решту кроків лише повторюють до успіху.
- Чи є готові реалізації в .NET? — Саги-автомати в MassTransit і NServiceBus, Durable Functions; стан вони зберігають самі.