Перейти к содержанию
Языки

100 вопросов по Go на собеседовании с ответами

Подборка начинается с языка — slices, maps, interfaces, goroutines, channels, context, runtime, errors и tests — а затем переходит к распределённым системам, очередям и надёжности, которыми владеют Go-команды российского бигтеха.

90 мин чтения100 подробных ответовПроверено 24 августа 2026
Главная мысль

В хорошем Go-ответе явно названы владельцы: кто меняет значение, закрывает канал, отменяет работу, наблюдает ошибку и гарантирует завершение каждой goroutine.

Вопросы и ответы

100 подробных ответов

01

Чем слайс отличается от массива и как слайс устроен под капотом?

Короткий ответ: Массив — это value-тип фиксированного размера, причём размер зашит в сам тип ([3]int и [4]int — разные типы), и он копируется целиком при присваивании и передаче. Слайс — это лёгкий дескриптор поверх массива: заголовок из трёх полей {указатель на backing array, len, cap}. Копируется только заголовок, данные остаются общими.

Подробно:

  1. Массив[N]T, длина — часть типа, данные лежат единым непрерывным блоком прямо по месту объявления (в стеке, структуре или куче) и копируются побайтово. Передал в функцию — получил копию всех элементов.
  2. Слайс[]T, три машинных слова: ptr на элемент backing array, len (сколько видно) и cap (сколько влезет до реаллокации).
  3. Срезarr[1:3] не копирует данные, а строит новый заголовок, указывающий внутрь того же массива.
s := arr[1:3]

  slice header          backing array [5]int
 ┌──────────┐          ┌───┬───┬───┬───┬───┐
 │ ptr  ────┼────────► │ 0 │ 1 │ 2 │ 3 │ 4 │
 │ len = 2  │              ▲       ▲
 │ cap = 4  │              └─ ptr  └─ len
 └──────────┘

⚠️ Частая ошибка: думать, что s2 := s1 даёт независимую копию. Копируется только 24-байтовый заголовок — оба слайса смотрят в один и тот же backing array, и запись s2[0] = x видна через s1.

02

Что делает append, когда capacity исчерпана, и почему два слайса могут неожиданно делить один массив?

Короткий ответ: Пока есть запас cap, append пишет в тот же backing array и просто увеличивает len — поэтому другой слайс, смотрящий в тот же массив, увидит чужие мутации (алиасинг). Когда cap исчерпана, рантайм выделяет новый массив, копирует туда данные, и слайсы расходятся — старый указывает на прежнюю память, новый на свежую.

Подробно:

  1. Есть место (len < cap) — запись на месте, backing array общий. Классический баг: b := a[:2]; b = append(b, x) затирает a[2].
  2. Места нет (len == cap) — новый массив, копирование, старые ссылки не видят новых элементов.
  3. Рост — при cap < 256 ёмкость удваивается; дальше формула плавно спадает от ~2x к ~1.25x (Go 1.18+, деталь реализации — не гарантия языка).
Пишем в слайс с запасом cap:

 до:  a ─► [1,2,3,_]  cap=4     b := a[:2]  ─► видит [1,2]
 append(b, 9)
 после: [1,2,9,_]                a[2] стал 9 — сюрприз!

Пишем, когда cap исчерпана:

 до:  a ─► [1,2,3]    cap=3
 append(a, 9)
 после: a ─► [1,2,3,9]  ← НОВЫЙ массив, старый жив отдельно

⚠️ Частая ошибка: передать подслайс s[:k] в функцию, которая делает append, и рассчитывать, что оригинал не тронут. Пока есть cap, append перезапишет хвост оригинала. Защита — full slice expression s[:k:k], обрезающий cap до len.

03

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

Короткий ответ: Присваивание b := a копирует только заголовок — данные остаются общими. Чтобы получить независимую копию, нужно скопировать сами элементы: copy(dst, src) в предвыделенный слайс, append([]T(nil), src...) или slices.Clone(src) (Go 1.21+).

Подробно:

  1. copy(dst, src) — копирует min(len(dst), len(src)) элементов; dst надо предвыделить через make, иначе скопируется 0 элементов.
  2. append([]T(nil), src...) — идиома для копии сразу нужной длины, без ручного make.
  3. slices.Clone(src) — самый читаемый способ начиная с Go 1.21.
  4. Смежный приём — full slice expression s[a:b:c] — данных не копирует, но обрезает cap до c-a: чужой append уедет в новый массив и не заденет оригинал.
// НЕ копия — общий backing array:
b := a

// Копии (изменения b не видны в a):
b := make([]int, len(a))
copy(b, a)

b := append([]int(nil), a...)

b := slices.Clone(a) // Go 1.21+

⚠️ Частая ошибка: для всех вариантов копия — поверхностная (shallow). Если T — слайс, мапа или структура с указателем, вложенные данные всё ещё общие; нужна ручная глубокая копия.

04

Чем nil-слайс отличается от пустого слайса и где эта разница стреляет?

Короткий ответ: У обоих len == 0 и cap == 0, по обоим можно итерироваться и в оба можно делать append. Разница в двух местах: nil-слайс равен nil (заголовок с нулевым указателем), а пустой — нет; и в encoding/json nil маршалится в null, а пустой — в [].

Подробно:

  1. nil-слайсvar s []T. Указатель nil, s == nil истинно.
  2. Пустой слайсs := []T{} или make([]T, 0). Указатель на пустой (не-nil) массив, s == nil ложно.
  3. Практическая разница — почти всегда её нет: len, range, append ведут себя одинаково. Стреляет она в JSON-контрактах и в явных проверках == nil.
nil-слайс пустой слайс
Объявление var s []T []T{}
len / cap 0 / 0 0 / 0
s == nil true false
append работает да да
JSON null []

⚠️ Частая ошибка: вернуть nil-слайс из ручки API, где фронт ждёт массив, — и получить в JSON null вместо []. Клиент падает на .map(...). Инициализируйте make([]T, 0), если контракт требует массив.

05

Go передаёт аргументы по значению или по ссылке? Что происходит при передаче слайса и мапы в функцию?

Короткий ответ: В Go всё передаётся строго по значению — ссылок нет. Просто у слайса «значение» — это заголовок {ptr, len, cap}, а у мапы — указатель на внутреннюю структуру hmap. Копируется этот дескриптор, но он указывает на те же данные, поэтому мутации элементов видны снаружи.

Подробно:

  1. Копируется дескриптор — функция получает копию заголовка слайса / копию указателя мапы, а не сами данные.
  2. Мутации элементов видныs[i] = x или m[k] = v меняют общие данные, вызывающая сторона их видит.
  3. Реассайн заголовка — не виденappend с реаллокацией меняет только локальную копию заголовка; чтобы результат вернулся, слайс переприсваивают: s = append(s, x).
func grow(s []int) {
    s = append(s, 99) // меняет ЛОКАЛЬНЫЙ заголовок
}
func set(s []int) {
    s[0] = 99         // меняет ОБЩИЕ данные
}

a := []int{1, 2, 3}
grow(a) // a по-прежнему [1 2 3]
set(a)  // a стал [99 2 3]

⚠️ Частая ошибка: сказать «слайсы и мапы передаются по ссылке». Для интервьюера это красный флаг. Правильно: по значению копируется дескриптор, ссылающийся на общие данные.

06

Как устроена мапа в Go изнутри: бакеты, load factor, эвакуация?

Короткий ответ: Классическая (до Go 1.24) реализация — хеш-таблица из бакетов по 8 пар ключ-значение; при коллизиях подцепляются overflow-бакеты. Когда среднее заполнение переваливает за load factor ~6.5 элементов на бакет, мапа растёт вдвое с инкрементальной эвакуацией — старые бакеты переносятся в новые не разом, а порциями на каждой записи. В Go 1.24 мапа переписана на Swiss Tables.

Подробно (классика, до 1.24):

  1. Бакет — массив из 8 слотов; хранит топ-байт хеша (tophash) для быстрого отсева, затем ключи, затем значения.
  2. Коллизии — если 8 слотов заняты, к бакету цепляется overflow-бакет.
  3. Рост — при count / buckets > 6.5 число бакетов удваивается; эвакуация инкрементальная, чтобы не было длинной паузы.

Что изменилось в Go 1.24 (Swiss Tables):

  1. Группы по 8 с 64-битным control word (по байту на слот, хранит младшие 7 бит хеша) — быстрый SIMD-подобный поиск внутри группы.
  2. Load factor 7/8 (~87.5%) вместо 6.5 — плотнее, меньше памяти.
  3. Нет overflow-бакетов — при коллизии поиск переходит к другим группам квадратичным пробированием; большие мапы бьются на несколько таблиц по ≤128 групп.
Классический бакет (до 1.24):
┌────────────── bucket ──────────────┐    overflow
│ tophash[8] │ keys[8] │ vals[8]     │ ─► ┌───────┐
└────────────────────────────────────┘    │  ...  │
                                          └───────┘

⚠️ Частая ошибка: называть load factor «ровно 6.5» как истину на все времена — с Go 1.24 это уже 7/8 и другая структура. Уточняйте версию Go, о которой говорите.

07

Что будет при чтении из nil-мапы? А при записи?

Короткий ответ: Чтение из nil-мапы безопасно — возвращается zero value типа значения, а в comma-ok форме ok == false. Запись в nil-мапу паникует: panic: assignment to entry in nil map.

Подробно:

  1. Чтениеv := m[k] вернёт zero value, v, ok := m[k] даст ok == false. Итерация по nil-мапе — ноль проходов, тоже без паники.
  2. Записьm[k] = v в nil-мапе роняет рантайм паникой. Мапу надо сначала инициализировать: m := make(map[K]V) или литералом map[K]V{}.
  3. Контраст со слайсом — в nil-слайс append работает и возвращает новый слайс, а в nil-мапу писать напрямую нельзя.
var m map[string]int // nil-мапа

n := m["x"]      // 0, паники нет
n, ok := m["x"]  // 0, false
for range m {}   // 0 итераций

m["x"] = 1       // panic: assignment to entry in nil map

⚠️ Частая ошибка: объявить var m map[string]int и сразу писать в неё. Читается как рабочий код, но падает в рантайме — нужен make.

08

Почему нельзя взять указатель на значение в мапе (&m[key])?

Короткий ответ: Значения в мапе не адресуемы: при росте и эвакуации бакеты перевыделяются, и элементы физически переезжают в другую память. Указатель на старое место стал бы висячим, поэтому &m[key] — ошибка компиляции: invalid operation: cannot take address of m[key].

Подробно:

  1. Причина — рантайм волен перемещать пары ключ-значение при resize/эвакуации; адрес, взятый раньше, указывал бы в никуда. Язык запрещает это на этапе компиляции.
  2. Обход через указатель в значении — храните map[K]*V, тогда сам указатель стабилен, а V живёт в куче отдельно.
  3. Обход через read-modify-write — прочитать значение, изменить копию, положить обратно: v := m[k]; v.N++; m[k] = v.
type Counter struct{ N int }
m := map[string]Counter{"a": {}}

// m["a"].N++            // ошибка компиляции: не адресуемо
// p := &m["a"]          // ошибка компиляции

v := m["a"]; v.N++; m["a"] = v   // read-modify-write

mp := map[string]*Counter{"a": {}}
mp["a"].N++              // ок: указатель адресуем

⚠️ Частая ошибка: пытаться сделать m[key].field = x для структуры-значения. Компилятор откажет; либо кладите указатели, либо переприсваивайте всю структуру.

09

Какие типы могут быть ключом мапы и почему слайс — не может?

Короткий ответ: Ключом может быть любой comparable-тип — тот, для которого определён оператор ==. Слайсы, мапы и функции несравнимы (для них == не определён, только сравнение с nil), поэтому ключами быть не могут. Структуры и массивы годятся, если все их поля/элементы тоже comparable.

Подробно:

  1. Можно — числа, строки, bool, указатели, каналы, интерфейсы, а также структуры и массивы из comparable-полей.
  2. Нельзя — слайс, мапа, функция: у них нет ==, только проверка на nil.
  3. Почему — ключ надо уметь хешировать и сравнивать на равенство; для слайса равенство неоднозначно (по указателю? поэлементно?), поэтому язык его запрещает.
Тип Ключ?
int, string, bool да
указатель, канал да
массив [N]T (T comparable) да
структура из comparable-полей да
слайс []T нет
мапа, функция нет

⚠️ Частая ошибка: взять interface{} (или any) ключом и положить туда слайс. Компилируется, но паникует в рантайме: panic: runtime error: hash of unhashable type.

10

Что произойдёт при конкурентной записи в мапу из нескольких горутин без синхронизации?

Короткий ответ: Рантайм детектит одновременный доступ и валит весь процесс: fatal error: concurrent map writes. Это именно fatal error, а не panic — его нельзя перехватить через recover, программа падает целиком.

Подробно:

  1. Не тихая гонка — рантайм специально отслеживает конкурентную запись (флаг в hmap) и намеренно падает, чтобы не портить структуру данных незаметно.
  2. Fatal, а не panicrecover не спасает, defer не отработает штатно. Такую гонку заодно поймает и детектор -race.
  3. Решенияsync.Mutex/sync.RWMutex вокруг доступа, либо sync.Map для сценариев «много читателей, точечные записи».
// Падает: fatal error: concurrent map writes
m := map[int]int{}
for i := 0; i < 8; i++ {
    go func() { m[i] = i }() // гонка
}

// Безопасно:
var mu sync.Mutex
mu.Lock(); m[i] = i; mu.Unlock()

⚠️ Частая ошибка: сказать, что «будет просто неверное значение» или что «спасёт recover». Интервьюер ждёт слов «fatal error, процесс падает, recover не помогает».

11

Почему порядок обхода мапы в Go случайный?

Короткий ответ: Рандомизация сделана намеренно: при каждом range рантайм выбирает случайную стартовую позицию. Так язык не даёт коду завязываться на порядок, который всё равно не гарантирован (бакеты и эвакуации меняют физическое расположение).

Подробно:

  1. Сделано специально — иначе разработчики начали бы неявно полагаться на «стабильный» порядок, а он поменялся бы при смене версии Go или размера мапы.
  2. Порядок и так не определён — вставка не сохраняет очерёдность; эвакуация при росте перетасовывает элементы.
  3. Нужен стабильный порядок — соберите ключи в слайс и отсортируйте.
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, m[k]) // детерминированный порядок
}

⚠️ Частая ошибка: писать тест, который ждёт конкретный порядок вывода мапы. Он будет «мигать» — падать через раз. Сортируйте ключи или сравнивайте как множество.

12

Что вернёт len() для строки с кириллицей и как правильно посчитать символы?

Короткий ответ: Строка в Go — это иммутабельная последовательность байтов в кодировке UTF-8, и len() считает байты, а не символы. Кириллическая буква занимает 2 байта, поэтому len("привет") == 12, а не 6. Символы (руны) считают через utf8.RuneCountInString или len([]rune(s)).

Подробно:

  1. len(s) — число байтов. Для ASCII совпадает с числом символов, для многобайтовых — нет.
  2. Подсчёт рунutf8.RuneCountInString(s) (без аллокации) или len([]rune(s)) (с аллокацией слайса рун).
  3. range по строке — идёт по рунам, но индекс i — байтовый (позиция начала руны), а не порядковый номер символа.
s := "привет"
len(s)                       // 12 — байты
utf8.RuneCountInString(s)    // 6  — руны
len([]rune(s))               // 6  — руны

for i, r := range s {        // i — байтовый индекс
    fmt.Printf("%d:%c ", i, r)
}

⚠️ Частая ошибка: s[0] = 'x' не скомпилируется — строки неизменяемы. А s[0] возвращает byte (первый байт), а не первый символ; для кириллицы это половина руны.

13

В чём разница между make и new?

Короткий ответ: new(T) выделяет зануленную память под любой тип и возвращает указатель *T. make работает только для slice, map и chan — он не просто выделяет память, а инициализирует внутренние структуры и возвращает готовое к работе значение (не указатель).

Подробно:

  1. new(T) — универсален, даёт *T на zero value. new(int)*int, указывающий на 0.
  2. make(T, ...) — только slice/map/chan; настраивает заголовок слайса / hmap / hchan. Возвращает сам T, а не указатель.
  3. Почему так — slice, map и chan бесполезны в «нулевом» виде без внутренней инициализации, поэтому для них есть отдельный make.
new(T) make(T, ...)
Типы любой slice, map, chan
Возвращает *T T
Инициализация зануление внутренние структуры
Пример new(int)*int make([]int, 3)[]int

⚠️ Частая ошибка: new(map[string]int) возвращает *map[string]int, указывающий на nil-мапу. Разыменовали и пишете — panic: assignment to entry in nil map. Для мап нужен make.

14

Назовите zero value слайса, мапы, канала, указателя и интерфейса — с какими из них можно работать без инициализации?

Короткий ответ: У всех перечисленных zero value — nil, но ведут они себя по-разному. С nil-слайсом можно работать (len, append), из nil-мапы можно читать, но не писать, nil-канал блокируется навсегда, разыменование nil-указателя паникует, а вызов метода на nil-интерфейсе паникует.

