Когда приложение разбивают на несколько сервисов, исчезает главное удобство монолита — единая транзакция базы данных. Раньше одна команда @Transactional гарантировала: либо заказ создан, деньги списаны и склад зарезервирован — либо ничего из этого. В микросервисах каждый сервис — отдельная база, отдельный процесс, отдельная сеть. Откатить чужую транзакцию нельзя. Сеть может упасть в самый неудобный момент.
Эта статья разбирает паттерны, которые решают именно эту проблему — один за другим, от простых к сложным.
Two-Phase Commit (2PC) — двухфазная фиксация
Проблема. Нужно, чтобы три сервиса либо все зафиксировали изменения, либо все откатились. Как это скоординировать?
Идея. Вводится координатор, который управляет процессом из двух фаз:
- Prepare (голосование): координатор спрашивает каждого участника «готов зафиксировать?». Участник выполняет всю работу — блокирует ресурсы, пишет в журнал — но не фиксирует. Отвечает «да» или «нет».
- Commit или Rollback (решение): если все ответили «да» — координатор говорит «фиксируй». Если хоть один ответил «нет» — все откатываются.
Почему 2PC редко используют в микросервисах:
- Между фазами ресурсы заблокированы. Если координатор упал — участники висят с заблокированными строками.
- Координатор — единая точка отказа. Его падение между фазами оставляет систему в неопределённом состоянии.
- Два сетевых раунда — задержка растёт с каждым участником.
Когда подходит: несколько баз данных одного вендора в рамках одной инфраструктуры (XA-транзакции). Для микросервисов через сеть — почти никогда.
Three-Phase Commit (3PC) — почему не спасает
3PC добавляет промежуточную фазу PRE_COMMIT, чтобы участники могли самостоятельно принять решение, если координатор пропадёт. Три сетевых раунда вместо двух — ещё больше задержка. И главное: 3PC не решает проблему сетевого раздела (network partition). Если участник получил PRE_COMMIT, потерял связь, зафиксировал — а другой участник в это время откатился — данные рассогласованы.
На практике проблемы 2PC решают не через 3PC, а через SAGA.
SAGA — цепочка компенсируемых шагов
Проблема. Нужно скоординировать действия нескольких сервисов, но без блокировок и единого координатора на уровне базы.
Идея. Вместо одной большой транзакции — цепочка локальных транзакций. Каждый шаг фиксируется самостоятельно. Если шаг N упал — выполняются компенсирующие транзакции для уже выполненных шагов в обратном порядке.
Шаг 1: Создать заказ → Компенсация: Отменить заказ
Шаг 2: Списать деньги → Компенсация: Вернуть деньги
Шаг 3: Зарезервировать на складе → Компенсация: Снять резерв
Ключевое отличие от 2PC: между шагами система находится во временно несогласованном состоянии. Это называется eventual consistency — в конечном счёте всё придёт к согласованности, но не мгновенно.
Оркестрация
Центральный оркестратор знает последовательность шагов и какую компенсацию вызвать при ошибке.
public class CreateOrderSaga {
public OrderResult execute(CreateOrderRequest request) {
SagaContext ctx = new SagaContext(request);
List<SagaStep<SagaContext>> completedSteps = new ArrayList<>();
List<SagaStep<SagaContext>> steps = List.of(
new SagaStep<>("create-order",
c -> orderService.create(c.getRequest()),
c -> orderService.cancel(c.getOrderId())),
new SagaStep<>("charge-payment",
c -> paymentService.charge(c.getUserId(), c.getTotal()),
c -> paymentService.refund(c.getPaymentId())),
new SagaStep<>("reserve-inventory",
c -> inventoryService.reserve(c.getOrderId(), c.getItems()),
c -> inventoryService.releaseReservation(c.getOrderId()))
);
for (SagaStep<SagaContext> step : steps) {
try {
StepResult result = step.action().apply(ctx);
ctx.apply(result);
completedSteps.add(step);
} catch (Exception e) {
compensate(ctx, completedSteps);
throw new SagaException("Step failed: " + step.name(), e);
}
}
return ctx.toResult();
}
}
Плюсы: логика в одном месте, легко отслеживать состояние, просто отлаживать.
Минусы: оркестратор знает про все сервисы — может разрастись в класс, который делает всё на свете.
Хореография
Нет центрального координатора. Каждый сервис слушает события и реагирует, публикуя свои:
// PaymentService реагирует на событие
@KafkaListener(topics = "order-events")
public void onOrderCreated(OrderCreatedEvent event) {
try {
PaymentResult result = paymentService.charge(event.userId(), event.total());
eventPublisher.publish(new PaymentChargedEvent(event.orderId(), result.paymentId()));
} catch (Exception e) {
eventPublisher.publish(new PaymentFailedEvent(event.orderId(), e.getMessage()));
}
}
Плюсы: сервисы слабо связаны, развиваются независимо.
Минусы: логику саги трудно отследить — она размазана по сервисам. Сложно понять, в каком состоянии находится процесс.
Оркестрация или хореография — что выбрать
Оркестрация лучше, когда шагов больше трёх-четырёх, есть ветвления («если оплата частичная — другой путь»), важна единая точка мониторинга.
Хореография лучше, когда шагов два-три, поток линейный без ветвлений, сервисы разрабатывают разные команды и им важна автономность.
Важно о компенсациях
Компенсация — это не откат базы данных. refund() — это новая бизнес-операция, которая приводит систему к эквиваленту отмены. Деньги возвращаются отдельной транзакцией, а не отменой списания.
Компенсация обязана быть идемпотентной — если вызвать её дважды, результат должен быть одинаковым:
public void refund(String paymentId) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow();
if (payment.getStatus() == PaymentStatus.REFUNDED) {
return; // уже возвращено — ничего не делаем
}
paymentGateway.refund(payment.getGatewayId());
payment.markRefunded();
paymentRepository.save(payment);
}
Transactional Outbox — атомарная запись данных и событий
Проблема. Типичная ошибка: сохранили заказ в базу, потом отправили событие в Kafka. Между этими двумя операциями процесс упал — заказ есть, событие потеряно. Обратная ситуация тоже плоха: событие ушло, а транзакция откатилась.
// Опасный код — не атомарно!
orderRepository.save(order); // транзакция зафиксирована
kafka.send(new OrderCreatedEvent); // процесс упал — событие потеряно
Решение. Событие сохраняется в ту же базу данных, в той же транзакции, что и бизнес-данные — в отдельную таблицу outbox_events. Отдельный фоновый процесс (relay) периодически читает эту таблицу и публикует события в брокер.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
published_at TIMESTAMP,
retry_count INT DEFAULT 0
);
// Бизнес-код: заказ и событие в одной транзакции
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(newOrder(cmd));
outboxPublisher.save("Order", order.getId().toString(),
"OrderCreated", new OrderCreatedPayload(order));
return order;
}
// Relay: периодически отправляет необработанные события
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPending() {
List<OutboxEvent> events = outboxRepository.findUnpublished(100);
for (OutboxEvent event : events) {
try {
kafka.send(topic(event), event.getAggregateId(), event.getPayload()).get();
event.markPublished();
} catch (Exception e) {
event.incrementRetryCount();
}
outboxRepository.save(event);
}
}
At-least-once — почему получатель должен быть идемпотентным
Outbox гарантирует at-least-once delivery: событие будет отправлено хотя бы один раз. Relay может упасть после отправки, но до того, как пометил строку published_at — тогда при следующем запуске событие уйдёт повторно. Поэтому потребители событий обязаны быть идемпотентными (см. раздел Idempotent Consumer ниже).
Polling vs CDC
Альтернатива polling-relay — Change Data Capture (CDC): внешний сервис читает журнал базы (WAL) и публикует изменения в Kafka напрямую. Но polling-relay в коде сервиса проще и надёжнее:
- Контракт события — в коде, а не в схеме таблицы. Изменение структуры
outbox_eventsне ломает потребителей. - CDC — отдельный кластер с собственным жизненным циклом и мониторингом. Сломался — отдельная команда, отдельная процедура.
- Висящий CDC-коннектор накапливает WAL до отказа диска. Polling-relay таких рисков не создаёт.
Опрос каждые 200–500 мс — задержка, неотличимая для бизнеса, и снимает целый класс операционных проблем.
Event Sourcing — история как источник истины
Проблема. Таблица orders хранит текущий статус: status = 'CONFIRMED'. Но мы не знаем, когда заказ был создан, когда оплачен, был ли он до этого в другом статусе. История потеряна.
Идея. Хранить не текущее состояние, а последовательность событий, которые к нему привели. Текущее состояние вычисляется воспроизведением всех событий.
OrderCreated → PaymentReceived → ItemReserved → OrderConfirmed
Агрегат восстанавливается из событий:
public class Order {
private String id;
private OrderStatus status;
private final List<DomainEvent> uncommittedEvents = new ArrayList<>();
private long version = 0;
public static Order fromEvents(List<DomainEvent> events) {
Order order = new Order();
for (DomainEvent event : events) {
order.apply(event);
order.version++;
}
return order;
}
private void apply(DomainEvent event) {
switch (event) {
case OrderCreated e -> { this.id = e.orderId(); this.status = OrderStatus.CREATED; }
case PaymentReceived e -> this.status = OrderStatus.PAID;
case OrderConfirmed e -> this.status = OrderStatus.CONFIRMED;
default -> throw new IllegalArgumentException("Unknown event: " + event.getClass());
}
}
public void confirm() {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("Can only confirm PAID orders, current=" + status);
}
raise(new OrderConfirmed(this.id, OffsetDateTime.now()));
}
private void raise(DomainEvent event) {
apply(event);
uncommittedEvents.add(event);
}
}
Проекции для чтения
Event Sourcing разделяет запись и чтение. Данные пишутся в Event Store (только добавление). Для чтения строятся проекции — таблицы, оптимизированные под конкретные запросы. Это естественно сочетается с CQRS.
public class OrderSummaryProjection {
public void on(OrderCreated event) {
repository.save(new OrderSummary(event.orderId(), "CREATED", event.createdAt()));
}
public void on(OrderConfirmed event) {
repository.updateStatus(event.orderId(), "CONFIRMED");
}
}
Когда подходит Event Sourcing
Подходит, когда нужна полная история изменений (финансы, аудит, расчёты с продавцами), возможность посмотреть состояние на любой момент времени, или сложная доменная логика с множеством переходов статусов.
Избыточен для простых справочников, CRUD без сложной логики, проектов без требований к истории.
Подводные камни
- Задержка проекций. Проекция обновляется асинхронно — пользователь может не увидеть свои изменения сразу.
- Версионирование событий. Событие
OrderCreated_v1не содержит поляcurrency. В_v2оно появилось. Нужна стратегия миграции старых событий при чтении. - Размер Event Store. Агрегат с тысячами событий — воспроизведение занимает время. Решение — снимки (снэпшоты): периодически сохранять текущее состояние, воспроизводить только от последнего снимка.
Idempotent Consumer — защита от дублей
Проблема. В распределённой системе сообщения приходят повторно: ретраи брокера, перебалансировка группы потребителей, гарантии at-least-once. Обработчик должен давать одинаковый результат при повторной обработке.
Решение. Хранить таблицу обработанных событий. Перед обработкой проверить — не обрабатывали ли уже это событие.
@Transactional
public <T> void process(String eventId, Supplier<T> handler) {
if (processedEvents.existsById(eventId)) {
return; // уже обработали — пропускаем
}
handler.get();
processedEvents.save(new ProcessedEvent(eventId, Instant.now()));
}
@KafkaListener(topics = "payment-events")
public void onPaymentCharged(PaymentChargedEvent event) {
idempotentProcessor.process(event.eventId(), () -> {
Order order = orderRepository.findById(event.orderId()).orElseThrow();
order.markPaid(event.paymentId());
orderRepository.save(order);
return null;
});
}
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL DEFAULT now()
);
-- Очистка старых записей
DELETE FROM processed_events
WHERE processed_at < now() - INTERVAL '7 days';
Тот же принцип работает для REST API — через заголовок Idempotency-Key. Клиент генерирует уникальный ключ, сервер проверяет, не обрабатывался ли уже запрос с таким ключом:
@PostMapping("/payments")
public ResponseEntity<PaymentResult> charge(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody ChargeRequest request) {
return idempotentProcessor.processOrReturn(idempotencyKey, () ->
paymentService.charge(request));
}
Distributed Lock — защита от конкурентного доступа
Проблема. Два экземпляра одного сервиса одновременно обрабатывают один заказ. Оба списывают деньги — пользователь платит дважды.
Решение. Распределённая блокировка через Redis: только один экземпляр может держать блокировку на один ресурс одновременно.
public <T> T executeWithLock(String lockKey, Duration timeout, Supplier<T> action) {
RLock lock = redisson.getLock(lockKey);
boolean acquired = lock.tryLock(timeout.toMillis(), timeout.toMillis() * 2, TimeUnit.MILLISECONDS);
if (!acquired) {
throw new LockAcquisitionException("Cannot acquire lock: " + lockKey);
}
try {
return action.get();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
public void processOrder(Long orderId) {
lockService.executeWithLock(
"order-processing:" + orderId,
Duration.ofSeconds(30),
() -> {
Order order = orderRepository.findById(orderId).orElseThrow();
order.confirm();
orderRepository.save(order);
return null;
});
}
Если Redis недоступен — можно использовать SELECT FOR UPDATE в базе данных:
public Optional<Order> findByIdForUpdate(Long id) {
OrdersRecord record = dsl.selectFrom(ORDERS)
.where(ORDERS.ID.eq(id))
.forUpdate()
.skipLocked()
.fetchOneInto(OrdersRecord.class);
return Optional.ofNullable(record).map(mapper::toDomainOrder);
}
Подводные камни блокировок:
- Взаимная блокировка. Два процесса блокируют ресурсы в разном порядке и ждут друг друга. Решение — всегда блокировать в одном порядке (например, по ID).
- Зависшая блокировка. Процесс взял блокировку, упал, не отпустил. Решение — TTL (автоматическое снятие по таймауту).
- Раздвоение в Redis. Redis перешёл на нового мастера, а старый ещё жив — два процесса держат одну блокировку. Решение — Redlock (блокировка сразу на нескольких независимых нодах Redis).
Как паттерны работают вместе
Реальная система не использует один паттерн в изоляции. Они складываются в цепочку:
OrderService создаёт заказ и сохраняет событие через Outbox — атомарно в одной транзакции. Polling Relay периодически читает таблицу и публикует в Kafka. PaymentService обрабатывает через Idempotent Consumer — дубли безопасны. При конкурентном доступе — Distributed Lock. Весь процесс координирует SAGA с компенсациями, если что-то пошло не так.
Коротко
- 2PC — строгая согласованность через двухфазный протокол; подходит только в рамках одной инфраструктуры, для микросервисов через сеть не применяют.
- SAGA — цепочка локальных транзакций с компенсирующими шагами; оркестрация (центральный координатор) или хореография (события между сервисами).
- Компенсация — это новая бизнес-операция, а не откат базы; обязана быть идемпотентной.
- Transactional Outbox — событие и бизнес-данные в одной транзакции; relay доставляет в брокер с гарантией at-least-once.
- Event Sourcing — хранение истории событий вместо текущего состояния; восстановление через воспроизведение; сочетается с CQRS.
- Idempotent Consumer — таблица обработанных событий защищает от дублей при повторной доставке.
- Distributed Lock — Redis-блокировка (Redisson/Redlock) или
SELECT FOR UPDATEзащищают от конкурентного доступа.
Что почитать дальше
- CQRS — паттерн разделения команд и запросов; естественно сочетается с Event Sourcing.
- Гексагональная архитектура — как изолировать SAGA-оркестратор от инфраструктуры.