Към съдържанието
wannacode

Транзакция през няколко услуги (saga)

Накратко: Saga е транзакция през няколко услуги като списък от стъпки, всяка с обратна стъпка; при отказ обратните стъпки вървят назад, от последната направена.

Проблем

Парите са взети, слушалките ги няма

Купувач поръчва последните слушалки за 59 € (поръчка 1042). Плащането минава. Секунда по-късно складът отговаря: „няма на склад“. Друг купувач е взел последната бройка точно преди това.

var payments = new Payments();
var warehouse = new Warehouse(new() { ["слушалки"] = 0 });
var delivery = new Delivery();
var order = new Order(1042, "слушалки", 59, "Варна");
try
{
payments.Charge(order); // плащането минава
warehouse.Reserve(order); // складът: няма стока
delivery.Book(order);
}
catch (StepFailed e)
{
Console.WriteLine($" ✗ {e.Message}");
}
Console.WriteLine($"взети от картата: {payments.Charged} €");
Изход
✓ взети 59 €
✗ няма слушалки на склад
взети от картата: 59 €

Програмата хваща грешката, но парите остават взети. Утре купувачът пише в поддръжката, а някой връща 59 € на ръка.

В монолит с една база отговорът е транзакция: или всичко, или нищо (видя го в урока за базите данни и в „ACID: всичко или нищо“). Тук обаче плащането е при външен доставчик, а складът е отделна услуга със своя база. Транзакция, която да обхване и трите, няма.

Идеята

Saga: стъпки с обратни стъпки

Щом не можеш да върнеш назад, правиш нови действия, които оправят последствията. Всяка стъпка на поръчката идва с обратна стъпка (компенсация):

Стъпка Услуга Обратна стъпка
взима 59 € плащания връща 59 €
запазва слушалките склад освобождава ги
поръчва куриер доставка отменя куриера

Всяка стъпка е малка локална транзакция в своята услуга. Ако стъпка откаже, обратните стъпки на вече направените вървят назад: последното направено се връща първо. Това се казва saga.

Обратната стъпка не е rollback. Rollback-ът изтрива, сякаш нищо не е станало. Връщането на 59 € е второ плащане в обратна посока, което купувачът вижда в банката си до първото. Затова между стъпките системата е в междинно състояние („поръчката е в процес“) и всеки трябва да го понася.

Тук saga се води от един обект, оркестратор: той знае стъпките, вика ги поред и при отказ вика компенсациите. Стъпките са в списък, а компенсациите в стек: последната сложена излиза първа.

Виж го

Saga в код

Отказът на услуга е изключение. В тази малка система услугите са обекти в един процес, а отказите идват по сценарий: слушалките са една бройка, а до Видин няма куриер.

record Order(int Id, string Item, int Price, string City);
class StepFailed(string reason) : Exception(reason);
record Step(Action<Order> Do, Action<Order> Undo);
class Warehouse(Dictionary<string, int> stock)
{
public void Reserve(Order o)
{
int left = stock.GetValueOrDefault(o.Item);
if (left == 0) throw new StepFailed($"няма {o.Item} на склад");
stock[o.Item] = left - 1;
Console.WriteLine($" ✓ запазено: {o.Item}");
}
public void Release(Order o)
{
stock[o.Item] = stock.GetValueOrDefault(o.Item) + 1;
Console.WriteLine($" ↩ освободено: {o.Item}");
}
}

Step е запис с два делегата Action<Order>: единият прави стъпката, другият я връща. Плащанията и доставката са като склада: метод за стъпката и метод за обратната.

Оркестраторът:

class Saga(List<Step> steps)
{
public string Run(Order order)
{
var undo = new Stack<Action<Order>>();
try
{
foreach (var step in steps)
{
step.Do(order);
undo.Push(step.Undo);
}
return "потвърдена";
}
catch (StepFailed e)
{
Console.WriteLine($" ✗ {e.Message}");
while (undo.Count > 0)
undo.Pop()(order);
return "отказана";
}
}
}
Стъпка по стъпка, с думи
  1. Поръчка 1043: зарядно за 25 € до Видин. Всяка стъпка е в друга услуга и всяка има обратна: взимане ↔ връщане, запазване ↔ освобождаване, куриер ↔ отмяна.
  2. Стъпка 1: Payments взима 25 €. Обратната ѝ стъпка отива в стека undo (Push).
  3. Стъпка 2: Warehouse запазва зарядното. „Освободи“ ляга върху „върни“.
  4. Стъпка 3: няма куриер до Видин и Delivery отказва. Първите две стъпки вече са станали в други системи, а rollback през тях няма.
  5. Saga хваща отказа и тръгва назад: вади компенсациите от стека, отгоре надолу.
  6. Pop дава последната сложена: първо се освобождава зарядното. Последното направено се връща първо.
  7. После плащането: 25 € се връщат на картата. Връщането е ново действие, не изтриване: клиентът вижда и двете в банката си.
  8. Отказана: зарядното е пак на склад, взети са 0 €. Системите пак са съгласни, с по-късни действия вместо една транзакция.