Подробно:

  1. Слайсnil; len, range, append работают. Самый «дружелюбный» nil.
  2. Мапаnil; чтение отдаёт zero value, запись паникует.
  3. Каналnil; отправка и приём блокируются навсегда (используется осознанно, чтобы «выключить» ветку в select).
  4. Указательnil; разыменование *ppanic: nil pointer dereference.
  5. Интерфейсnil; сравнение с nil ок, вызов метода паникует.
Тип zero value Без init
слайс nil len/append ок
мапа nil чтение ок, запись — паника
канал nil блок навсегда
указатель nil разыменование — паника
интерфейс nil вызов метода — паника

⚠️ Частая ошибка: валить всё в кучу «nil есть nil». Ключевой нюанс — nil-мапа читается, но не пишется, а nil-канал не паникует, а блокируется.

15

Что такое iota и как с его помощью делают enum?

Короткий ответ: iota — это автоинкрементный счётчик внутри блока const: он равен 0 в первой строке блока и увеличивается на 1 с каждой следующей строкой ConstSpec. Обнуляется в каждом новом блоке const. На нём строят enum-подобные наборы констант.

Подробно:

  1. Базовый enum — перечисляем константы, iota подставляет 0, 1, 2… автоматически.
  2. Пропуск значения_ = iota пропускает 0 (часто чтобы zero value означал «не задано»).
  3. Битовые флаги1 << iota даёт степени двойки для масок.
type Weekday int
const (
    Sunday Weekday = iota // 0
    Monday                // 1
    Tuesday               // 2
)

type Perm uint
const (
    Read  Perm = 1 << iota // 1
    Write                  // 2
    Exec                   // 4
)

const (
    _  = iota            // пропускаем 0
    KB = 1 << (10 * iota) // 1024
    MB                    // 1048576
)

⚠️ Частая ошибка: думать, что iota считает константы. Он считает строки ConstSpec в блоке: если в одной строке объявлено несколько констант через запятую, на всех них iota одинаковый, а прирост идёт по строкам.

16

Почему интерфейс, в который положили типизированный nil-указатель, не равен nil?

Короткий ответ: Интерфейс внутри — это пара (тип, значение), и он равен nil только когда обе части пусты. Если положить в него типизированный nil-указатель ((*MyErr)(nil)), часть «тип» заполнена, поэтому iface == nil даёт false, хотя данные внутри — nil.

Подробно:

  1. Пара (тип, значение) — сравнение i == nil истинно, только если и тип, и указатель на данные нулевые.
  2. Типизированный nil — переменная var e *MyErr сама равна nil, но при возврате через error в интерфейс уезжает её тип *MyErr. Тип задан → интерфейс не nil.
  3. Классический прод-баг — функция возвращает error, вызывающий пишет if err != nil, и ветка ошибки срабатывает на «успехе».
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }

func do() error {
    var e *MyErr = nil // типизированный nil
    return e           // уходит в интерфейс error вместе с типом
}

func main() {
    err := do()
    fmt.Println(err == nil) // false — ловушка!
}
чистый error(nil)         error с (*MyErr)(nil)
┌──────┬───────┐          ┌─────────┬─────────┐
│ type │ value │          │  *MyErr │   nil   │
│ nil  │  nil  │          │ (задан) │ (данные)│
└──────┴───────┘          └─────────┴─────────┘
    == nil ✔                    != nil ✘

⚠️ Частая ошибка: заводить переменную ошибки конкретного типа (var e *MyErr) и возвращать её из функции с сигнатурой error. Держите переменную как var err error или делайте return nil явно — типизированный nil-указатель не должен утекать в интерфейс.

17

Как устроены iface и eface на уровне рантайма?

Короткий ответ: Пустой интерфейс (interface{}/any) представлен структурой eface из двух указателей: на дескриптор типа (*_type) и на данные. Интерфейс с методами — iface: указатель на itab (тип + таблица методов) и указатель на данные. itab строится один раз и кэшируется рантаймом.

Подробно:

  • eface{*_type, data unsafe.Pointer}. Знает конкретный тип значения, но не несёт таблицы методов.
  • iface{*itab, data unsafe.Pointer}. Здесь itab = {inter (тип интерфейса), _type (конкретный тип), hash, fun[...] — указатели на методы}.
  • Кэш itab — рантайм строит itab для пары (интерфейс, тип) один раз и кладёт в глобальный хэш; последующие присваивания берут готовый.
  • data — указатель на значение; для значения оно, как правило, боксится (копия в куче).
eface (any)                iface (интерфейс с методами)
┌────────────┐             ┌────────────┐
│ *_type     │─► тип       │ *itab      │─► ┌──────────────┐
├────────────┤             ├────────────┤   │ inter (тип I)│
│ data       │─► данные    │ data       │   │ _type (тип T)│
│ unsafe.Ptr │             │ unsafe.Ptr │   │ hash         │
└────────────┘             └────────────┘   │ fun[0..n] ───│─► методы
                                            └──────────────┘

⚠️ Частая ошибка: считать any/interface{} бесплатным. Упаковка значения в интерфейс часто вызывает аллокацию (боксинг) и мешает инлайну — на горячем пути это заметно.

18

Как в Go реализован полиморфизм без классов и наследования?

Короткий ответ: Полиморфизм даёт неявная (структурная) реализация интерфейсов: тип удовлетворяет интерфейсу автоматически, если у него есть нужный набор методов — никакого implements. Вместо наследования — композиция через встраивание (embedding), а инкапсуляция — регистром первой буквы имени.

Подробно:

  1. Структурная типизация (duck typing) — «если крякает как утка». Тип и интерфейс нигде явно не связывают; связь проверяется по сигнатурам методов на этапе компиляции.
  2. Композиция вместо наследования — встраивание продвигает методы встроенного типа наружу, но это has-a, а не is-a.
  3. Инкапсуляция — имя с заглавной буквы экспортируется, со строчной — приватно внутри пакета.
type Stringer interface{ String() string }

type Point struct{ X, Y int }

func (p Point) String() string { // Point реализует Stringer
    return fmt.Sprintf("(%d,%d)", p.X, p.Y)
} // без "implements Stringer" — достаточно совпадения метода

⚠️ Частая ошибка: называть embedding «наследованием». Здесь нет подтипов и виртуальных вызовов: внешний тип не приводится к встроенному, а метод встроенного типа не «переопределяется» и не видит полей внешнего.

19

Как сделать type assertion, не рискуя паникой?

Короткий ответ: Используйте comma-ok форму v, ok := i.(T): при несовпадении типа ok == false, а v получает нулевое значение — без паники. Для разбора нескольких вариантов берите type switch. Одиночная форма i.(T) при несовпадении паникует.

Подробно:

  1. Одиночная форма i.(T) — паникует, если динамический тип не T. Годится, только когда тип гарантирован.
  2. Comma-ok v, ok := i.(T) — безопасна; всегда проверяйте ok перед использованием v.
  3. Type switch switch v := i.(type) — идиома для нескольких типов сразу.
var i any = "hello"

s := i.(string)     // ок; но i.(int) → panic

s, ok := i.(string) // ok == true,  s == "hello"
n, ok := i.(int)    // ok == false, n == 0 (без паники)

switch v := i.(type) {
case string: fmt.Println("строка", v)
case int:    fmt.Println("число", v)
default:     fmt.Println("другой тип")
}

⚠️ Частая ошибка: писать одиночную v := i.(T) по данным, тип которых не гарантирован (запросы, JSON, плагины). Один неожиданный тип роняет сервис паникой — используйте comma-ok или type switch.

20

Value receiver против pointer receiver: в чём разница и как выбор влияет на реализацию интерфейса?

Короткий ответ: Pointer receiver (*T) может менять оригинал и не копирует структуру при вызове; value receiver (T) работает с копией. Для интерфейсов ключевое: method set у *T содержит и value-, и pointer-методы, а у T — только value-методы. Поэтому если метод объявлен на *T, значение типа T интерфейс не удовлетворяет.

Подробно:

  1. Мутация — pointer-метод меняет получателя; value-метод правит копию, и изменения теряются.
  2. Копирование — value receiver копирует всю структуру на каждый вызов; для больших структур это накладно.
  3. Method set — определяет, что подходит под интерфейс:
Ресивер метода В method set T В method set *T
func (t T) M()
func (t *T) M()

Отсюда: если M объявлен на *T, то var _ I = T{} не скомпилируется — нужно var _ I = &T{}. При прямом вызове t.M() на адресуемой переменной Go сам берёт &t, но для удовлетворения интерфейса эта магия не работает.

⚠️ Частая ошибка: смешивать value- и pointer-ресиверы на одном типе, а потом удивляться, почему T{} не подходит под интерфейс. Выбирайте один вид ресивера на тип; есть хоть один pointer-метод — обычно делают pointer у всех.

21

Как работает встраивание структур и что будет, если два embedded-типа имеют одинаковый метод?

Короткий ответ: Встраивание (embedding) — включение типа в структуру без имени поля; его методы и поля продвигаются (promotion) на внешнюю структуру. Метод внешней структуры затеняет одноимённый встроенный. Если два встроенных типа несут метод с одним именем на одной глубине — селектор становится неоднозначным (ambiguous), и компилятор ругается при вызове без явного пути.

Подробно:

  1. Promotiona.Method() вызывает метод встроенного типа, если у внешнего своего нет.
  2. Затенение (shadowing) — метод/поле внешнего уровня перекрывает встроенное; выигрывает ближайший по глубине.
  3. Ambiguous selector — два кандидата на одной глубине → ошибка компиляции; разрешается явным путём a.Inner.Method().
type A struct{}
func (A) Hello() string { return "A" }

type B struct{}
func (B) Hello() string { return "B" }

type C struct{ A; B } // оба встроены на одной глубине

func main() {
    c := C{}
    // c.Hello()     // ошибка компиляции: ambiguous selector
    _ = c.A.Hello()  // "A" — явный путь
    _ = c.B.Hello()  // "B"
}

⚠️ Частая ошибка: ждать от embedding переопределения, как в ООП. Виртуальных вызовов нет: если метод A внутри зовёт Hello(), он позовёт именно A.Hello(), а не «переопределённый» на уровне C.

22

Чем горутина отличается от потока ОС?

Короткий ответ: Горутина — это лёгкая единица выполнения, которой управляет рантайм Go, а не ядро ОС. Её стек стартует с ~2 КБ и растёт динамически, тогда как поток ОС резервирует мегабайты фиксированного стека. Рантайм мультиплексирует тысячи горутин на небольшое число потоков по модели M:N, поэтому в одном процессе спокойно живут сотни тысяч горутин.

Подробно:

  1. Стек — горутина: ~2 КБ с ростом/сжатием по мере надобности; поток ОС: фиксированные 1–8 МБ, зарезервированные заранее.
  2. Планировщик — горутины планирует рантайм Go (модель GMP: G — горутина, M — поток ОС, P — процессор-контекст) в user space; потоки планирует ядро.
  3. Стоимость переключения — переключение горутины не заходит в ядро и на порядки дешевле переключения контекста потока.
  4. Масштаб — потоков реалистично тысячи, горутин — сотни тысяч и больше.
Критерий Горутина Поток ОС
Стек ~2 КБ, растёт 1–8 МБ, фиксирован
Кто планирует рантайм Go (M:N) ядро ОС
Переключение user space, дёшево системный вызов, дорого
Сколько на процесс сотни тысяч тысячи

⚠️ Частая ошибка: ответить «это лёгкий поток» и остановиться. Без упоминания планировщика рантайма и модели M:N ответ звучит по-джуновски — интервьюер ждёт именно механику.

23

В чём разница между буферизированным и небуферизированным каналом?

Короткий ответ: Небуферизированный канал передаёт значение синхронно, из рук в руки: send блокируется, пока другая горутина не сделает receive, и наоборот. Буферизированный канал позволяет отправить значение без ожидания получателя, пока в буфере есть место, и блокирует send только когда буфер полон (а receive — когда буфер пуст).

Подробно:

  1. Небуферизированный (make(chan T)) — точка синхронизации: send и receive встречаются в один момент, обе стороны выступают барьером друг для друга.
  2. Буферизированный (make(chan T, N)) — очередь на N элементов: отправитель может уйти вперёд получателя на размер буфера.
  3. Когда буфер помогает — сгладить всплески нагрузки, развязать производителя и потребителя по темпу; но буфер прячет проблемы бэкпрешера, если выбран «на глаз».
func main() {
    ch := make(chan int) // небуферизированный
    ch <- 1              // блок навсегда: получателя нет
    fmt.Println(<-ch)    // сюда уже не дойдём
}
// fatal error: all goroutines are asleep - deadlock!

⚠️ Частая ошибка: отправить в небуферизированный канал в той же горутине, где потом собираешься читать. send заблокируется до появления получателя, а получатель — это следующая строка той же горутины, до которой управление не доходит → deadlock.

24

Что будет при отправке в закрытый канал, чтении из него и повторном close?

Короткий ответ: Отправка в закрытый канал вызывает панику. Чтение из закрытого канала сначала возвращает оставшиеся в буфере значения, а затем — нулевое значение типа с ok == false. Повторный close того же канала тоже паникует.

Подробно:

  1. Send в закрытыйpanic: send on closed channel. Поэтому канал закрывает горутина, которая владеет жизненным циклом отправки и знает, что новых значений точно не будет; получатель этого знать не может.
  2. Receive из закрытого — буфер дренируется как обычно; когда он пуст, чтения мгновенно возвращают zero value и ok == false без блокировки.
  3. Повторный closepanic: close of closed channel; двойной close — типичный симптом отсутствия единого владельца.
  4. Close nil-канала — тоже паника (close of nil channel).
Операция над каналом Открытый Закрытый
ch <- v ок / блок panic
<-ch ок / блок остатки буфера, затем zero + ok=false
close(ch) закрывает panic

⚠️ Частая ошибка: считать, что чтение из закрытого канала блокируется. Наоборот — оно возвращается мгновенно, поэтому закрытый канал в select в цикле будет «стрелять» на каждой итерации, если не занулить его переменную.

25

Что произойдёт при чтении или записи в nil-канал и как это используют в select?

Короткий ответ: И send, и receive на nil-канале блокируются навсегда. Это не баг, а инструмент: присвоив переменной канала nil, вы «выключаете» соответствующую ветку select — она перестаёт быть готовой и больше не выбирается.

Подробно:

  1. Семантика nil-канала<-nilCh и nilCh <- v блокируются вечно; close(nilCh) паникует.
  2. Зачем это в select — закрытый канал в select готов мгновенно и в цикле «стреляет» zero value без конца. Чтобы отключить исчерпанный вход, его переменную зануляют — тогда ветка навсегда не готова.
  3. Паттерн fan-in — сливаем два канала в один и по мере закрытия источников выключаем их ветки.
for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok { a = nil; continue } // выключаем ветку a
        out <- v
    case v, ok := <-b:
        if !ok { b = nil; continue } // выключаем ветку b
        out <- v
    }
}
close(out)

⚠️ Частая ошибка: оставить закрытый канал в select как есть. Ветка case <-closedCh будет срабатывать на каждой итерации и крутить busy-loop на 100% CPU, вместо того чтобы обслуживать оставшиеся источники.

26

Как понять при чтении, что канал закрыт?

Короткий ответ: Через форму v, ok := <-ch: после закрытия и полного дренажа буфера ok становится false. Либо через for v := range ch — цикл сам завершается, когда канал закрыт и опустошён.

Подробно:

  1. Comma-okv, ok := <-ch: пока канал открыт или в буфере есть данные, ok == true; после закрытия и опустошения v — нулевое значение типа, ok == false.
  2. rangefor v := range ch читает до закрытия и выходит без лишнего кода; идиоматичный способ для потребителя.
  3. Зачем именно ok — отличить «прислали настоящий zero value» (например, 0 или "") от «канал закрыт» можно только по ok; само значение неотличимо.
// форма 1: comma-ok
v, ok := <-ch
if !ok {
    // канал закрыт и пуст
}

// форма 2: range — выходит сам при close(ch)
for v := range ch {
    process(v)
}

⚠️ Частая ошибка: проверять «канал закрыт?» сравнением значения с нулём (if v == 0). Настоящий 0, отправленный в канал, неотличим от zero value закрытого канала — единственный надёжный сигнал это ok.

27

Кто должен закрывать канал — отправитель или получатель? Почему?

Короткий ответ: Закрывать канал должен отправитель, и только он. close — это сигнал «данных больше не будет», а его вправе давать лишь тот, кто пишет. Если канал закроет получатель, отправитель на следующем send получит panic: send on closed channel.

Подробно:

  1. Владелец = писатель — тот, кто создал и пишет в канал, отвечает за его закрытие; читатель просто читает до конца.
  2. Несколько отправителей — ни один из них не закрывает канал напрямую (иначе двойной close → паника). Нужен координатор: WaitGroup дожидается всех писателей, отдельная горутина делает единственный close.
  3. Зачем закрывать вообще — чтобы разбудить range/comma-ok у получателей; если получатель и так знает, когда остановиться, канал можно не закрывать (GC приберёт).
