0 / 42 (0%)

Sync vs async: gRPC и Kafka

Бизнесовая ситуация банальная. В приложении лояльности Brew зарегистрировался новый клиент — и про это надо узнать сразу куче сервисов: аналитике, нотификациям, биллингу, антифроду, CRM. Послезавтра аналитика переедет с Postgres на ClickHouse, и нам придётся менять — что?

Вот в этом «что» — вся лекция.

Решение «один сервис рассылает по списку получателей через gRPC» и решение «один сервис пишет одно событие в Kafka, остальные читают» снаружи делают одно и то же. По коду — два разных мира. Дальше по тексту мы построим оба и поговорим, чем они платят.

Сценарий

Клиент завёл аккаунт в приложении лояльности Brew, и сервис, принявший регистрацию, хочет «сообщить про это всем заинтересованным». Заинтересованных на старте у нас несколько:

  1. analytics (analytics-service) — пишет факт регистрации в свой сторадж для отчётов.
  2. notifications (notification-service) — шлёт welcome-письмо новому клиенту.
  3. billing — заводит пустой счёт под будущие списания за заказы.

Завтра, послезавтра — добавятся ещё.

Два варианта реализации:

  • Sync (gRPC fan-out). Сервис регистрации знает URL'ы всех получателей. Дёргает каждый по отдельному unary-RPC Notify(CustomerSignedUp). Все три ответили OK — считаем регистрацию законченной.
  • Async (Kafka). Сервис регистрации пишет одно сообщение в топик customer-events. Получатели сами подписаны (каждый в своей consumer-группе) и читают независимо. Sender'у дальше до них дела нет.

Оба варианта в репозитории и собираются. Запустим, потрогаем, сравним.

Что в коде

plaintext
06-03-sync-vs-async/
├── proto/users/v1/users.proto         # общий контракт CustomerSignedUp + service CustomerEventService
├── cmd/grpc-broadcast/                # sync sender
├── cmd/grpc-listener/                 # sync receiver (запускается N копий на разных портах)
├── cmd/kafka-broadcast/               # async sender
└── cmd/kafka-listener/                # async receiver (запускается N копий с разными -group)

CustomerSignedUp — одна и та же proto-схема для обеих веток. Чтобы не было соблазна сравнивать яблоки с грушами.

Sync: gRPC fan-out

Контракт — единственный unary RPC, его реализует каждый получатель:

proto
service CustomerEventService {
  rpc Notify(NotifyRequest) returns (NotifyResponse);
}

Receiver сидит на своём порту и ждёт, когда его позовут. Внутри — никакой логики, только лог «получил такого-то клиента»:

go
func (s *listenerServer) Notify(_ context.Context, req *usersv1.NotifyRequest) (*usersv1.NotifyResponse, error) {
    ev := req.GetEvent()
    if ev.GetCustomerId() == "" {
        return nil, status.Error(codes.InvalidArgument, "customer_id is required")
    }
    fmt.Printf("[%s] got customer_id=%s email=%s loyalty_tier=%s\n",
        s.name, ev.GetCustomerId(), ev.GetEmail(), ev.GetLoyaltyTier())
    return &usersv1.NotifyResponse{Accepted: true}, nil
}

Главное на стороне sender'а — список URL'ов. Откуда он берётся, разговор отдельный (env, конфиг, service discovery — нужное подчеркнуть). Важно, что sender про существование каждого получателя знает явно:

go
targets := flag.String("targets", "",
    "список URL'ов получателей через запятую; если пусто, берётся LISTENER_URLS")

Дальше — собственно fan-out. Два режима, потому что в учебной программе хочется увидеть оба эффекта: «один медленный тормозит всех» (последовательно) и «один промах изолирован» (параллельно):

go
if !parallel {
    for _, c := range clients {
        callOne(ctx, c, ev, timeout)
    }
    return
}
var wg sync.WaitGroup
for _, c := range clients {
    wg.Add(1)
    go func(c targetClient) {
        defer wg.Done()
        callOne(ctx, c, ev, timeout)
    }(c)
}
wg.Wait()

Теперь по пунктам, что мы тут получили — и за что заплатили.

  1. Coupling — туго. Чтобы добавить нового получателя, надо передеплоить сервис регистрации (или, в лучшем случае, передёрнуть конфиг и перезагрузить). Это не «декорация» — каждый новый downstream становится частью deploy-чеклиста для upstream-сервиса.
  2. Latency — сумма или максимум. Sequential — общий хвост = сумма всех Notify. Parallel — максимум. Один тормозящий получатель тормозит весь юзкейс.
  3. Доставка — best-effort. Получатель упал между accept и обработкой? Событие потеряно. Хотите retry — пишете его руками. Хотите дедуп — тоже руками. Очереди тут нет, держать события негде.
  4. Замена получателя. Аналитика мигрирует на новую версию — нужен blue-green с двумя URL'ами в списке, синхронизированный момент переключения, сложный отказ только одной части.