Три поръчки: 1041 минава, 1042 спира в склада, 1043 спира при доставката.

var payments = new Payments();
var warehouse = new Warehouse(new() { ["слушалки"] = 1, ["зарядно"] = 5 });
var delivery = new Delivery();
var saga = new Saga([
new Step(payments.Charge, payments.Refund),
new Step(warehouse.Reserve, warehouse.Release),
new Step(delivery.Book, delivery.Cancel),
]);
Order[] orders = [
new Order(1041, "слушалки", 59, "София"),
new Order(1042, "слушалки", 59, "Варна"),
new Order(1043, "зарядно", 25, "Видин"),
];
foreach (var order in orders)
{
Console.WriteLine($"поръчка {order.Id}");
Console.WriteLine($" = {saga.Run(order)}");
}
Console.WriteLine($"взети от картите: {payments.Charged} €");
Изход
поръчка 1041
✓ взети 59 €
✓ запазено: слушалки
✓ куриер до София
= потвърдена
поръчка 1042
✓ взети 59 €
✗ няма слушалки на склад
↩ върнати 59 €
= отказана
поръчка 1043
✓ взети 25 €
✓ запазено: зарядно
✗ няма куриер до Видин
↩ освободено: зарядно
↩ върнати 25 €
= отказана
взети от картите: 59 €

Накрая от картите са взети 59 €: само за поръчката, която мина.

Пробвай

Предвиди изхода

Същата saga, но складът е първата стъпка, а плащането втората. Слушалките са свършили. Какво ще отпечата?

var payments = new Payments();
var warehouse = new Warehouse(new() { ["слушалки"] = 0 });
var delivery = new Delivery();
var saga = new Saga([
new Step(warehouse.Reserve, warehouse.Release),
new Step(payments.Charge, payments.Refund),
new Step(delivery.Book, delivery.Cancel),
]);
Console.WriteLine($" = {saga.Run(new Order(1042, "слушалки", 59, "Варна"))}");
Отговор

Идеята

Кое не се връща

Не всяка стъпка има обратна. Имейлът „поръчката ти е потвърдена“ не може да се изпрати обратно. Пратката, която куриерът вече е взел, не се връща с едно извикване.

Затова такива стъпки отиват последни, след всичко, което може да откаже. Стъпката, след която saga вече само върви напред, се казва pivot (точката на обръщане). След нея отказите не се компенсират, а се повтарят, докато минат: с timeout и retry.

Пробвай

Намери грешката

Колега пренарежда цикъла и тестовете минават: при отказ парите се връщат. Седмица по-късно счетоводството намира връщания за плащания, които никога не са минали: картата е била отказана, а купувачът е получил 59 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

Оркестрация или хореография

Оркестраторът е едно място, което знае целия процес. Другият начин е без такова място: всяка услуга слуша събития и отговаря със свое. Плащанията чуват „поръчка“ и казват „платено“. Складът чува „платено“ и казва „запазено“ или „няма стока“. Плащанията чуват „няма стока“ и връщат парите. Това се казва хореография.

Шината е опашка в паметта с абонаменти, както в урока за събитията:

record Event(string Type, int Order);
class Bus
{
readonly Dictionary<string, List<Action<Event>>> handlers = [];
readonly Queue<Event> queue = new();
public void On(string type, Action<Event> handler)
{
if (!handlers.TryGetValue(type, out var list))
handlers[type] = list = [];
list.Add(handler);
}
public void Publish(Event e) => queue.Enqueue(e);
public void Run()
{
while (queue.TryDequeue(out var e))
{
Console.WriteLine($"{e.Type} {e.Order}");
foreach (var handler in handlers.GetValueOrDefault(e.Type, []))
handler(e);
}
}
}

Същият отказ на 1042, като верига от събития:

var bus = new Bus();
// плащанията
bus.On("OrderPlaced", e => bus.Publish(new Event("PaymentCharged", e.Order)));
bus.On("StockMissing", e => bus.Publish(new Event("PaymentRefunded", e.Order)));
// складът (в този пример стоката за 1042 е свършила)
bus.On("PaymentCharged", e => bus.Publish(new Event("StockMissing", e.Order)));
// поръчките
bus.On("PaymentRefunded", e => bus.Publish(new Event("OrderCancelled", e.Order)));
bus.Publish(new Event("OrderPlaced", 1042));
bus.Run();
Изход
OrderPlaced 1042
PaymentCharged 1042
StockMissing 1042
PaymentRefunded 1042
OrderCancelled 1042
Оркестрация Хореография
Къде е процесът в един обект разпилян по абонаментите
Нова стъпка ред в списъка нов абонамент и ново събитие
„Защо 1042 спря?“ състоянието на оркестратора събираш събитията от всички услуги
Обвързване оркестраторът познава всички всяка услуга знае само събитията

За две-три стъпки хореографията е по-лека. Когато стъпките станат пет и имат условия, процесът се губи и оркестраторът е по-ясният избор.

Идеята

Компенсацията идва два пъти

