Sync vs async: gRPC и Kafka
Бизнесовая ситуация банальная. В приложении лояльности Brew зарегистрировался новый клиент — и про это надо узнать сразу куче сервисов: аналитике, нотификациям, биллингу, антифроду, CRM. Послезавтра аналитика переедет с Postgres на ClickHouse, и нам придётся менять — что?
Вот в этом «что» — вся лекция.
Решение «один сервис рассылает по списку получателей через gRPC» и решение «один сервис пишет одно событие в Kafka, остальные читают» снаружи делают одно и то же. По коду — два разных мира. Дальше по тексту мы построим оба и поговорим, чем они платят.
Сценарий
Клиент завёл аккаунт в приложении лояльности Brew, и сервис, принявший регистрацию, хочет «сообщить про это всем заинтересованным». Заинтересованных на старте у нас несколько:
analytics(analytics-service) — пишет факт регистрации в свой сторадж для отчётов.notifications(notification-service) — шлёт welcome-письмо новому клиенту.billing— заводит пустой счёт под будущие списания за заказы.
Завтра, послезавтра — добавятся ещё.
Два варианта реализации:
- Sync (gRPC fan-out). Сервис регистрации знает URL'ы всех получателей. Дёргает каждый по отдельному unary-RPC
Notify(CustomerSignedUp). Все три ответилиOK— считаем регистрацию законченной. - Async (Kafka). Сервис регистрации пишет одно сообщение в топик
customer-events. Получатели сами подписаны (каждый в своей consumer-группе) и читают независимо. Sender'у дальше до них дела нет.
Оба варианта в репозитории и собираются. Запустим, потрогаем, сравним.
Что в коде
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, его реализует каждый получатель:
service CustomerEventService {
rpc Notify(NotifyRequest) returns (NotifyResponse);
}Receiver сидит на своём порту и ждёт, когда его позовут. Внутри — никакой логики, только лог «получил такого-то клиента»:
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 про существование каждого получателя знает явно:
targets := flag.String("targets", "",
"список URL'ов получателей через запятую; если пусто, берётся LISTENER_URLS")Дальше — собственно fan-out. Два режима, потому что в учебной программе хочется увидеть оба эффекта: «один медленный тормозит всех» (последовательно) и «один промах изолирован» (параллельно):
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()Теперь по пунктам, что мы тут получили — и за что заплатили.
- Coupling — туго. Чтобы добавить нового получателя, надо передеплоить сервис регистрации (или, в лучшем случае, передёрнуть конфиг и перезагрузить). Это не «декорация» — каждый новый downstream становится частью deploy-чеклиста для upstream-сервиса.
- Latency — сумма или максимум. Sequential — общий хвост = сумма всех
Notify. Parallel — максимум. Один тормозящий получатель тормозит весь юзкейс. - Доставка — best-effort. Получатель упал между
acceptи обработкой? Событие потеряно. Хотите retry — пишете его руками. Хотите дедуп — тоже руками. Очереди тут нет, держать события негде. - Замена получателя. Аналитика мигрирует на новую версию — нужен blue-green с двумя URL'ами в списке, синхронизированный момент переключения, сложный отказ только одной части.
С другой стороны, и плюсы реальные. Латенси предсказуема (нет гарантированной задержки от broker'а), ошибка у получателя видна сразу — sender знает, дошло или не дошло. Для синхронных команд («списать деньги, дождаться подтверждения, потом продолжить») это и нужно. Просто это не наш сценарий.
Async: Kafka publish/subscribe
Sender — один продьюсер, один топик, одно сообщение на регистрацию. Никаких URL'ов, никакого «списка получателей» в его коде вообще. Вот ядро:
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:
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.
Что мы получили:
- Coupling — слабый. Sender не знает про получателей. Завтра CRM захочет подписаться — поднимут сервис с новой группой
crm, sender про это даже не услышит. - Latency — два прыжка. Sender → broker → receiver. Это медленнее, чем прямой gRPC, на типовых стендах разница в районе единиц миллисекунд. Для большинства event-driven сценариев — не задача.
- Доставка — at-least-once. Сообщение лежит в логе до retention. Получатель упал, перезапустился, продолжил с committed offset. Replay (перечитать всё за неделю) — встроен бесплатно. Дедуп — на стороне получателя (обсуждаем в Гарантии обработки).
- Замена получателя. Поднял новую версию analytics с группой
analytics-v2, она читает с earliest и догоняет историю. Сравнили счётчики, переключили downstream-потребителей на v2. Старая версия может ещё неделю догнивать рядом — ей это не мешает.
Цена тоже честная. Sender теряет видимость «дошло ли событие реально до получателя» — он знает только «брокер записал». Между «записано» и «обработано» расстояние может быть в часах, если получатель отстал. Eventual consistency. Это надо принять.
Decision matrix
Не таблица «правильно/неправильно», а оси, по которым выбор очевидно склоняется в ту или другую сторону.
| Критерий | gRPC fan-out | Kafka pub/sub |
|---|---|---|
| Sender знает получателей | да, явный список | нет, sender пишет в топик |
| Добавить нового получателя | передеплой sender'а | поднять новую consumer-группу |
| Latency end-to-end | сумма (seq) или max (par) | produce + lag по топику |
| Один медленный получатель | тормозит всех (seq) / себя (par) | тормозит только себя |
| Sender узнаёт об ошибке получателя | сразу, по gRPC status | никогда (его это не касается) |
| Replay прошлых событий | руками (надо хранить отдельно) | встроен (по retention топика) |
| Гарантия доставки | best-effort | at-least-once (с правильным acks) |
| Backpressure | sender блокируется на медленном | 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 терминала):
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 терминала):
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 — посмотрите там, если хотите проверить, что построенное здесь действительно держит нагрузку.