С другой стороны, и плюсы реальные. Латенси предсказуема (нет гарантированной задержки от broker'а), ошибка у получателя видна сразу — sender знает, дошло или не дошло. Для синхронных команд («списать деньги, дождаться подтверждения, потом продолжить») это и нужно. Просто это не наш сценарий.

Async: Kafka publish/subscribe

Sender — один продьюсер, один топик, одно сообщение на регистрацию. Никаких URL'ов, никакого «списка получателей» в его коде вообще. Вот ядро:

go
ev := mockCustomer(i)
payload, err := proto.Marshal(ev)
// ...
rec := &kgo.Record{
    Topic: *topic,
    Key:   []byte(ev.GetCustomerId()),
    Value: payload,
}
res := cl.ProduceSync(rpcCtx, rec)

Ключ — customer_id. Это не для маршрутизации между получателями (получателей мы вообще не знаем), а для гарантии «события одного клиента — в одну партицию». Если кому-то из получателей завтра захочется stateful-обработки per-customer — у него уже будет порядок.

Receiver — обычная consumer-группа. Имя группы определяет, кто это: analytics, notifications, billing. Каждая группа читает топик независимо, у каждой свой commit'нутый offset:

go
opts := []kgo.Opt{
    kgo.ConsumerGroup(group),
    kgo.ConsumeTopics(topic),
    kgo.ClientID(fmt.Sprintf("lecture-06-03-listener-%s", group)),
}
if fromStart {
    opts = append(opts, kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()))
}

Дальше — стандартный цикл PollFetches, proto.Unmarshal, печать. Никаких HTTP-портов, никаких retry-loop'ов в коде sender'а — всё это уехало в инфраструктуру Kafka.

Что мы получили:

  1. Coupling — слабый. Sender не знает про получателей. Завтра CRM захочет подписаться — поднимут сервис с новой группой crm, sender про это даже не услышит.
  2. Latency — два прыжка. Sender → broker → receiver. Это медленнее, чем прямой gRPC, на типовых стендах разница в районе единиц миллисекунд. Для большинства event-driven сценариев — не задача.
  3. Доставка — at-least-once. Сообщение лежит в логе до retention. Получатель упал, перезапустился, продолжил с committed offset. Replay (перечитать всё за неделю) — встроен бесплатно. Дедуп — на стороне получателя (обсуждаем в Гарантии обработки).
  4. Замена получателя. Поднял новую версию analytics с группой analytics-v2, она читает с earliest и догоняет историю. Сравнили счётчики, переключили downstream-потребителей на v2. Старая версия может ещё неделю догнивать рядом — ей это не мешает.

Цена тоже честная. Sender теряет видимость «дошло ли событие реально до получателя» — он знает только «брокер записал». Между «записано» и «обработано» расстояние может быть в часах, если получатель отстал. Eventual consistency. Это надо принять.

Decision matrix

Не таблица «правильно/неправильно», а оси, по которым выбор очевидно склоняется в ту или другую сторону.

КритерийgRPC fan-outKafka pub/sub
Sender знает получателейда, явный списокнет, sender пишет в топик
Добавить нового получателяпередеплой sender'аподнять новую consumer-группу
Latency end-to-endсумма (seq) или max (par)produce + lag по топику
Один медленный получательтормозит всех (seq) / себя (par)тормозит только себя
Sender узнаёт об ошибке получателясразу, по gRPC statusникогда (его это не касается)
Replay прошлых событийруками (надо хранить отдельно)встроен (по retention топика)
Гарантия доставкиbest-effortat-least-once (с правильным acks)
Backpressuresender блокируется на медленномbroker буферизует, sender не ждёт
Порядокпо порядку вызововper-partition, по ключу

Семь измерений (восемь, если считать порядок). На практике решает обычно не одно, а связка из пары-тройки.

Грубое правило, которое срабатывает в 90% случаев. Если получатель один и нужен ответ — gRPC unary. Если получателей много или будет много — Kafka. Если получатель меняется командой не из вашей зоны ответственности — точно Kafka, иначе тимлид соседней команды на всю жизнь подсядет к вам с просьбой «обнови нам URL в конфиге».

Антипаттерны

