Перейти к основному содержимому

Mock-собеседование по Go от Team Lead из Яндекса

· 10 мин. чтения

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

Вопрос 1. Напишите функцию, которая конкурентно обращается к N репликам базы данных, получает значение по ключу из первой ответившей реплики и возвращает его, не дожидаясь остальных. Функция Get уже реализована и возвращает строку или ошибку. Нужно учесть контекст с таймаутом. Корректно обрабатывать: когда все реплики вернули ошибку (недоступны), когда ключ не найден (not_found — сразу возвращать), когда пришёл таймаут контекста. Также реализовать ретраи с экспоненциальным backoff для ошибок (кроме not_found), если ещё никто не записал значение в канал.

Таймкод: 00:00:17

Ответ собеседника: неполный. Кандидат правильно предложил архитектуру с каналами и горутинами для конкурентных запросов к репликам. Обсудил использование WaitGroup, проверку контекста перед запуском горутин, создание отдельных каналов для значений и ошибок not_found. Предложил экспоненциальный backoff для ретраев. Однако в реализации были существенные трудности: не смог самостоятельно реализовать неблокирующую запись в канал через select с default (потребовалась подсказка), допустил ошибку с отсутствием return после записи ошибки в канал, не самостоятельно пришёл к решению закрытия каналов через отдельную горутину. Код не был доведён до полностью рабочего состояния — финальная версия получена преимущественно с подсказками интервьюера. Кандидат знаком с базовыми приёмами Go (каналы, горутины, контексты, WaitGroup), но испытывает сложности с деталями реализации и корнер-кейсами.

Правильный ответ:

Это классическая задача на паттерн «первый успешный ответ» с дополнительными требованиями: приоритет ошибки not_found, ретраи с экспоненциальным backoff и уважение к контексту. Рассмотрим архитектуру и полную реализацию.

Архитектура решения

Ключевые требования и как они влияют на дизайн:

  • Конкурентные запросы к N репликам — запускаем горутины для каждой реплики.
  • Первый успешный ответ — используем канал с буфером 1, чтобы первая успешная горутина записала результат, а остальные не зависли.
  • not_found имеет приоритет — если любая реплика вернула not_found, нужно вернуть это немедленно, даже если другие ещё работают. Для этого нужен отдельный канал для not_found.
  • Таймаут контекста — слушаем ctx.Done() в основном select.
  • Ретраи с экспоненциальным backoff — если реплика вернула ошибку (не not_found) и при этом никто ещё не записал успешный результат, повторяем запрос с задержкой.
  • Все реплики вернули ошибку — после исчерпания всех попыток возвращаем агрегированную ошибку.

Выбор примитивов синхронизации

Для координации используем:

  • chan string (буфер 1) — для первого успешного результата.
  • chan struct{} (буфер 1) — для сигнала not_found.
  • sync.WaitGroup — чтобы дождаться завершения всех горутин перед возвратом (или отмена через контекст).
  • context.Context — для таймаута и отмены.

Полная реализация

package main

import (
"context"
"errors"
"fmt"
"math"
"sync"
"time"
)

// Ошибки
var ErrNotFound = errors.New("key not found")
var ErrAllReplicasFailed = errors.New("all replicas failed")

// Replica — интерфейс реплики БД
type Replica interface {
Get(ctx context.Context, key string) (string, error)
}

// GetFromReplicas конкурентно опрашивает реплики и возвращает первый успешный ответ.
// Приоритеты:
// 1. not_found — немедленный возврат
// 2. Первый успешный результат
// 3. Таймаут контекста
// 4. Все реплики вернули ошибку
func GetFromReplicas(ctx context.Context, replicas []Replica, key string) (string, error) {
if len(replicas) == 0 {
return "", ErrAllReplicasFailed
}

// Проверяем, что контекст уже не отменён
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}

// Каналы для коммуникации между горутинами
resultCh := make(chan string, 1) // буфер 1 — только первый результат
notFoundCh := make(chan struct{}, 1) // буфер 1 — только первый not_found
errCh := make(chan error, 1) // последняя ошибка, если все упали

var wg sync.WaitGroup

// Запускаем горутину для каждой реплики
for _, replica := range replicas {
wg.Add(1)
go func(r Replica) {
defer wg.Done()
tryReplica(ctx, r, key, resultCh, notFoundCh, errCh)
}(replica)
}

// Горутина, которая закроет errCh после завершения всех горутин
// Это нужно, чтобы основной select мог определить, что все реплики обработаны
go func() {
wg.Wait()
close(errCh)
}()

// Основной цикл ожидания результата
for {
select {
case <-ctx.Done():
return "", ctx.Err()

case value := <-resultCh:
return value, nil

case <-notFoundCh:
return "", ErrNotFound

case err, ok := <-errCh:
if !ok {
// Канал закрыт — все горутины завершились, результата нет
if err != nil {
return "", fmt.Errorf("%w: %v", ErrAllReplicasFailed, err)
}
return "", ErrAllReplicasFailed
}
// Пока канал открыт, просто запоминаем ошибку
// (она перезапишется, если придёт ещё одна)
_ = err
}
}
}