Между услугите командите и събитията минават през опашка, а опашката доставя поне веднъж (видя го в урока за събитията). Ако „върни 59 € за 1042“ дойде два пъти, купувачът получава 118 €.

Затова всяка стъпка и всяка компенсация е идемпотентна: втори път със същия вход не прави нищо. Ключът е действието и поръчката. Към истинския доставчик той се праща като ключ за идемпотентност, за да откаже повторното връщане и той.

class Refunds
{
readonly HashSet<string> done = [];
public int Total { get; private set; }
public void Refund(int order, int euros)
{
// ключът на стъпката: действие + поръчка
if (!done.Add($"refund:{order}"))
{
Console.WriteLine($" · {order}: вече е върнато");
return;
}
Total += euros;
Console.WriteLine($" ↩ върнати {euros} € за {order}");
}
}
var refunds = new Refunds();
// опашката доставя „поне веднъж“: 1042 идва два пъти
refunds.Refund(1042, 59);
refunds.Refund(1042, 59);
refunds.Refund(1043, 25);
Console.WriteLine($"върнати общо: {refunds.Total} €");
Изход
↩ върнати 59 € за 1042
· 1042: вече е върнато
↩ върнати 25 € за 1043
върнати общо: 84 €

Пробвай

Попълни

Какво проверява дали връщането вече е направено?

if (!done.($"refund:{order}"))
{
    Console.WriteLine($"  · {order}: вече е върнато");
    return;
}

Идеята

Кога saga вреди

Saga не е безплатна:

  • Кодът се удвоява. Всяка стъпка иска обратна, тестове за двете и тестове за отказ на всяко място.
  • Междинните състояния се виждат. Купувачът вижда „в процес“, а парите са взети за миг, преди да се върнат. Някой друг може да прочете поръчка, която после се отказва: saga няма изолация като транзакцията.
  • Отказът се разследва по-трудно. Стъпките са в различни услуги, с различни логове.

Затова не я ползвай, където стига транзакция. Ако складът и поръчките са модули на един монолит с една база, транзакцията ги пази по-просто и по-сигурно. Saga е за стъпките, които излизат навън: външен доставчик, друга услуга, друга фирма.

По-точно, за любопитните

Защо не две фази (2PC)? Има протокол за обща транзакция през няколко бази: координаторът пита всички „готови ли сте?“ и после казва на всички „потвърди“. Външният доставчик на плащания не участва в такъв протокол. А докато координаторът чака, всички държат заключени редове; ако той падне по средата, те остават заключени. Затова между услуги почти винаги се ползва saga.

Оркестраторът също пада. Ако процесът спре след плащането, откъде знае докъде е стигнал? Истинският оркестратор записва състоянието на всяка saga в база (стъпка 2 от 3, поръчка 1042) и при рестарт продължава. Командите към услугите тръгват през outbox, за да не се загубят между записа и изпращането.

Семантично заключване. Поръчка „в процес“ е знак за другите: не я пращай за фактура, не позволявай промяна на адреса. Така се компенсира липсата на изолация.

Готови инструменти: Temporal, Camunda и AWS Step Functions пазят състоянието на дълги процеси и повтарят стъпките. Има и библиотеки за saga в рамките: Axon (Java), MassTransit и NServiceBus (.NET). Опашката между услугите в истински проект е Kafka или RabbitMQ. Как се пускат и наблюдават, е в курса по DevOps ZeroToOps.

Името идва от статия на Hector Garcia-Molina и Kenneth Salem от 1987 г. за дълги транзакции в бази данни.

Упражнение

Точки за лоялност

Реши задачата в редактора си.

Магазинът дава по 1 точка за всеки пълни 10 €. Добави услуга Loyalty с метод за стъпката (дава точките) и метод за обратната (взима ги). Сложи я в saga като трета стъпка, между склада и доставката. Пусни поръчка 1044: колонка за 120 € до Видин, а на склад има 2 колонки. Накрая отпечатай точките и взетите пари.

Изход
поръчка 1044
✓ взети 120 €
✓ запазено: колонка
✓ +12 точки
✗ няма куриер до Видин
↩ -12 точки
↩ освободено: колонка
↩ върнати 120 €
= отказана
точки: 0, взети: 0 €

Още задачи

  • Отказана карта. Направи плащането да отказва над 100 € („картата е отказана“) и пусни 1044 до София. Какво има в стека, когато плащането откаже?
  • Хореография. Добави в шината събитията StockReserved и OrderConfirmed и пусни поръчка, за която стоката има.

Тест

Провери се

1. Защо поръчката не може да се пази с една транзакция в базата, както в монолита?
2. Стъпките са плащане, склад, доставка. Доставката отказва. В какъв ред вървят компенсациите?
3. Защо компенсацията „върни 59 € за 1042“ трябва да е идемпотентна?
4. Кога хореографията (услугите си реагират на събитията) е по-добрият избор от оркестрацията?
5. Магазинът е монолит с една база: поръчки, склад и плащания в различни таблици. Плащането е при външен доставчик. Трябва ли ти saga за склада и поръчката?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·