---
title: "Load balancing"
description: "grpc load balancing в Go: resolver и target, pickfirst и round_robin, SubConn и пикеры, service config, DROP-пикер, клиентский и серверный LB, sticky sessions."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/16-lb/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 20
---

# Load balancing

*Курс «gRPC-Go: высокопроизводительный RPC», урок 16 из 25. ~20 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/16-lb/*

grpc load balancing — это то, что случается с вашим вызовом после строки grpc.NewClient и до первого сетевого пакета: резолвер превращает target-строку в список адресов, политика балансировки решает, на какой из них уедет каждый RPC, а SubConn держит сами соединения. Пятнадцатый урок закрыл ретраи точным адресом из спеки A6 — «in-between the channel and the load balancing policy», между каналом и политикой балансировки; пришло время заглянуть в этот соседний этаж. Конвейер одинаков для любого канала: target-строка, резолвер, LoadBalancer, SubConn, пикер — и на каждом шаге есть рычаги: схема dns:///, service config с loadBalancingConfig, дефолтные pickfirst и round_robin, DROP-пикеры, ErrNoSubConnAvailable. Здесь вы узнаете, почему без всякой настройки канал держит одно соединение; как включить round_robin одной строкой JSON; почему незнакомое имя политики молча превращается в pick_first; что резолвер приносит не только адреса; чем клиентская схема отличается от серверного балансировщика — и почему sticky sessions в gRPC не бывают бесплатными.

### grpc load balancing: конвейер от target-строки до SubConn

Клиентская балансировка grpc-вызовов начинается не с алгоритма «распределить по бэкендам», а с разбора строки цели. grpc.NewClient из пятого урока принимает target — URI со схемой резолвера; пакет resolver держит реестр «схема → Builder» (resolver.Register заполняет его в init(), resolver.Get ищет по имени), а Target — это структура с единственным полем URL url.URL, разобранным по RFC 3986. Док-комментарий Target обещает поведение на всякий случай: если схемы нет или она не зарегистрирована, клиент подставит схему по умолчанию и перепарсит цель заново. Дальше резолвер отдаёт resolver.State — и это не «список строк»: там лежат Addresses и Endpoints, а также ServiceConfig; документация service_config формулирует контракт целиком: name resolver, когда его просят разрешить имя сервера, «will return both the resolved addresses and the service config». Третий этаж — балансер: «Package balancer defines APIs for load balancing in gRPC». Builder.Build получает ClientConn-фасад и создаёт SubConn'ы: «A SubConn represents a single connection to a gRPC backend service» — внутри это HTTP/2-соединение, на котором мультиплексируются все вызовы. Важная деталь из док-комментария SubConn: «All SubConns start in IDLE, and will not try to connect. To trigger a connection attempt, Balancers must call Connect» — соединяться балансер заставляет сам. Четвёртый этаж — пикер: «Picker is used by gRPC to pick a SubConn to send an RPC», и вызывается он на каждый RPC. Балансер собирает новый пикер из текущего снимка состояния и отдаёт каналу через UpdateState(State{ConnectivityState, Picker}). Итого: канал — диспетчер, резолвер — источник данных, балансер — стратегия, пикер — мгновенное решение на конкретный вызов. Ниже пройдём по этажам с исходниками в руках.

### Клиент: round_robin через service config

```go
// cmd/client/main.go — включаем round_robin строкой service config.
const sc = `{
  "loadBalancingConfig": [
    { "round_robin": {} } // первая зарегистрированная политика из списка
  ],
  "methodConfig": [
    {
      // Дефолт для всех методов сервиса: ждать Ready и иметь свой срок.
      "name": [{"service": "shop.v1.OrderService"}],
      "waitForReady": true,
      "timeout": "2s"
    }
  ]
}`

// dns-резолвер вернёт все адреса orders.internal:50051, round_robin
// откроет по соединению на каждый и будет чередовать вызовы.
conn, err := grpc.NewClient("dns:///orders.internal:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithDefaultServiceConfig(sc)) // конфиг-дефолт канала
if err != nil {
        log.Fatal(err)
}
client := pb.NewOrderServiceClient(conn)

// Шесть вызовов подряд: посмотрите в логах серверов, как они распределятся.
for i := 0; i < 6; i++ {
        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        _, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
        log.Printf("вызов %d: код %v", i, status.Code(err))
        cancel()
}
```

Три вещи, на которые стоит смотреть. Первая: round_robin включается полем loadBalancingConfig — массивом объектов, где ключ — имя политики, значение — её конфиг; у round_robin конфиг пустой. Вторая: конфиг, как и в пятнадцатом уроке, — обычная строка JSON, которую канал передаёт в политику; сюда же просится waitForReady из пятого урока — при старте клиента, пока резолвер ещё не ответил, вызовы подождут готовности вместо мгновенного Unavailable. Третья: сам вызов не меняется — балансировка происходит ниже вашего кода, между каналом и транспортом; клиентская логика видит только статус из тринадцатого урока.

### loadBalancingConfig против loadBalancingPolicy: как канал выбирает политику

В service config живёт пара полей, и разница между ними — источник тихих сюрпризов. Устаревшая форма — строка: «loadBalancingPolicy»: «round_robin»; каноническая — массив: «loadBalancingConfig»: [{ «round_robin»: {} }]. Механика разбора в service_config.go: если массива нет, берётся имя из строкового поля, а если и его нет — name := pickfirst.Name, то есть дефолтная политика называется pick_first. Дальше самое интересное: «if balancer.Get(name) == nil { name = pickfirst.Name }» — незнакомое имя в строковом поле НЕ роняет конфиг: канал молча подставит pick_first и поедет дальше. Вы написали «round_robn» с опечаткой? Ошибки не будет — просто балансировка не включится, и искать это придётся по поведению, а не по логам. С массивом правила другие: gracefulswitch.ParseConfig проходит по списку объектов, требует, чтобы в каждом был ровно один ключ («expected a JSON struct with one entry»), и для незнакомых имён говорит «Skip unregistered balancer names» — пропустить и смотреть следующего. Массив [{ «fancy_lb»: {} }, { «round_robin»: {} }] возьмёт round_robin: первый кандидат не зарегистрирован, второй — да. Но если НЕ зарегистрирован никто, разбор падает уже с ошибкой: «no supported policies found in config». Сам реестр политик — тот же паттерн, что у резолверов: balancer.Register в init(), и док-комментарий предупреждает: «If multiple Balancers are registered with the same name, the one registered last will take effect» — последний побеждает. В базовой поставке grpc зарегистрированы ровно две политики: pick_first и round_robin — второй импортируется в clientconn.go с честным комментарием «// To register roundrobin». Остальные — least_request_experimental, ring_hash_experimental, weighted_target и компания — лежат в репозитории, но регистрируются при импорте своих пакетов и в обычный канал без этого импорта не попадают; большинство из них рассчитано на xDS-инфраструктуру.

### Две формы записи политики в service config

```go
// Устаревшая форма: строка без настроек политики.
const legacy = `{
  "loadBalancingPolicy": "round_robin"
}`

// Каноническая форма: массив политик по порядку предпочтения.
const modern = `{
  "loadBalancingConfig": [
    { "fancy_lb": {} },     // не зарегистрирована — молча пропущена
    { "round_robin": {} }   // первая зарегистрированная — и победит
  ]
}`

// Если в массиве не зарегистрирован НИКТО, конфиг отвергается с ошибкой
// «no supported policies found in config»; строка с опечаткой просто
// тихо вернётся к pick_first — разница, о которой стоит помнить.
```

Обе строки — валидные service config'и, и разница между ними не в синтаксисе, а в поведении при ошибке: массив отдаёт разбор политику наилучшим из доступных вариантов и ругается только когда доступных нет вовсе, а строка с незнакомым именем молча деградирует до дефолта. Практическое правило: новые конфиги пишите массивом — он же единственный способ передать политике настройки (у pickfirst, например, есть shuffleAddressList).

### Проверка: незнакомое имя политики

**Вопрос:** В service config указано только «loadBalancingPolicy»: «ring_hash», при этом пакет balancer/ringhash не импортировался. Что сделает канал?

- Отвергнет service config целиком: политика не зарегистрирована
- ✅ Молча возьмёт pick_first: balancer.Get вернёт nil, и имя подменяется на дефолт без ошибки
- Вернёт ошибку на каждом вызове: политика заявлена, но не установлена
- Попробует политики по порядку и выберет round_robin как следующую доступную

Ветку видно в service_config.go дословно: если loadBalancingConfig нет, а в строковом поле «if balancer.Get(name) == nil { name = pickfirst.Name }» — незнакомое имя тихо заменяется дефолтом, конфиг остаётся валидным. Тихий откат — осознанный дизайн совместимости: конфиги едут к клиенту из внешних источников, и одно поле не должно ронять весь service config вместе с ретраями и таймаутами. Другое поведение у массива loadBalancingConfig: там незнакомые имена пропускаются по одному («Skip unregistered balancer names»), но если не зарегистрирован ни один вариант, разбор падает с «no supported policies found in config». ring_hash при желании можно зарегистрировать самому — импортом пакета balancer/ringhash: его init() вызывает balancer.Register, после чего имя в конфиге начнёт работать.

### pickfirst: дефолт, который держит одно соединение

Что происходит, когда вы вообще не думали о балансировке? Работает pick_first — политика из balancer/pickfirst с const Name = «pick_first». Её стратегия — не распределение нагрузки, а живучесть: соединение устанавливается с первым доступным адресом и используется для всех вызовов. Правило порядка адресов сформулировано в док-комментарии SubConn: «gRPC will try to connect to the addresses in sequence, and stop trying the remainder once the first connection is successful» — адреса пробуются по очереди, и на первом успешном попытки прекращаются. Под этим комментарием стоит приписка «However, this behavior is deprecated. SubConns should only use a single address» — в современной модели каждый адрес получает собственный SubConn, а не один общий со списком; смысл остался тем же: один активный канал связи, остальные адреса — запасные. Детали из исходников pickfirst. Первый проход по списку — «happy eyeballs»: между попытками держится пауза connectionDelayInterval = 250 * time.Millisecond, чтобы не долбить все адреса разом; неудача первого прохода запоминается в connectionFailedInFirstPass. Список можно перемешать конфигом: pfConfig с полем ShuffleAddressList, json-ключ «shuffleAddressList» — полезно, когда резолвер возвращает адреса в одинаковом порядке для всех клиентов и первый адрес собрал бы всю нагрузку. Провал соединения вводит SubConn в TransientFailure с «липким» поведением из gRFC A62 — канал ждёт, пока политика не решит переподключиться. Для ваших вызовов это означает: упал единственный активный бэкенд — канал уходит в TransientFailure, и дальше включается знакомый по пятому уроку расклад: fail fast вернёт Unavailable сразу, WaitForReady(true) — подождёт восстановления. Смысл дефолта честный: одной SubConn хватает подавляющему большинству приложений, а «балансировать» начинают, когда бэкендов становится несколько и нагрузка перестаёт помещаться в одно соединение.

### Три адреса и pick_first

```go
// service config политики НЕ задаёт — работает дефолт pick_first.
// dns-резолвер вернул три адреса в порядке A, B, C:
//   A — сервер выключен, соединение отбивается;
//   B и C — живые.
// Клиент делает один вызов GetOrder.
```

**Что выведет программа?**

- Вызов упадёт с Unavailable: первый адрес мёртв, pick_first дальше не смотрит
- ✅ Клиент соединится с B: адреса пробуются по очереди, на первом успешном попытки прекращаются
- Клиент создаст три соединения и распределит вызовы между B и C
- Резолвер перезапустится и вернёт список без адреса A

Дефолтная политика про порядок: «try to connect to the addresses in sequence, and stop trying the remainder once the first connection is successful». Адрес A умирает — после паузы в 250 миллисекунд первого happy-eyeballs-прохода клиент пробует B, соединяется и дальше работает только с ним; C остаётся неиспользованным запасом. Именно поэтому pick_first — политика отказоустойчивости, а не распределения нагрузки: три SubConn'а не создаются параллельно, нагрузка не делится, и «мёртвый первый адрес» — штатный сценарий, а не авария. Перемешать порядок, чтобы разные клиенты начинали с разных адресов, можно опцией shuffleAddressList в конфиге политики. Ошибки резолвера тут нет: список адресов валидный, проблема была только в одном бэкенде.

### round_robin: регистрация и балансировка по RPC

Вторая политика из коробки — round_robin из balancer/roundrobin. Регистрация классическая: const Name = «round_robin» и balancer.Register(builder{}) в init(); док-комментарий пакета объясняет, почему импортировать его руками не нужно: «Roundrobin balancer is installed as one of the default balancers in gRPC, users don't need to explicitly install this balancer». За словом «roundrobin» в исходниках стоит двухэтажная конструкция. Внешний этаж — endpointsharding: пакет, который держит по дочернему балансеру на каждый endpoint из resolver.State. Внутренний этаж — pickfirst-лист на каждый endpoint: dns-резолвер отдаёт адреса без Endpoints, и сработает правило из resolver.go — «If a resolver sets Addresses but does not set Endpoints, one Endpoint will be created for each Address», то есть один адрес = один endpoint = один pickfirst-лист = одно соединение. Итог: round_robin по dns-адресам держит по одному соединению на каждый бэкенд. Само чередование делает пикер endpointsharding — два этажа механики в его исходнике: старт со случайного индекса next: uint32(randIntN(len(pickers))) и шаг на каждый вызов: «nextIndex := atomic.AddUint32(&p.next, 1)», затем «p.pickers[nextIndex%uint32(len(p.pickers))]» — атомарный счётчик и деление по модулю на число готовых детей. Дети делятся по состояниям: если есть хоть один Ready, пикер собирается только из готовых; нет готовых — из Connecting, затем Idle, затем TransientFailure. Два следствия, которые стоит осознать. Первое: распределение идёт по RPC, а не по соединениям — один вызов за другим честно обходят все живые бэкенды, включая HTTP/2-мультиплексирование внутри каждого соединения. Второе: старт со случайного индекса нужен, чтобы десять клиентов не начали с одного и того же первого бэкенда — та же идея, что у shuffleAddressList, только встроенная.

### Проверка: механика чередования

**Вопрос:** round_robin обслуживает канал с тремя готовыми бэкендами. Как пикер выбирает SubConn для очередного вызова?

- Случайно: индекс бэкенда выбирается заново на каждом вызове
- По соединению: каждый клиент прилипает к одному бэкенду до его отказа
- ✅ Атомарный счётчик увеличивается на единицу, индекс берётся по модулю числа готовых детей; стартовый сдвиг случайный
- По нагрузке: пикер спрашивает у бэкендов число активных вызовов и выбирает наименее загруженный

Исходник пикера endpointsharding: «nextIndex := atomic.AddUint32(&p.next, 1)» и «p.pickers[nextIndex%uint32(len(p.pickers))]» — классический round robin на атомарном счётчике, причём вызовы Pick идут параллельно из разных горутин, поэтому счётчик именно атомарный. Стартовый индекс — next: uint32(randIntN(len(pickers))), случайный сдвиг, чтобы все клиенты не начали обход с одного бэкенда. Случайный выбор на каждый вызов — это другая политика; «выбрать наименее загруженный» — третья (в репозитории есть least_request_experimental, но она не входит в базовую пару). И не путайте: соединение в round_robin открывается на КАЖДЫЙ адрес — один пикер чередует несколько SubConn'ов, а не один.

### Пикер: три исхода Pick и DROP без ретраев

Пикер — самая короткая функция в конвейере и самая важная: док-комментарий Picker требует «Pick should not block» — блокироваться в нём нельзя. Что может вернуть Pick? Три исхода. Первый — SubConn: вызов уезжает на выбранное соединение. Второй — balancer.ErrNoSubConnAvailable, и его док-контракт проговаривает ожидание: «ErrNoSubConnAvailable indicates no SubConn is available for pick(). gRPC will block the RPC until a new picker is available via UpdateState()» — вызов замирает до появления нового пикера; тот же эффект даёт SubConn, который ещё не Ready. Третий — ошибка: статусная ошибка завершает RPC немедленно с этим кодом, прочие ошибки при fail fast дают Unavailable, а с WaitForReady — ожидание; док-комментарий pick в picker_wrapper.go перечисляет случаи, когда вызов блокируется, и заканчивает фразой «pick blocks until the picker gets updated». Теперь то, ради чего стоило читать про пикер в уроке про ретраи. Статусная ошибка из пикера оборачивается в dropError — «dropError is a wrapper error that indicates the LB policy wishes to drop the RPC and not retry it», и getTransport в stream.go ставит попытке флаг a.drop = true. Этот флаг попадает в самую первую ветку shouldRetry, которую пятнадцатый урок цитировал как «… || a.drop { // RPC is finished or committed or was dropped by the picker; cannot retry» — пикер отбросил вызов, ретрая нет. Спека A6 фиксирует правило для обеих пикер-ошибок: если результат пикера DROP — «the RPC will fail immediately, without any retries», если TRANSIENT_FAILURE — «the RPC will be handled by the configured retry policy». Практический источник DROP — gRPCLB, lookaside-балансировщик: его пикер отдаёт статус Unavailable с сообщением «request dropped by grpclb» по drop-токенам из списка серверов. Вывод для дизайна: политика балансировки — единственное место, где вызов может умереть ДО ретрай-механики и умышленно, без второй попытки.

### DROP против retryPolicy

```go
// service config канала:
//   "loadBalancingConfig": [{ "grpclb": {} }],
//   retryPolicy для GetOrder: maxAttempts 3,
//   retryableStatusCodes: ["unavailable"]
//
// Пикер балансировщика отработал drop-токен и вернул:
//   status.Errorf(codes.Unavailable, "request dropped by grpclb")
//
// Что получит клиент?
```

**Что выведет программа?**

- Три попытки и успех: Unavailable есть в retryableStatusCodes, политика сработает
- ✅ Одна попытка и Unavailable: пикер пожелал отбросить вызов — dropError даёт a.drop = true, и shouldRetry выходит в ветку cannot retry до всякой проверки политики
- DeadlineExceeded: drop-токен сжигает дедлайн вызова
- Internal: drop-токены запрещены для методов с политикой ретраев

Порядок проверки в shouldRetry жёсткий: сначала «cs.finished || cs.committed || a.drop» — и только потом код сравнивается с retryableStatusCodes. Пикер вернул статус Unavailable, но завернул его в dropError с комментарием «wishes to drop the RPC and not retry it», getTransport поставил a.drop = true — ретраи не начинаются, клиент получает Unavailable с первой попытки. Спека формулирует это правилом различения двух пикер-ошибок: DROP — «fail immediately, without any retries», TRANSIENT_FAILURE — «handled by the configured retry policy». Смысл у DROP прикладной: так балансировщик просит снизить нагрузку — ретраить «сброшенный» вызов значило бы отменить решение балансировщика и усилить перегрузку. Это и есть замок между уроками: ретраи живут между каналом и политикой балансировки, но пикер стоит ещё раньше — и его слово «дроп» главнее любого retryPolicy.

### dns-резолвер: порт, authority, TXT и тридцать секунд

Пятый урок зафиксировал развилку: NewClient использует dns-резолвер по умолчанию, Dial — passthrough, «It sends the target name without scheme back to gRPC as resolved address». Док-комментарий GetDefaultScheme фиксирует дефолт дословно: если SetDefaultScheme не вызывался, «the default scheme used by grpc.NewClient is "dns" instead». Теперь внутрь dns. Регистрация — в init(): resolver.Register(NewBuilder()), Scheme возвращает «dns», а док-комментарий пакета честно зовёт его «to be installed as the default resolver». Порт: const defaultPort = "443" — цель без порта получит 443, а не ваш 50051, поэтому dns:///orders.internal означает orders.internal:443; всегда пишите порт явно. IP-цель обрабатывается без DNS-запроса: formatIP распознаёт литеральный адрес, резолвер сразу отдаёт его в State — и на этом заканчивается: для IP-цели возвращается deadResolver, который ничего не делает, watcher не запускается, переразрешений не бывает. Authority из цели работает: «dns://8.8.8.8/foo.googleapis.com» — часть между вторым и третьим слэшем выбирает DNS-сервер (newNetResolver строит net.Resolver с PreferGo и Dial на выбранную пару host:port; порт самого DNS — defaultDNSSvrPort "53"). Дальше — то, что делает dns-резолвер союзником балансировки: он не «разрешил и забыл», а живёт в горутине watcher и переразрешает имя по подсказкам ResolveNow — канал дёргает их, когда соединения в беде; повторное разрешение ограничено снизу MinResolutionInterval = 30 * time.Second — «the minimum interval at which re-resolutions are allowed. This helps to prevent excessive re-resolution», на сам запрос отведено ResolvingTimeout = 30 * time.Second, а после неудачи watcher уходит в экспоненциальный бэкофф из backoff.DefaultExponential. И главное для service config: резолвер запрашивает TXT-запись с префиксом txtPrefix = "_grpc_config." и ожидает атрибут «grpc_config=» — «In DNS, service config is encoded in a TXT record via the mechanism described in RFC-1464», схема из gRFC A2. Так конфиг с retryPolicy и loadBalancingConfig доезжает до клиента без единой строки кода; TXT-поиски включены по умолчанию — переменная окружения GRPC_ENABLE_TXT_SERVICE_CONFIG со значением false их отключает, как и WithDisableServiceConfig. Не хотите конфиг из сети — WithDisableServiceConfig: «causes gRPC to ignore any service config provided by the resolver and provides a hint to the resolver to not fetch service configs», причём дефолтный конфиг из WithDefaultServiceConfig продолжит работать.

### Соберите канал с round_robin

Впишите ключ service config, схему резолвера, опцию канала и имя дефолтной политики.

```go
const sc = `{
  "___": [{ "round_robin": {} }]
}`

conn, err := grpc.NewClient("___:///orders.internal:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.___(sc)) // конфиг-дефолт канала
// А без всякого конфига канал работает на политике ___.
```

Подсказка: Политика задаётся массивом loadBalancingConfig; резолвер NewClient по умолчанию — dns; дефолтную политику зовут pick_first.

Правильные ответы в пропусках: `loadBalancingConfig`, `dns`, `WithDefaultServiceConfig`, `pick_first`

### Свой резолвер: manual в тесте

```go
// Тест: ручной резолвер подсовывает каналу адреса, которых нет в DNS.
r := manual.NewBuilderWithScheme("lab") // своя схема

conn, err := grpc.NewClient("lab:///orders",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithResolvers(r)) // добавить builder в реестр канала
if err != nil {
        log.Fatal(err)
}

// Публикуем первое состояние: два «бэкенда» на локалхосте.
r.InitialState(resolver.State{
        Addresses: []resolver.Address{
                {Addr: "127.0.0.1:50051"},
                {Addr: "127.0.0.1:50052"},
        },
})
client := pb.NewOrderServiceClient(conn)

// Позже список можно заменить целиком — канал узнает из UpdateState:
// r.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: "127.0.0.1:50053"}}})
```

manual.Resolver — резолвер-заглушка для тестов и примеров: NewBuilderWithScheme создаёт Builder с вашей схемой, Build возвращает сам резолвер, InitialState и UpdateState публикуют resolver.State вручную, а колбэки BuildCallback/UpdateStateCallback позволяют тесту реагировать на действия канала. Опция WithResolvers добавляет builder в поиск канала. Механика ровно та же, что у dns: State с адресами едет в политику балансировки, дальше работают pickfirst или round_robin. В проде на место manual встаёт настоящий резолвер — о нём следующий блок.

### Резолвер под свой каталог: discovery-стиль

Интерфейс резолвера намеренно крошечный: Builder с методами Build(target Target, cc ClientConn, opts BuildOptions) и Scheme(), плюс Resolver с ResolveNow и Close. Реализация подписывается на источник правды и пушит resolver.State через cc.UpdateState — ошибка возврата просит повторить позже с бэкоффом, ReportError сообщает о проблеме разрешения. На этом строится discovery-стиль эксплуатации: резолвер опрашивает каталог сервиса — health-чеки Consul, etcd, Kubernetes Endpoints — и превращает список ЗДОРОВЫХ инстансов в Addresses. Клиент при этом ничего не знает о каталоге: для него «dns» и «consul» — просто схемы в target; док-пример NewClient даже показывает чужую схему «zookeeper://zk.example.com:9900/example_service» как легальную форму цели. Полезно посмотреть, что вы получаете бесплатно: Target.URL — обычный url.URL, значит query-параметры цели доступны резолверу (для опций вида consul://catalog?dc=dc1), метод Endpoint() отдаёт путь без ведущего слэша, а String() собирает каноническую строку scheme://host/endpoint. Ровно на этом стыке живёт тизер: в отдельном курсе по go-kit есть урок про discovery, где каталог и health-check'и становятся главным героем — там эта схема разбирается с Consul по-настоящему, от регистрации сервиса до фильтрации нездоровых инстансов; здесь же достаточно понимать: ваш резолвер — это сорок строк Go между каталогом и каналом. Отдельно запомните ограничение из док-комментария manual: каждый экземпляр резолвера работает только с одним каналом — «Every instance of the manual resolver may only ever be used with a single grpc.ClientConn». Забавная кухня напоследок: внутри resolver/map.go endpoint'ы сравниваются как множества, для чего адреса кодируются в base64 — «base64 encoding the address strings restricts the characters present within the strings», чтобы разделитель не требовал экранирования.

### Проверка: незарегистрированная схема

**Вопрос:** grpc.NewClient("consul://localhost:8500/orders") вызван в программе, где резолвер со схемой consul НЕ зарегистрирован. Что произойдёт?

- NewClient вернёт ошибку: схема неизвестна
- Канал молча возьмёт pick_first и соединится с localhost:8500
- ✅ Клиент подставит резолвер по умолчанию и перепарсит цель им; ошибка NewClient возможна только если нет резолвера и для дефолтной схемы
- Цель будет проигнорирована: вызовы уйдут в бесконечное ожидание

Док-комментарий NewClient описывает фолбэк: «The default name resolver will be used if no scheme is detected, or if the parsed scheme is not a registered name resolver». В коде это initParsedTargetAndResolverBuilder с комментарием «the user's dial target did not contain a scheme or specified an unregistered scheme»: неудачная схема не возвращает ошибку, а пересобирает цель как canonicalTarget — дефолтная схема плюс исходная строка; для NewClient дефолт — dns (пятый урок показывал развилку Dial/passthrough против NewClient/dns), и dns попытается разрешить то, что осталось. Тот же комментарий хранит и исключение: «except when a custom dialer is specified in which case, we should always use passthrough scheme» — с кастомным dialer'ом фолбэк всегда passthrough, чтобы ваша функция получила строку цели как есть. Ошибка из NewClient выйдет только если не найдётся резолвер даже для дефолтной схемы: «could not get resolver for default scheme». Практический вывод: забытый импорт вашего консул-резолвера проявится не паникой на старте, а странной целью у dns-резолвера — и лучше ловить это тестом с manual-резолвером, чем логами в проде.

### Клиентский LB против серверного

| Аспект | Клиентский LB | Серверный LB |
| --- | --- | --- |
| Где живёт решение | Резолвер и политика внутри процесса клиента; бэкенды известны каждому клиенту | Отдельный узел — L4/L7-балансировщик или lookaside-сервис; клиент видит один адрес |
| Что балансирует | Каждый RPC: пикер выбирает SubConn на вызов, соединений — по числу бэкендов | L4 — соединения, а не RPC: HTTP/2 упаковывает все вызовы в один TCP; L7-proxy видит :path и может делить по RPC |
| Отказ бэкенда | Замечен сразу соединением; пикер перестаёт отдавать мёртвый SubConn | Обнаруживает балансировщик по health-чекам; клиент не в курсе |
| Sticky sessions | Только консистентным хешированием или своим пикером | L4 — по TCP-соединению; по RPC — нужен L7, понимающий HTTP/2 |
| Конфигурация | Едет с service config: DNS TXT или WithDefaultServiceConfig | Инфраструктура; gRPCLB-lookaside отдаёт клиенту список бэкендов и drop-токены |

### Sticky sessions: почему в gRPC нет «приклейки» из коробки

Классический вопрос после внедрения round_robin: «а как направлять сессию пользователя всегда на тот же инстанс?» Честный ответ: в gRPC это не бесплатная опция, и вот почему. Первый слой проблемы — сам транспорт: HTTP/2 мультиплексирует все вызовы в одно TCP-соединение, поэтому L4-балансировщик, привыкший в мире HTTP/1 распределять соединения и «липнуть» к ним, здесь видит один длинный поток на клиента — балансировать по соединениям бессмысленно, все вызовы уже внутри. Второй слой — балансировка grpc на клиенте по определению бессессионна: round_robin кладёт соседние вызовы одного пользователя на разные бэкенды атомарным счётчиком, и никакого «пользователь ↔ соединение» не существует. Третий слой — куки не работают: в HTTP-мире sticky реализуют cookie-инъекцией, но куки — механизм веб-приложений; gRPC-прокси читают метаданные и :path, а решать по ним умеют далеко не все балансировщики. Что делать. Первый путь — перестать хотеть: сделать сервис stateless, состояние вынести в общее хранилище; это главная рекомендация, и балансировка по RPC тогда безопасна. Второй путь — консистентное хеширование: одинаковый ключ уезжает на одинаковый бэкенд при живых адресах; в репозитории grpc-go есть ring_hash_experimental (balancer/ringhash), рассчитанный на xDS-конфигурацию, где хеш-ключи задаются политиками hash по заголовкам. Третий путь — свой пикер: кастомная политика, которая читает PickInfo.Ctx, достаёт ключ сессии из метаданных десятого урока и возвращает всегда один и тот же SubConn — API балансера для этого открыт (Builder, ClientConn, Picker), а балансер-обёртки вроде endpointsharding показывают, как строить составные политики. Просто помните цену: sticky возвращает проблему «умёр бэкенд с сессиями» — держите под ней репликацию состояния, иначе отказоустойчивость, которую давал round_robin, исчезнет вместе с привязкой.

> **Пять ловушек балансировки.** Первая: цель без порта — dns подставит дефолтный 443 (defaultPort), и ваш сервер на 50051 не найдётся; порт пишите всегда. Вторая: опечатка в loadBalancingPolicy — не ошибка, а тихий откат к pick_first («if balancer.Get(name) == nil { name = pickfirst.Name }»); проверяйте, что политика вообще включилась. Третья: пустой список адресов — не ошибка резолвера: канал уйдёт в TransientFailure, и fail fast из пятого урока вернёт Unavailable на вызовах без WaitForReady. Четвёртая: DROP-пикер не проходит через retryPolicy — флаг a.drop выводит shouldRetry в ветку cannot retry; ретраи из пятнадцатого урока тут бессильны по замыслу. Пятая: round_robin выравнивает ЧИСЛО вызовов, а не нагрузку — тяжёлый метод на одном бэкенде перегреет его и при честных долях; для чувствительных сервисов смотрите в сторону least_request-политик и серверной отчетности о нагрузке.

### Чек-лист: load balancing

- Конвейер канала: target-строка → резолвер → LoadBalancer → SubConn → пикер; у каждого этажа свои API и свои ошибки.
- Резолвер возвращает и адреса, и service config — «will return both the resolved addresses and the service config».
- NewClient по умолчанию dns, Dial — passthrough; незарегистрированная схема не падает, а перепарсивается через схему по умолчанию.
- dns добавляет порт 443, если порт не указан; dns://8.8.8.8/host выбирает DNS-сервер из authority; повторное разрешение — не чаще раза в 30 секунд.
- Политика берётся из loadBalancingConfig (массив, побеждает первая зарегистрированная) или устаревшего loadBalancingPolicy (строка, незнакомое имя — тихо pick_first).
- pick_first — дефолт: адреса по очереди, одно активное соединение, пауза 250 мс между попытками первого прохода, перемешивание через shuffleAddressList.
- round_robin зарегистрирован из коробки: по SubConn на адрес, чередование готовых детей атомарным счётчиком с модулем, старт со случайного индекса, распределение по RPC.
- Пикер не блокируется: ErrNoSubConnAvailable ставит вызов в ожидание нового пикера, статус-ошибка завершает RPC немедленно — так работает DROP.
- DROP-пикер не ретраится: a.drop = true выводит shouldRetry в ветку cannot retry — политика повторов не запускается вовсе.
- Sticky sessions не бесплатны: stateless-сервисы, консистентное хеширование (ring_hash через xDS) или свой пикер с ключом из метаданных.

### Путь RPC: от target-строки до SubConn

Расставьте этапы жизни вызова в порядке, в котором их проходит канал.

Правильный порядок строк:

1. `grpc.NewClient("dns:///orders.internal:50051") — канал создан без сети`
2. `канал парсит target в resolver.Target и ищет Builder по схеме dns`
3. `dns-резолвер возвращает resolver.State: адреса бэкендов и service config`
4. `по loadBalancingConfig канал выбирает политику балансировки round_robin`
5. `round_robin создаёт по SubConn на каждый адрес и запускает соединения`
6. `каждый SubConn доходит до Ready или падает в TransientFailure`
7. `балансер собирает из готовых SubConn пикер и отдаёт его каналу через UpdateState`
8. `RPC вызывает пикер.Pick и уходит на выбранный SubConn`

### Карточки: load balancing

- **resolver** — Источник адресов и service config. Builder ищется по схеме target (dns, passthrough, unix, manual), резолвер пушит resolver.State с Addresses/Endpoints и ServiceConfig. Контракт: «will return both the resolved addresses and the service config». NewClient по умолчанию — dns.
- **SubConn** — «A SubConn represents a single connection to a gRPC backend service». Стартует в IDLE и сам не соединяется — балансер вызывает Connect; состояния Idle/Connecting/Ready/TransientFailure. Внутри — HTTP/2-соединение, по которому мультиплексируются все RPC.
- **pick_first** — Дефолтная политика (const Name = "pick_first"): адреса пробуются «in sequence, and stop trying the remainder once the first connection is successful». Одно активное соединение; happy-eyeballs-пауза 250 мс; shuffleAddressList перемешивает список; провал — TransientFailure по A62.
- **round_robin** — «Installed as one of the default balancers in gRPC» — регистрируется сам через init(). Построен на endpointsharding: один адрес = один endpoint = pickfirst-лист; пикер чередует готовых детей: nextIndex := atomic.AddUint32(&p.next, 1), pickers[nextIndex % len], стартовый индекс случайный. Распределение — по RPC.
- **Пикер и DROP** — Pick не блокируется. ErrNoSubConnAvailable — RPC ждёт новый пикер («pick blocks until the picker gets updated»). Статусная ошибка из пикера завершает вызов сразу; dropError — «wishes to drop the RPC and not retry it» — даёт a.drop = true и ветку cannot retry в shouldRetry: «request dropped by grpclb».
- **Sticky sessions** — Round robin и HTTP/2-мультиплексирование не дают «приклеить» пользователя к инстансу: L4 видит один TCP-поток, куки — не механизм gRPC. Пути: stateless-сервисы; консистентное хеширование (ring_hash_experimental, обычно через xDS); свой пикер с ключом сессии из метаданных.

### Что дальше по курсу

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять резолвер и три бэкенда в браузере нельзя, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за вечер на паре из четвёртого и пятого уроков. Три эксперимента. Первый: поднимите два инстанса сервера на разных портах, включите round_robin через service config и посчитайте в перехватчике из одиннадцатого урока, как распределились вызовы; затем выключите один инстанс и убедитесь, что вызовы перетекли без ошибок клиента. Второй: уберите порт из цели dns:///orders.internal и прочитайте ошибку соединения — там всплывёт 443; верните порт и сравните. Третий: возьмите manual-резолвер из этого урока, опубликуйте список из «мёртвого» первого адреса и живого второго, включите pick_first — по логам соединений увидите happy-eyeballs-проход и выбор второго адреса; затем поменяйте список через UpdateState и понаблюдайте переключение. Дальше по маршруту: семнадцатый урок — health checking protocol: grpc_health_v1, серверные health-сервисы и probes; связь с сегодняшним материалом прямая — балансировщики умеют слушать здоровье бэкендов через health listener (round_robin включает его для своих pickfirst-детей комментарием «Enable the health listener in pickfirst children for client side health checks»), и нездоровый инстанс исчезает из пикера, не дожидаясь обрыва соединения. Перед ним перечитайте [урок про ретраи](/courses/grpc-go/lessons/15-retry/) — DROP-ветку shouldRetry вы теперь видели с обеих сторон, и [урок про клиент](/courses/grpc-go/lessons/05-klient/) — target-синтаксис и состояния канала. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

## Коротко: что запомнить

- Клиентский LB — конвейер из четырёх этажей: target-строка, резолвер, LoadBalancer, пикер; SubConn — соединение до конкретного бэкенда, а пикер выбирает SubConn для каждого RPC.
- Резолвер возвращает resolver.State: адреса, endpoints и service config; в DNS конфиг приезжает TXT-записью _grpc_config с атрибутом grpc_config= (RFC-1464, gRFC A2).
- NewClient по умолчанию dns, Dial — passthrough; незарегистрированная схема перепарсивается через дефолтную; dns подставляет порт 443, умеет authority с DNS-сервером и переразрешается не чаще раза в 30 секунд.
- Политика балансировки берётся из loadBalancingConfig (массив, побеждает первая зарегистрированная; все незнакомые — ошибка no supported policies) или из строкового loadBalancingPolicy (незнакомое имя тихо даёт pick_first).
- pick_first — дефолт: адреса по очереди, одно активное соединение, happy-eyeballs-пауза 250 мс, перемешивание списка конфигом shuffleAddressList.
- round_robin построен на endpointsharding с pickfirst-детьми; пикер чередует готовых детей атомарным счётчиком по модулю со случайным стартом — распределение по RPC, по соединению на бэкенд.
- Pick не блокируется: ErrNoSubConnAvailable — ждать новый пикер, статус-ошибка — умереть сразу; DROP-пикер (dropError, a.drop = true) обходит retryPolicy — «fail immediately, without any retries».
- Sticky sessions в gRPC не бесплатны: HTTP/2 сводит клиента к одному TCP-потоку, куки не работают; пути — stateless, консистентное хеширование (ring_hash через xDS) или свой пикер с ключом из метаданных.

## Домашнее задание

Соберите лабораторию балансировки на паре сервер/клиент из четвёртого и пятого уроков. Первое: поднимите три инстанса OrderService на портах 50051-50053, включите round_robin через WithDefaultServiceConfig и прогоните двадцать вызовов GetOrder; в перехватчике из одиннадцатого урока логируйте peer-адрес и постройте распределение — убедитесь, что каждый инстанс получил примерно равную долю и что стартовый обход начался не с первого инстанса. Второе: выключите второй инстанс и повторите серию — ошибок клиент показать не должен, а доля вызовов должна перетечь на живых; затем поднимите инстанс обратно и проверьте, что он вернулся в ротацию. Третье: переключите политику на pick_first тем же конфигом и объясните по логам, почему теперь все вызовы идут на один инстанс; уроните его и убедитесь, что клиент переехал на следующий адрес списка, а не распределил нагрузку. Четвёртое: уберите порт из цели dns:///orders.internal и прочитайте ошибку соединения — найдите в ней 443; верните порт. Пятое: напишите тест с manual-резолвером (NewBuilderWithScheme, InitialState, WithResolvers), опубликуйте адрес «мёртвого» бэкенда и живого, включите pick_first и по логам соединений проследите happy-eyeballs-проход; затем через UpdateState замените список адресов и убедитесь, что канал перестроил соединения. Шестое: пропишите в конфиге loadBalancingPolicy с опечаткой (например, «round_robn») и докажите по поведению, что канал молча работает на pick_first; затем перенесите имя в loadBalancingConfig с несуществующей политикой и round_robin по соседству — проверьте, что сработал второй вариант, а после удаления round_robin из массива конфиг отвергается с «no supported policies found in config».

## Вопросы и ответы по уроку

### Как включить round_robin и почему после включения ничего не меняется?

Политика задаётся в service config: loadBalancingConfig: [{ "round_robin": {} }] — через WithDefaultServiceConfig или через DNS TXT (grpc_config=). Если эффекта нет, проверьте три вещи. Первая: конфиг вообще доезжает — строковое поле loadBalancingPolicy с опечаткой молча даёт pick_first («if balancer.Get(name) == nil { name = pickfirst.Name }»), ошибок не будет. Вторая: у резолвера несколько адресов — если dns возвращает один адрес, чередовать нечего, round_robin вырождается в одно соединение; посмотрите, что за записи в DNS и сколько бэкендов за именем. Третья: распределение видно только по бэкендам — логируйте peer-адрес в перехватчике или stats handler, а не результат вызова. И помните: у round_robin конфиг пустой объект — настраивать в нём нечего, весь тюнинг живёт в methodConfig рядом.

### Клиентская балансировка или серверный балансировщик — что выбрать?

Клиентская (pick_first, round_robin, свой резолвер) убирает лишний хоп: клиент соединяется с каждым бэкендом напрямую, знает об их отказах немедленно и делит вызовы по RPC; цена — каждый клиент должен уметь резолвить и держать несколько соединений, а правила едут service config'ом. Серверный балансировщик (L4-типа NLB или L7-типа Envoy) проще эксплуатировать: один адрес, клиенты наивные, health-чеки централизованы; но L4 в мире HTTP/2 балансирует соединения, а не вызовы — все RPC клиента упакованы в один TCP, и перекосы неизбежны, поэтому для gRPC L4-хоп часто бессмыслен, а L7-proxy обязан понимать HTTP/2. Lookaside-вариант gRPCLB — гибрид: отдельный сервис отдаёт клиенту список бэкендов и drop-токены, а соединения клиент держит сам; его drop-пикер вы уже видели — «request dropped by grpclb», и ретраи такие вызовы не повторяют. Практика: небольшой кластер и свой каталог — клиентская схема; многоязычные клиенты и жёсткий контроль — L7-proxy; крупная инфраструктура Google-стиля — lookaside.

### Почему sticky sessions в gRPC не работают «из коробки» и что делать?

Потому что в gRPC нет объекта, к которому можно приклеиться. Соединения — TCP — обрабатывают много вызовов сразу (HTTP/2 мультиплексирование), так что sticky по соединению либо ничего не даёт (клиентская балансировка вообще не знает про ваши сессии), либо вреден (L4 видит один поток на клиента и не может делить по RPC). Куки — механизм веб-приложений: gRPC-прокси оперируют метаданными и :path, а cookie-инъекция в бинарный протокол не пролезает. Решения три. Первое — правильное: сделать сервис stateless, вынеся состояние в общее хранилище; тогда привязка не нужна, а round_robin безопасен. Второе — консистентное хеширование: в репозитории есть ring_hash_experimental, обычно включаемый через xDS, где ключи хеширования задаются политиками по заголовкам вызова. Третье — своя политика балансировки: открытый API (balancer.Builder, ClientConn, Picker) позволяет прочитать PickInfo.Ctx, взять ключ сессии из метаданных и всегда возвращать один SubConn. Любой sticky возвращает классическую беду — отказ бэкенда вместе с его сессиями, так что под привязкой обязана лежать репликация состояния.
