voidlearn
 

11.3Межсервисное взаимодействие

Разбери, почему транзакция не пересекает процессную границу, и выбери синхронный вызов, outbox/inbox, сагу или событие по требованиям сценария. Пойми, где подтверждение может потеряться, какие шаги повторятся и как сохранить совместимость схемы сообщения.

Синхронное или асинхронное: не вкус, а свойство сценария

Синхронное взаимодействие (HTTP, gRPC) устроено как запрос-ответ: вызывающий блокируется и ждёт. В асинхронном сервис публикует сообщение в брокер и идёт дальше, а получатель обработает его когда-нибудь потом. Разница выглядит как разница транспортов, но на деле это разница в том, кто кому обязан быть живым.

При синхронном вызове сервис A не может выполнить свою работу, если сервис B недоступен. Это временная связность: A и B обязаны быть живыми одновременно. При асинхронном A обязан быть живым только вместе с брокером; B может лежать час, и это задержит обработку, но не сломает запрос клиента. Ровно поэтому вопрос «синхронно или асинхронно» на самом деле звучит так: «нужен ли клиенту результат B прямо сейчас».

Арифметика доступности: почему цепочки убивают

Если запрос клиента проходит через N сервисов последовательно и любой из них может ответить ошибкой, доступности перемножаются. Это самая полезная формула на собесе, потому что она превращает архитектурный спор в числа.

ЦепочкаРасчётИтогПростой в месяц
1 сервис 99.9 %0.99999.90 %~43 мин
3 сервиса по 99.9 %0.9993 = 0.997099.70 %~2 ч 10 мин
5 сервисов по 99.9 %0.9995 = 0.995099.50 %~3 ч 36 мин
10 сервисов по 99.9 %0.99910 = 0.990099.00 %~7 ч 12 мин
10 сервисов по 99.99 %0.999910 = 0.999099.90 %~43 мин

Читается это так: чтобы система из десяти звеньев держала три девятки, каждое звено должно держать четыре. Один порядок надёжности «съедается» самим фактом цепочки. И это ещё оптимистичный расчёт: он не учитывает латентность. Если у каждого сервиса p99 = 50 мс, у цепочки из пяти p99 окажется примерно в два с половиной раза хуже одного звена, около 130 мс: хвосты редко совпадают, поэтому сумма p99 остаётся верхней оценкой, а не результатом. Зато вероятность зацепить чей-нибудь хвост растёт с длиной цепочки: 1 - 0.99^5 = 4.9 % запросов вместо 1 %.

Синхронная цепочка: доступность клиента = произведение доступностей client svc-order svc-billing svc-stock svc-notify 99.9 % 99.9 % 99.9 % 99.9 % 0.999 в четвёртой = 99.6 % - почти 3 часа простоя в месяц вместо 43 минут Асинхронная публикация: клиенту нужен только свой сервис и брокер client svc-order broker svc-billing svc-stock svc-notify ответ клиенту здесь упал потребитель - растёт лаг, а не 500-е для клиента
Где заканчивается ответственность за ответ клиенту. В синхронной цепочке клиент ждёт всех и зависит от всех. В асинхронной клиент ждёт только запись в свою БД и публикацию события, а отказ потребителя превращается из ошибки в отставание.

Критерии выбора

Признак сценарияСинхронноАсинхронно
Клиенту нужен результат в этом же HTTP-ответеДаНет
Ответ влияет на дальнейшее ветвление логики прямо сейчасДаНет
Операция - «сообщить, что факт произошёл»НетДа
Получателей несколько и список меняетсяНет (N вызовов)Да (одно событие)
Работа тяжёлая: отчёт, письмо, конвертация видеоНетДа
Нужен строгий порядок и немедленная консистентностьДаНет (eventual)
Пик нагрузки надо сгладитьНетДа (очередь как буфер)
Нужна простая отладка и понятный стектрейсДаНет
Практическое правило

Читаем синхронно, побочные эффекты пишем асинхронно. Проверить остаток на счёте перед оплатой надо синхронно, иначе решение принимать не на чем. Отправить письмо, обновить поисковый индекс, начислить бонусы, уведомить склад можно асинхронно: клиенту эти результаты в ответе не нужны, а связывать с ними доступность оформления заказа уже вредительство.

Подвох: «асинхронно» не значит «надёжно»

Часто считают, что переход на брокер решает проблему надёжности. Он её перемещает: теперь надо гарантировать, что событие вообще опубликовано (запись в БД и публикация живут в двух разных системах, общей транзакции между ними нет - отсюда outbox), что оно обработано хотя бы раз (ack после обработки), что повтор не сломает данные (идемпотентность), и что порядок там, где он важен, сохранён (ключ партиции). Асинхронность меняет отказы на сложность и задержку, а не даёт выигрыш даром.

Третий вариант, о котором часто забывают

Между «ждать» и «выстрелить и забыть» есть асинхронный запрос с колбэком/поллингом: клиент делает POST /reports, мгновенно получает 202 Accepted и Location: /reports/{id}, дальше опрашивает статус или получает webhook. Это синхронный по смыслу сценарий («мне нужен результат»), реализованный без временной связности.

Событийная архитектура: три разных вида «события»

Слово «событие» на собесе используют минимум в трёх разных смыслах, и путаница между ними даёт половину неудачных ответов. Разница в том, сколько состояния несёт сообщение и кто здесь источник истины.

1. Event notification - уведомление

Сообщение говорит «произошёл факт» и почти ничего не несёт: идентификатор сущности, тип события, время. Потребитель, если ему нужны детали, идёт обратно в сервис-издатель синхронным запросом.

// Event notification: только сам факт и ссылка
type OrderPlaced struct {
    EventID    string    `json:"event_id"`   // для дедупликации у потребителя
    OrderID    string    `json:"order_id"`
    OccurredAt time.Time `json:"occurred_at"`
    Version    int       `json:"version"`     // версия схемы события
}
// За подробностями потребитель идёт в GET /orders/{id}
  • Плюс: сообщения крошечные, схема почти не меняется, нет дублирования данных.
  • Минус: обратный синхронный вызов возвращает временную связность и нагрузку на издателя: 1 событие × 5 потребителей = 5 запросов «дай подробности».
  • Минус: гонки. Потребитель может прочитать состояние новее, чем то, что было в момент события, или, наоборот, попасть в реплику с отставанием и увидеть старое.

2. Event-carried state transfer - событие с данными

Событие несёт всё нужное потребителям состояние. Потребитель складывает у себя локальную копию (кэш, денормализованную таблицу) и живёт полностью автономно: издатель может лежать, а потребитель продолжает отвечать на запросы.

// ECST: потребителю не нужно ходить обратно
type OrderPlaced struct {
    EventID    string      `json:"event_id"`
    OrderID    string      `json:"order_id"`
    UserID     string      `json:"user_id"`
    UserEmail  string      `json:"user_email"`   // копия данных пользователя
    Items      []OrderItem `json:"items"`
    TotalCents int64       `json:"total_cents"`
    Currency   string      `json:"currency"`
    OccurredAt time.Time   `json:"occurred_at"`
    Version    int         `json:"version"`
}
  • Плюс: полная развязка во времени, нет обратных вызовов, потребитель переживает падение издателя.
  • Минус: данные дублируются и устаревают; на такую eventual consistency идут осознанно.
  • Минус: схема события становится публичным контрактом и её больно менять; сообщения растут, брокер и сеть нагружаются сильнее.
  • Минус: поменяв логику, нельзя «перечитать историю», потому что старые события несут старый набор полей.

3. Event sourcing - событие как источник истины

Здесь события и есть единственное хранимое состояние. Текущее состояние сущности не хранится вовсе: его собирают заново, проигрывая (replay) все её события от начала. Таблица accounts с колонкой balance исчезает, остаётся журнал Deposited(100), Withdrawn(30), Deposited(50), а баланс 120 получается свёрткой журнала.

Проще всего думать про банковскую выписку. Банк не хранит просто число «на счету 120 рублей», он хранит список операций, а число получается их сложением. Выписку нельзя «поправить»: ошибочный платёж отменяют не стиранием строки, а новой строкой «возврат». Так же устроена запись шахматной партии - хранится список ходов, а не расстановка фигур, и позицию на любой момент всегда можно получить, проиграв ходы с начала.

// Сворачиваем журнал событий в текущее состояние
func (a *Account) Apply(e Event) {
    switch ev := e.(type) {
    case Deposited:
        a.Balance += ev.Amount
    case Withdrawn:
        a.Balance -= ev.Amount
    case Frozen:
        a.Frozen = true
    }
    a.Version++
}

func Load(events []Event) *Account {
    a := &Account{}
    for _, e := range events { // длинную историю читаем от снапшота
        a.Apply(e)
    }
    return a
}