var wg sync.WaitGroup
for _, w := range workers {
    wg.Add(1)
    go func(w Worker) { defer wg.Done(); w.emit(out) }(w)
}
// единственный close — после всех отправителей
go func() { wg.Wait(); close(out) }()
for v := range out { process(v) }

⚠️ Частая ошибка: закрывать канал на стороне получателя «чтобы освободить». Это провоцирует панику у любого пишущего и ломает контракт — освобождение делает сборщик мусора, а не close.

28

Как select выбирает ветку, если готовы несколько каналов, и что меняет default?

Короткий ответ: Если готовы сразу несколько веток, select выбирает одну из них псевдослучайно — это защита от starvation, чтобы один постоянно готовый канал не вытеснял остальные. Если не готова ни одна ветка, select блокируется до появления готовой; ветка default делает select неблокирующим — при отсутствии готовых он мгновенно уходит в default.

Подробно:

  1. Несколько готовых — равновероятный случайный выбор среди готовых веток (не по порядку в коде).
  2. Ни одной готовой, без default — блокировка до первой готовности.
  3. default — исполняется сразу, если ничего не готово; так делают неблокирующие приём/отправку и poll-циклы.
  4. Таймаут — классический паттерн через time.After, который присылает значение по истечении срока.
select {
case res := <-work:
    return res, nil
case <-time.After(2 * time.Second):
    return nil, errors.New("timeout")
case <-ctx.Done():
    return nil, ctx.Err()
}

⚠️ Частая ошибка: класть time.After внутрь горячего цикла на каждой итерации. До Go 1.23 каждый такой Timer жил в куче до срабатывания — на высокой частоте это была заметная утечка; с Go 1.23 несработавшие таймеры собирает GC, но аллокация на каждой итерации осталась, поэтому для повторов берут time.NewTimer/NewTicker и переиспользуют.

29

Что такое утечка горутин и как её найти в работающем сервисе?

Короткий ответ: Утечка горутин — это горутина, которая заблокирована навсегда и никогда не завершится: send в канал без читателя, receive из канала, куда никто не пишет, ожидание без отмены по контексту. Такие горутины держат свои стеки и захваченную память, а их число монотонно растёт. Ищут по профилю pprof goroutine и по метрике runtime.NumGoroutine().

Подробно:

  1. Причины — блокирующая операция без пути выхода: заброшенный канал, отсутствие ctx.Done(), забытый WaitGroup.
  2. Диагностикаnet/http/pprof даёт goroutine-профиль со стеками (видно, где именно висят); runtime.NumGoroutine() в метриках показывает монотонный рост.
  3. Профилактика — у каждой горутины должен быть гарантированный путь выхода: ctx.Done(), done-канал или закрытие входного канала.
// УТЕЧКА: получатель ушёл по timeout, отправитель висит на send навсегда
func leak() <-chan int {
    ch := make(chan int) // небуферизированный
    go func() { ch <- expensive() }() // некому читать → блок навсегда
    return ch
}

// ФИКС: буфер на 1 даёт отправителю уйти, даже если получателя нет
func fixed() <-chan int {
    ch := make(chan int, 1)
    go func() { ch <- expensive() }()
    return ch
}

⚠️ Частая ошибка: запустить горутину-писателя в небуферизированный канал и вернуть его наружу, где получатель может уйти по таймауту. Отправитель зависнет на send навсегда — классическая утечка на каждый такой вызов.

30

Уронит ли паника в одной горутине весь процесс?

Короткий ответ: Да. Необработанная паника в любой горутине разворачивает её стек до конца и, если её никто не поймал через recover, завершает всю программу целиком. Паника не изолируется в пределах одной горутины.

Подробно:

  1. Масштаб — паника поднимается по стеку своей горутины; дойдя до верха без recover, роняет весь процесс с трейсом.
  2. recover только «свой»recover() работает лишь в defer внутри той же горутины, где случилась паника. Поймать панику соседней горутины нельзя — там нет общего стека.
  3. Практика для воркеров — каждую горутину, куда может прилететь паника, оборачивают в defer/recover, чтобы одна сбойная задача не убила сервис.
func safeGo(task func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("recovered: %v", r)
            }
        }()
        task() // паника здесь не уронит процесс
    }()
}

⚠️ Частая ошибка: думать, что «умрёт только эта горутина». Наоборот — падает весь процесс; и recover в главной горутине не спасёт от паники в дочерней, ловить нужно там же, где паникуешь.

31

Какая классическая ловушка была с захватом переменной цикла в горутинах и что поменял Go 1.22?

Короткий ответ: До Go 1.22 переменная цикла была одна на все итерации, и горутины, замкнувшие её, видели её общее текущее значение — к моменту их запуска цикл обычно уже завершался, и все печатали последнее значение. В Go 1.22 семантику поменяли: теперь на каждой итерации создаётся своя копия переменной, и ловушка исчезла.

Подробно:

  1. Причина (до 1.22)i (или v в range) — единственная переменная, переиспользуемая между итерациями; замыкание захватывает её по ссылке, а не значение на момент создания.
  2. Симптом — вывод вроде 3 3 3 вместо 0 1 2: горутины стартуют после цикла, когда переменная уже равна финалу.
  3. Go 1.22 — переменная цикла стала per-iteration; старый код «просто заработал правильно» при go 1.22 в go.mod.
  4. Фикс для старых версийi := i (shadowing) или передача аргументом в горутину.
// До Go 1.22 печатало 3 3 3; с 1.22 — 0 1 2
for i := 0; i < 3; i++ {
    go func() { fmt.Println(i) }()
}

// Фикс, работающий на любой версии — передать аргументом:
for i := 0; i < 3; i++ {
    go func(i int) { fmt.Println(i) }(i)
}

⚠️ Частая ошибка: на собеседовании уверенно сказать «выведет 0 1 2», не уточнив версию Go. Правильный ответ зависит от неё: до 1.22 — почти наверняка 3 3 3, с 1.22 — 0 1 2 (в неопределённом порядке).

32

Как корректно остановить запущенные горутины снаружи?

Короткий ответ: Убить горутину извне нельзя — в Go нет kill. Остановка только кооперативная: горутина сама периодически проверяет сигнал отмены и выходит. Идиоматично — через отмену context; альтернатива — закрытие общего done-канала, который работает как broadcast сразу всем слушателям.

Подробно:

  1. Нет принудительного kill — рантайм не даёт остановить чужую горутину; она обязана сотрудничать.
  2. context (идиоматично) — родитель вызывает cancel(), горутина ловит <-ctx.Done() в рабочем select и возвращается; заодно прокидывается таймаут/дедлайн.
  3. done-каналclose(done) мгновенно разблокирует всех, кто читает <-done (broadcast); удобно, когда контекст избыточен.
  4. Обязательный дренаж — при выходе нужно не оставить зависших писателей/читателей на других каналах.
func worker(ctx context.Context, in <-chan Job) {
    for {
        select {
        case <-ctx.Done(): // сигнал остановки снаружи
            return
        case job, ok := <-in:
            if !ok { return }
            process(job)
        }
    }
}

⚠️ Частая ошибка: ждать, что cancel() немедленно прервёт работу горутины. Он лишь закрывает ctx.Done(); если в цикле нет проверки этого канала, горутина продолжит крутиться как ни в чём не бывало.

33

Как net/http обрабатывает входящие запросы — что там с конкурентностью?

Короткий ответ: Сервер net/http запускает отдельную горутину на каждое входящее соединение (и, соответственно, на каждый запрос). Поэтому хендлеры вызываются конкурентно, и обращаться к общему изменяемому состоянию из них нужно под синхронизацией — мьютексом или через атомики/каналы. Отмена со стороны клиента видна через r.Context(), который отменяется при разрыве соединения.

Подробно:

  1. Горутина на соединениеServer.Serve в цикле принимает соединение и запускает go c.serve(...); хендлеры разных запросов выполняются параллельно.
  2. Требование к хендлерам — они обязаны быть безопасны для конкурентного вызова: общее состояние (кэш, счётчики, map) — под sync.Mutex или sync/atomic.
  3. r.Context() — отменяется при разрыве клиента или дедлайне; используйте его, чтобы прервать дорогие операции (запрос в БД, вызов апстрима) и не жечь ресурсы на брошенный запрос.
// ГОНКА: конкурентная запись в map из многих горутин-хендлеров
var cache = map[string]int{}
func bad(w http.ResponseWriter, r *http.Request) {
    cache[r.URL.Path]++ // concurrent map writes → fatal
}

// ФИКС: защитить общий доступ мьютексом
var mu sync.Mutex
func good(w http.ResponseWriter, r *http.Request) {
    mu.Lock(); cache[r.URL.Path]++; mu.Unlock()
}

⚠️ Частая ошибка: держать общее состояние в хендлере без синхронизации, считая, что «запросы идут по очереди». Они идут параллельно; конкурентная запись в map роняет процесс с fatal error: concurrent map writes.

34

В чём разница между sync.Mutex и sync.RWMutex и когда RWMutex реально оправдан?

Короткий ответ: Mutex даёт эксклюзивный доступ — одна горутина в критической секции в любой момент. RWMutex разделяет блокировку на две: под RLock() читатели заходят параллельно, а Lock() писателя эксклюзивен и ждёт, пока все читатели выйдут. RWMutex оправдан только там, где чтений заметно больше, чем записей.

Подробно:

  1. Mutex — простой замок: Lock()/Unlock(), всегда один владелец. Дефолтный выбор.
  2. RWMutexRLock() для читателей (можно много одновременно), Lock() для писателя (один, эксклюзивно).
  3. Когда переходить на RWMutex — только если профилирование показало contention на чтениях и соотношение read:write велико (условно 10:1 и выше), а критическая секция не микроскопическая.
Критерий sync.Mutex sync.RWMutex
Читатели по одному параллельно
Писатель эксклюзивно эксклюзивно
Накладные расходы ниже выше (сложнее внутри)
Когда по умолчанию чтений >> записей

⚠️ Частая ошибка: ставить RWMutex «на всякий случай» и пугать голоданием писателей. В Go его нет: RWMutex write-preferring — ожидающий Lock() блокирует новых читателей (побочный эффект: рекурсивный RLock() в одной горутине может дать дедлок). Реальная цена — более дорогой захват: на коротких секциях обычный Mutex часто быстрее.

35

Как работает sync.WaitGroup и какая классическая ошибка с wg.Add()?

Короткий ответ: WaitGroup — это счётчик горутин: Add(n) увеличивает его, Done() уменьшает на единицу, Wait() блокируется, пока счётчик не дойдёт до нуля. Классическая ошибка — вызывать Add(1) внутри уже запущенной горутины: Wait() может увидеть нулевой счётчик и пройти дальше до того, как горутина успела инкрементировать его.

Подробно:

  1. Правильный порядокAdd(1) вызывается в родительской горутине до go func(), а Done() ставится через defer в самой горутине.
  2. Почему гонка — если Add внутри горутины, планировщик может дать Wait() отработать раньше запуска — тогда wg уже «пуст» и ждать нечего.
  3. Go 1.25 — появился wg.Go(fn): он сам делает Add(1) до старта и Done() после, закрывая этот класс багов. Убедись, что проект уже на 1.25, прежде чем использовать.
// НЕПРАВИЛЬНО — Add внутри горутины, гонка с Wait
for _, u := range users {
    go func(u User) {
        wg.Add(1)          // может не успеть до Wait()
        defer wg.Done()
        process(u)
    }(u)
}
wg.Wait()

// ПРАВИЛЬНО — Add до go, Done через defer
for _, u := range users {
    wg.Add(1)
    go func(u User) {
        defer wg.Done()
        process(u)
    }(u)
}
wg.Wait()

⚠️ Частая ошибка: забыть defer wg.Done() при раннем return/panic — счётчик не дойдёт до нуля и Wait() повиснет навсегда (дедлок).

36

Что гарантирует sync.Once и где его типично применяют?

Короткий ответ: sync.Once гарантирует, что переданная в Do(f) функция выполнится ровно один раз за всё время жизни программы, даже если Do дёргают из множества горутин одновременно. Остальные вызывающие блокируются, пока первый не завершит f, и только потом идут дальше. Типичный кейс — ленивая инициализация синглтона.

Подробно:

  1. Гарантия — «ровно один раз» плюс happens-before: после возврата из Do результат f виден всем горутинам без гонок.
  2. Синхронность — конкурентные вызовы не проскакивают мимо, а ждут завершения первого; это не «попытался — и ладно».
  3. Где применяют — ленивое создание конфига, пула соединений, клиента к внешнему сервису — когда инициализация дорогая и нужна один раз.
  4. Go 1.21+sync.OnceValue(f) и sync.OnceFunc(f) возвращают функцию с той же семантикой, но без ручного объявления Once и флага.
var (
    once     sync.Once
    instance *DB
)

func GetDB() *DB {
    once.Do(func() {
        instance = connect() // выполнится один раз
    })
    return instance
}

// Go 1.21+: то же самое короче
var GetDB = sync.OnceValue(func() *DB { return connect() })

⚠️ Частая ошибка: повторно использовать одну Once для «переинициализации» — сбросить её нельзя, второй Do просто ничего не сделает. Для этого нужен другой механизм.

37

Когда sync/atomic уместнее мьютекса?

Короткий ответ: sync/atomic уместен, когда нужно атомарно менять одно машинное слово — счётчик, флаг, указатель — без блокировки. Такие lock-free операции (в основе — CAS, compare-and-swap) дешевле мьютекса, потому что не усыпляют горутину и не ходят в планировщик. Как только инвариант охватывает несколько полей сразу, atomic уже не спасает — нужен Mutex.

Подробно:

  1. Одиночное слово — инкремент счётчика, установка флага, подмена указателя на конфиг: atomic быстрее и не блокирует.
  2. CASCompareAndSwap меняет значение, только если оно совпало с ожидаемым; на нём строят lock-free алгоритмы, но их легко написать с багами.
  3. Составной инвариант — если надо согласованно изменить два и более полей (например balance и history), atomic не гарантирует их совместную атомарность — только Mutex.
  4. Go 1.19+ — типизированные atomic.Int64, atomic.Bool, atomic.Pointer[T]: безопаснее и читабельнее старых функций atomic.AddInt64(&x, ...).
Ситуация atomic Mutex
Счётчик/флаг да, дёшево избыточно
Подмена указателя atomic.Pointer[T] можно, но дороже
Несколько полей вместе нельзя да
Сложная критическая секция нельзя да

⚠️ Частая ошибка: собирать «согласованное» состояние из нескольких atomic-переменных. По отдельности каждая атомарна, но вместе они читаются в рассогласованном виде — это уже гонка на уровне логики.

38

Зачем нужен sync.Map и почему это не универсальная замена map с мьютексом?

Короткий ответ: sync.Map — специализированная конкурентная мапа, оптимизированная под два сценария: ключ пишется один раз и потом много читается, либо разные горутины работают с непересекающимися наборами ключей. Внутри она снижает contention на многих ядрах. В общем же случае обычная map под RWMutex быстрее, типобезопаснее и понятнее.

Подробно:

  1. Под что заточена — read-heavy кэши со стабильными ключами; внутри есть отдельная read-only копия, которую читают без блокировки.
  2. Почему не универсальна — при активных вставках/удалениях разных ключей sync.Map проигрывает map+RWMutex из-за промахов мимо read-only копии и её перестроений.
  3. Типобезопасность — API работает с any: на каждом обращении идёт упаковка в интерфейс и приведение типа, компилятор не проверит ключ и значение.
  4. Правило выбора — начинай с map под RWMutex; переходи на sync.Map, только если профиль подтвердил один из её двух паттернов.
Критерий map + RWMutex sync.Map
Типы статические, проверяет компилятор any, приведения в рантайме
Записи разных ключей быстро медленнее
Read-heavy, стабильные ключи ок быстрее
Читабельность выше ниже

⚠️ Частая ошибка: тащить sync.Map по умолчанию «потому что concurrent». В большинстве задач map под RWMutex и проще, и быстрее.

39

Как работает флаг -race и почему он не ловит все гонки?

Короткий ответ: -race включает динамический детектор гонок на основе ThreadSanitizer: компилятор инструментирует каждый доступ к памяти, и рантайм строит happens-before, отслеживая, что два обращения к одной ячейке из разных горутин не упорядочены и хотя бы одно — запись. Ключевое ограничение: он видит только те гонки, которые реально произошли на исполненном пути в этом запуске.

Подробно:

  1. Как включитьgo test -race, go run -race, go build -race; место ему в тестах и CI, не в проде.
  2. Динамический, не статический — если код с гонкой не выполнился (не тот вход, не тот тайминг планировщика), детектор промолчит. Отсюда — гонять под нагрузкой и разными сидами.
  3. Цена — замедление по CPU в 2–20 раз и рост потребления памяти в 5–10 раз; поэтому только в тестах, не на боевом трафике.
  4. Вывод — при поимке печатает стек читателя, писателя и место аллокации, что и указывает на баг.
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
  main.incr()
      /app/main.go:14 +0x44

