Зачем разделять
В монолите вызов $orderService->create($dto) -- это метод в памяти: нет сети, нет партиций, стектрейс полный. В микросервисах у каждого вызова есть цена: RTT сети, сериализация, возможный таймаут, retry. Выбор между sync (request-response) и async (events) -- это не вкусовщина, а решение о connascence во времени, отказоустойчивости и latency.
Определения
- Sync (request-response) -- вызывающий блокируется до ответа. HTTP/REST, gRPC, GraphQL query.
- Async (event-driven) -- вызывающий отправляет сообщение и продолжает работу. Kafka, RabbitMQ, SNS/SQS, NATS.
Temporal coupling
Это ключевое понятие. Sync-вызов создаёт временную зависимость: оба сервиса должны быть живы одновременно. Async -- нет: продюсер пишет, консьюмер читает когда сможет.
Sync (temporal coupling):
OrderService ----HTTP----> InventoryService
| |
+------ должны оба быть UP ------+
Async (loose coupling):
OrderService --> Kafka --> InventoryService
^ ^ ^
| может упасть | буфер | может быть down
| и не важно | сохранит | догонит потом
Когда что выбирать
| Критерий | Sync | Async |
|---|---|---|
| Нужен ответ немедленно | да | нет |
| Клиент ждёт результата | HTTP user | background job |
| Допустим eventual consistency | нет | да |
| Много потребителей одного события | неудобно | естественно |
| Тяжёлые долгие операции | плохо (таймауты) | хорошо |
| Простая отладка | да (стектрейс, curl) | сложнее (трассировка) |
| Backpressure из коробки | нет | да (очередь) |
Типичное правило
- Query (чтение) -- sync. Клиент ждёт данные.
- Command (запись, побочный эффект) -- чаще async. Приняли команду, подтвердили, обработаем.
- Cross-aggregate изменения -- async через события (Saga, domain events).
Ошибки "всё async"
Молодые команды часто уходят в крайность "Kafka везде". Типичные грабли:
- Query-via-event -- "отправь событие
UserRequested, получи ответ вUserResponse". Это переизобретённый RPC с худшей отладкой и latency. - Цепочки длиной 6+ --
A -> B -> C -> D -> E -> F, каждая пишет в Kafka. Один баг в середине -- события копятся, DLQ переполняется, никто не знает где. - Нет идемпотентности -- at-least-once доставка умножает побочные эффекты. Заказ обработан дважды, деньги списаны дважды.
- Нет correlation ID -- в распределённой трассировке теряется связь между событиями. Отладка превращается в археологию.
- Событие как "RPC без ответа" -- продюсер думает что дело сделано. Консьюмер упал. Бизнес-логика молча не выполнилась.
Практика: checkout flow двумя способами
Рассмотрим одну и ту же бизнес-задачу -- оформление заказа: создать заказ, зарезервировать товар, списать деньги, отправить письмо.
Вариант A: Sync (orchestration через HTTP)
Orchestrator (CheckoutService) последовательно вызывает сервисы, сам обрабатывает ошибки и компенсации.
<?php
declare(strict_types=1);
namespace App\Checkout\Sync;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Psr\Log\LoggerInterface;
/**
* Sync orchestrator for checkout.
*
* Calls inventory, payment, notification services via HTTP.
* On failure - runs compensation (saga).
*/
final class SyncCheckoutOrchestrator
{
public function __construct(
private readonly HttpClientInterface $inventory,
private readonly HttpClientInterface $payment,
private readonly HttpClientInterface $notification,
private readonly OrderRepository $orders,
private readonly LoggerInterface $logger,
) {}
public function checkout(CheckoutCommand $cmd): CheckoutResult
{
$orderId = $this->orders->createPending($cmd);
$reservationId = null;
$paymentId = null;
try {
// Step 1: reserve inventory (timeout 3s)
$resp = $this->inventory->request('POST', '/reservations', [
'json' => ['order_id' => $orderId, 'items' => $cmd->items],
'timeout' => 3.0,
'headers' => ['Idempotency-Key' => $orderId],
]);
$reservationId = $resp->toArray()['reservation_id'];
// Step 2: charge payment (timeout 5s)
$resp = $this->payment->request('POST', '/charges', [
'json' => [
'order_id' => $orderId,
'amount' => $cmd->totalCents,
'currency' => $cmd->currency,
],
'timeout' => 5.0,
'headers' => ['Idempotency-Key' => $orderId],
]);
$paymentId = $resp->toArray()['payment_id'];
$this->orders->markPaid($orderId, $paymentId, $reservationId);
// Step 3: notification is fire-and-forget even in sync flow
$this->notification->request('POST', '/emails', [
'json' => ['template' => 'order_confirmed', 'order_id' => $orderId],
'timeout' => 2.0,
]);
return CheckoutResult::success($orderId);
} catch (HttpExceptionInterface $e) {
$this->logger->error('checkout failed', [
'order_id' => $orderId,
'step' => $paymentId ? 'notify' : ($reservationId ? 'pay' : 'reserve'),
'error' => $e->getMessage(),
]);
// Compensation: release reservation if taken
if ($reservationId !== null && $paymentId === null) {
$this->safeRelease($reservationId);
}
// If payment succeeded but something after failed - still OK for user
// Notification failure is non-fatal.
$this->orders->markFailed($orderId, $e->getMessage());
return CheckoutResult::failure($orderId, $e->getMessage());
}
}
private function safeRelease(string $reservationId): void
{
try {
$this->inventory->request('DELETE', "/reservations/$reservationId", [
'timeout' => 3.0,
]);
} catch (\Throwable $e) {
// Compensation failed - send to DLQ / alert
$this->logger->critical('compensation failed', [
'reservation_id' => $reservationId,
'error' => $e->getMessage(),
]);
}
}
}
Ни один сервис не знает про других. Каждый реагирует на события и публикует свои.
OrderCreated -> [inventory] -> InventoryReserved
\
[payment] -> PaymentCharged
\
[notification] -> EmailSent
<?php
declare(strict_types=1);
namespace App\Checkout\Async;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Psr\Log\LoggerInterface;
// ============ Events ============
final readonly class OrderCreated
{
public function __construct(
public string $orderId,
public string $userId,
public array $items,
public int $totalCents,
public string $currency,
) {}
}
final readonly class InventoryReserved
{
public function __construct(
public string $orderId,
public string $reservationId,
) {}
}
final readonly class InventoryReservationFailed
{
public function __construct(
public string $orderId,
public string $reason,
) {}
}
final readonly class PaymentCharged
{
public function __construct(
public string $orderId,
public string $paymentId,
) {}
}
// ============ Handlers (choreography) ============
#[AsMessageHandler]
final class OnOrderCreated
{
public function __construct(
private readonly InventoryService $inventory,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger,
) {}
public function __invoke(OrderCreated $e): void
{
// Idempotency: if already reserved for this order, skip
if ($existing = $this->inventory->findReservation($e->orderId)) {
$this->bus->dispatch(new InventoryReserved($e->orderId, $existing));
return;
}
try {
$reservationId = $this->inventory->reserve($e->orderId, $e->items);
$this->bus->dispatch(new InventoryReserved($e->orderId, $reservationId));
} catch (InsufficientStockException $ex) {
$this->bus->dispatch(new InventoryReservationFailed($e->orderId, $ex->getMessage()));
}
}
}
#[AsMessageHandler]
final class OnInventoryReserved
{
public function __construct(
private readonly PaymentService $payment,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(InventoryReserved $e): void
{
$order = $this->payment->loadOrder($e->orderId);
// Idempotency check at handler level
if ($paymentId = $this->payment->findCharge($e->orderId)) {
$this->bus->dispatch(new PaymentCharged($e->orderId, $paymentId));
return;
}
$paymentId = $this->payment->charge(
orderId: $e->orderId,
amount: $order->totalCents,
currency: $order->currency,
idempotencyKey: $e->orderId,
);
$this->bus->dispatch(new PaymentCharged($e->orderId, $paymentId));
}
}
#[AsMessageHandler]
final class OnInventoryReservationFailed
{
public function __construct(
private readonly OrderRepository $orders,
private readonly NotifierService $notifier,
) {}
public function __invoke(InventoryReservationFailed $e): void
{
// No compensation needed - no payment yet.
$this->orders->markFailed($e->orderId, $e->reason);
$this->notifier->notifyUser($e->orderId, 'order_failed_out_of_stock');
}
}
В choreography compensation -- это такие же события:
PaymentFailed -> [inventory handler] -> releases reservation
\-> [order handler] -> marks order cancelled
Каждый сервис знает только как откатывать свои изменения. Saga-coordinator (если используется orchestrated saga) хранит состояние и шлёт команды явно -- это гибрид между choreography и sync orchestration.
Latency comparison
Для checkout flow с 3 шагами:
| Подход | Happy path p50 | Happy path p99 | Failure recovery | Наблюдаемость |
|---|---|---|---|---|
| Sync HTTP | 80-150 ms | 400-800 ms | быстрый откат, но ошибки каскадируют | тривиальная (HTTP логи + traces) |
| Async Kafka | 200-500 ms end-to-end | 2-5 сек (consumer lag) | сложнее, зато надёжнее | сложнее (correlation ID обязателен) |
| gRPC (sync) | 30-80 ms | 150-400 ms | как HTTP, быстрее | нужна настройка |
Ключевой trade-off
Sync быстрее в happy path, но хрупче: если inventory тормозит -- тормозит весь checkout. Async медленнее в среднем, но эластичнее: всплеск заказов буферизуется в Kafka и обрабатывается по capacity.
Гибридный подход (рекомендация)
В реальных системах обычно гибрид:
- Прямой путь пользователя -- sync. HTTP POST
/checkoutвозвращает202 Acceptedсorder_id. - Фоновая оркестрация -- async. OrderService эмитит
OrderCreatedв Kafka, choreography разворачивает процесс. - Клиент поллит или получает WebSocket -- видит статус:
pending -> paid -> confirmed.
Так достигается и быстрый отклик пользователю, и устойчивость обработки.
// HTTP controller (thin handler)
#[Route('/checkout', methods: ['POST'])]
public function checkout(CheckoutRequest $req, CommandBus $bus): JsonResponse
{
$orderId = $bus->dispatch(new CreateOrderCommand($req->userId, $req->items));
// Returns immediately - processing continues in the background.
return new JsonResponse(['order_id' => $orderId, 'status' => 'pending'], 202);
}
Checklist: какой вызов делать sync, какой async
- Нужен ответ пользователю в HTTP? -- sync до первого подтверждения, остальное async
- Операция идемпотентна? -- обязательно для async
- Есть лимит времени на обработку? -- sync (лучше видно таймаут)
- Есть множественные consumer'ы одного события? -- async естественно
- Логика критична к порядку? -- одна partition Kafka / очередь
- Требуется низкая latency и возможно деградировать? -- sync с circuit breaker
- Длинный workflow (минуты-часы)? -- async + persisted saga (Temporal, Cadence)
Выводы
Sync -- про немедленность и простоту отладки, async -- про устойчивость и слабую связность. "Всё async" -- антипаттерн: цепочки без ответа трудно отлаживать. "Всё sync" -- тоже антипаттерн: один медленный сервис кладёт всю систему. Для checkout-подобных операций рабочий рецепт: принять команду sync (HTTP 202), обработать через choreography в Kafka, показать прогресс клиенту через polling/WebSocket. Всегда нужны: идемпотентность, correlation ID, DLQ, мониторинг consumer lag.