Перейти до вмісту

Черги й Kafka

Як сервіси обмінюються повідомленнями асинхронно: чим черга відрізняється від логу подій, як влаштована Kafka, які гарантії доставки реальні та як не загубити подію між базою і брокером. Без цих понять не обговорити жодну інтеграцію між сервісами, тому їх питають майже на кожній співбесіді з бекенду.

Перед цією темою: Стилі інтеграції, Транзакції та блокування.

Junior+

Чим черга повідомлень (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) — скільки записів між кінцем партиції та позицією групи.

Підсумок: вибір між чергою й логом — вибір, хто відповідає за доставку: брокер за кожне повідомлення чи споживач за свою позицію в потоці.

Приклад
Confluent.Kafka
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();

У черзі так не вийде: оброблене повідомлення вже видалено, а нова підписка бачить лише те, що надійшло після її створення.

Middle+
Відповідь

Що це

  • Топік — іменований потік подій, наприклад orders.
  • Партиція — частина топіка, окремий упорядкований лог. Кожен запис у партиції отримує номер — offset.
  • Producer пише записи, consumer читає їх.
  • Група споживачів — екземпляри одного сервісу зі спільним GroupId. Партиції топіка діляться між ними.

Як користуватися

  1. Producer надсилає запис із ключем, наприклад з id замовлення.
  2. Kafka обирає партицію за гешем ключа: події одного замовлення завжди потрапляють в одну партицію й ідуть по порядку.
  3. 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 — рішення застосунку, і помилки в них дорого виправляти.

Producer: ключ і порядок
Confluent.Kafka
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 122
Consumer: група й ребалансування
Confluent.Kafka
using 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 і отримає половину партицій.

Middle+

Які бувають гарантії доставки і як обробляти збої?

Section titled “Які бувають гарантії доставки і як обробляти збої?”
Відповідь

Що це

Гарантія Втрата Дублі
at-most-once можлива немає
at-least-once немає можливі
exactly-once немає немає

Звідки беруться дублі

  1. Споживач обробив повідомлення, наприклад списав гроші.
  2. Він упав раніше, ніж підтвердив обробку брокеру.
  3. Після перезапуску брокер видає це повідомлення знову.

Як користуватися

  • Підтверджуйте повідомлення після обробки, а не до.
  • Робіть обробку ідемпотентною: перевіряйте, чи не оброблено вже повідомлення з цим id.
  • Повідомлення, яке падає раз у раз, після кількох спроб відкладайте в dead letter queue — окрему чергу для розбору.

Типова помилка

Підтверджувати повідомлення одразу після отримання. Якщо обробка впаде, повідомлення втрачено.

Як влаштовано

  • At-most-once. Підтвердження або коміт offset до обробки. Падіння посеред обробки — повідомлення втрачено.
  • At-least-once. Підтвердження після обробки. Падіння між обробкою й підтвердженням — повтор.
  • Exactly-once у Kafka. Ідемпотентний producer відкидає дублі від повторних надсилань. Транзакції атомарно об’єднують запис у вихідні топіки й коміт offset, а споживачі з read_committed не бачать скасованих записів. Зовнішня база до транзакції не входить.