Previous read at 0x00c0000b4010 by goroutine 6:
  main.read()
      /app/main.go:9 +0x38
==================

⚠️ Частая ошибка: считать «прошло под -race» доказательством отсутствия гонок. Это отсутствие доказательства, а не доказательство отсутствия — непокрытый путь останется незамеченным.

40

Почему нельзя копировать sync.Mutex (или структуру с ним) и как go vet это ловит?

Короткий ответ: Копия мьютекса дублирует его внутреннее состояние (флаг захвата, счётчик ожидающих), и получаются два независимых замка вместо одного — взаимное исключение молча ломается, разные горутины считают, что «залочили», работая с разными копиями. go vet через анализатор copylocks помечает любую передачу такой структуры по значению.

Подробно:

  1. Что ломается — после копирования оригинал и копия не синхронизированы между собой; защита пропадает без единой ошибки в рантайме.
  2. Как ловит vetcopylocks смотрит на типы, реализующие Lock/Unlock (sync.Locker), и ругается на присваивание, передачу в функцию или возврат по значению.
  3. Правило — тип с мьютексом внутри держат и передают только по указателю, а методы объявляют с pointer receiver (func (s *Store)), не value receiver.
type Counter struct {
    mu sync.Mutex
    n  int
}

// БАГ: value receiver копирует Counter вместе с mu на каждом вызове
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

// go vet: Inc passes lock by value: Counter contains sync.Mutex

// Правильно: pointer receiver — работаем с одним замком
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

⚠️ Частая ошибка: объявить метод с value receiver у структуры с sync.Mutex. Замок копируется на каждый вызов, инкремент теряется под гонкой — а go vet в CI сразу это подсветит.

41

Зачем нужен context и чем отличаются WithCancel, WithTimeout и WithDeadline?

Короткий ответ: context переносит через границы API и горутин три вещи: сигнал отмены, дедлайн и request-scoped значения. Контексты образуют дерево — отмена родителя отменяет всех потомков. WithCancel даёт ручную отмену, WithDeadline — отмену в абсолютный момент времени, WithTimeout — это сахар над WithDeadline (дедлайн = «сейчас + длительность»).

Подробно:

  1. WithCancel — возвращает ctx, cancel; отменяешь вручную, когда работа больше не нужна (например, первый ответ из нескольких горутин уже пришёл).
  2. WithDeadline — отмена наступит не позже указанного time.Time; удобно, когда дедлайн задан извне и абсолютен.
  3. WithTimeout — то же, что WithDeadline(now + d); удобно для относительных таймаутов запроса.
  4. Общее — все три возвращают cancel, который надо звать через defer, чтобы освободить ресурсы контекста; отмена распространяется вниз по дереву.
Конструктор Триггер отмены Когда
WithCancel ручной вызов cancel() отмена по событию/условию
WithDeadline наступление time.Time абсолютный дедлайн извне
WithTimeout истечение длительности относительный таймаут

⚠️ Частая ошибка: не вызвать defer cancel(). Даже если контекст отменится по таймауту, без cancel() внутренний таймер и связанные ресурсы держатся до срабатывания дедлайна — go vet предупреждает о потерянном cancel.

42

В чём разница между context.Background() и context.TODO()?

Короткий ответ: Функционально они идентичны — оба возвращают пустой (но не nil) контекст без отмены, дедлайна и значений. Разница чисто семантическая, для читателя кода: Background() — осознанный корень дерева контекстов, а TODO() — маркер «здесь нужен контекст, но какой именно — ещё не решено».

Подробно:

  1. Background — стартовая точка: main, инициализация, верхний уровень входящего запроса. Ты сознательно говоришь «это корень».
  2. TODO — заглушка при рефакторинге: настоящий ctx до этого места ещё не пробросили, а передавать nil нельзя. Линтеры и ревьюеры видят, что тут долг.
  3. Технически — под капотом оба emptyCtx; выбор влияет не на поведение, а на намерение, читаемое из кода.
context.Background() context.TODO()
Поведение пустой контекст пустой контекст
Смысл осознанный корень «ещё не решил»
Где main, init, корень запроса заглушка при рефакторинге

⚠️ Частая ошибка: сыпать TODO() по коду просто чтобы «скомпилировалось». Он для временной заглушки; в готовом пути должен течь реальный ctx из вызывающего кода.

43

Почему context.WithValue — антипаттерн для передачи бизнес-параметров?

Короткий ответ: WithValue хранит значения как any по ключу-any: зависимость становится невидимой — она не отражена в сигнатуре функции, компилятор не проверит ни наличие, ни тип, а забытое или неверно приведённое значение выстрелит паникой или nil только в рантайме. Бизнес-параметры (userID, лимит, фильтр) надо передавать явными аргументами. Легально в контексте живут только request-scoped метаданные.

Подробно:

  1. Нет типобезопасности — на извлечении делаешь ctx.Value(k).(T); ошибся типом или ключом — паника или nil в проде, а не ошибка компиляции.
  2. Скрытая зависимость — функция принимает ctx, но что она из него достаёт, по сигнатуре не видно; рефакторить и тестировать тяжело.
  3. Что класть можно — сквозные метаданные запроса: trace/request ID, информацию об аутентификации, локаль — то, что пронизывает все слои и не является бизнес-входом.
  4. Ключи — только собственного неэкспортируемого типа, чтобы исключить коллизии между пакетами.
// Ключ собственного типа — защита от коллизий
type ctxKey string
const traceIDKey ctxKey = "traceID"

func WithTraceID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, traceIDKey, id)
}

func TraceID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(traceIDKey).(string)
    return id, ok
}

⚠️ Частая ошибка: класть ключом строку или встроенный тип (ctx.Value("user")). Два пакета с одинаковым ключом молча перезатрут значения друг друга — ключ обязан быть неэкспортируемого типа.

44

Почему context передают первым аргументом, а не хранят в поле структуры?

Короткий ответ: Контекст живёт в рамках одного вызова или запроса и должен течь явно по цепочке вызовов — поэтому его передают первым параметром ctx context.Context. В поле структуры его время жизни размывается: объект переживёт запрос, и один сохранённый ctx начнёт случайно обслуживать другие, посторонние запросы, ломая отмену и дедлайны.

Подробно:

  1. Явный потокctx первым аргументом делает область его действия видимой: понятно, что этот вызов подчинён этому контексту и его отмене.
  2. Проблема поля — долгоживущий объект (сервис, клиент) с полем ctx привяжется к контексту первого запроса; следующие запросы будут наследовать чужой дедлайн или уже отменённый контекст.
  3. Конвенция — прямо зафиксирована в документации пакета context: «Do not store Contexts inside a struct type; instead, pass a Context explicitly».
  4. Исключения — осознанные: http.Request носит контекст внутри, потому что сам объект строго request-scoped и живёт ровно один запрос.
// Идиоматично: ctx — первый параметр, течёт по цепочке
func (s *Service) FetchUser(ctx context.Context, id string) (*User, error) {
    return s.repo.Get(ctx, id)
}

// Антипаттерн: ctx спрятан в поле долгоживущего сервиса
type Service struct {
    ctx context.Context // переживёт запрос, обслужит чужие
}

⚠️ Частая ошибка: сохранить ctx в поле, чтобы «не таскать его всюду». Долгоживущий объект залипнет на контексте первого запроса — отмена и таймаут поедут для всех последующих.

45

Мьютекс, канал, atomic или иммутабельность — как выбрать способ разделить состояние между горутинами?

Короткий ответ: Выбор по природе задачи, а не по вкусу. atomic — для одиночных слов (счётчики, флаги, указатели). Mutex — когда надо согласованно защитить составной инвариант из нескольких полей. Каналы — для передачи владения данными и оркестрации горутин («share memory by communicating»). Иммутабельность снимает проблему совсем: неизменяемые данные можно читать из любого числа горутин без синхронизации.

Подробно:

  1. atomic — одна ячейка, простая операция; максимально дёшево, но только слово целиком.
  2. Mutex — несколько полей должны меняться и читаться как единое целое; классическая защита критической секции.
  3. Каналы — данные перемещаются между стадиями/воркерами, нужна координация, backpressure, сигнал завершения; владение передаётся, а не разделяется.
  4. Иммутабельность — построил значение один раз и только читаешь (или заменяешь указатель через atomic.Pointer); гонок нет по определению.
Инструмент Идеальный случай Не для этого
atomic счётчик, флаг, указатель составной инвариант
Mutex несколько полей вместе передача владения
Канал пайплайн, воркеры, сигналы простой общий счётчик
Иммутабельность read-only конфиг/снапшот частые точечные записи

⚠️ Частая ошибка: отвечать «каналы всегда лучше». Интервьюер слушает про уместность: общий счётчик под каналом — это переусложнение, а составной инвариант через atomic — гонка. Инструмент выбирают под форму данных.

46

Расскажите про модель G-M-P: как планировщик Go распределяет горутины?

Короткий ответ: Планировщик Go — это M:N-модель поверх трёх сущностей. G — горутина (задача со стеком), M — поток ОС (machine), P — логический процессор (processor) с локальной очередью готовых горутин. M исполняет G, только пока держит P; число P ограничено GOMAXPROCS, поэтому параллельно Go-код крутят ровно столько потоков, сколько P.

Подробно:

  1. P — это право на исполнение. У каждого P своя runqueue (локальная очередь, до 256 G). M забирает G из очереди своего P и запускает.
  2. Work stealing. Когда локальная очередь P опустела, он крадёт половину горутин из очереди чужого P или тянет из глобальной очереди — так нагрузка балансируется без единого глобального лока.
  3. Глобальная очередь. Переполнение локальной очереди и «разбуженные» горутины уходят в общий globrunq; чтобы он не голодал, P периодически (каждый 61-й тик) заглядывает туда.
  4. Развязка M и P. Когда M блокируется, P отцепляется и достаётся другому M — горутины не простаивают.
   G G G          G G            G G G G
   ┌─────┐        ┌─────┐        ┌─────┐   global runq
   │ P0  │        │ P1  │        │ P2  │   [G G G ...]
   └──┬──┘        └──┬──┘        └──┬──┘        ▲
      │ steal◄───────┘              │           │ (каждый 61-й тик)
   ┌──┴──┐        ┌─────┐        ┌──┴──┐
   │ M0  │        │ M1  │        │ M2  │  ← потоки ОС
   └─────┘        └─────┘        └─────┘

⚠️ Частая ошибка: говорить «горутина = поток». Горутин могут быть сотни тысяч, а потоков (M) — единицы; именно P мультиплексирует множество G на небольшое число M.

47

Что задаёт GOMAXPROCS и чему он равен по умолчанию?

Короткий ответ: GOMAXPROCS задаёт число P — максимум потоков ОС, одновременно исполняющих Go-код. По умолчанию он равен числу логических CPU (runtime.NumCPU()) — так с Go 1.5.

Подробно:

  1. Что именно ограничивает. Только параллельное исполнение Go-кода. Горутины, заблокированные на syscall или сетевом I/O, в лимит не входят — потоков в процессе может быть куда больше, чем GOMAXPROCS.
  2. Как менять. Через переменную окружения GOMAXPROCS или в рантайме runtime.GOMAXPROCS(n).
  3. Ловушка контейнеров. Исторически дефолт брал число ядер всей ноды, игнорируя CPU-лимит cgroup: на 64-ядерной ноде с лимитом «2 CPU» рантайм поднимал 64 P — лишние переключения контекста и троттлинг. Классическое лечение — uber-go/automaxprocs.
  4. Свежий Go. С Go 1.25 рантайм по умолчанию учитывает CPU-лимит cgroup — на Linux дефолт округляется вверх от лимита, поэтому проверяйте версию, прежде чем тащить костыль.
// GOMAXPROCS=4 ./app — через переменную окружения
runtime.GOMAXPROCS(4)      // или в рантайме
n := runtime.GOMAXPROCS(0) // 0 — прочитать текущее значение, не меняя

⚠️ Частая ошибка: на Go < 1.25 положиться на дефолт в Kubernetes с CPU-лимитом. Без automaxprocs рантайм видит все ядра ноды, а не выделенную квоту.

48

Планировщик Go кооперативный или вытесняющий? Что изменилось в Go 1.14?

Короткий ответ: До Go 1.14 планировщик был кооперативным — горутина отдавала P только в safepoint'ах (в основном на вызовах функций). С Go 1.14 добавлено асинхронное вытеснение сигналами: sysmon шлёт потоку SIGURG, и горутину снимают с P принудительно, даже если она сама не уступает.

Подробно:

  1. Кооперативная модель (до 1.14). Переключение возможно только там, где компилятор вставил проверку — на вызовах функций, аллокации, операциях с каналами. Тесный цикл без единого вызова не давал планировщику точки для вытеснения.
  2. Симптом. Горутина с for {} могла занять P навсегда: другие горутины на этом P голодают, а сборка мусора не может дождаться STW, потому что не все горутины доходят до safepoint.
  3. Асинхронное вытеснение (1.14+). Поток-монитор sysmon замечает, что G крутится дольше ~10 мс, и посылает потоку сигнал SIGURG; обработчик безопасно снимает горутину и возвращает P в пул.
До Go 1.14 Go 1.14+
Модель кооперативная + асинхронное вытеснение
Точка снятия только safepoint (вызовы) в любой точке по сигналу
Порог ~10 мс на G
for {} вешал P/GC вытесняется штатно

⚠️ Частая ошибка: считать, что вытеснение стало абсолютным. Сигнал может прийти в любой момент, но на unsafe-point'ах (короткие участки без метаданных для сканирования стека, внутренности рантайма) снятие откладывается; для прикладного кода планировщик можно считать вытесняющим.

49

Что происходит с P и M, когда горутина уходит в блокирующий системный вызов?

Короткий ответ: При блокирующем syscall M блокируется вместе со своей G, но P от него отцепляется (hand-off) и достаётся другому — свободному или заново созданному — M, чтобы остальные горутины этого P продолжали работать. Сетевой I/O — отдельная история: он идёт через netpoller и не блокирует M вовсе.

Подробно:

  1. Hand-off P. Перед syscall рантайм помечает P как «в syscall». Если вызов затягивается, sysmon отцепляет P и отдаёт другому M; горутины из локальной очереди не простаивают.
  2. Возврат из syscall. Когда M выходит из вызова, он пытается снова захватить P (свой или любой свободный). Не удалось — G кладётся в глобальную очередь, а M паркуется в пул.
  3. Netpoller. Сетевые операции (сокеты) регистрируются в epoll (Linux) / kqueue (BSD/macOS). Горутина паркуется, M освобождается сразу; когда fd готов, netpoller возвращает G в очередь готовых. Один M обслуживает тысячи соединений.
  Блокирующий syscall (файл/диск):        Сетевой I/O:
  ┌─────┐ syscall ┌─────┐                 ┌─────┐   ┌──────────┐
  │  G  │────────►│  M  │(блок)           │  G  │──►│ netpoller│ epoll
  └─────┘         └─────┘                 └─────┘   │(kqueue)  │
     P ─hand-off─► M2 (работает дальше)      M свободен └────┬─────┘
                                              fd ready ──────┘► G в runq

⚠️ Частая ошибка: думать, что тысяча блокирующих файловых операций поднимет ровно GOMAXPROCS потоков. Каждый блокирующий syscall держит свой M, так что потоков ОС может стать заметно больше числа P.

50

Как работает сборщик мусора в Go — что такое tricolor mark-and-sweep?

Короткий ответ: GC в Go — конкурентный, non-moving mark-and-sweep с трёхцветной абстракцией. Объекты делятся на белые (кандидаты в мусор), серые (достижимы, но их ссылки ещё не обошли) и чёрные (обойдены полностью). Метка идёт параллельно с работой приложения; корректность при этом держит write barrier, а полностью останавливать мир (STW) приходится лишь на две короткие фазы.

Подробно:

  1. Три множества. Старт: всё белое. Корни (стеки, глобалы) красятся серым. Из серого множества берём объект, красим его ссылки серым, а сам объект — чёрным. Повторяем, пока серых не останется.
  2. Инвариант. Всё, что осталось белым, недостижимо → выметается (sweep). Sweep ленивый: память возвращается по мере аллокаций.
  3. Write barrier. Пока мутатор бегает параллельно, он может записать ссылку на белый объект в чёрный. Гибридный write barrier (с Go 1.8) перекрашивает такой объект, чтобы живой объект не смели по ошибке.
  4. STW. Только на границах: включение барьера (mark start) и завершение метки (mark termination). Обе паузы — суб-миллисекундные.
  ● white   — кандидат в мусор (пока не достигнут)
  ◐ grey    — достигнут, ссылки ещё не обойдены
  ◉ black   — достигнут, ссылки обойдены

  roots ──► ◉ ──► ◐ ──► ●        волна метки движется
            (обойден) (в работе) (ждёт)   слева направо

