Асинхронное взаимодействие и события
Вместо того чтобы звонить соседу и ждать, сервис оставляет запись в общем журнале — и идёт заниматься своими делами. Разбираем, что мы за это получаем и чем платим.
Асинхронное взаимодействие — обмен через посредника (брокера сообщений), при котором отправитель не ждёт ответа и, как правило, вообще не знает, кто, когда и сколько раз обработает его сообщение.
Вернёмся к примеру из прошлого урока. Заказ оформлен. Нужно: списать товар со склада, начислить бонусы, отправить письмо, обновить аналитику, дёрнуть антифрод. Синхронно это пять вызовов, пять таймаутов и пять причин, по которым кнопка «Оформить» перестанет работать. А главное — каждый раз, когда бизнес придумывает шестое действие, вам придётся править сервис заказов, который к этому шестому действию не имеет никакого отношения.
Асинхронный подход переворачивает картинку. Сервис заказов не командует соседями. Он сообщает факт: «заказ 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 — от тихого отставания консюмеров.