MidПрактика16 min

Sync vs Async коммуникация между сервисами

Request-response vs events, orchestration vs choreography, temporal coupling, saga. PHP и Go реализации checkout flow

Зачем разделять

В монолите вызов $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 везде". Типичные грабли:

  1. Query-via-event -- "отправь событие UserRequested, получи ответ в UserResponse". Это переизобретённый RPC с худшей отладкой и latency.
  2. Цепочки длиной 6+ -- A -> B -> C -> D -> E -> F, каждая пишет в Kafka. Один баг в середине -- события копятся, DLQ переполняется, никто не знает где.
  3. Нет идемпотентности -- at-least-once доставка умножает побочные эффекты. Заказ обработан дважды, деньги списаны дважды.
  4. Нет correlation ID -- в распределённой трассировке теряется связь между событиями. Отладка превращается в археологию.
  5. Событие как "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(),
            ]);
        }
    }
}
### Вариант B: Async (choreography через Kafka)

Ни один сервис не знает про других. Каждый реагирует на события и публикует свои.

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');
    }
}
### Saga upstream: rollback через компенсирующие события

В 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.

Гибридный подход (рекомендация)

В реальных системах обычно гибрид:

  1. Прямой путь пользователя -- sync. HTTP POST /checkout возвращает 202 Accepted с order_id.
  2. Фоновая оркестрация -- async. OrderService эмитит OrderCreated в Kafka, choreography разворачивает процесс.
  3. Клиент поллит или получает 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.