⚠️ Частая ошибка: называть Go-шный GC «stop-the-world» или generational. Он конкурентный (мир стоит лишь на суб-мс границах) и не поколенческий/не перемещающий — объекты остаются на месте, поэтому указатели в C-коде через cgo стабильны.

51

Что настраивают GOGC и GOMEMLIMIT и какой trade-off за этим стоит?

Короткий ответ: GOGC задаёт, насколько куча может вырасти между сборками: при дефолте GOGC=100 цикл GC запускается, когда живая куча удвоилась. GOMEMLIMIT (Go 1.19+) — мягкий потолок общего объёма памяти рантайма, который включает GC чаще по мере приближения к лимиту и спасает от OOM в контейнерах.

Подробно:

  1. GOGC — про частоту. GOGC=100 → следующая сборка при +100% к живой куче. Ниже значение = меньше пиковая память, но GC ходит чаще и суммарно съедает больше CPU; выше = реже GC, но жирнее RSS. GOGC=off выключает GC совсем.
  2. GOMEMLIMIT — про потолок. Мягкий лимит на суммарную память (куча + стек + метаданные рантайма). По мере приближения рантайм ужимает целевой размер кучи и чаще собирает, лишь бы не вылезти за лимит.
  3. Как комбинировать. Типичный прод-рецепт: оставить GOGC=100 для нормального режима и задать GOMEMLIMIT чуть ниже лимита пода — тогда под давлением GC станет агрессивнее вместо OOM-килла.
Параметр Что задаёт Ниже/жёстче → Риск перегиба
GOGC=100 рост кучи между GC меньше RAM, больше CPU GC-thrashing
GOMEMLIMIT мягкий потолок памяти защита от OOM death spiral: GC жрёт CPU у самого лимита

⚠️ Частая ошибка: ставить GOMEMLIMIT ровно на хард-лимит контейнера и полностью выключать GOGC. У самого потолка рантайм срывается в «GC death spiral» — сборка почти не освобождает память и съедает CPU. Держите зазор.

52

Что такое escape analysis и как узнать, ушла ли переменная в кучу?

Короткий ответ: Escape analysis — это анализ компилятора, который решает, где разместить переменную: на стеке (дёшево, освобождается автоматически при выходе из функции) или в куче (нагрузка на GC). Если компилятор не может доказать, что переменная не переживёт кадр функции, она «убегает» (escapes) в кучу. Проверяют флагом go build -gcflags='-m'.

Подробно:

Типичные причины escape:

  1. Возврат указателя наружу — вернули &local, значит объект должен пережить функцию.
  2. Захват замыканием — переменную использует горутина/замыкание, живущее дольше кадра.
  3. Упаковка в интерфейс — присваивание конкретного значения в interface{} часто вынуждает аллокацию (например, аргументы fmt.Println).
  4. Неизвестный размер на этапе компиляции — слайс/массив с размером, известным лишь в рантайме.
func newUser() *User {
    u := User{Name: "Go"} // -m: moved to heap: u
    return &u             // указатель убегает наружу
}
// $ go build -gcflags='-m' ./...
// ./main.go:2:2: moved to heap: u

⚠️ Частая ошибка: думать, что & (взятие адреса) само по себе означает кучу. Локальный указатель, не покидающий функцию, спокойно живёт на стеке — решает именно escape analysis, а не наличие &.

53

Как растёт стек горутины и что будет при превышении лимита?

Короткий ответ: Горутина стартует с крохотного стека ~2 КБ. Когда его не хватает, рантайм выделяет стек вдвое больше, целиком копирует туда данные и правит указатели (contiguous stacks). Есть жёсткий потолок ~1 ГБ на 64-битных платформах; при его превышении — fatal error: stack overflow, который recover не перехватывает.

Подробно:

  1. Дешёвый старт. ~2 КБ на горутину — поэтому их можно держать сотнями тысяч, в отличие от потоков ОС с мегабайтными стеками.
  2. Рост копированием. На входе в функцию пролог проверяет, влезет ли кадр. Не влезает → morestack: аллоцируется новый, больший стек, старый копируется, указатели внутри стека переписываются. Стек и растёт, и (при сборке) может ужиматься.
  3. Жёсткий лимит. maxstacksize — ~1 ГБ на 64-битных (около 250 МБ на 32-битных). Упёрлись — рантайм валит процесс.
  4. Не паника, а фатал. stack overflow — это fatal error рантайма, а не panic; defer/recover его не ловят, программа падает целиком.
 [2KB] ──переполнен──► выделить [4KB] ──► copy old→new ──► fix pointers
   ▲                                                          │
   └──────────── contiguous stack, растёт ×2 ─────────────────┘

 глубокая/бесконечная рекурсия ──► ~1GB cap ──► fatal: stack overflow

⚠️ Частая ошибка: рассчитывать «поймать» бесконечную рекурсию через recover. Переполнение стека — фатальная ошибка рантайма, а не паника; её recover не спасает.

54

В каком порядке выполняются несколько defer и когда вычисляются их аргументы?

Короткий ответ: Отложенные вызовы выполняются в порядке LIFO — последний объявленный defer срабатывает первым, при выходе из функции. А вот аргументы defer вычисляются сразу, в момент выполнения оператора defer, а не в момент фактического вызова.

Подробно:

  1. Стек, а не очередь — каждый defer кладётся на стек функции; при возврате они снимаются сверху вниз (LIFO). Это удобно для парных операций: Lock/Unlock, Open/Close — освобождаешь ресурсы в обратном порядке захвата.
  2. Аргументы фиксируются сразу — выражения-аргументы вычисляются в точке defer, а тело вызова откладывается. Поэтому defer fmt.Println(i) захватывает текущее i, а не финальное.
  3. Замыкание откладывает чтениеdefer func(){ use(x) }() читает x в момент вызова, а не объявления. Нюанс: с Go 1.22 переменная цикла своя на каждой итерации, поэтому и замыкание в цикле напечатает 2 1 0.
func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Println(i) // аргумент i вычислен сейчас
    }
}
// Вывод: 2 1 0  (LIFO + i зафиксирован на каждой итерации)

⚠️ Частая ошибка: отвечать 3 3 3, будто defer читает общий i при выходе из функции. Аргумент — снимок в момент объявления defer; к тому же с Go 1.22 у каждой итерации своя переменная i.

55

Может ли defer изменить возвращаемое значение функции?

Короткий ответ: Да, но только при именованных возвращаемых значениях. return x сначала присваивает значение именованной переменной, затем выполняются defer'ы, и лишь потом функция реально отдаёт результат — поэтому defer успевает его переприсвоить.

Подробно:

  1. Механика returnreturn не атомарен: он (1) записывает значения в named-переменные результата, (2) запускает отложенные функции, (3) отдаёт итог. Отложенная функция видит и меняет эти переменные.
  2. Только именованные — при анонимном результате (func() error) менять нечего: defer работает с копией и на возврат не влияет.
  3. Прикладной паттерн — превратить панику в error на границе API: recover() внутри defer + присваивание named return.
func parse(data []byte) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("parse panic: %v", r)
        }
    }()
    doRiskyParse(data) // паникует внутри — наружу уйдёт err
    return nil
}

⚠️ Частая ошибка: ждать того же трюка от func() error. Без имени у результата defer меняет локальную копию, а вызывающий получает исходное значение из return.

56

Где именно должен быть вызван recover(), чтобы он перехватил панику?

Короткий ответ: recover() работает только при непосредственном вызове внутри отложенной функции той же горутины, где произошла паника. Вызванный вне defer или из функции, которую отложенная функция сама вызывает, он возвращает nil и ничего не перехватывает.

Подробно:

  1. Только напрямую в defer — паника ловится, если recover() вызван прямо в теле функции, которую defer поставил на выполнение. Не в функции, которую эта отложенная функция вызвала.
  2. Только своя горутина — паника в другой горутине твоим recover не перехватывается; каждая горутина ловит панику сама, иначе падает весь процесс.
  3. Вне паники — nil — если паники нет, recover() просто вернёт nil, так что проверка if r := recover(); r != nil безопасна.
func catch() { _ = recover() }

defer func() { _ = recover() }() // работает
defer catch()                    // тоже работает: catch — сама отложенная функция
defer func() { catch() }()       // НЕ работает: recover на кадр глубже, вернёт nil

⚠️ Частая ошибка: вызвать хелпер с recover() изнутри deferred-замыкания — defer func(){ catch() }(). recover оказывается не в самой отложенной функции, а на кадр глубже и вернёт nil. А вот defer catch() работает: тут catch и есть отложенная функция.

57

В чём разница между errors.Is и errors.As?

Короткий ответ: errors.Is идёт по цепочке Unwrap и сравнивает ошибку с конкретным значением-сентинелом (например, sql.ErrNoRows). errors.As ищет в цепочке ошибку нужного типа и извлекает её в переданный target, чтобы достучаться до полей.

Подробно:

  1. errors.Is(err, target) — отвечает на вопрос «эта ошибка (или любая в её цепочке) равна вот этому значению?». Для проверки на известные сентинелы.
  2. errors.As(err, &target) — отвечает «есть ли в цепочке ошибка типа T?»; если да — присваивает её в target, и ты читаешь поля (.Code, .Field). target — обязательно указатель.
  3. Обе разворачивают цепочку %w, так что оборачивание контекстом не ломает проверки.
errors.Is errors.As
Ищет конкретное значение конкретный тип
Аргумент сентинел указатель на переменную типа
Даёт доступ к полям нет да
Типичный кейс Is(err, io.EOF) As(err, &pathErr)
if errors.Is(err, sql.ErrNoRows) { /* нет строки */ }

var perr *os.PathError
if errors.As(err, &perr) { log.Print(perr.Path) }

⚠️ Частая ошибка: сравнивать ошибки через == или err.Error() == "...". После оборачивания %w прямое сравнение сломается — нужен errors.Is/As.

58

Как обернуть ошибку, добавив контекст, но сохранив цепочку для errors.Is/As?

Короткий ответ: Через fmt.Errorf с глаголом %w: fmt.Errorf("read config: %w", err). Именно %w вшивает исходную ошибку в цепочку Unwrap, так что errors.Is/errors.As продолжают её видеть. Глаголы %v и %s вставляют только текст и обрывают цепочку.

Подробно:

  1. %w сохраняет цепочку — оборачивает ошибку, оставляя доступ к оригиналу через Unwrap. Добавляй короткий контекст: что делал код, а не пересказ ошибки.
  2. %v/%s рвут цепочку — годятся только когда исходную ошибку намеренно скрываешь от вызывающего (граница абстракции).
  3. Контекст по слоям — на каждом уровне добавляй свой префикс; получается читаемый след open user file: read config: permission denied.
if err != nil {
    return fmt.Errorf("read config %q: %w", path, err)
}
// errors.Is(err, os.ErrPermission) по-прежнему работает

⚠️ Частая ошибка: обрабатывать ошибку дважды — и залогировать, и вернуть её наверх. Каждый слой либо оборачивает и возвращает, либо (на самом верху) логирует — не одновременно, иначе логи задваиваются.

59

Когда функция должна вернуть error, а когда — паниковать?

Короткий ответ: Ожидаемые, восстановимые сбои — всегда error как обычное возвращаемое значение: файл не найден, сеть отвалилась, ввод невалиден. panic — для программистских ошибок и нарушенных инвариантов, из которых нет осмысленного продолжения: выход за границы, разыменование nil, «невозможное» состояние.

Подробно:

  1. error — штатный путь — всё, что вызывающий может предвидеть и обработать, возвращается явным error. Это часть контракта функции.
  2. panic — баг, не сценарий — сигнализирует, что программа в неконсистентном состоянии и продолжать нельзя. Рантайм паникует на записи в nil map, делении на ноль, индексе за границей.
  3. recover — только на границе — например, чтобы HTTP-сервер не падал целиком из-за паники в одном хендлере.
Вернуть error panic
Причина ожидаемый сбой баг / нарушенный инвариант
Примеры нет файла, таймаут, плохой ввод index out of range, nil deref
Кто виноват внешний мир программист
Ожидание вызывающий обработает продолжать нельзя

⚠️ Частая ошибка: использовать panic как control flow для «не найдено» или валидации ввода. Это код-смелл, который интервьюер специально провоцирует: такие случаи — обычный error.

60

Sentinel-ошибки против кастомных типов ошибок — что когда выбрать?

Короткий ответ: Sentinel (var ErrNotFound = errors.New(...)) — это простой опознаваемый сигнал без дополнительных данных, проверяется через errors.Is. Кастомный тип с полями нужен, когда вызывающему важны детали сбоя (код, имя поля, статус) — тогда errors.As извлекает ошибку и даёт доступ к её полям.

Подробно:

  1. Sentinel — флаг — одно значение-константа на весь пакет. Дёшево, читаемо, но несёт только сам факт: «не найдено», «нет прав». Проверка — errors.Is.
  2. Кастомный тип — структура с данными — когда нужно передать контекст: ValidationError{Field, Rule}, APIError{Code}. Проверка и извлечение — errors.As.
  3. Оба варианта — часть API — и то и другое становится публичным контрактом: вызывающие проверяют их по имени.
Sentinel Кастомный тип
Несёт данные нет да (поля)
Проверка errors.Is errors.As
Стоимость минимальна больше кода
Когда простой сигнал нужны детали
var ErrNotFound = errors.New("not found")

type ValidationError struct{ Field, Rule string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Rule }

⚠️ Частая ошибка: забывать, что sentinel — часть публичного API. Пользователи пишут errors.Is(err, pkg.ErrNotFound), поэтому переименование или удаление ломает их код так же, как смена сигнатуры функции.

61

Реализуйте worker pool с graceful shutdown через context. Что проверяет интервьюер?

Короткий ответ: N воркеров читают из общего канала jobs через range; продюсер закрывает jobs, а results закрывает отдельная горутина после wg.Wait(). Внутри воркера — select с ctx.Done(), чтобы бросить работу по отмене. Интервьюер смотрит, кто закрывает каналы и не текут ли горутины.

Подробно:

  1. Кто закрывает jobs — только продюсер (отправитель). Закрыть канал со стороны воркера-потребителя — паника на send/двойном close.
  2. Кто закрывает results — отдельная горутина: go func(){ wg.Wait(); close(results) }(). Иначе не понять, когда все воркеры отработали.
  3. Отмена — в каждом воркере select между jobs и ctx.Done(); по отмене воркер выходит, wg.Done() в defer.
  4. Утечки — без ctx зависший продюсер или полный results навсегда заблокирует воркеров.
func pool(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case job, ok := <-jobs:
                    if !ok {
                        return // канал закрыт продюсером
                    }
                    select {
                    case results <- process(job):
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }()
    }
    go func() { wg.Wait(); close(results) }()
    return results
}

⚠️ Частая ошибка: закрыть results внутри воркера или до wg.Wait() — «send on closed channel». Закрывает всегда одна горутина и только после того, как все отправители завершились.

62

Как слить несколько каналов в один (fan-in) и не словить панику при закрытии?

Короткий ответ: На каждый входной канал — своя горутина, которая пересылает значения в общий out; WaitGroup считает эти горутины, а close(out) вызывается ровно один раз в отдельной горутине после wg.Wait(). Так выходной канал закрывается, только когда иссякли все входы.

Подробно:

  1. Горутина на входfor v := range in { out <- v }, затем wg.Done().
  2. WaitGroupAdd(len(cs)) до старта горутин, чтобы Wait() не проскочил раньше времени.
  3. Единственный closego func(){ wg.Wait(); close(out) }(). Закрывает только тот, кто владеет out.
  4. Отмена (опц.) — для раннего выхода добавь select с done в отправке в out, иначе горутины повиснут на send.