Это короткий список того, что выглядит соблазнительно, но в проде кончается плохо.

Kafka вместо синхронного RPC «потому что модно». «Сделаем заказ через Kafka — будет асинхронно, красиво». Клиент нажал «купить», ждёт redirect на success-страницу, а заказ катится через broker, потом через consumer'а биллинга, потом ответ обратно через ещё один топик. Латенси — секунды, дебаг — ад. Если нужен синхронный ответ — нужен синхронный RPC. Точка.

gRPC fan-out с пятью получателями вместо event bus. Прошло полгода, получателей стало пять, передеплой каждого — отдельная драма. Каждый новый внутренний сервис отвечает за то, чтобы попасть в чужой конфиг. В какой-то момент кто-то добавляет fallback «если URL недоступен — лог и пропускаем». Дальше начинаются тихие потери событий. Как только видите, что список URL'ов в конфиге растёт — это сигнал переехать на topic.

Смешивание в одном RPC. «Сделаем gRPC API, который синхронно отвечает создание заказа, и заодно публикует событие в Kafka, и заодно дёргает email-сервис». Три точки отказа в одной операции, три места, где транзакционность ломается. Если очень надо «и сразу ответить, и опубликовать» — это outbox pattern (Outbox-паттерн), а не три параллельных вызова в одном handler'е.

Kafka «вместо REST» для запросов. RPC «верни мне профиль клиента по id» через топик customer-requests и обратный топик customer-responses с correlation_id — это видно у людей, которые любят Kafka больше чем синхронные API. Latency терпимая, дебаг невозможен, наблюдаемость на нуле. Не делайте так. Запросы — gRPC/HTTP. Kafka — для событий, которые случились.

Что запускать руками

Стенд из лекций 01–04 уже должен быть поднят (docker compose up -d в корне репозитория). Дальше открываем кучу терминалов.

Sync-вариант (4 терминала):

plaintext
make run-grpc-listener-1            # терминал 1, на :50061, имя analytics
make run-grpc-listener-2            # терминал 2, на :50062, имя notifications
make run-grpc-listener-3            # терминал 3, на :50063, имя billing
make run-grpc-broadcast USERS=5     # терминал 4, посылает 5 событий каждому

В каждом из первых трёх терминалов появятся 5 строк [name] got customer_id=.... В четвёртом — таблица «куда отправили, что вернули, сколько заняло». Дальше можно остановить любой listener (Ctrl+C) и снова запустить broadcast — увидим FAIL code=Unavailable для остановленного, остальные отрабатывают.

Параллельный режим: make run-grpc-broadcast-parallel USERS=5. Эффект «один медленный тормозит всех» в этом учебном демо без искусственного time.Sleep не виден; в проде — самая частая причина деградации end-to-end latency.

Async-вариант (4 терминала):

plaintext
make topic-create                                 # один раз
make run-kafka-listener-analytics                 # терминал 1, group=analytics
make run-kafka-listener-notifications             # терминал 2, group=notifications
make run-kafka-listener-billing                   # терминал 3, group=billing
make run-kafka-broadcast USERS=5                  # терминал 4

Первые три терминала спокойно ждут. После запуска broadcast — каждый из них напечатает 5 строк [group] partition=X offset=Y customer_id=.... Останавливаем listener analytics, запускаем broadcast ещё раз. notifications и billing получили новые события, analytics — пропустил. Теперь поднимаем analytics обратно — и он догоняет. Никаких retry-петель в sender'е, никакого знания «кто там жив, а кто нет».

Поиграйте отдельно с тем, как добавить нового получателя. В sync-варианте — запустить четвёртый listener, дописать его URL в LISTENER_URLS, перезапустить broadcast. В async-варианте — запустить четвёртый listener с новой группой make run-kafka-listener -group=crm (или явно go run ./cmd/kafka-listener -group=crm -from-start=true). Sender в обоих случаях пишет одно и то же — но во втором его трогать не пришлось вообще.

К чему ведёт

В следующей лекции (Гибрид gRPC + Kafka) мы возьмём гибрид — синхронный gRPC API на запись плюс outbox-паттерн с Kafka на события. Получится та самая «sync на write, async на side-effects» архитектура, которую почти всегда выбирают в продакшене, когда сервисов больше двух и нагрузка реальная.

А use case Коммуникация микросервисов раскатывает то же самое на multi-node setup с интеграционным тестом и failure recovery — посмотрите там, если хотите проверить, что построенное здесь действительно держит нагрузку.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Паттерны коммуникации / Sync vs async: gRPC и Kafka