Когда приложение разбивают на несколько сервисов, исчезает главное удобство монолита — единая транзакция базы данных. Раньше одна обёртка dataSource.transaction() гарантировала: либо заказ создан, деньги списаны и склад зарезервирован — либо ничего из этого. В микросервисах каждый сервис — отдельная база, отдельный процесс, отдельная сеть. Откатить чужую транзакцию нельзя. Сеть может упасть в самый неудобный момент.
Эта статья разбирает паттерны, которые решают именно эту проблему — один за другим, от простых к сложным.
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 — в конечном счёте всё придёт к согласованности, но не мгновенно.
Оркестрация
Центральный оркестратор знает последовательность шагов и какую компенсацию вызвать при ошибке.
interface SagaStep<C> {
name: string;
action: (ctx: C) => Promise<StepResult>;
compensation: (ctx: C) => Promise<void>;
}
export class CreateOrderSaga {
async execute(request: CreateOrderRequest): Promise<OrderResult> {
const ctx = new SagaContext(request);
const completedSteps: SagaStep<SagaContext>[] = [];
const steps: SagaStep<SagaContext>[] = [
{
name: 'create-order',
action: (c) => this.orderService.create(c.request),
compensation: (c) => this.orderService.cancel(c.orderId),
},
{
name: 'charge-payment',
action: (c) => this.paymentService.charge(c.userId, c.total),
compensation: (c) => this.paymentService.refund(c.paymentId),
},
{
name: 'reserve-inventory',
action: (c) => this.inventoryService.reserve(c.orderId, c.items),
compensation: (c) => this.inventoryService.releaseReservation(c.orderId),
},
];
for (const step of steps) {
try {
const result = await step.action(ctx);
ctx.apply(result);
completedSteps.push(step);
} catch (e) {
await this.compensate(ctx, completedSteps);
throw new SagaError(`Step failed: ${step.name}`, { cause: e });
}
}
return ctx.toResult();
}
}
Плюсы: логика в одном месте, легко отслеживать состояние, просто отлаживать.
Минусы: оркестратор знает про все сервисы — может разрастись в класс, который делает всё на свете.
Хореография
Нет центрального координатора. Каждый сервис слушает события и реагирует, публикуя свои:
// PaymentService реагирует на событие (kafkajs)
await consumer.subscribe({ topic: 'order-events' });
await consumer.run({
eachMessage: async ({ message }) => {
const event: OrderCreatedEvent = JSON.parse(message.value!.toString());
try {
const result = await paymentService.charge(event.userId, event.total);
await eventPublisher.publish(
new PaymentChargedEvent(event.orderId, result.paymentId));
} catch (e) {
await eventPublisher.publish(
new PaymentFailedEvent(event.orderId, (e as Error).message));
}
},
});
Плюсы: сервисы слабо связаны, развиваются независимо.
Минусы: логику саги трудно отследить — она размазана по сервисам. Сложно понять, в каком состоянии находится процесс.
Оркестрация или хореография — что выбрать
Оркестрация лучше, когда шагов больше трёх-четырёх, есть ветвления («если оплата частичная — другой путь»), важна единая точка мониторинга.
Хореография лучше, когда шагов два-три, поток линейный без ветвлений, сервисы разрабатывают разные команды и им важна автономность.
Важно о компенсациях
Компенсация — это не откат базы данных. refund() — это новая бизнес-операция, которая приводит систему к эквиваленту отмены. Деньги возвращаются отдельной транзакцией, а не отменой списания.
Компенсация обязана быть идемпотентной — если вызвать её дважды, результат должен быть одинаковым:
async refund(paymentId: string): Promise<void> {
const payment = await this.paymentRepository.findOneByOrFail({ id: paymentId });
if (payment.status === PaymentStatus.REFUNDED) {
return; // уже возвращено — ничего не делаем
}
await this.paymentGateway.refund(payment.gatewayId);
payment.markRefunded();
await this.paymentRepository.save(payment);
}
Transactional Outbox — атомарная запись данных и событий
Проблема. Типичная ошибка: сохранили заказ в базу, потом отправили событие в Kafka. Между этими двумя операциями процесс упал — заказ есть, событие потеряно. Обратная ситуация тоже плоха: событие ушло, а транзакция откатилась.
// Опасный код — не атомарно!
await orderRepository.save(order); // транзакция зафиксирована
await producer.send(orderCreated); // процесс упал — событие потеряно
Решение. Событие сохраняется в ту же базу данных, в той же транзакции, что и бизнес-данные — в отдельную таблицу 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
);
// Бизнес-код: заказ и событие в одной транзакции (TypeORM)
async createOrder(cmd: CreateOrderCommand): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
const order = await manager.save(newOrder(cmd));
await this.outboxPublisher.save(manager, 'Order', order.id,
'OrderCreated', new OrderCreatedPayload(order));
return order;
});
}
// Relay: периодически отправляет необработанные события
@Interval(500)
async publishPending(): Promise<void> {
const events = await this.outboxRepository.findUnpublished(100);
for (const event of events) {
try {
await this.producer.send({
topic: topicFor(event),
messages: [{ key: event.aggregateId, value: event.payload }],
});
event.markPublished();
} catch {
event.incrementRetryCount();
}
await this.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
Агрегат восстанавливается из событий:
export class Order {
private id!: string;
private status!: OrderStatus;
private readonly uncommittedEvents: DomainEvent[] = [];
private version = 0;
static fromEvents(events: DomainEvent[]): Order {
const order = new Order();
for (const event of events) {
order.apply(event);
order.version++;
}
return order;
}
private apply(event: DomainEvent): void {
switch (event.type) {
case 'OrderCreated':
this.id = event.orderId;
this.status = OrderStatus.CREATED;
break;
case 'PaymentReceived':
this.status = OrderStatus.PAID;
break;
case 'OrderConfirmed':
this.status = OrderStatus.CONFIRMED;
break;
default:
throw new Error(`Unknown event: ${(event as { type: string }).type}`);
}
}
confirm(): void {
if (this.status !== OrderStatus.PAID) {
throw new Error(`Can only confirm PAID orders, current=${this.status}`);
}
this.raise({ type: 'OrderConfirmed', orderId: this.id, confirmedAt: new Date() });
}
private raise(event: DomainEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
}
Проекции для чтения
Event Sourcing разделяет запись и чтение. Данные пишутся в Event Store (только добавление). Для чтения строятся проекции — таблицы, оптимизированные под конкретные запросы. Это естественно сочетается с CQRS.
export class OrderSummaryProjection {
async onOrderCreated(event: OrderCreated): Promise<void> {
await this.repository.save(
new OrderSummary(event.orderId, 'CREATED', event.createdAt));
}
async onOrderConfirmed(event: OrderConfirmed): Promise<void> {
await this.repository.updateStatus(event.orderId, 'CONFIRMED');
}
}
Когда подходит Event Sourcing
Подходит, когда нужна полная история изменений (финансы, аудит, расчёты с продавцами), возможность посмотреть состояние на любой момент времени, или сложная доменная логика с множеством переходов статусов.
Избыточен для простых справочников, CRUD без сложной логики, проектов без требований к истории.
Подводные камни
- Задержка проекций. Проекция обновляется асинхронно — пользователь может не увидеть свои изменения сразу.
- Версионирование событий. Событие
OrderCreated_v1не содержит поляcurrency. В_v2оно появилось. Нужна стратегия миграции старых событий при чтении. - Размер Event Store. Агрегат с тысячами событий — воспроизведение занимает время. Решение — снимки (снэпшоты): периодически сохранять текущее состояние, воспроизводить только от последнего снимка.
Idempotent Consumer — защита от дублей
Проблема. В распределённой системе сообщения приходят повторно: ретраи брокера, перебалансировка группы потребителей, гарантии at-least-once. Обработчик должен давать одинаковый результат при повторной обработке.
Решение. Хранить таблицу обработанных событий. Перед обработкой проверить — не обрабатывали ли уже это событие.
async process<T>(eventId: string, handler: () => Promise<T>): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const alreadyProcessed = await manager.existsBy(ProcessedEvent, { eventId });
if (alreadyProcessed) {
return; // уже обработали — пропускаем
}
await handler();
await manager.save(new ProcessedEvent(eventId, new Date()));
});
}
await consumer.subscribe({ topic: 'payment-events' });
await consumer.run({
eachMessage: async ({ message }) => {
const event: PaymentChargedEvent = JSON.parse(message.value!.toString());
await idempotentProcessor.process(event.eventId, async () => {
const order = await orderRepository.findOneByOrFail({ id: event.orderId });
order.markPaid(event.paymentId);
await orderRepository.save(order);
});
},
});
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. Клиент генерирует уникальный ключ, сервер проверяет, не обрабатывался ли уже запрос с таким ключом:
@Post('payments')
async charge(
@Headers('idempotency-key') idempotencyKey: string,
@Body() request: ChargeRequest,
): Promise<PaymentResult> {
return this.idempotentProcessor.processOrReturn(idempotencyKey, () =>
this.paymentService.charge(request));
}
Distributed Lock — защита от конкурентного доступа
Проблема. Два экземпляра одного сервиса одновременно обрабатывают один заказ. Оба списывают деньги — пользователь платит дважды.
Решение. Распределённая блокировка через Redis: только один экземпляр может держать блокировку на один ресурс одновременно.
async executeWithLock<T>(
lockKey: string,
ttlMs: number,
action: () => Promise<T>,
): Promise<T> {
let lock: Lock;
try {
lock = await this.redlock.acquire([lockKey], ttlMs);
} catch {
throw new LockAcquisitionError(`Cannot acquire lock: ${lockKey}`);
}
try {
return await action();
} finally {
await lock.release();
}
}
async processOrder(orderId: number): Promise<void> {
await this.lockService.executeWithLock(
`order-processing:${orderId}`,
30_000,
async () => {
const order = await this.orderRepository.findOneByOrFail({ id: orderId });
order.confirm();
await this.orderRepository.save(order);
});
}
Если Redis недоступен — можно использовать SELECT FOR UPDATE в базе данных:
async findByIdForUpdate(manager: EntityManager, id: number): Promise<Order | null> {
return manager.getRepository(Order)
.createQueryBuilder('order')
.setLock('pessimistic_write')
.setOnLocked('skip_locked')
.where('order.id = :id', { id })
.getOne();
}
Подводные камни блокировок:
- Взаимная блокировка. Два процесса блокируют ресурсы в разном порядке и ждут друг друга. Решение — всегда блокировать в одном порядке (например, по 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-блокировка (Redlock через node-redlock) или
SELECT FOR UPDATEзащищают от конкурентного доступа.
Что почитать дальше
- CQRS — паттерн разделения команд и запросов; естественно сочетается с Event Sourcing.
- Гексагональная архитектура — как изолировать SAGA-оркестратор от инфраструктуры.