func merge[T any](cs ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup
    wg.Add(len(cs))
    for _, c := range cs {
        go func(c <-chan T) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

⚠️ Частая ошибка: закрыть out из каждой входной горутины или раньше, чем иссякли все входы, — получите панику «close of closed channel» или «send on closed channel». Закрытие — строго один раз, после Wait().

63

Как ограничить параллелизм — например, максимум 10 одновременных запросов к внешнему API?

Короткий ответ: Буферизированный канал-семафор ёмкостью 10: перед запросом пишем токен (acquire), в defer читаем обратно (release). Когда буфер полон, отправка блокируется и новые горутины не стартуют, пока не освободится слот — это даёт естественный backpressure.

Подробно:

  1. Семафорsem := make(chan struct{}, 10); acquire = sem <- struct{}{}, release = <-sem.
  2. release в defer — обязательно: паника или ранний return внутри воркера «съест» слот, и пул медленно застынет.
  3. Готовые решенияerrgroup.Group.SetLimit(10) или golang.org/x/sync/semaphore для взвешенных лимитов.
  4. Не «горутина на запрос» — интервьюер хочет ограниченную одновременность и backpressure, а не 10000 горутин, кладущих чужой API.
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, url := range urls {
    sem <- struct{}{} // acquire: ждём свободный слот до запуска горутины
    wg.Add(1)
    go func(url string) {
        defer wg.Done()
        defer func() { <-sem }() // release
        fetch(url)
    }(url)
}
wg.Wait()

⚠️ Частая ошибка: release без defer. Если fetch паникует или делает ранний return, токен не вернётся — свободных слотов станет меньше, и со временем пул встанет колом.

64

Как построить pipeline из нескольких стадий на каналах и корректно его останавливать?

Короткий ответ: Каждая стадия — функция вида func(ctx, in <-chan T) <-chan U, которая заводит свою горутину, пишет в собственный out и делает defer close(out). Отмену прокидываем через ctx во все send/receive (select с ctx.Done()), иначе стадии зависнут, если потребитель ушёл раньше.

Подробно:

  1. Стадия владеет своим выходом — создаёт out, пишет в него и закрывает; следующий этап только читает.
  2. Закрытие вниз по потоку — когда вход иссяк (range завершился), стадия закрывает свой out, и close каскадом идёт дальше.
  3. Отмена вверх по потоку — на каждом send делаем select { case out <- v:; case <-ctx.Done(): return }, чтобы ранний выход потребителя разблокировал верхние стадии.
  4. Источник — паттерн из Go blog «Pipelines and cancellation».
generate ──► square ──► sum
  nums        out       result
   │           │          │
   └──── ctx.Done() отменяет все стадии ────┘

⚠️ Частая ошибка: писать в out без select с ctx.Done(). Если потребитель перестал читать, верхние стадии навсегда блокируются на send — классическая утечка горутин в pipeline.

65

Как реализовать rate limiter в Go и чем он отличается от семафора?

Короткий ответ: Семафор ограничивает одновременность (сколько операций идёт прямо сейчас), а rate limiter — частоту во времени (сколько операций в секунду). Для равномерного темпа берут time.Ticker, для всплесков — token bucket из golang.org/x/time/rate.

Подробно:

  1. Семафор ≠ rate limiter — «10 одновременных запросов» и «10 запросов в секунду» — это разные ограничения.
  2. Tickert := time.NewTicker(time.Second/10); перед каждым запросом <-t.C. Ровно 10 rps, без burst.
  3. Token bucketrate.NewLimiter(10, 20): 10 токенов/сек, burst до 20; limiter.Wait(ctx) блокирует до токена. Стандарт для внешних API.
  4. BackpressureWait ждёт токена, Allow сразу говорит «нет» — выбираешь между ожиданием и отбросом (429).
Критерий Семафор Rate limiter
Ограничивает одновременность частоту во времени
Единица N параллельно N в секунду
Инструмент chan struct{} time.Ticker / x/time/rate
Burst нет да (token bucket)

⚠️ Частая ошибка: наивный time.Sleep между запросами вместо лимитера. Он не даёт burst, накапливает дрейф и ломается при параллельных воркерах — каждый спит сам по себе.

66

Как устроен LRU-кэш и как сделать его потокобезопасным?

Короткий ответ: map[key]*list.Element поверх двусвязного списка (container/list): map даёт доступ за O(1), список хранит порядок использования. На Get двигаем элемент в голову, на Put при переполнении вытесняем хвост. Потокобезопасность — sync.Mutex вокруг обеих структур.

Подробно:

  1. Две структурыmap[K]*list.Element для поиска за O(1) и *list.List (двусвязный) для порядка «свежести».
  2. Get — нашли в map → MoveToFront(el) → вернули значение.
  3. Put — ключ есть → обновить и MoveToFront; нет → PushFront, а при Len() > capacity удалить Back() и его ключ из map.
  4. Потокобезопасность — один Lock() на всю операцию (map и список меняются вместе); при высоком contention — шардирование по хешу ключа.
map[key]                двусвязный список (MRU ⇄ LRU)
┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
│ "a" ─┼──►│  a   │⇄  │  c   │⇄  │  b   │
│ "c" ─┼──►└──────┘   └──────┘   └──────┘
│ "b" ─┼──►  head=MRU            tail=вытесняем
└──────┘

⚠️ Частая ошибка: RWMutex с RLock на Get. Get двигает элемент в списке — это запись, а не чтение; под RLock две горутины повредят список. Get требует полного Lock.

67

Как сделать таймаут операции через select и в чём подвох time.After в цикле?

Короткий ответ: select между рабочим каналом и time.After(d): что сработает первым, то и выиграет. Подвох в горячем цикле: раньше таймер от time.After не освобождался до срабатывания, и на каждой итерации копился новый — утечка. С Go 1.23 такие таймеры собирает GC, но на старых версиях берут time.NewTimer + Stop.

Подробно:

  1. Базовый таймаутselect { case v := <-ch: ...; case <-time.After(d): ... }; для отмены по цепочке вызовов лучше context.WithTimeout.
  2. Подвох в цикле — до Go 1.23 time.After в for создавал таймер, живший все d, даже если ветка ch срабатывала мгновенно: тысячи итераций → тысячи живых таймеров.
  3. Go 1.23 — таймеры и тикеры собираются GC, даже если Stop не вызван; каналы таймеров стали небуферизованными. time.After в цикле больше не течёт.
  4. Старые версии — свой time.NewTimer(d) и Stop() сразу, как только пришло значение из ch.
// До Go 1.23 течёт: новый таймер живёт ~секунду каждую итерацию
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Second):
        return
    }
}

// Безопасно везде: свой таймер, Stop сразу после ch
for {
    t := time.NewTimer(time.Second)
    select {
    case v := <-ch:
        t.Stop() // освобождаем таймер немедленно
        handle(v)
    case <-t.C:
        return
    }
}

⚠️ Частая ошибка: заявить «time.After всегда течёт». На Go 1.23+ это уже не так — уточни версию рантайма. Но привычка к NewTimer + Stop безопаснее и на старых версиях.

68

Какие баги чаще всего прячут в задачах «найди ошибку в конкурентном коде»?

Короткий ответ: Закладки одни и те же: гонка на общей переменной без mutex/atomic, захват переменной цикла (до Go 1.22), wg.Add() внутри горутины, range по незакрытому каналу с deadlock и горутина без recover, роняющая весь процесс. Держи этот список в голове и сканируй код по нему.

Подробно:

  • Гонка данныхcounter++ из нескольких горутин без sync.Mutex/atomic. go test -race ловит мгновенно.
  • Переменная цикла (до Go 1.22)go func(){ use(i) }() без параметра: все горутины видят финальное i. С Go 1.22 каждая итерация — свежая переменная.
  • wg.Add внутри горутиныWait() может проскочить до Add. Add(1) всегда до go.
  • Незакрытый каналfor v := range ch без close(ch) где-то → deadlock, «all goroutines are asleep».
  • Нет recover — паника в отдельной горутине не ловится вызывающим и валит весь процесс.
counter++                                   // 1) гонка: нужен mutex/atomic
for _, v := range xs { go func(){ use(v) }() } // 2) var цикла (до 1.22)
go func(){ wg.Add(1); defer wg.Done() }()   // 3) Add внутри горутины
for v := range ch { _ = v }                 // 4) кто делает close(ch)?
go func(){ mightPanic() }()                 // 5) нет recover → упадёт процесс

⚠️ Частая ошибка: искать баг глазами вместо go test -race и go vet. Детектор гонок и vet находят половину закладок за секунды — упомяни их первым делом.

69

Что такое табличные тесты и почему это идиоматичный стиль в Go?

Короткий ответ: Табличный тест — это слайс структур-кейсов (вход + ожидаемый результат), по которому мы бежим циклом и запускаем каждый кейс через t.Run(name, ...). Так получаются именованные сабтесты, изоляция падений и нулевое дублирование. Это стиль самой стандартной библиотеки, поэтому его ждут на собеседовании.

Подробно:

  1. Слайс кейсов — каждый кейс это структура с именем, входом и ожиданием; добавить новый сценарий — это одна строка, а не новая функция.
  2. t.Run(tc.name, ...) — оборачивает кейс в сабтест: падение одного не останавливает остальные, а имя попадает в вывод (TestParse/empty_input), так что сразу видно, что сломалось.
  3. Один код проверки — логика ассерта написана один раз, кейсы только меняют данные.
  4. Бонус: t.Parallel() внутри сабтеста гонит независимые кейсы параллельно.
func TestAbs(t *testing.T) {
    tests := []struct {
        name string
        in   int
        want int
    }{
        {"positive", 3, 3},
        {"negative", -3, 3},
        {"zero", 0, 0},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            if got := Abs(tc.in); got != tc.want {
                t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
            }
        })
    }
}

⚠️ Частая ошибка: захват переменной цикла в параллельном сабтесте. До Go 1.22 нужен был tc := tc перед t.Run; с Go 1.22 переменная цикла своя на каждой итерации, и костыль больше не нужен.

70

Как профилировать Go-сервис через pprof и какие типы профилей бывают?

Короткий ответ: Для живого сервиса добавляют import _ "net/http/pprof" — он вешает эндпоинты на /debug/pprof/, и снимок берут через go tool pprof http://host/debug/pprof/heap. Для офлайн-анализа пишут профиль в файл через runtime/pprof. Ключевые типы: CPU, heap, goroutine, block, mutex.

Подробно:

  1. Сборnet/http/pprof для боевого сервиса (эндпоинты на debug-порту, не публичном!) либо runtime/pprof / флаги go test -cpuprofile для локального прогона.
  2. Анализgo tool pprof в интерактиве: top, list Func, web (граф) и флейм-граф в браузере через -http=:8080.
  3. Выбор профиля под симптом — senior-маркер в том, чтобы знать, какой профиль снимать под какую проблему, а не снимать CPU на всё подряд.
Профиль Что ищем
cpu где горит процессор, горячие функции
heap аллокации и потребление памяти
goroutine утечки горутин (растущее число)
block ожидание на каналах/мьютексах
mutex contention на блокировках

Профили block и mutex по умолчанию выключены — их включают через runtime.SetBlockProfileRate и runtime.SetMutexProfileFraction.

⚠️ Частая ошибка: оставить net/http/pprof на публичном порту. Импорт регистрирует хендлеры на DefaultServeMux, и /debug/pprof/ утекает наружу — держите его на отдельном внутреннем listener'е.

71

Что ловит go vet такого, что пропускает компилятор?

Короткий ответ: go vet ловит подозрительные, но синтаксически корректные конструкции, которые компилятор пропускает: несоответствие аргументов формату printf, копирование мьютексов, битые struct-теги, недостижимый код. Компилятор проверяет, что программа собирается; vet проверяет, что она, скорее всего, делает то, что вы имели в виду.

Подробно:

  1. printf-форматыfmt.Printf("%d", "str") компилируется, но vet укажет: Printf format %d has arg of wrong type string.
  2. copylocks — присваивание или передача по значению структуры с sync.Mutex копирует мьютекс вместе с его внутренним состоянием и ломает синхронизацию.
  3. struct-теги — опечатка в json:"..." (кривые кавычки, пробелы) молча ломает маршалинг — vet её видит.
  4. unreachable / lostcancel — недостижимый код, потерянный cancel от context.WithCancel.
Чек Пример проблемы
printf Printf("%d", s) — s это string
copylocks передача sync.Mutex по значению
structtag опечатка в json: теге
lostcancel не вызвали cancel()

В CI обычно гоняют не голый vet, а golangci-lint — агрегатор, который поверх vet подключает staticcheck, errcheck и десятки других линтеров.

⚠️ Частая ошибка: думать, что go test не запускает vet. На самом деле go test по умолчанию прогоняет подмножество проверок vet перед тестами — часть предупреждений вы уже видите там.

72

Зачем нужны go.mod и go.sum и как работает Minimal Version Selection?

Короткий ответ: go.mod объявляет путь модуля и требуемые версии зависимостей, go.sum фиксирует криптографические хэши скачанных модулей для верификации целостности. Minimal Version Selection (MVS) выбирает для каждой зависимости минимальную версию, которая удовлетворяет все require в графе, — а не самую свежую. Это даёт воспроизводимую сборку без отдельного lock-файла.

Подробно:

  1. go.modmodule, версия go, блок require с прямыми и косвенными зависимостями и их версиями.
  2. go.sum — хэши по каждому модулю и его go.mod; при сборке go сверяет скачанное с ними и с checksum-базой (sum.golang.org).
  3. MVS — берёт максимум из минимально требуемых версий по всему графу. Если A требует v1.2.0, а B — v1.3.0, выберется v1.3.0; релиз v1.9.0 в апстриме сборку не сдвинет, пока никто её явно не потребует.
твой модуль
 ├─ require A v1.2.0 ─► требует lib v1.4.0
 └─ require B v1.5.0 ─► требует lib v1.3.0
         MVS выбирает lib = max(1.4.0, 1.3.0) = v1.4.0

⚠️ Частая ошибка: называть go.sum lock-файлом. Он не «пинит» версии — версии решает go.mod + MVS; go.sum лишь хранит хэши для проверки, что скачали именно то, что ожидали, и в него могут попадать хэши даже не выбранных версий.

73

Как работают дженерики в Go и что означает constraint comparable?

Короткий ответ: С Go 1.18 функции и типы могут иметь параметры типа в квадратных скобках, ограниченные constraint'ом. Constraint — это интерфейс, задающий набор допустимых типов и операций над ними. comparable — встроенный constraint, разрешающий операции == и != (например, чтобы использовать тип как ключ мапы или искать через Contains).

Подробно:

  1. Параметры типаfunc F[T Constraint](x T); компилятор подставляет конкретный тип на месте вызова, без приведений в рантайме.
  2. Constraint как интерфейс — обычный (метод-сет) или type-set: [T int | float64] разрешает только эти типы и их операторы.
  3. comparable — покрывает типы, поддерживающие ==/!=; нужен там, где значение сравнивают или кладут в мапу/множество.
  4. Против any — дженерик даёт типобезопасность на компиляции без type assertion'ов и боксинга в interface{}.
func Index[T comparable](s []T, target T) int {
    for i, v := range s {
        if v == target { // == разрешён благодаря comparable
            return i
        }
    }
    return -1
}

⚠️ Частая ошибка: тащить дженерики туда, где хватает обычного интерфейса. Если методы у типов уже общие — интерфейс идиоматичнее; дженерики нужны, когда важен сам конкретный тип (элементы слайса, ключи мапы, арифметика).

74

Как мокают зависимости в Go-тестах, если нет monkey-patching?

Короткий ответ: Зависимость выражают маленьким интерфейсом, объявленным на стороне потребителя, и внедряют реализацию через конструктор (DI). В тесте подставляют свою фейковую реализацию этого интерфейса — руками или сгенерированную mockgen. Никакого monkey-patching не нужно.

Подробно:

  1. Узкий интерфейс у потребителя — код зависит не от конкретного *sql.DB, а от интерфейса с одним-двумя методами, которые ему реально нужны.
  2. Внедрение через конструкторNewService(store Store); в проде передаём боевую реализацию, в тесте — фейк.
  3. Фейк или mock — простой стаб пишут руками; для интерфейсов с проверкой вызовов мок генерируют через mockgen (gomock, теперь живёт в go.uber.org/mock) или берут testify/mock.
type Store interface {
    Get(id string) (User, error)
}

type fakeStore struct{ u User }
func (f fakeStore) Get(string) (User, error) { return f.u, nil }

func TestService(t *testing.T) {
    svc := NewService(fakeStore{u: User{Name: "Ann"}})
    // ... проверяем поведение svc на фейке
}

⚠️ Частая ошибка: мокать чужой огромный интерфейс с двадцатью методами вместо своего узкого. Идиома «accept interfaces, return structs»: интерфейс объявляет тот, кто его потребляет, ровно под свои нужды, — тогда и фейк тривиален.

75

Как написать бенчмарк в Go и что показывает флаг -benchmem?

Короткий ответ: Бенчмарк — это функция func BenchmarkX(b *testing.B) в _test.go, где измеряемый код гоняют в цикле нужное число итераций. Запуск: go test -bench=. -benchmem. Флаг -benchmem добавляет к времени метрики памяти: B/op (байт на операцию) и allocs/op (число аллокаций на операцию).

Подробно:

  1. Классический циклfor i := 0; i < b.N; i++; фреймворк сам подбирает b.N, чтобы набрать статистику.
  2. b.Loop() (Go 1.24)for b.Loop() заменяет ручной b.N, сам исключает setup из замера и не даёт компилятору выкинуть тело цикла.
  3. Чтение выводаns/op это время, B/op и allocs/op — память; аллокации часто важнее наносекунд, потому что грузят GC.
var sink string
func BenchmarkJoin(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = strings.Join([]string{"a", "b", "c"}, ",")
    }
}
// BenchmarkJoin-8   35000000   34.2 ns/op   8 B/op   1 allocs/op

⚠️ Частая ошибка: компилятор видит, что результат не используется, и выкидывает тело цикла — бенчмарк показывает ~0 ns/op. При классическом b.N-цикле результат сохраняют в пакетную sink-переменную; b.Loop() (Go 1.24) решает это за вас.

76

Сага: оркестрация или хореография — когда что выбрать?

