Асинхронное взаимодействие и события

Вместо того чтобы звонить соседу и ждать, сервис оставляет запись в общем журнале — и идёт заниматься своими делами. Разбираем, что мы за это получаем и чем платим.

Асинхронное взаимодействие — обмен через посредника (брокера сообщений), при котором отправитель не ждёт ответа и, как правило, вообще не знает, кто, когда и сколько раз обработает его сообщение.

Вернёмся к примеру из прошлого урока. Заказ оформлен. Нужно: списать товар со склада, начислить бонусы, отправить письмо, обновить аналитику, дёрнуть антифрод. Синхронно это пять вызовов, пять таймаутов и пять причин, по которым кнопка «Оформить» перестанет работать. А главное — каждый раз, когда бизнес придумывает шестое действие, вам придётся править сервис заказов, который к этому шестому действию не имеет никакого отношения.

Асинхронный подход переворачивает картинку. Сервис заказов не командует соседями. Он сообщает факт: «заказ 1024 оплачен». Дальше это уже не его дело.

Событие, а не вызов

Событие — это утверждение о том, что уже произошло, в прошедшем времени и без адресата. Команда — указание кому-то что-то сделать. Разница не в терминологии, а в том, кто от кого зависит.

КомандаСобытие
ФормулировкаSendWelcomeEmail — «отправь письмо»OrderPaid — «заказ оплачен»
АдресатКонкретный, известен отправителюНеизвестен, их может быть 0 или 10
Кто знает о комОтправитель знает получателяПолучатель знает отправителя
Добавить новую реакциюПравить отправителяПросто подписаться, отправитель не меняется
Ожидание ответаОбычно даНет

Вот как выглядит нормальное событие. Оно самодостаточно: подписчику не нужно бежать обратно в сервис заказов за подробностями.

{
  "event_id": "01HQ2Z8K4M-9c1f",
  "event_type": "OrderPaid",
  "event_version": 1,
  "occurred_at": "2026-07-14T10:31:05Z",
  "producer": "orders-service",
  "data": {
    "order_id": 1024,
    "customer_id": 42,
    "amount": 5300,
    "currency": "RUB",
    "items_count": 3
  }
}

Четыре поля метаданных здесь важнее самих данных. event_id позволит подписчику отбросить дубль. event_type и event_version дадут понять, как это читать. occurred_at — время факта, а не время доставки: сообщение может прилететь через час после того, как событие случилось.

Брокер как посредник

Брокер сообщений — инфраструктурный сервис, который принимает сообщения от продюсеров, хранит их и отдаёт консюмерам. Он делает три вещи, которых не даст прямой вызов: буферизует (сосед лежит — сообщения ждут), размножает (одно событие получают все подписчики) и сглаживает пики (продюсер выдал 10 000 событий в секунду, консюмер спокойно переваривает по 500).

                        ┌→ inventory-service   (списать товар)
orders-service → БРОКЕР ├→ loyalty-service     (начислить бонусы)
   (продюсер)   topic:  ├→ mail-service        (отправить письмо)
                orders  └→ analytics-service   (посчитать выручку)

Новому сервису нужны заказы? Он просто подписывается.
В orders-service не меняется НИ СТРОЧКИ.

Два больших семейства брокеров стоит различать сразу. Очередь (RabbitMQ и подобные): сообщение доставлено, обработано, подтверждено — и удалено. Лог (Apache Kafka): сообщения складываются в упорядоченный журнал и лежат там заданный срок, а каждый консюмер держит свой курсор (offset) и может перечитать историю с любого места. Отсюда и разные сценарии: очередь — про распределение задач, лог — про поток фактов, который можно переиграть заново.

Слабая связанность: что мы выиграли

  • Отказ соседа больше не ваш отказ. Сервис бонусов лежит два часа — заказы продолжают оформляться, события копятся в брокере, после подъёма консюмер догоняет отставание.
  • Новые потребители бесплатны. Отдел маркетинга захотел реагировать на оплаты — заводит свой сервис и подписывается. Владелец заказов об этом даже не узнает.
  • Пики не убивают. «Чёрная пятница» — брокер работает демпфером между быстрым фронтом и медленной обработкой.
  • Скорость ответа пользователю. Кнопка «Оформить» отвечает за 80 мс, потому что она делает одно дело: сохраняет заказ и публикует событие.

Eventual consistency: чем мы заплатили

Eventual consistency (согласованность в конечном счёте) — свойство системы, при котором данные в разных сервисах какое-то время расходятся, но при отсутствии новых изменений неизбежно сходятся.

Пользователь оплатил заказ. Сервис заказов уже знает, что заказ оплачен. Сервис бонусов узнает об этом через 300 миллисекунд. Или через 3 секунды. Или через 20 минут, если консюмер отстал. Всё это время система несогласованна, и это не баг, а осознанная плата за независимость сервисов.

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

СценарийДопустимо ли отставаниеЧто делать
Начисление бонусовДа, секунды и даже минутыСобытие, показать «начисляем…»
Письмо о заказеДа, минутыСобытие
Списание денег с картыНетСинхронно, с ключом идемпотентности
Проверка остатка при оформленииСпорноСинхронная проверка + компенсация, если не сошлось
Витрина «мои заказы» сразу после покупкиНет — пользователь ждёт увидеть свой заказRead-your-writes: читать из источника или показать локально

