gRPC представляет собой RPC-фреймворк поверх HTTP/2, который по Protocol Buffers (типизированной бинарной схеме в файле .proto) генерирует код клиента и сервера. Выбирайте его для вызовов между своими сервисами; для браузеров и публичных API оставьте REST/JSON.
Сначала контракт: .proto остаётся единственным источником правды. protoc-gen-go и protoc-gen-go-grpc (или buf generate) генерируют типизированные сообщения и клиентский стаб, поэтому несовпадение ломает сборку, а не прод.
Дешевле по сети: бинарное кодирование в разы компактнее JSON и быстрее парсится, а HTTP/2 гоняет много вызовов по одному соединению.
Из коробки: дедлайны, коды статуса, метаданные и стриминг, которые в REST держатся на соглашениях.
Цена: данные не читаются глазами (grpcurl вместо curl), а браузер не умеет вызывать gRPC напрямую: нужен gRPC-Web за прокси, Connect или JSON-край, сгенерированный из того же .proto через grpc-gateway.
GoSenior
gRPC и микросервисы
gRPC и эволюция protobuf, дедлайны, синхронные и асинхронные вызовы, outbox и саги, ретраи и circuit breaker
Закрыто вопросов
0 / 20
- 0Повторить
- 2Часто спрашивают
Вопросы
Монолит образует одну единицу деплоя; микросервисы представляют собой независимо деплоящиеся сервисы, каждый со своими данными. Они дают автономию команд и независимое масштабирование, а стоят сетевых сбоев, распределённых данных и тяжёлой эксплуатации, поэтому команде из пяти человек подойдёт модульный монолит.
Что дают микросервисы
Команды собирают, деплоят и откатывают свои сервисы по своему графику.
Горячая часть масштабируется отдельно, каждый сервис сам выбирает хранилище, а сбой можно изолировать таймаутами и circuit breaker.
Чем за них платят
Любой вызов может упасть или затормозить, поэтому дедлайны, ретраи, идемпотентность и breaker становятся обязательными.
Транзакции между сервисами нет: вместо JOIN и BEGIN используют outbox, саги и eventual consistency.
Трейсинг, service discovery, пайплайн на каждый сервис, версионирование контрактов и дежурства по множеству деплоев.
Как решать
Делите, когда начинают мешать границы команд и очередь на деплой, а не потому что модно; сервисы повторяют структуру команд (закон Конвея).
В Go модульный монолит устроен так: пакет на каждый домен в internal/, модули общаются через интерфейсы, у каждого модуля своя схема в одной базе. Модуль с чистой границей потом можно вынести в сервис.
Избегайте распределённого монолита: сервисы, которые деплоятся только вместе или делят таблицы, платят все издержки и не получают ни одного преимущества.
Вызывайте синхронно, когда вызывающему нужен ответ, чтобы продолжить (цена, проверка прав); публикуйте событие, когда он лишь сообщает, что что-то произошло. Асинхронность даёт развязку и буферизацию ценой eventual consistency.
Синхронно: просто понять и отладить, но доступность перемножается: запрос через пять сервисов по 99,9% каждый доступен примерно на 99,5%, а задержки складываются.
Асинхронно: брокер поглощает пики и простои, а продюсер не знает своих консьюмеров, так что новый консьюмер ничего не меняет выше по цепочке.
Цена асинхронности: результат виден не сразу (в UI «обрабатывается»); сообщения приходят at-least-once и могут прийти не по порядку, поэтому консьюмеры обязаны быть идемпотентными; для трейсинга trace id нужно класть в заголовки сообщений; брокер добавляет ещё одну систему в эксплуатацию.
Команды и события: ChargeCard является командой одному владельцу, часто это синхронный вызов; OrderPlaced сообщает факт, на который реагируют другие, и его место в топике.
Запрос, который должен ответить сразу, но запускает долгую работу: сохраните его, опубликуйте через outbox и верните 202 Accepted.
Унарный (один запрос и один ответ), server streaming (один запрос и много ответов), client streaming (много запросов и один ответ) и двунаправленный (обе стороны независимо пишут в один стрим). Стримьте, когда данных много, их поток не ограничен или они живые; иначе оставайтесь на унарных.
Server streaming: большой экспорт, лента обновлений цен, подписка на изменения: клиент начинает читать раньше, чем сервер закончил.
Client streaming: загрузка файла или пачки телеметрии; сервер отвечает один раз, после того как клиент вызовет CloseAndRecv.
Двунаправленный: чат, совместное редактирование, долгоживущий управляющий канал; чтение и запись обычно идут в разных горутинах.
Цена: стрим всю жизнь сидит на одном бэкенде, поэтому балансировка идёт по стримам, а не по сообщениям. После обрыва восстанавливать его должно само приложение, а дедлайн покрывает весь стрим целиком. Унарные вызовы проще ретраить, кешировать и балансировать.
Клиент получит codes.Unknown с вашим сырым текстом order 42: sql: no rows in result set. Так по сети уходит любая ошибка, которая не является статусом gRPC. Возвращайте status.Errorf(codes.NotFound, ...), чтобы вызывающий мог ветвиться по коду.
По сети едут только код, сообщение и необязательные детали. Цепочка ошибок Go остаётся на сервере, поэтому errors.Is(err, sql.ErrNoRows) на клиенте всегда false.
Контрактом служит код: при NotFound повторять не нужно, при Unavailable можно повторить позже, при InvalidArgument нужно исправить запрос.
Сырое сообщение выдаёт внутренности вроде SQL или имён хостов. Детали пишите в лог, а наружу отдавайте codes.Internal с безопасным текстом.
На клиенте код читает status.Code(err): codes.OK для nil, codes.Unknown для ошибки, которая не является статусом.
Один на адрес на всю жизнь процесса: создайте его через grpc.NewClient при старте, делите между горутинами, закройте при остановке. Соединение на каждый вызов каждый раз платит за DNS, TCP и TLS и выбрасывает мультиплексирование HTTP/2.
ClientConn не является сокетом: внутри резолвер имён, политика балансировки и соединения под ней, и переподключается он сам, с backoff.
Он безопасен для конкурентного использования. Параллельные RPC идут стримами в одном HTTP/2-соединении, так что один ClientConn несёт тысячи вызовов одновременно.
grpc.NewClient не делает I/O; соединение поднимается при первом RPC. grpc.Dial, DialContext и WithBlock объявлены устаревшими.
На каждый вызов нужен дедлайн, а не соединение.
API gateway служит единой точкой входа для внешних клиентов (маршрутизация, аутентификация, rate limit, TLS), service discovery подсказывает, где живые экземпляры другого сервиса, а service mesh ставит прокси рядом с каждым сервисом для mTLS, ретраев, таймаутов, балансировки и телеметрии.
Gateway: Kong, Envoy Gateway или облачный шлюз; он умеет агрегировать вызовы и переводить REST в gRPC. Это край системы, а не место для бизнес-логики.
Discovery: в Kubernetes это DNS-имена Service и endpoints API; вне его используют Consul или etcd. При клиентском discovery (резолвер dns:/// в gRPC, xDS) экземпляр выбирает сам клиент, при серверном между ними стоит балансировщик.
Mesh: Istio или Linkerd: идентичность сервисов и mTLS без сертификатов в вашем коде, единые ретраи и таймауты, L7-балансировка по запросам, метрики и трейсы на каждом хопе. Ambient-режим Istio заменяет sidecar в каждом поде L4-прокси на узле и добавляет необязательный waypoint-прокси для L7.
Что остаётся в коде: дедлайны и проброс ctx, идемпотентность, решение, какие ошибки безопасно ретраить, trace context, бизнес-фолбэки. Mesh не знает, можно ли повторить Charge.
Цена: ещё одна движущаяся часть, лишняя задержка на каждом хопе и настройки ретраев и таймаутов, которые могут разойтись с тем, на что рассчитывает код.
На проводе поле определяется только номером, поэтому добавлять и переименовывать поля безопасно в бинарном виде, а смена номера или типа и повторное использование удалённого номера молча ломают старых участников. reserved запрещает повторное использование; optional возвращает field presence.
Совместимые и ломающие изменения
Безопасно: новое поле с новым номером, переименование поля (только для бинарного формата: меняется JSON-имя), новый RPC или значение enum. Старые читатели пропускают незнакомые поля; новые видят нулевые значения у отсутствующих.
Ломает: смена номера или типа поля, повторное использование номера, переименование пакета или сервиса (меняется путь метода /orders.v1.Orders/GetOrder).
Повторное использование ломает молча: bool и int64 оба кодируются как varint, поэтому старое is_trial: true прочитается как trial_days: 1 без всякой ошибки. Добавьте reserved 4; reserved "is_trial"; в том же коммите, где удаляете поле.
Запускайте buf breaking в CI; по-настоящему ломающее изменение выпускайте новым пакетом orders.v2 рядом с v1.
Field presence
Обычный скаляр proto3 не помнит, был ли он задан: отсутствующий bool и явный false оба читаются как false, и обновление не отличит «не трогай» от «сбрось».
optional bool notify_email = 3; генерирует в Go *bool, где nil значит «не прислали». Для частичного обновления многих полей google.protobuf.FieldMask перечисляет пути, которые вызывающий собирается записать. В Protobuf Editions (edition = "2023") явный presence включён по умолчанию.
Таймаут HTTP-клиента работает просто как локальный таймер: о бюджете сервер ничего не знает и замечает лишь обрыв соединения. Дедлайн gRPC едет вместе с вызовом в заголовке grpc-timeout, поэтому ctx хендлера несёт тот же дедлайн и отменяется, когда он истекает.
С HTTP сервер видит отменённый r.Context() лишь после того, как клиент оборвал запрос; с gRPC он заранее знает оставшееся время, поэтому может отказать сразу или передать бюджет дальше.
Клиент получает codes.DeadlineExceeded. На сервере закрывается ctx.Done() и ctx.Err() возвращает context.DeadlineExceeded, но хендлер продолжает работать, если не передаёт ctx в базу и в свои исходящие вызовы.
codes.Canceled означает другой случай: клиент отменил вызов или отключился до дедлайна.
По умолчанию gRPC дедлайн не ставит, и вызов без него может вечно ждать зависший сервер. Каждому клиентскому вызову нужен context.WithTimeout.
Передавайте ctx хендлера в исходящие RPC, и остаток времени пробрасывается от хопа к хопу; новый context.Background() рвёт цепочку.
Если времени осталось слишком мало, падайте сразу, а не начинайте работу, результат которой никто не прочитает.
Интерцептор в gRPC выполняет роль middleware: функция-обёртка вокруг каждого вызова для аутентификации, логирования, метрик или восстановления после паники. Унарные и стриминговые вызовы идут через разные цепочки, поэтому интерцептор только в grpc.ChainUnaryInterceptor стрим не увидит.
На сервере есть grpc.UnaryServerInterceptor и grpc.StreamServerInterceptor, у клиента есть такая же пара. Регистрируйте оба, иначе целый тип вызовов работает без аутентификации.
Заголовки запроса приходят как метаданные: metadata.FromIncomingContext(ctx) на сервере, metadata.AppendToOutgoingContext на клиенте. Ключи пишутся в нижнем регистре.
grpc.ServerStream отдаёт контекст только на чтение, поэтому стриминговый интерцептор, которому нужно добавить значение, передаёт хендлеру обёртку, где Context() возвращает новый контекст.
Стриминговый интерцептор вызывается один раз на стрим, а не на сообщение; работу на каждое сообщение делают в переопределённых RecvMsg/SendMsg.
Используйте ChainUnaryInterceptor: повторная установка grpc.UnaryInterceptor паникует. Трейсинг через OpenTelemetry теперь подключается как stats handler: grpc.StatsHandler(otelgrpc.NewServerHandler()).
req.Page является полем-сообщением, поэтому, если клиент его не заполнил, это nil-указатель, и req.Page.Size паникует. grpc-go не перехватывает панику в хендлерах, так что один такой запрос роняет весь процесс и все вызовы, которые в нём выполнялись.
Почему nil: в сгенерированном Go-коде каждое поле с типом сообщения становится указателем (Page *Page), и поле, которое клиент не прислал, остаётся nil. Скаляры безопасны: незаполненный customer_id равен просто "".
Почему падает под: net/http перехватывает панику хендлера, логирует её и закрывает только это соединение. grpc-go вызывает хендлер без recover, поэтому паника завершает процесс, а остальные клиенты получают Unavailable.
Исправление: читать поля через сгенерированные геттеры. GetPage() на nil-запросе возвращает nil, а GetSize() на nil *Page возвращает 0, поэтому req.GetPage().GetSize() никогда не паникует, а незаданная страница откатывается к 20.
Эшелонированная защита: добавьте recovery-интерсептор (recovery.UnaryServerInterceptor из go-grpc-middleware v2), который логирует стек и возвращает codes.Internal: следующая пропущенная проверка уронит один вызов, а не под.
Как поймать: линтер protogetter в golangci-lint ругается на прямое чтение полей proto-сообщений, а тест хендлера с &pb.ListOrdersRequest{} воспроизводит падение одной строкой.
io.EOF из Recv означает нормальное завершение стрима, а не сбой; а ранний возврат без отмены контекста стрима оставляет стрим живым. Проверяйте io.EOF и создавайте отменяемый контекст с defer cancel().
Стрим заканчивается io.EOF, когда хендлер на сервере вернул nil. Любая другая ошибка несёт статус, который прислал сервер.
grpc-go освобождает клиентский стрим, только когда отменён его контекст, Recv вернул ошибку или закрыт ClientConn. Возврат после первого совпадения не делает ничего из этого, и на каждом вызове остаются горутина и HTTP/2-стрим.
Отмена ещё и останавливает сервер: его stream.Context() отменяется и следующий Send падает, вместо того чтобы стримить результаты, которые никто не читает.
gRPC сам повторяет упавший вызов, если в service config для метода задан retryPolicy: до maxAttempts попыток с backoff и только для перечисленных кодов статуса. Включайте его только для идемпотентных методов, потому что Unavailable не доказывает, что сервер ничего не сделал.
**retryPolicy и transparent retry**: transparent retry не требует конфигурации и повторяет только вызов, который не дошёл до хендлера. retryPolicy задаётся явно, на метод, и повторяет вызов и после того, как сервер начал работу.
**maxAttempts**: клиент ограничивает его пятью. Дедлайн вызова покрывает все попытки вместе, поэтому указывать DeadlineExceeded бессмысленно.
**retryableStatusCodes**: для записей только UNAVAILABLE; для чтений можно добавить такие коды, как RESOURCE_EXHAUSTED. Никогда INVALID_ARGUMENT или NOT_FOUND: тот же запрос упадёт так же.
**retryThrottling: token bucket на сервер: сбои его расходуют, а когда осталось меньше половины, ретраи прекращаются, и перегруженный сервер не заваливают.
hedgingPolicy**: отправляет следующую попытку через задержку, не дожидаясь сбоя; для критичных к задержке идемпотентных чтений и не вместе с retryPolicy на одном методе.
Сначала идемпотентность: списание могло пройти, а потеряться мог только ответ, поэтому вызывающий шлёт один ключ идемпотентности на одно логическое списание, а сервер по нему дедуплицирует.
Circuit breaker оборачивает вызовы одной зависимости и перестаёт её вызывать, когда сбои переходят порог, и отказывает сразу вместо ожидания таймаутов. Из closed он переходит в open при сбоях, после паузы уходит в half-open, пропуская несколько пробных вызовов, и обратно в closed, если они успешны.
Состояния
Closed: вызовы проходят, их результаты считаются.
Open: каждый вызов сразу падает (gobreaker.ErrOpenState); вызывающий отдаёт фолбэк (значение из кеша, значение по умолчанию или урезанный ответ).
Half-open: после Timeout проходит до MaxRequests пробных вызовов; успех закрывает breaker, сбой снова открывает.
Когда срабатывать
По доле сбоев на минимальном числе запросов, а не по одной ошибке: «больше 50% из минимум 20 вызовов» не реагирует на шум при малом трафике.
Считайте только сбои, которые говорят о нездоровье зависимости: Unavailable, DeadlineExceeded, ResourceExhausted. NotFound или InvalidArgument значат, что зависимость работает.
Один breaker на зависимость или на хост, но не один общий на всё.
Зачем это нужно
Он даёт перегруженной зависимости время восстановиться и не даёт вашим горутинам и соединениям копиться на вызовах, которые всё равно упадут. Он дополняет дедлайны и бюджет ретраев; mesh умеет то же вне кода (outlier detection в Istio).
failures не сбрасывается при успехе, поэтому считает все ошибки с момента старта процесса. После пятой, как бы далеко они ни отстояли друг от друга, каждая одиночная ошибка открывает breaker на 30 с, и он отклоняет вызовы к здоровой зависимости.
Почему: failures >= 5 задумано как «пять ошибок подряд», но счётчик ничто не уменьшает. В тестовом цикле с одной ошибкой на 1000 вызовов, укладывающемся в окно 30 с, этот breaker отклонил все вызовы после 5000-го, а исправленный не отклонил ни одного. В продакшене это 30 секунд недоступности после каждой следующей ошибки.
Исправление: сбрасывать failures в 0 при каждом успехе. Теперь breaker срабатывает только на пять ошибок подряд; так выглядит упавшая зависимость, а не фоновый шум.
Триггер получше: продакшен-breaker'ы смотрят на долю ошибок в скользящем окне с минимальным числом запросов; gobreaker в закрытом состоянии обнуляет счётчики каждый Interval и решает в ReadyToTrip.
Всё ещё упрощён: после паузы этот breaker пропускает всех ожидающих сразу. Настоящее полуоткрытое состояние (half-open) пропускает несколько пробных вызовов (MaxRequests в gobreaker) и закрывается, только если они успешны.
Как поймать: юнит-тест со смешанными исходами: четыре ошибки, успех, затем одна ошибка, после чего breaker должен остаться закрытым. Внедрите часы (now func() time.Time), чтобы проверять паузу без sleep.
kube-proxy балансирует соединения, а не запросы, а gRPC шлёт каждый вызов стримом в одном долгоживущем HTTP/2-соединении, поэтому под, которому досталось соединение, получает весь трафик. Балансируйте по запросам: на клиенте или в L7-прокси.
REST выглядит сбалансированным только потому, что клиенты HTTP/1.1 держат пул соединений и постоянно их меняют.
На клиенте: headless Service (clusterIP: None) заставляет DNS вернуть IP всех подов. Подключайтесь к dns:///name:port и задайте политику round_robin в service config; политика по умолчанию pick_first берёт один адрес.
В прокси: service mesh (Istio, Linkerd) или Envoy терминирует HTTP/2 и балансирует каждый запрос без изменений в клиенте.
Новые поды: клиент перечитывает DNS только при смене соединений, поэтому после масштабирования новые поды трафика не получают. Задайте на сервере MaxConnectionAge: он периодически шлёт GOAWAY, и клиенты переподключаются к актуальному набору подов.
Ретраи умножают нагрузку ровно тогда, когда мощности меньше всего (три попытки на каждом из трёх хопов дают до 27-кратного трафика), а таймауты по 30 с заставляют каждый зависший запрос держать горутину и соединение из пула, пока у общего вызывающего не кончатся горутины и соединения для всех.
Один дедлайн: задаётся на краю и пробрасывается через ctx; ретрай тратит его остаток, а не новые 30 с.
Ретраи только на одном уровне и в рамках бюджета: например, не больше 10% от числа запросов (retryThrottling в gRPC), с экспоненциальным backoff и jitter.
Circuit breaker на каждую зависимость, чтобы отказывать сразу, пока она лежит.
Bulkhead: ограниченное число одновременных вызовов на зависимость (буферизованный канал как семафор, в клиентском интерцепторе), чтобы одна медленная зависимость не забрала все горутины и соединения.
Сброс нагрузки (load shedding): на сервере проверка Allow() у rate.Limiter или лимит одновременных запросов; при заполнении отказывайте сразу с ResourceExhausted или 503, а не ставьте в очередь; запрос в очереди, чей вызывающий уже сдался, означает чистые потери.
Две записи в две системы не станут атомарными от перестановки. Используйте transactional outbox: вставляйте событие в таблицу outbox в той же транзакции базы, что и заказ, а публикует его в брокер отдельный релей.
«Сначала коммит, потом публикация» теряет событие, если процесс умер или Kafka недоступна между шагами; «сначала публикация, потом коммит» шлёт событие о заказе, который откатился.
С outbox событие существует ровно тогда, когда существует заказ, потому что это одна локальная транзакция.
Релей выбирает неопубликованные строки с FOR UPDATE SKIP LOCKED, публикует их и только потом помечает. Падение между этими шагами приводит к повторной публикации, поэтому доставка at-least-once, а консьюмеры дедуплицируют по id из outbox.
Или пусть релеем будет change data capture: Debezium читает WAL базы и публикует строки outbox без опроса.
Ключуйте сообщения по id агрегата, чтобы сохранить порядок по заказу, а опубликованные строки удаляйте отдельной задачей очистки.
Сага представляет собой последовательность локальных транзакций, у каждой из которых есть компенсирующее действие; если шаг падает, завершённые шаги компенсируются в обратном порядке. Она заменяет двухфазный коммит, который держит блокировки в нескольких сервисах, ценой изоляции.
Почему не двухфазный коммит
Каждый участник держит блокировки, пока не проголосует самый медленный, а координатор, упавший посреди коммита, оставляет всех заблокированными. Kafka и внешние API вроде процессинга карт участвовать в нём вообще не могут.
Оркестрация и хореография
Оркестрация: координатор вызывает каждый шаг и его компенсацию: машина состояний, сохраняемая в базе, или workflow-движок вроде Temporal. Поток описан в одном месте, его легко мониторить.
Хореография: каждый сервис реагирует на предыдущее событие (StockReserved запускает списание). Центрального узла нет, но поток размазан по сервисам, и уже после трёх-четырёх шагов за ним трудно уследить.
Чем приходится платить
Другие видят промежуточные состояния, поэтому моделируйте их явно: PENDING, PAYMENT_FAILED.
Компенсации оформляются как новые транзакции (возврат денег, снятие резерва), они должны быть идемпотентными и повторяться до успеха.
Ставьте шаги, которые трудно отменить, в конец и знайте опорный шаг (pivot): после него сага уже не может откатиться и должна повторять вперёд, пока не получится.
Сообщения шагов приходят at-least-once, поэтому каждый шаг идемпотентен, а события шагов уходят через outbox.
Захватывают через SET key token NX PX ttl, освобождают скриптом, который удаляет ключ, только если в нём всё ещё ваш токен. Блокировка может истечь, пока владелец стоит на паузе, и действуют двое; fencing token (число, растущее с каждым захватом и проверяемое ресурсом) отсекает устаревшего.
Почему это ломается: пауза GC, медленный вызов или заморозка VM дольше TTL: блокировка истекает, её берёт другой воркер, а первый просыпается и всё равно пишет. Со стороны блокировки это не исправить; отказать старому владельцу может только ресурс.
Fencing: хранилище помнит наибольший увиденный токен и отклоняет записи с меньшим.
Откуда брать токены: etcd и ZooKeeper выдают монотонно растущую ревизию при каждой блокировке. В Redis такого нет; в качестве обходного пути обычно берут счётчик на INCR, увеличиваемый в том же Lua-скрипте, который берёт блокировку, а Redlock на нескольких узлах fencing тоже не добавляет.
Освобождение: обычный DEL может удалить блокировку следующего владельца, если ваша уже истекла; сравнивайте токен и удаляйте в одном Lua-скрипте.
Варианты проще: если ресурс и так Postgres, pg_try_advisory_lock или SELECT ... FOR UPDATE SKIP LOCKED обойдутся без второй системы. У выбора лидера та же проблема: используйте concurrency.Election из etcd или Lease в Kubernetes и всё равно защищайте записи fencing-токеном.