Як обробляти збої

  1. Тимчасова помилка (таймаут, дедлок) — кілька повторів із паузою просто в обробнику.
  2. Не допомогло — відкладена повторна доставка або retry-топік зі зростаючою затримкою.
  3. Спроби вичерпано або помилка постійна (битий 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.

Приклад
Confluent.Kafka
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 зобов’язаний бути ідемпотентним.

Senior+

Як надійно опублікувати подію після зміни в БД?

Section titled “Як надійно опублікувати подію після зміни в БД?”
Відповідь

Що це

Сервіс зберігає замовлення й публікує подію OrderCreated. Це два записи в різні системи — базу й брокер, спільної транзакції в них немає.

Що може піти не так

  • Замовлення збережено, а процес упав до надсилання — подію втрачено.
  • Подію надіслано, а транзакцію в базі відкочено — інші сервіси дізналися про неіснуюче замовлення.

Як користуватися outbox

  1. У тій самій транзакції, що й замовлення, вставте рядок у таблицю OutboxMessages: тип події та JSON.
  2. Фоновий процес читає ненадіслані рядки й публікує їх у брокер.
  3. Після підтвердження брокера рядок позначається надісланим.

Типова помилка

Надсилати подію всередині транзакції БД і чекати, що відкат її скасує. Брокер до транзакції не входить.

Як влаштовано

  1. Бізнес-зміна й запис в outbox — одна локальна транзакція. В EF Core це один SaveChanges.
  2. Relay — BackgroundService або окремий процес — вибирає ненадіслані записи по порядку.
  3. Публікує кожен у брокер і чекає підтвердження.
  4. Позначає запис надісланим. Упав між кроками 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 — і вирішити, як зберегти порядок.

Outbox на EF Core: запис і один прохід relay
// NuGet: Microsoft.EntityFrameworkCore.SqlServer, Confluent.Kafka
using 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");
}
SQL: inbox і захоплення рядків кількома relay
-- 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, відкат і ack
UPDATE Accounts SET Balance = Balance - @Amount WHERE Id = @AccountId;
COMMIT;
-- Relay у кількох екземплярах (усередині транзакції): кожен бере свої рядки, зайняті пропускає
SELECT TOP (100) Id, MessageId, AggregateId, Payload
FROM OutboxMessages WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE SentAt IS NULL
ORDER BY Id;

READPAST дозволяє relay працювати паралельно, але порядок між екземплярами вже не гарантований.

Senior+

Як виконати бізнес-операцію через кілька сервісів без розподіленої транзакції?

Section titled “Як виконати бізнес-операцію через кілька сервісів без розподіленої транзакції?”
Відповідь

Що це

Оформлення замовлення зачіпає три сервіси: замовлення, оплату й склад. Кожен має свою базу, спільної транзакції немає. Сага розбиває операцію на кроки, і кожен крок — локальна транзакція в одному сервісі.

Як це працює

  1. Сервіс замовлень створює замовлення зі статусом Pending.
  2. Сервіс оплати списує гроші.
  3. Сервіс складу резервує товар.
  4. Сервіс замовлень переводить замовлення в Confirmed.

Що як крок не вдався

Товару немає — крок 3 не виконано. Тоді у зворотному порядку йдуть компенсації: оплата повертає гроші, замовлення отримує статус Cancelled.

Типова помилка

Вважати компенсацію відкатом, який «стирає» крок. Списання вже було, клієнт міг отримати SMS — компенсація це нова бізнес-дія зі своїми наслідками.

Як влаштовано

Ознака Хореографія Оркестрація
Хто веде процес ніхто: сервіси реагують на події оркестратор розсилає команди
Де видно стан розмазаний по сервісах в одному записі саги
Підходить 2–3 кроки, простий потік багато кроків, розгалуження, таймаути

Замовлення з оркестратором

  1. Оркестратор створює запис саги в стані AwaitingPayment і надсилає команду ChargePayment.
  2. Отримавши PaymentCompleted, переходить в AwaitingStock і надсилає ReserveStock.
  3. Отримавши StockReserved, підтверджує замовлення — сагу завершено.
  4. Отримавши StockUnavailable, надсилає компенсацію RefundPayment, а після повернення скасовує замовлення.
  5. Відповіді немає довше за таймаут — повторює команду або запускає компенсації.

Пастки

  • Повідомлення надходять 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()); // ChargePayment
Print(saga.Handle(new PaymentCompleted())); // ReserveStock
Print(saga.Handle(new StockUnavailable())); // RefundPayment — компенсація
Print(saga.Handle(new StockUnavailable())); // (порожньо) — дубль ігнорується
Print(saga.Handle(new PaymentRefunded())); // CancelOrder
Console.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, а таймаути надходять як відкладені повідомлення.