Пошук уроків, статей та іншого контенту
Координуємо розподілені транзакції через кроки й компенсації та визначаємо, коли Saga краща за синхронні транзакції.
Saga — це патерн координації розподіленої бізнес-транзакції, яка складається з кількох локальних транзакцій у різних сервісах.
Замість однієї глобальної транзакції система виконує послідовність кроків:
кожен сервіс змінює власний стан у локальній транзакції;
після успіху кроку запускається наступний;
якщо один із наступних кроків не вдається, виконуються компенсуючі дії для вже завершених кроків.
Наприклад, оформлення замовлення може включати:
резервування товару в Inventory;
списання або авторизацію платежу в Payment;
створення доставки в Shipping;
підтвердження замовлення в Order.
Ці операції можуть виконуватися в різних сервісах і базах даних, тому звичайну ACID-транзакцію між ними застосувати складно або небажано.
Нехай бізнес-операція складається з локальних транзакцій:
[ T_1 \rightarrow T_2 \rightarrow T_3 ]
Для кожної операції визначається компенсація:
[ C_1, C_2, C_3 ]
Якщо T3 не вдалася, Saga може виконати:
[ C_2 \rightarrow C_1 ]
Компенсація не є буквальною операцією «назад». Вона виконує нову бізнес-операцію, яка повертає систему до прийнятного стану.
Наприклад:
reserveProduct → releaseProduct;
authorizePayment → cancelAuthorization;
createShipment → cancelShipment.
Компенсація може бути неможливою або мати власні обмеження. Наприклад, уже відправлений товар не можна просто «відкотити» одним SQL-запитом. У такому випадку потрібен окремий процес: повернення, відшкодування або ручне втручання.
Saga забезпечує:
локальну атомарність кожного кроку;
узгодженість у кінцевому результаті;
можливість роботи без розподіленої блокувальної транзакції;
контрольоване відновлення після помилок.
Saga не забезпечує:
глобальну атомарність усієї послідовності;
миттєву узгодженість між усіма сервісами;
автоматичне скасування побічних ефектів;
відсутність проміжних станів.
Під час виконання Saga система може тимчасово перебувати у стані, коли:
товар уже зарезервовано;
платіж ще не авторизовано;
замовлення ще не підтверджено.
Тому кожен сервіс і користувацький інтерфейс повинні вміти працювати з проміжними статусами: PENDING, PROCESSING, COMPLETED, FAILED, COMPENSATING.
В оркестрованій Saga є центральний компонент — оркестратор. Він знає:
порядок виконання кроків;
правила переходу між станами;
операції компенсації;
політику повторних спроб;
фінальний результат процесу.
Схема:
Order Saga Orchestrator
|
+--> Inventory: reserve
|
+--> Payment: authorize
|
+--> Shipping: createПереваги:
бізнес-процес видно в одному місці;
простіше керувати порядком кроків;
зручніше реалізувати компенсації та повторні спроби;
простіше відстежувати стан Saga.
Недоліки:
оркестратор стає важливим компонентом;
надмірна логіка в оркестраторі може перетворити його на монолітний координатор;
усі команди проходять через центральну точку.
У хореографованій Saga немає центрального оркестратора. Сервіси реагують на події та публікують власні події.
Наприклад:
OrderCreated
-> InventoryReserved
-> PaymentAuthorized
-> ShipmentCreatedПереваги:
слабше зв’язування сервісів;
немає центрального координатора;
природно працює з брокерами повідомлень.
Недоліки:
загальний процес важче побачити;
складніше зрозуміти, хто відповідає за компенсацію;
зростає ризик циклічних залежностей;
відлагодження та трасування стають складнішими.
Практичне правило:
для короткого процесу з кількома незалежними реакціями може підійти хореографія;
для довгого бізнес-процесу з чітким порядком і складними компенсаціями зазвичай простіша оркестрація.
Нижче наведено спрощений, але runnable-приклад на JavaScript. Сервіси представлені об’єктами, а замість мережевих викликів використовуються асинхронні функції.
class InventoryService {
constructor() {
this.reservations = new Map();
}
async reserve(orderId, productId, quantity) {
if (this.reservations.has(orderId)) {
return this.reservations.get(orderId);
}
const reservation = {
orderId,
productId,
quantity,
status: "RESERVED"
};
this.reservations.set(orderId, reservation);
console.log(`[Inventory] Товар зарезервовано для ${orderId}`);
return reservation;
}
async release(orderId) {
const reservation = this.reservations.get(orderId);
if (!reservation || reservation.status === "RELEASED") {
return;
}
reservation.status = "RELEASED";
console.log(`[Inventory] Резерв скасовано для ${orderId}`);
}
}
class PaymentService {
constructor() {
this.payments = new Map();
}
async authorize(orderId, amount) {
const existingPayment = this.payments.get(orderId);
// Повторний виклик повертає попередній результат.
if (existingPayment) {
return existingPayment;
}
if (amount > 1000) {
throw new Error("Платіж відхилено: перевищено ліміт");
}
const payment = {
orderId,
amount,
status: "AUTHORIZED"
};
this.payments.set(orderId, payment);
console.log(`[Payment] Платіж авторизовано для ${orderId}`);
return payment;
}
async cancelAuthorization(orderId) {
const payment = this.payments.get(orderId);
if (!payment || payment.status === "CANCELLED") {
return;
}
payment.status = "CANCELLED";
console.log(`[Payment] Авторизацію скасовано для ${orderId}`);
}
}
class ShippingService {
constructor() {
this.shipments = new Map();
}
async create(orderId, address) {
if (address === "invalid") {
throw new Error("Адресу доставки не перевірено");
}
const existingShipment = this.shipments.get(orderId);
// Ідемпотентність не створює другу доставку при повторі.
if (existingShipment) {
return existingShipment;
}
const shipment = {
orderId,
address,
status: "CREATED"
};
this.shipments.set(orderId, shipment);
console.log(`[Shipping] Доставку створено для ${orderId}`);
return shipment;
}
async cancel(orderId) {
const shipment = this.shipments.get(orderId);
if (!shipment || shipment.status === "CANCELLED") {
return;
}
shipment.status = "CANCELLED";
console.log(`[Shipping] Доставку скасовано для ${orderId}`);
}
}
class OrderSaga {
constructor({ inventory, payment, shipping }) {
this.inventory = inventory;
this.payment = payment;
this.shipping = shipping;
}
async execute(order) {
const completedSteps = [];
try {
await this.inventory.reserve(
order.id,
order.productId,
order.quantity
);
completedSteps.push("inventory");
await this.payment.authorize(order.id, order.amount);
completedSteps.push("payment");
await this.shipping.create(order.id, order.address);
completedSteps.push("shipping");
console.log(`[Saga] Замовлення ${order.id} підтверджено`);
return { status: "COMPLETED" };
} catch (error) {
console.error(`[Saga] Помилка для ${order.id}: ${error.message}`);
await this.compensate(order, completedSteps);
return {
status: "COMPENSATED",
reason: error.message
};
}
}
async compensate(order, completedSteps) {
console.log(`[Saga] Початок компенсації для ${order.id}`);
// Компенсуємо у зворотному порядку.
if (completedSteps.includes("shipping")) {
await this.shipping.cancel(order.id);
}
if (completedSteps.includes("payment")) {
await this.payment.cancelAuthorization(order.id);
}
if (completedSteps.includes("inventory")) {
await this.inventory.release(order.id);
}
console.log(`[Saga] Компенсацію завершено для ${order.id}`);
}
}
async function main() {
const saga = new OrderSaga({
inventory: new InventoryService(),
payment: new PaymentService(),
shipping: new ShippingService()
});
const successfulOrder = {
id: "order-100",
productId: "laptop",
quantity: 1,
amount: 900,
address: "Kyiv"
};
const failedOrder = {
id: "order-101",
productId: "phone",
quantity: 1,
amount: 1200,
address: "Lviv"
};
console.log("=== Успішна Saga ===");
console.log(await saga.execute(successfulOrder));
console.log("\n=== Saga з компенсацією ===");
console.log(await saga.execute(failedOrder));
}
main().catch((error) => {
console.error("Непередбачена помилка:", error);
process.exitCode = 1;
});У цьому прикладі:
резервування товару завершується успішно;
платіж для другого замовлення відхиляється;
оркестратор запускає компенсацію;
резерв товару скасовується;
замовлення переходить у стан COMPENSATED.
У реальній системі виклики сервісів відбувалися б через HTTP або брокер повідомлень, а стан Saga зберігався б у базі даних.
Оркестратор не повинен покладатися лише на стан у пам’яті. Якщо процес триває довго або між кроками відбувається збій, після перезапуску потрібно знати:
ідентифікатор Saga;
ідентифікатор бізнес-операції;
поточний крок;
завершені кроки;
кількість спроб;
причину останньої помилки;
статус компенсації.
Приклад станів:
PENDING
-> INVENTORY_RESERVED
-> PAYMENT_AUTHORIZED
-> SHIPPING_CREATED
-> COMPLETEDАльтернативна гілка:
PAYMENT_FAILED
-> COMPENSATING
-> COMPENSATEDСтан Saga має змінюватися атомарно разом із фіксацією результату локальної операції. Інакше можливий небезпечний випадок:
сервіс успішно зарезервував товар;
оркестратор не встиг зберегти інформацію про це;
оркестратор перезапустився;
система вже не знає, що резерв потрібно компенсувати.
Ця проблема називається розривом між виконанням дії та фіксацією її стану. Її вирішують через надійне збереження стану та ідемпотентні операції. Для подій часто додатково застосовують патерн Outbox, але його реалізація є окремим аспектом надійної доставки повідомлень.
Мережевий збій не означає, що операція не виконалася.
Наприклад:
Payment Service успішно авторизував платіж;
відповідь загубилася під час передачі;
оркестратор вважає виклик невдалим;
оркестратор повторює запит.
Якщо операція не ідемпотентна, платіж може бути авторизований двічі.
Для цього кожен крок повинен мати ідемпотентний ключ, наприклад:
sagaId = saga-500
step = authorize-payment
idempotencyKey = saga-500:authorize-paymentСервіс зберігає результат операції за цим ключем і під час повторного запиту повертає той самий результат.
Ідемпотентними мають бути:
основні кроки Saga;
компенсації;
обробники подій;
команди, які можуть бути доставлені повторно.
Компенсація також може завершитися помилкою. Тому її зазвичай:
повторюють із backoff;
записують у журнал;
переводять Saga у стан COMPENSATION_FAILED;
передають на ручну обробку, якщо автоматичне відновлення неможливе.
Компенсації зазвичай виконуються у зворотному порядку:
T1 -> T2 -> T3
C3 -> C2 -> C1Це зменшує кількість залежностей між уже виконаними діями. Наприклад, перед скасуванням резерву товару доцільно скасувати доставку та платіж, які використовують цей резерв.
Однак зворотний порядок не є універсальним законом. Бізнес-правила можуть вимагати іншої послідовності. Важливо явно описати залежності та перевірити, що компенсація одного кроку не руйнує передумови іншого.
Saga не замінює транзакції всередині сервісу.
Кожен сервіс усе одно повинен атомарно виконувати власну зміну. Наприклад, Payment Service має в одній локальній транзакції:
створити запис про авторизацію;
змінити статус платежу;
зафіксувати ідемпотентний ключ.
Saga відповідає за бізнес-послідовність між сервісами, а локальна транзакція — за цілісність даних усередині одного сервісу.
Saga доречна, коли:
операція охоплює кілька сервісів і баз даних;
розподілена блокувальна транзакція занадто дорога або недоступна;
процес може тривати довго;
окремі кроки залежать від зовнішніх систем;
тимчасова неузгодженість прийнятна;
для кожної дії можна визначити компенсацію;
система має продовжувати роботу навіть за часткової недоступності сервісів.
Наприклад, обробка замовлення може тривати хвилини або години, якщо потрібне підтвердження постачальника. Утримувати глобальне блокування баз даних протягом усього цього часу недоцільно.
Краще використати звичайну локальну транзакцію, якщо всі дані належать одній базі та операцію можна виконати атомарно в її межах.
Saga також небажана, якщо:
бізнес не допускає жодного проміжного стану;
компенсацію неможливо надійно виконати;
операція має незворотні зовнішні наслідки;
помилка після часткового виконання неприйнятна;
процес настільки простий, що розподілена координація лише ускладнить систему.
Якщо потрібна строга синхронна узгодженість між кількома ресурсами, слід спочатку перевірити, чи можна змінити межі сервісів і зберігати пов’язані дані разом. Saga не повинна використовуватися лише тому, що система вже розділена на мікросервіси.
Для діагностики кожна команда та подія повинна містити:
sagaId;
orderId або інший бізнес-ідентифікатор;
назву кроку;
номер спроби;
час початку й завершення;
результат;
причину помилки.
Корисно розділяти:
помилки, які можна повторити: timeout, тимчасова недоступність;
остаточні помилки: недостатній баланс, невалідна адреса;
помилки компенсації.
Система повинна мати метрики:
кількість успішних Saga;
кількість компенсованих Saga;
кількість невдалих компенсацій;
тривалість процесу;
кількість повторних спроб;
кількість Saga, що зависли в проміжному стані.
Saga не робить усі операції атомарними. Між кроками існують проміжні стани, а компенсація може бути відкладеною або невдалою.
Перед додаванням кроку потрібно відповісти:
що станеться, якщо наступний крок не виконається;
як скасувати поточну дію;
чи є скасування повним або частковим;
що робити, якщо компенсація недоступна.
Повторний HTTP-запит або повідомлення від брокера — нормальна ситуація. Без ідемпотентності можна двічі списати кошти, створити дві доставки або повторно видати бонус.
Потрібно компенсувати всі успішно завершені кроки, а не лише безпосередню причину помилки.
Після перезапуску сервіс може втратити інформацію про вже виконані дії. Стан Saga має бути довговічним і відновлюваним.
Компенсація може виконатися лише частково. Такий результат повинен мати окремий статус і зрозумілий операційний процес.
Якщо кілька змін можна безпечно виконати в одній локальній транзакції, Saga додасть зайву складність, затримки та нові класи помилок.
Saga координує розподілену бізнес-операцію через локальні транзакції.
Для кожного основного кроку потрібно визначити компенсацію.
Saga забезпечує кінцеву узгодженість, але не глобальну атомарність.
Оркестрація централізує порядок і стан процесу, а хореографія передає координацію між подіями сервісів.
Усі кроки та компенсації мають бути ідемпотентними.
Стан Saga потрібно зберігати надійно, підтримувати повторні спроби та відстежувати помилки компенсації.
Saga краща за синхронну транзакцію для довгих розподілених процесів, де прийнятні проміжні стани.
Для короткої операції в межах однієї бази звичайна локальна транзакція зазвичай простіша й надійніша.