Transactional outbox
Накратко: Transactional outbox записва събитието в същата база и в същата транзакция като промяната, а отделен изпращач го праща към брокера, затова не се губи нито едно събитие.
Проблем
Платено, а пакет не тръгва
От предишния урок магазинът пуска събитие, щом поръчката е записана. Записът е в базата, а събитието отива при брокера. Ето два начина да се напише това. В тях db е базата, queue е опашката на брокера, а crash.at е място, където процесът може да падне по записан сценарий:
// НЕ ТАКА: записът и събитието са в две отделни системиvoid SaveThenPublish(int id, int total){ db.Transaction(tx => tx.Orders[id] = total); crash.At("след записа"); queue.Add($"evt-{id}: OrderPlaced {id}");}void PublishThenSave(int id, int total){ queue.Add($"evt-{id}: OrderPlaced {id}"); db.Transaction(tx => { tx.Orders[id] = total; crash.At("преди commit"); });}Във всяка функция процесът пада веднъж, на своето място:
crash = new CrashScript("след записа");try{ SaveThenPublish(1001, 120);}catch (Crash e){ Console.WriteLine($"✗ процесът пада {e.Message}");}crash = new CrashScript("преди commit");try{ PublishThenSave(1002, 80);}catch (Crash e){ Console.WriteLine($"✗ процесът пада {e.Message}");}Console.WriteLine($"поръчки: [{string.Join(", ", db.Orders.Keys)}]");Console.WriteLine($"опашка: [{string.Join(", ", queue)}]");✗ процесът пада след записа✗ процесът пада преди commitпоръчки: [1001]опашка: [evt-1002: OrderPlaced 1002]Поръчка 1001 е в базата, но събитие за нея няма. Клиентът е платил, а складът не знае, че трябва да прати пакет. Събитието за 1002 е в опашката, но поръчка 1002 я няма: транзакцията се е върнала. Складът опакова поръчка, която не съществува.
Сривът не е рядкост. Процесът се рестартира при всеки deploy, машината губи ток, мрежата до брокера прекъсва за секунда. При хиляди поръчки на ден все някоя попада точно между двата реда.
Идеята
Две системи, без обща транзакция
Транзакцията обещава „всичко или нищо“, но само за записите в своята база. Брокерът е друга система. Commit на базата не знае за него, а съобщение, пратено към брокера, не може да се „върне“. Затова каквото и да е редът на двата реда, срив между тях оставя едната система без другата.
Има протокол за обща транзакция през две системи (two-phase commit), но повечето брокери не го поддържат, а където го има, е бавен и крехък.
Решението е да няма две системи в момента на записа. Събитието се записва като ред в таблица на същата база, в същата транзакция като поръчката. Таблицата се казва outbox („изходяща поща“). Commit записва и двата реда, или нито един. После отделен изпращач (relay) чете outbox, праща непратените редове към брокера и ги отмята. Това е шаблонът transactional outbox.
Виж го
Базата и сривът в паметта
Базата тук е обект с две таблици. Транзакцията трупа записите в Tx и ги слага в таблиците наведнъж, само ако работата стигне до края. Ако по средата хвърли, нищо не е записано: това е rollback.
class OutboxRow(string id, string ev){ public string Id = id; public string Event = ev; public bool Sent;}
// записите на една транзакция, преди commitclass Tx{ public SortedDictionary<int, int> Orders = new(); public List<OutboxRow> Outbox = [];}
class Db{ public SortedDictionary<int, int> Orders = new(); public List<OutboxRow> Outbox = [];
// всичко или нищо: записите влизат в таблиците, само ако работата стигне до края public void Transaction(Action<Tx> work) { var tx = new Tx(); work(tx); foreach (var (id, total) in tx.Orders) Orders[id] = total; // commit Outbox.AddRange(tx.Outbox); }}tx. Ако тя хвърли, редовете след извикването ѝ (commit) не се изпълняват.Сривът е изключение на точно определено място. Базата и опашката са отделни системи: оцеляват след срива, а програмата продължава като рестартиран процес.
class Crash(string where) : Exception(where);
// записан сценарий: къде пада процесът (без място: никъде)class CrashScript(string point = ""){ public void At(string here) { if (here == point) throw new Crash(here); }}var db = new Db();var queue = new List<string>(); // опашката на брокераvar crash = new CrashScript();Как изглежда същото с истинска база, видя в урока за базите данни.
Виж го
Поръчката и редът в outbox заедно
Записът на поръчката слага и ред в outbox, в същата транзакция. Към брокера не праща нищо:
void PlaceOrder(int id, int total){ db.Transaction(tx => { tx.Orders[id] = total; // в същата транзакция tx.Outbox.Add(new OutboxRow($"evt-{id}", $"OrderPlaced {id}")); crash.At("преди commit"); });}Изпращачът минава по outbox, праща всеки непратен ред и го отмята:
// изпращачът праща непратените редове от outboxvoid Relay(){ foreach (var row in db.Outbox) { if (row.Sent) continue; queue.Add($"{row.Id}: {row.Event}"); crash.At("в изпращача"); row.Sent = true; }}void Show(){ Console.WriteLine($"поръчки: [{string.Join(", ", db.Orders.Keys)}]"); Console.WriteLine($"outbox: [{string.Join(", ", db.Outbox.Select(r => r.Id + (r.Sent ? " ✓" : "")))}]"); Console.WriteLine($"опашка: [{string.Join(", ", queue)}]");}Поръчка 1003 минава. Поръчка 1004 пада преди commit. После изпращачът тръгва, както след рестарт:
PlaceOrder(1003, 120);Show();crash = new CrashScript("преди commit");try{ PlaceOrder(1004, 80);}catch (Crash e){ Console.WriteLine($"✗ процесът пада {e.Message}");}crash = new CrashScript();Relay(); // след рестартаShow();поръчки: [1003]outbox: [evt-1003]опашка: []✗ процесът пада преди commitпоръчки: [1003]outbox: [evt-1003 ✓]опашка: [evt-1003: OrderPlaced 1003]void PlaceOrder(int id, int total){ db.Transaction(tx => { tx.Orders[id] = total; // в същата транзакция tx.Outbox.Add(new OutboxRow($"evt-{id}", $"OrderPlaced {id}")); crash.At("преди commit"); });}// НЕ ТАКА: записът и събитието са в две отделни системиvoid SaveThenPublish(int id, int total){ db.Transaction(tx => tx.Orders[id] = total); crash.At("след записа"); queue.Add($"evt-{id}: OrderPlaced {id}");}void PublishThenSave(int id, int total){ queue.Add($"evt-{id}: OrderPlaced {id}"); db.Transaction(tx => { tx.Orders[id] = total; crash.At("преди commit"); });}void PlaceOrder(int id, int total){ db.Transaction(tx => { tx.Orders[id] = total; // в същата транзакция tx.Outbox.Add(new OutboxRow($"evt-{id}", $"OrderPlaced {id}")); crash.At("преди commit"); });}// изпращачът праща непратените редове от outboxvoid Relay(){ foreach (var row in db.Outbox) { if (row.Sent) continue; queue.Add($"{row.Id}: {row.Event}"); crash.At("в изпращача"); row.Sent = true; }}Стъпка по стъпка, с думи
- Сценарий 1: първо запис, после събитие. Поръчка 1001 влиза в базата с commit.
- Процесът пада точно между двата реда. Поръчката е в базата, а събитието не тръгва никога.
- Този ред не се изпълнява. Складът не научава за 1001: клиентът е платил, а пакет няма да тръгне.
- Сценарий 2: обратното. Събитието evt-1002 тръгва първо.
- После поръчката се записва в транзакция.
- Процесът пада преди commit и базата връща записа. Поръчка 1002 я няма.
- А събитието вече е в опашката: складът опакова поръчка, която не съществува.
- Сценарий 3, outbox: поръчка 1003 и събитието ѝ като ред в таблицата outbox, в една транзакция.
- Commit: двата реда влизат наведнъж. Или и двата, или нито един.
- Процесът пада, преди да прати нещо. Нищо не е изгубено: редът чака в outbox.
- След рестарта изпращачът чете outbox и намира непратения ред.
- Праща evt-1003 в опашката и складът го получава.
- Чак тогава отмята реда. Срив между двете праща реда пак, затова потребителят трябва да е идемпотентен.
- Записът и събитието вече не се разминават: и двете стигат, рано или късно.
След commit поръчката и редът в outbox са в базата, а опашката още е празна: процесът може да падне тук и нищо не се губи. Поръчка 1004 не оставя нищо: нито поръчка, нито ред, защото двете са в една транзакция. Изпращачът намира непратения ред и го праща.
В истинската система изпращачът е цикъл, който на всяка секунда пита базата: „дай ми непратените редове“. Затова събитието тръгва малко по-късно от commit, а не в същия миг.
Пробвай
Предвиди изхода
Изпращачът праща реда и пада, преди да го отметне. След рестарта тръгва пак. Какво има в опашката?
Последният ред на изхода е:
PlaceOrder(1003, 120);crash = new CrashScript("в изпращача");try{ Relay();}catch (Crash e){ Console.WriteLine($"✗ процесът пада {e.Message}");}crash = new CrashScript();Relay(); // след рестартаShow();Редът не е отметнат, затова изпращачът го праща пак. Outbox дава доставка „поне веднъж“: нищо не се губи, но понякога идва два пъти. Затова събитието носи id (evt-1003), а потребителят го помни, като склада от предишния урок.
Пробвай
Намери грешката
Колега решава, че дубликатите пречат, и разменя два реда в изпращача. Сривът е на същото място, между двата реда:
✗ процесът пада в изпращачапоръчки: [1003]outbox: [evt-1003 ✓]опашка: []Редът в outbox е отметнат, а опашката е празна. На кой ред е грешката?
Избери реда с грешката.
Редът се отмята, преди да е пратен. Срив между двете оставя отметнат ред, който никой няма да прати: събитието е изгубено, точно проблемът, който outbox решава. Дубликатът се хваща от потребителя. Изгубеното събитие не го хваща никой. Затова първо пращане, после отметка.
Пробвай
Подреди редовете
Подреди тялото на цикъла в изпращача така, че нищо да не се губи.
queue.Add($"{row.Id}: {row.Event}");if (row.Sent) continue;row.Sent = true;
Идеята
Кога outbox не си струва
Outbox добавя таблица, изпращач и закъснение. Не го ползвай, когато:
- няма отделен брокер. Ако реакциите са в същия процес и пишат в същата база (Observer в същата транзакция), една транзакция стига;
- изгубено съобщение не вреди. Обновяване на статистика или затопляне на кеш може да се изгуби веднъж на месец, без някой да пострада;
- събитието трябва да тръгне в същата милисекунда. Изпращачът чете на интервали. Секунда закъснение обикновено е наред, но не винаги.
А когато го ползваш, мисли за:
- дубликатите. Изпращачът праща поне веднъж. Потребителите трябва да са идемпотентни;
- реда. Един изпращач праща по реда на outbox. Няколко изпращача наведнъж могат да разменят реда или да пратят един ред два пъти, затова редовете се заключват при четене;
- растежа. Пратените редове се трият след няколко дни, иначе таблицата расте вечно.
От другата страна има огледален шаблон, inbox: потребителят записва id-то на полученото събитие в своята база, в същата транзакция като работата си. Това е seen на склада от предишния урок, но в таблица.
По-точно, за любопитните
Изпращачът в истинска система. Най-простият е цикъл със заявка SELECT … FROM outbox WHERE sent = false ORDER BY id LIMIT 100. В PostgreSQL FOR UPDATE SKIP LOCKED позволява няколко изпращача, без да вземат един и същ ред. Друг начин е change data capture: Debezium чете дневника на базата (WAL в PostgreSQL, binlog в MySQL) и праща новите редове на outbox в Kafka, без заявки на интервали. Как се пуска и наблюдава Debezium или Kafka Connect е тема на DevOps курса ZeroToOps.
Готови решения. Spring Modulith има регистър на събитията, който е outbox. В .NET MassTransit и NServiceBus имат вграден outbox към Entity Framework. В Python и Node.js обикновено се пише на ръка, с таблица и малък изпращач, както в урока.
Two-phase commit. XA транзакциите (JTA в Java, System.Transactions в .NET) обещават обща транзакция през база и брокер. На практика Kafka не ги поддържа, а RabbitMQ и облачните опашки също. Затова outbox е обичайният отговор.
Защо не просто retry? „Ако пращането падне, опитай пак“ помага при грешка от брокера, но не и при срив на процеса: след рестарта никой не помни, че нещо е трябвало да тръгне. Outbox е паметта, която оцелява след срива.
Упражнение
Изпращач на партиди
Реши задачата в редактора си.
В истинската система изпращачът чете най-много няколко реда наведнъж (LIMIT в заявката). Направи Relay(limit), което праща най-много limit непратени реда и връща колко е пратило. Запиши три поръчки и върти изпращача с limit 2, докато не върне 0:
кръг 1: пратени 2кръг 2: пратени 1кръг 3: пратени 0опашка: 3Още задачи
- Отказана поръчка. Добави
cancelOrder(id), който в една транзакция маха поръчката и слага в outboxOrderCancelled. Провери, че срив преди commit не маха поръчката и не праща нищо. - Чистене. Добави към реда момента на пращане (тик) и функция, която трие пратените редове, по-стари от 10 тика.
- Inbox. Направи склад, който пази id-тата на получените събития и хвърля дубликата от картата „Предвиди изхода“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Бази данни · Observer · Монолит, модулен монолит, услуги · Събития и опашки
- Продължава в: ADR: записани архитектурни решения · Транзакция през няколко услуги (saga) · Твоята първа архитектурна задача
- Задачи по темата: Софтуерни архитектури