Короткий ответ: Оркестрация — явный координатор (state machine) ведёт сагу и вызывает шаги; хореография — сервисы реагируют на события друг друга без центра. Оба варианта дают только eventual consistency и требуют компенсирующих транзакций — выбор лишь о том, где жить сложности: в явном оркестраторе или в неявном потоке событий.

Подробно:

Оркестрация Хореография
Поток явная state machine в одном месте неявный: цепочка событий
Связанность сервисы знают оркестратора слабая: только события
Наблюдаемость статус саги в одном месте «кто виноват» — собирать по трейсам
Риски оркестратор — точка отказа и узел связности циклические подписки, каскады событий
Когда длинные саги, много шагов, нужен аудит 2–3 шага, независимые команды
  1. Оркестрация — легко ответить «в каком состоянии заказ №42», просто добавить таймауты и ретраи; цена — все бизнес-потоки стягиваются в один сервис, и его доступность становится критичной.
  2. Хореография — сервисы деплоятся независимо; цена — поток нигде не записан: новый шаг = «подписаться туда-то», и через год никто не знает всю цепочку.

⚠️ Частая ошибка: подавать хореографию как «более правильную из-за слабой связанности». Связность не исчезает — она переезжает из кода в неявный граф событий, который дороже отлаживать.

77

Спроектируйте компенсации для саги «создать заказ → списать оплату → зарезервировать товар», если резервирование падает.

Короткий ответ: Компенсирующие транзакции выполняются в обратном порядке: вернуть деньги → отменить заказ. Каждая компенсация обязана быть идемпотентной и ретраибельной; заказ до конца саги живёт в статусе PENDING (semantic lock) и не виден как подтверждённый; компенсация, которая сама не проходит, уходит в ретраи + алерт + очередь ручного разбора — молча бросить её нельзя.

Подробно:

create order (PENDING) ──► charge payment ──► reserve stock ✗ FAIL

  cancel order (PENDING→CANCELLED) ◄── refund payment ◄──┘
  1. Обратный порядок — компенсируем только выполненные шаги: сначала refund, затем cancel; само резервирование не удалось — компенсировать нечего.
  2. Semantic lock — пока сага не завершилась, заказ в PENDING: пользователь не видит его подтверждённым, «полусостояние» не утекает наружу.
  3. Идемпотентность компенсаций — refund с ключом идемпотентности (orderId): ретрай после таймаута платёжки не вернёт деньги дважды.
  4. Компенсация не проходит — платёжный шлюз лежит: ретраи с backoff, после N попыток — алерт и запись в очередь ручного разбора. Деньги клиента нельзя потерять в логах.

⚠️ Частая ошибка: считать компенсацию «откатом транзакции». Это обычная бизнес-операция: она может упасть, задублироваться и должна проектироваться так же тщательно, как прямой шаг.

78

Что такое transactional outbox и какую проблему он решает?

Короткий ответ: Проблему dual write: коммит в БД и publish в брокер — две разные системы, атомарности между ними нет. Упади сервис между ними — событие потеряно (или наоборот: событие улетело, а записи нет). Outbox: событие пишется в таблицу аутбокса в той же локальной транзакции, что и бизнес-данные, а отдельный relay публикует его в брокер.

Подробно:

┌──── одна локальная транзакция ────┐
│ INSERT INTO orders …              │
│ INSERT INTO outbox (event) …      │
└───────────────────────────────────┘
   outbox ──► relay (поллер или CDC/Debezium) ──► брокер
  1. Атомарность бесплатно — обе записи в одной БД: либо есть и заказ, и событие, либо ничего.
  2. Relay — поллер (SELECT … FOR UPDATE SKIP LOCKED) или CDC: Debezium читает WAL — события уезжают без нагрузки поллинга.
  3. Следствие: at-least-once — relay может упасть после publish, но до отметки «отправлено» → событие уедет дважды. Значит, на другой стороне обязан стоять идемпотентный консюмер с дедупликацией.

⚠️ Частая ошибка: «опубликуем до коммита — тогда точно не потеряем». Это лишь переворачивает сбой: коммит упал, а событие уже улетело — фантомное событие о заказе, которого не существует.

79

Как устроен идемпотентный консюмер (inbox-паттерн)?

Короткий ответ: Консюмер держит таблицу обработанных message id с UNIQUE-ограничением и вставляет id в одной локальной транзакции с бизнес-эффектом. Повторная доставка упирается в constraint — сообщение просто пропускается, эффект не задваивается. Два ключевых условия: id стабилен между ретраями, а дедупликация атомарна с эффектом.

Подробно:

BEGIN;
INSERT INTO processed_messages (message_id) VALUES ($1);
-- дубль → unique_violation → ROLLBACK, сообщение ack'аем
UPDATE accounts SET balance = balance - 100 WHERE id = $2;
COMMIT;
  1. Одна транзакция — если проверять «обработано?» отдельно от эффекта, между проверкой и записью влезает гонка двух консюмеров или ретраев.
  2. Стабильный id — его генерирует продюсер (id события из аутбокса), а не брокер при доставке: ретрай обязан прийти с тем же id.
  3. TTL/чистка — таблица растёт: чистим по времени, но окно хранения должно перекрывать максимальную задержку редоставки (ретраи, DLQ).
  4. Эффект вне БД — HTTP-вызов этим паттерном не защищается: нужен ключ идемпотентности уже на стороне вызываемого API.

⚠️ Частая ошибка: дедуплицировать в Redis или в памяти рядом с транзакцией в Postgres — «отметка» и эффект снова расходятся; это тот же dual write в профиль.

80

Двухфазный коммит: как работает и почему в микросервисах его избегают?

Короткий ответ: Координатор рассылает prepare (каждый участник голосует и держит локи), затем commit. Протокол блокирующий: упади координатор между фазами — участники зависают in-doubt с удержанными локами и не могут ни закоммитить, ни откатиться. Плюс латентность по самому медленному участнику и нетерпимость к партишенам — поэтому в микросервисах вместо 2PC живут сага и Outbox.

Подробно:

координатор:  prepare? ──► A: yes   B: yes
              💥 crash до рассылки commit
A, B:         in-doubt — локи держатся, строки заблокированы;
              сами решить не могут: вдруг координатор уже
              успел записать commit
  1. Фаза 1 (prepare) — участники пишут redo/undo, голосуют и держат локи до развязки.
  2. Фаза 2 (commit/abort) — единогласное yes → commit всем; любой no или таймаут → abort.
  3. Цена — синхронное ожидание всех участников: латентность = самый медленный; блокировка при отказе координатора; при сетевом партишене протокол просто стоит.
  4. Где встречается — XA-транзакции внутри одного JVM/СУБД-мира существуют, но между сервисами с разными БД и брокерами — почти никогда.

⚠️ Частая ошибка: предлагать 2PC как «честную» альтернативу саге. 2PC покупает атомарность ценой доступности и живучести — в распределённой системе эта цена обычно неприемлема.

81

Существует ли exactly-once доставка? Что на самом деле имеют в виду под exactly-once?

Короткий ответ: Exactly-once доставки не существует (задача двух генералов: подтверждение может потеряться, и отправитель обязан ретраить). Достижима exactly-once-семантика обработки: at-least-once доставка + идемпотентный консюмер с дедупликацией — эффект применяется ровно один раз, сколько бы раз ни пришло сообщение.

Подробно:

Уровень Гарантия Как
at-most-once нет дублей, возможна потеря fire-and-forget
at-least-once нет потерь, возможны дубли ack + ретраи
exactly-once processing эффект ровно один раз at-least-once + идемпотентность/дедупликация
  1. Почему доставка невозможна — между «обработал» и «ack дошёл» всегда есть окно сбоя; брокер обязан передоставить — иначе потеря.
  2. Kafka «exactly-once» — транзакции покрывают цикл read-process-write внутри Kafka: consume и produce атомарны, читатели с read_committed не видят абортов.
  3. Главная ловушка — транзакции Kafka не покрывают эффекты вне Kafka: запись в Postgres, HTTP-вызов, отправка письма. Для них нужна своя идемпотентность (inbox, ключи идемпотентности).

⚠️ Частая ошибка: «у нас Kafka с exactly-once, дубли невозможны». Как только консюмер пишет в свою БД или зовёт внешний API — гарантия закончилась на границе Kafka.

82

Распределённый лок на Redis через SET key value NX PX — что может пойти не так?

Короткий ответ: Три классики: TTL истёк, пока держатель ещё работает (GC-пауза, медленный I/O) — лок берёт второй, и держателей уже двое; release удаляет чужой лок; failover Redis теряет лок из-за асинхронной репликации. Поэтому value — уникальный токен держателя, release — только атомарный check-and-delete на Lua, а для строгой корректности одного Redis-лока мало.

Подробно:

t=0   A: SET lock tokenA NX PX 10000 → OK, работает
t=10  TTL истёк (A завис в GC / ждёт диск) — A не знает
t=11  B: SET lock tokenB NX → OK — держателей ДВОЕ
t=12  A очнулся и дописал в общий ресурс → конфликт
t=13  A: DEL lock → снёс уже ЧУЖОЙ лок B (DEL без проверки)
  1. Уникальный токен — value = случайный uuid держателя; release сравнивает и удаляет атомарно (Lua: if GET == token then DEL), иначе A сносит лок B.
  2. TTL — всегда компромисс — короткий: истекает под живым держателем; длинный: после падения держателя все ждут впустую.
  3. Failover — репликация асинхронна: мастер упал до реплицирования SET — новый мастер про лок не знает, его возьмут второй раз.

⚠️ Частая ошибка: «взять лок» и считать взаимное исключение гарантированным. Redis-лок с TTL — это lease: он может истечь под тобой, и без fencing token защищаемый ресурс этого даже не заметит.

83

За что Клеппман критиковал Redlock и что такое fencing token?

Короткий ответ: Безопасность Redlock опирается на тайминговые допущения — ограниченный дрейф часов и ограниченные паузы процессов, — которые реальные системы нарушают (stop-the-world GC, page fault, сетевые задержки). Зависший держатель с «истёкшим» локом всё равно пишет в ресурс. Лечит fencing token: монотонно растущий номер, который проверяет сам ресурс и отбрасывает записи устаревших держателей.

Подробно:

A берёт лок (token 33) ──► GC stop-the-world 15 c
        TTL истёк; B берёт лок (token 34) и пишет
A очнулся, «всё ещё держит лок», пишет
  без fencing:  запись A затирает запись B  💥
  с fencing:    хранилище видит 33 < 34 → reject A
  1. Суть критики — лок «по таймеру» без общего источника порядка не может быть безопасным: длительность паузы процесса и дрейф часов ничем не ограничены, а изнутри пауза невидима.
  2. Fencing token — выдаётся вместе с локом и строго растёт; проверять обязан ресурс: условный UPDATE … WHERE token >= $1 в БД, CAS в хранилище. Клиент проверить сам себя не может.
  3. Что выбрать — корректность критична: лок на консенсусе (ZooKeeper/etcd; zxid/revision — готовый fencing token) или fencing на самом хранилище. Лок «для эффективности» (не делать работу дважды, дубль не страшен) — одиночный Redis ок.

⚠️ Частая ошибка: тюнить TTL Redlock «с запасом» вместо fencing. Никакой TTL не спасает от паузы неизвестной длины — без проверки на стороне ресурса гарантии нет.

84

Advisory locks в Postgres: когда они лучше лока на Redis?

Короткий ответ: Когда все претенденты и так ходят в один Postgres. pg_advisory_xact_lock освобождается автоматически вместе с транзакцией (или сессией) — TTL-гонки не существует в принципе: упал процесс → закрылось соединение → лок снят. Идеально для cron-синглтона, гарда миграций, «одна обработка сущности за раз».

Подробно:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('billing-cron'));
-- критическая секция; лок снимется сам на COMMIT/ROLLBACK/разрыве
COMMIT;
Redis SET NX PX pg advisory lock
Освобождение TTL: гонка «истёк под живым» автоматически с транзакцией
Падение держателя ждать TTL мгновенно: соединение закрылось
Fencing нужен отдельно не нужен в пределах этой БД
Масштаб любые сервисы только клиенты этого Postgres
Цена отдельная инфраструктура держит соединение и транзакцию

Ограничения честно: не работает поверх шардов и нескольких БД; длинная критическая секция = длинная транзакция (мешает vacuum); session-level варианты несовместимы с pgbouncer в transaction mode.

⚠️ Частая ошибка: тащить Redis ради лока в систему, где уже есть один общий Postgres. Advisory lock даёт более сильную гарантию бесплатно — без TTL, без fencing, без новой инфраструктуры.

85

Линеаризуемость vs eventual consistency: как обеспечить read-your-writes поверх асинхронных реплик?

Короткий ответ: Линеаризуемость: каждая операция как будто атомарно происходит в некоторый момент между её началом и концом — все видят единую временную шкалу. Нужна для балансов, проверок уникальности, лидер-элекшена. Eventual: реплики сойдутся «когда-нибудь». Read-your-writes поверх реплик: читать данные автора с праймари (session stickiness) либо отслеживать LSN/логический таймстемп записи и ждать, пока реплика его догонит.

Подробно:

Потребность Модель Механика
баланс, проверка уникальности линеаризуемость чтение с лидера / кворумное чтение
лента, каталог, счётчики eventual любая реплика
«я сохранил — я вижу» read-your-writes праймари для автора / ожидание LSN
  1. Стикнуть к праймари — N секунд после записи читать этого пользователя с праймари; просто, но грузит лидера и требует session-состояния.
  2. Отслеживать позицию — запомнить LSN коммита (pg_current_wal_lsn()) и читать с реплики, только когда pg_last_wal_replay_lsn() >= LSN; точнее, но сложнее в обвязке.
  3. Кворум — R + W > N даёт сильное чтение ценой латентности каждого запроса.

⚠️ Частая ошибка: смешивать модели консистентности с уровнями изоляции БД. Изоляция (read committed, serializable) — про конкурентные транзакции на одном узле; консистентность (линеаризуемость, eventual) — про реплики и распределённость. Это разные оси.

86

Event sourcing и CQRS: когда они оправданы и какова цена?

Короткий ответ: Event sourcing: состояние не хранится, а выводится — state = fold(events); первичен журнал событий. CQRS: модель записи и модели чтения разделены. Дают полный аудит, temporal queries («как выглядел заказ вчера»), пересборку проекций с нуля. Цена высокая: версионирование событий, снапшоты, eventually consistent read-модели, дорогой тулинг. Это не архитектура по умолчанию.

Подробно:

events:  OrderCreated → ItemAdded → ItemAdded → OrderPaid
state  = fold(events)   — всегда выводимо заново
проекции: «заказы за день», «топ товаров» — свои read-модели,
          можно пересобрать из журнала с нуля
  1. Когда оправдано — движение денег, леджеры, домены с жёстким аудитом и комплаенсом; «почему баланс стал таким» — вопрос бизнеса, а не логов.
  2. Цена №1: версионирование — событие живёт вечно; изменилась схема — читать все старые версии (upcasters) или мигрировать журнал целиком.
  3. Цена №2: чтение — read-модели асинхронны: после команды проекция отстаёт, и UI, тесты, саппорт обязаны это переживать.
  4. CQRS без ES — легитимен и сильно дешевле: обычная БД записи + денормализованные проекции для чтения.

⚠️ Частая ошибка: предлагать event sourcing для CRUD-приложения «на вырост». Если аудит — не бизнес-требование, вы платите всю цену ES и не получаете ничего, что не дала бы таблица плюс журнал изменений.

87

Почему нельзя полагаться на wall-clock между сервисами и что ломается при last-write-wins?

Короткий ответ: Часы машин расходятся несмотря на NTP — дрейф, leap smearing, паузы VM дают от миллисекунд до секунд, — поэтому «больший таймстемп» ≠ «случилось позже». Last-write-wins по таким часам молча выбрасывает конкурентные записи: классический lost update между репликами, причём без единой ошибки в логах.

Подробно:

часы A отстают на 2 с
реально t=10.0  A: UPDATE profile   (свой ts = 8.0)
реально t=9.5   B: UPDATE profile   (свой ts = 9.5)
LWW: 9.5 > 8.0 → «побеждает» B,
хотя запись A физически была ПОЗЖЕ — и молча исчезла
  1. Wall clock vs monotonic — wall clock прыгает (NTP умеет корректировать назад); интервалы меряют monotonic clock, но он не сравним между машинами. Общего «сейчас» нет.
  2. Чем чинить — версии + optimistic locking (UPDATE … WHERE version = $1); монотонный sequence или fencing token от единственного писателя; vector clocks — концептуально, чтобы отличать конкурентность от порядка; проектировать операции коммутативными (increment вместо set, CRDT-мышление).
  3. Где LWW терпим — телеметрия, кэши, поля, где «последний прав» — честная бизнес-семантика, а потеря записи не страшна.

⚠️ Частая ошибка: «настроим NTP поточнее — и норм». NTP уменьшает дрейф, но не ограничивает его гарантированно: без bounded-clock-инфраструктуры уровня TrueTime корректность на таймстемпах не строится.

88

Устройство топика Kafka: партиции, реплики, лидер, ISR — и какие настройки делают запись долговечной?