Цена event sourcing высока, и складывается она так: миграция схемы старых событий (их нельзя «поправить ALTER-ом», они уже случились), снапшоты для сущностей с тысячами событий, обязательный CQRS для любых нетривиальных выборок: изменения уходят в журнал, а запросы обслуживают проекции, построенные под конкретные экраны. Нужен он не из красоты, а по нужде: по журналу событий нельзя сделать WHERE status = 'paid' ORDER BY total - там нет ни колонки status, ни строк, по которым можно отфильтровать. Дальше идёт непривычная отладка, а людей с опытом эксплуатации на рынке почти нет. И сверху GDPR: любое удаление данных ломается о неизменяемость журнала, приходится городить крипто-удаление.

Audit log - почему его путают с event sourcing

Аудит-лог пишет дополнительную запись «кто, что, когда изменил» рядом с обычной таблицей состояния. При event sourcing таблицы состояния нет вообще. Внешне похоже, разница принципиальная: аудит-лог можно потерять, и система продолжит работать; журнал событий потерять нельзя - вместе с ним теряются данные. Если на собесе спрашивают «нам нужна история изменений - брать event sourcing?», правильный ответ почти всегда: «нет, возьмём аудит-лог или temporal-таблицы, это на два порядка дешевле».

Event notificationEvent-carried stateEvent sourcing
Размер сообщенияДесятки байтКилобайтыКилобайты
Обратный вызов к издателюНуженНе нуженНе нужен
Источник истиныБД издателяБД издателяСам журнал
Дублирование данныхНетДа, осознанноДа (проекции)
Хрупкость контрактаНизкаяВысокаяМаксимальная
Хранение событийМожно чиститьМожно чиститьВечно
Когда братьМного потребителей, детали редко нужныПотребителю нужна автономностьИстория - часть домена (финансы, аудит, версии)
Глубже: событие против команды

Событие называют в прошедшем времени и адресуют «всем, кого касается»: OrderPlaced, PaymentCaptured. Команду пишут в повелительном наклонении и шлют конкретному получателю: ReserveSeat, ChargeCard. Издатель события не знает своих подписчиков и не отвечает за их работу; отправитель команды знает адресата и ждёт, что тот её выполнит. Если сообщение называется SendEmailEvent, перед тобой команда, переодетая событием, и связность в системе на самом деле осталась.

Почему «просто транзакция» между сервисами не работает

В монолите сценарий «списать деньги + занять место + забронировать отель» укладывается в один BEGIN … COMMIT. Атомарность обеспечивает СУБД: она держит блокировки, пишет WAL и умеет откатить всё разом. Как только эти три шага живут в трёх сервисах с тремя базами, механизма, который дал бы то же самое, просто нет:

  • Нет общего менеджера транзакций. Транзакция живёт как состояние внутри одного экземпляра СУБД: список блокировок, undo/redo, снапшот MVCC. Postgres сервиса A физически не знает, что MySQL сервиса B вообще существует, и не может держать его строки заблокированными.
  • Границы транзакции нельзя протянуть через сеть. HTTP-вызов в транзакции не участвует. Вызвали POST /payments, он вернул 200 - деньги уже списаны и закоммичены на той стороне. Своим ROLLBACK ты это не отменишь.
  • Сеть даёт третий исход. В локальной транзакции результат бинарный: закоммитилось или нет. В сети есть неизвестность: таймаут не отвечает на вопрос, выполнилась операция или нет. Именно это ломает всю логику «если ошибка - откатываем».
  • Блокировки становятся распределёнными. Даже если бы протокол существовал, он держал бы строки заблокированными на всё время сетевых раундтрипов, а это на три-четыре порядка дольше локальной транзакции. Пропускная способность обрушится.
  • Часть участников вообще не базы. Платёжный шлюз, отправка SMS, вызов внешнего API партнёра не умеют ни prepare, ни rollback. Списанные деньги нельзя «откатить», их можно только вернуть новой операцией.
Как это сформулировать

«ACID между сервисами недостижим не потому, что мы поленились, а потому что атомарность требует общего менеджера блокировок, а он несовместим с автономностью сервисов и с сетью. Поэтому вместо атомарности мы берём атомарность на шаг плюс механизм, который доводит сценарий до одного из двух конечных состояний. Так появляется сага.»

Сага: оркестрация против хореографии

В быту механика ровно та же. Ты собираешь поездку через три независимые конторы: покупаешь билет на самолёт, бронируешь отель, берёшь машину напрокат. Общей кнопки «отменить всё» не существует - у каждой конторы своя касса и свои правила. Если на третьем шаге машин не оказалось, ты не «откатываешь» первые два шага, а делаешь ещё два действия: сдаёшь билет и снимаешь бронь. Каждое из них проходит отдельной операцией и оставляет след: в выписке будет и списание, и возврат, а не пустота.

Это и есть сага. Сага склеивает несколько шагов в разных сервисах: каждый шаг фиксируется сразу и насовсем, а на случай неудачи для него заранее написано компенсирующее действие, обратная операция, которая отменяет эффект по смыслу, а не стирает факт. Слово взято из статьи 1987 года Гарсиа-Молины и Салема про длинные транзакции в БД; к микросервисам приём приспособили позже.

Формально сага складывается из локальных транзакций, каждая атомарна в своём сервисе, плюс компенсация для каждой. Сага не даёт изоляции: промежуточные состояния видны наружу. Она гарантирует только одно: сценарий не застрянет посередине навсегда - либо все шаги выполнены, либо все выполненные скомпенсированы.

Склеить шаги между собой можно двумя способами, и разница между ними такая же, как между руководителем проекта и эффектом домино.

  • Оркестрация. Есть отдельный сервис-дирижёр, оркестратор. Он держит у себя весь сценарий целиком, по очереди говорит участникам «сделай это», ждёт ответа и решает, что дальше. Участники друг о друге не знают вообще: каждый выполняет команду и отчитывается дирижёру. Слово пришло от «оркестра»: музыканты вступают по взмаху палочки.
  • Хореография. Дирижёра нет. Каждый сервис просто публикует событие «я своё сделал», а следующий подписан на это событие и включается сам. Команд никто не отдаёт - все знают свою партию и момент вступления. Отсюда и слово: танцоры на сцене двигаются без указаний, потому что роли разучены заранее.

Главное различие видно сразу: при оркестрации сценарий записан в одном месте и читается сверху вниз; при хореографии он не записан нигде - чтобы узнать, что произойдёт после оплаты, надо обойти все сервисы и посмотреть, кто на что подписан.

Оркестрация: сценарий и его состояние живут в одном месте svc-payment списано 12 400 руб svc-tickets место 14C занято svc-hotels FAIL: нет номеров 1 ChargeCard 2 ReserveSeat 3 BookRoom saga orchestrator state = COMPENSATING компенсации, обратный порядок видно из одной точки: где сага, что уже сделано, что осталось откатить Хореография: центра нет, каждый реагирует на событие соседа svc-payment ok svc-tickets ok svc-hotels FAIL PaymentCaptured SeatReserved HotelBookingFailed - освободи место SeatReleased - верни деньги чтобы понять сценарий целиком, надо прочитать код всех трёх сервисов
Два способа склеить шаги. Оркестратор рассылает команды и хранит состояние саги; в хореографии каждый сервис подписан на события предыдущего и сам решает, что делать. Компенсации в обоих случаях идут в обратном порядке.
КритерийОркестрацияХореография
Где живёт сценарийВ одном сервисе-оркестраторе, читается сверху внизРазмазан по подписчикам, целиком нигде не записан
СвязностьОркестратор знает всех участников (но участники не знают друг друга)Никто не знает никого - только имена событий
Добавить шагПравка одного сервисаНовый подписчик, издателя не трогаем
Понять «где застряло»Один SELECT по таблице сагСобирать по логам и трейсам всех сервисов
КомпенсацииЯвный обратный проход, порядок под контролемКаскад «failed»-событий, порядок надо проектировать
РискОркестратор превращается в божественный объект и SPOF-логикиЦиклы событий, «а кто это вызвал?», непредсказуемые каскады
Кол-во участниковХорошо до 8–10 шаговХорошо на 2–3 шагах, дальше не читается
Когда братьБизнес-процесс с ветвлениями, таймаутами, ручным разборомПростая линейная реакция: «заказ оформлен - начисли бонусы»
Как отвечать про выбор

«Хореографию беру по умолчанию для двух-трёх шагов без ветвлений: она дешевле и не создаёт центральной зависимости. Как только появляются условия, таймауты, ручное вмешательство оператора или больше трёх участников - перехожу на оркестрацию, потому что процесс становится бизнес-сущностью, у которой должно быть имя, состояние и владелец. Гибрид тоже нормален: оркестратор ведёт основной сценарий, а побочные эффекты вроде уведомлений живут на хореографии.»

Пример: «оплата + бронь билета + отель»

ШагЛокальная транзакцияКомпенсацияПочему это не откат
T1Списать 12 400 руб с картыC1: вернуть 12 400 руб (refund)Списание уже прошло через банк и видно в выписке; refund - новая проводка, а не исчезновение старой. Комиссия эквайринга не возвращается
T2Занять место 14C, статус RESERVEDC2: освободить место, статус AVAILABLEПока место было занято, другой пассажир его не увидел и купил соседнее - этот эффект не отменяется
T3Забронировать номер в отеле-Упал: компенсировать нечего, шаг не выполнился

