Irbisa · Шпаргалка

gRPC и микросервисы

gRPC и эволюция protobuf, дедлайны, синхронные и асинхронные вызовы, outbox и саги, ретраи и circuit breaker

Senior-разработчик · 20 вопросов

  1. 01

    Что такое gRPC и Protocol Buffers и когда вы выберете gRPC вместо REST с JSON?

    gRPC представляет собой RPC-фреймворк поверх HTTP/2, который по Protocol Buffers (типизированной бинарной схеме в файле .proto) генерирует код клиента и сервера.

    // orders/v1/orders.proto////   syntax = "proto3";//   package orders.v1;//   option go_package = "example.com/gen/orders/v1;ordersv1";//…
  2. 02

    Каковы компромиссы между монолитом и микросервисами и что вы выберете для стартапа из пяти человек?

    Монолит образует одну единицу деплоя; микросервисы представляют собой независимо деплоящиеся сервисы, каждый со своими данными.

  3. 03

    Когда сервисам общаться синхронно через REST или gRPC, а когда через брокер сообщений? Чем платят за асинхронность?

    Вызывайте синхронно, когда вызывающему нужен ответ, чтобы продолжить (цена, проверка прав); публикуйте событие, когда он лишь сообщает, что что-то произошло.

  4. 04

    Какие четыре вида вызовов есть в gRPC и когда стриминг лучше унарного вызова?

    Унарный (один запрос и один ответ), server streaming (один запрос и много ответов), client streaming (много запросов и один ответ) и двунаправленный (обе стороны независимо пишут в один стрим).

    //   service Catalog {//     rpc GetItem(GetItemRequest) returns (Item);            // unary//     rpc ListItems(ListRequest) returns (stream Item);      // server streaming//     rpc Upload(stream Item) returns (UploadSummary);       // client streaming//     rpc Sync(stream SyncEvent) returns (stream SyncEvent); // bidirectional//   }…
  5. 05

    Этот хендлер возвращает ошибку из fmt.Errorf для несуществующего заказа. Какой код статуса получит gRPC-клиент и как хендлер должен сообщать «не найдено»?

    func (s *server) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {	o, err := s.repo.Find(ctx, req.GetId())	if errors.Is(err, sql.ErrNoRows) {		return nil, fmt.Errorf("order %s: %w", req.GetId(), err)	}	if err != nil {		return nil, err	}	return o, nil}

    Клиент получит codes.Unknown с вашим сырым текстом order 42: sql: no rows in result set.

  6. 06

    Сколько должен жить grpc.ClientConn (один на вызов или один на процесс) и во что обходится новое соединение на каждый вызов?

    Один на адрес на всю жизнь процесса: создайте его через grpc.NewClient при старте, делите между горутинами, закройте при остановке.

    type Payments struct{ client pb.PaymentsClient } // Built once in main; conn.Close is returned for shutdown.func NewPayments(target string) (*Payments, func() error, error) {	conn, err := grpc.NewClient(target,		grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})),…
  7. 07

    Что делают API gateway, service discovery и service mesh и какие задачи они забирают из вашего Go-кода?

    API gateway служит единой точкой входа для внешних клиентов (маршрутизация, аутентификация, rate limit, TLS), service discovery подсказывает, где живые экземпляры другого сервиса, а service mesh ставит прокси рядом с каждым сервисом для mTLS, ретраев, таймаутов, балансировки и телеметрии.

  8. 08

    Какие изменения .proto безопасны, когда клиенты уже в проде, почему номер удалённого поля нужно пометить reserved и что даёт optional в proto3?

    На проводе поле определяется только номером, поэтому добавлять и переименовывать поля безопасно в бинарном виде, а смена номера или типа и повторное использование удалённого номера молча ломают старых участников.

    // user.proto////   message UpdateUserRequest {//     reserved 4;                       // was bool is_trial//     reserved "is_trial";              // and the name cannot come back either//     string id = 1;…
  9. 09

    Чем дедлайн gRPC отличается от таймаута HTTP-клиента и что видит сервер, когда дедлайн клиента истёк?

    Таймаут HTTP-клиента работает просто как локальный таймер: о бюджете сервер ничего не знает и замечает лишь обрыв соединения.

    func quote(ctx context.Context, c pb.PricingClient, sku string) (*pb.Quote, error) {	// no default deadline in gRPC: without this a stuck server holds the call forever	ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)	defer cancel()	q, err := c.Quote(ctx, &pb.QuoteRequest{Sku: sku}) // remaining time sent as grpc-timeout	if status.Code(err) == codes.DeadlineExceeded {…
  10. 10

    Что такое gRPC-интерцептор и почему интерцептор аутентификации, который работает для всех унарных методов, не срабатывает для стриминговых?

    Интерцептор в gRPC выполняет роль middleware: функция-обёртка вокруг каждого вызова для аутентификации, логирования, метрик или восстановления после паники.

    func UnaryAuth(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {	ctx, err := authenticate(ctx)	if err != nil {		return nil, err	}	return h(ctx, req)…
  11. 11

    Найдите баг: запрос одного клиента перезапускает под заказов, и все остальные вызовы в процессе падают с Unavailable.

    func (s *server) ListOrders(ctx context.Context, req *pb.ListOrdersRequest) (*pb.ListOrdersResponse, error) {	limit := int(req.Page.Size)	if limit <= 0 || limit > 100 {		limit = 20	}	orders, next, err := s.repo.List(ctx, req.GetCustomerId(), limit, req.Page.Token)	if err != nil {		return nil, status.Error(codes.Internal, "list orders failed")	}	return &pb.ListOrdersResponse{Orders: orders, NextPageToken: next}, nil}

    req.Page является полем-сообщением, поэтому, если клиент его не заполнил, это nil-указатель, и req.Page.Size паникует.

  12. 12

    Этот клиент возвращает первый товар в наличии из server-стрима. Поиски без совпадений пишут в лог search failed: EOF, а горутины копятся. Какие тут две ошибки?

    func findFirst(ctx context.Context, c pb.CatalogClient, q string) (*pb.Item, error) {	stream, err := c.Search(ctx, &pb.SearchRequest{Query: q})	if err != nil {		return nil, err	}	for {		item, err := stream.Recv()		if err != nil {			log.Printf("search failed: %v", err)			return nil, err		}		if item.GetInStock() {			return item, nil		}	}}

    io.EOF из Recv означает нормальное завершение стрима, а не сбой; а ранний возврат без отмены контекста стрима оставляет стрим живым.

  13. 13

    Как работают встроенные ретраи gRPC, какие коды статуса стоит указывать в retryableStatusCodes и почему метод должен быть идемпотентным до включения ретраев?

    gRPC сам повторяет упавший вызов, если в service config для метода задан retryPolicy: до maxAttempts попыток с backoff и только для перечисленных кодов статуса.

    // Passed with grpc.WithDefaultServiceConfig. Charge is listed only because it carries// an idempotency key; a write without one must never be retried by the transport.const svcConfig = `{  "methodConfig": [{    "name": [{"service": "payments.v1.Payments", "method": "Charge"}],    "retryPolicy": {…
  14. 14

    Что делает circuit breaker, какие у него три состояния и когда он должен срабатывать?

    Circuit breaker оборачивает вызовы одной зависимости и перестаёт её вызывать, когда сбои переходят порог, и отказывает сразу вместо ожидания таймаутов.

    // github.com/sony/gobreaker/v2var pricingCB = gobreaker.NewCircuitBreaker[*pb.Quote](gobreaker.Settings{	Name:        "pricing",	MaxRequests: 3,                // trial calls while half-open	Interval:    30 * time.Second, // closed: reset the counts every 30 s	Timeout:     10 * time.Second, // open -> half-open after 10 s…
  15. 15

    Найдите баг: через неделю после деплоя этот circuit breaker то и дело открывается на 30 с, хотя зависимость падает на одном вызове из тысячи.

    func (b *Breaker) Do(fn func() error) error {	b.mu.Lock()	if b.failures >= 5 && time.Since(b.lastFail) < 30*time.Second {		b.mu.Unlock()		return ErrOpen	}	b.mu.Unlock()	err := fn()	b.mu.Lock()	if err != nil {		b.failures++		b.lastFail = time.Now()	}	b.mu.Unlock()	return err}

    failures не сбрасывается при успехе, поэтому считает все ошибки с момента старта процесса.

  16. 16

    Десять реплик стоят за Kubernetes-сервисом типа ClusterIP. REST-трафик распределяется ровно, а gRPC шлёт около 90% вызовов в один под. Почему и как это исправить?

    kube-proxy балансирует соединения, а не запросы, а gRPC шлёт каждый вызов стримом в одном долгоживущем HTTP/2-соединении, поэтому под, которому досталось соединение, получает весь трафик.

    func dial() (*grpc.ClientConn, error) {	return grpc.NewClient(		// headless Service: DNS returns every pod IP, not one virtual IP		"dns:///orders.prod.svc.cluster.local:8080",		grpc.WithTransportCredentials(insecure.NewCredentials()),		// the default policy is pick_first: one pod gets everything…
  17. 17

    p99 одной зависимости вырос с 400 мс до 6 с, и вместе с ней легли четыре сервиса за общим вызывающим. Клиенты ретраят 3 раза с таймаутом 30 с. Что усилило сбой и что его останавливает?

    Ретраи умножают нагрузку ровно тогда, когда мощности меньше всего (три попытки на каждом из трёх хопов дают до 27-кратного трафика), а таймауты по 30 с заставляют каждый зависший запрос держать горутину и соединение из пула, пока у общего вызывающего не кончатся горутины и соединения для всех.

    // One Bulkhead per dependency: at most cap(slot) calls in flight to it.type Bulkhead struct{ slot chan struct{} } func NewBulkhead(n int) *Bulkhead { return &Bulkhead{slot: make(chan struct{}, n)} } func (b *Bulkhead) Unary(ctx context.Context, method string, req, reply any,…
  18. 18

    У части заказов нет события OrderCreated, а если публиковать до коммита, появляются события для откатившихся заказов. Какой паттерн исправляет оба случая?

    Две записи в две системы не станут атомарными от перестановки.

    func CreateOrder(ctx context.Context, tx *sql.Tx, o Order, payload []byte) error {	_, err := tx.ExecContext(ctx, `INSERT INTO orders (id, total) VALUES ($1, $2)`, o.ID, o.Total)	if err == nil { // same transaction: the event exists if and only if the order does		_, err = tx.ExecContext(ctx,			`INSERT INTO outbox (aggregate_id, type, payload) VALUES ($1, $2, $3)`,			o.ID, "OrderCreated", payload)…
  19. 19

    Оформление заказа должно зарезервировать товар, списать деньги и вызвать курьера, и каждый шаг выполняется в своём сервисе. Почему не распределённая транзакция и как вместо неё работает сага?

    Сага представляет собой последовательность локальных транзакций, у каждой из которых есть компенсирующее действие; если шаг падает, завершённые шаги компенсируются в обратном порядке.

  20. 20

    Как построить распределённую блокировку на Redis и почему блокировка без fencing token небезопасна?

    Захватывают через SET key token NX PX ttl, освобождают скриптом, который удаляет ключ, только если в нём всё ещё ваш токен.

    func acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, bool, error) {	token := uuid.NewString() // random: only the owner can release it	ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result()	return token, ok, err}…