Короткий ответ: Топик — набор партиций; партиция — append-only лог, порядок гарантирован только внутри неё. У каждой партиции есть лидер (обслуживает чтение и запись) и фолловеры-реплики; ISR — реплики, не отстающие от лидера. Стандарт долговечности: acks=all + min.insync.replicas=2 при replication.factor=3.

Подробно:

topic orders (RF=3)
p0: [0|1|2|3|4] ──► leader: broker1, ISR: {1,2,3}
p1: [0|1|2]     ──► leader: broker2, ISR: {2,3}
p2: [0|1|2|3]   ──► leader: broker3, ISR: {1,3}
  1. acks=all — лидер отвечает продюсеру только после репликации записи на все реплики из ISR.
  2. min.insync.replicas=2 — если живых ISR меньше двух, продюсер получает NotEnoughReplicas вместо тихой записи в единственную копию.
  3. Вместе с RF=3 — падение одного брокера переживается без потери данных и без остановки записи: в ISR остаются двое.

⚠️ Частая ошибка: «Kafka гарантирует порядок сообщений». Только внутри одной партиции; глобального порядка в топике нет.

89

Как консюмер-группа распределяет партиции и что будет, если консюмеров больше, чем партиций?

Короткий ответ: Внутри группы каждая партиция читается максимум одним консюмером. Консюмеров больше, чем партиций — лишние простаивают: максимальный параллелизм группы равен числу партиций. Разные группы читают один топик независимо, у каждой свои оффсеты.

Подробно:

topic orders: p0  p1  p2  p3
group A (3 консюмера):
  c1 ◄─ p0, p1    c2 ◄─ p2    c3 ◄─ p3
group A (6 консюмеров):
  c1◄p0  c2◄p1  c3◄p2  c4◄p3   c5, c6 — idle
group B: читает те же партиции независимо (свои оффсеты)
  1. Внутри группы — партиция достаётся ровно одному консюмеру: так сохраняется порядок обработки внутри партиции.
  2. Между группами — независимое чтение: каждая группа коммитит свои оффсеты в __consumer_offsets, одни и те же данные обслуживают и биллинг, и аналитику.
  3. Планирование — число партиций задаёт потолок масштабирования группы; закладывайте его с запасом при создании топика.

⚠️ Частая ошибка: «добавим консюмеров — станет быстрее». После числа партиций добавленные консюмеры просто простаивают.

90

Что запускает ребалансировку консюмер-группы и чем она опасна?

Короткий ответ: Ребалансировка — перераспределение партиций внутри группы. Триггеры: консюмер вошёл/вышел/упал, истёк session.timeout.ms, превышен max.poll.interval.ms (медленная обработка в poll-цикле — классический самострел), изменилась подписка или число партиций. Опасна тем, что eager-протокол останавливает всю группу.

Подробно:

  1. Eager (классика) — stop-the-world: все консюмеры отзывают все партиции, обработка группы замирает до конца ребаланса.
  2. Cooperative / incremental (KIP-429)CooperativeStickyAssignor: отзываются только перемещаемые партиции, остальные продолжают работать.
  3. Static membershipgroup.instance.id: при rolling-рестарте брокер узнаёт вернувшийся консюмер и не запускает ребаланс.
  4. Шторм ребалансов — тяжёлая работа внутри poll-цикла: превысили max.poll.interval.ms → консюмер «объявлен мёртвым» → ребаланс → партиции переехали → новый консюмер тоже не успевает → цикл повторяется.
session.timeout.ms=45000
max.poll.interval.ms=300000
group.instance.id=payments-1
partition.assignment.strategy=CooperativeStickyAssignor

⚠️ Частая ошибка: лечить «умирающий» консюмер бесконечным увеличением таймаутов вместо выноса тяжёлой обработки из poll-цикла.

91

Как выбрать ключ партиционирования и что ломается при плохом ключе?

Короткий ответ: Ключ — это ваша единица порядка: сообщения с одним ключом попадают в одну партицию и читаются по порядку. Берите бизнес-идентификатор, вокруг которого нужен порядок (order_id, user_id). Плохой ключ даёт перекос — горячие партиции, которые нельзя разгрузить добавлением консюмеров.

Подробно:

Ключ Эффект
order_id порядок событий заказа; ровное распределение
user_id порядок по пользователю; риск горячих «китов»
country 3–5 значений → перекос, hot partitions
null round-robin/sticky: ровно, но порядка нет
  1. Перекос — партиция с горячим ключом упирается в единственный консюмер: лаг растёт только на ней, а масштабирование группы не помогает.
  2. Смена числа партиций — hash(key) % partitions меняется: старые и новые сообщения одного ключа оказываются в разных партициях, порядок по ключу ломается. Число партиций закладывайте заранее.
  3. null-ключ — годится для событий без сущности-владельца, где порядок не важен.

⚠️ Частая ошибка: случайный ключ «для равномерности» там, где нужен порядок по сущности — дозаказать порядок потом уже негде.

92

Стратегии коммита оффсетов: авто или вручную — и как правильно получить at-least-once?

Короткий ответ: enable.auto.commit=true коммитит оффсеты по таймеру (auto.commit.interval.ms), вне связи с фактом обработки: можно закоммитить раньше, чем обработали (потеря при падении), или позже (дубли). Правильный at-least-once: сначала обработать — потом закоммитить вручную.

Подробно:

Когда коммитим Семантика Риск
до обработки at-most-once упали после коммита → сообщение потеряно
после обработки at-least-once упали до коммита → дубль
по таймеру (auto) непредсказуемо и то и другое
  1. Рабочий паттернenable.auto.commit=false; цикл: poll → обработать батч → commitAsync() (не блокирует poll).
  2. На выходеcommitSync() в shutdown-хуке и в onPartitionsRevoked ребаланс-листенера: последняя позиция гарантированно доедет.
  3. Дубли остаются — at-least-once по определению допускает повторы: дедупликация — обязанность консюмера.

⚠️ Частая ошибка: авто-коммит срабатывает до конца обработки батча — упавший консюмер «съел» сообщения, и никто не заметил (это тихий at-most-once).

93

Консюмер обработал сообщение, но упал до коммита оффсета. Что произойдёт?

Короткий ответ: Оффсет не закоммичен — для Kafka сообщение не обработано: после ребаланса партиция достанется другому консюмеру, и он получит то же сообщение снова. Эффект уже применён один раз → обработка задвоится. Именно поэтому Kafka по умолчанию at-least-once, а консюмер обязан быть идемпотентным.

Подробно:

c1: poll ──► обработал (записал в БД) ──► 💥 crash
                    оффсет НЕ закоммичен
ребаланс ──► c2 читает с последнего коммита
         ──► то же сообщение доставлено снова
  1. Это контракт, а не баг — брокер не знает, что обработка завершилась; единственный сигнал — закоммиченный оффсет.
  2. Защита — идемпотентный консюмер: сохранять стабильный message id в inbox-таблицу с UNIQUE-ограничением в одной транзакции с бизнес-эффектом. Повторная доставка упирается в constraint и не применяет эффект второй раз.
  3. Обратный порядок хуже — коммит до обработки превращает тот же сбой в потерю сообщения, а не в дубль.

⚠️ Частая ошибка: считать дубли аномалией и не писать дедупликацию — «у нас же Kafka, там всё надёжно».

94

Лаг консюмера растёт. Как диагностировать и чинить?

Короткий ответ: Сначала измерить per-partition лаг (kafka-consumer-groups.sh или экспортер в метрики) и понять, что изменилось: вырос входной поток или замедлилась обработка. Дальше чинить по ранжиру: консюмеры до числа партиций, вынести тяжёлую работу из poll-цикла, батчинг, и только потом — увеличение партиций и backpressure.

Подробно:

$ kafka-consumer-groups.sh --describe --group billing
TOPIC   PART  CURRENT   LOG-END   LAG
orders  0     91400     91420     20
orders  1     52100     98700     46600  ◄ одна партиция
orders  2     97800     97810     10
  1. Лаг на одной партиции — горячий ключ или «застрявшее» сообщение: масштабирование не поможет, чинить ключ или обработку.
  2. Лаг равномерный — скейлить консюмеры до числа партиций; дальше — бессмысленно.
  3. Медленная обработка — вынести I/O из poll-цикла в пул воркеров (аккуратно: порядок по ключу), батчить записи в БД.
  4. Системно — увеличить число партиций (планово: ремапит ключи) или договориться с продюсерами о backpressure.
  5. Пока чините — следите за max.poll.interval.ms: медленный цикл спровоцирует ребаланс и удвоит лаг.

⚠️ Частая ошибка: первым делом добавить консюмеров сверх числа партиций — новые просто простаивают.

95

Как построить ретраи в Kafka, не блокируя партицию?

Короткий ответ: Ретраить на месте, блокируя poll, нельзя: одно ядовитое сообщение остановит всю партицию. Паттерн — ретраи через отдельные топики с нарастающей задержкой (orders-retry-5m → orders-retry-30m) и DLQ после N попыток, с заголовками о причине сбоя.

Подробно:

orders ──✗──► orders-retry-5m ──✗──► orders-retry-30m ──✗──► orders-dlq
   │ ok           │ ok (после паузы)      │ ok                  │ алерт +
   ▼              ▼                       ▼                     ручной разбор
  1. Основной консюмер — при ошибке публикует сообщение в retry-топик и коммитит оффсет: партиция не блокируется.
  2. Retry-консюмер — выдерживает задержку (сравнивает timestamp сообщения с now), обрабатывает; новая ошибка — следующий уровень.
  3. DLQ — после N попыток; в заголовках: причина, стектрейс, счётчик попыток; алерт на непустой DLQ обязателен.
  4. Цена — порядок — сообщение ушло в ретраи, а следующее по тому же ключу обработалось раньше: per-key порядок сломан. Несите версию/sequence в событии и отбрасывайте устаревшие.

⚠️ Частая ошибка: бесконечный in-place ретрай ядовитого сообщения — партиция стоит, лаг растёт, а таймауты poll добивают группу ребалансами.

96

Идемпотентный продюсер и транзакции Kafka: что на самом деле гарантирует каждый механизм?

Короткий ответ: enable.idempotence=true: продюсер получает producer id, каждое сообщение — sequence number, и брокер отбрасывает дубли ретраев — в пределах партиции и сессии продюсера. Транзакции (transactional.id + read_committed) добавляют атомарный consume-transform-produce между топиками Kafka. Ни то ни другое не дедуплицирует бизнес-дубли и не покрывает эффекты вне Kafka.

Подробно:

Механизм Что гарантирует Границы
идемпотентный продюсер ретрай send не создаст дубль партиция + сессия продюсера
транзакции consume + produce атомарны только топики Kafka
read_committed абортированные записи не видны сторона консюмера
  1. Идемпотентность — защита от сетевых ретраев самого клиента; повторный send() из кода приложения — это новое сообщение.
  2. Транзакции — оффсеты коммитятся в той же транзакции (sendOffsetsToTransaction): цикл read-process-write целиком или никак.
  3. Границы — приложение отправило одно бизнес-событие дважды → для Kafka это два разных сообщения; запись в Postgres или HTTP-вызов транзакцией Kafka не покрыты.

⚠️ Частая ошибка: «включили exactly-once — дедупликация не нужна». На границе Kafka гарантия заканчивается; идемпотентный консюмер всё ещё обязателен.

97

Kafka или RabbitMQ: как выбрать под конкретную задачу?

Короткий ответ: Kafka — реплицируемый лог с retention и replay: огромная пропускная способность, много независимых консюмер-групп, стриминг и event sourcing. RabbitMQ — умный брокер: маршрутизация exchange'ами, per-message ack/requeue, приоритеты, TTL и отложенная доставка из коробки — классическая очередь задач.

Подробно:

Требование Kafka RabbitMQ
replay / перечитать историю ✔ retention ✘ удаляется после ack
сложная маршрутизация ✘ только топики/ключи ✔ exchanges: topic, fanout, headers
delay / приоритеты через retry-топики, костыльно ✔ нативно
пропускная способность миллионы msg/s десятки–сотни тысяч
много независимых читателей ✔ группы со своими оффсетами нужно плодить очереди
очередь задач посредственно ✔ ack/requeue per message
  1. Kafka — данные как лог: интеграция сервисов через события, аналитика, стрим-обработка, шина event sourcing.
  2. Rabbit — команды и задачи: «отправь письмо», ретрай одного сообщения без влияния на соседей, ниже латентность на малых масштабах.

⚠️ Частая ошибка: «Kafka всегда лучше, она масштабнее». Для очереди задач с приоритетами и отложенной доставкой Rabbit проще и точнее по семантике.

98

Как гарантировать порядок обработки событий одного заказа end-to-end?

Короткий ответ: Порядок держится цепочкой: ключ партиционирования = order_id (все события заказа в одной партиции), партицию читает один консюмер группы, внутри консюмера нет переупорядочивания (serial по ключу при worker-пуле), а ретраи не обгоняют — в событии есть версия, устаревшие отбрасываются.

Подробно:

  1. Продюсер — key=order_id: created, paid, shipped одного заказа лягут в одну партицию по порядку записи; плюс идемпотентный продюсер, чтобы сетевой ретрай не переставил сообщения.
  2. Брокер → группа — партиция назначена ровно одному консюмеру: порядок чтения равен порядку лога.
  3. Внутри консюмера — асинхронный worker-пул ломает порядок: шардируйте задачи по ключу — один ключ всегда попадает в один воркер и обрабатывается последовательно.
  4. Ретраи — retry-топики обгоняют основную очередь: несите version/sequence в событии, консюмер отбрасывает устаревшие (state machine, терпимая к out-of-order).
  5. Между топиками — порядка НЕТ и не будет: payments и shipments не синхронизированы; проектируйте state machine, а не надейтесь на порядок доставки.

⚠️ Частая ошибка: обеспечить ключ и группу, но раздать сообщения в асинхронный пул — порядок тихо ломается уже внутри процесса.

99

Зачем ставить очередь между сервисами, если есть HTTP с ретраями?

Короткий ответ: Очередь развязывает сервисы во времени: консюмер может лежать, а продюсер продолжает работать; пики сглаживаются буфером; одно событие фан-аутится многим потребителям; историю можно перечитать. HTTP с ретраями оставляет временнУю связанность: получатель обязан быть жив прямо сейчас.

Подробно:

HTTP + ретраи Очередь
получатель лежит ретраи, потом отказ сообщения копятся, обработаются позже
пик нагрузки бьёт по получателю буфер; консюмер в своём темпе
фан-аут N вызовов из кода подписки; продюсер не знает читателей
ответ сразу ✘ correlation id, callback
консистентность сильнее (sync) eventual
  1. Очередь — события, фоновые задачи, пиковые нагрузки, несколько независимых потребителей одного события.
  2. HTTP — нужен немедленный ответ или сильная консистентность в рамках запроса.
  3. Цена очереди — eventual consistency, обязательная обработка дублей (at-least-once), мониторинг лага, request-response через correlation — сложнее и дороже в отладке.

⚠️ Частая ошибка: тащить очередь туда, где вызывающему нужен синхронный ответ — получите ту же связанность плюс брокер в придачу.

100

Cache-aside, write-through, write-behind: как работает каждая стратегия и когда какую выбрать?

Короткий ответ: Cache-aside — приложение читает кэш, на промахе идёт в БД и кладёт результат; на запись пишет в БД и инвалидирует ключ. Write-through — запись синхронно в кэш и в хранилище. Write-behind — ack от кэша, в хранилище сбрасывается асинхронно. Дефолт — cache-aside; остальные две — под конкретные профили нагрузки.

Подробно:

Cache-aside Write-through Write-behind
Чтение miss → БД → кэш из кэша из кэша
Запись БД + инвалидировать ключ кэш + БД синхронно кэш сразу, БД потом
Консистентность окно stale после записи читатели видят свежее слабая до сброса
Латентность записи как у БД БД + кэш: медленнее только кэш: быстро
Риск потери нет нет есть: упал до сброса
  1. Cache-aside — кэш не стоит на критическом пути записи и его падение переживается (просто больше промахов); цена — свой протокол инвалидации на каждую запись.
  2. Write-through — консистентные чтения из коробки; цена — каждая запись ждёт оба хранилища, и кэшируется в том числе то, что никто не прочитает.
  3. Write-behind — буфер для шквала записей (счётчики, лайки, метрики); без durable-буфера (очередь, AOF) падение узла = потерянные записи.

⚠️ Частая ошибка: выбирать write-behind «для скорости», не ответив, что случится с несброшенными записями при падении процесса.

Источники

Источники и редакционная политика

Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку

Стратегия интервью3 мин

Собеседование Go-разработчика в Ozon Tech

Что повторить Go-разработчику перед интервью в Ozon Tech: slices, channels, context, runtime, микросервисы, Kafka, PostgreSQL и надёжность.

3 быстрых ответа
Библиотека собеседований RecallDeck

Подробные русские ответы, разборы этапов найма и планы подготовки для российского IT-рынка.

RSS