// tryReplica выполняет запрос к одной реплике с ретраями и экспоненциальным backoff.
func tryReplica(
ctx context.Context,
replica Replica,
key string,
resultCh chan string,
notFoundCh chan struct{},
errCh chan error,
) {
const maxRetries = 3
const baseDelay = 10 * time.Millisecond

for attempt := 0; attempt <= maxRetries; attempt++ {
// Проверяем контекст перед каждой попыткой
select {
case <-ctx.Done():
return
default:
}

// Проверяем, не пришёл ли уже результат от другой реплики
// Неблокирующая проверка resultCh
select {
case <-resultCh:
// Кто-то уже записал результат — выходим
return
default:
}

value, err := replica.Get(ctx, key)
if err == nil {
// Успех — пробуем записать в канал (неблокирующе)
select {
case resultCh <- value:
// Записали — отлично
default:
// Канал уже заполнен — кто-то другой был быстрее
}
return
}

// Ошибка not_found — приоритет, ретраи не нужны
if errors.Is(err, ErrNotFound) {
select {
case notFoundCh <- struct{}{}:
default:
}
return
}

// Другая ошибка — проверяем, нужно ли делать ретрай
if attempt < maxRetries {
delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay

select {
case <-ctx.Done():
return
case <-resultCh:
// Пока ждали, кто-то уже получил результат
return
case <-time.After(delay):
// Экспоненциальный backoff — продолжаем
}
} else {
// Все попытки исчерпаны — отправляем ошибку
select {
case errCh <- err:
case <-ctx.Done():
}
return
}
}
}

Ключевые моменты реализации

1. Неблокирующая запись в канал (try-send)

Это критически важный паттерн. Если канал с буфером 1 уже заполнен, запись в него заблокирует горутину. Используем select с default:

select {
case resultCh <- value:
// Записали
default:
// Канал заполнен — кто-то другой был быстрее, просто выходим
}

Без default горутина зависнет навсегда, что приведёт к утечке горутин.

2. Проверка перед запуском работы

Перед каждой попыткой (включая первую) проверяем, не пришёл ли уже результат:

select {
case <-resultCh:
return // Кто-то уже получил результат — выходим
default:
}

Это предотвращает бесполезные запросы к БД, когда ответ уже получен.

3. Приоритет not_found

Ошибка not_found обоснована бизнес-логикой: если ключ точно отсутствует в системе, нет смысла спрашивать другие реплики. Отдельный канал notFoundCh позволяет мгновенно сигнализировать об этом.

4. Экспоненциальный backoff при ретраях

Задлинка удваивается с каждой попыткой: 10ms → 20ms → 40ms. При этом между ретраями проверяется:

  • Не отменён ли контекст.
  • Не пришёл ли уже результат от другой реплики.

5. Закрытие канала ошибок

Канал errCh закрывается в отдельной горутине после wg.Wait(). Это позволяет основному циклу определить, что все горутины завершились без результата:

case err, ok := <-errCh:
if !ok {
// Все горутины завершились
return "", ErrAllReplicasFailed
}

Пример использования

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

replicas := []Replica{
&mockReplica{delay: 200 * time.Millisecond, value: "value_from_replica_1"},
&mockReplica{delay: 50 * time.Millisecond, value: "value_from_replica_2"},
&mockReplica{delay: 100 * time.Millisecond, err: ErrNotFound},
}

result, err := GetFromReplicas(ctx, replicas, "mykey")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Result: %s\n", result)
}

Типичные ошибки, которых следует избегать

  • Утечка горутин: если не проверять resultCh перед записью и не слушать ctx.Done() внутри ретраев, горутины зависнут навсегда.
  • Блокировка на записи в канал: отсутствие default в select при записи в resultCh.
  • Ретраи при not_found: бессмысленно и опасно — тратит ресурсы и может задержать ответ.
  • Паника при записи в закрытый канал: resultCh и notFoundCh не закрываются явно — они с GC'нутся после того, как все горутины завершатся.
  • Возврат без проверки контекста: после записи ошибки в errCh нужно сразу return, иначе горутина продолжит работать.

Вопрос 2. Реализуйте функцию с ретраями: если запрос к реплике завершился ошибкой (не not_found), нужно повторять попытку с экспоненциальной задержкой (backoff), но только если ещё никто не записал значение в канал. При получении not_found — сразу возвращать ошибку без ретраев.

Таймкод: 00:45:51

Ответ собеседника: неполный. Кандидат предложил вынести логику запроса к одной реплике в отдельную функцию-колбэк, который будет вызываться в цикле ретраев. Обсудил экспоненциальный backoff с умножением задержки на 2, использование параметра count для количества попыток. Однако реализация не была завершена: кандидат не самостоятельно пришёл к решению, как прерывать цикл ретраев при записи значения в канал другой горутиной (потребовались подсказки). Код ретрая с экспоненциальным бэкоффом был написан частично, но интеграция с основной логикой не завершена.

Правильный ответ:

