Пошук уроків, статей та іншого контенту
Порівняємо 2PC, 3PC та Saga й визначимо, як координувати зміни в кількох сервісах без спільної транзакційної бази.
У межах одного сервісу транзакція бази даних може гарантувати атомарність:
BEGIN
списати кошти
створити замовлення
COMMITАле в мікросервісній системі ці операції часто належать різним сервісам:
Order Service створює замовлення;
Payment Service списує кошти;
Inventory Service резервує товар;
Shipping Service створює доставку.
Кожен сервіс має власну базу даних і може бути незалежно розгорнутий. Спільної транзакції бази даних між ними немає.
Проблема виникає, якщо частина операцій виконалася, а частина — ні:
кошти списані;
товар не вдалося зарезервувати;
замовлення залишилося в некоректному стані.
Локальна транзакція кожного сервісу не вирішує цю проблему. Потрібен протокол координації, який визначає:
коли всі учасники можуть зафіксувати зміни;
що робити при помилці;
як поводитися під час тайм-ауту;
як відновитися після перезапуску сервісу;
чи потрібна строга атомарність, чи допустима тимчасова неузгодженість.
Основні підходи:
2PC — Two-Phase Commit;
3PC — Three-Phase Commit;
Saga — послідовність локальних транзакцій із компенсувальними діями.
2PC використовує координатора та учасників.
Координатор керує транзакцією та приймає фінальне рішення.
Учасники виконують локальні операції й повідомляють координатору, чи готові вони зафіксувати зміни.
Координатор надсилає всім учасникам запит:
PREPAREУчасник:
виконує локальні перевірки;
записує необхідні зміни у проміжний стан;
блокує ресурси, якщо це потрібно;
зберігає стан підготовки на диску;
відповідає YES або NO.
Важливо: YES означає не просто «операція зараз успішна». Це означає:
Я готовий зафіксувати цю транзакцію, якщо координатор накаже це зробити.
Якщо всі учасники відповіли YES, координатор надсилає:
COMMITЯкщо хоча б один учасник відповів NO, координатор надсилає:
ROLLBACKУчасники завершують локальну транзакцію та звільняють ресурси.
Координатор Сервіс A Сервіс B
| | |
|------ PREPARE ------->| |
|------ PREPARE ---------------------------->|
| | |
|<------- YES ----------| |
|<-------------------------- YES ------------|
| | |
|------ COMMIT -------->| |
|------ COMMIT ----------------------------->|
| | |
|<------- OK -----------| |
|<-------------------------- OK -------------|2PC забезпечує сильну атомарність:
або всі учасники фіксують зміни;
або всі учасники скасовують зміни.
Це корисно для операцій, де частковий результат неприйнятний:
фінансові перекази;
зміни в кількох сховищах, які підтримують один і той самий протокол;
критичні операції з жорсткими вимогами до узгодженості.
Учасник, який відповів YES, уже не може самостійно вирішити, що робити далі. Він очікує команду координатора.
Розглянемо послідовність:
усі учасники відповіли YES;
координатор підготував рішення COMMIT;
координатор аварійно завершив роботу до надсилання команди;
учасники не знають, чи буде COMMIT, чи ROLLBACK.
У цей момент вони змушені утримувати блокування та чекати відновлення координатора.
Тому 2PC може:
блокувати ресурси на тривалий час;
зменшувати доступність системи;
погано працювати через повільну мережу;
створювати каскадні тайм-аути;
збільшувати затримку кожної операції.
2PC не усуває проблеми мережевих розділень. Він лише визначає протокол, за яким узгоджуються учасники.
Координатор має зберігати журнал станів транзакції:
transaction-123: PREPARING
transaction-123: COMMIT_DECIDEDУчасник також зберігає власний стан:
transaction-123: PREPAREDПісля перезапуску координатор може відновити рішення з журналу. Учасник може повторно запитати координатора про фінальний стан.
Без надійного журналу координатор може втратити інформацію про вже прийняте рішення, а учасники залишаться заблокованими.
3PC розширює 2PC додатковою фазою. Його мета — зменшити ймовірність блокування учасників.
Класичні фази 3PC:
CanCommit?
PreCommit
DoCommit
Координатор запитує, чи можуть учасники виконати транзакцію.
Учасники перевіряють:
доступність потрібних ресурсів;
коректність даних;
можливість локального виконання.
Вони відповідають YES або NO, але ще не переходять у стан, у якому остаточно не можуть відмовитися.
Якщо всі відповіли YES, координатор надсилає:
PRECOMMITУчасник:
готує зміни;
фіксує, що транзакція переходить до завершення;
відповідає підтвердженням.
Ця фаза розділяє момент перевірки готовності та момент остаточного коміту.
Після отримання підтверджень координатор надсилає:
DOCOMMITУчасники остаточно фіксують зміни.
Схема:
Координатор Учасник A Учасник B
| | |
|----- CanCommit? ----->| |
|----- CanCommit? -------------------------->|
| | |
|<-------- YES ---------| |
|<-------------------------- YES ------------|
| | |
|------ PRECOMMIT ----->| |
|------ PRECOMMIT -------------------------->|
| | |
|<--------- ACK ---------| |
|<-------------------------- ACK ------------|
| | |
|------- DOCOMMIT ------>| |
|------- DOCOMMIT -------------------------->|У 2PC після PREPARE учасник може залишитися в невизначеному стані й чекати координатора.
У 3PC учасники отримують проміжний стан PRECOMMIT. Завдяки цьому вони можуть використовувати тайм-аут і зробити висновок про наступний крок за певних умов.
Проте це працює лише за припущень про систему, зокрема:
мережа не має необмежених затримок;
учасники можуть відрізнити недоступний вузол від повільного;
системний час і тайм-аути достатньо передбачувані;
одночасно не виникають довільні типи збоїв.
У реальних мережах ці припущення часто порушуються. Мережевий тайм-аут не доводить, що координатор не встиг виконати операцію.
3PC не є універсальним рішенням проблем розподілених транзакцій.
Він:
додає ще один мережевий раунд;
збільшує затримку;
складніший у реалізації;
не гарантує коректної роботи при довільних мережевих збоях;
не усуває залежність від координатора;
не робить локальні бази даних частиною єдиної магічної транзакції.
На практиці 3PC використовується значно рідше, ніж 2PC або Saga. Для сервісів із незалежними базами даних зазвичай обирають Saga, якщо сильна синхронна атомарність не є абсолютною вимогою.
Saga — це розподілена операція, поділена на послідовність локальних транзакцій.
Кожна локальна транзакція:
змінює дані лише у своєму сервісі;
фіксується незалежно;
запускає наступний крок;
має компенсувальну дію, якщо вся операція не може бути завершена.
Наприклад, оформлення замовлення:
Створити замовлення
-> Зарезервувати товар
-> Списати кошти
-> Створити доставкуЯкщо створення доставки не вдалося, система може виконати компенсації:
Скасувати списання коштів
-> Скасувати резерв товару
-> Скасувати замовленняКомпенсація — це не технічний ROLLBACK. Це нова бізнес-операція, яка виправляє наслідки попередньої.
Наприклад:
chargeCard() компенсується через refundPayment();
reserveInventory() компенсується через releaseInventory();
createOrder() компенсується через cancelOrder().
Не кожна дія має ідеальну компенсацію. Наприклад, відправлення товару фізично не можна «скасувати» так само легко, як запис у базі даних.
Окремий компонент — оркестратор — знає порядок кроків і викликає сервіси.
Оркестратор
|
|-- createOrder
|-- reserveInventory
|-- chargePayment
|-- createShipment
|
|-- у разі помилки:
|-- refundPayment
|-- releaseInventory
|-- cancelOrderПереваги:
бізнес-процес видно в одному місці;
простіше керувати порядком кроків;
легше реалізувати компенсації;
простіше додати тайм-аути та повторні спроби.
Недоліки:
оркестратор стає важливою частиною системи;
складна логіка може перетворити його на монолітний координатор;
потрібно забезпечити відновлення після його перезапуску.
Сервіси взаємодіють через події, не маючи центрального координатора.
OrderCreated
-> InventoryReserved
-> PaymentCharged
-> ShipmentCreatedЯкщо виникла помилка, публікується подія для компенсації:
PaymentFailed
-> ReleaseInventory
-> CancelOrderПереваги:
менша залежність від центрального компонента;
сервіси реагують на події;
зручно додавати незалежних підписників.
Недоліки:
повний процес складно побачити в одному місці;
зростає зв’язність через події;
легко отримати циклічні реакції;
складніше відстежувати причину помилки;
важче контролювати порядок і повторне доставлення подій.
Вибір між оркестрацією та хореографією залежить від складності бізнес-процесу. Для довгих процесів із багатьма умовами оркестрація зазвичай простіша для контролю.
Нижче наведено спрощений, але runnable-приклад на JavaScript. Сервіси представлені функціями, які імітують локальні транзакції. Якщо списання коштів не вдається, оркестратор звільняє резерв товару та скасовує замовлення.
class InventoryService {
constructor() {
this.reservations = new Set();
}
async reserve(orderId) {
// Локальна транзакція сервісу складу
this.reservations.add(orderId);
console.log(`Товар зарезервовано для ${orderId}`);
}
async release(orderId) {
// Компенсувальна дія для резервування
this.reservations.delete(orderId);
console.log(`Резерв товару скасовано для ${orderId}`);
}
}
class PaymentService {
constructor() {
this.charges = new Set();
}
async charge(orderId) {
// Локальна транзакція сервісу платежів
if (orderId === "order-fail-payment") {
throw new Error("Платіж відхилено");
}
this.charges.add(orderId);
console.log(`Платіж виконано для ${orderId}`);
}
async refund(orderId) {
// Компенсувальна дія для платежу
this.charges.delete(orderId);
console.log(`Повернення коштів виконано для ${orderId}`);
}
}
class OrderService {
constructor() {
this.orders = new Map();
}
async create(orderId) {
// Локальна транзакція сервісу замовлень
this.orders.set(orderId, "CONFIRMED");
console.log(`Замовлення створено: ${orderId}`);
}
async cancel(orderId) {
// Компенсувальна дія для створення замовлення
this.orders.set(orderId, "CANCELLED");
console.log(`Замовлення скасовано: ${orderId}`);
}
}
class OrderSaga {
constructor(orderService, inventoryService, paymentService) {
this.orderService = orderService;
this.inventoryService = inventoryService;
this.paymentService = paymentService;
}
async execute(orderId) {
const completedSteps = [];
try {
await this.orderService.create(orderId);
completedSteps.push("order");
await this.inventoryService.reserve(orderId);
completedSteps.push("inventory");
await this.paymentService.charge(orderId);
completedSteps.push("payment");
console.log(`Saga успішно завершена для ${orderId}`);
} catch (error) {
console.log(`Saga завершилася помилкою: ${error.message}`);
// Компенсації виконуються у зворотному порядку
if (completedSteps.includes("payment")) {
await this.paymentService.refund(orderId);
}
if (completedSteps.includes("inventory")) {
await this.inventoryService.release(orderId);
}
if (completedSteps.includes("order")) {
await this.orderService.cancel(orderId);
}
throw error;
}
}
}
async function main() {
const orderService = new OrderService();
const inventoryService = new InventoryService();
const paymentService = new PaymentService();
const saga = new OrderSaga(
orderService,
inventoryService,
paymentService
);
await saga.execute("order-100");
try {
await saga.execute("order-fail-payment");
} catch {
console.log("Помилку оброблено, компенсації виконано");
}
console.log(orderService.orders);
console.log(inventoryService.reservations);
console.log(paymentService.charges);
}
main().catch(console.error);У цьому прикладі компенсації викликаються безпосередньо. У production-системі між кроками часто використовують повідомлення або події, оскільки:
сервіс може бути тимчасово недоступним;
запит може завершитися тайм-аутом, хоча операція вже виконалася;
компенсацію може знадобитися повторити;
оркестратор може перезапуститися.
Повторне виконання одного кроку не повинно створювати неправильний результат.
Наприклад, повторна обробка команди:
ReserveInventory(order-100)не повинна створити два резерви.
Сервіс може зберігати ідентифікатор операції:
operationId = saga-123:reserve-inventoryПеред виконанням він перевіряє, чи не була ця операція вже успішно завершена.
Ідемпотентними мають бути:
основні кроки;
компенсувальні дії;
обробники повторно доставлених повідомлень.
Тимчасова помилка не обов’язково означає помилку бізнес-операції. Запит міг завершитися через:
короткочасну недоступність сервісу;
перевищення тайм-ауту;
мережеву помилку;
тимчасове перевантаження.
Тому оркестратор або обробник подій має розрізняти:
повторювані тимчасові помилки;
постійні бізнес-помилки;
невизначений результат.
Повторювати можна не всі операції. Повторення неідемпотентного списання може призвести до подвійного платежу.
Оркестратор не повинен зберігати поточний крок лише в пам’яті. Після перезапуску він має знати:
ідентифікатор Saga;
поточний стан;
завершені кроки;
кількість спроб;
останню помилку;
необхідні компенсації.
Наприклад:
sagaId: saga-123
orderId: order-100
state: PAYMENT_COMPLETED
completedSteps:
- CREATE_ORDER
- RESERVE_INVENTORY
- CHARGE_PAYMENTТакий стан можна зберігати в окремому сховищі оркестратора. Це не спільна транзакційна база сервісів, а журнал стану бізнес-процесу.
Тайм-аут означає лише те, що відповідь не отримано вчасно. Він не доводить, що операція не виконалася.
Наприклад:
Payment Service списав кошти;
відповідь загубилася;
оркестратор отримав тайм-аут;
оркестратор повторив списання.
Без ідемпотентного ключа кошти можуть бути списані двічі.
Правильний підхід:
кожна команда має унікальний ідентифікатор;
сервіс зберігає результат обробки команди;
повторна команда повертає вже відомий результат;
для невизначених операцій передбачена перевірка статусу.
Saga не забезпечує миттєвої глобальної атомарності.
Між локальними транзакціями може існувати стан, у якому:
замовлення вже створено;
товар уже зарезервовано;
платіж ще обробляється.
Це називають eventual consistency — узгодженість досягається з часом.
Користувач може тимчасово бачити статус:
PROCESSINGПісля успішного завершення він зміниться на:
CONFIRMEDА після невдалої компенсації — наприклад:
CANCELLATION_PENDINGВажливо моделювати ці стани явно. Не варто використовувати лише два стани SUCCESS і FAILED, якщо процес може перебувати в проміжному або невизначеному стані.
Обирають, коли:
потрібна сильна атомарність;
усі учасники підтримують спільний протокол;
блокування та затримка прийнятні;
кількість учасників обмежена;
операція короткотривала.
Основний ризик — блокування при збоях координатора або мережі.
Може бути доречним, коли:
потрібна модель на основі фаз підготовки;
система відповідає припущенням протоколу;
додаткова затримка виправдана;
команда готова підтримувати складнішу реалізацію.
На практиці його рідко обирають як універсальну заміну 2PC, особливо в ненадійних мережах.
Обирають, коли:
сервіси мають власні бази даних;
бізнес-процес може бути розділений на локальні кроки;
тимчасова неузгодженість допустима;
є зрозумілі компенсувальні дії;
потрібна висока доступність;
процес може тривати довго.
Основний ризик — складність компенсацій та обробки повторів.
Під час проєктування потрібно відповісти на кілька запитань:
Чи справді операція має бути атомарною в усіх сервісах?
Чи можна тимчасово показувати стан PROCESSING?
Чи існує коректна компенсація для кожного кроку?
Що станеться, якщо компенсація також завершиться помилкою?
Чи можуть команди бути повторно доставлені?
Як система визначить результат операції після тайм-ауту?
Де зберігається стан розподіленого процесу?
Як оператор знайде всі операції, що застрягли?
Як відновиться процес після перезапуску координатора?
Які дії безпечні для автоматичного повторення?
Зазвичай корисна практична стратегія така:
локальні зміни виконувати в локальних транзакціях;
між сервісами передавати команди або події;
кожну операцію робити ідемпотентною;
зберігати стан Saga;
передбачати повтори та компенсації;
явно моделювати проміжні стани;
мати моніторинг завислих процесів.
STARTED
|
v
ORDER_CREATED
|
v
INVENTORY_RESERVED
|
v
PAYMENT_COMPLETED
|
v
COMPLETEDГілка помилки:
INVENTORY_RESERVED
|
v
PAYMENT_FAILED
|
v
RELEASING_INVENTORY
|
v
CANCELING_ORDER
|
v
CANCELLEDЯкщо компенсація не вдалася:
PAYMENT_FAILED
|
v
COMPENSATION_PENDINGТакий стан не можна приховувати. Система повинна мати механізм:
повторної компенсації;
ручного втручання;
повідомлення оператора;
аудиту всіх спроб.
ROLLBACK бази даних одного сервісу не може скасувати вже зафіксовану операцію в іншому сервісі.
Для Saga потрібна окрема бізнес-компенсація.
Тайм-аут повідомляє про відсутність відповіді, а не про відсутність виконання.
Потрібні ідемпотентність, ідентифікатори операцій і перевірка статусу.
Автоматичний retry небезпечний для неідемпотентних операцій. Повторне списання або повторне створення доставки може мати реальні наслідки.
Якщо оркестратор знає поточний крок лише з пам’яті, його перезапуск може втратити інформацію про незавершені дії.
Скасування резерву зазвичай просте. Повернення коштів може тривати довше. Скасування відправленого товару може бути неможливим.
Компенсації потрібно проєктувати як окремі бізнес-операції.
Повідомлення можуть бути доставлені більше одного разу. Обробники повинні перевіряти ідентифікатор повідомлення або операції.
Довгий розподілений процес не повинен маскуватися під миттєвий SUCCESS. Користувачам і операторам потрібні стани на кшталт:
PROCESSING;
PAYMENT_PENDING;
COMPENSATION_PENDING;
CANCELLED;
COMPLETED.
2PC координує учасників у двох фазах і забезпечує сильну атомарність, але може блокувати ресурси.
3PC додає проміжну фазу підготовки та намагається зменшити блокування, проте складніший і залежить від припущень про мережу.
Saga складається з локальних транзакцій і компенсувальних дій.
Saga не дає миттєвої глобальної атомарності, але краще підходить для незалежних мікросервісів і довгих бізнес-процесів.
Оркестрація централізує керування процесом, а хореографія покладається на події між сервісами.
Для надійної Saga потрібні ідемпотентність, збереження стану, тайм-аути, повторні спроби, компенсації та явні проміжні статуси.
Вибір між 2PC, 3PC і Saga визначається не популярністю протоколу, а вимогами до атомарності, доступності, затримки та можливості компенсувати бізнес-операції.