Как это работает

Брокеры почти всегда дают гарантию at-least-once — «хотя бы один раз». Это значит: сообщение точно не потеряется, но может прийти дважды. Почему? Консюмер обработал сообщение, но не успел отправить подтверждение (ack) — брокер честно считает его необработанным и доставляет снова.

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

events = [
    {"id": "e-1", "type": "OrderPaid", "order": 100, "amount": 500},
    {"id": "e-2", "type": "OrderPaid", "order": 101, "amount": 300},
    {"id": "e-1", "type": "OrderPaid", "order": 100, "amount": 500},  # дубль от брокера
]

processed = set()          # в бою это таблица в БД с UNIQUE по event_id
bonus_balance = 0

for e in events:
    if e["id"] in processed:
        print(f"{e['id']}: уже обработано, пропускаем")
        continue
    processed.add(e["id"])
    bonus_balance += e["amount"] // 100
    print(f"{e['id']}: начислено бонусов: {e['amount'] // 100}")

print("Итоговый баланс бонусов:", bonus_balance)

Результат:

e-1: начислено бонусов: 5
e-2: начислено бонусов: 3
e-1: уже обработано, пропускаем
Итоговый баланс бонусов: 8

Без этих семи строк повторная доставка начислила бы бонусы дважды. С ними — сколько угодно раз, результат один.

Вторая механика, о которой обязательно надо знать, — transactional outbox. Классическая дыра: сервис сохранил заказ в свою БД и упал, не успев опубликовать событие. Заказ есть — события нет, бонусы никогда не начислят. Публиковать сначала в брокер тоже нельзя: событие уйдёт, а транзакция откатится, и подписчики будут реагировать на несуществующий заказ. Решение: писать событие в ту же БД и в той же транзакции, в таблицу outbox, а отдельный процесс вычитывает её и толкает в брокер. Транзакция атомарна — либо есть и заказ, и запись в outbox, либо ничего.

Третья — DLQ (dead letter queue), очередь «мёртвых» сообщений. Событие, которое не удалось обработать после N попыток, уезжает в отдельную очередь, а не крутится в бесконечном ретрае, блокируя всю остальную обработку. Разбирать DLQ должен человек: там лежат настоящие баги.

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

  • Команда, замаскированная под событие. Событие SendEmailRequested — это команда: отправитель точно знает, чего он хочет и от кого. Настоящее событие называется OrderPaid и ничего не требует.
  • Анемичное событие. В теле только {"order_id": 1024}, и каждый из семи подписчиков синхронно бежит в сервис заказов за подробностями. Вы вернули синхронную связанность и вдобавок устроили DDoS собственному сервису. Кладите в событие данные, нужные потребителям.
  • Обработчик без идемпотентности. Дубль обязательно случится — не «если», а «когда». Двойное списание со склада или двойное письмо клиенту — прямое следствие.
  • Публикация события до коммита транзакции. Подписчик получит событие и пойдёт читать данные, которых в БД ещё (или уже) нет. Лечится outbox.
  • Асинхронность там, где пользователь ждёт ответа. «Ваш заказ обрабатывается» вместо мгновенного ответа — иногда честно, но чаще это вы просто спрятали проблему за спиннером.
  • Брокер как база данных. Хранить в Kafka «текущее состояние заказа» и вычитывать его при каждом запросе — плохая идея. Брокер — про поток, а не про произвольный доступ.
  • Нет мониторинга отставания консюмера (lag). Сервис «работает», ошибок нет, а очередь растёт третьи сутки, и бонусы опаздывают на два дня. Lag — метрика номер один в асинхронной системе.

Итоги

  • Событие сообщает о факте в прошедшем времени и не знает своих получателей; команда адресована конкретному исполнителю. Это разные вещи с разной связанностью.
  • Брокер даёт буферизацию, размножение и сглаживание пиков — благодаря ему падение соседа перестаёт быть вашим падением, а новый потребитель подключается без правки продюсера.
  • Цена — eventual consistency: данные в сервисах какое-то время расходятся. Окно несогласованности нужно обсуждать с бизнесом, а не прятать.
  • Доставка обычно at-least-once, поэтому дубли неизбежны, и каждый обработчик обязан быть идемпотентным (проверка по event_id).
  • Transactional outbox спасает от «заказ есть, события нет», DLQ — от вечных ретраев, метрика lag — от тихого отставания консюмеров.
Проверьте себя
1. Брокер даёт гарантию at-least-once. Что из этого следует для обработчика события?
AСообщение может потеряться, поэтому продюсер должен отправлять его дважды
BСообщение может прийти повторно, поэтому обработчик обязан быть идемпотентным
CПорядок сообщений всегда гарантирован, дополнительных мер не нужно
DОбработчик может не отправлять подтверждение — брокер сам разберётся
2. Сервис заказов сохранил заказ в свою БД и упал до того, как опубликовал событие OrderPaid. Какой приём решает эту проблему?
AПубликовать событие в брокер до записи в БД
BУвеличить количество ретраев публикации в брокер
CTransactional outbox: писать событие в ту же БД в той же транзакции, а публиковать отдельным процессом
DПеревести вызов бонусов обратно на синхронный REST