Это уточняющий вопрос по механизму ретраев внутри задачи с конкурентными запросами к репликам. Ключевая сложность — корректно прерывать цикл ретраев, когда другая горутина уже получила результат.

Архитектура функции tryReplica

Функция для работы с одной репликой должна:

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

Реализация

package main

import (
"context"
"errors"
"math"
"time"
)

var ErrNotFound = errors.New("key not found")

type Replica interface {
Get(ctx context.Context, key string) (string, error)
}

// tryReplica выполняет запросы к одной реплике с ретраями.
// resultCh — канал для записи первого успешного результата (буфер 1).
// notFoundCh — канал для сигнала not_found (буфер 1).
func tryReplica(
ctx context.Context,
replica Replica,
key string,
resultCh chan string,
notFoundCh chan struct{},
) {
const maxRetries = 3
const baseDelay = 10 * time.Millisecond

for attempt := 0; attempt <= maxRetries; attempt++ {
// Проверка 1: не отменён ли контекст
select {
case <-ctx.Done():
return
default:
}

// Проверка 2: не записал ли кто-то уже результат
// Неблокирующее чтение из канала с буфером 1
select {
case <-resultCh:
return // Другая реплика уже ответила — выходим
default:
}

// Выполняем запрос
value, err := replica.Get(ctx, key)
if err == nil {
// Успех — пробуем записать в канал (неблокирующе)
select {
case resultCh <- value:
// Мы первые — результат записан
default:
// Канал уже заполнен — кто-то был быстрее
}
return
}

// Обработка ошибки
if errors.Is(err, ErrNotFound) {
// not_found — приоритетный сигнал, ретраи не нужны
select {
case notFoundCh <- struct{}{}:
default:
}
return
}

// Транзиентная ошибка — делаем ретрай, если попытки не исчерпаны
if attempt < maxRetries {
// Экспоненциальный backoff: baseDelay * 2^attempt
// attempt=0: 10ms, attempt=1: 20ms, attempt=2: 40ms
delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay

// Ждём с возможностью прерывания
select {
case <-ctx.Done():
return
case <-resultCh:
// Пока ждали, другая реплика получила результат
return
case <-time.After(delay):
// Backoff завершён — продолжаем цикл
}
}
}

// Все попытки исчерпаны — горутина завершается без результата
// Ошибку можно отправить в отдельный канал, если нужно агрегировать
}

Детальный разбор ключевых моментов

1. Двойная проверка перед запросом

Перед каждым вызовом replica.Get() мы проверяем два условия:

// Проверка контекста
select {
case <-ctx.Done():
return
default:
}

// Проверка, не получен ли уже результат
select {
case <-resultCh:
return
default:
}

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

2. Неблокирующая запись в канал

Используем select с default для try-send:

select {
case resultCh <- value:
// Записали — мы первые
default:
// Канал заполнен — выходим
}

Без default горутина заблокируется навсегда, если канал уже содержит значение.

3. Экспоненциальный backoff с возможностью прерывания

Задлинка удваивается с каждой попыткой:

delay := time.Duration(math.Pow(2, float64(attempt))) * baseDelay

При этом time.After(delay) обёрнут в select, который может быть прерван:

select {
case <-ctx.Done():
return // Таймаут контекста
case <-resultCh:
return // Кто-то уже получил результат
case <-time.After(delay):
continue // Продолжаем цикл ретраев
}

4. Приоритет not_found

Ошибка not_found обрабатывается мгновенно без ретраев:

if errors.Is(err, ErrNotFound) {
select {
case notFoundCh <- struct{}{}:
default:
}
return
}

Это бизнес-логика: если ключ отсутствует, повторные запросы бессмысленны.

Альтернативная реализация backoff без math.Pow

Для целочисленных степеной двойки можно использовать битовый сдвиг:

delay := baseDelay << attempt // baseDelay * 2^attempt

Это работает быстрее и точнее для целых чисел:

// 10ms << 0 = 10ms
// 10ms << 1 = 20ms
// 10ms << 2 = 40ms

Интеграция с основной функцией

func GetFromReplicas(ctx context.Context, replicas []Replica, key string) (string, error) {
resultCh := make(chan string, 1)
notFoundCh := make(chan struct{}, 1)

var wg sync.WaitGroup
for _, r := range replicas {
wg.Add(1)
go func(replica Replica) {
defer wg.Done()
tryReplica(ctx, replica, key, resultCh, notFoundCh)
}(r)
}

// Закрываем канал после завершения всех горутин
go func() {
wg.Wait()
}()

select {
case <-ctx.Done():
return "", ctx.Err()
case value := <-resultCh:
return value, nil
case <-notFoundCh:
return "", ErrNotFound
}
}

Типичные ошибки

  • Блокировка на записи без default: горутина зависнет, если канал заполнен.
  • Ретраи при not_found: бессмысленная нагрузка на БД.
  • Непроверка контекста во время backoff: горутина продолжит спать, даже если таймаут истёк.
  • Утечка горутин: если основная функция вернула результат, а горутины продолжают работать.