Як реалізувати розподілені транзакції в мікросервісах
У монолітній архітектурі з однією реляційною СУБД транзакції забезпечуються механізмом ACID самої бази даних: якщо одна операція падає, база відкочує весь стан (rollback).
🟢 Junior Level
У монолітній архітектурі з однією реляційною СУБД транзакції забезпечуються механізмом ACID самої бази даних: якщо одна операція падає, база відкочує весь стан (rollback).
У мікросервісній архітектурі діє принцип Database per Service: кожен сервіс володіє власною ізольованою базою даних (PostgreSQL, MongoDB, Redis). Єдиного менеджера транзакцій немає, а мережеві виклики між сервісами можуть завершуватися помилками, затримками або мережевими розділеннями (Network Partition).
Моноліт (ACID):
[Сервіс] ──► [Одна БД] (BEGIN ──► INSERT/UPDATE ──► COMMIT / ROLLBACK)
Мікросервіси (BASE / Eventual Consistency):
[Order Service] [Payment Service] [Stock Service]
│ │ │
▼ ▼ ▼
[(БД Orders)] [(БД Payments)] [(БД Warehouse)]
Основні стратегії реалізації:
- Saga (Сага) — послідовність незалежних локальних транзакцій. Кожен сервіс комітить зміни у своїй БД. Якщо на якомусь кроці відбувається збій, викликаються компенсувальні транзакції, що скасовують попередні дії у зворотному порядку.
- TCC (Try-Confirm-Cancel) — двофазний бізнес-протокол: спочатку ресурси тимчасово резервуються (
Try), потім при успіху підтверджуються (Confirm), або при збої бронь знімається (Cancel). - Двофазний коміт (2PC / XA) — жорсткий синхронний протокол розподілених транзакцій. Координатор опитує учасників («Чи готові?»), і якщо всі відповіли «Так», розсилає команду коміту. У сучасних мікросервісах практично не застосовується через блокування та низьку надійність.
Золоте правило архітектури: Найкраща розподілена транзакція — та, якої вдалося уникнути. Якщо два об’єкти завжди мають змінюватися строго атомарно, найчастіше вони належать до одного Bounded Context і повинні знаходитися в одному мікросервісі.
🟡 Middle Level
1. Патерн Saga: ACD без I (Isolation)
Сага гарантує Atomicity, Consistency, Durability, але НЕ гарантує Isolation: поки сага виконується, проміжні результати локальних транзакцій видно іншим транзакціям.
Реалізується двома шляхами:
- Хореографія: Сервіси слухають події один одного через брокер повідомлень (Kafka/RabbitMQ).
- Оркестрація: Спеціальний координатор (Saga Orchestrator) зберігає стейт-машину і шле команди сервісам.
2. Проблема Dual Write та Transactional Outbox
Класична помилка: всередині одного методу зберегти сутність у БД та надіслати подію в Kafka:
// АНТИПАТЕРН: Dual-Write Problem!
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // 1. Запис у БД
kafkaTemplate.send("orders", new OrderCreatedEvent(order.getId())); // 2. Мережа/Брокер
// Якщо брокер впав або мережа моргнула — замовлення в БД залишилося, подія не пішла!
// Якщо після відправлення впав коміт БД — подія пішла, а замовлення в базі немає!
}
Рішення: Transactional Outbox Pattern Бізнес-сутність і подія зберігаються в одній локальній транзакції в реляційну БД. Потім фоновий процес або CDC (Change Data Capture) надійно передає подію в брокер:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items(), OrderStatus.PENDING);
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"Order",
order.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedPayload(order.getId(), order.getTotalAmount()))
);
outboxRepository.save(event); // Зберігається АТОМАРНО із замовленням!
return order;
}
}
3. TCC (Try-Confirm-Cancel) vs Saga
| Параметр | Saga | TCC (Try-Confirm-Cancel) | 2PC (Two-Phase Commit) |
|---|---|---|---|
| Рівень реалізації | Прикладний (події/команди) | Прикладний (бізнес-резервування) | Інфраструктурний / БД (XA) |
| Фіксація ресурсів | Одразу комітиться в БД, при помилці відкочується компенсацією | Ресурс «заморожується» в полі reserved_amount на кроці Try |
Блокування рядків на рівні СУБД до кінця транзакції |
| Видимість змін | Проміжні дані видно одразу всім | Заблокований ресурс недоступний, але баланс ще не списано | До коміту зміни нікому не видно |
| Складність | Середня | Висока (кожна операція ділиться на 3 методи) | Низька в коді, екстремально складна в експлуатації |
🔴 Senior Level
1. Аномалії відсутності ізоляції в Saga та методи боротьби
Оскільки в Saga локальні транзакції комітяться негайно, виникають класичні аномалії паралелізму:
- Lost Updates (Втрачені оновлення): Сага 1 списала залишок товару, Сага 2 перезаписала його поверх, не знаючи про першу сагу.
- Dirty Reads (Брудні читання): Сага 1 створила бронь готелю. Клієнт побачив заброньований номер і купив авіаквиток. Потім Сага 1 впала на кроці оплати і скасувала бронь готелю — клієнт залишився з квитком, але без готелю.
- Non-repeatable Reads (Неповторювані читання): Читання даних між кроками саги повертає різні значення через проміжні зміни інших саг.
Заходи протидії (Saga Countermeasures):
- Semantic Lock (Семантичне блокування): Запис позначається статусом
PENDING_*(наприклад,OrderStatus.APPROVAL_PENDING). Будь-які інші операції із замовленням блокуються на рівні бізнес-логіки до зняття прапорця. - Commutative Updates (Комутативні операції): Проєктування операцій так, щоб порядок їх застосування не мав значення (наприклад, операції
account.credit()таaccount.debit()комутативні відносно підсумкової суми за наявності кредитного ліміту). - Pessimistic View (Песимістичний погляд): Перестановка кроків саги так, щоб операції з найбільшим бізнес-ризиком виконувалися першими (Pivotal Transaction).
Структура кроків правильної Саги:
1. Compensable Transactions ──► Можуть бути компенсовані при збої
2. Pivotal Transaction ──► Точка неповернення (якщо успішна — сага зобов'язана дійти до кінця)
3. Retriable Transactions ──► Йдуть після точки неповернення, зобов'язані завершитися успіхом (ідемпотентні повтори)
2. Реалізація надійного Outbox: Polling Publisher vs CDC (Debezium)
- Polling Publisher (
SELECT ... FOR UPDATE SKIP LOCKED): Фоновий планувальник (scheduler) кожні $N$ мілісекунд читає записи зі статусомNEWз таблиціoutbox. Недоліки: Додаткове навантаження на СУБД (I/O, блокування індексів, vacuum/bloat у PostgreSQL), затримка (latency) дорівнює інтервалу полінгу. - CDC через Debezium:
Debezium підключається до реплікаційного слота бази (PostgreSQL WAL або MySQL binlog) як репліка. Він асинхронно вичитує бінарні логи транзакцій і стрімить події безпосередньо в Kafka із затримкою в одиниці мілісекунд, не навантажуючи процесор СУБД запитами
SELECT.
[Service] ──(Single ACID Tx)──► [PostgreSQL: orders + outbox]
│ (WAL Engine)
▼
[Debezium CDC]
│ (Streaming)
▼
[Apache Kafka]
3. Ідемпотентність споживача (Consumer Idempotency)
Оскільки брокери повідомлень гарантують доставку At-Least-Once, повторні повідомлення неминучі. Споживач зобов’язаний реалізовувати Idempotent Consumer з використанням таблиці оброблених повідомлень:
CREATE TABLE processed_messages (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL,
handler_name VARCHAR(100) NOT NULL
);
Усередині транзакції консьюмера:
@Transactional
public void handle(OrderPaidEvent event) {
// Вставка з унікальним ключем: при дублікаті викине DuplicateKeyException
if (processedMessageRepo.existsById(event.eventId())) {
log.warn("Дублікат події: {}, пропускаємо", event.eventId());
return;
}
processedMessageRepo.save(new ProcessedMessage(event.eventId(), Instant.now()));
deliveryService.scheduleDelivery(event.orderId());
}
4 Tricky Questions
1. Чому двофазний коміт (2PC) вважається антипатерном у сучасних хмарних мікросервісах?
Відповідь:
- Синхронні блокування: Під час фази Prepare ресурси (рядки в БД) блокуються ексклюзивними блокуваннями до моменту, поки всі учасники не надішлють відповідь і координатор не видасть команду Commit. Це драматично знижує пропускну здатність (throughput) усієї системи.
- Блокуючий координатор (In-Doubt State): Якщо координатор падає після того, як розіслав Prepare, але до розсилки Commit, учасники зависають у стані невизначеності. Вони не мають права ні зазумітити, ні відкотити транзакцію самостійно. Рядки в БД залишаються заблокованими до ручного втручання адміністратора.
- Гетерогенність: Сучасні мікросервіси використовують NoSQL (MongoDB, DynamoDB) і брокери (Kafka), які не підтримують інтерфейс XA.
2. Чим фундаментально відрізняються патерн Saga та патерн TCC (Try-Confirm-Cancel)?
Відповідь:
- У Saga на кожному кроці виконується повноцінна локальна транзакція з негайним зміненням та фіксацією бізнес-даних у БД. Якщо щось пішло не так на наступному кроці, запускається окрема коригувальна транзакція (компенсація). Проміжні дані видно всій системі.
- У TCC операція явно розбивається на дві фази:
Try: ресурс не списується остаточно, а резервується (наприклад, створюється тимчасовий холдуючий балансreserved_amount += 100, а доступний баланс зменшуєтьсяavailable_amount -= 100).Confirm/Cancel: якщо всі учасники успішно виконалиTry, викликаєтьсяConfirm(гроші списуються зreserved). Якщо хтось упав — викликаєтьсяCancel(бронь знімається:available += 100,reserved -= 100). TCC забезпечує кращу ізоляцію і запобігає брудним читанням на рівні бізнес-логіки, але вимагає втричі більшого обсягу коду.
3. Що станеться, якщо в Сазі падає компенсувальна транзакція?
Відповідь: Компенсувальна транзакція не має права впасти з бізнес-помилкою, оскільки відкат відкату неможливий.
- При технічних збоях (падіння мережі, недоступність БД) компенсація повинна повторюватися з експоненційною затримкою (Exponential Backoff) до переможного кінця (Retry until success).
- Якщо компенсація завершилася фатальною помилкою (баг у коді, порушення інваріанту даних):
- Дані переводяться в статус
MANUAL_INTERVENTION_REQUIRED. - Подія відправляється в Dead Letter Queue (DLQ).
- Спрацьовує алерт для чергових інженерів (SRE / Support) для ручного виправлення стану системи.
- Дані переводяться в статус
4. Чому наївний Polling Publisher (@Scheduled) для Transactional Outbox не масштабується на високих навантаженнях?
Відповідь:
- Lock Contention: Кілька інстансів сервісу намагаються одночасно вичитати нові записи з таблиці Outbox, що призводить до конкуренції блокувань (
SELECT ... FOR UPDATE). ВикористанняSKIP LOCKEDдопомагає, але створює колосальне навантаження на дискову підсистему і буферний пул СУБД. - Табличний Bloat (роздування): Постійні
INSERT,UPDATE(позначкаprocessed = true) і подальшіDELETEу реляційних базах (особливо PostgreSQL з механізмом MVCC) призводять до деградації індексів і необхідності частогоVACUUM. - Затримка (Latency): Мінімальна затримка передачі події обмежена інтервалом роботи планувальника (наприклад, 500 мс – 1 с). Вирішення проблеми на високому навантаженні — перенесення читання на рівень бінарних логів реплікації через CDC (Debezium).
🎯 Шпаргалка для інтерв’ю
Головне:
- У мікросервісах немає єдиної транзакції (ACID). Використовується модель BASE та Eventual Consistency.
- Основний інструмент оркестрації бізнес-транзакцій — Saga (Choreography або Orchestration).
- Сага гарантує атомарність через компенсувальні транзакції, але не гарантує ізоляцію (потрібні Semantic Locks).
- Проблема Dual-Write вирішується патерном Transactional Outbox + CDC (Debezium).
- Усі обробники повідомлень зобов’язані бути ідемпотентними (Idempotent Consumer + таблиця
processed_messages).
Чек-лист проєктування розподіленої транзакції:
- Чи можна об’єднати ці дані в один сервіс і уникнути розподіленості? (Якщо так — об’єднайте!).
- Чи реалізовано Transactional Outbox для публікації подій?
- Чи є всі локальні кроки та компенсації ідемпотентними?
- Чи визначено точку неповернення (Pivotal transaction)?
- Чи налаштовано Dead Letter Queue (DLQ) та алертинг на випадок завислих компенсацій?
Червоні прапорці (Чого говорити НЕ МОЖНА):
- ❌ «Ми використовуємо XA / 2PC для зв’язку мікросервісів через HTTP/gRPC» (2PC блокує потоки й ресурси, ламає масштабованість і породжує In-Doubt збої).
- ❌ «Я просто відправляю подію в Kafka прямо з
@Transactionalметоду в Spring» (Це Dual-Write: гарантує втрату даних або неузгодженість при падінні мережі/БД). - ❌ «У Saga можна просто відкотити транзакцію через Rollback» (У Saga кожна локальна транзакція вже закомічена; відкат можливий лише новою компенсувальною транзакцією).