Когда сервисов больше одного, они должны как-то общаться. Самый очевидный путь — один сервис вызывает другой по HTTP и ждёт ответа. Но есть и другой способ: сервис публикует сообщение в очередь и идёт дальше, не ожидая никого.
Разберём, что означает каждый подход, в чём реальная разница и как не ошибиться с выбором.
Сразу на одном примере — оформление заказа, которому нужны четыре соседних сервиса.
Второстепенный сосед, привязанный синхронным вызовом, отменяет главный сценарий: лёг сервис писем — не оформился заказ. Синхронным оставляем только то, без чего нечего показать пользователю, остальное уходит событием в очередь и догоняет позже — цепочка из пяти шагов превращается в три.
Синхронный вызов: спросил — жди ответа
Представьте, что вы звоните в службу доставки и ждёте на линии, пока оператор проверит, доступен ли ваш адрес. Пока оператор не ответил — вы ничего не можете продолжать делать.
Именно так работает синхронный вызов между сервисами. Сервис A отправляет HTTP-запрос к сервису B и блокируется в ожидании ответа. Только получив результат, он продолжает работу.
Синхронный вызов: A стоит и ждёт ответа. Значит, доступность A не выше доступности B, а его время ответа складывается из своего и чужого.
Это привычно и просто: видно, что происходит, ошибки возвращаются сразу, результат есть здесь и сейчас.
Но есть цена: оба сервиса должны работать одновременно. Если сервис B недоступен — операция в сервисе A тоже падает. Если B тормозит — A тоже тормозит. Их судьбы связаны.
Асинхронный вызов: опубликовал — живи дальше
Теперь другой сценарий. Вы не звоните оператору, а оставляете сообщение на автоответчике: «Оформите заказ № 42». И тут же занимаетесь другими делами — оператор перезвонит, когда обработает.
Так работает взаимодействие через брокер сообщений (Kafka, RabbitMQ и подобные). Сервис A публикует событие в очередь и не ждёт: он уже завершил свою работу. Сервис B читает это событие тогда, когда готов.
Асинхронный обмен: A кладёт событие в очередь и продолжает работу немедленно. B может быть недоступен в этот момент — сообщение дождётся его в очереди.
Если сервис B временно недоступен — событие лежит в очереди и дожидается его. Когда B поднимется, он его обработает.
Но «дожидается» — не значит «вечно». У сообщения есть срок хранения: Kafka по умолчанию держит записи неделю, в RabbitMQ срок задают самой очереди или отдельному сообщению, и там же ставят потолок её длины. Если B пролежал дольше этого срока или очередь успела переполниться, событие пропадёт тихо — и узнают об этом уже по разъехавшимся данным, а не по ошибке.
Обратная сторона: ответа нет. A не знает, когда B обработает событие и обработает ли вообще. Результат появится через секунды, а то и позже.
Главный вопрос выбора
Задайте себе один вопрос: нужен ли результат соседнего сервиса прямо сейчас, чтобы продолжить операцию?
Если да — синхронный вызов. Примеры:
- проверить остаток на счёте перед списанием;
- получить стоимость доставки, чтобы показать итог в корзине;
- авторизовать платёж в платёжном шлюзе.
Если нет — асинхронное событие. Примеры:
- уведомить пользователя по email после оформления заказа;
- обновить поисковый индекс, когда товар изменился;
- начислить бонусные баллы;
- передать данные в аналитику.
Хороший тест: что покажет пользователь экран, если сосед не ответил? Если «операция не завершилась» — нужен синхронный вызов. Если «то же самое, что и обычно» — подойдёт асинхронное событие.
Что происходит, когда сосед недоступен
Это самое важное практическое отличие.
При синхронном вызове недоступность соседа — ваш отказ. Если платёжный шлюз лежит, оплата не проходит. Это честно: без шлюза оплата всё равно бессмысленна.
При асинхронном взаимодействии сосед может быть недоступен — это не ваша проблема. Заказ оформляется успешно, событие уходит в очередь, сервис уведомлений обработает его позже, когда поднимется.
Частая ошибка: привязывать главный сценарий к второстепенному сервису синхронным вызовом. Если оформление заказа падает из-за того, что сервис начисления бонусов недоступен — это плохая архитектура. Бонусы второстепенны, они должны быть асинхронными.
Один получатель или несколько
При синхронном вызове у вас один адресат: сервис A знает и вызывает конкретный сервис B.
При публикации события получателей может быть сколько угодно. Событие «заказ оплачен» одновременно интересно складу, сервису уведомлений, системе аналитики и отделу лояльности. Все они подписываются на это событие и обрабатывают независимо.
Издатель не знает своих читателей: лояльность подписалась позже, и сервис заказов при этом не меняли.
Главное: издатель события не знает, кто его читает. Когда появится новый потребитель, сервис заказов не нужно трогать — новый сервис просто подписывается на очередь.
При синхронных вызовах добавление нового потребителя требует изменений в вызывающем коде.
Цена асинхронности: дубли и порядок
До сих пор события выглядели как чистая выгода: опубликовал и живи дальше. Плата за это есть, и она одна и та же у всех брокеров — две вещи, которых при синхронном вызове не было.
Сообщение придёт больше одного раза. Все популярные брокеры дают доставку «хотя бы один раз». Это не оговорка мелким шрифтом: подтверждение обработки не дошло, соединение оборвалось, потребителя перезапустили посреди пачки — и сообщение приходит снова. Возможность «ровно один раз» на словах существует, но при записи во внешнюю базу она не работает: брокер и база не участвуют в одной транзакции.
Значит, обработчик события обязан быть идемпотентным: второй приход того же события не должен ничего изменить. Минимальный способ — таблица обработанных событий, куда идентификатор вставляется в той же транзакции, что и бизнес-действие; повторная вставка падает на уникальном индексе, и обработчик просто выходит. Иногда идемпотентность получается сама: «установить статус в PAID» безопасно повторять, а «прибавить 100 бонусов» — нет. Разбор приёмов — в статье про шаблоны обмена сообщениями.
Практический вывод для выбора: если обработчик нельзя сделать идемпотентным (например, он списывает деньги и у внешнего провайдера нет ключа операции), асинхронность обойдётся дороже, чем кажется.
Порядок не гарантирован — почти. Два события об одном заказе, «оплачен» и «отменён», могут прийти в обратном порядке, и обработчик, который просто применяет последнее пришедшее, поставит заказу неверный статус. Гарантии тут такие: в Kafka порядок сохраняется внутри партиции, то есть для событий с одним ключом — поэтому ключом берут идентификатор заказа, а не случайное число. В RabbitMQ порядок держится внутри одной очереди с одним активным потребителем; как только потребителей несколько, порядок теряется, и лечится это отдельными приёмами.
Но даже с правильным ключом полагаться только на порядок нельзя: отправитель мог опубликовать события в неверной последовательности, а повторная доставка ломает её и при одном потребителе. Поэтому состояние меняют по правилам, а не по факту прихода:
- Переходы состояний описаны явно. «Отменён» из «оплачен» допустим, «оплачен» из «отменён» — нет и просто игнорируется. Это обычный конечный автомат, и он же защищает от дублей.
- У события есть версия или время самого события. Если пришло событие с версией меньше текущей, оно устарело — применять его не нужно. Это дешевле полноценного автомата и хорошо работает для «последнее значение поля».
Тот же вопрос в лоб: если ваша логика зависит от строгого порядка нескольких событий из разных сервисов, это признак, что шаги стоило сделать явной последовательностью с состоянием, а не набором независимых подписок.
Нагрузка и буферизация
Синхронный вызов передаёт нагрузку напрямую. Если на сервис A пришли 10 000 запросов в секунду, столько же запросов прилетит в сервис B. Его лимиты становятся вашими лимитами.
Брокер сообщений работает как буфер. Сервис A публикует события быстро, сервис B читает их в своём темпе. Пиковые нагрузки сглаживаются естественным образом. У буфера есть цена: пока сервис B разгребает накопившееся, его данные отстают от реальности, и пользователю это надо показать — «остаток обновляется» вместо цифры, которая уже неверна.
Как узнать, что событие обработано
«Обратная сторона: ответа нет» — верно, но для читателя, который впервые видит очередь, это главный практический вопрос: событие ушло, а как узнать, что с ним стало? Ответов три, и они не взаимозаменяемы.
Обратное событие. Обработчик, закончив работу, публикует своё событие: NotificationSent, PaymentAuthorized, ShipmentBooked. Отправитель подписывается на него и обновляет своё состояние. Это основной путь для связки между сервисами: связность остаётся низкой, и обратное событие может слушать кто-то ещё. Цена — у отправителя теперь есть собственное состояние «ждём ответа», которое надо где-то хранить и как-то закрывать по таймауту.
Запрос статуса. Отправитель хранит идентификатор операции и по нему спрашивает статус — у себя, если обратное событие уже обновило его модель, или у соседа, если нет. Это то, что обычно отдаётся наружу клиенту: POST /orders отвечает 202 Accepted с адресом /orders/42/status, а клиент опрашивает его. Просто и понятно, но опрос стоит запросов и даёт задержку размером с интервал опроса.
Подписка от клиента. Браузер или мобильное приложение держит соединение (веб-сокет, поток событий) и получает уведомление о готовности сразу. Хорошо для интерфейса, но требует, чтобы уведомление доехало до конкретного соединения, и не заменяет два предыдущих способа на стороне сервисов.
Что выбирают на практике: между сервисами — обратное событие, для клиента — 202 плюс статус, а подписку добавляют, когда ожидание заметно человеку. И к любому из вариантов нужна страховка: операция, которая слишком долго висит в состоянии «ждём», должна попадать в фоновую проверку, а не ждать вечно — как это устроено для внешних провайдеров, разобрано ниже.
Что показывать пользователю, пока результата нет. Не «оплачено» и не пустой спиннер, а честное промежуточное состояние: «заказ оформлен, оплата проверяется». Это состояние живёт в модели, а не в интерфейсе, потому что по нему работает и фоновая проверка, и повторное открытие страницы. Правило простое: если в модели нет состояния «в процессе», значит интерфейс врёт одной из сторон.
Что ломается на асинхронной стороне
Синхронный вызов ломается понятно: таймаут, код ответа, стектрейс. Асинхронная связка ломается иначе, и к этим четырём случаям стоит быть готовым заранее — иначе первый инцидент будет выглядеть как «всё работает, но ничего не происходит».
Отставание потребителя. Обработчик читает медленнее, чем в очередь пишут, и разрыв растёт. Внешне ничего не падает: запросы проходят, ошибок нет, просто данные всё сильнее отстают от реальности. Это главная метрика асинхронной системы — размер очереди или лаг группы потребителей, — и на неё ставят сигнал по тренду, а не по порогу: важно не «сейчас тысяча сообщений», а «растёт полчаса и не разгребается». Лечение: больше потребителей (но не больше, чем позволяет параллелизм брокера), ускорение обработчика, а иногда осознанный сброс — например, отбросить устаревшие события обновления кэша.
Отравленное сообщение. Одно сообщение, на котором обработчик падает всегда: неразбираемое тело, ссылка на удалённую сущность, число, которое не влезает. Без защиты оно блокирует обработку: упал, вернулось, снова упал. Так узнают, что значит «очередь встала из-за одного заказа». Лечение стандартное: несколько попыток, потом сообщение уходит в очередь недоставленных, а обработка едет дальше.
Очередь недоставленных, в которую никто не смотрит. Она сама по себе ничего не решает: сообщения в ней лежат, а данные в системе неполны. Значит, у неё есть сигнал «в очереди недоставленных больше нуля» и есть человек, который её разбирает, — обычно дежурный. И в сообщении должна быть причина: класс исключения и стектрейс, иначе разбор превращается в сопоставление времени с логами.
Тишина вместо ошибки. Событие не опубликовалось (брокер был недоступен, а отправку никто не проверил), потребитель не подписан (опечатка в имени очереди или в маске), сообщение ушло в никуда, потому что ключ не совпал ни с одной привязкой. Во всех случаях всё «работает», просто ничего не происходит — и узнают об этом от пользователей. Отсюда два правила: отправку страхуют таблицей исходящих сообщений, а на каждый важный поток ставят проверку «событий за последний час больше нуля». Отсутствие трафика — тоже авария, и её ловит только явная проверка.
Ко всему этому добавляется отладка: путь запроса больше не виден в одном стеке вызовов. Сквозной идентификатор запроса, который едет в заголовках сообщения и попадает в логи обоих сервисов, — не украшение, а единственный способ потом собрать картину.
Типичные ошибки
Цепочка синхронных вызовов. Оформление заказа последовательно вызывает склад, биллинг, доставку и уведомления. Звеньев в этой цепочке пять, а не четыре: сам сервис заказов тоже иногда лежит, и его доступность так же входит в общий счёт. Если каждое звено работает 99 % времени, вся цепочка — это 99 % от 99 % и так пять раз подряд, то есть 95 %. Латентности складываются. Отказ четвёртого сервиса отменяет работу трёх предыдущих. Такие сценарии с несколькими шагами естественно ложатся на события, а не на цепочку вызовов.
Команда в очереди. События называют свершившиеся факты: «OrderPaid», «UserRegistered», «InvoiceIssued» — их может слушать кто угодно, и отправителю всё равно, кто. «SendEmailRequested» — не факт, а приказ конкретному сервису, замаскированный под событие. Это худшее из двух миров: связность команды плюс сложность асинхронности.
Асинхронность там, где пользователь ждёт ответа. «Ваш запрос принят в обработку» вместо результата оплаты — и пользователь вынужден обновлять страницу и проверять статус. Интерактивные шаги, где человек смотрит на экран и ждёт, по своей природе синхронны.
События без гарантии доставки. Публикация события в брокер после коммита транзакции простым вызовом: если приложение упало между коммитом и отправкой — событие потеряно, данные разъехались. Переставить отправку перед коммитом не спасает, а делает хуже: событие уже улетело, а транзакция откатилась — и соседи начинают работать с заказом, которого в базе нет. Пока запись в базу и отправка в брокер — два независимых действия, одно из них всегда может пройти без другого. Решение — паттерн outbox: событие сначала сохраняется в ту же базу в рамках той же транзакции, отдельный процесс его отправляет.
Смотрите на место между commit и отправкой: сверху там теряется событие при падении, снизу оно уже записано той же транзакцией, и его отправляет отдельный процесс.
// Опасно: между commit и send приложение может упасть
orderRepo.save(order);
tx.commit();
kafka.send("order-events", new OrderPaid(order.id()));
// Безопасно: событие фиксируется той же транзакцией, отправляет его отдельный процесс
orderRepo.save(order);
outbox.add(new OrderPaid(order.id()));
tx.commit();
// Опасно: между Commit и отправкой приложение может упасть
orders.Save(ctx, tx, order)
tx.Commit(ctx)
producer.WriteMessages(ctx, kafka.Message{Topic: "order-events", Value: orderPaid(order.ID)})
// Безопасно: событие фиксируется той же транзакцией, отправляет его отдельный процесс
orders.Save(ctx, tx, order)
outbox.Add(ctx, tx, orderPaid(order.ID))
tx.Commit(ctx)
// Опасно: между commit и send приложение может упасть
await orderRepo.save(order, { transaction: tx });
await tx.commit();
await producer.send({ topic: 'order-events', messages: [{ value: orderPaid(order.id) }] });
// Безопасно: событие фиксируется той же транзакцией, отправляет его отдельный процесс
await orderRepo.save(order, { transaction: tx });
await outbox.add(orderPaid(order.id), { transaction: tx });
await tx.commit();
# Опасно: между commit и send приложение может упасть
order_repo.save(order, session)
session.commit()
producer.send("order-events", order_paid(order.id))
# Безопасно: событие фиксируется той же транзакцией, отправляет его отдельный процесс
order_repo.save(order, session)
outbox.add(order_paid(order.id), session)
session.commit()
Синхронный — это не только HTTP, асинхронный — не только брокер
В статье «синхронный вызов» по умолчанию означал HTTP, и это стоит уточнить, потому что выбор шире.
Синхронно. Кроме обычного HTTP есть gRPC: двоичный формат, описанный контракт, генерация клиента, потоковая передача в обе стороны и меньшие задержки на частых внутренних вызовах. Модель та же — вызвал и ждёшь, — поэтому всё сказанное про цепочки, таймауты и общую доступность верно и для него. Выбор между HTTP и gRPC — это выбор формата и удобства, а не синхронности; разбирают его статьи про стили API.
Асинхронно, но с ответом. Существует запрос-ответ поверх очереди: клиент публикует запрос, указывает адрес для ответа и ждёт его. Выглядит как асинхронность, ведёт себя как синхронный вызов — клиентский поток всё равно ждёт, — и берут это, когда сетевое ограничение не даёт сделать прямой вызов: нет публичного адреса, нужна балансировка по пулу разнородных обработчиков. Отдельно стоит знать, что у такого вызова отказы неприятнее, чем у HTTP: статья про шаблоны обмена разбирает, что ломается.
Асинхронно без брокера. Не всякая асинхронность требует отдельной системы. Фоновая задача в том же сервисе, таблица заданий в собственной базе с выборкой под блокировку, отложенный вызов по расписанию — это тоже «опубликовал и живи дальше», только очередь лежит в базе, которая у вас уже есть. Для фоновых задач одного сервиса это чаще всего правильный ответ.
Брокер — это отдельная система
В списке ошибок есть «брокер ради брокера», и это стоит развернуть в цифры, потому что решение обычно принимают, не глядя на них.
Брокер надо развернуть и обновлять: минимальная отказоустойчивая установка — три узла, у каждого свой диск и своя память, плюс настройки очередей или топиков, плюс доступы. Надо наблюдать: размер очередей, лаг потребителей, состояние узлов, место на диске — и на каждое поставить сигнал. Надо чинить: разобранные выше отставание, отравленные сообщения и очередь недоставленных — это работа дежурного, и ночью она сложнее, чем перезапуск сервиса. И надо учить: разработчику, который раньше писал синхронные вызовы, теперь нужны идемпотентность, порядок, повторы и наблюдаемость асинхронных потоков — это не день чтения.
Ориентир по трудозатратам: развернуть управляемый брокер у облачного провайдера — часы, свой кластер — дни, а обслуживание в обоих случаях занимает несколько часов в месяц и заметно больше в первые месяцы, пока настраиваются сигналы. Умножать это на «а ещё второй брокер, потому что задачи разные» стоит только при очевидной необходимости.
Отсюда практический порядок выбора, который экономит больше всего сил. Пока потребитель ваших событий один и он ваш, начните с таблицы заданий в собственной базе: транзакция общая, повторы простые, ничего нового разворачивать не нужно. Брокер берут, когда появляется второй независимый читатель того же потока, когда нужна буферизация пиков, которую база уже не держит, или когда события читают сервисы других команд. Тогда его цена оправдана — и её платят осознанно, а не потому что «в микросервисах так принято».
Как они сочетаются
В одной системе оба подхода используются одновременно — и это нормально.
Синхронные вызовы там, где пользователь ждёт интерактивного ответа. Асинхронные события там, где домены сообщают друг другу о случившихся фактах.
Подозрительны крайности. Система только на HTTP-вызовах — это сцепленные сервисы, которые падают вместе. Система только на событиях, включая сценарии оплаты и авторизации, — это пользователи, смотрящие на бесконечный спиннер.
И не заводите брокер ради брокера: если между двумя сервисами одной команды один потребитель и ровная нагрузка, добавлять Kafka ради «правильной архитектуры» не стоит — REST с повторными попытками проще в отладке и наблюдении.
Глубже: узнать результат долгой внешней операции: уведомление плюс опрос застрявшихрасширенное
Платёж ушёл в банк, документ на подпись к провайдеру, посылка перевозчику, и результат будет через секунды, минуты или сутки. Синхронно ждать нельзя, а «опубликовал и живи дальше» не отвечает на вопрос, как узнать, чем кончилось. Ответ состоит из трёх частей, и любая одна из них по отдельности не работает.
Уведомление от провайдера. Провайдер присылает вебхук «платёж 42 проведён». Это основной путь: быстро и без нагрузки. Но уведомление может не прийти (сеть, наш сбой в момент доставки, ошибка в их очереди), прийти дважды и прийти не по порядку («проведён» раньше «принят»). Обработчик обязан быть идемпотентным и переводить состояние по правилам автомата, а не по порядку прихода: переход «проведён» из «принят» и из «создан» допустим, повторный «проведён» это ничего не делать и ответить 200.
Опрос застрявших. Фоновая задача раз в минуту берёт операции, которые висят в промежуточном состоянии дольше ожидаемого (status = 'PENDING' AND updated_at < now() - interval '5 minutes'), и спрашивает провайдера о статусе по его API. Это страховка от потерянного уведомления, и без неё часть операций зависает навсегда. Интервал опроса растёт с возрастом операции (через минуту, пять, полчаса), а после суток операция получает состояние «требует ручного разбора» и попадает в очередь оператора, потому что бесконечный опрос это тоже утечка.
Промежуточный статус для пользователя. Пока результата нет, пользователь видит честное «платёж обрабатывается, обычно это занимает до минуты», а не спиннер и не «оплачено». Это состояние в модели, а не в интерфейсе: PENDING с временем создания, по которому и работает опрос выше.
Защита от повторного уведомления. Дедупликация уведомлений держится на уникальном индексе: таблица provider_events(provider, event_id) с первичным ключом, вставка в той же транзакции, что и переход состояния, нарушение уникальности означает «уже обработано». Идентификатор события даёт провайдер; если не даёт, ключом становится пара «операция, новое состояние», потому что повторный переход в то же состояние безвреден по определению.
Так собирается система, где обычный путь быстрый (уведомление), редкий путь надёжный (опрос), а пользователь не введён в заблуждение. Это тот же набор, что и у вебхуков с вашей стороны в разделе REST, только теперь вы получатель.
Коротко
- Синхронный вызов: A вызывает B и ждёт ответа. Оба должны работать одновременно. Асинхронное событие: A публикует факт в очередь и идёт дальше. B обработает, когда готов.
- Главный критерий один: если экран пользователя без ответа соседа выглядит так же, это событие, а не вызов; события называют фактами домена, а не приказом конкретному сервису.
- Недоступность соседа при синхронном вызове — ваш отказ. При событии — его проблема, он догонит потом.
- События без outbox теряются при сбое между записью в БД и отправкой в брокер. Оба подхода сочетаются в одной системе — по задаче, а не по принципу.
- Результат долгой внешней операции: идемпотентный вебхук с переходами по автомату, опрос застрявших с растущим интервалом и ручной очередью после суток, честный промежуточный статус в модели, уникальный индекс на идентификатор события провайдера. Узнать результат можно обратным событием (между сервисами),
202плюс статусом (для клиента) или подпиской, и у пользователя всегда есть честное промежуточное состояние в модели. - Асинхронность даёт доставку «хотя бы один раз»: обработчик обязан быть идемпотентным, а состояние меняют по правилам переходов или по версии события, а не по факту прихода.
- Порядок держится только внутри партиции по ключу (Kafka) или внутри очереди с одним активным потребителем (RabbitMQ); зависимость от строгого порядка нескольких событий — признак, что нужна явная последовательность с состоянием.
- Асинхронная связка ломается тихо: отставание потребителя, отравленное сообщение, очередь недоставленных без читателя и полное отсутствие трафика — на каждое нужен свой сигнал.
- Брокер — отдельная система с узлами, сигналами и дежурством: пока читатель один и он ваш, дешевле таблица заданий в своей базе.
Что почитать дальше
- Распределённые паттерны — saga, outbox, идемпотентность: инструментарий асинхронной стороны.
- Паттерны отказоустойчивости — таймауты, повторные попытки, circuit breaker: инструментарий синхронной стороны.
- AMQP vs Kafka — следующий выбор, если решили использовать события.
- Проблемы распределённых систем — почему «сосед недоступен» неотличимо от «сосед медленный», и что делать с третьим исходом вызова.