← назад к разделу

Когда приложение разбивают на несколько сервисов, исчезает главное удобство монолита — единая транзакция базы данных. Раньше одна команда @Transactional гарантировала: либо заказ создан, деньги списаны и склад зарезервирован — либо ничего из этого. В микросервисах каждый сервис — отдельная база, отдельный процесс, отдельная сеть. Откатить чужую транзакцию нельзя. Сеть может упасть в самый неудобный момент.

Эта статья разбирает паттерны, которые решают именно эту проблему — один за другим, от простых к сложным.

Two-Phase Commit (2PC) — двухфазная фиксация

Проблема. Нужно, чтобы три сервиса либо все зафиксировали изменения, либо все откатились. Как это скоординировать?

Идея. Вводится координатор, который управляет процессом из двух фаз:

  1. Prepare (голосование): координатор спрашивает каждого участника «готов зафиксировать?». Участник выполняет всю работу — блокирует ресурсы, пишет в журнал — но не фиксирует. Отвечает «да» или «нет».
  2. Commit или Rollback (решение): если все ответили «да» — координатор говорит «фиксируй». Если хоть один ответил «нет» — все откатываются.
diagram

Почему 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();
    }
}

Плюсы: логика в одном месте, легко отслеживать состояние, просто отлаживать.

Минусы: оркестратор знает про все сервисы — может разрастись в класс, который делает всё на свете.

Хореография

Нет центрального координатора. Каждый сервис слушает события и реагирует, публикуя свои:

diagram
// 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) периодически читает эту таблицу и публикует события в брокер.

diagram
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
diagram

Агрегат восстанавливается из событий:

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).

Как паттерны работают вместе

Реальная система не использует один паттерн в изоляции. Они складываются в цепочку:

diagram

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-оркестратор от инфраструктуры.