Mock-собеседование по Go от Team Lead из Яндекса
Сегодня мы разберем собеседование по языку 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: горутина продолжит спать, даже если таймаут истёк.
- Утечка горутин: если основная функция вернула результат, а горутины продолжают работать.
