Опирается на правила:
R-DIST-WHEN-1…R-DIST-WHEN-3иR-DIST-WHEN-X1…R-DIST-WHEN-X2→ раздел 1. Когда нужны распределённые паттерны.
Важно знать
- Распределённые паттерны нужны, когда бизнес-операция охватывает 2+ сервиса и нельзя завершить её одной
pgx.Tx.- В пределах одного сервиса с одним PostgreSQL — явная
pgx.Txчерезpgxpool.Poolи атомарность БД, никаких саг и outbox.- В Go нет транзакционного менеджера в стиле Spring: транзакция — явный
pgx.Tx, пробрасываемый черезcontext.Contextили параметр.- Перед введением distributed-сложности проверь три альтернативы: объединение в один BC, modular monolith с одним
pgxpool.Pool, eventual consistency без саги.- Распределение всегда дорогое: больше latency, сложнее debugging, нужен distributed tracing, появляются новые failure modes.
- Микросервисы из амбиций — без бизнес-требования — главный источник лишней сложности на Go-проекте.
- Если два сервиса всегда меняются вместе — это один Bounded Context, разделение было ошибкой.
Saga, outbox, idempotent consumer — инструменты для cross-service сценариев. У каждого есть цена: дополнительные горутины, дополнительные таблицы, дополнительные failure modes. Применяем когда бизнес действительно требует разнесения по сервисам, а не «потому что так принято».
Три условия, при которых нужны распределённые паттерны
R-DIST-WHEN-1: распределённые паттерны нужны, когда выполняется одно из следующих условий.
Операция охватывает 2+ сервиса. «Создать заказ» = order-service + payment-service + inventory-service. Каждый шаг — отдельная локальная транзакция в своём PostgreSQL; объединить в одну pgx.Tx невозможно физически.
Операция охватывает 2+ датасорса. Перевод между счетами в разных банках — у каждого свой pgxpool.Pool, pgx.Tx из одного пула не охватывает другой.
Операция требует cross-service побочного эффекта. Изменение статуса заказа → нотификация. Если notification-service лежит, заказ всё равно должен обновиться — нужны outbox + retry, иначе сцепка ломается.
// internal/saga/order_saga.go
// Три сервиса, три pgx.Pool. Одной pgx.Tx, охватывающей все три, не существует.
func (o *OrderSagaOrchestrator) Start(ctx context.Context, tx pgx.Tx, cmd StartOrderSagaCommand) (uuid.UUID, error) {
sagaID := uuid.New()
qtx := o.queries.WithTx(tx)
if err := qtx.InsertOrderSaga(ctx, db.InsertOrderSagaParams{
SagaID: sagaID,
OrderID: cmd.OrderID,
Status: string(SagaPaymentPending),
CurrentStep: "reserve_payment",
Payload: cmd.Payload,
}); err != nil {
return uuid.Nil, fmt.Errorf("insert order saga: %w", err)
}
return sagaID, o.outbox.Write(ctx, tx, OutboxMessage{
SagaID: sagaID,
Topic: "payment.commands",
Payload: ReservePaymentCommand{SagaID: sagaID, OrderID: cmd.OrderID, Amount: cmd.Amount},
})
}
Если ни одно из трёх условий не выполняется — distributed-паттерны не нужны.
Когда распределённые паттерны НЕ нужны
R-DIST-WHEN-2: если операция в одном сервисе и одном PostgreSQL — явная pgx.Tx через pgxpool.Pool и атомарность БД.
// core/order/confirm_order_handler.go
type ConfirmOrderHandler struct {
pool *pgxpool.Pool
queries *db.Queries
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) (*db.Order, error) {
tx, err := h.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
qtx := h.queries.WithTx(tx)
order, err := qtx.GetOrderForUpdate(ctx, cmd.OrderID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, &OrderNotFoundError{ID: cmd.OrderID}
}
return nil, fmt.Errorf("get order for update: %w", err)
}
updated, err := qtx.UpdateOrderStatus(ctx, db.UpdateOrderStatusParams{
OrderID: cmd.OrderID,
Status: "confirmed",
})
if err != nil {
return nil, fmt.Errorf("update order status: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &updated, nil
}
Здесь сага была бы карго-культом: одна pgx.Tx атомарна, никакая compensation не нужна, никакой outbox для синхронизации внутри одного сервиса не нужен. Если потом понадобится опубликовать OrderConfirmed в Kafka — outbox добавится именно для публикации, не для атомарности внутри сервиса.
Три альтернативы перед введением distributed-сложности
R-DIST-WHEN-3: прежде чем вводить saga, outbox и idempotency, проверь три альтернативы.
1. Объединение сервисов
Если два сервиса всегда меняются вместе, всегда деплоятся вместе, всегда обсуждаются вместе — это один Bounded Context, разделение было ошибкой. Пример: customer-service хранит профиль, customer-preferences-service хранит настройки уведомлений. Если ни один use case не работает с одним без другого — объединить, никаких саг и outbox не нужно.
2. Modular monolith
Несколько Bounded Context в одном Go-бинаре и одном PostgreSQL, логически разделённых: разные пакеты, разные sqlc-схемы, явные интерфейсы между модулями. Одна pgxpool.Pool работает через границы модулей, distributed-сложность не нужна.
order-service (modular monolith):
├── core/orders/ (BC Order)
├── core/payments/ (BC Payment, схема payments в том же PG)
└── core/inventory/ (BC Inventory, схема inventory в том же PG)
// ConfirmOrderHandler использует одну pgx.Tx
tx, _ := pool.Begin(ctx)
defer tx.Rollback(ctx)
qtx := queries.WithTx(tx)
qtx.UpdateOrderStatus(ctx, ...) // orders-пакет
qtx.ChargePayment(ctx, ...) // payments-пакет, та же транзакция
qtx.ReserveInventoryItems(ctx, ...) // inventory-пакет, та же транзакция
tx.Commit(ctx)
Когда команды вырастают и BC начинают деплоиться независимо — отделяются в свои бинары, тогда появляются саги.
3. Eventual consistency без саги
Если нет необходимости в rollback'ах — нет саги. Достаточно: write в исходный сервис → outbox → событие → read-side обновление. Пример: OrderConfirmed → notification-service отправляет SMS. Если SMS не отправится — заказ всё равно подтверждён, никакой compensation не нужно, проблема решается retry на стороне consumer.
// adapters/in/kafka/consumer.go — notification-service просто применяет событие
func (c *NotificationConsumer) handleOrderConfirmed(ctx context.Context, tx pgx.Tx, event OrderConfirmedEvent) error {
qtx := c.queries.WithTx(tx)
return qtx.InsertNotificationTask(ctx, db.InsertNotificationTaskParams{
CustomerID: event.CustomerID,
Type: "order_confirmed",
OrderID: event.OrderID,
})
}
| Сценарий | Нужна сага? |
|---|---|
| Создать заказ + списать оплату + зарезервировать товар | Да — нужна compensation при сбое |
| Изменить статус заказа + отправить уведомление | Нет — события + retry |
| Создать пользователя + профиль + настройки в одной БД | Нет — одна pgx.Tx |
| Перевод между счетами разных банков | Да — нужна compensation |
Обновить Product + отправить событие ProductUpdated | Нет — outbox в той же транзакции |
Что запрещено
Распределённые паттерны для одного сервиса
R-DIST-WHEN-X1: saga для двух операций в одной БД — self-orchestrated сложность. Если все шаги — INSERT/UPDATE в одном PostgreSQL, явная pgx.Tx атомарна и никакая compensation не нужна.
// ПЛОХО — «сага» для двух INSERT в одной БД
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
if err := h.queries.InsertOrder(ctx, ...); err != nil {
return err
}
if err := h.queries.InsertOrderItems(ctx, ...); err != nil {
h.queries.DeleteOrder(ctx, cmd.OrderID) // ручной «rollback» — лишний
return err
}
return nil
}
// ПРАВИЛЬНО — одна pgx.Tx откатит обе вставки при ошибке
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
tx, err := h.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
qtx := h.queries.WithTx(tx)
if err := qtx.InsertOrder(ctx, ...); err != nil {
return fmt.Errorf("insert order: %w", err)
}
if err := qtx.InsertOrderItems(ctx, ...); err != nil {
return fmt.Errorf("insert order items: %w", err)
}
return tx.Commit(ctx)
}
Микросервисы из амбиций
R-DIST-WHEN-X2: распределение всегда дорогое:
- Latency увеличивается на каждый сетевой hop: 1ms
pgx-запрос превращается в 20–50ms HTTP-вызов через chi-роутер соседнего сервиса. - Debugging сложнее: нужен distributed tracing,
slogсtrace_id/span_id, OpenTelemetry. - Failure modes появляются новые: timeout, partial failure, retry storm, network partition.
- Транзакционность теряется: невозможно атомарно обновить два сервиса через одну
pgx.Tx, нужны saga + compensation.
Если бизнес не требует разделения — лучше modular monolith. «Мы хотим микросервисы потому что так делают все» — не аргумент. Аргумент — «команды по 6–8 человек на каждый сервис, разный deploy cadence, разные SLA».
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Saga для операций в одном PostgreSQL | R-DIST-WHEN-X1 | явная pgx.Tx |
Ручной «rollback» через DELETE вместо tx.Rollback | R-DIST-WHEN-X1 | defer tx.Rollback(ctx) |
| Микросервисы без бизнес-требования | R-DIST-WHEN-X2 | modular monolith с одним pgxpool.Pool |
| Outbox для событий внутри одного сервиса | R-DIST-WHEN-X1 | прямой вызов в той же pgx.Tx |
| Разделение тесно связанных BC без независимого деплоя | R-DIST-WHEN-3 | объединение в один BC |
Куда дальше
- Saga — оркестрация vs хореография — главный паттерн для cross-service flow в Go.
- Idempotency — обязательное условие для любой cross-service интеграции.
- Outbox + Inbox — как опубликовать событие атомарно с write в БД через outbox-writer.
- Компенсационные транзакции — semantic compensation через отдельную команду, не
DELETE. - Eventual consistency — bounded staleness, read-your-writes, causal consistency на Go.
- Distributed transactions — что НЕ делать — почему последовательный commit по двум
pgx.Txне работает.