Порядок тут важен: компенсации идут в обратном порядке шагов, сначала освобождаем место, потом возвращаем деньги. Это не формальность: если вернуть деньги первым, а освободить место не получится, система останется с занятым местом без оплаты - то есть с реальной потерей выручки. Обратный порядок оставляет систему в состоянии «оплачено, но не выдано»: неприятно, но восстановимо, и такое обычно уже покрыто процессом ручного разбора.

Почему компенсация не rollback

Rollback стирает следы: после отката никто никогда не узнает, что транзакция была. Компенсация запускает новую бизнес-операцию, которая семантически отменяет предыдущую, и обе остаются в истории. Письмо «ваш заказ принят» уже улетело, и компенсация не выдернет его из почтового ящика: она пошлёт второе, «заказ отменён». Отсюда следствие: сага не даёт изоляции (I в ACID). Между T1 и C1 наружу видно промежуточное состояние, и другие процессы могут на нём принять решения. Это называется грязным чтением саги, и лечится оно не технически, а доменно: статусами (PENDING, CONFIRMED), семантическими блокировками (место RESERVED на 10 минут) и правилом «не показывать наружу то, что ещё не подтверждено».

Персистентное состояние саги: конечный автомат

Оркестратор без состояния бессмыслен: если он упадёт между шагами, никто не узнает, что деньги списаны, а место не занято. Поэтому сага живёт как строка в базе данных оркестратора, которая обновляется в той же локальной транзакции, что и решение о следующем шаге. Формально это конечный автомат.

CREATE TABLE saga_instances (
    saga_id      uuid PRIMARY KEY,
    saga_type    text        NOT NULL,          -- 'book_trip'
    state        text        NOT NULL,          -- см. автомат ниже
    step         smallint    NOT NULL DEFAULT 0,
    payload      jsonb       NOT NULL,          -- вход + накопленные id операций
    attempts     smallint    NOT NULL DEFAULT 0,
    last_error   text,
    next_retry_at timestamptz,                  -- для повторов и таймаутов
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);
-- воркеры разбирают «зависшие» саги
CREATE INDEX ON saga_instances (state, next_retry_at)
    WHERE state NOT IN ('COMPLETED', 'FAILED_COMPENSATED', 'NEEDS_ATTENTION');
type State string

const (
    Started        State = "STARTED"
    Charging       State = "CHARGING"          // ждём ответа платёжного
    Charged        State = "CHARGED"
    ReservingSeat  State = "RESERVING_SEAT"
    SeatReserved   State = "SEAT_RESERVED"
    BookingHotel   State = "BOOKING_HOTEL"
    Completed      State = "COMPLETED"         // терминальное: успех
    Compensating   State = "COMPENSATING"
    Compensated    State = "FAILED_COMPENSATED" // терминальное: чисто откатились
    NeedsAttention State = "NEEDS_ATTENTION"   // терминальное: зовём человека
)

// Шаг саги = ровно одна локальная транзакция оркестратора:
// прочитали состояние -> решили -> записали новое состояние + команду в outbox.
func (o *Orchestrator) advance(ctx context.Context, id uuid.UUID) error {
    return o.db.InTx(ctx, func(tx *sql.Tx) error {
        s, err := loadForUpdate(tx, id) // SELECT ... FOR UPDATE: один воркер на сагу
        if err != nil {
            return err
        }
        switch s.State {
        case Charged:
            s.State = ReservingSeat
            enqueueCommand(tx, s.ID, "tickets.ReserveSeat", s.Payload)
        case SeatReserved:
            s.State = BookingHotel
            enqueueCommand(tx, s.ID, "hotels.BookRoom", s.Payload)
        // ... остальные переходы
        }
        return save(tx, s)
    })
}

Три свойства этого автомата стоит назвать вслух: переходы идемпотентны (повторный ответ от сервиса на тот же шаг не двигает сагу дважды, потому что мы сравниваем текущее состояние с ожидаемым), у каждого «ожидающего» состояния есть таймаут (next_retry_at: если ответа нет 30 секунд, шаг повторяется, а после N попыток сага уходит в компенсацию), и есть терминальные состояния, из которых нет выхода - иначе сага будет крутиться вечно.

Что делать, если упала сама компенсация

Компенсация идёт обычным сетевым вызовом и падает так же, как основной шаг. Ответ складывается из четырёх уровней:

  1. Компенсации обязаны быть ретраебельными и идемпотентными. «Вернуть деньги по операции X» можно вызывать сто раз - возврат произойдёт один. Делается это ключом идемпотентности saga_id + step.
  2. Ретраи с экспоненциальной задержкой и без верхнего предела по количеству, но с ограничением по времени. В отличие от основного шага, компенсацию нельзя «просто бросить»: система уже в несогласованном состоянии, и выйти из него можно только одним способом - довести компенсацию до конца.
  3. Проектировать компенсации так, чтобы они не могли упасть по бизнес-причине. «Освободить место» не может вернуть «нельзя» - это действие возможно всегда. Если компенсация способна получить логический отказ (например, «нельзя отменить: билет уже использован»), значит, шаги стоят в неправильном порядке: необратимые действия ставят последними. Правило дизайна саг звучит так: сначала всё, что можно откатить, в конце то, что нельзя.
  4. Терминальное состояние NEEDS_ATTENTION + алерт. Когда ретраи исчерпаны, сага не «теряется», а попадает в очередь ручного разбора: дашборд, алерт дежурному, ручные действия оператора. Кандидат, который признаёт, что часть случаев разбирает человек, показывает эксплуатационный опыт. В финтехе это отдельный процесс со своим SLA.
На чём ловят

Три классических провала. Первый: «сага откатит всё как транзакция». Нет, изоляции нет и промежуточные состояния видны. Второй: сага без персистентного состояния, «оркестратор просто вызывает по очереди», а при рестарте пода такая сага исчезает вместе с памятью. Третий: забыли про таймауты. Если сервис не ответил ни успехом, ни ошибкой, сага должна сама проснуться и решить, что делать, иначе она зависнет навсегда в состоянии CHARGING, а деньги у клиента уже списаны.

Вопросы

10
Суть: вопрос не «что современнее», а нужен ли вызывающему ответ прямо сейчас, чтобы продолжить. Если нужен - синхронно, и ты платишь доступностью. Если не нужен - асинхронно, и ты платишь консистентностью во времени и сложностью отладки.

Тут два разных измерения, которые постоянно смешивают. Первое отвечает за синхронность вызова: блокируется ли вызывающий до ответа. Второе смотрит на направление знания: знает ли отправитель, кто его слушает. Можно сделать асинхронный запрос-ответ через очередь (вызывающий всё равно логически ждёт ответ) и можно сделать синхронную публикацию через HTTP в шину. Настоящая развязка появляется, только когда выполняются оба: вызывающий не ждёт и не знает получателей.

Критерии, которые стоит проговорить вслух

КритерийСинхронно (HTTP/gRPC)Асинхронно (брокер)
Нужен ответ для продолженияда - цена товара, доступность остатка, результат авторизациинет - «уведоми», «пересчитай», «проиндексируй»
Доступностьперемножается по цепочкеограничена своей БД и брокером
Латентность для клиентасумма всех хоповтолько свой хоп
Пики нагрузкипрокатываются насквозь до самого слабогогасятся очередью, растёт лаг, а не 5xx
Консистентностьближе к «сразу»eventual, окно рассинхрона видно наружу
Отладкаодин трейс, понятная причинанужен distributed tracing и correlation id
Обратное давлениетаймаут и ошибкалаг консьюмера, метрика вместо инцидента

Как это связано со связностью

Синхронный вызов даёт временную связность (temporal coupling): чтобы сервис A работал, сервис B должен быть жив прямо в эту миллисекунду. Плюс связность по адресу: A знает, что B существует и как его найти. Событие снимает временную связность полностью - потребитель может лежать час, события подождут в топике. Но взамен приходит связность по схеме: все потребители завязаны на формат события, и его изменение задевает всех сразу. Поэтому формулировка «асинхронно = развязано» неполная: связность не исчезает, она переезжает из времени в контракт.

Практическое правило, которое хорошо звучит на собесе

Запросы шлём синхронно, факты публикуем асинхронно. Если A нужно узнать что-то, чем владеет B, чтобы принять решение прямо сейчас, это запрос: синхронный вызов с таймаутом, ретраем и деградацией. Если A совершил у себя факт и другим стоит об этом знать, это событие: публикуем и забываем. Ошибка проектирования почти всегда в том, что командой («сделай мне X») называют то, что должно быть фактом («у меня произошёл Y»), и тогда в шине оказываются приказы, а не события, и получается распределённый монолит с брокером вместо HTTP.

Гибриды, которые полезно назвать

  • Синхронно для чтения, асинхронно для записи. Заказ создаётся синхронно (клиенту нужен номер), а бонусы, кэшбэк и письмо уходят событиями.
  • Локальный кэш чужих данных. Вместо синхронного вызова «дай мне тариф» подписываемся на события изменения тарифов и держим локальную копию. Убирает хоп и временную связность ценой окна устаревания. Это event-carried state transfer.
  • Асинхронный запрос-ответ (очередь запросов + очередь ответов + correlation id): нужен, когда операция длинная (генерация отчёта, конвертация видео), а клиент опрашивает статус или получает вебхук.
На чём ловят

«Давайте всё через Kafka, будет надёжнее» - а на вопрос «а как клиент узнает цену заказа» ответа нет. Второй капкан: асинхронность выбрана, но клиенту всё равно нужен результат, и поверх очереди городится поллинг статуса каждые 200 мс - получилась синхронность с худшей латентностью и лишней инфраструктурой. Третий: забыть, что у асинхронности есть наблюдаемая наружу цена. Пользователь видит «заказ создан», но баланс обновится через две секунды, и это надо либо объяснить в UI, либо не делать асинхронным.

Суть: notification - это «эй, посмотри, у меня что-то случилось», получатель идёт за данными обратно. Carried state - «вот что случилось и вот все данные», получатель ни за чем не ходит. Первое даёт маленькие события и обратный трафик, второе - независимость ценой размера события и дублирования данных.

Event notification

Событие содержит минимум: тип, идентификатор сущности, время, версию. Потребитель, которому нужны подробности, делает синхронный вызов обратно к владельцу.

// notification: только факт и ссылка
type OrderPaid struct {
    EventID    string    `json:"event_id"`   // для дедупликации
    OrderID    string    `json:"order_id"`
    OccurredAt time.Time `json:"occurred_at"`
    Version    int       `json:"version"`    // версия агрегата, не схемы
}
  • Плюс: событие крошечное, схема почти не меняется, нет дублирования данных, нет вопроса «а что если в событии устаревшие данные».
  • Минус: временная связность вернулась через заднюю дверь - потребитель обязан сходить к продюсеру, и если тот лежит, обработка встала. Плюс N потребителей дают N обратных запросов на каждое событие (эффект «громового стада» по владельцу).
  • Ещё минус: гонка. Событие «заказ оплачен» пришло, потребитель пошёл читать заказ и увидел уже отменённый заказ - состояние ушло вперёд относительно события. Лечится так: в событии едет версия, а потребитель проверяет, не старее ли то, что он прочитал.

Event-carried state transfer

Событие несёт всё, что нужно типичному потребителю, и потребитель строит у себя локальную денормализованную копию.

type OrderPaid struct {
    EventID    string      `json:"event_id"`
    OrderID    string      `json:"order_id"`
    UserID     string      `json:"user_id"`
    Amount     Money       `json:"amount"`
    Items      []OrderItem `json:"items"`
    Address    Address     `json:"address"`
    OccurredAt time.Time   `json:"occurred_at"`
    Version    int         `json:"version"`
}
  • Плюс: полная временная развязка - потребитель работает, даже если владелец лежит сутки. Нет обратного трафика. Потребитель может отвечать на свои запросы из своей копии, и его латентность не зависит от чужой.
  • Минус: событие большое, схема меняется чаще, данные продублированы в N местах, и любое изменение смысла поля надо согласовывать со всеми потребителями.
  • Минус, о котором забывают: в каждой локальной копии свой лаг, и наружу пользователь видит расхождение между экранами. Нужен способ пересобрать копию с нуля (replay топика или отдельный «полный снимок»).
Как выбирать

По частоте чтения к частоте изменения и по требованию к свежести. Если данные читаются на каждый запрос, а меняются раз в день (тарифы, справочники, настройки мерчанта), держи локальную копию через carried state, без синхронных хопов. Если данные нужны редко, они объёмные и должны быть максимально свежими (полный профиль клиента для одного отчёта), хватит notification и запроса по требованию. Промежуточный вариант, который на собесе звучит зрело: notification с «толстым» полем-подсказкой, где лежит минимум полей для 90 % потребителей, а остальные ходят за деталями.

Audit log - и чем он не является

Audit log хранит журнал того, что произошло, для людей и регуляторов: кто, когда, что изменил, с какого IP, по какому запросу. Его пишут рядом с основным состоянием, и нужен он для расследований, комплаенса, разбора инцидентов.

Его постоянно путают с event sourcing, и разница принципиальная: в event sourcing истина лежит в событиях, а состояние выводится из них; в audit log истину хранит состояние, а журнал вторичен. Если удалить audit log, система продолжит работать корректно и никто не потеряет данные - пропадёт только история. Если удалить события в event sourcing, система перестанет существовать. Отсюда практическое следствие: audit log вводится дёшево и почти всегда окупается (отдельная таблица, отдельный топик, запись через триггер или в той же транзакции), а event sourcing остаётся тяжёлым архитектурным решением, которое меняет всё.

На чём ловят

«У нас event-driven, мы шлём события», а в событии лежит {"user_id": 42} и все потребители синхронно ходят обратно; фактически это RPC с лишним звеном и худшей отладкой. Второй капкан: carried state без версии и без порядка. Два события по одному заказу обработаны в обратном порядке, и локальная копия навсегда застряла в старом состоянии. Ответ: ключ партиционирования по идентификатору агрегата (порядок гарантирован в пределах партиции) плюс проверка версии перед применением.

Суть: транзакция - свойство одного менеджера ресурсов, который держит блокировки и умеет атомарно применить или откатить журнал. Между двумя базами и сетью такого менеджера нет, и любая попытка его изобрести упирается в фундаментальную невозможность отличить «упал» от «медленно отвечает».

Что даёт локальная транзакция и что из этого исчезает

  • Атомарность держится на WAL: до COMMIT ничего не видно, после видно сразу всё. Между сервисами общего журнала нет: сервис A уже закоммитил, сервис B вернул ошибку, и никто уже не «развидит» коммит A.
  • Изоляция держится на блокировках и снимках внутри одного движка. Через сеть изоляция означала бы держать блокировку в чужой БД на время сетевого раунд-трипа, а это убивает пропускную способность и делает систему заложником чужой латентности.
  • Долговечность у каждого своя, и они не синхронизированы: A мог сфсинкать на диск, а B в этот момент потерял питание до fsync.

Три конкретные причины, которые звучат убедительно

  1. Неотличимость отказа от задержки. Ты послал «коммить» и не получил ответ. Он не дошёл? Дошёл, но ответ потерялся? Узел жив и коммитит прямо сейчас? С точки зрения отправителя эти три случая идентичны, а действий требуют противоположных. Это не инженерная недоработка, а свойство асинхронной сети.
  2. Блокировки через сеть. Любой протокол атомарного коммита обязан держать ресурсы «замороженными» до общего решения. Время заморозки = сетевая задержка + время на согласование, то есть в тысячи раз больше локального коммита. Пропускная способность падает на порядок, а горячие строки становятся глобальным узким местом.
  3. Автономность сервисов. Микросервисы вводились именно ради того, чтобы команда владела своей схемой и релизила независимо. Общая транзакция возвращает общую блокировочную судьбу: деплой одного сервиса вешает транзакции другого. Технически это ещё и требует, чтобы у всех участников был XA-совместимый ресурс - а у Kafka, Redis, S3 и большинства внешних API его нет в принципе.
Формулировка, которую стоит запомнить

«Между сервисами нельзя получить атомарность без потери доступности, поэтому на практике отказываются не от корректности, а от изоляции и мгновенности: операция становится многошаговой, промежуточные состояния становятся легальными и получают имена в домене (PENDING, RESERVED, AWAITING_CONFIRMATION), а откат становится компенсацией.» Дальше сам собой открывается разговор про сагу и outbox.

Что делают вместо

  • Переопределить границу. С этого и стоит начинать: если две операции обязаны быть атомарными по бизнесу, скорее всего они принадлежат одному bounded context и не должны быть в разных сервисах. Списание с баланса и запись в историю баланса живут в одном сервисе, в одной БД, в одной транзакции.
  • Сага выстраивает цепочку локальных транзакций с компенсациями и меняет изоляцию на доступность.
  • Transactional outbox запирает «изменил состояние + отправил сообщение» в одну локальную транзакцию, дальше идёт at-least-once доставка.
  • Идемпотентность + повтор до успеха. Если операция идемпотентна, «не знаю, прошло ли» лечится повтором, и половина проблемы исчезает.
  • Сверка. Фоновый процесс, который сравнивает состояния двух сервисов и чинит расхождения. В финтехе это обязательный элемент, а не костыль: любая распределённая система рано или поздно расходится, и вопрос только в том, заметишь ты это сам или тебе скажет клиент.
Суть: сага - последовательность локальных транзакций, где на каждый шаг заранее написана компенсация. Оркестрация - процесс управляется явным координатором с персистентным конечным автоматом; хореография - каждый реагирует на события соседей, координатора нет. По умолчанию: до 3 шагов - хореография, дальше - оркестрация.

Сравнение по существу

ХореографияОркестрация
Где живёт процесснигде - размазан по подпискамв одном сервисе-оркестраторе, в таблице
Связностьниже: все знают только событиявыше: оркестратор знает всех участников
Понятностьпадает нелинейно с числом шаговпроцесс читается как код
Отладка «где застряло»надо собирать трейс по топикамSELECT state FROM saga_instances
Циклы и ветвленияпочти невозможно контролироватьестественны
Таймауты шаговкаждый реализует самцентрализованы в автомате
Риск«никто не отвечает за целое»оркестратор превращается в god-service

Сильный ответ добавляет границу: оркестратор допустим, пока он координирует, а не решает. Как только в него переезжают бизнес-правила участников («если сумма больше 100 000, то проверить лимит и позвать риск-скоринг»), сервисы становятся анемичными CRUD-обёртками, и получается распределённый монолит с единой точкой изменений. Признак болезни: каждая новая фича в любом сервисе требует правки оркестратора.

Компенсации: правила, которые отличают знающего

  1. Компенсация не откатывает, а делает обратную бизнес-операцию. «Отменить бронь», «сделать возврат», «послать письмо об отмене». Обе операции остаются в истории.
  2. Необратимые шаги ставь в конец. Отправка письма, выдача физического товара, списание во внешней системе без возврата. Всё, что можно откатить, идёт раньше.
  3. Компенсации идемпотентны и ретраебельны без ограничения по числу попыток - система уже в промежуточном состоянии, «сдаться» нельзя. Ключ идемпотентности складывается из saga_id + step.
  4. Компенсация не должна уметь отказать по бизнес-причине. Если может - порядок шагов выбран неверно.
  5. Семантические блокировки вместо изоляции. Статус RESERVED с TTL вместо реального удержания строки. Наружу промежуточное состояние показывается как «в обработке», а не как результат.

Персистентное состояние - обязательное, не опциональное

Оркестратор без таблицы саг не переживает рестарт пода. Схема, конечный автомат и код перехода приведены в теории выше; на собесе достаточно назвать четыре свойства: состояние в БД, один воркер на сагу (SELECT ... FOR UPDATE или партиционирование по saga_id), таймаут у каждого ожидающего состояния (next_retry_at + фоновый сканер зависших), терминальные состояния, включая NEEDS_ATTENTION для ручного разбора.

Чем добить

«Сага гарантирует консистентность?» Нет, она гарантирует атомарность в терминах бизнеса (либо все шаги, либо все компенсации) и не даёт изоляции; итоговая консистентность остаётся eventual. «Что если компенсация упала?» Ретраи без предела по числу, идемпотентность, а в пределе NEEDS_ATTENTION и человек. «Как тестировать?» Тесты автомата как чистой функции переходов плюс сценарные тесты с инъекцией отказа на каждом шаге: на N шагов должно быть N тестов «упало здесь - компенсации прошли».

На чём ловят

Классика: сказать «сага = распределённая транзакция» и на вопрос про грязное чтение не иметь ответа. Вторая ловушка - хореография на семь шагов: рисуешь схему, и она превращается в клубок; правильно тут самому сказать, что нужен оркестратор. Третья: компенсация как DELETE строки. В реальности удалять нельзя, нужен статус и след в истории, иначе теряется аудит и ломается идемпотентность (повторное событие создаст запись заново).

Суть: 2PC даёт настоящую атомарность через координатора и голосование, но ценой блокирующего окна: если координатор умер между фазами, участники держат блокировки и не имеют права решить сами. Это протокол не отказоустойчивый, а «атомарный при условии, что все выживут».

2PC позволяет нескольким независимым базам закоммитить одну общую транзакцию по принципу «либо все, либо никто». Работает это как сделка через нотариуса. Сначала нотариус обходит участников и спрашивает каждого: «ты готов подписать?». Тот, кто ответил «да», уже не может передумать и обязан ждать. Когда «да» сказали все, нотариус объявляет «подписываем» - и только в этот момент сделка состоялась. Нотариус здесь называется координатором, остальные участниками, а два обхода и есть две фазы.

Аналогия ломается ровно в одном месте: нотариус может выйти из комнаты между вопросом и объявлением. Участники, уже сказавшие «да», останутся сидеть связанными - отказаться нельзя, подписать без объявления нельзя, разойтись нельзя. Именно это и называют блокирующим окном.

Как работает

  1. Фаза 1, prepare. Координатор рассылает PREPARE. Каждый участник выполняет работу, записывает всё необходимое в свой журнал, берёт блокировки и отвечает YES (обещание: «я гарантированно смогу закоммитить, что бы дальше ни случилось») или NO. После YES участник теряет право решать самостоятельно - он в неопределённости.
  2. Фаза 2, commit/abort. Если все ответили YES, координатор пишет решение COMMIT в свой журнал (эта запись и есть точка невозврата) и рассылает её. Иначе рассылает ABORT. Участники применяют решение и отпускают блокировки.
2PC: две фазы и точка, после которой участник уже не свободен coordinator participant A participant B PREPARE YES YES блокировки взяты решение принять нельзя ждём координатора - окно неопределённости - координатор умер здесь COMMIT apply + unlock apply + unlock пока координатор не вернулся, строки A и B заблокированы - и это не таймаут, а протокол
Главная проблема 2PC не в двух раундах, а в состоянии между ними. Участник, ответивший YES, обязан ждать решения сколь угодно долго: отпустить блокировки самому означает нарушить обещание и потенциально разъехаться с соседом.

Почему избегают: пять причин по убыванию важности

  1. Блокирующий протокол. Доказано, что атомарный коммит без блокировки при отказе координатора невозможен в асинхронной сети. Падение координатора в окне между фазами замораживает данные у всех участников. Восстановиться можно только по журналу координатора - то есть координатор становится критической единой точкой отказа, которую надо реплицировать (и тогда ты уже строишь консенсус).
  2. Доступность перемножается и падает. Транзакция успешна, только если живы все участники плюс координатор. Четыре компонента по 99.9 % дают 99.6 %.
  3. Латентность и пропускная способность. Два сетевых раунда плюс fsync журнала у каждого; блокировки держатся всё это время. Горячая строка (баланс, склад) превращается в глобальный сериализатор.
  4. Не все ресурсы поддерживают XA. Kafka, Redis, S3, внешние платёжные API - фазы prepare у них нет. Двухфазный коммит работает только в мире «несколько реляционных БД под одним менеджером транзакций», а это ровно та монолитная картина, от которой уходили.
  5. Связывает автономию. Общая транзакция означает общие релизы, общие окна обслуживания и общий инцидент.
Где 2PC всё-таки жив

Внутри одной системы, где координатор реплицирован консенсусом и участники под контролем: распределённые БД (Spanner, CockroachDB, YDB, TiDB) используют 2PC поверх Raft/Paxos - консенсус чинит именно ту проблему, из-за которой 2PC плох, и делает координатора отказоустойчивым. Ещё жив в enterprise-интеграции через XA (сервер приложений + пара Oracle) и в связке «одна БД + брокер с XA» в старых стеках. Правильная формулировка на собесе: «2PC избегают не потому, что он неправильный, а потому что его цена (блокировка при отказе) в межсервисном контуре неприемлема; там, где координатор надёжен, 2PC используют вовсю».

На чём ловят

Вопрос «а 3PC решает проблему?» - формально 3PC добавляет фазу pre-commit и снимает блокировку при отказе координатора, но только в синхронной модели сети: при сетевом разделении он может нарушить атомарность (разные части кластера примут разные решения). Поэтому на практике его не используют, а берут консенсус. Второй капкан: путать 2PC с 2PL. Первое про атомарный коммит, второе про изоляцию внутри одной БД, общего у них только число «два».

Суть: outbox решает dual write - невозможность атомарно изменить БД и отправить сообщение в брокер. Сообщение пишется в ту же таблицу-транзакцию, что и данные, а отдельный процесс дочитывает его и публикует. Inbox - зеркальная защита на стороне потребителя: таблица обработанных message_id для дедупликации.

Проблема dual write

// Так нельзя: два разных хранилища, атомарности нет
func (s *Service) PayOrder(ctx context.Context, id string) error {
    if err := s.db.MarkPaid(ctx, id); err != nil {   // (1)
        return err
    }
    return s.broker.Publish(ctx, OrderPaid{OrderID: id}) // (2)
}

Между (1) и (2) четыре разных плохих исхода. Процесс упал после коммита - событие потеряно навсегда, а данные изменены. Брокер недоступен, и результат тот же. Publish прошёл, но ответ не дошёл и мы вернули ошибку: клиент повторит, событие уедет дважды. Publish поставили перед коммитом, событие ушло, а транзакция откатилась; сообщение о том, чего не было, чинить уже нечем, и это худший из вариантов. Ретраи внутри функции не спасают: нет способа откатить уже опубликованное сообщение.

Outbox: реализация

