Транзакция през няколко услуги (saga)
Накратко: Saga е транзакция през няколко услуги като списък от стъпки, всяка с обратна стъпка; при отказ обратните стъпки вървят назад, от последната направена.
Проблем
Парите са взети, слушалките ги няма
Купувач поръчва последните слушалки за 59 € (поръчка 1042). Плащането минава. Секунда по-късно складът отговаря: „няма на склад“. Друг купувач е взел последната бройка точно преди това.
var payments = new Payments();var warehouse = new Warehouse(Map.of("слушалки", 0));var delivery = new Delivery();var order = new Order(1042, "слушалки", 59, "Варна");try { payments.charge(order); // плащането минава warehouse.reserve(order); // складът: няма стока delivery.book(order);} catch (StepFailed e) { System.out.println(" ✗ " + e.getMessage());}System.out.println("взети от картата: " + 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 extends RuntimeException { StepFailed(String reason) { super(reason); }}
record Step(Consumer<Order> action, Consumer<Order> undo) {}class Warehouse { private final Map<String, Integer> stock;
Warehouse(Map<String, Integer> stock) { this.stock = new HashMap<>(stock); }
void reserve(Order o) { int left = stock.getOrDefault(o.item(), 0); if (left == 0) throw new StepFailed("няма " + o.item() + " на склад"); stock.put(o.item(), left - 1); System.out.println(" ✓ запазено: " + o.item()); }
void release(Order o) { stock.merge(o.item(), 1, Integer::sum); System.out.println(" ↩ освободено: " + o.item()); }}Step е запис с две функции: Consumer<Order> прави стъпката, другата я връща. Плащанията и доставката са като склада: метод за стъпката и метод за обратната.
Оркестраторът:
class Saga { private final List<Step> steps;
Saga(List<Step> steps) { this.steps = steps; }
String run(Order order) { Deque<Consumer<Order>> undo = new ArrayDeque<>(); try { for (Step step : steps) { step.action().accept(order); undo.push(step.undo()); } return "потвърдена"; } catch (StepFailed e) { System.out.println(" ✗ " + e.getMessage()); while (!undo.isEmpty()) undo.pop().accept(order); return "отказана"; } }}Стъпка по стъпка, с думи
- Поръчка 1043: зарядно за 25 € до Видин. Всяка стъпка е в друга услуга и всяка има обратна: взимане ↔ връщане, запазване ↔ освобождаване, куриер ↔ отмяна.
- Стъпка 1: Payments взима 25 €. Обратната ѝ стъпка отива в стека undo (push).
- Стъпка 2: Warehouse запазва зарядното. „Освободи“ ляга върху „върни“.
- Стъпка 3: няма куриер до Видин и Delivery отказва. Първите две стъпки вече са станали в други системи, а rollback през тях няма.
- Saga хваща отказа и тръгва назад: вади компенсациите от стека, отгоре надолу.
- pop дава последната сложена: първо се освобождава зарядното. Последното направено се връща първо.
- После плащането: 25 € се връщат на картата. Връщането е ново действие, не изтриване: клиентът вижда и двете в банката си.
- Отказана: зарядното е пак на склад, взети са 0 €. Системите пак са съгласни, с по-късни действия вместо една транзакция.
Три поръчки: 1041 минава, 1042 спира в склада, 1043 спира при доставката.
var payments = new Payments();var warehouse = new Warehouse(Map.of("слушалки", 1, "зарядно", 5));var delivery = new Delivery();var saga = new Saga(List.of( new Step(payments::charge, payments::refund), new Step(warehouse::reserve, warehouse::release), new Step(delivery::book, delivery::cancel)));var orders = List.of( new Order(1041, "слушалки", 59, "София"), new Order(1042, "слушалки", 59, "Варна"), new Order(1043, "зарядно", 25, "Видин"));for (Order order : orders) { System.out.println("поръчка " + order.id()); System.out.println(" = " + saga.run(order));}System.out.println("взети от картите: " + payments.charged + " €");поръчка 1041 ✓ взети 59 € ✓ запазено: слушалки ✓ куриер до София = потвърденапоръчка 1042 ✓ взети 59 € ✗ няма слушалки на склад ↩ върнати 59 € = отказанапоръчка 1043 ✓ взети 25 € ✓ запазено: зарядно ✗ няма куриер до Видин ↩ освободено: зарядно ↩ върнати 25 € = отказанавзети от картите: 59 €Накрая от картите са взети 59 €: само за поръчката, която мина.
Пробвай
Предвиди изхода
Същата saga, но складът е първата стъпка, а плащането втората. Слушалките са свършили. Какво ще отпечата?
var payments = new Payments();var warehouse = new Warehouse(Map.of("слушалки", 0));var delivery = new Delivery();var saga = new Saga(List.of( new Step(warehouse::reserve, warehouse::release), new Step(payments::charge, payments::refund), new Step(delivery::book, delivery::cancel)));System.out.println(" = " + saga.run(new Order(1042, "слушалки", 59, "Варна")));Складът отказва още на първата стъпка, затова в стека няма нищо: няма пари за връщане. Редът на стъпките е архитектурно решение. Първо идват стъпките, които най-често отказват и които е най-евтино да се върнат. Плащането, което струва такса и дразни купувача, идва след тях.
Идеята
Кое не се връща
Не всяка стъпка има обратна. Имейлът „поръчката ти е потвърдена“ не може да се изпрати обратно. Пратката, която куриерът вече е взел, не се връща с едно извикване.
Затова такива стъпки отиват последни, след всичко, което може да откаже. Стъпката, след която saga вече само върви напред, се казва pivot (точката на обръщане). След нея отказите не се компенсират, а се повтарят, докато минат: с timeout и retry.
Пробвай
Намери грешката
Колега пренарежда цикъла и тестовете минават: при отказ парите се връщат. Седмица по-късно счетоводството намира връщания за плащания, които никога не са минали: картата е била отказана, а купувачът е получил 59 €. На кой ред е грешката?
Избери реда с грешката.
Обратната стъпка влиза в стека преди самата стъпка. Ако плащането откаже (картата е отказана), връщането му вече е в стека и се вика: купувачът получава пари, които не е плащал. Компенсацията се записва само след като стъпката е минала.
Идеята
Оркестрация или хореография
Оркестраторът е едно място, което знае целия процес. Другият начин е без такова място: всяка услуга слуша събития и отговаря със свое. Плащанията чуват „поръчка“ и казват „платено“. Складът чува „платено“ и казва „запазено“ или „няма стока“. Плащанията чуват „няма стока“ и връщат парите. Това се казва хореография.
Шината е опашка в паметта с абонаменти, както в урока за събитията:
record Event(String type, int order) {}
class Bus { private final Map<String, List<Consumer<Event>>> handlers = new HashMap<>(); private final Deque<Event> queue = new ArrayDeque<>();
void on(String type, Consumer<Event> handler) { handlers.computeIfAbsent(type, t -> new ArrayList<>()).add(handler); }
void publish(Event event) { queue.add(event); }
void run() { while (!queue.isEmpty()) { Event e = queue.poll(); System.out.println(e.type() + " " + e.order()); for (var handler : handlers.getOrDefault(e.type(), List.of())) handler.accept(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 1042PaymentCharged 1042StockMissing 1042PaymentRefunded 1042OrderCancelled 1042| Оркестрация | Хореография | |
|---|---|---|
| Къде е процесът | в един обект | разпилян по абонаментите |
| Нова стъпка | ред в списъка | нов абонамент и ново събитие |
| „Защо 1042 спря?“ | състоянието на оркестратора | събираш събитията от всички услуги |
| Обвързване | оркестраторът познава всички | всяка услуга знае само събитията |
За две-три стъпки хореографията е по-лека. Когато стъпките станат пет и имат условия, процесът се губи и оркестраторът е по-ясният избор.
Идеята
Компенсацията идва два пъти
Между услугите командите и събитията минават през опашка, а опашката доставя поне веднъж (видя го в урока за събитията). Ако „върни 59 € за 1042“ дойде два пъти, купувачът получава 118 €.
Затова всяка стъпка и всяка компенсация е идемпотентна: втори път със същия вход не прави нищо. Ключът е действието и поръчката. Към истинския доставчик той се праща като ключ за идемпотентност, за да откаже повторното връщане и той.
class Refunds { private final Set<String> done = new HashSet<>(); int total = 0;
void refund(int order, int euros) { // ключът на стъпката: действие + поръчка if (!done.add("refund:" + order)) { System.out.println(" · " + order + ": вече е върнато"); return; } total += euros; System.out.println(" ↩ върнати " + euros + " € за " + order); }}var refunds = new Refunds();// опашката доставя „поне веднъж“: 1042 идва два пътиrefunds.refund(1042, 59);refunds.refund(1042, 59);refunds.refund(1043, 25);System.out.println("върнати общо: " + refunds.total + " €"); ↩ върнати 59 € за 1042 · 1042: вече е върнато ↩ върнати 25 € за 1043върнати общо: 84 €Пробвай
Попълни
Какво проверява дали връщането вече е направено?
if (!done.("refund:" + order)) {
System.out.println(" · " + order + ": вече е върнато");
return;
}Set.add връща false, ако ключът вече е в множеството. Проверката и записът са едно извикване. В истинска услуга ключовете са в базата, в същата транзакция като връщането, иначе срив между двете пак дава дубликат.
Идеята
Кога 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и пусни поръчка, за която стоката има.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Transactional outbox · Дизайн на API: договор, версии, повторени заявки · Компромиси · Когато съседът е бавен: timeout, retry, circuit breaker
- Продължава в: Интеграция със стари и чужди системи · Смяна на жива система (strangler fig, expand–contract) · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа