11.3Межсервисное взаимодействие
Разбери, почему транзакция не пересекает процессную границу, и выбери синхронный вызов, outbox/inbox, сагу или событие по требованиям сценария. Пойми, где подтверждение может потеряться, какие шаги повторятся и как сохранить совместимость схемы сообщения.
Синхронное или асинхронное: не вкус, а свойство сценария
Синхронное взаимодействие (HTTP, gRPC) устроено как запрос-ответ: вызывающий блокируется и ждёт. В асинхронном сервис публикует сообщение в брокер и идёт дальше, а получатель обработает его когда-нибудь потом. Разница выглядит как разница транспортов, но на деле это разница в том, кто кому обязан быть живым.
При синхронном вызове сервис A не может выполнить свою работу, если сервис B недоступен. Это временная связность: A и B обязаны быть живыми одновременно. При асинхронном A обязан быть живым только вместе с брокером; B может лежать час, и это задержит обработку, но не сломает запрос клиента. Ровно поэтому вопрос «синхронно или асинхронно» на самом деле звучит так: «нужен ли клиенту результат B прямо сейчас».
Арифметика доступности: почему цепочки убивают
Если запрос клиента проходит через N сервисов последовательно и любой из них может ответить ошибкой, доступности перемножаются. Это самая полезная формула на собесе, потому что она превращает архитектурный спор в числа.
| Цепочка | Расчёт | Итог | Простой в месяц |
|---|---|---|---|
| 1 сервис 99.9 % | 0.999 | 99.90 % | ~43 мин |
| 3 сервиса по 99.9 % | 0.9993 = 0.9970 | 99.70 % | ~2 ч 10 мин |
| 5 сервисов по 99.9 % | 0.9995 = 0.9950 | 99.50 % | ~3 ч 36 мин |
| 10 сервисов по 99.9 % | 0.99910 = 0.9900 | 99.00 % | ~7 ч 12 мин |
| 10 сервисов по 99.99 % | 0.999910 = 0.9990 | 99.90 % | ~43 мин |
Читается это так: чтобы система из десяти звеньев держала три девятки, каждое звено должно
держать четыре. Один порядок надёжности «съедается» самим фактом цепочки. И это ещё
оптимистичный расчёт: он не учитывает латентность. Если у каждого сервиса p99 = 50 мс,
у цепочки из пяти p99 окажется примерно в два с половиной раза хуже одного звена, около 130 мс: хвосты редко
совпадают, поэтому сумма p99 остаётся верхней оценкой, а не результатом. Зато вероятность
зацепить чей-нибудь хвост растёт с длиной цепочки: 1 - 0.99^5 = 4.9 %
запросов вместо 1 %.
Критерии выбора
| Признак сценария | Синхронно | Асинхронно |
|---|---|---|
| Клиенту нужен результат в этом же 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 notification | Event-carried state | Event 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 года Гарсиа-Молины и Салема про длинные транзакции в БД; к микросервисам приём приспособили позже.
Формально сага складывается из локальных транзакций, каждая атомарна в своём сервисе, плюс компенсация для каждой. Сага не даёт изоляции: промежуточные состояния видны наружу. Она гарантирует только одно: сценарий не застрянет посередине навсегда - либо все шаги выполнены, либо все выполненные скомпенсированы.
Склеить шаги между собой можно двумя способами, и разница между ними такая же, как между руководителем проекта и эффектом домино.
- Оркестрация. Есть отдельный сервис-дирижёр, оркестратор. Он держит у себя весь сценарий целиком, по очереди говорит участникам «сделай это», ждёт ответа и решает, что дальше. Участники друг о друге не знают вообще: каждый выполняет команду и отчитывается дирижёру. Слово пришло от «оркестра»: музыканты вступают по взмаху палочки.
- Хореография. Дирижёра нет. Каждый сервис просто публикует событие «я своё сделал», а следующий подписан на это событие и включается сам. Команд никто не отдаёт - все знают свою партию и момент вступления. Отсюда и слово: танцоры на сцене двигаются без указаний, потому что роли разучены заранее.
Главное различие видно сразу: при оркестрации сценарий записан в одном месте и читается сверху вниз; при хореографии он не записан нигде - чтобы узнать, что произойдёт после оплаты, надо обойти все сервисы и посмотреть, кто на что подписан.
| Критерий | Оркестрация | Хореография |
|---|---|---|
| Где живёт сценарий | В одном сервисе-оркестраторе, читается сверху вниз | Размазан по подписчикам, целиком нигде не записан |
| Связность | Оркестратор знает всех участников (но участники не знают друг друга) | Никто не знает никого - только имена событий |
| Добавить шаг | Правка одного сервиса | Новый подписчик, издателя не трогаем |
| Понять «где застряло» | Один SELECT по таблице саг | Собирать по логам и трейсам всех сервисов |
| Компенсации | Явный обратный проход, порядок под контролем | Каскад «failed»-событий, порядок надо проектировать |
| Риск | Оркестратор превращается в божественный объект и SPOF-логики | Циклы событий, «а кто это вызвал?», непредсказуемые каскады |
| Кол-во участников | Хорошо до 8–10 шагов | Хорошо на 2–3 шагах, дальше не читается |
| Когда брать | Бизнес-процесс с ветвлениями, таймаутами, ручным разбором | Простая линейная реакция: «заказ оформлен - начисли бонусы» |
«Хореографию беру по умолчанию для двух-трёх шагов без ветвлений: она дешевле и не создаёт центральной зависимости. Как только появляются условия, таймауты, ручное вмешательство оператора или больше трёх участников - перехожу на оркестрацию, потому что процесс становится бизнес-сущностью, у которой должно быть имя, состояние и владелец. Гибрид тоже нормален: оркестратор ведёт основной сценарий, а побочные эффекты вроде уведомлений живут на хореографии.»
Пример: «оплата + бронь билета + отель»
| Шаг | Локальная транзакция | Компенсация | Почему это не откат |
|---|---|---|---|
| T1 | Списать 12 400 руб с карты | C1: вернуть 12 400 руб (refund) | Списание уже прошло через банк и видно в выписке; refund - новая проводка, а не исчезновение старой. Комиссия эквайринга не возвращается |
| T2 | Занять место 14C, статус RESERVED | C2: освободить место, статус AVAILABLE | Пока место было занято, другой пассажир его не увидел и купил соседнее - этот эффект не отменяется |
| T3 | Забронировать номер в отеле | - | Упал: компенсировать нечего, шаг не выполнился |
Порядок тут важен: компенсации идут в обратном порядке шагов, сначала освобождаем место, потом возвращаем деньги. Это не формальность: если вернуть деньги первым, а освободить место не получится, система останется с занятым местом без оплаты - то есть с реальной потерей выручки. Обратный порядок оставляет систему в состоянии «оплачено, но не выдано»: неприятно, но восстановимо, и такое обычно уже покрыто процессом ручного разбора.
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 попыток сага уходит в компенсацию),
и есть терминальные состояния, из которых нет выхода - иначе сага будет крутиться вечно.
Что делать, если упала сама компенсация
Компенсация идёт обычным сетевым вызовом и падает так же, как основной шаг. Ответ складывается из четырёх уровней:
- Компенсации обязаны быть ретраебельными и идемпотентными. «Вернуть деньги по
операции X» можно вызывать сто раз - возврат произойдёт один. Делается это ключом
идемпотентности
saga_id + step. - Ретраи с экспоненциальной задержкой и без верхнего предела по количеству, но с ограничением по времени. В отличие от основного шага, компенсацию нельзя «просто бросить»: система уже в несогласованном состоянии, и выйти из него можно только одним способом - довести компенсацию до конца.
- Проектировать компенсации так, чтобы они не могли упасть по бизнес-причине. «Освободить место» не может вернуть «нельзя» - это действие возможно всегда. Если компенсация способна получить логический отказ (например, «нельзя отменить: билет уже использован»), значит, шаги стоят в неправильном порядке: необратимые действия ставят последними. Правило дизайна саг звучит так: сначала всё, что можно откатить, в конце то, что нельзя.
- Терминальное состояние
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, либо не делать асинхронным.
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.
Три конкретные причины, которые звучат убедительно
- Неотличимость отказа от задержки. Ты послал «коммить» и не получил ответ. Он не дошёл? Дошёл, но ответ потерялся? Узел жив и коммитит прямо сейчас? С точки зрения отправителя эти три случая идентичны, а действий требуют противоположных. Это не инженерная недоработка, а свойство асинхронной сети.
- Блокировки через сеть. Любой протокол атомарного коммита обязан держать ресурсы «замороженными» до общего решения. Время заморозки = сетевая задержка + время на согласование, то есть в тысячи раз больше локального коммита. Пропускная способность падает на порядок, а горячие строки становятся глобальным узким местом.
- Автономность сервисов. Микросервисы вводились именно ради того, чтобы команда владела своей схемой и релизила независимо. Общая транзакция возвращает общую блокировочную судьбу: деплой одного сервиса вешает транзакции другого. Технически это ещё и требует, чтобы у всех участников был XA-совместимый ресурс - а у Kafka, Redis, S3 и большинства внешних API его нет в принципе.
«Между сервисами нельзя получить атомарность без потери доступности, поэтому
на практике отказываются не от корректности, а от изоляции и мгновенности:
операция становится многошаговой, промежуточные состояния становятся легальными
и получают имена в домене (PENDING, RESERVED,
AWAITING_CONFIRMATION), а откат становится компенсацией.»
Дальше сам собой открывается разговор про сагу и outbox.
Что делают вместо
- Переопределить границу. С этого и стоит начинать: если две операции обязаны быть атомарными по бизнесу, скорее всего они принадлежат одному bounded context и не должны быть в разных сервисах. Списание с баланса и запись в историю баланса живут в одном сервисе, в одной БД, в одной транзакции.
- Сага выстраивает цепочку локальных транзакций с компенсациями и меняет изоляцию на доступность.
- Transactional outbox запирает «изменил состояние + отправил сообщение» в одну локальную транзакцию, дальше идёт at-least-once доставка.
- Идемпотентность + повтор до успеха. Если операция идемпотентна, «не знаю, прошло ли» лечится повтором, и половина проблемы исчезает.
- Сверка. Фоновый процесс, который сравнивает состояния двух сервисов и чинит расхождения. В финтехе это обязательный элемент, а не костыль: любая распределённая система рано или поздно расходится, и вопрос только в том, заметишь ты это сам или тебе скажет клиент.
Сравнение по существу
| Хореография | Оркестрация | |
|---|---|---|
| Где живёт процесс | нигде - размазан по подпискам | в одном сервисе-оркестраторе, в таблице |
| Связность | ниже: все знают только события | выше: оркестратор знает всех участников |
| Понятность | падает нелинейно с числом шагов | процесс читается как код |
| Отладка «где застряло» | надо собирать трейс по топикам | SELECT state FROM saga_instances |
| Циклы и ветвления | почти невозможно контролировать | естественны |
| Таймауты шагов | каждый реализует сам | централизованы в автомате |
| Риск | «никто не отвечает за целое» | оркестратор превращается в god-service |
Сильный ответ добавляет границу: оркестратор допустим, пока он координирует, а не решает. Как только в него переезжают бизнес-правила участников («если сумма больше 100 000, то проверить лимит и позвать риск-скоринг»), сервисы становятся анемичными CRUD-обёртками, и получается распределённый монолит с единой точкой изменений. Признак болезни: каждая новая фича в любом сервисе требует правки оркестратора.
Компенсации: правила, которые отличают знающего
- Компенсация не откатывает, а делает обратную бизнес-операцию. «Отменить бронь», «сделать возврат», «послать письмо об отмене». Обе операции остаются в истории.
- Необратимые шаги ставь в конец. Отправка письма, выдача физического товара, списание во внешней системе без возврата. Всё, что можно откатить, идёт раньше.
- Компенсации идемпотентны и ретраебельны без ограничения по числу попыток -
система уже в промежуточном состоянии, «сдаться» нельзя. Ключ идемпотентности
складывается из
saga_id + step. - Компенсация не должна уметь отказать по бизнес-причине. Если может - порядок шагов выбран неверно.
- Семантические блокировки вместо изоляции. Статус
RESERVEDс TTL вместо реального удержания строки. Наружу промежуточное состояние показывается как «в обработке», а не как результат.
Персистентное состояние - обязательное, не опциональное
Оркестратор без таблицы саг не переживает рестарт пода. Схема, конечный автомат
и код перехода приведены в теории выше; на собесе достаточно назвать четыре свойства:
состояние в БД, один воркер на сагу (SELECT ... FOR UPDATE
или партиционирование по saga_id), таймаут у каждого ожидающего
состояния (next_retry_at + фоновый сканер зависших),
терминальные состояния, включая NEEDS_ATTENTION для ручного разбора.
«Сага гарантирует консистентность?»
Нет, она гарантирует атомарность в терминах бизнеса (либо все шаги, либо все
компенсации) и не даёт изоляции; итоговая консистентность остаётся eventual. «Что если
компенсация упала?» Ретраи без предела по числу, идемпотентность,
а в пределе NEEDS_ATTENTION и человек. «Как тестировать?»
Тесты автомата как чистой функции переходов плюс сценарные тесты с инъекцией отказа
на каждом шаге: на N шагов должно быть N тестов «упало здесь - компенсации прошли».
Классика: сказать «сага = распределённая транзакция» и на вопрос про грязное чтение
не иметь ответа. Вторая ловушка - хореография на семь шагов: рисуешь схему, и она
превращается в клубок; правильно тут самому сказать, что нужен оркестратор.
Третья: компенсация как DELETE строки. В реальности удалять нельзя,
нужен статус и след в истории, иначе теряется аудит и ломается идемпотентность
(повторное событие создаст запись заново).
2PC позволяет нескольким независимым базам закоммитить одну общую транзакцию по принципу «либо все, либо никто». Работает это как сделка через нотариуса. Сначала нотариус обходит участников и спрашивает каждого: «ты готов подписать?». Тот, кто ответил «да», уже не может передумать и обязан ждать. Когда «да» сказали все, нотариус объявляет «подписываем» - и только в этот момент сделка состоялась. Нотариус здесь называется координатором, остальные участниками, а два обхода и есть две фазы.
Аналогия ломается ровно в одном месте: нотариус может выйти из комнаты между вопросом и объявлением. Участники, уже сказавшие «да», останутся сидеть связанными - отказаться нельзя, подписать без объявления нельзя, разойтись нельзя. Именно это и называют блокирующим окном.
Как работает
- Фаза 1, prepare. Координатор рассылает
PREPARE. Каждый участник выполняет работу, записывает всё необходимое в свой журнал, берёт блокировки и отвечаетYES(обещание: «я гарантированно смогу закоммитить, что бы дальше ни случилось») илиNO. ПослеYESучастник теряет право решать самостоятельно - он в неопределённости. - Фаза 2, commit/abort. Если все ответили
YES, координатор пишет решениеCOMMITв свой журнал (эта запись и есть точка невозврата) и рассылает её. Иначе рассылаетABORT. Участники применяют решение и отпускают блокировки.
YES, обязан ждать решения сколь угодно долго: отпустить
блокировки самому означает нарушить обещание и потенциально разъехаться с соседом.Почему избегают: пять причин по убыванию важности
- Блокирующий протокол. Доказано, что атомарный коммит без блокировки при отказе координатора невозможен в асинхронной сети. Падение координатора в окне между фазами замораживает данные у всех участников. Восстановиться можно только по журналу координатора - то есть координатор становится критической единой точкой отказа, которую надо реплицировать (и тогда ты уже строишь консенсус).
- Доступность перемножается и падает. Транзакция успешна, только если живы все участники плюс координатор. Четыре компонента по 99.9 % дают 99.6 %.
- Латентность и пропускная способность. Два сетевых раунда плюс fsync журнала у каждого; блокировки держатся всё это время. Горячая строка (баланс, склад) превращается в глобальный сериализатор.
- Не все ресурсы поддерживают XA. Kafka, Redis, S3, внешние платёжные API - фазы prepare у них нет. Двухфазный коммит работает только в мире «несколько реляционных БД под одним менеджером транзакций», а это ровно та монолитная картина, от которой уходили.
- Связывает автономию. Общая транзакция означает общие релизы, общие окна обслуживания и общий инцидент.
Внутри одной системы, где координатор реплицирован консенсусом и участники под контролем: распределённые БД (Spanner, CockroachDB, YDB, TiDB) используют 2PC поверх Raft/Paxos - консенсус чинит именно ту проблему, из-за которой 2PC плох, и делает координатора отказоустойчивым. Ещё жив в enterprise-интеграции через XA (сервер приложений + пара Oracle) и в связке «одна БД + брокер с XA» в старых стеках. Правильная формулировка на собесе: «2PC избегают не потому, что он неправильный, а потому что его цена (блокировка при отказе) в межсервисном контуре неприемлема; там, где координатор надёжен, 2PC используют вовсю».
Вопрос «а 3PC решает проблему?» - формально 3PC добавляет фазу pre-commit и снимает блокировку при отказе координатора, но только в синхронной модели сети: при сетевом разделении он может нарушить атомарность (разные части кластера примут разные решения). Поэтому на практике его не используют, а берут консенсус. Второй капкан: путать 2PC с 2PL. Первое про атомарный коммит, второе про изоляцию внутри одной БД, общего у них только число «два».
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): нулевая нагрузка на таблицы, минимальная задержка, честный порядок, но плюс один серьёзный компонент в инфраструктуре и своя эксплуатация.
Между удачным 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 по уровням, от дешёвого к дорогому
- Разные модели в коде.
CreateOrderCommandиOrderListItemустроены по-разному, потому что у записи и чтения разные инварианты. Стоит почти ничего, полезно почти всегда. - Разные пути доступа. Запись через доменную модель с валидацией и агрегатами, чтение - прямым SQL с джойнами в плоский DTO, минуя домен. Убирает половину «мапперов ради мапперов».
- Реплика для чтения. Та же схема, но чтение с read-replica. Появляется репликационный лаг и вопрос read-your-writes.
- Отдельное хранилище для чтения. Проекция в 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 лог неизменяем, ошибку исправляют новым событием, а не правкой старого. Четвёртое: забыть про снапшоты и версионирование схемы событий. Это первые две вещи, которые больно кусают в проде на горизонте года.
Что решает 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, любой сможет прислать X-User-Id: 1.
Отсюда либо mTLS и сетевые политики, либо проброс подписанного токена внутрь
с повторной проверкой подписи. Это мягкое подбрюшье пентестеры находят регулярно.
BFF: backend for frontend
Проблема, которую он решает: у мобильного приложения, веба и партнёрского API
разные потребности. Мобильному нужен один запрос с минимумом байт (батарея, сеть),
вебу богатый ответ, партнёру стабильный контракт на годы. Общий API, который
пытается устроить всех, устраивает никого: он либо переусложнён параметрами
(?include=a,b,c&fields=...), либо заставляет клиента делать 8 запросов
на один экран.
BFF работает как слой на клиента: агрегирует вызовы к нескольким сервисам, обрезает лишние поля, приводит формат и принадлежит команде этого клиента. Отличие от gateway: gateway один и доменно-нейтрален, BFF-ов несколько и они знают про конкретный UI. Логика в BFF допустима, но только презентационная: «на экране заказа показываем адрес, статус и три последних события» - это про экран, а не про домен.
«Gateway это единая точка отказа». Да, поэтому его держат в нескольких репликах за L4-балансировщиком, делают stateless и готовят деградацию (если сервис интроспекции токенов лёг - кэш решений с коротким TTL, а не отказ всем). Второй вопрос: «а не добавляет ли BFF латентность». Добавляет один хоп, но убирает несколько клиентских раунд-трипов по мобильной сети, где RTT 100+ мс, так что обычно выигрывает. Третий: «сколько BFF». По одному на тип клиента, а не на команду и не на экран, иначе получается зоопарк с общим кодом, скопированным N раз.
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 и аналоги).
- Тест на стороне потребителя гоняется против мока, сгенерированного из пакта, так что живой провайдер ему не нужен.
- Пакт публикуется в брокер с версией потребителя.
- В CI провайдера запускается верификация: реальный провайдер проверяется против всех пактов всех потребителей. Красный билд означает «твоё изменение сломает вот этих».
- Перед деплоем спрашиваем брокер: «совместима ли эта версия провайдера
со всем, что сейчас в проде?» (
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.