CREATE TABLE outbox (
    id             bigserial PRIMARY KEY,      -- монотонность = порядок публикации
    aggregate_type text        NOT NULL,       -- 'order'
    aggregate_id   text        NOT NULL,       -- ключ партиционирования в брокере
    event_type     text        NOT NULL,       -- 'order.paid.v1'
    payload        jsonb       NOT NULL,
    headers        jsonb       NOT NULL DEFAULT '{}',   -- trace_id, correlation_id
    created_at     timestamptz NOT NULL DEFAULT now(),
    published_at   timestamptz                 -- NULL = ещё не отправлено
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;
func (s *Service) PayOrder(ctx context.Context, id string) error {
    return s.db.InTx(ctx, func(tx *sql.Tx) error {
        if err := markPaid(tx, id); err != nil {  // бизнес-изменение
            return err
        }
        return insertOutbox(tx, Event{           // сообщение в той же транзакции
            AggregateID: id,
            Type:        "order.paid.v1",
            Payload:     mustJSON(OrderPaid{OrderID: id}),
        })
    })
}

// Отдельный релей: читает пачками, публикует, помечает.
func (r *Relay) tick(ctx context.Context) error {
    rows := r.db.Query(ctx, `SELECT id, aggregate_id, event_type, payload
                             FROM outbox WHERE published_at IS NULL
                             ORDER BY id LIMIT 200
                             FOR UPDATE SKIP LOCKED`)   // несколько релеев не мешают друг другу
    for _, e := range rows {
        if err := r.broker.Publish(ctx, e.Key(), e.Payload); err != nil {
            return err                                   // не помечаем, повторим
        }
        r.db.Exec(ctx, `UPDATE outbox SET published_at = now() WHERE id = $1`, e.ID)
    }
    return nil
}

Вычитывать outbox можно двумя способами. Polling publisher работает как в коде выше: просто, заводится с любой БД, а платишь задержкой на интервал опроса и лишней нагрузкой на БД. CDC / log tailing читает WAL напрямую (Debezium + Postgres logical replication): нулевая нагрузка на таблицы, минимальная задержка, честный порядок, но плюс один серьёзный компонент в инфраструктуре и своя эксплуатация.

Outbox даёт at-least-once, а не exactly-once

Между удачным Publish и UPDATE published_at процесс может упасть - сообщение уедет второй раз. Это неустранимо, и именно поэтому outbox всегда идёт в паре с идемпотентностью потребителя. Ответ «мы сделали outbox, значит, ровно один раз» ловят немедленно. Правильная формула: «outbox гарантирует, что событие не потеряется и не появится без изменения данных; уникальность обеспечивает потребитель».

Inbox: зеркало на стороне потребителя

CREATE TABLE inbox (
    message_id   text PRIMARY KEY,             -- event_id продюсера
    consumer     text        NOT NULL,
    processed_at timestamptz NOT NULL DEFAULT now()
);
func (c *Consumer) Handle(ctx context.Context, m Message) error {
    return c.db.InTx(ctx, func(tx *sql.Tx) error {
        ok, err := claim(tx, m.ID, c.name)  // INSERT ... ON CONFLICT DO NOTHING
        if err != nil {
            return err
        }
        if !ok {
            return nil                      // уже обрабатывали, тихо подтверждаем
        }
        return c.apply(tx, m)               // эффект и отметка в одной транзакции
    })
}

Отметка об обработке и сам эффект должны лежать в одной локальной транзакции. Если пометить в Redis, а изменение сделать в Postgres, вернулся тот же dual write, только на стороне потребителя. Inbox нужен не всегда: если сам эффект естественно идемпотентен (UPSERT по ключу, SET статуса, «положить файл по имени»), отдельная таблица избыточна.

Подводные камни, которые стоит назвать самому

  • Порядок. Публикация по ORDER BY id плюс ключ партиционирования = aggregate_id сохраняет порядок в пределах агрегата. С параллельными релеями и SKIP LOCKED глобального порядка нет - и он обычно не нужен, но это надо сказать вслух.
  • Раздувание таблицы. Outbox становится самой горячей таблицей на запись. Нужен DELETE/партиционирование по дням с отрезанием старых партиций, иначе автовакуум и индексы деградируют.
  • Разрыв «изменил - забыл записать в outbox». Лечится тем, что публикацию прячут в репозиторий/UoW, а не оставляют на дисциплину разработчика.
  • Транзакция стала длиннее на одну вставку - обычно незаметно, но на очень горячих путях это лишний WAL-трафик, и его надо учитывать.
  • Не путать с Kafka transactions. Транзакции Kafka дают атомарность «прочитал из топика - записал в топик» внутри Kafka, но не связывают Kafka с твоей БД. Мост «БД ↔ брокер» всё равно строится через outbox или CDC.
Суть: CQRS - разделение модели записи и модели чтения; в лёгкой форме это просто разные структуры и разные запросы в одной БД, в тяжёлой - разные хранилища и асинхронная проекция. Event sourcing - хранить не текущее состояние, а поток событий, из которых оно выводится. Это два независимых решения, их постоянно склеивают в одно.

CQRS по уровням, от дешёвого к дорогому

  1. Разные модели в коде. CreateOrderCommand и OrderListItem устроены по-разному, потому что у записи и чтения разные инварианты. Стоит почти ничего, полезно почти всегда.
  2. Разные пути доступа. Запись через доменную модель с валидацией и агрегатами, чтение - прямым SQL с джойнами в плоский DTO, минуя домен. Убирает половину «мапперов ради мапперов».
  3. Реплика для чтения. Та же схема, но чтение с read-replica. Появляется репликационный лаг и вопрос read-your-writes.
  4. Отдельное хранилище для чтения. Проекция в Elasticsearch/Redis/денормализованную таблицу, обновляемая событиями. Настоящая цена начинается тут: eventual consistency, пересборка проекций, мониторинг лага, обработка «проекция сломалась».

Оправдан, когда профили чтения и записи разошлись объективно: соотношение чтений к записям 100:1 и больше; чтения требуют агрегатов и полнотекстового поиска, которые ломают нормализованную схему записи; масштабировать надо только одну сторону; разным потребителям нужны принципиально разные представления одних данных. Не оправдан на CRUD с равномерной нагрузкой - там он просто удваивает код и добавляет окно рассинхрона на ровном месте.

Event sourcing: механика

Источником истины служит append-only лог событий агрегата. Текущее состояние получается свёрткой: state = fold(apply, events). Запись добавляет событие и проверяет оптимистичную блокировку по номеру версии.

// Событие неизменяемо и в прошедшем времени: оно уже случилось.
type Event interface{ isEvent() }

type AccountOpened struct {
    ID, Owner string
}
type Deposited struct {
    Amount Money
}
type Withdrawn struct {
    Amount Money
}

// метод-маркер: запечатывает интерфейс, чужой тип событием не притворится
func (AccountOpened) isEvent() {}
func (Deposited) isEvent()     {}
func (Withdrawn) isEvent()     {}

type Account struct {
    ID      string
    Balance Money
    Version int
}

func (a *Account) Apply(e Event) {          // чистая функция без побочных эффектов
    switch v := e.(type) {
    case AccountOpened:
        a.ID = v.ID
    case Deposited:
        a.Balance = a.Balance.Add(v.Amount)
    case Withdrawn:
        a.Balance = a.Balance.Sub(v.Amount)
    }
    a.Version++
}

// Команда: загрузили состояние, проверили инвариант, вернули новые события.
func (a *Account) Withdraw(amount Money) ([]Event, error) {
    if a.Balance.Less(amount) {
        return nil, ErrInsufficientFunds
    }
    return []Event{Withdrawn{Amount: amount}}, nil
}
CREATE TABLE events (
    stream_id  text     NOT NULL,          -- agg id
    version    int      NOT NULL,          -- 1,2,3... внутри стрима
    type       text     NOT NULL,
    payload    jsonb    NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (stream_id, version)       -- защита от конкурентной записи
);

Первичный ключ (stream_id, version) и есть оптимистичная блокировка: два конкурентных процесса, прочитавших версию 7, попытаются вставить версию 8 - один получит конфликт и перечитает.

Что покупает и сколько стоит

ПокупаетСтоит
Полная история: не «баланс 100», а как он таким сталнельзя просто изменить данные - правка = компенсирующее событие
Аудит и комплаенс встроены by designлюбой запрос «покажи все с балансом > X» требует проекции
Time travel: состояние на любой моментверсионирование схемы событий навсегда - старые события нельзя переписать, нужен upcasting
Новую проекцию можно построить задним числом по всей историиснапшоты, иначе агрегат с 100 000 событий грузится вечно
Естественная интеграция: события уже естьGDPR/«право на забвение» против immutable-лога - отдельная головная боль (крипто-шреддинг)
Отладка багов «как получилось такое состояние»порог входа команды: мышление событиями, а не строками
Как звучит зрелый ответ

«CQRS и event sourcing ортогональны: CQRS без ES встречается сплошь и рядом и почти бесплатен, ES без CQRS практически невозможен (по логу событий нельзя делать произвольные выборки, нужна проекция для чтения). Event sourcing я бы взял для одного агрегата, где история входит в домен: счёт, платёж, движение товара, жизненный цикл заявки. А переводить на ES весь сервис ради моды почти всегда ошибка: цена платится ежедневно, а выгода нужна раз в квартал.»

На чём ловят

Первое: «CQRS это когда две базы» (нет, речь про модели, а две базы уже крайний случай). Второе: не иметь ответа на «как читать данные в ES». Отвечать надо так: «проекции, и они eventual». Третье: «мы удалим неправильное событие» - в event sourcing лог неизменяем, ошибку исправляют новым событием, а не правкой старого. Четвёртое: забыть про снапшоты и версионирование схемы событий. Это первые две вещи, которые больно кусают в проде на горизонте года.

Суть: gateway - единая точка входа, которая снимает с каждого сервиса сквозные заботы: TLS, аутентификацию, rate limit, роутинг, наблюдаемость. BFF - это отдельный gateway на каждый тип клиента, который вдобавок агрегирует и подгоняет ответ под конкретный UI. Главная опасность у обоих одна: превратиться в место, где живёт бизнес-логика.

Что решает API Gateway

  • Единая точка входа и роутинг. Клиент знает один домен, а внутренняя топология его не касается. Сервисы можно делить и переименовывать, не трогая клиентов.
  • Терминация TLS и mTLS внутрь. Сертификаты в одном месте.
  • Аутентификация. Проверка подписи JWT / интроспекция токена делается один раз, дальше внутрь идёт проверенный контекст (заголовки с subject, scope, tenant). Аутентификацию gateway берёт на себя, авторизацию нет (об этом ниже).
  • Rate limiting и квоты по клиенту/ключу/IP, защита от всплесков и абьюза.
  • Сквозная наблюдаемость. Генерация trace_id/request_id, единые метрики и логи по всему внешнему трафику: на «сколько 5xx у нас снаружи» отвечает один дашборд.
  • Развязка версий и протоколов. Снаружи REST/JSON, внутри gRPC; наружу /v1, внутри уже v3.
  • Инфраструктурные мелочи, которых иначе N копий: CORS, сжатие, размер тела, таймауты, канареечный роутинг по заголовку, allowlist эндпоинтов.

Что в gateway класть нельзя

Правило простое: gateway не должен знать домен. Как только в конфигурации появляется «если order.total > 10000, отправить в другой сервис» или «склеить ответы и посчитать скидку», начинается ESB-паттерн из 2005-го: центральный узел с бизнес-правилами, который релизится отдельно, ломает всех и становится узким местом организационно (очередь на изменение конфига) и технически. Проверить просто: если для новой фичи нужен релиз gateway - граница проведена неверно.

Аутентификация на gateway, авторизация в сервисе

Проверить, что токен подлинный и не истёк, можно централизованно - это доменно-нейтральная задача. Решить, что этот пользователь может редактировать этот заказ, нельзя: правило зависит от состояния данных, которые знает только владелец. Второе следствие: сервис не должен слепо верить заголовкам. Если во внутреннюю сеть можно попасть мимо gateway, любой сможет прислать X-User-Id: 1. Отсюда либо mTLS и сетевые политики, либо проброс подписанного токена внутрь с повторной проверкой подписи. Это мягкое подбрюшье пентестеры находят регулярно.

BFF: backend for frontend

Проблема, которую он решает: у мобильного приложения, веба и партнёрского API разные потребности. Мобильному нужен один запрос с минимумом байт (батарея, сеть), вебу богатый ответ, партнёру стабильный контракт на годы. Общий API, который пытается устроить всех, устраивает никого: он либо переусложнён параметрами (?include=a,b,c&fields=...), либо заставляет клиента делать 8 запросов на один экран.

BFF работает как слой на клиента: агрегирует вызовы к нескольким сервисам, обрезает лишние поля, приводит формат и принадлежит команде этого клиента. Отличие от gateway: gateway один и доменно-нейтрален, BFF-ов несколько и они знают про конкретный UI. Логика в BFF допустима, но только презентационная: «на экране заказа показываем адрес, статус и три последних события» - это про экран, а не про домен.

Один gateway на всех mobile web partner gateway authn · rl · route svc-order svc-user svc-catalog клиент сам склеивает 3 ответа BFF на каждый тип клиента mobile web partner bff-mobile bff-web bff-partner svc-order svc-user svc-catalog домены каждый BFF склеивает под свой экран Общее правило: gateway и BFF снимают сквозные и презентационные заботы. Как только там появляется правило домена - это ESB, и он станет узким местом. Цена BFF: N сервисов на поддержку и риск дублирования одной и той же агрегации в каждом
Gateway один и не знает домена; BFF несколько и они знают экран. В реальных системах они сосуществуют: снаружи gateway с authn и rate limit, за ним BFF-ы, за ними доменные сервисы.
На чём ловят

«Gateway это единая точка отказа». Да, поэтому его держат в нескольких репликах за L4-балансировщиком, делают stateless и готовят деградацию (если сервис интроспекции токенов лёг - кэш решений с коротким TTL, а не отказ всем). Второй вопрос: «а не добавляет ли BFF латентность». Добавляет один хоп, но убирает несколько клиентских раунд-трипов по мобильной сети, где RTT 100+ мс, так что обычно выигрывает. Третий: «сколько BFF». По одному на тип клиента, а не на команду и не на экран, иначе получается зоопарк с общим кодом, скопированным N раз.

Суть: discovery отвечает на вопрос «по какому адресу сейчас живёт сервис B», конфигурация - «с какими параметрами я работаю на этом стенде», mesh - «кто и как делает ретраи, mTLS, таймауты и телеметрию за меня». Три разные проблемы, которые часто закрываются одной платформой и потому смешиваются.

Service discovery

Поды переезжают, инстансы автоскейлятся, и статический список адресов в конфиге не живёт. Нужен реестр: инстанс при старте регистрируется и шлёт heartbeat, при остановке снимается, а клиент получает актуальный список.

  • Client-side discovery. Клиент сам запрашивает реестр (Consul, etcd, Eureka) и сам балансирует. Плюсы: умная балансировка (least-request, learning от латентности) и нет лишнего хопа. Минус: логика едет в каждый клиент на каждом языке.
  • Server-side discovery. Клиент бьёт в стабильный адрес (k8s Service, ELB), балансировкой занимается инфраструктура. Клиент от этого тупеет, и это плюс, но добавляется лишний хоп и слабый контроль стратегии. В Kubernetes это по умолчанию: DNS-имя svc.ns.svc.cluster.local плюс kube-proxy/iptables.
  • Health checks. Реестр обязан быстро выкидывать мёртвые инстансы, иначе клиенты будут упорно бить в труп. Разделяй liveness («перезапусти меня») и readiness («не шли мне трафик») - путаница между ними даёт либо рестарт-луп, либо 502 при деплое.
  • Залипание на старом адресе, классические грабли: под переехал, а клиент продолжает бить в прежний IP. Сам резолвер Go ответы не кэширует - держат адрес кэш ОС и живые соединения в пуле, для которых адрес спрашивали один раз. В gRPC лечится резолвером с подпиской, в HTTP - временем жизни соединения: IdleConnTimeout и CloseIdleConnections.

Конфигурация

  • Разделение по природе. Код в образе, конфиг в окружении, секреты - в хранилище секретов (Vault, KMS, k8s Secret с монтированием файлом и ротацией). Env-переменная для секрета плоха тем, что видна в /proc/<pid>/environ и в дампах (а вписанная в манифест значением, без Secret, — ещё и в kubectl describe), и ротируется только рестартом.
  • Статическая vs динамическая. Статическая читается на старте (адрес БД, размер пула). Динамическая должна меняться без рестарта: фича-флаги, лимиты, семплирование логов, аварийные переключатели. Для второй нужен watch/pull с валидацией и безопасным дефолтом при недоступности источника.
  • Валидация на старте, fail fast. Отсутствующий или бессмысленный параметр роняет приложение при запуске с внятным сообщением, а не nil-паникой через час под нагрузкой.
  • Конфиг-сервис становится единой точкой отказа, если приложение не стартует без него. Лечится кэшем последней валидной конфигурации на диске.

Service mesh

Идея: вынести всю сетевую обвязку из библиотек приложения в sidecar-прокси (Envoy в каждом поде рядом с приложением) или в общий прокси на узле (ambient-режим Istio; Cilium часть работы делает прямо в ядре через eBPF). Приложение ходит по обычному адресу сервиса и о перехвате не знает: трафик молча заворачивают на прокси (iptables или eBPF), а он делает всё остальное.

Что даёт meshЧто это заменяет
mTLS между всеми сервисами и ротация сертификатовручную настройку TLS в каждом сервисе
Ретраи, таймауты, circuit breaker, outlier detectionбиблиотеки устойчивости на каждом языке
Трафик-менеджмент: канарейка по весам, зеркалирование, fault injectionсамописные переключалки и отдельные деплойменты
Единая телеметрия: RED-метрики и трейсинг без правки кодаинструментирование в каждом сервисе
Авторизация L7 между сервисами (кто кого может звать)сетевые политики руками

Цена: +1 контейнер и +2 сетевых хопа на каждый запрос (обычно единицы миллисекунд, но на внутренних вызовах в 1 мс это удвоение), заметный расход CPU/памяти на sidecar-ы, сложная плоскость управления, которую надо уметь чинить в 3 часа ночи, и новый класс инцидентов («запросы не идут, потому что sidecar стартовал позже приложения»). На собесе это звучит так: «mesh окупается примерно от нескольких десятков сервисов и/или при жёстком требовании mTLS и zero-trust; на пяти сервисах это дороже, чем библиотека с ретраями и circuit breaker».

Чем добить

Спросят «что делает mesh с ретраями, если приложение тоже ретраит» - умножение попыток: 3 попытки в приложении × 3 в прокси = 9 запросов, готовый retry storm. Правило: ретраи живут в одном месте, и обычно это mesh; в приложении отключаются. Второй добивающий: «что не может mesh». Понять бизнес-семантику. Он не знает, идемпотентен ли твой POST, и потому ретрай на уровне прокси по умолчанию безопасен только для методов, которые ты сам пометил идемпотентными.

Суть: версионируй так, чтобы версия почти никогда не понадобилась: только аддитивные изменения, толерантный читатель, обязательное перекрытие версий при выкатке. А проверяет, что ты не сломался, не документ, а тесты - и написаны они потребителем.

Что считается ломающим изменением

Безопасно (аддитивно)Ломает
Добавить необязательное поле в ответУдалить или переименовать поле
Добавить необязательное поле в запрос с дефолтомСделать необязательное поле обязательным
Добавить новый эндпоинт/методИзменить тип поля (int → string)
Добавить значение в enum, если потребители толерантныСузить допустимый диапазон, ужесточить валидацию
Ослабить валидацию входаИзменить смысл поля при том же типе - худшее, компилятор не поймает

Отдельная ловушка: enum. Новый статус добавить безопасно, только если потребители написаны толерантно; если у кого-то switch с default: panic, аддитивное изменение уронит его в проде. Отсюда правило: «новое значение enum ломает совместимость, пока не доказано обратное».

Стратегии версионирования

  • Аддитивность + tolerant reader. Основной механизм: не удалять, не менять типы, игнорировать незнакомые поля при разборе. В protobuf это встроено: номера полей неизменны, неизвестные поля сохраняются, удалённые номера помечаются reserved, чтобы их никто не переиспользовал.
  • Версия в URL (/v1/orders) - просто, видно в логах, легко роутить на gateway; минус в дублировании кода при живых v1 и v2.
  • Версия в медиа-типе (Accept: application/vnd.acme.v2+json): чище с точки зрения REST, хуже для отладки и кэширования.
  • Версия в имени топика или типа события (order.paid.v2). Для брокеров это самый практичный вариант: старые потребители продолжают читать старый топик, пока не мигрируют. Плюс schema registry с проверкой совместимости (BACKWARD/FORWARD/FULL) на этапе публикации схемы - то есть ломающее изменение не проходит в CI.
  • Обязательное перекрытие. Порядок выкатки: сначала расширить (сервер поддерживает и старое, и новое), затем мигрировать потребителей, затем удалить старое. Это тот же expand–migrate–contract, что и для схемы БД, и точно так же нельзя делать шаги одновременно.
  • Политика вывода версии из эксплуатации: объявленный срок, метрика «кто ещё ходит в v1» с разбивкой по клиентам, письма владельцам, только потом удаление. Без метрики использования удаление превращается в русскую рулетку.

Consumer-driven contracts

Интеграционные тесты «поднимем всё вместе» медленные и хрупкие, да ещё требуют собрать всю систему на каждый коммит. CDC переворачивает подход. Каждый потребитель описывает, что именно ему нужно от провайдера: конкретные запросы и ожидаемые куски ответов. Этот пакт публикуется в общий брокер контрактов (Pact Broker и аналоги).

  1. Тест на стороне потребителя гоняется против мока, сгенерированного из пакта, так что живой провайдер ему не нужен.
  2. Пакт публикуется в брокер с версией потребителя.
  3. В CI провайдера запускается верификация: реальный провайдер проверяется против всех пактов всех потребителей. Красный билд означает «твоё изменение сломает вот этих».
  4. Перед деплоем спрашиваем брокер: «совместима ли эта версия провайдера со всем, что сейчас в проде?» (can-i-deploy).

Главная ценность в том, что провайдер узнаёт о поломке в своём CI за минуты, а не от соседней команды через неделю. Побочная: пакты показывают, какие поля реально кем-то используются, и неиспользуемое можно спокойно удалять. Ограничение: CDC проверяет форму и совместимость, но не бизнес-смысл - если ты изменил значение поля amount с копеек на рубли, все пакты останутся зелёными.

Отдельно про события

Контракт события ломать опаснее, чем контракт HTTP: потребителей может быть больше, чем ты знаешь, они могут читать топик с недельным лагом, а в брокере лежат старые сообщения в старой схеме. Отсюда три правила: schema registry с проверкой совместимости в CI, обязательные event_id/version/ occurred_at в конверте события, и двойная публикация на время миграции, пока живут обе версии. И правило гигиены: событие это часть публичного API, а не дамп внутренней структуры; сериализовать доменную модель напрямую в топик - значит намертво связать свою схему БД со всеми потребителями.

На чём ловят

«У нас в конфлюенсе описан контракт» - документ не проверяется на каждом коммите и расходится с реальностью на второй неделе. Второе: «выкатим v2 и попросим всех перейти». Без перекрытия версий это скоординированный релиз, ровно то, ради отсутствия чего заводили микросервисы. Третье: не подумать про откат. Если новая версия сервиса уже пишет данные в новом формате, откат бинаря назад приведёт к тому, что старый код не прочитает свои же свежие данные. Совместимость нужна в обе стороны, а не только вперёд.

Что стоит запомнить

  • Синхронный вызов связывает доступность и deadline вызывающего с вызываемым сервисом.
  • Outbox фиксирует бизнес-изменение и событие в локальной транзакции, relay может публиковать повторно.
  • Сага координирует локальные транзакции и компенсации, но промежуточное состояние видимо и компенсация не равна откату.

Частые ошибки

  • Не делай двойную запись DB+broker без outbox, потому что падение между действиями теряет одно из них.
  • Не рассчитывай, что компенсация вернёт внешний мир точно в исходное состояние.
  • Не называй notification, state transfer и event sourcing одним видом события: payload определяет связанность и владение данными.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай состояние саги

Проследи переход при временной ошибке бронирования отеля и затем при окончательном отказе.

switch {
case err == nil:
    saga.State = Completed
case isTransient(err) && saga.Attempts < maxAttempts:
    saga.State = BookingHotel
    saga.NextRetryAt = now.Add(backoff(saga.Attempts))
case err != nil:
    saga.State = Compensating
}
Ответ и разбор

Временная ошибка ниже лимита оставляет сагу в BookingHotel и задаёт next_retry_at, поэтому worker продолжает с текущего шага, не повторяя платёж и бронь места. При превышении лимита или постоянной ошибке переход идёт в Compensating, где выполненные шаги отменяются в обратном порядке. Все внешние шаги должны быть идемпотентны.

Код-ревью

2. Найди двойную запись

Ниже бизнес-изменение и публикация не атомарны. Предложи outbox-исправление и объясни оставшийся повторный путь.

if err := s.db.MarkPaid(ctx, id); err != nil { return err }
return s.broker.Publish(ctx, OrderPaid{OrderID: id})
Ответ и разбор

В одной DB-транзакции обнови заказ и вставь outbox row с event_id/payload. Relay выбирает строки, публикует, ждёт confirm, затем помечает published_at. Если процесс падает после confirm до отметки, строка уйдёт повторно, поэтому consumer фиксирует inbox message_id и бизнес-эффект в одной транзакции.

Применение

3. Выбери тип события

Склад должен узнавать, что заказ оплачен, и ему нужен email покупателя и позиции. Сравни notification с ссылкой на заказ и state transfer с нужными полями. Какой риск имеет каждый?

Ответ и разбор

Notification передаёт OrderID и предлагает складу обратиться к API; он уменьшает копирование данных, но добавляет синхронную зависимость и может увидеть уже изменённое текущее состояние. State transfer включает email/позиции и позволяет обработать без обратного запроса, но дублирует данные и требует версии/эволюции схемы. Выбор зависит от того, нужен ли факт на момент события или актуальное состояние.

Применение

4. Выбери sync или async

Checkout должен немедленно сообщить клиенту, что платёжный провайдер отклонил карту. Email чек может отправиться позже. Раздели взаимодействия и назови поведение при недоступности зависимостей.

Ответ и разбор

Проверку/авторизацию платежа обычно выполняй синхронно в пределах бюджета запроса, потому что клиенту нужен результат сейчас; таймаут/неизвестный исход требует idempotency key и сверки статуса. Email положи в outbox и отправляй асинхронно, чтобы недоступность почтового провайдера не ломала checkout.