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

Открытое собеседование на Golang разработчика

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

Сегодня мы разберём реальное собеседование на позицию middle Go-разработчика: кандидат Костя, имеющий опыт в IT и менеджменте, но недавно начавший изучать Go, отвечает на вопросы от базовых типов данных и структур до конкурентности, работы с базами данных, Docker/Kubernetes и HTTP. Интервьюер Сергей мягко подталкивает кандидата к более глубоким ответам, подсказывает направление мысли и в итоге даёт честный фидбек — отмечая хорошее понимание основ, но указывая на недостаток практики и уверенности в деталях, что типично для разработчика в процессе перехода на новый стек.

Вопрос 1. Чем отличается объявление переменной через var от короткого объявления через := в Go?

Таймкод: 00:08:58

Ответ собеседника: неполный. Отличие в синтаксисе: используется двоеточие равно, и Go сам пытается определить тип переменной. Упомянул, что для сложных объектов, например map, нужно использовать make.

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

Собеседник затронул лишь поверхностные аспекты. Разберём все ключевые отличия подробно.

1. Область видимости (scope)

Краткое объявление := можно использовать только внутри функции. Ключевое слово var работает как внутри функций, так и на уровне пакета (package-level).

// На уровне пакета — только var
var globalVar = 42

func main() {
// Здесь можно использовать оба способа
localVar := 10 // короткое объявление
var anotherVar = 20 // полное объявление
}

2. Явное указание типа

Через var можно явно задать тип, даже если присваивается значение:

var x int64 = 42 // явно int64
var y = 42 // компилятор выведет int
z := 42 // тоже int, но без возможности указать int64

Это важно, когда нужен конкретный тип, а не тип по умолчанию:

var timeout time.Duration = 5 * time.Second // явно Duration

3. Объявление без инициализации

var позволяет объявить переменную без значения — она получит нулевое значение (zero value) для своего типа:

var count int // 0
var name string // ""
var ptr *int // nil
var m map[string]int // nil (не путать с инициализированной map!)

Через := значение обязательно, поэтому нулевое значение задать нельзя напрямую.

4. Переобъявление (redeclaration)

Это критически важное отличие. Короткое объявление позволяет переобъявлять переменные в том же блоке кода, если хотя бы одна из переменных в левой части — новая:

x := 10
fmt.Println(x) // 10

x, y := 20, 30 // OK: x переобъявляется, y — новая переменная
fmt.Println(x, y) // 20 30

Через var такое невозможно — повторное объявление в том же блоке вызовет ошибку компиляции.

5. Использование с уже существующими переменными

Если переменная уже объявлена в том же блоке, := без новой переменной слева вызовет ошибку:

x := 10
x := 20 // ошибка: no new variables on left side of :=

В этом случае нужно использовать просто =:

x := 10
x = 20 // OK

6. Инициализация сложных типов

Собеседник упомянул make для map — это верно, но неполно. Короткое объявление прекрасно работает с make и литералами:

// Все допустимые варианты через :=
m := make(map[string]int)
s := make([]int, 0, 10)
ch := make(chan int, 5)

// Литералы тоже работают
m2 := map[string]int{"key": 1}
s2 := []int{1, 2, 3}

7. Практические рекомендации

  • Используйте := внутри функций для краткости и читаемости — это идиоматичный подход в Go.
  • Используйте var, когда нужно явно указать тип, когда переменная объявляется без инициализации, или на уровне пакета.
  • Используйте var с группировкой для объявления нескольких связанных переменных:
var (
host = "localhost"
port = 8080
timeout = 30 * time.Second
)
)

Резюме ключевых отличий:

Критерийvar:=
Область видимостиВезде (пакет + функции)Только внутри функций
Явный типДаНет (выводится)
Без инициализацииДа (zero value)Нет (обязательно значение)
ПереобъявлениеНетДа (если есть новая переменная)
Переопределение значенияЧерез =Через = (без двоеточия)

Вопрос 2. Какие аргументы передаются в make при создании слайса?

Таймкод: 00:10:11

Ответ собеседника: неполный. Упомянул, что первый аргумент — это сам тип, а дальше зависит от типа объекта. Предположил, что для слайса передаётся длина и, возможно, ёмкость, но не был уверен.

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

Собеседник был близок к правильному ответу, но не дал полной картины. Разберём подробно.

Сигнатура make для слайсов

make([]T, length, capacity)

Функция make для слайсов принимает два или три аргумента:

  1. Первый аргумент — тип слайса ([]T), где T — тип элементов.
  2. Второй аргументдлина (length) — количество элементов, которые будут доступны сразу после создания.
  3. Третий аргумент (опциональный) — ёмкость (capacity) — размер внутреннего массива.
// Длина = 3, ёмкость = 5
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s)) // 3, 5

// Длина = ёмкость = 5 (если capacity не указана)
s2 := make([]int, 5)
fmt.Println(len(s2), cap(s2)) // 5, 5

Что происходит внутри

Когда вы вызываете make([]int, 3, 5), Go:

  1. Выделяет массив из 5 элементов int в памяти.
  2. Создаёт структуру слайса (slice header), которая содержит:
    • Указатель на начало массива.
    • Длину (length) = 3 — первые 3 элемента доступны для чтения/записи.
    • Ёмкость (capacity) = 5 — максимальный размер без реаллокации.
s := make([]int, 3, 5)
// s[0], s[1], s[2] — доступны (zero values: 0, 0, 0)
// s[3], s[4] — НЕ доступны, panic при обращении

s = append(s, 42) // OK, добавляет на позицию 3, len становится 4
s = append(s, 43) // OK, добавляет на позицию 4, len становится 5
s = append(s, 44) // Реаллокация! Ёмкость удваивается

Зачем разделять length и capacity

Это ключевая оптимизация производительности:

// Плохо: множественные реаллокации
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i) // реаллокация при каждом превышении capacity
}

// Хорошо: одна аллокация
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i) // реаллокаций не будет
}

Сравнение с литералом слайса

Литерал создаёт слайс с length == capacity:

// Литерал: len=3, cap=3
s1 := []int{1, 2, 3}

// make: len=0, cap=3 — пустой, но с запасом
s2 := make([]int, 0, 3)

// make: len=3, cap=3 — три нуля
s3 := make([]int, 3)

make для других типов

Для полноты картины, make работает с тремя типами:

// Map: make(map[K]V) или make(map[K]V, hint)
m := make(map[string]int, 100) // hint — ожидаемое количество элементов

// Channel: make(chan T) или make(chan T, bufferSize)
ch := make(chan int, 10) // буферизованный канал на 10 элементов

Практические рекомендации

  • Если знаете примерный размер слайса заранее — всегда указывайте capacity.
  • Используйте make([]T, 0, cap), когда планируете заполнять через append.
  • Используйте make([]T, length), когда нужны элементы с нулевыми значениями сразу доступными.

Вопрос 3. Что будет со слайсом после вызова функции, которая сортирует его через sort.Ints, если слайс передаётся по значению, а не по указателю?

Таймкод: 00:11:34

Ответ собеседника: правильный. Слайс содержит указатель на адрес памяти первого элемента массива под капотом. При передаче в функцию копируется этот указатель, поэтому сортировка меняет элементы исходного массива, и после возврата из функции слайс будет отсортирован.

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

Ответ собеседника полностью верен. Дополним его деталями для полноты понимания.

Устройство слайса в Go

Слайс — это структура (slice header), содержащая три поля:

type slice struct {
array unsafe.Pointer // указатель на базовый массив
len int // длина
cap int // ёмкость
}

При передаче слайса в функцию копируется этот header (24 байта на 64-битной системе), но указатель array ссылается на тот же базовый массив.

func sortSlice(s []int) {
sort.Ints(s) // сортирует элементы в базовом массиве
}

func main() {
data := []int{3, 1, 4, 1, 5}
sortSlice(data)
fmt.Println(data) // [1 1 3 4 5] — отсортирован!
}

Что изменится, а что нет

Модификация элементов видна снаружи:

func modifyElements(s []int) {
s[0] = 999 // видно снаружи
}

Но изменение длины или ёмкости — не видно:

func tryAppend(s []int) {
s = append(s, 42) // создаётся новый header локально
fmt.Println(len(s)) // 6
}

func main() {
data := []int{1, 2, 3}
tryAppend(data)
fmt.Println(len(data)) // 3 — не изменилось!
}

Когда нужен указатель на слайс

Если функция может изменить длину или ёмкость слайса (например, через append), нужно передавать указатель:

func appendElement(s *[]int, val int) {
*s = append(*s, val)
}

func main() {
data := []int{1, 2, 3}
appendElement(&data, 4)
fmt.Println(data) // [1 2 3 4]
}

Или возвращать новый слайс (более идиоматично в Go):

func appendElement(s []int, val int) []int {
return append(s, val)
}

func main() {
data := []int{1, 2, 3}
data = appendElement(data, 4)
fmt.Println(data) // [1 2 3 4]
}

Итог

Собеседник правильно понял суть: слайс в Go — это уже ссылочный тип данных, поэтому для модификации элементов указатель не нужен. Указатель на слайс требуется только когда меняется сама структура слайса (len/cap).

Вопрос 4. Какой алгоритм работает без выделения новой памяти, выполняя операции «на месте»?

Таймкод: 00:12:25

Ответ собеседника: неправильный. Кандидат не смог назвать алгоритм, работающий in-place. Ответ был неопределённым.

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

Существует множество алгоритмов, работающих in-place (на месте), то есть использующих O(1) дополнительной памяти. Рассмотрим основные категории.

1. In-place сортировки

Пузырьковая сортировка (Bubble Sort)

func bubbleSort(arr []int) {
n := len(arr)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j]
}
}
}
}
// Сложность: O(n²) времени, O(1) памяти

Сортировка вставками (Insertion Sort)

func insertionSort(arr []int) {
for i := 1; i < len(arr); i++ {
key := arr[i]
j := i - 1
for j >= 0 && arr[j] > key {
arr[j+1] = arr[j]
j--
}
arr[j+1] = key
}
}
// Сложность: O(n²) времени, O(1) памяти

Сортировка выбором (Selection Sort)

func selectionSort(arr []int) {
for i := 0; i < len(arr)-1; i++ {
minIdx := i
for j := i + 1; j < len(arr); j++ {
if arr[j] < arr[minIdx] {
minIdx = j
}
}
arr[i], arr[minIdx] = arr[minIdx], arr[i]
}
}
// Сложность: O(n²) времени, O(1) памяти

Быстрая сортировка (Quick Sort) — in-place версия

func quickSort(arr []int, low, high int) {
if low < high {
pivot := partition(arr, low, high)
quickSort(arr, low, pivot-1)
quickSort(arr, pivot+1, high)
}
}

func partition(arr []int, low, high int) int {
pivot := arr[high]
i := low - 1
for j := low; j < high; j++ {
if arr[j] < pivot {
i++
arr[i], arr[j] = arr[j], arr[i]
}
}
arr[i+1], arr[high] = arr[high], arr[i+1]
return i + 1
}
// Сложность: O(n log n) среднее время, O(log n) стека рекурсии

Пирамидальная сортировка (Heap Sort)

func heapSort(arr []int) {
n := len(arr)

// Построение max-heap
for i := n/2 - 1; i >= 0; i-- {
heapify(arr, n, i)
}

// Извлечение элементов
for i := n - 1; i > 0; i-- {
arr[0], arr[i] = arr[i], arr[0]
heapify(arr, i, 0)
}
}

func heapify(arr []int, n, i int) {
largest := i
left := 2*i + 1
right := 2*i + 2

if left < n && arr[left] > arr[largest] {
largest = left
}
if right < n && arr[right] > arr[largest] {
largest = right
}
if largest != i {
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
}
}
// Сложность: O(n log n) времени, O(1) памяти (если итеративный heapify)

2. Разворот массива/слайса

func reverse(arr []int) {
for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 {
arr[i], arr[j] = arr[j], arr[i]
}
}
// Сложность: O(n) времени, O(1) памяти

3. Удаление дубликатов из отсортированного массива

func removeDuplicates(arr []int) int {
if len(arr) == 0 {
return 0
}
writeIdx := 1
for readIdx := 1; readIdx < len(arr); readIdx++ {
if arr[readIdx] != arr[readIdx-1] {
arr[writeIdx] = arr[readIdx]
writeIdx++
}
}
return writeIdx
}
// Сложность: O(n) времени, O(1) памяти

4. Поиск цикла в связном списке (алгоритм Флойда)

func hasCycle(head *ListNode) bool {
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
return true
}
}
return false
}
// Сложность: O(n) времени, O(1) памяти

5. Перестановка элементов (in-place swap)

// Перестановка чётных и нечётных на месте
func segregateEvenOdd(arr []int) {
left, right := 0, len(arr)-1
for left < right {
for arr[left]%2 == 0 && left < right {
left++
}
for arr[right]%2 == 1 && left < right {
right--
}
if left < right {
arr[left], arr[right] = arr[right], arr[left]
left++
right--
}
}
}

Сравнение алгоритмов

АлгоритмВремяПамятьСтабильность
Bubble SortO(n²)O(1)Да
Insertion SortO(n²)O(1)Да
Selection SortO(n²)O(1)Нет
Quick SortO(n log n)O(log n)*Нет
Heap SortO(n log n)O(1)Нет

*Quick Sort использует O(log n) стека рекурсии, но O(1) дополнительной памяти для данных.

Ключевой принцип in-place алгоритмов

Все они используют обмен элементами (swap) вместо создания новых структур данных. В Go это особенно удобно благодаря множественному присваиванию:

arr[i], arr[j] = arr[j], arr[i]

Вопрос 5. Как устроена мапа под капотом в Go и что такое эвакуация (evacuation)?

Таймкод: 00:12:58

Ответ собеседника: правильный. Мапа — это реализация хеш-таблицы. Под капотом есть хеш-функция, которая раскладывает значения по бакетам. Эвакуация — это процесс расширения мапы: когда среднее количество элементов в бакете достигает порогового значения, Go создаёт больше бакетов и перемещает элементы из старых в новые. Процесс происходит постепенно при вставке новых элементов.

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

Ответ собеседника верен и покрывает основные аспекты. Дополним техническими деталями для полноты.

Структура hmap

Мапа в Go — это указатель на структуру hmap:

type hmap struct {
count int // количество элементов
flags uint8
B uint8 // log2 количества бакетов (2^B бакетов)
noverflow uint16 // количество overflow-бакетов
hash0 uint32 // seed для хеш-функции

buckets unsafe.Pointer // массив бакетов
oldbuckets unsafe.Pointer // старые бакеты (при эвакуации)
nevacuate uintptr // прогресс эвакуации

extra *mapextra // overflow-указатели
}

Структура бакета (bmap)

Каждый бакет содержит до 8 пар ключ-значение:

type bmap struct {
tophash [8]uint8 // старшие биты хеша для быстрого сравнения
// За ними следуют 8 ключей, затем 8 значений
// В конце — указатель на overflow-бакет (если есть)
}

Процесс поиска элемента

// Упрощённая логика поиска
func mapaccess(m *hmap, key any) any {
hash := m.hash(key) // вычисляем хеш
bucket := hash & (1<<m.B - 1) // определяем бакет

for b := m.buckets[bucket]; b != nil; b = b.overflow {
for i := 0; i < 8; i++ {
if b.tophash[i] == top(hash) && equal(b.keys[i], key) {
return b.values[i]
}
}
}
return nil
}

Когда происходит рост мапы

Рост запускается при превышении load factor = 6.5 (среднее количество элементов на бакет):

// Порог переполнения
maxAvg := 6.5 // из исходников Go

// Условие роста
overLoadFactor(count, B) {
return count > bucketCnt && float32(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

Процесс эвакуации (evacuation)

При росте мапы создаётся вдвое больше бакетов. Эвакуация происходит инкрементально — не все элементы перемещаются сразу:

// Упрощённая логика
func growWork(m *hmap, key any) {
// 1. Эвакуируем один старый бакет
evacuate(m, m.oldbuckets[m.nevacuate])

// 2. Если ещё не всё эвакуировано — эвакуируем ещё один
if m.nevacuate < len(m.oldbuckets) {
evacuate(m, m.oldbuckets[m.nevacuate])
}
}

Типы эвакуации

Равномерное расширение (same size) — при переполнении overflow-бакетов, но без реального роста:

// Происходит когда много overflow-бакетов, но load factor не превышен
// Элементы перемещаются в те же позиции, но компактнее

Удвоение размера — стандартный рост:

// Старые бакеты: 2^B
// Новые бакеты: 2^(B+1)
// Каждый старый бакет эвакуируется в 2 новых бакета

Распределение при эвакуации

Старый бакет i эвакуируется в два новых бакета: i и i + 2^B:

func evacuate(b *bmap, newbuckets uintptr, B uint8) {
newbit := uintptr(1) << B
for i := 0; i < 8; i++ {
hash := b.tophash[i]
useY := hash & newbit // определяет, в какой из двух бакетов

if useY != 0 {
// в новый бакет i + 2^B
} else {
// в новый бакет i
}
}
}

Почему инкрементальная эвакуация

Если бы все элементы перемещались сразу, вставка вызывала бы большой пик латентности. Инкрементальный подход распределяет стоимость:

// При каждой вставке или удалении эвакуируется 1-2 бакета
// Это гарантирует O(1) амортизированное время операций

Практические рекомендации

// Хорошо: предвыделение ёмкости
m := make(map[string]int, 10000)

// Плохо: многократные реаллокации
m := make(map[string]int)
for i := 0; i < 10000; i++ {
m[fmt.Sprintf("key%d", i)] = i
}

Визуализация структуры

hmap
├── buckets ──→ [bmap0] → [overflow] → [overflow]
│ [bmap1] → [overflow]
│ [bmap2]
│ ...

├── oldbuckets ──→ (при эвакуации указывает на старые бакеты)

└── nevacuate = 3 (эвакуировано 3 старых бакета)

Ключевые моменты

  • Бакет содержит максимум 8 элементов.
  • Overflow-бакеты создаются при коллизиях (цепочечное хеширование).
  • Эвакуация инкрементальна — распределяет стоимость роста.
  • Load factor 6.5 — порог для удвоения количества бакетов.

Вопрос 6. Можно ли управлять процессом эвакуации мапы напрямую?

Таймкод: 00:14:07

Ответ собеседника: неполный. Напрямую управлять нельзя. Но можно заранее задать ёмкость при создании мапы через make, если известен ожидаемый объём элементов, что уменьшит количество эвакуаций.

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

Собеседник верно отметил, что прямого управления эвакуацией нет, и упоминил предвыделение ёмкости. Дополним ответ подробнее.

Прямое управление эвакуацией

Напрямую управлять процессом эвакуации невозможно — это внутренний механизм runtime Go. В публичном API нет функций для:

  • Принудительного запуска эвакуации.
  • Отмены или паузы эвакуации.
  • Настройки load factor.
  • Контроля скорости инкрементальной эвакуации.

Что можно сделать косвенно

1. Предвыделение ёмкости

// Если ожидаем ~10000 элементов
m := make(map[string]int, 10000)

// Go выделит сразу достаточно бакетов: 2^B >= 10000/6.5
// Это предотвратит множественные эвакуации при заполнении

2. Контроль размаха хешей

Хеш-функция для строк и других типов использует рандомизированный seed, что защищает от атак, но не позволяет влиять на распределение.

3. Использование альтернативных структур

Если нужен полный контроль — можно реализовать собственную хеш-таблицу:

type CustomHashMap struct {
buckets []Bucket
count int
// полный контроль над ростом и эвакуацией
}

Практический пример оптимизации

// Плохо: множественные реаллокации
func buildMap(items []Item) map[string]Item {
result := make(map[string]Item) // ёмкость 0
for _, item := range items {
result[item.Key] = item // эвакуации при каждом превышении load factor
}
return result
}

// Хорошо: одна аллокация
func buildMap(items []Item) map[string]Item {
result := make(map[string]Item, len(items)) // сразу нужная ёмкость
for _, item := range items {
result[item.Key] = item // эвакуаций не будет (или минимум)
}
return result
}

Бенчмарк для демонстрации разницы

func BenchmarkMapNoPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int)
for j := 0; j < 10000; j++ {
m[j] = j
}
}
}

func BenchmarkMapPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int, 10000)
for j := 0; j < 10000; j++ {
m[j] = j
}
}
}

Результат обычно показывает 2-3x ускорение при предвыделении.

Итог

Единственный доступный рычаг — предвыделение ёмкости через make(map[K]V, hint). Эвакуация полностью управляется runtime и является деталью реализации, которая может меняться между версиями Go.

Вопрос 7. Каким свойством должны обладать ключи мапы в Go? Может ли структура быть ключом мапы?

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

Ответ собеседника: правильный. Ключи мапы должны быть comparable — для них должна быть определена операция сравнения. Структура может быть ключом, если она не содержит несравнимых типов, таких как слайсы или другие мапы.

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

Ответ собеседника полностью верен. Дополним примерами и деталями.

Comparable типы в Go

Тип является comparable, если для него определён оператор == и !=:

Comparable:

  • Базовые типы: int, float64, string, bool, byte, rune
  • Указатели (*T)
  • Каналы (chan T)
  • Интерфейсы (interface{})
  • Массивы фиксированного размера ([N]T, где T — comparable)
  • Структуры, содержащие только comparable поля

Не comparable:

  • Слайсы ([]T)
  • Мапы (map[K]V)
  • Функции (func(...))

Примеры структур как ключей

// OK: все поля comparable
type Point struct {
X, Y int
}

type UserID struct {
Region string
Number int
}

m := make(map[Point]string)
m[Point{1, 2}] = "origin"

users := make(map[UserID]User)
users[UserID{"US", 123}] = User{Name: "John"}
// Ошибка компиляции: содержит слайс
type BadKey struct {
Name string
Tags []string // слайс — не comparable!
}

// m := make(map[BadKey]int) // compile error: invalid map key type
// Ошибка компиляции: содержит мапу
type AlsoBad struct {
Name string
Data map[string]int // мапа — не comparable!
}

Как работает сравнение структур

Go сравнивает структуры поэлементно:

type Key struct {
A int
B string
}

k1 := Key{1, "hello"}
k2 := Key{1, "hello"}
k3 := Key{2, "world"}

fmt.Println(k1 == k2) // true
fmt.Println(k1 == k3) // false

Обход ограничения для несравнимых типов

Если нужен ключ со слайсом или мапой — используйте строковое представление:

// Хеширование слайса через сериализацию
func sliceKey(s []int) string {
return fmt.Sprint(s) // "[1 2 3]"
}

m := make(map[string]int)
m[sliceKey([]int{1, 2, 3})] = 42

Или используйте указатель (сравнивается по адресу):

// Указатели на слайсы — comparable
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}

m := make(map[*[]int]int)
m[&s1] = 1
m[&s2] = 2 // другой ключ, т.к. разные адреса

Массивы vs Слайсы

Массивы фиксированного размера — comparable, слайсы — нет:

// OK: массив — comparable
type MatrixKey [3][3]int
m := make(map[MatrixKey]string)

// Ошибка: слайс — не comparable
type SliceKey [][]int
// m := make(map[SliceKey]string) // compile error

Практический пример: составные ключи

// Координаты на игровом поле
type Position struct {
X, Y int
}

gameState := make(map[Position]string)
gameState[Position{0, 0}] = "player"
gameState[Position{5, 3}] = "enemy"

// Поиск за O(1)
if val, ok := gameState[Position{0, 0}]; ok {
fmt.Println("Found:", val)
}

Итог

Ключ мапы должен быть comparable. Структура автоматически становится comparable, если все её поля — comparable. Слайсы, мапы и функции нельзя использовать как ключи напрямую.

Вопрос 8. Что такое структура в Go? Для чего используются пустые структуры?

Таймкод: 00:16:02

Ответ собеседника: правильный. Структура — это возможность создавать кастомные типы в Go. Пустые структуры весят 0 байт и полезны для передачи сигналов через каналы, когда не нужно передавать какие-либо данные.

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

Ответ собеседника верен, но неполон. Разберём структуру и пустые структуры глубже.

Структура в Go

Структура — это составной тип, объединяющий поля разных типов:

type User struct {
ID int
Name string
Email string
CreatedAt time.Time
}

Основные возможности структур

1. Встраивание (embedding) — композиция вместо наследования

type Animal struct {
Name string
}

func (a Animal) Speak() {
fmt.Println(a.Name, "makes a sound")
}

type Dog struct {
Animal // встраивание
Breed string
}

dog := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
dog.Speak() // "Rex makes a sound" — метод поднят

2. Методы

type Rectangle struct {
Width, Height float64
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}

3. Теги структур

type Config struct {
Host string `json:"host" env:"HOST"`
Port int `json:"port" env:"PORT" default:"8080"`
Timeout int `json:"timeout" env:"TIMEOUT"`
}

Пустые структуры (struct{})

Пустая структура занимает 0 байт памяти:

var s struct{}
fmt.Println(unsafe.Sizeof(s)) // 0

Использование пустых структур

1. Сигнальные каналы

done := make(chan struct{})

go func() {
// работа...
close(done) // сигнал завершения
}()

<-done // ждём сигнала

2. Set (множество) без значений

type StringSet map[string]struct{}

func (s StringSet) Add(key string) {
s[key] = struct{}{}
}

func (s StringSet) Contains(key string) bool {
_, ok := s[key]
return ok
}

func (s StringSet) Remove(key string) {
delete(s, key)
}

// Использование
set := make(StringSet)
set.Add("apple")
set.Add("banana")
fmt.Println(set.Contains("apple")) // true

3. Реализация интерфейса без состояния

type Logger interface {
Log(msg string)
}

type SilentLogger struct{}

func (s SilentLogger) Log(msg string) {
// ничего не делает
}

// SilentLogger не требует памяти для хранения состояния

4. Сигнал вместо данных

type WorkerPool struct {
stop chan struct{}
}

func (wp *WorkerPool) Stop() {
close(wp.stop)
}

func (wp *WorkerPool) worker() {
for {
select {
case <-wp.stop:
return
default:
// работа
}
}
}

Почему struct{}, а не bool

// bool занимает 1 байт
chan bool // 1 байт на сообщение

// struct{} занимает 0 байт
chan struct{} // 0 байт на сообщение

Для сигнальных каналов это не критично, но для множеств разница заметна:

// Множество из 1M элементов
map[string]bool // ~16 MB для значений
map[string]struct{} // ~0 bytes для значений

Практический пример: context.Context

ctx, cancel := context.WithCancel(context.Background())

go func() {
select {
case <-ctx.Done():
return
}
}()

cancel() // отмена через закрытие chan struct{}

Итог

Структуры в Go — основной способ создания составных типов с поддержкой методов, встраивания и тегов. Пустые структуры (struct{}) — оптимизация для случаев, когда нужно только наличие ключа или сигнал, без данных.

Вопрос 9. Какую роль структуры играют в объектной модели Go? Какие концепции ООП они реализуют?

Таймкод: 00:16:44

Ответ собеседника: неполный. Кандидат отметил, что в Go нет классов, и структуры используются для описания объектов. Упомянул встраивание (embedding) как аналог наследования — одна структура может быть вложена в другую, предоставляя доступ к полям и методам. Не упомянул полиморфизм через интерфейсы и инкапсуляцию.

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

Go — не классический ООП-язык, но структуры реализуют все ключевые концепции ООП через собственные механизмы.

1. Инкапсуляция

Инкапсуляция в Go реализуется через экспортируемость (первая буква заглавная/строчная):

package user

type User struct {
ID int // экспортируемое поле
Name string // экспортируемое поле
email string // приватное поле (не видно за пределами пакета)
createdAt time.Time // приватное поле
}

func (u *User) Email() string {
return u.email // контролируемый доступ к приватному полю
}

func (u *User) SetEmail(email string) error {
if !isValidEmail(email) {
return errors.New("invalid email")
}
u.email = email
return nil
}
package main

func main() {
u := user.User{ID: 1, Name: "John"}
// u.email = "test" // ошибка: email не экспортируется
u.SetEmail("john@example.com") // OK
}

2. Композиция вместо наследования (Embedding)

Go не имеет наследования в классическом смысле. Вместо этого — встраивание:

type Animal struct {
Name string
Age int
}

func (a Animal) Speak() string {
return "..."
}

func (a Animal) Info() string {
return fmt.Sprintf("%s (%d years)", a.Name, a.Age)
}

// Dog "наследует" Animal через встраивание
type Dog struct {
Animal
Breed string
}

func (d Dog) Speak() string {
return "Woof!"
}

dog := Dog{
Animal: Animal{Name: "Rex", Age: 5},
Breed: "Labrador",
}

fmt.Println(dog.Name) // "Rex" — поле поднято
fmt.Println(dog.Speak()) // "Woof!" — метод переопределён
fmt.Println(dog.Info()) // "Rex (5 years)" — метод унаследован

3. Полиморфизм через интерфейсы

Интерфейсы в Go — неявные (duck typing):

type Speaker interface {
Speak() string
}

type Walker interface {
Walk() string
}

// Cat реализует оба интерфейса неявно
type Cat struct {
Name string
}

func (c Cat) Speak() string { return "Meow!" }
func (c Cat) Walk() string { return "Sneaking" }

// Функция принимает любой Speaker
func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}

// Композиция интерфейсов
type AnimalBehavior interface {
Speaker
Walker
}

func Perform(a AnimalBehavior) {
fmt.Println(a.Speak())
fmt.Println(a.Walk())
}

cat := Cat{Name: "Whiskers"}
MakeSound(cat) // "Meow!"
Perform(cat) // работает как Speaker + Walker

4. Конструкторы

В Go нет конструкторов — используются фабричные функции:

type Server struct {
host string
port int
timeout time.Duration
}

func NewServer(host string, port int) *Server {
return &Server{
host: host,
port: port,
timeout: 30 * time.Second,
}
}

func NewServerWithTimeout(host string, port int, timeout time.Duration) *Server {
return &Server{
host: host,
port: port,
timeout: timeout,
}
}

// Functional options pattern
type ServerOption func(*Server)

func WithTimeout(d time.Duration) ServerOption {
return func(s *Server) { s.timeout = d }
}

func WithTLS(cert string) ServerOption {
return func(s *Server) { s.cert = cert }
}

func NewServer(host string, port int, opts ...ServerOption) *Server {
s := &Server{host: host, port: port}
for _, opt := range opts {
opt(s)
}
return s
}

// Использование
s := NewServer("localhost", 8080, WithTimeout(10*time.Second))

5. Методы с указателем vs значением

type Counter struct {
count int
}

// Value receiver — не изменяет оригинал
func (c Counter) Value() int {
return c.count
}

// Pointer receiver — изменяет оригинал
func (c *Counter) Increment() {
c.count++
}

// Правило: если хотя бы один метод с pointer receiver — используйте pointer для всех

Сравнение с классическим ООП

Концепция ООПВ классических языкахВ Go
Классclassstruct
Наследованиеextendsembedding
ПолиморфизмЯвные интерфейсыНеявные интерфейсы
Инкапсуляцияprivate/publicРегистр первой буквы
Конструктор__init__Фабричные функции

Итог

Структуры в Go реализуют все принципы ООП, но через композицию, а не наследование. Интерфейсы обеспечивают полиморфизм без явного указания реализации. Это делает код более гибким и слабо связанным.

Вопрос 10. Что такое receiver (приёмник) метода в Go и в чём разница между pointer receiver и value receiver?

Таймкод: 00:18:30

Ответ собеседника: неполный. Receiver — это часть объявления метода, которая привязывает метод к конкретному типу (структуре). Кандидат не смог объяснить разницу между pointer receiver и value receiver, сказав, что обычно пишет с указателем, но не может объяснить почему.

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

Receiver — это специальный параметр между func и именем метода, который привязывает метод к типу:

func (r TypeName) MethodName() { } // value receiver
func (r *TypeName) MethodName() { } // pointer receiver

Value receiver — работает с копией

type Counter struct {
count int
}

// Value receiver — получает КОПИЮ структуры
func (c Counter) Increment() {
c.count++ // изменяет копию, оригинал не затронут
}

func main() {
c := Counter{count: 0}
c.Increment()
c.Increment()
c.Increment()
fmt.Println(c.count) // 0 — оригинал не изменился!
}

Pointer receiver — работает с оригиналом

type Counter struct {
count int
}

// Pointer receiver — получает АДРЕС оригинала
func (c *Counter) Increment() {
c.count++ // изменяет оригинал
}

func main() {
c := Counter{count: 0}
c.Increment()
c.Increment()
c.Increment()
fmt.Println(c.count) // 3 — оригинал изменён
}

Ключевые различия

1. Изменение состояния

type User struct {
Name string
Age int
}

// Value receiver — безопасен, не меняет оригинал
func (u User) WithName(name string) User {
u.Name = name // изменяет копию
return u // возвращаем новую копию
}

// Pointer receiver — изменяет оригинал
func (u *User) SetName(name string) {
u.Name = name // изменяет оригинал
}

user := User{Name: "John", Age: 30}

newUser := user.WithName("Jane") // user не изменился
fmt.Println(user.Name) // "John"
fmt.Println(newUser.Name) // "Jane"

user.SetName("Jane") // user изменился
fmt.Println(user.Name) // "Jane"

2. Производительность

type BigStruct struct {
data [1024]byte // 1 KB данных
}

// Value receiver — копирует 1 KB при каждом вызове
func (b BigStruct) Process() {
// работа с копией
}

// Pointer receiver — копирует только 8 байт (указатель)
func (b *BigStruct) Process() {
// работа с оригиналом
}

3. Реализация интерфейсов

type Stringer interface {
String() string
}

type MyType struct {
value int
}

// Value receiver — MyType реализует Stringer
func (m MyType) String() string {
return fmt.Sprintf("%d", m.value)
}

// Pointer receiver — только *MyType реализует Stringer
func (m *MyType) String() string {
return fmt.Sprintf("%d", m.value)
}

var s Stringer

m := MyType{value: 42}
// s = m // ошибка, если String() с pointer receiver
s = &m // OK

Правило: если есть хотя бы один pointer receiver — используйте pointer для всех методов типа

type Server struct {
addr string
port int
}

// НЕПРАВИЛЬНО: смешение receiver'ов
func (s Server) Addr() string { return s.addr } // value
func (s *Server) SetPort(p int) { s.port = p } // pointer

// ПРАВИЛЬНО: все pointer receivers
func (s *Server) Addr() string { return s.addr }
func (s *Server) SetPort(p int) { s.port = p }

Когда использовать value receiver

// 1. Небольшие типы (примитивы, маленькие структуры)
type Point struct{ X, Y float64 }

func (p Point) Distance(other Point) float64 {
dx := p.X - other.X
dy := p.Y - other.Y
return math.Sqrt(dx*dx + dy*dy)
}

// 2. Неизменяемые операции
func (p Point) Add(other Point) Point {
return Point{p.X + other.X, p.Y + other.Y}
}

// 3. Функциональный стиль (возврат нового значения)
func (s String) ToUpper() String {
return strings.ToUpper(string(s))
}

Когда использовать pointer receiver

// 1. Нужно изменять состояние
func (c *Counter) Increment() { c.count++ }

// 2. Большие структуры (избегаем копирования)
func (b *BigStruct) Process() { /* ... */ }

// 3. Реализация интерфейсов с мутацией
func (l *List) Push(v int) { l.items = append(l.items, v) }

// 4. Синхронизация (mutex нельзя копировать)
type SafeCounter struct {
mu sync.Mutex
count int
}

func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}

Важный нюанс: sync.Mutex нельзя копировать

type BadCounter struct {
mu sync.Mutex // содержит noCopy
count int
}

// Value receiver — ОШИБКА: копирует mutex
func (c BadCounter) Increment() {
c.mu.Lock() // блокировка копии!
c.count++
c.mu.Unlock()
}

// Pointer receiver — ПРАВИЛЬНО
func (c *BadCounter) Increment() {
c.mu.Lock() // блокировка оригинала
c.count++
c.mu.Unlock()
}

Итог

КритерийValue receiverPointer receiver
Работает сКопиейОригиналом
Изменяет оригиналНетДа
КопированиеРазмер структуры8 байт (указатель)
ИнтерфейсыT и *TТолько *T
Когда использоватьМаленькие типы, неизменяемые операцииМутация, большие структуры, mutex

Вопрос 11. Что такое интерфейс в Go и что такое пустой интерфейс?

Таймкод: 00:19:27

Ответ собеседника: правильный. Интерфейс — это контракт, описывание набора методов (их сигнатуры). В Go используется утиная типизация: тип реализует интерфейс неявно, если реализует все его методы. Пустой интерфейс (interface{}) не содержит методов, поэтому ему соответствует любой тип, так как все типы реализуют ноль методов.

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

Ответ собеседника полностью верен. Дополним техническими деталями и примерами.

Устройство интерфейса под капотом

Интерфейс — это структура из двух указателей:

type eface struct { // пустой интерфейс interface{}
_type *_type // информация о типе
data unsafe.Pointer // указатель на данные
}

type iface struct { // непустой интерфейс
tab *itab // таблица методов
data unsafe.Pointer // указатель на данные
}

Неявная реализация (duck typing)

type Speaker interface {
Speak() string
}

type Dog struct{}
func (d Dog) Speak() string { return "Woof!" }

type Cat struct{}
func (c Cat) Speak() string { return "Meow!" }

// Оба типа автоматически реализуют Speaker
// Нет ключевого слова implements!

Использование интерфейсов

func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}

MakeSound(Dog{}) // "Woof!"
MakeSound(Cat{}) // "Meow!"

Пустой интерфейс interface{}

// Любой тип реализует interface{}
func PrintAnything(v interface{}) {
fmt.Printf("Type: %T, Value: %v\n", v, v)
}

PrintAnything(42) // Type: int, Value: 42
PrintAnything("hello") // Type: string, Value: hello
PrintAnything([]int{1, 2}) // Type: []int, Value: [1 2]

Type assertion и type switch

func Process(v interface{}) {
// Type assertion
if s, ok := v.(string); ok {
fmt.Println("String:", s)
return
}

// Type switch
switch val := v.(type) {
case int:
fmt.Println("Int:", val)
case string:
fmt.Println("String:", val)
case Speaker:
fmt.Println("Speaker:", val.Speak())
default:
fmt.Println("Unknown type")
}
}

Пустой интерфейс в стандартной библиотеке

// fmt.Println принимает ...interface{}
func Println(a ...interface{}) (n int, err error)

// context.WithValue
ctx = context.WithValue(ctx, key, value) // value — interface{}

// map с произвольными значениями
config := map[string]interface{}{
"host": "localhost",
"port": 8080,
"debug": true,
"timeout": 30 * time.Second,
}

Ограничения пустого интерфейса

func Sum(a, b interface{}) interface{} {
// Нельзя просто сложить — нужен type assertion
aInt, ok1 := a.(int)
bInt, ok2 := b.(int)
if !ok1 || !ok2 {
panic("not integers")
}
return aInt + bInt
}

Generics как альтернатива (Go 1.18+)

// Вместо interface{}
func Sum[T constraints.Ordered](a, b T) T {
return a + b
}

Sum(1, 2) // 3
Sum(1.5, 2.5) // 4.0

Итог

Интерфейсы в Go — контракты с неявной реализацией. Пустой интерфейс interface{} принимает любой тип, но требует type assertion для работы с данными. В Go 1.18+ generics часто заменяют пустые интерфейсы для типобезопасного кода.

Вопрос 12. Для чего нужны интерфейсы в Go? Приведите примеры использования.

Таймкод: 00:21:15

Ответ собеседника: неполный. Кандидат привёл абстрактные примеры: объекты с похожими методами можно объединить общим интерфейсом и вызывать эти методы в цикле. Также упомянул расширяемость и поддерживаемость кода, но не привёл конкретных примеров использования интерфейсов.

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

Интерфейсы в Go решают несколько ключевых задач. Рассмотрим каждую с конкретными примерами.

1. Полиморфизм — работа с разными типами через единый контракт

type Storage interface {
Save(key string, value []byte) error
Load(key string) ([]byte, error)
Delete(key string) error
}

// Реализация для файловой системы
type FileStorage struct {
basePath string
}

func (fs FileStorage) Save(key string, value []byte) error {
return ioutil.WriteFile(fs.basePath+"/"+key, value, 0644)
}

func (fs FileStorage) Load(key string) ([]byte, error) {
return ioutil.ReadFile(fs.basePath + "/" + key)
}

func (fs FileStorage) Delete(key string) error {
return os.Remove(fs.basePath + "/" + key)
}

// Реализация для S3
type S3Storage struct {
bucket string
client *s3.S3
}

func (s S3Storage) Save(key string, value []byte) error {
_, err := s.client.PutObject(&s3.PutObjectInput{
Bucket: &s.bucket,
Key: &key,
Body: bytes.NewReader(value),
})
return err
}

// ... другие методы

// Бизнес-логика работает с любым Storage
type Service struct {
storage Storage
}

func NewService(storage Storage) *Service {
return &Service{storage: storage}
}

func (s *Service) ProcessData(key string, data []byte) error {
return s.storage.Save(key, data)
}

2. Тестирование — моки и стабы

// Мок для тестов
type MockStorage struct {
data map[string][]byte
mu sync.Mutex
}

func NewMockStorage() *MockStorage {
return &MockStorage{data: make(map[string][]byte)}
}

func (m *MockStorage) Save(key string, value []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
return nil
}

func (m *MockStorage) Load(key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.data[key]
if !ok {
return nil, errors.New("not found")
}
return val, nil
}

func (m *MockStorage) Delete(key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
return nil
}

// Тест
func TestService_ProcessData(t *testing.T) {
mock := NewMockStorage()
svc := NewService(mock)

err := svc.ProcessData("test", []byte("hello"))
require.NoError(t, err)

val, err := mock.Load("test")
require.NoError(t, err)
assert.Equal(t, []byte("hello"), val)
}

3. Dependency Injection — внедрение зависимостей

type Logger interface {
Info(msg string, args ...interface{})
Error(msg string, args ...interface{})
}

type StdLogger struct{}
func (l StdLogger) Info(msg string, args ...interface{}) { log.Printf(msg, args...) }
func (l StdLogger) Error(msg string, args ...interface{}) { log.Printf("ERROR: "+msg, args...) }

type JSONLogger struct{}
func (l JSONLogger) Info(msg string, args ...interface{}) {
logJSON("INFO", msg, args)
}
func (l JSONLogger) Error(msg string, args ...interface{}) {
logJSON("ERROR", msg, args)
}

type App struct {
logger Logger
db *sql.DB
}

func NewApp(logger Logger, db *sql.DB) *App {
return &App{logger: logger, db: db}
}

func (a *App) HandleRequest() {
a.logger.Info("Processing request")
// ...
}

4. Композиция интерфейсов

type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

type Closer interface {
Close() error
}

// Композиция
type ReadWriter interface {
Reader
Writer
}

type ReadWriteCloser interface {
Reader
Writer
Closer
}

// io.Copy работает с любым ReadWriter
func Copy(dst Writer, src Reader) (int64, error)

5. Ограничение доступа (interface segregation)

// Полный интерфейс
type FullUserRepo interface {
Create(user User) error
GetByID(id int) (User, error)
Update(user User) error
Delete(id int) error
List() ([]User, error)
}

// Только чтение — для сервисов, которые не должны писать
type UserReader interface {
GetByID(id int) (User, error)
List() ([]User, error)
}

type ReportService struct {
users UserReader // не может изменять пользователей!
}

func (rs ReportService) GenerateReport() {
users, _ := rs.users.List()
// генерирует отчёт
}

6. io.Reader и io.Writer — стандартные интерфейсы

// Чтение из разных источников
func ProcessInput(r io.Reader) error {
data, err := io.ReadAll(r)
if err != nil {
return err
}
// обработка данных
return nil
}

// Работает с файлом
file, _ := os.Open("data.txt")
ProcessInput(file)

// Работает со строкой
ProcessInput(strings.NewReader("hello"))

// Работает с буфером
buf := bytes.NewBufferString("world")
ProcessInput(buf)

// Работает с HTTP-запросом
func handler(w http.ResponseWriter, r *http.Request) {
ProcessInput(r.Body)
}

7. Стратегия (Strategy pattern)

type PaymentProcessor interface {
ProcessPayment(amount float64) error
}

type CreditCardProcessor struct{}
func (c CreditCardProcessor) ProcessPayment(amount float64) error {
// обработка кредитной карты
return nil
}

type PayPalProcessor struct{}
func (p PayPalProcessor) ProcessPayment(amount float64) error {
// обработка PayPal
return nil
}

type CryptoProcessor struct{}
func (c CryptoProcessor) ProcessPayment(amount float64) error {
// обработка криптовалюты
return nil
}

type Checkout struct {
processor PaymentProcessor
}

func (c *Checkout) CompleteOrder(amount float64) error {
return c.processor.ProcessPayment(amount)
}

// Выбор стратегии во время выполнения
func NewCheckout(method string) *Checkout {
switch method {
case "creditcard":
return &Checkout{processor: CreditCardProcessor{}}
case "paypal":
return &Checkout{processor: PayPalProcessor{}}
case "crypto":
return &Checkout{processor: CryptoProcessor{}}
default:
panic("unknown payment method")
}
}

8. io.Reader в цепочках (decorator pattern)

// Обёртки над Reader
func Compress(r io.Reader) io.Reader {
pr, pw := io.Pipe()
zw := gzip.NewWriter(pw)

go func() {
io.Copy(zw, r)
zw.Close()
pw.Close()
}()

return pr
}

func Encrypt(r io.Reader, key []byte) io.Reader {
// шифрование потока
}

// Композиция
file, _ := os.Open("data.txt")
compressed := Compress(file)
encrypted := Encrypt(compressed, key)
// данные текут через цепочку: file → compress → encrypt

Итог

Интерфейсы в Go используются для:

  • Полиморфизма — единый код для разных типов
  • Тестирования — подмена реальных зависимостей моками
  • Dependency Injection — гибкая конфигурация компонентов
  • Разделения ответственности — ограничение доступа через маленькие интерфейсы
  • Композиции — объединение интерфейсов в более сложные

Принцип: принимайте интерфейсы, возвращайте структуры (accept interfaces, return structs).

Вопрос 13. Как реализована конкурентная 模型 в Go? Какие компоненты она включает?

Таймкод: 00:22:30

Ответ собеседника: неполный. Кандидат упомянул, что конкурентность — одно из главных преимуществ Go. Назвал горутины (легковесные потоки), каналы как способ общения между горутинами и планировщик, который управляет горутинами. Не раскрыл подробности модели (GMP-модель, work stealing и т.д.).

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

Собеседник назвал основные компоненты, но не раскрыл внутреннее устройство. Разберём конкурентную модель Go подробно.

GMP-модель

Go использует трёхуровневую модель:

// G (Goroutine) — легковесный поток
type g struct {
stack stack // стек [2KB - 1GB]
stackguard0 uintptr // предел стека
m *m // текущий M (если запущена)
sched gobuf // контекст для переключения
status uint32 // состояние: Grunning, Grunnable, Gwaiting...
// ...
}

// M (Machine) — системный поток (OS thread)
type m struct {
g0 *g // горутины планировщика
curg *g // текущая горутина
p puintptr // привязанный P
// ...
}

// P (Processor) — процессор (контекст выполнения)
type p struct {
id int32
status uint32 // Pidle, Prunning, Psyscall...
m muintptr // привязанный M
runqhead uint32 // локальная очередь горутин
runqtail uint32
runq [256]guintptr // до 256 горутин
runnext guintptr // приоритетная горутина
// ...
}

Компоненты GMP:

  • G (Goroutine) — легковесный поток, стартовый стек ~2KB (растёт динамически).
  • M (Machine) — системный поток OС, обычно соответствует ядру CPU.
  • P (Processor) — контекст выполнения, количество по умолчанию = GOMAXPROCS.

Связь компонентов

┌─────────────────────────────────────────────────────────┐
│ Runtime Scheduler │
├─────────────────────────────────────────────────────────┤
│ Global Run Queue │
│ ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ │
│ │ G │ G │ G │ G │ G │ G │ G │ G │ G │ G │ G │ │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ │
├─────────────────────────────────────────────────────────┤
│ P0 │ P1 │ P2 │ P3 │
│ ┌────────┐ │ ┌────────┐ │ ┌────────┐ │ ┌─────┐ │
│ │Local │ │ │Local │ │ │Local │ │ │Local│ │
│ │Queue │ │ │Queue │ │ │Queue │ │ │Queue│ │
│ │G G G G │ │ │G G G │ │ │G G G G │ │ │G G │ │
│ └───┬────┘ │ └───┬────┘ │ └───┬────┘ │ └──┬──┘ │
│ │ │ │ │ │ │ │ │
│ ┌───▼────┐ │ ┌───▼────┐ │ ┌───▼────┐ │ ┌─▼──┐ │
│ │ M0 │ │ │ M1 │ │ │ M2 │ │ │ M3 │ │
│ │(OS │ │ │(OS │ │ │(OS │ │ │(OS │ │
│ │thread) │ │ │thread) │ │ │thread) │ │ │thr)│ │
│ └────────┘ │ └────────┘ │ └────────┘ │ └────┘ │
└─────────────────────────────────────────────────────────┘

Work Stealing — алгоритм балансировки

Когда P заканчивает свои горутины, он "ворует" у других:

// Упрощённая логика work stealing
func schedule() {
gp := runqget(_p_) // проверяем локальную очередь
if gp != nil {
return gp
}

// Проверяем runnext (приоритетная горутина)
if gp := runqget(_p_); gp != nil {
return gp
}

// Проверяем глобальную очередь
if gp := globrunqget(_p_, 0); gp != nil {
return gp
}

// Work stealing: воруем у другого P
for i := 0; i < 4; i++ {
gp := runqsteal(_p_, allp[fastrand()%uint32(gomaxprocs)])
if gp != nil {
return gp
}
}

// Ничего не нашли — паркуем M
stopm()
}

Состояния горутины

const (
_Gidle = iota // 0 — создана, но не запущена
_Grunnable // 1 — готова к выполнению
_Grunning // 2 — выполняется
_Gwaiting // 3 — ждёт (канал, мьютекс, GC)
_Gdead // 4 — завершена
_Gcopystack // 5 — стек растёт
_Gpreempted // 6 — вытеснена (Go 1.14+)
)

Переключение контекста

Переключение между горутинами дешевле, чем между потоками:

// Переключение горутин: ~200 наносекунд
// Сохраняется: SP, PC, BP, несколько регистров

// Переключение потоков: ~1-10 микросекунд
// Сохраняется: все регистры, TLB flush, cache pollution

Примеры работы с горутинами

// Запуск горутины
go func() {
fmt.Println("Hello from goroutine")
}()

// С аргументами
func process(id int) {
fmt.Printf("Processing %d\n", id)
}

for i := 0; i < 10; i++ {
go process(i)
}

// Ожидание завершения
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
process(id)
}(i)
}
wg.Wait()

Каналы — коммуникация между горутинами

// Небуферизованный канал — синхронный
ch := make(chan int)
go func() { ch <- 42 }() // блокируется до чтения
val := <-ch // блокируется до записи

// Буферизованный канал — асинхронный (до заполнения)
ch := make(chan int, 10)
ch <- 42 // не блокируется, пока буфер не полон

// Select — мультиплексирование
select {
case v1 := <-ch1:
fmt.Println("ch1:", v1)
case v2 := <-ch2:
fmt.Println("ch2:", v2)
case ch3 <- 42:
fmt.Println("sent to ch3")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready")
}

Preemption (вытеснение)

До Go 1.13: cooperative scheduling — горутина отдавала управление добровольно.

С Go 1.14+: asynchronous preemption через сигнал SIGURG:

// Горутина с долгим циклом будет вытеснена
func busyLoop() {
for {
// даже без точек вытеснения
// runtime прервёт через ~10ms
}
}

Настройка GOMAXPROCS

import "runtime"

func main() {
// По умолчанию = количество CPU
numCPU := runtime.NumCPU()
runtime.GOMAXPROCS(numCPU)

// Для CPU-bound задач: GOMAXPROCS = NumCPU
// Для I/O-bound задач: можно увеличить
}

Итог

КомпонентОписаниеКоличество
G (Goroutine)Легковесный потокМиллионы
M (Machine)Системный поток~GOMAXPROCS (по умолчанию)
P (Processor)Контекст выполненияGOMAXPROCS

Ключевые механизмы:

  • Work stealing — балансировка нагрузки между P.
  • Asynchronous preemption — вытеснение долгих горутин (Go 1.14+).
  • Growable stacks — стек горутины растёт от 2KB до 1GB.
  • M:N scheduling — M горутин на N потоках.

Вопрос 14. Можно ли управлять рантаймом Go? Чем можно управлять?

Таймкод: 00:23:25

Ответ собеседника: неполный. Кандидат отметил, что идеология Go — не лезть под капот. Сказал, что можно управлять количеством процессов (потоков ОС) через GOMAXPROCS, которое по умолчанию равно количеству ядер на машине. Не упомянул другие способы управления рантаймом.

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

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

1. GOMAXPROCS — количество потоков ОС

import "runtime"

func main() {
// Установить количество P (процессоров)
runtime.GOMAXPROCS(4)

// Узнать текущее значение
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = только запросить

// По умолчанию = runtime.NumCPU()
}

2. Управление памятью и GC

// Установить целевой процент роста кучи (по умолчанию 100)
// Больше значение → реже GC, но больше потребление памяти
debug.SetGCPercent(200)

// Установить лимит памяти (Go 1.19+)
// Ограничивает общий размер кучи
debug.SetMemoryLimit(1 << 30) // 1 GB

// Принудительный запуск GC
runtime.GC()

// Статистика по GC
var stats debug.GCStats
debug.ReadGCStats(&stats)
fmt.Printf("Last GC: %v\n", stats.LastGC)
fmt.Printf("Num GC: %d\n", stats.NumGC)
fmt.Printf("Pause Total: %v\n", stats.PauseTotal)

3. Статистика по памяти

var m runtime.MemStats
runtime.ReadMemStats(&m)

fmt.Printf("Alloc = %v MiB\n", m.Alloc / 1024 / 1024)
fmt.Printf("TotalAlloc = %v MiB\n", m.TotalAlloc / 1024 / 1024)
fmt.Printf("Sys = %v MiB\n", m.Sys / 1024 / 1024)
fmt.Printf("NumGC = %v\n", m.NumGC)
fmt.Printf("HeapObjects = %v\n", m.HeapObjects)

4. Управление горутинами

// Количество запущенных горутин
numGoroutines := runtime.NumGoroutine()
fmt.Printf("Running goroutines: %d\n", numGoroutines)

// Завершить текущую горутину
runtime.Goexit()

// Блокировка текущей горутины (не потока!)
runtime.Gosched() // уступить управление другим горутинам

5. Профилирование

// CPU профилирование
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()

// Heap профилирование
f, _ := os.Create("mem.prof")
pprof.WriteHeapProfile(f)

// Goroutine профилирование
f, _ := os.Create("goroutine.prof")
pprof.Lookup("goroutine").WriteTo(f, 0)

6. Переменные окружения для отладки

# Подробный вывод GC
GODEBUG=gctrace=1 ./app

# Пример вывода:
# gc 1 @0.005s 0%: 0.018+0.46+0.005 ms clock, 0.075+0.11/0.36/0.94+0.023 ms cpu, 4->4->0 MB, 5 MB goal, 8 P

# Трассировка планировщика
GODEBUG=schedtrace=1000,scheddetail=1 ./app

# Пример вывода:
# SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=12 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]

# Отключить параллельный GC
GOMAXPROCS=1 ./app

# Увидеть все доступные настройки
GODEBUG=help ./app

7. runtime/debug — дополнительные настройки

import "runtime/debug"

// Установить мягкий лимит стека для горутины
debug.SetMaxStack(1 << 20) // 1 MB

// Установить максимальное количество потоков ОС
// При превышении — fatal error
debug.SetMaxThreads(10000)

// Включить или отключить вытеснение (Go 1.14+)
// По умолчанию включено

8. Трассировка выполнения (execution tracer)

import "runtime/trace"

f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()

// Анализ: go tool trace trace.out

9. Управление финализаторами

type Resource struct {
handle int
}

func NewResource() *Resource {
r := &Resource{handle: 1}
runtime.SetFinalizer(r, func(r *Resource) {
fmt.Println("Resource finalized")
closeResource(r.handle)
})
return r
}

// Отменить финализатор
runtime.SetFinalizer(r, nil)

10. LockOSThread — привязка к потоку

// Привязать горутину к текущему потоку ОС
// Полезно для GUI, OpenGL, CGO
runtime.LockOSThread()
defer runtime.UnlockOSThread()

// Теперь эта горутина ВСЕГДА выполняется в одном потоке

Практический пример: мониторинг рантайма

func startRuntimeMonitor() {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()

for range ticker.C {
var m runtime.MemStats
runtime.ReadMemStats(&m)

log.Printf(
"Goroutines: %d, Heap: %d MB, GC: %d, Pause: %v",
runtime.NumGoroutine(),
m.Alloc/1024/1024,
m.NumGC,
m.PauseNs[(m.NumGC+255)%256],
)
}
}()
}

Итог

АспектСпособ управления
Потоки ОСruntime.GOMAXPROCS()
GCdebug.SetGCPercent(), debug.SetMemoryLimit()
Памятьruntime.ReadMemStats()
Горутиныruntime.NumGoroutine(), runtime.Gosched()
Профилированиеruntime/pprof, runtime/trace
ОтладкаGODEBUG переменная окружения
Потокruntime.LockOSThread()

Go предоставляет достаточно инструментов для мониторинга и тонкой настройки рантайма, не требуя вмешательства в его внутренности.

Вопрос 15. Как работает сборщик мусора в Go?

Таймкод: 00:24:50

Ответ собеседника: правильный. Сборщик мусора в Go использует алгоритм пометки и выметания (mark and sweep) с трицветной маркировкой (чёрный, серый, белый). Объекты, которые нужно удалить, помечаются, а затем удаляются.

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

Ответ собеседника верен в целом. Дополним техническими деталями о реализации Go.

Трицветная маркировка

// Три цвета объектов:
// Белый — потенциально мусор (пока не проверен)
// Серый — достижим, но его потомки ещё не проверены
// Чёрный — достижим и все потомки проверены

// Инвариант: чёрный объект не может указывать на белый
// (write barrier обеспечивает это)

Фазы работы GC

// 1. Mark Setup (STW) — остановка мира, инициализация
// - Включить write barrier
// - Подготовить корневые объекты (стеки, глобальные переменные)

// 2. Marking — параллельная пометка (concurrent)
// - Обход графа объектов от корней
// - Горутины продолжают работать

// 3. Mark Termination (STW) — остановка мира, завершение
// - Отключить write barrier
// - Допометить оставшиеся серые объекты

// 4. Sweep — параллельная уборка (concurrent)
// - Освобождение белых объектов
// - Горутины продолжают работать

Write Barrier — барьер записи

Позволяет GC работать параллельно с горутинами:

// При записи указателя в объект:
func writeBarrier(slot *unsafe.Pointer, ptr unsafe.Pointer) {
// Если объект чёрный, а новый указатель на белый —
// пометить белый объект серым
if isBlack(slot) && isWhite(ptr) {
shade(ptr) // пометить серым
}
*slot = ptr
}

Когда запускается GC

// Основной триггер: размер кучи удвоился с момента последней сборки

// Формула:
// triggerRatio = (heap_goal - heap_live) / heap_live
// где heap_goal = heap_live_at_last_GC * (1 + GCPercent/100)

// По умолчанию GCPercent = 100
// Значит, если куча была 4 MB, следующая сборка при ~8 MB

GOGC и GOMEMLIMIT

// GOGC — процент роста кучи (по умолчанию 100)
// Можно установить через переменную окружения или код
debug.SetGCPercent(100) // удвоение кучи
debug.SetGCPercent(200) // утроение кучи (реже GC, больше память)
debug.SetGCPercent(50) // рост на 50% (чаще GC, меньше память)

// GOMEMLIMIT — жёсткий лимит памяти (Go 1.19+)
debug.SetMemoryLimit(1 << 30) // 1 GB
// GC будет чаще, чтобы не превысить лимит

Pacing — расчёт времени следующей сборки

// GC старается закончить пометку до достижения heap_goal
// Целевая длительность паузы: ~100 микросекунд

// Адаптивная настройка:
// Если куча растёт быстро → запускать GC чаще
// Если куча растёт медленно → запускать GC реже

Эволюция GC в Go

// Go 1.0 - 1.3: Stop-the-world mark and sweep
// Go 1.4: Concurrent mark, STW sweep
// Go 1.5: Fully concurrent mark and sweep
// Go 1.6: Sub-millisecond STW pauses
// Go 1.8: Sub-millisecond STW pauses (hybrid write barrier)
// Go 1.12: Smarter scavenging
// Go 1.14: Page allocator rewrite
// Go 1.19: Soft memory limit (GOMEMLIMIT)

Практический пример: мониторинг GC

func monitorGC() {
var lastNumGC uint32

for {
var m runtime.MemStats
runtime.ReadMemStats(&m)

if m.NumGC != lastNumGC {
pauseNs := m.PauseNs[(m.NumGC+255)%256]
log.Printf(
"GC #%d: pause %v, heap %d MB → %d MB",
m.NumGC,
pauseNs,
m.HeapAlloc/1024/1024,
m.HeapInuse/1024/1024,
)
lastNumGC = m.NumGC
}

time.Sleep(time.Second)
}
}

Оптимизация работы с GC

// Плохо: множественные аллокации
func process(items []Item) []Result {
var results []Result
for _, item := range items {
results = append(results, Result{
Data: make([]byte, 1024), // аллокация в каждой итерации
})
}
return results
}

// Хороше: sync.Pool для переиспользования
var resultPool = sync.Pool{
New: func() interface{} {
return &Result{Data: make([]byte, 1024)}
},
}

func process(items []Item) {
for _, item := range items {
r := resultPool.Get().(*Result)
defer resultPool.Put(r)
// используем r
}
}

Итог

GC в Go — concurrent, tri-color mark and sweep с двумя короткими STW-фазами. Основные характеристики:

  • Concurrent marking — пометка параллельно с горутинами
  • Write barrier — обеспечивает корректность при параллельной работе
  • Pacing — адаптивный расчёт частоты сборок
  • Целевая пауза — ~100 микросекунд
  • НастройкаGOGC и GOMEMLIMIT

Вопрос 16. Что такое горутина? Сколько она весит? Сколько весит обычный поток?

Таймкод: 00:25:13

Ответ собеседния: правильный. Горутина — это легковесный поток, который весит примерно 2 КБ стека (изначально). Обычный поток весит значительно больше (порядка мегабайт), зависит от архитектуры и настроек.

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

Ответ собеседника верен. Дополним деталями о структуре и динамическом росте стека.

Структура горутины

type g struct {
stack stack // границы стека [lo, hi]
stackguard0 uintptr // предел стека для проверки
stackguard1 uintptr

m *m // текущий M (если запущена)
sched gobuf // контекст для переключения
status uint32 // Grunning, Grunnable, Gwaiting...

// Для стека:
stackguard0 указывает на предел, при превышении которого
происходит stack growth (удвоение размера)
}

Размер стека горутины

// Изначально: 2 KB (с Go 1.4+)
// До Go 1.4: 8 KB

// Максимум: 1 GB (на 64-битных системах)

// Стек растёт динамически:
// 2KB → 4KB → 8KB → 16KB → ... → 1GB

Сравнение с потоками ОС

// Linux pthread (по умолчанию):
// - Стек: 8 MB (ulimit -s)
// - Создание: ~10-100 микросекунд
// - Переключение контекста: ~1-10 микросекунд

// Go goroutine:
// - Стек: 2 KB (начальный)
// - Создание: ~200 наносекунд
// - Переключение контекста: ~200 наносекунд

Практическая разница

func main() {
// Можно запустить миллионы горутин
for i := 0; i < 1_000_000; i++ {
go func() {
time.Sleep(time.Hour)
}()
}

// 1M горутин × 2KB = ~2 GB виртуальной памяти
// (реально меньше, т.к. стек растёт по необходимости)

// 1M потоков × 8MB = ~8 TB — невозможно!

time.Sleep(time.Second)
fmt.Println(runtime.NumGoroutine())
}

Как работает рост стека

// При вызове функции проверяется stackguard0
func morestack() {
// 1. Выделить новый стек (в 2 раза больше)
// 2. Скопировать данные из старого стека
// 3. Обновить все указатели
// 4. Освободить старый стек
}

// С Go 1.3+: contiguous stacks (непрерывные стеки)
// Вместо segmented stacks (сегментированных)

Stack splitting vs stack copying

// Go 1.2: Segmented stacks
// Стек состоял из связанных сегментов
// Проблема: hot split (частое выделение/освобождение)

// Go 1.3+: Stack copying
// При нехватке места — копируем в новый, больший стек
// Решает проблему hot split

Измерение размера стека

func printStackUsage() {
var buf [64]byte
n := runtime.Stack(buf[:], false)
fmt.Printf("Stack trace:\n%s\n", buf[:n])
}

func recursiveFunc(depth int) {
if depth == 0 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Stack in use: ~%d bytes\n", m.StackInuse)
return
}
recursiveFunc(depth - 1)
}

Итог

ПараметрGoroutineOS Thread
Начальный стек2 KB8 MB
Максимальный стек1 GBОбычно 8 MB (фиксирован)
Создание~200 ns~10-100 μs
Переключение~200 ns~1-10 μs
КоличествоМиллионыТысячи
Рост стекаДинамическийФиксированный

Горутины позволяют Go эффективно обрабатывать сотни тысяч конкурентных задач с минимальными накладными расходами на память.

Вопрос 17. Что такое deadlock в контексте горутин?

Таймкод: 00:26:16

Ответ собеседника: правильный. Deadlock — это ситуация, когда горутины блокируют друг друга в ожидании ресурсов, создавая взаимное ожидание, из которого невозможно выйти.

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

Ответ собеседника верен. Рассмотрим различные виды deadlock и способы их предотвращения.

Классический deadlock с каналами

// Deadlock: обе горутины ждут друг друга
func deadlockExample() {
ch1 := make(chan int)
ch2 := make(chan int)

go func() {
ch1 <- 1 // ждём, пока прочитают из ch1
<-ch2 // потом читаем из ch2
}()

go func() {
ch2 <- 2 // ждём, пока прочитают из ch2
<-ch1 // потом читаем из ch1
}()

// Обе горутины заблокированы — deadlock!
time.Sleep(time.Second)
}

Deadlock с мьютексами

func mutexDeadlock() {
var mu1, mu2 sync.Mutex

go func() {
mu1.Lock()
time.Sleep(time.Millisecond)
mu2.Lock() // ждём mu2
mu2.Unlock()
mu1.Unlock()
}()

go func() {
mu2.Lock()
time.Sleep(time.Millisecond)
mu1.Lock() // ждём mu1 — deadlock!
mu1.Unlock()
mu2.Unlock()
}()

time.Sleep(time.Second)
}

Deadlock с unbuffered каналом

func channelDeadlock() {
ch := make(chan int)

// Отправляем в канал, но никто не читает
ch <- 42 // блокировка навсегда — goroutine leak

// Или наоборот
val := <-ch // блокировка навсегда — никто не пишет
}

Условия возникновения deadlock (Coffman conditions)

// Все четыре условия должны выполняться одновременно:
// 1. Mutual Exclusion — ресурс занят одним владельцем
// 2. Hold and Wait — горутина держит ресурс и ждёт другой
// 3. No Preemption — ресурс нельзя отобрать
// 4. Circular Wait — циклическая зависимость

Способы предотвращения

1. Порядок блокировок

// Плохо: разный порядок
go func() {
mu1.Lock()
mu2.Lock()
// ...
}()

go func() {
mu2.Lock()
mu1.Lock() // deadlock!
// ...
}()

// Хорошо: одинаковый порядок
go func() {
mu1.Lock()
mu2.Lock()
// ...
mu2.Unlock()
mu1.Unlock()
}()

go func() {
mu1.Lock()
mu2.Lock() // тот же порядок — нет deadlock
// ...
mu2.Unlock()
mu1.Unlock()
}()

2. Таймауты

func withTimeout() {
ch := make(chan int)

select {
case val := <-ch:
fmt.Println("Received:", val)
case <-time.After(5 * time.Second):
fmt.Println("Timeout!")
}
}

3. Буферизованные каналы

// Плохо: unbuffered
ch := make(chan int)
ch <- 1 // блокируется, если нет reader

// Хорошо: буферизованный
ch := make(chan int, 10)
ch <- 1 // не блокируется, пока буфер не полон

4. Context с отменой

func withContext(ctx context.Context) error {
ch := make(chan Result)

go func() {
result, err := doWork()
if err != nil {
ch <- Result{err: err}
return
}
ch <- Result{data: result}
}()

select {
case result := <-ch:
return result.err
case <-ctx.Done():
return ctx.Err() // timeout или cancel
}
}

5. sync.WaitGroup с таймаутом

func waitWithTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()

select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}

Обнаружение deadlock в Go

// Go runtime обнаруживает deadlock когда ВСЕ горутины заблокированы
// и выбрасывает fatal error:

func main() {
ch := make(chan int)
<-ch // fatal: all goroutines are asleep - deadlock!
}

Практический пример: worker pool без deadlock

func workerPool(jobs []Job, numWorkers int) []Result {
jobCh := make(chan Job, len(jobs))
resultCh := make(chan Result, len(jobs))

// Запускаем workers
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobCh {
resultCh <- process(job)
}
}()
}

// Отправляем jobs
for _, job := range jobs {
jobCh <- job
}
close(jobCh) // сигнал завершения

// Ждём завершения workers
wg.Wait()
close(resultCh) // закрываем после завершения всех workers

// Собираем результаты
var results []Result
for result := range resultCh {
results = append(results, result)
}

return results
}

Итог

Deadlock возникает при взаимном ожидании ресурсов. Способы предотвращения:

  • Единый порядок блокировок мьютексов
  • Таймауты через select + time.After
  • Буферизованные каналы для снижения блокировок
  • Context для контроля времени выполнения
  • Правильное закрытие каналов (close только от sender)

Вопрос 18. Как можно завершить выполнение горутин?

Таймкод: 00:26:55

Ответ собеседника: правильный. Можно использовать контекст с отменой (context.WithCancel), передавая его в горутину и вызывая cancel. Также можно создать канал для сигнала завершения. Есть вариант с runtime.Goexit(), но кандидат отметил, что это тоже горутина и не решит задачу принудительного завершения других горутин.

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

Ответ собеседника полностью верен. Дополним примерами и нюансами.

Важно: в Go нет принудительного завершения горутин

Go не предоставляет механизма для принудительного убийства горутины. Горутина должна сама проверить сигнал завершения. Это осознанное решение — принудительное завершение могло бы оставить ресурсы в неконсистентном состоянии.

1. Context с отменой — рекомендуемый способ

func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Worker stopped:", ctx.Err())
return
default:
// основная работа
doWork()
}
}
}

func main() {
ctx, cancel := context.WithCancel(context.Background())

go worker(ctx)
go worker(ctx)

time.Sleep(5 * time.Second)
cancel() // сигнал завершения всем воркерам

time.Sleep(time.Second) // ждём завершения
}

2. Context с таймаутом

func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
doWork()
}
}
}

func main() {
// Автоматическая отмена через 10 секунд
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

go worker(ctx)

<-ctx.Done() // ждём завершения
}

3. Канал для сигнала завершения

func worker(done chan struct{}) {
for {
select {
case <-done:
fmt.Println("Worker stopped")
return
default:
doWork()
}
}
}

func main() {
done := make(chan struct{})

go worker(done)
go worker(done)

time.Sleep(5 * time.Second)
close(done) // сигнал завершения — все горутины получат уведомление

time.Sleep(time.Second)
}

4. Закрытие канала данных

func consumer(dataCh <-chan int) {
for data := range dataCh {
process(data)
}
fmt.Println("Consumer stopped: channel closed")
}

func main() {
dataCh := make(chan int)

go consumer(dataCh)

for i := 0; i < 100; i++ {
dataCh <- i
}
close(dataCh) // сигнал завершения

time.Sleep(time.Second)
}

5. runtime.Goexit() — завершение текущей горутины

func worker() {
defer fmt.Println("Worker cleanup")

for {
if shouldStop() {
runtime.Goexit() // завершает текущую горутину
}
doWork()
}
}

6. Комбинированный подход с очисткой ресурсов

func worker(ctx context.Context) {
// Ресурсы, которые нужно очистить
db := connectDB()
defer db.Close()

file := openFile()
defer file.Close()

for {
select {
case <-ctx.Done():
fmt.Println("Cleaning up...")
// defer выполнится автоматически
return
case job := <-jobCh:
process(db, file, job)
}
}
}

7. Ожидание завершения нескольких горутин

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, id)
}(i)
}

// Сигнал завершения
time.Sleep(5 * time.Second)
cancel()

// Ждём завершения всех горутин
wg.Wait()
fmt.Println("All workers stopped")
}

Антипаттерн: горутина-зомбие (goroutine leak)

// ПЛОХО: горутина никогда не завершится
func leakyWorker() {
ch := make(chan int)
go func() {
for val := range ch {
process(val)
}
}()

// ch никогда не закроется — горутина утечёт!
}

// ХОРОШО: всегда закрывайте каналы
func goodWorker() {
ch := make(chan int)
go func() {
for val := range ch {
process(val)
}
}()

defer close(ch) // гарантируем закрытие
}

Итог

СпособКогда использовать
context.WithCancel()Дерево горутин, передача через API
context.WithTimeout()Ограничение времени выполнения
close(ch)Простые случаи, broadcast сигнал
runtime.Goexit()Завершение текущей горутины

Принцип: горутина должна сама решить завершиться при получении сигнала. Принудительное завершение невозможно и небезопасно.

Вопрос 19. Что такое канал в Go? Какие операции с ним можно выполнять?

Таймкод: 00:27:44

Ответ собеседника: правильный. Канал — это средство для общения между горутинами, напоминающее очередь. Он потокобезопасен, бывает буферизированным и небуферизированным. С каналом можно выполнять операции: запись, чтение, закрытие. При чтении из закрытого канала получается нулевое значение типа. Чтобы отличить нулевое значение от закрытого канала, используется синтаксис с дополнительной переменной (value, ok := <-ch), аналогично мапам.

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

Ответ собеседника полностью верен. Дополним техническими деталями и дополнительными операциями.

Устройство канала под капотом

type hchan struct {
qcount uint // количество элементов в очереди
dataqsiz uint // размер буфера
buf unsafe.Pointer // кольцевой буфер
sendx uint // индекс для записи
recvx uint // индекс для чтения

recvq waitq // очередь ожидающих чтения
sendq waitq // очередь ожидающей записи

lock mutex // мьютекс для синхронизации
}

Создание каналов

// Небуферизованный канал (синхронный)
ch1 := make(chan int)

// Буферизованный канал (асинхронный до заполнения)
ch2 := make(chan int, 10)

// Канал только для чтения (в сигнатуре функции)
func reader(ch <-chan int) {}

// Канал только для записи (в сигнатуре функции)
func writer(ch chan<- int) {}

Основные операции

ch := make(chan int, 5)

// Запись
ch <- 42

// Чтение
val := <-ch

// Чтение с проверкой закрытия
val, ok := <-ch // ok == false если канал закрыт

// Закрытие (только sender должен закрывать!)
close(ch)

Небуферизованный vs буферизованный

// Небуферизованный: запись блокируется до чтения
ch := make(chan int)
go func() { ch <- 42 }() // блокируется до чтения
val := <-ch // разблокирует запись

// Буферизованный: запись не блокируется пока буфер не полон
ch := make(chan int, 3)
ch <- 1 // не блокируется
ch <- 2 // не блокируется
ch <- 3 // не блокируется
// ch <- 4 // заблокируется — буфер полон

Range по каналу

func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}

func consumer(ch <-chan int) {
// range автоматически завершается при закрытии канала
for val := range ch {
fmt.Println(val)
}
}

Select — мультиплексирование каналов

func multiplex() {
ch1 := make(chan int)
ch2 := make(chan int)

go func() {
for {
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
}
}()
}

Операция len() и cap()

ch := make(chan int, 10)
ch <- 1
ch <- 2

fmt.Println(len(ch)) // 2 — количество элементов в буфере
fmt.Println(cap(ch)) // 10 — размер буфера

Поведение при закрытии

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

// Можно читать оставшиеся значения
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 0 (zero value), канал закрыт

// Проверка закрытия
val, ok := <-ch // val = 0, ok = false

// Запись в закрытый канал — panic!
// ch <- 42 // panic: send on closed channel

// Двойное закрытие — panic!
// close(ch) // panic: close of closed channel

Практический пример: pipeline

func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}

func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}

// Использование
c := generate(2, 3, 4, 5)
out := square(square(c))
for n := range out {
fmt.Println(n) // 16, 81, 256, 625
}

Итог

ОперацияСинтаксисОписание
Созданиеmake(chan T, size)Буфер 0 если не указан
Записьch <- valБлокируется при заполнении
Чтениеval := <-chБлокируется при пустоте
Проверкаval, ok := <-chok=false если закрыт
Закрытиеclose(ch)Только от sender
Размерlen(ch)Элементы в буфере
Ёмкостьcap(ch)Размер буфера

Вопрос 20. Что такое контекст в Go? Для чего он используется? Что стоит и не стоит хранить в контексте?

Таймкод: 00:29:59

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

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

Ответ собеседника полностью верен. Дополним техническими деталями и примерами.

Интерфейс context.Context

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

Типы контекстов

// 1. Background — корневой контекст, никогда не отменяется
ctx := context.Background()

// 2. TODO — заглушка, когда не знаете какой контекст использовать
ctx := context.TODO()

// 3. WithCancel — ручная отмена
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 4. WithTimeout — автоматическая отмена по таймауту
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// 5. WithDeadline — отмена в конкретное время
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

// 6. WithValue — передача значений
ctx := context.WithValue(context.Background(), "requestID", "abc123")

Каскадная отмена

func main() {
ctx, cancel := context.WithCancel(context.Background())

// Создаём дерево контекстов
child1, cancel1 := context.WithCancel(ctx)
child2, cancel2 := context.WithCancel(ctx)

grandchild, _ := context.WithCancel(child1)

go worker(child1, "child1")
go worker(child2, "child2")
go worker(grandchild, "grandchild")

time.Sleep(2 * time.Second)
cancel1() // отменяет child1 И grandchild

time.Sleep(1 * time.Second)
cancel() // отменяет всё
}

func worker(ctx context.Context, name string) {
<-ctx.Done()
fmt.Printf("%s cancelled: %v\n", name, ctx.Err())
}

Передача значений

type contextKey string // свой тип для ключей — избегаем коллизий

const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)

func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Добавляем значения в контекст
ctx = context.WithValue(ctx, requestIDKey, generateRequestID())
ctx = context.WithValue(ctx, userIDKey, extractUserID(r))

next.ServeHTTP(w, r.WithContext(ctx))
})
}

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Извлекаем значения
requestID := ctx.Value(requestIDKey).(string)
userID := ctx.Value(userIDKey).(int)

// Используем в логировании
log.Printf("[%s] User %d: processing request", requestID, userID)
}

Что стоит хранить в контексте

// ХОРОШО: метаданные запроса
ctx = context.WithValue(ctx, "requestID", "abc-123")
ctx = context.WithValue(ctx, "traceID", "trace-456")
ctx = context.WithValue(ctx, "userID", 42)
ctx = context.WithValue(ctx, "authToken", "jwt-token")

// ХОРОШО: настройки трассировки
ctx = context.WithValue(ctx, "samplingRate", 0.1)

Что НЕ стоит хранить в контексте

// ПЛОХО: большие структуры
ctx = context.WithValue(ctx, "user", User{ // не делать!
ID: 42,
Name: "John",
Email: "john@example.com",
// ... ещё 20 полей
})

// ПЛОХО: зависимости сервиса
ctx = context.WithValue(ctx, "db", db) // не делать!
ctx = context.WithValue(ctx, "cache", redisClient) // не делать!

// ПЛОХО: параметры функций
ctx = context.WithValue(ctx, "pageSize", 10) // передайте явно!
ctx = context.WithValue(ctx, "filter", filter) // передайте явно!

Правильная альтернатива

// Вместо хранения в контексте — передавайте явно
func ProcessUser(ctx context.Context, userID int, pageSize int) error {
// ctx — только для отмены и метаданных
// userID, pageSize — явные параметры
}

// Вместо хранения сервисов — используйте замыкания или структуры
type UserService struct {
db *sql.DB
cache *redis.Client
}

func (s *UserService) ProcessUser(ctx context.Context, userID int) error {
// Сервисы доступны через структуру
}

Практический пример: HTTP-сервер с таймаутом

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", withTimeout(handleData, 5*time.Second))

server := &http.Server{
Addr: ":8080",
Handler: mux,
}

server.ListenAndServe()
}

func withTimeout(h http.HandlerFunc, timeout time.Duration) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()

r = r.WithContext(ctx)

done := make(chan struct{})
go func() {
h(w, r)
close(done)
}()

select {
case <-done:
return
case <-ctx.Done():
http.Error(w, "Request timeout", http.StatusGatewayTimeout)
return
}
}
}

func handleData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Долгая операция с учётом контекста
result, err := fetchData(ctx)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
http.Error(w, "Timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

json.NewEncoder(w).Encode(result)
}

func fetchData(ctx context.Context) (*Data, error) {
// Передаём контекст в БД
row := db.QueryRowContext(ctx, "SELECT ...")
// ...
}

Итекст в цепочке вызовов

func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Таймаут на всю операцию
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

// Передаём вниз по стеку вызовов
user, err := userService.GetUser(ctx, userID)
if err != nil {
return
}

orders, err := orderService.GetOrders(ctx, user.ID)
if err != nil {
return
}

// При отмене ctx — все операции прервутся
}

Итог

АспектРекомендация
ОтменаОсновное назначение контекста
ТаймаутыWithTimeout(), WithDeadline()
МетаданныеrequestID, traceID, userID
Большие структурыНе хранить — передавать явно
Сервисы/зависимостиНе хранить — использовать DI
Параметры функцийНе хранить — передавать явно

Вопрос 21. Что такое error в Go? Это структура или интерфейс?

Таймкод: 00:31:55

Ответ собеседника: правильный. Error в Go — это интерфейс, а не структура. Он реализует один метод Error() string. В Go почти всё является структурой, но error — это интерфейс.

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

Ответ собеседника верен. Дополним деталями о работе с ошибками в Go.

Интерфейс error

type error interface {
Error() string
}

Стандартная реализация — errors.errorString

// Из пакета errors
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

func New(text string) error {
return &errorString{text}
}

Создание ошибок

// 1. Простая ошибка
err := errors.New("something went wrong")

// 2. Форматированная ошибка
err := fmt.Errorf("user %d not found", userID)

// 3. Своя структура ошибки
type ValidationError struct {
Field string
Message string
}

func (e ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}

err := ValidationError{Field: "email", Message: "invalid format"}

Проверка ошибок

// Проверка на nil
if err != nil {
return err
}

// Проверка типа
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println("Field:", validationErr.Field)
}

// Проверка на конкретную ошибку
if errors.Is(err, ErrNotFound) {
// обработка not found
}

Обёртывание ошибок (Go 1.13+)

// Обёртывание с контекстом
func getUser(id int) (*User, error) {
user, err := db.Query("SELECT ...")
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err) // %w для обёртывания
}
return user, nil
}

// Разворачивание цепочки
func main() {
_, err := getUser(42)
if err != nil {
fmt.Println(err) // "get user 42: connection refused"

// Проверка на конкретную ошибку в цепочке
if errors.Is(err, sql.ErrNoRows) {
// обработка
}

// Извлечение конкретного типа
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Println("DB Code:", dbErr.Code)
}
}
}

Sentinel errors — предопределённые ошибки

// Пакет объявляет ошибки
var (
ErrNotFound = errors.New("not found")
ErrExists = errors.New("already exists")
ErrInvalid = errors.New("invalid input")
)

// Использование
func FindUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrInvalid
}
// ...
return nil, ErrNotFound
}

// Проверка
user, err := FindUser(42)
if errors.Is(err, ErrNotFound) {
// обработка
}

Паттерн: ошибка как часть возвращаемого значения

// Функция возвращает (result, error)
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}

// Использование
result, err := Divide(10, 0)
if err != nil {
log.Printf("Error: %v", err)
return
}

Итог

  • error — интерфейс с методом Error() string
  • Стандартная реализация — errors.errorString (структура)
  • Для обёртывания используйте %w в fmt.Errorf
  • Для проверки — errors.Is() и errors.As()
  • Sentinel errors — предопределённые переменные ошибок

Вопрос 22. Что такое дженерики в Go? Для чего они нужны и как работают?

Таймкод: 00:33:02

Ответ собеседника: правильный. Дженерики позволяют писать обобщённый код, работающий с разными типы без дублироваования. Можно передавать общий тип и задавать ограничения (constraints) на типы. Это позволяет создавать универсальные обработчики для разных типов объектов.

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

Ответ собеседника верен. Дополним синтаксисом и практическими примерами.

Синтаксис дженериков

// Функция с параметром типа
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

// Вызов с явным указанием типа
result := Min[int](3, 5)

// Вывод типа автоматически
result := Min(3, 5) // int
result := Min(3.14, 2.7) // float64

Type constraints (ограничения типов)

// Ограничение через интерфейс
func Print[T fmt.Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}

// Встроенные constraints из golang.org/x/exp/constraints
import "golang.org/x/exp/constraints"

func Sum[T constraints.Integer | constraints.Float](nums []T) T {
var sum T
for _, n := range nums {
sum += n
}
return sum
}

// Собственный constraint
type StringerError interface {
error
String() string
}

func HandleError[T StringerError](err T) {
fmt.Println(err.String())
}

Дженерик-типы

// Обобщённая структура
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}

// Использование
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)

strStack := Stack[string]{}
strStack.Push("hello")

Практические примеры

1. Map/Filter/Reduce

func Map[T, U any](items []T, fn func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = fn(item)
}
return result
}

func Filter[T any](items []T, fn func(T) bool) []T {
var result []T
for _, item := range items {
if fn(item) {
result = append(result, item)
}
}
return result
}

func Reduce[T, U any](items []T, initial U, fn func(U, T) U) U {
result := initial
for _, item := range items {
result = fn(result, item)
}
return result
}

// Использование
numbers := []int{1, 2, 3, 4, 5}

doubled := Map(numbers, func(n int) int { return n * 2 })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })

2. Ключ по любому comparable типу

type Cache[K comparable, V any] struct {
data map[K]V
mu sync.RWMutex
}

func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}

func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}

// Использование
intCache := NewCache[int, string]()
intCache.Set(42, "answer")

strCache := NewCache[string, *User]()
strCache.Set("john", &User{Name: "John"})

3. Работа с указателями

func PtrTo[T any](val T) *T {
return &val
}

// Использование
name := PtrTo("John") // *string
age := PtrTo(42) // *int

4. Обобщённый HTTP-клиент

func GetJSON[T any](url string) (T, error) {
var result T

resp, err := http.Get(url)
if err != nil {
return result, err
}
defer resp.Body.Close()

if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, err
}

return result, nil
}

// Использование
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}

user, err := GetJSON[User]("https://api.example.com/users/1")

Ограничения дженериков

// Нельзя использовать параметры типа в:
// - константах
// - методах не-дженерик типов
// - размере массива

// Нельзя:
const size = T(42) // ошибка
var arr [T]int // ошибка

// Можно:
type Container[T any] struct {
items []T // OK
}

Итог

Дженерики в Go (с версии 1.18):

  • Позволяют писать типобезопасный обобщённый код
  • Синтаксис: func Name[T Constraint](params) returnType
  • Constraint any — любой тип
  • Constraint comparable — типы с поддержкой ==
  • Встроенные constraints: constraints.Ordered, constraints.Integer, constraints.Float
  • Устраняют необходимость использовать interface{} в типобезопасном коде

Вопрос 23. Как бы вы реализовали переход с одной базы данных на другую без дублирования кода — с помощью дженериков или интерфейсов?

Таймкод: 00:33:49

Ответ собеседника: правильный. Кандидат предложил использовать интерфейс репозитория, который можно подменять в нужном месте. Это корректный подход, основанный на полиморфизме. Дженерики здесь менее подходят, так как различие в реализации баз данных лучше абстрагировать через интерфейс.

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

Собеседник правильно определил, что интерфейсы — правильный инструмент для этой задачи. Рассмотрим полную реализацию.

Интерфейс репозитория

type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
List(ctx context.Context, filter Filter) ([]User, error)
}

Реализация для PostgreSQL

type PostgresUserRepo struct {
db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) GetByID(ctx context.Context, id int) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &user, nil
}

func (r *PostgresUserRepo) Create(ctx context.Context, user *User) error {
return r.db.QueryRowContext(ctx,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
user.Name, user.Email,
).Scan(&user.ID)
}

func (r *PostgresUserRepo) Update(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
user.Name, user.Email, user.ID,
)
return err
}

func (r *PostgresUserRepo) Delete(ctx context.Context, id int) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
return err
}

func (r *PostgresUserRepo) List(ctx context.Context, filter Filter) ([]User, error) {
// PostgreSQL-специфичная реализация
query := "SELECT id, name, email FROM users WHERE 1=1"
var args []interface{}

if filter.Name != "" {
query += " AND name ILIKE $1"
args = append(args, "%"+filter.Name+"%")
}

rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()

return scanUsers(rows)
}

Реализация для MongoDB

type MongoUserRepo struct {
collection *mongo.Collection
}

func NewMongoUserRepo(db *mongo.Database) *MongoUserRepo {
return &MongoUserRepo{collection: db.Collection("users")}
}

func (r *MongoUserRepo) GetByID(ctx context.Context, id int) (*User, error) {
var user User
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &user, nil
}

func (r *MongoUserRepo) Create(ctx context.Context, user *User) error {
result, err := r.collection.InsertOne(ctx, user)
if err != nil {
return err
}
user.ID = result.InsertedID.(int)
return nil
}

func (r *MongoUserRepo) Update(ctx context.Context, user *User) error {
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": user.ID}, user)
return err
}

func (r *MongoUserRepo) Delete(ctx context.Context, id int) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}

func (r *MongoUserRepo) List(ctx context.Context, filter Filter) ([]User, error) {
mongoFilter := bson.M{}
if filter.Name != "" {
mongoFilter["name"] = bson.M{"$regex": filter.Name, "$options": "i"}
}

cursor, err := r.collection.Find(ctx, mongoFilter)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)

var users []User
if err := cursor.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}

Сервис работает с интерфейсом

type UserService struct {
repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}

func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}

func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
user := &User{Name: name, Email: email}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return user, nil
}

Конфигурация и подключение

func main() {
var repo UserRepository

switch config.DBType {
case "postgres":
db, _ := sql.Open("postgres", config.PostgresDSN)
repo = NewPostgresUserRepo(db)
case "mongo":
client, _ := mongo.Connect(context.Background(), options.Client().ApplyURI(config.MongoURI))
repo = NewMongoUserRepo(client.Database("myapp"))
default:
log.Fatal("unknown db type")
}

userService := NewUserService(repo)
// Использование...
}

Когда дженерики уместны

Дженерики полезны для общей логики, не зависящей от типа БД:

// Обобщённый кэш поверх любого репозитория
type CachedRepo[T any] struct {
repo T
cache *Cache[string, any]
}

func (c *CachedRepo[T]) Get(key string) (any, bool) {
if val, ok := c.cache.Get(key); ok {
return val, true
}
// ...
}

// Обобщённый пагинатор
type PaginatedResult[T any] struct {
Items []T
Total int
Page int
Size int
}

func Paginate[T any](items []T, page, size int) PaginatedResult[T] {
start := (page - 1) * size
end := start + size
if end > len(items) {
end = len(items)
}
return PaginatedResult[T]{
Items: items[start:end],
Total: len(items),
Page: page,
Size: size,
}
}

Итог

ПодходКогда использовать
ИнтерфейсыАбстракция над разными реализациями (БД, хранилища)
ДженерикиОбщая логика, не зависящая от типа (кэш, пагинация, Map/Filter)

Для перехода между БД — интерфейсы правильный выбор. Дженерики дополняют их для вспомогательной логики.

Вопрос 24. Как читать данные из канала в горутинах при реализации worker pool?

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

Ответ собеседника: неполный. Кандидат предложил просто читать из канала с помощью оператора <- и обрабатывать задачи. Упомянул, что для более сложных сценариев можно использовать select. Не упомянул range по каналу, проверку закрытия канала (ok pattern) или корректное завершение горутин.

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

Собеседник упомянул базовые подходы, но не раскрыл полную картину. Рассмотрим все способы чтения из канала в контексте worker pool.

1. Range по каналу — самый распространённый способ

func worker(id int, jobs <-chan Job, results chan<- Result) {
// range автоматически завершается при закрытии канала
for job := range jobs {
result := process(job)
results <- result
}
fmt.Printf("Worker %d: jobs channel closed, stopping\n", id)
}

2. Проверка закрытия канала (ok pattern)

func worker(id int, jobs <-chan Job, results chan<- Result) {
for {
job, ok := <-jobs
if !ok {
// Канал закрыт — завершаем горутину
fmt.Printf("Worker %d: channel closed\n", id)
return
}
result := process(job)
results <- result
}
}

3. Select с контекстом для управления завершением

func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
// Контекст отменён — завершаемся
fmt.Printf("Worker %d: context cancelled\n", id)
return
case job, ok := <-jobs:
if !ok {
// Канал закрыт — завершаемся
fmt.Printf("Worker %d: jobs closed\n", id)
return
}
result := process(job)
results <- result
}
}
}

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

type Job struct {
ID int
Data string
}

type Result struct {
JobID int
Output string
Err error
}

type WorkerPool struct {
numWorkers int
jobs chan Job
results chan Result
}

func NewWorkerPool(numWorkers, queueSize int) *WorkerPool {
return &WorkerPool{
numWorkers: numWorkers,
jobs: make(chan Job, queueSize),
results: make(chan Result, queueSize),
}
}

func (wp *WorkerPool) Start(ctx context.Context) {
var wg sync.WaitGroup

for i := 0; i < wp.numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, id, wp.jobs, wp.results)
}(i)
}

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

func (wp *WorkerPool) Submit(job Job) {
wp.jobs <- job
}

func (wp *WorkerPool) Stop() {
close(wp.jobs) // сигнал завершения для всех workers
}

func (wp *WorkerPool) Results() <-chan Result {
return wp.results
}

func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
output, err := process(job)
results <- Result{
JobID: job.ID,
Output: output,
Err: err,
}
}
}
}

func process(job Job) (string, error) {
// Обработка задачи
return fmt.Sprintf("processed: %s", job.Data), nil
}

Использование Worker Pool

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

pool := NewWorkerPool(10, 100)
pool.Start(ctx)

// Горутина для сбора результатов
go func() {
for result := range pool.Results() {
if result.Err != nil {
log.Printf("Job %d failed: %v", result.JobID, result.Err)
} else {
log.Printf("Job %d: %s", result.JobID, result.Output)
}
}
fmt.Println("All results processed")
}()

// Отправка задач
for i := 0; i < 1000; i++ {
pool.Submit(Job{ID: i, Data: fmt.Sprintf("task-%d", i)})
}

pool.Stop() // закрываем jobs — workers завершатся

// Ждём обработки всех результатов
time.Sleep(time.Second)
}

Паттерн Fan-Out / Fan-In

// Fan-Out: один канал читается множеством workers
func fanOut(input <-chan Job, numWorkers int) []<-chan Result {
channels := make([]<-chan Result, numWorkers)

for i := 0; i < numWorkers; i++ {
channels[i] = worker(input)
}

return channels
}

// Fan-In: множество каналов объединяется в один
func fanIn(channels ...<-chan Result) <-chan Result {
merged := make(chan Result)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(c <-chan Result) {
defer wg.Done()
for result := range c {
merged <- result
}
}(ch)
}

go func() {
wg.Wait()
close(merged)
}()

return merged
}

func worker(jobs <-chan Job) <-chan Result {
results := make(chan Result)

go func() {
defer close(results)
for job := range jobs {
output, _ := process(job)
results <- Result{JobID: job.ID, Output: output}
}
}()

return results
}

Graceful shutdown с дренированием

func workerWithDrain(ctx context.Context, jobs <-chan Job, results chan<- Result) {
defer fmt.Println("Worker stopped")

for {
select {
case <-ctx.Done():
// Контекст отменён — дренируем оставшиеся задачи
for job := range jobs {
result := process(job)
results <- result
}
return

case job, ok := <-jobs:
if !ok {
return
}
result := process(job)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}
}

Итог

СпособКогда использовать
for job := range chПростой случай, завершение при закрытии канала
job, ok := <-chНужна явная проверка закрытия
select + ctx.Done()Нужно управление через контекст
select + ok + ctx.Done()Максимальный контроль

Ключевые принципы:

  • Sender закрывает канал — никогда не receiver
  • Range — самый безопасный способ чтения
  • Context — для принудительной остановки
  • sync.WaitGroup — для ожидания завершения workers

Вопрос 25. Писал ли ты тесты на Go? Какие виды тестов знаешь? Какие возможности тестирования есть в стандартном пакете testing?

Таймкод: 00:37:35

Ответ собеседника: неполный. Кандидат писал юнит-тесты и интеграционные тесты на Go (используя testcontainers для поднятия PostgreSQL в контейнере). Отметил, что в Go встроены все необходимые инструменты для тестирования. Знает про бенчмарки (testing.B), но не знает про fuzzing (testing.F). Не смог назвать, как разделять запуск юнит и интеграционных тестов (build tags).

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

Собеседник упомянул основные виды тестов, но не раскрыл все возможности пакета testing. Рассмотрим полную картину.

1. Unit-тесты

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}

// Table-driven tests — идиоматичный подход
func TestAddTable(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
{"zero", 0, 0, 0},
{"mixed", -1, 1, 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}

2. Subtests — вложенные тесты

func TestUser(t *testing.T) {
user := NewUser("John", "john@example.com")

t.Run("Name", func(t *testing.T) {
if user.Name != "John" {
t.Errorf("Name = %q; want %q", user.Name, "John")
}
})

t.Run("Email", func(t *testing.T) {
if user.Email != "john@example.com" {
t.Errorf("Email = %q; want %q", user.Email, "john@example.com")
}
})

// Подтесты могут быть вложенными
t.Run("Validation", func(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
if err := user.Validate(); err != nil {
t.Errorf("unexpected error: %v", err)
}
})

t.Run("InvalidEmail", func(t *testing.T) {
user.Email = "invalid"
if err := user.Validate(); err == nil {
t.Error("expected error for invalid email")
}
})
})
}

3. Бенчмарки (testing.B)

func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}

// Бенчмарк с подтестами
func BenchmarkFibonacci(b *testing.B) {
benchmarks := []struct {
name string
n int
}{
{"Fib(10)", 10},
{"Fib(20)", 20},
{"Fib(30)", 30},
}

for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(bm.n)
}
})
}
}

// Бенчмарк с аллокациями
func BenchmarkProcess(b *testing.B) {
b.ReportAllocs() // отчёт по аллокациям
b.ResetTimer() // сброс таймера после подготовки

for i := 0; i < b.N; i++ {
Process(data)
}
}

4. Fuzzing (testing.F) — Go 1.18+

func FuzzAdd(f *testing.F) {
// Seed corpus — начальные значения для fuzzing
f.Add(1, 2)
f.Add(-1, 1)
f.Add(0, 0)

f.Fuzz(func(t *testing.T, a, b int) {
result := Add(a, b)
// Проверка свойств
if Add(a, b) != Add(b, a) {
t.Errorf("Add is not commutative: Add(%d,%d) != Add(%d,%d)", a, b, b, a)
}
})
}

// Fuzzing с парсингом
func FuzzParseJSON(f *testing.F) {
f.Add(`{"name": "John"}`)
f.Add(`[1, 2, 3]`)

f.Fuzz(func(t *testing.T, data string) {
var v interface{}
// Не должно паниковать
_ = json.Unmarshal([]byte(data), &v)
})
}

5. TestMain — глобальная настройка

var testDB *sql.DB

func TestMain(m *testing.M) {
// Setup — выполняется перед всеми тестами
var err error
testDB, err = sql.Open("postgres", "postgres://localhost/test")
if err != nil {
log.Fatal(err)
}

// Запуск тестов
code := m.Run()

// Teardown — выполняется после всех тестов
testDB.Close()

os.Exit(code)
}

func TestUserRepo(t *testing.T) {
repo := NewUserRepo(testDB)
// тесты...
}

6. Build tags — разделение тестов

//go:build integration
// +build integration

package myapp

import "testing"

func TestDatabaseIntegration(t *testing.T) {
// Этот тест запустится только с тегом integration
db := connectToRealDB()
// ...
}
# Запуск только unit-тестов
go test ./...

# Запуск интеграционных тестов
go test -tags=integration ./...

# Запуск обоих
go test -tags=integration ./...

7. Покрытие кода (coverage)

# Покрытие в консоли
go test -cover ./...

# Подробный отчёт
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

# Покрытие с учётом build tags
go test -tags=integration -coverprofile=coverage.out ./...

8. Моки и стабы

// Интерфейс для тестирования
type UserRepository interface {
GetByID(id int) (*User, error)
}

// Мок
type MockUserRepo struct {
users map[int]*User
err error
}

func (m *MockUserRepo) GetByID(id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}

// Тест с моком
func TestUserService_GetUser(t *testing.T) {
mock := &MockUserRepo{
users: map[int]*User{
1: {ID: 1, Name: "John"},
},
}

svc := NewUserService(mock)
user, err := svc.GetUser(1)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "John" {
t.Errorf("Name = %q; want %q", user.Name, "John")
}
}

9. Helper-функции

func TestComplex(t *testing.T) {
t.Helper() // помечает функцию как helper — ошибки указывают на вызывающий тест

setupTestData(t)
// ...
}

func setupTestData(t *testing.T) {
t.Helper()
// подготовка данных
}

10. Параллельные тесты

func TestParallel(t *testing.T) {
t.Parallel() // этот тест может выполняться параллельно с другими

// Тест...
}

func TestGroup(t *testing.T) {
t.Run("Subtests", func(t *testing.T) {
t.Parallel() // все подтесты параллельны

for _, tc := range testCases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Тест...
})
}
})
}

Команды для запуска тестов

# Все тесты
go test ./...

# Конкретный пакет
go test ./internal/service/...

# Конкретный тест
go test -run TestAdd ./...

# По регулярному выражению
go test -run "TestAdd|TestSub" ./...

# Подробный вывод
go test -v ./...

# С таймаутом
go test -timeout 30s ./...

# Бенчмарки
go test -bench=. ./...
go test -bench=BenchmarkAdd -benchmem ./...

# Fuzzing
go test -fuzz=FuzzAdd -fuzztime=30s ./...

Итог

ВозможностьТипОписание
testing.TUnitБазовый тип тестов
testing.BBenchmarkПроизводительность
testing.FFuzzГенеративное тестирование
TestMainSetupГлобальная инициализация
Build tagsРазделение//go:build integration
t.Parallel()ПараллельПараллельное выполнение
t.Helper()ВспомогательноеПравильные номера строк ошибок

Вопрос 26. Что такое Docker multi-stage build? Для чего он используется?

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

Ответ собеседника: правильный. Multi-stage build позволяет использовать один образ с расширенными инструментами для сборки, а в рантайме использовать более легковесный образ. Сборка происходит на этапе билда, а в рантайме запускается минимальный образ.

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

Ответ собеседника верен. Дополним практическими примерами и деталями.

Проблема: большой размер образа

# Без multi-stage: ~1 GB образ
FROM golang:1.21

WORKDIR /app
COPY . .
RUN go build -o myapp .

CMD [".myapp"]
# С multi-stage: ~10-20 MB образ
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o myapp .

FROM alpine:3.18
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/myapp /usr/local/bin/
CMD ["myapp"]

Полный пример для Go-приложения

# Этап 1: Сборка
FROM golang:1.21-alpine AS builder

# Установка зависимостей для сборки
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

# Копируем зависимости (кэширование слоёв)
COPY go.mod go.sum ./
RUN go mod download

# Копируем исходный код
COPY . .

# Сборка бинарника
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags='-w -s -extldflags "-static"' \
-o /bin/server ./cmd/server

# Этап 2: Минимальный рантайм-образ
FROM scratch

# Копируем сертификаты из builder
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

# Копируем бинарник
COPY --from=builder /bin/server /server

# Порт
EXPOSE 8080

ENTRYPOINT ["/server"]

Множественные этапы сборки

# Этап 1: Тестирование
FROM golang:1.21 AS tester
WORKDIR /app
COPY . .
RUN go test -v ./...

# Этап 2: Сборка
FROM golang:1.21 AS builder
WORKDIR /app
COPY --from=tester /app ./
RUN CGO_ENABLED=0 go build -o /bin/server .

# Этап 3: Документация
FROM golang:1.21 AS docs
WORKDIR /app
COPY --from=tester /app ./
RUN go doc -all ./... > /docs/api.txt

# Этап 4: Рантайм
FROM alpine:3.18
COPY --from=builder /bin/server /server
CMD ["/server"]

Build конкретного этапа

# Собрать только до этапа builder
docker build --target builder -t myapp:builder .

# Собрать полный образ
docker build -t myapp:latest .

# Запустить конкретный этап
docker run --rm myapp:builder /bin/sh

Оптимизация: кэширование слоёв

FROM golang:1.21 AS builder
WORKDIR /app

# Сначала копируем только зависимости — кэшируется
COPY go.mod go.sum ./
RUN go mod download

# Потом копируем исходный код — инвалидируется при изменении
COPY . .
RUN go build -o /bin/server .

FROM alpine:3.18
COPY --from=builder /bin/server /server
CMD ["/server"]

Сравнение размеров образов

# golang:1.21 — ~1 GB
# golang:1.21-alpine — ~300 MB
# alpine:3.18 — ~7 MB
# scratch — 0 MB (пустой образ)

# Итоговый размер с multi-stage:
# alpine: ~15 MB (alpine + бинарник)
# scratch: ~10 MB (только бинарник)

Практический пример: веб-приложение с фронтендом

# Этап 1: Сборка фронтенда
FROM node:18 AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# Этап 2: Сборка бэкенда
FROM golang:1.21 AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server

# Этап 3: Финальный образ
FROM alpine:3.18
RUN apk --no-cache add ca-certificates

COPY --from=backend-builder /bin/server /server
COPY --from=frontend-builder /app/dist /static

EXPOSE 8080
CMD ["/server"]

Итог

Multi-stage build позволяет:

  • Уменьшить размер образа — в 10-100 раз
  • Улучшить безопасность — нет компиляторов и dev-инструментов в рантайме
  • Кэшировать слои — ускорение повторных сборок
  • Разделить этапы — тесты, сборка, документация

Ключевые принципы:

  • Используйте scratch или distroless для Go (статическая линковка)
  • Используйте alpine если нужен shell или пакеты
  • Копируйте только необходимое с предыдущих этапов
  • Называйте этапы для читаемости (AS builder)

Вопрос 27. Работал ли ты с Docker Compose и Kubernetes? Какая самая маленькая единица в Kubernetes и чем управляется Pod?

Таймкод: 00:43:05

Ответ собеседника: правильный. Кандидат работал с Docker Compose и Kubernetes. Самая маленькая единица в Kubernetes — это Pod. Pod управляется через Deployment и другие контроллеры (ReplicaSet, StatefulSet, DaemonSet и т.д.).

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

Ответ собеседника верен. Дополним деталями об архитектуре Kubernetes и контроллерах.

Docker Compose — оркестрация контейнеров

# docker-compose.yml
version: '3.8'

services:
app:
build: .
ports:
- "8080:8080"
environment:
- DB_HOST=postgres
- REDIS_HOST=redis
depends_on:
- postgres
- redis
restart: unless-stopped

postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"

redis:
image: redis:7-alpine
ports:
- "6379:6379"

volumes:
pgdata:

Архитектура Kubernetes

┌─────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
├─────────────────────────────────────────────────────────┤
│ Control Plane │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ API Server │ │ Scheduler │ │ Controller Mgr │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Worker Nodes │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Node 1 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Pod │ │ Pod │ │ Pod │ │ │
│ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ │
│ │ │ │ C1 │ │ │ │ C2 │ │ │ │ C3 │ │ │ │
│ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Pod — минимальная единица

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:latest
ports:
- containerPort: 8080
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 3

Deployment — управление ReplicaSet

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:v1.2.3
ports:
- containerPort: 8080
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: db-secret
key: host
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "256Mi"
cpu: "500m"

Service — сетевой доступ к Pod

# service.yaml
apiVersion: v1
kind: Service
metadata:
name: myapp-service
spec:
selector:
app: myapp
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP

Типы контроллеров

# ReplicaSet — поддерживает нужное количество реплик
apiVersion: apps/v1
kind: ReplicaSet
spec:
replicas: 3
selector:
matchLabels:
app: myapp

---
# StatefulSet — для stateful приложений (БД, очереди)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 3
template:
spec:
containers:
- name: postgres
image: postgres:15
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi

---
# DaemonSet — один Pod на каждом Node
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluentd
spec:
selector:
matchLabels:
app: fluentd
template:
spec:
containers:
- name: fluentd
image: fluentd:latest

---
# Job — однократное выполнение
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-db
spec:
template:
spec:
containers:
- name: migrate
image: myapp:latest
command: ["./migrate", "up"]
restartPolicy: Never

---
# CronJob — периодическое выполнение
apiVersion: batch/v1
kind: CronJob
metadata:
name: backup
spec:
schedule: "0 2 * * *" # каждый день в 2:00
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: myapp:latest
command: ["./backup"]
restartPolicy: OnFailure

ConfigMap и Secret

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
APP_ENV: "production"
LOG_LEVEL: "info"
config.yaml: |
server:
port: 8080
timeout: 30s

---
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
data:
host: cG9zdGdyZXM= # base64 encoded
password: c2VjcmV0 # base64 encoded

Ingress — внешний доступ

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: myapp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-service
port:
number: 80

Основные команды kubectl

# Применение манифестов
kubectl apply -f deployment.yaml
kubectl apply -f ./k8s/

# Просмотр ресурсов
kubectl get pods
kubectl get services
kubectl get deployments
kubectl get all

# Логи
kubectl logs -f pod/myapp-pod
kubectl logs -f deployment/myapp

# Выполнение команд в Pod
kubectl exec -it myapp-pod -- /bin/sh

# Масштабирование
kubectl scale deployment myapp --replicas=5

# Rolling update
kubectl set image deployment/myapp myapp=myapp:v1.2.4

# Откат
kubectl rollout undo deployment/myapp

# Статус деплоймента
kubectl rollout status deployment/myapp

Итог

КонтроллерНазначение
DeploymentStateless приложения, rolling updates
StatefulSetStateful приложения (БД, очереди)
DaemonSetОдин Pod на каждом Node (логирование, мониторинг)
JobОднократные задачи
CronJobПериодические задачи
ReplicaSetПоддержание реплик (обычно через Deployment)

Pod — минимальная единица развёртывания, содержащая один или несколько контейнеров с общими сетью и хранилищем.

Вопрос 28. Что такое HTTP-сервер в Go? Почему сервер не падает при панике в обработчике? Какой HTTP-код возвращается по умолчанию?

Таймкод: 00:44:09

Ответ собеседника: неполный. HTTP-сервер в Go — это структура, позволяющая создавать HTTP-сервер и обрабатывать запросы. Сервер не падает при панике благодаря встроенному recover внутри пакета net/http. По умолчанию при панике возвращается HTTP 500. Кандидат предположил про recover, но не знал деталей реализации.

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

Собеседник верно указал на recover и код 500, но не раскрыл детали реализации. Рассмотрим подробно.

Базовый HTTP-сервер

package main

import (
"fmt"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/api/users", usersHandler)

server := &http.Server{
Addr: ":8080",
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}

log.Fatal(server.ListenAndServe())
}

func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}

Как работает recover в net/http

Каждый запрос обрабатывается в отдельной горутине. Внутри net/http есть встроенный recover:

// Упрощённая логика из net/http/server.go
func (c *conn) serve(ctx context.Context) {
defer func() {
if err := recover(); err != nil {
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
log.Printf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)

// Отправляем 500 если ещё не отправлен
if !c.hijacked() {
c.close()
}
}
}()

// Обработка запроса...
serverHandler{c.server}.ServeHTTP(w, req)
}

Демонстрация поведения при панике

func panicHandler(w http.ResponseWriter, r *http.Request) {
panic("something went wrong!")
}

func main() {
http.HandleFunc("/panic", panicHandler)

// Сервер НЕ упадёт — только текущий запрос получит 500
log.Fatal(http.ListenAndServe(":8080", nil))
}

Собственный middleware с recover

func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic: %v\n%s", err, debug.Stack())

// Проверяем, не отправлен ли уже ответ
if w.Header().Get("Content-Type") == "" {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}()

next.ServeHTTP(w, r)
})
}

// Использование
mux := http.NewServeMux()
mux.HandleFunc("/panic", panicHandler)

server := &http.Server{
Addr: ":8080",
Handler: RecoveryMiddleware(mux),
}

http.ResponseWriter — интерфейс

type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}

Коды ответов по умолчанию

func handler(w http.ResponseWriter, r *http.Request) {
// Если не вызван WriteHeader — автоматически 200 OK
w.Write([]byte("OK")) // 200

// Явное указание кода
w.WriteHeader(http.StatusCreated) // 201
w.Write([]byte("Created"))

// При панике — 500 Internal Server Error
}

Полный пример с обработкой ошибок

func apiHandler(w http.ResponseWriter, r *http.Request) {
// Устанавливаем Content-Type до WriteHeader
w.Header().Set("Content-Type", "application/json")

result, err := processRequest(r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "internal_error",
Message: err.Error(),
})
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}

Graceful shutdown

func main() {
server := &http.Server{
Addr: ":8080",
Handler: mux,
}

// Запуск в горутине
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()

// Ожидание сигнала завершения
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

// Graceful shutdown с таймаутом
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := server.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}

log.Println("Server stopped")
}

Итог

  • HTTP-сервер в Go — http.Server из пакета net/http
  • Каждый запрос обрабатывается в отдельной горутине
  • Встроенный recover перехватывает паники и возвращает 500
  • Сервер продолжает работать после паники в обработчике
  • Для production рекомендуется добавить собственный middleware с recover и логированием стека вызовов

Вопрос 29. Чем отличается HTTP/1 от HTTP/2? Какой формат у этих протоколов?

Таймкод: 00:46:14

Ответ собеседника: правильный. HTTP/1 — текстовый протокол, HTTP/2 — бинарный. В HTTP/2 появилось мультиплексирование: в рамках одного соединения можно передавать несколько пакетов/запросов одновременно. В HTTP/1 для параллельных запросов нужно открывать несколько соединений.

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

Ответ собеседника верен. Дополним деталями о различиях протоколов.

Формат протоколов

HTTP/1.1 — текстовый

GET /api/users HTTP/1.1
Host: example.com
User-Agent: curl/7.68.0
Accept: application/json
Connection: keep-alive

{"name": "John"}

HTTP/2 — бинарный

// Бинарные фреймы (упрощённо):
// +-----------------------------------------------+
// | Length (24) |
// +---------------+---------------+---------------+
// | Type (8) | Flags (8) |
// +-+-------------+---------------+-------------------------------+
// |R| Stream Identifier (31) |
// +=+=============================================================+
// | Frame Payload (0...) ...
// +---------------------------------------------------------------+

Ключевые различия

1. Мультиплексирование

HTTP/1.1 (без мультиплексирования):
Connection 1: [Request 1] → [Response 1]
Connection 2: [Request 2] → [Response 2]
Connection 3: [Request 3] → [Response 3]

HTTP/2 (с мультиплексированием):
Connection 1: [Req 1] [Req 2] [Req 3]
[Res 2] [Res 1] [Res 3] // ответы могут прийти в любом порядке

2. Сжатие заголовков (HPACK)

HTTP/1.1:
GET /api/users HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0...
Accept: application/json
Accept-Language: en-US
Accept-Encoding: gzip, deflate
Cookie: session=abc123; user=john
// ~200-500 байт заголовков на каждый запрос

HTTP/2:
// Первый заголовок (полный):
:method: GET
:path: /api/users
:authority: example.com
user-agent: Mozilla/5.0...
accept: application/json

// Последующие (только изменения):
:path: /api/users/1 // только изменившиеся поля

3. Server Push

// HTTP/2 Server Push — сервер может отправить ресурсы до запроса клиента
Client: GET /index.html
Server: → index.html
→ style.css (push)
→ script.js (push)
→ logo.png (push)

4. Приоритизация потоков

Stream 1 (приоритет 1): index.html ← загружается первым
Stream 3 (приоритет 16): style.css
Stream 5 (приоритет 256): image.png ← загружается последним

Сравнительная таблица

ХарактеристикаHTTP/1.1HTTP/2
ФорматТекстовыйБинарный
МультиплексированиеНетДа
СоединенияМножественныеОдно
Сжатие заголовковНет (gzip тела)HPACK
Server PushНетДа
ПриоритизацияНетДа
Head-of-line blockingДа (на уровне TCP)Решён на уровне HTTP

Head-of-line blocking

HTTP/1.1:
[Загрузка большого файла] → блокирует все остальные запросы в соединении

HTTP/2:
[Большой файл] [Маленький файл] [API-ответ]
↓ ↓ ↓
Поток 1 Поток 2 Поток 3 // не блокируют друг друга

HTTP/2 в Go

// HTTP/2 поддерживается автоматически с TLS
server := &http.Server{
Addr: ":443",
Handler: mux,
}

// Автоматически использует HTTP/2 если клиент поддерживает
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))

// Принудительный HTTP/2 без TLS (h2c)
import "golang.org/x/net/http2"
import "golang.org/x/net/http2/h2c"

h2s := &http2.Server{}
server := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(mux, h2s),
}

Итог

HTTP/2 решает основные проблемы HTTP/1.1:

  • Бинарный формат — эффективнее парсинг
  • Мультиплексирование — одно соединение для всех запросов
  • HPACK — сжатие заголовков уменьшает overhead
  • Server Push — проактивная отправка ресурсов
  • Приоритизация — важные ресурсы загружаются первыми

Вопрос 30. Какие типы баз данных ты знаешь? Что такое ACID в контексте реляционных баз данных?

Таймкод: 00:48:14

Ответ собеседника: неполный. Кандидат назвал SQL, NoSQL, NewSQL, документо-ориентированные и графовые базы данных. Про ACID помнит атомарность (A) и консистентность (C), но не вспомнил изоляцию (I) и долговечность/персистентность (D).

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

Собеседник назвал основные типы БД, но не раскрыл ACID полностью. Рассмотрим подробно.

Типы баз данных

1. Реляционные (SQL)

-- PostgreSQL, MySQL, Oracle, SQL Server
-- Данные хранятся в таблицах со связями

CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
total DECIMAL(10,2),
status VARCHAR(20)
);

-- Связь через JOIN
SELECT u.name, o.total, o.status
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 1;

2. Документо-ориентированные

// MongoDB, CouchDB
// Данные хранятся в виде JSON-документов

// users collection
{
"_id": ObjectId("..."),
"name": "John",
"email": "john@example.com",
"addresses": [
{"city": "NYC", "zip": "10001"},
{"city": "LA", "zip": "90001"}
],
"orders": [
{"id": 1, "total": 100.00},
{"id": 2, "total": 250.00}
]
}

3. Key-Value

# Redis, Memcached, DynamoDB
# Простейшая модель: ключ → значение

SET user:1:name "John"
SET user:1:email "john@example.com"
GET user:1:name # "John"

# Redis структуры
LPUSH user:1:tags "golang"
LPUSH user:1:tags "python"
LRANGE user:1:tags 0 -1 # ["python", "golang"]

4. Column-family

# Cassandra, HBase
# Данные хранятся по колонкам, а не по строкам

CREATE TABLE user_events (
user_id uuid,
event_time timestamp,
event_type text,
data text,
PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

5. Графовые

# Neo4j, Amazon Neptune
# Узлы и связи между ними

CREATE (john:User {name: "John"})
CREATE (jane:User {name: "Jane"})
CREATE (post:Post {title: "Hello"})
CREATE (john)-[:FRIEND]->(jane)
CREATE (john)-[:POSTED]->(post)

// Запрос: найти друзей друзей
MATCH (u:User {name: "John"})-[:FRIEND*2]->(fof:User)
RETURN fof.name

6. Time-series

# TimescaleDB, InfluxDB
# Оптимизированы для временных данных

SELECT time_bucket('1 hour', time) AS bucket,
AVG(temperature),
MAX(humidity)
FROM sensor_data
WHERE time > NOW() - INTERVAL '24 hours'
GROUP BY bucket
ORDER BY bucket;

7. NewSQL

# CockroachDB, TiDB, YugabyteDB
# Масштабируемость NoSQL + ACID гарантии SQL

-- CockroachDB: распределённые транзции с SERIALIZABLE
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

ACID — свойства транзакций

A — Atomicity (Атомарность)

Транзакция выполняется целиком или не выполняется вовсе:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Если здесь произойдёт ошибка — ОБА запроса откатятся
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- При ошибке:
ROLLBACK; -- откат всех изменений

C — Consistency (Согласованность)

После транзакции данные находятся в валидном состоянии:

-- До транзакции: баланс = 1000
-- Правило: баланс >= 0

BEGIN;
UPDATE accounts SET balance = balance - 1500 WHERE id = 1;
-- Нарушение ограничения CHECK (balance >= 0)
-- → транзакция откатывается
COMMIT;

-- После: баланс = 1000 (не отрицательный)
-- Ограничения обеспечивают консистентность
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
balance DECIMAL(10,2) CHECK (balance >= 0),
CONSTRAINT positive_balance CHECK (balance >= 0)
);

I — Isolation (Изоляция)

Параллельные транзакции не влияют друг на друга:

-- Транзакция 1 -- Транзакция 2
BEGIN; BEGIN;
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id = 1; -- 1000 WHERE id = 1; -- 1000
UPDATE accounts SET UPDATE accounts SET
balance = 900 WHERE id = 1; balance = 800 WHERE id = 1;
COMMIT; COMMIT;
-- Результат зависит от уровня изоляции

Уровни изоляции:

УровеньDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedВозможенВозможенВозможен
Read CommittedНетВозможенВозможен
Repeatable ReadНетНетВозможен
SerializableНетНетНет
-- Установка уровня изоляции
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;

D — Durability (Долговечность)

После COMMIT данные сохраняются навсегда, даже при сбое:

BEGIN;
INSERT INTO orders (user_id, total) VALUES (1, 100.00);
COMMIT; -- данные гарантированно записаны на диск

-- Даже если сервер упадёт после COMMIT — данные не потеряются
-- Обеспечивается через WAL (Write-Ahead Log)

Практический пример в Go

func TransferMoney(ctx context.Context, fromID, toID int, amount float64) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()

// Атомарность: оба запроса или ни один
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, fromID,
)
if err != nil {
return fmt.Errorf("debit: %w", err)
}

_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, toID,
)
if err != nil {
return fmt.Errorf("credit: %w", err)
}

// Долговечность: после Commit данные сохранены
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}

return nil
}

Итог

СвойствоОписание
AtomicityВсё или ничего — транзакция неделима
ConsistencyДанные остаются валидными
IsolationПараллельные транзакции не мешают друг другу
DurabilityПосле COMMIT данные сохранены навсегда

Вопрос 31. Что такое индекс в базе данных? Зачем он нужен? Когда индекс негативно влияет на таблицу?

Таймкод: 00:49:31

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

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

Ответ собеседника верен. Дополним деталями о типах индексов и практических примерах.

Типы индексов

1. B-Tree индекс (по умолчанию)

-- Создание B-Tree индекса
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- Составной индекс
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Запрос использует индекс
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'john@example.com';
-- Index Scan using idx_users_email

B-Tree структура:

[50]
/ \
[20|40] [60|80]
/ | \ / | \
[10][30][45][55][70][90]

2. Hash индекс

-- Только для точного совпадения (=)
CREATE INDEX idx_users_email_hash ON users USING HASH (email);

-- Работает:
SELECT * FROM users WHERE email = 'john@example.com';

-- НЕ работает:
SELECT * FROM users WHERE email LIKE 'john%';

3. GIN (Generalized Inverted Index)

-- Для полнотекстового поиска и массивов
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);

-- Поиск по массиву
SELECT * FROM posts WHERE tags @> ARRAY['golang'];

-- Полнотекстовый поиск
CREATE INDEX idx_posts_content ON posts USING GIN(to_tsvector('english', content));
SELECT * FROM posts WHERE to_tsvector('english', content) @@ to_tsquery('golang & postgresql');

4. GiST (Generalized Search Tree)

-- Для геоданных и диапазонов
CREATE INDEX idx_locations ON locations USING GIST(coordinates);

-- Поиск ближайших
SELECT * FROM locations
ORDER BY coordinates <-> point '(55.75, 37.62)'
LIMIT 10;

Когда индекс полезен

-- 1. WHERE по индексированному полю
SELECT * FROM orders WHERE user_id = 123;

-- 2. JOIN по индексированному полю
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id;

-- 3. ORDER BY по индексированному полю
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;

-- 4. GROUP BY с агрегацией
SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id;

Когда индекс НЕ используется

-- 1. Функция над индексированным полем
SELECT * FROM users WHERE LOWER(email) = 'john@example.com';

-- Решение: функциональный индекс
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- 2. LIKE с подстановкой в начале
SELECT * FROM users WHERE email LIKE '%@example.com';

-- Решение: триграммный индекс
CREATE INDEX idx_users_email_trgm ON users USING GIN(email gin_trgm_ops);

-- 3. Маленькая таблица (seq scan быстрее)
-- Для таблиц < 1000 строк индекс может замедлить запрос

-- 4. OR условия на разных полях
SELECT * FROM users WHERE email = 'a@b.com' OR phone = '123';

-- Решение: UNION
SELECT * FROM users WHERE email = 'a@b.com'
UNION
SELECT * FROM users WHERE phone = '123';

Негативное влияние индексов

-- 1. Замедление INSERT
INSERT INTO users (name, email) VALUES ('John', 'john@example.com');
-- Нужно обновить: PK индекс + idx_email + idx_name + ...

-- 2. Замедление UPDATE индексированных полей
UPDATE users SET email = 'new@example.com' WHERE id = 1;
-- Нужно пересчитать idx_email

-- 3. Увеличение размера на диске
-- Каждый индекс занимает место

-- 4. Замедление VACUUM и ANALYZE
-- Больше индексов → больше работы для обслуживания

Практический пример в Go

// Миграция с созданием индексов
func migrate(db *sql.DB) error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)`,

`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
ON users(email)`,

`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_created
ON users(created_at DESC)`,
}

for _, q := range queries {
if _, err := db.Exec(q); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
}
return nil
}

// Анализ использования индексов
func analyzeIndexUsage(db *sql.DB) error {
query := `
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC
`

rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()

for rows.Next() {
var name string
var scan, read, fetch int64
rows.Scan(&name, &scan, &read, &fetch)
log.Printf("Index %s: scans=%d, reads=%d, fetches=%d", name, scan, read, fetch)
}
return nil
}

Итог

АспектОписание
ПользаУскорение SELECT, JOIN, ORDER BY, GROUP BY
СтоимостьЗамедление INSERT, UPDATE, DELETE; занимает место
Когда создаватьПоля в WHERE, JOIN, ORDER BY
Когда не создаватьМаленькие таблицы, редко используемые поля, поля с низкой кардинальностью

Принцип: создавайте индексы на основе реальных запросов, мониторьте использование через pg_stat_user_indexes.

Вопрос 32. Когда индекс негативно влияет на таблицу? Как решить проблему, когда нужно и много писать, и много читать?

Таймкод: 00:50:44

Ответ собеседника: неполный. Индекс негативно влияет на запись, так как при каждой вставке/обновлении нужно пересчитывать индекс. Для решения проблемы с высокой нагрузкой на чтение и запись кандидат предложил использовать репликацию — поставить дополнительный инстанс базы данных и переливать данные. Назвал схемы: master-master, master-slave. Не предложил конкретного решения с разделением чтения и записи.

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

Собеседник верно указал на репликацию, но не раскрыл полную стратегию разделения нагрузки. Рассмотрим все подходы.

Проблема: индексы замедляют запись

-- Таблица с 5 индексами
CREATE TABLE events (
id SERIAL PRIMARY KEY, -- 1. PK индекс
user_id INT NOT NULL, -- 2. idx_events_user_id
event_type VARCHAR(50), -- 3. idx_events_type
created_at TIMESTAMP, -- 4. idx_events_created
data JSONB -- 5. idx_events_data (GIN)
);

-- INSERT теперь обновляет ВСЕ 5 индексов
INSERT INTO events (user_id, event_type, created_at, data)
VALUES (1, 'click', NOW(), '{"page": "/home"}');

Решения для высокой нагрузки

1. Read Replicas — разделение чтения и записи

type DatabaseCluster struct {
primary *sql.DB // запись
replicas []*sql.DB // чтение
counter uint64 // round-robin счётчик
}

func NewCluster(primaryDSN string, replicaDSNs []string) (*DatabaseCluster, error) {
primary, err := sql.Open("postgres", primaryDSN)
if err != nil {
return nil, err
}

var replicas []*sql.DB
for _, dsn := range replicaDSNs {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
replicas = append(replicas, db)
}

return &DatabaseCluster{
primary: primary,
replicas: replicas,
}, nil
}

// Для записи — всегда primary
func (c *DatabaseCluster) Write(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return c.primary.ExecContext(ctx, query, args...)
}

// Для чтения — round-robin по репликам
func (c *DatabaseCluster) Read(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
idx := atomic.AddUint64(&c.counter, 1) % uint64(len(c.replicas))
return c.replicas[idx].QueryContext(ctx, query, args...)
}

// Транзакции — всегда на primary
func (c *DatabaseCluster) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
return c.primary.BeginTx(ctx, opts)
}

2. Оптимизация индексов

-- Удаление неиспользуемых индексов
SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey';

-- Частичный индекс (меньше данных для обновления)
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';

-- Индекс с условием — только активных пользователей
CREATE INDEX idx_active_users ON users(email)
WHERE deleted_at IS NULL;

3. Партиционирование

-- Партиционирование по времени
CREATE TABLE events (
id SERIAL,
user_id INT NOT NULL,
event_type VARCHAR(50),
created_at TIMESTAMP NOT NULL,
data JSONB
) PARTITION BY RANGE (created_at);

-- Создание партиций
CREATE TABLE events_2024_01 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE events_2024_02 PARTITION OF events
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- Индексы создаются на каждой партиции отдельно
CREATE INDEX idx_events_2024_01_user ON events_2024_01(user_id);

-- INSERT попадает только в одну партицию
-- Индексы других партиций не затрагиваются

4. Архитектура CQRS (Command Query Responsibility Segregation)

// Command — запись в основную БД
type EventCommand struct {
db *sql.DB
}

func (c *EventCommand) CreateEvent(ctx context.Context, event Event) error {
_, err := c.db.ExecContext(ctx,
"INSERT INTO events (user_id, event_type, data) VALUES ($1, $2, $3)",
event.UserID, event.Type, event.Data,
)
return err
}

// Query — чтение из оптимизированного хранилища
type EventQuery struct {
db *sql.DB
cache *redis.Client
}

func (q *EventQuery) GetUserEvents(ctx context.Context, userID int) ([]Event, error) {
// Сначала проверяем кэш
cached, err := q.cache.Get(ctx, fmt.Sprintf("events:%d", userID)).Result()
if err == nil {
return parseEvents(cached), nil
}

// Чтение из read replica или materialized view
rows, err := q.db.QueryContext(ctx,
"SELECT * FROM events_denormalized WHERE user_id = $1 ORDER BY created_at DESC",
userID,
)
// ...
}

5. Буферизация записей

type BufferedWriter struct {
db *sql.DB
buffer []Event
mu sync.Mutex
ticker *time.Ticker
}

func NewBufferedWriter(db *sql.DB, bufferSize int, flushInterval time.Duration) *BufferedWriter {
bw := &BufferedWriter{
db: db,
buffer: make([]Event, 0, bufferSize),
ticker: time.NewTicker(flushInterval),
}

go bw.periodicFlush()
return bw
}

func (bw *BufferedWriter) Write(event Event) {
bw.mu.Lock()
bw.buffer = append(bw.buffer, event)

if len(bw.buffer) >= cap(bw.buffer) {
bw.flush()
}
bw.mu.Unlock()
}

func (bw *BufferedWriter) flush() {
if len(bw.buffer) == 0 {
return
}

tx, _ := bw.db.Begin()
stmt, _ := tx.Prepare("INSERT INTO events (user_id, event_type, data) VALUES ($1, $2, $3)")

for _, event := range bw.buffer {
stmt.Exec(event.UserID, event.Type, event.Data)
}

tx.Commit()
bw.buffer = bw.buffer[:0]
}

6. Схемы репликации

Master-Slave (Primary-Replica):
┌─────────┐ async replication ┌──────────┐
│ Primary │ ─────────────────────────→ │ Replica 1│
│ (write) │ ─────────────────────────→ │ Replica 2│
└─────────┘ └──────────┘

Master-Master:
┌─────────┐ bidirectional ┌──────────┐
│ Primary │ ←────────────────────────→│ Primary │
│ A │ replication │ B │
└─────────┘ └──────────┘

Итог

ПодходКогда использовать
Read Replicas80% чтение, 20% запись
ПартиционированиеБольшие таблицы, данные по времени
CQRSРазные модели для чтения и записи
БуферизацияВысокая нагрузка на запись
Оптимизация индексовУменьшение overhead на запись

Комбинированный подход: read replicas + партиционирование + оптимизированные индексы + кэширование.

Вопрос 33. Как решить проблему, когда нужно и много писать, и много читать из базы данных?

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

Ответ собеседния: неполный. Кандидат предложил использовать репликацию — поставить дополнительный инстанс базы данных и переливать данные. Назвал схемы: master-master, master-slave. Упомянул, что минус master-slave — задержка репликации, данные могут быть несинхронными. Не предложил конкретного решения с разделением чтения и записи на уровне приложения.

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

Этот вопрос дублирует предыдущий, поэтому приведём краткий итог всех подходов.

Комплексное решение

1. Уровень приложения — Read/Write splitting

type DBCluster struct {
primary *sql.DB
replicas []*sql.DB
}

func (c *DBCluster) Query(ctx context.Context, sql string, args ...interface{}) (*sql.Rows, error) {
// Чтение из реплики
replica := c.replicas[rand.Intn(len(c.replicas))]
return replica.QueryContext(ctx, sql, args...)
}

func (c *DBCluster) Exec(ctx context.Context, sql string, args ...interface{}) (sql.Result, error) {
// Запись в primary
return c.primary.ExecContext(ctx, sql, args...)
}

2. Кэширование

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
// Сначала кэш
if cached, err := s.cache.Get(ctx, fmt.Sprintf("user:%d", id)).Result(); err == nil {
return decodeUser(cached), nil
}

// Потом БД (реплика)
user, err := s.db.GetUser(ctx, id)
if err != nil {
return nil, err
}

// Сохраняем в кэш
s.cache.Set(ctx, fmt.Sprintf("user:%d", id), encodeUser(user), time.Hour)
return user, nil
}

3. Инфраструктура

┌─────────┐ ┌─────────────┐ ┌─────────────┐
│ App │────→│ PgBouncer │────→│ Primary │
│ │ │ (pooling) │ │ (write) │
└─────────┘ └─────────────┘ └──────┬──────┘
│ replication
┌──────┴──────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Replica 1│ │ Replica 2│
│ (read) │ │ (read) │
└───────────┘ └───────────┘

Итог

Оптимальная стратегия — комбинация:

  • Read replicas для масштабирования чтения
  • Кэширование (Redis) для горячих данных
  • Партиционирование для больших таблиц
  • Оптимизация индексов — удаление неиспользуемых
  • Connection pooling (PgBouncer)
  • CQRS если модели чтения/записи сильно отличаются

Вопрос 34. Как реализовать на уровне приложения схему, где запись идёт в master, а чтение из slave?

Таймкод: 00:53:12

Ответ собеседника: неполный. Кандидат предположил, что можно создать два отдельных объекта (репозитория) для чтения и записи, но не смог предложить конкретного решения, как разделить запросы. Интервьюер подсказал использовать отдельные sqlx-коннекшены и признаки для маршрутизации запросов.

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

Рассмотрим полную реализацию read/write splitting на уровне приложения.

1. Простой подход — два подключения

package database

import (
"context"
"database/sql"
"sync/atomic"
)

type DBCluster struct {
master *sql.DB
slaves []*sql.DB
counter uint64
}

func NewCluster(masterDSN string, slaveDSNs []string) (*DBCluster, error) {
master, err := sql.Open("postgres", masterDSN)
if err != nil {
return nil, fmt.Errorf("open master: %w", err)
}
master.SetMaxOpenConns(25)
master.SetMaxIdleConns(5)

var slaves []*sql.DB
for _, dsn := range slaveDSNs {
slave, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open slave: %w", err)
}
slave.SetMaxOpenConns(50)
slave.SetMaxIdleConns(10)
slaves = append(slaves, slave)
}

return &DBCluster{
master: master,
slaves: slaves,
}, nil
}

// Выбор slave через round-robin
func (c *DBCluster) getSlave() *sql.DB {
if len(c.slaves) == 0 {
return c.master
}
idx := atomic.AddUint64(&c.counter, 1) % uint64(len(c.slaves))
return c.slaves[idx]
}

2. Методы для чтения и записи

// QueryContext — чтение из slave
func (c *DBCluster) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return c.getSlave().QueryContext(ctx, query, args...)
}

func (c *DBCluster) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
return c.getSlave().QueryRowContext(ctx, query, args...)
}

// ExecContext — запись в master
func (c *DBCluster) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return c.master.ExecContext(ctx, query, args...)
}

// BeginTx — транзакция на master
func (c *DBCluster) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
return c.master.BeginTx(ctx, opts)
}

3. Репозиторий с автоматической маршрутизацией

type UserRepo struct {
db *DBCluster
}

func NewUserRepo(db *DBCluster) *UserRepo {
return &UserRepo{db: db}
}

// Чтение — автоматически идёт в slave
func (r *UserRepo) GetByID(ctx context.Context, id int) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}
return &user, nil
}

func (r *UserRepo) List(ctx context.Context) ([]User, error) {
rows, err := r.db.QueryContext(ctx,
"SELECT id, name, email FROM users ORDER BY id",
)
if err != nil {
return nil, err
}
defer rows.Close()

var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}

// Запись — автоматически идёт в master
func (r *UserRepo) Create(ctx context.Context, user *User) error {
return r.db.QueryRowContext(ctx,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
user.Name, user.Email,
).Scan(&user.ID)
}

func (r *UserRepo) Update(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
user.Name, user.Email, user.ID,
)
return err
}

func (r *UserRepo) Delete(ctx context.Context, id int) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
return err
}

4. Транзакции — всегда на master

func (r *UserRepo) Transfer(ctx context.Context, fromID, toID int, amount float64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, fromID,
)
if err != nil {
return err
}

_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, toID,
)
if err != nil {
return err
}

return tx.Commit()
}

5. Продвинутый подход — с учётом репликационной задержки

type AwareDBCluster struct {
master *sql.DB
slaves []*sql.DB
counter uint64
}

// Для критичных чтений после записи — читаем из master
func (c *AwareDBCluster) QueryAfterWrite(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return c.master.QueryContext(ctx, query, args...)
}

// Для обычных чтений — slave
func (c *AwareDBCluster) QueryReadOnly(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return c.getSlave().QueryContext(ctx, query, args...)
}

// Пример: чтение сразу после записи
func (r *UserRepo) CreateAndGet(ctx context.Context, user *User) (*User, error) {
// Запись в master
err := r.db.QueryRowContext(ctx,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
user.Name, user.Email,
).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}

// Чтение из master чтобы избежать replication lag
var result User
err = r.db.QueryAfterWrite(ctx,
"SELECT id, name, email FROM users WHERE id = $1", user.ID,
).Scan(&result.ID, &result.Name, &result.Email)

return &result, err
}

6. Использование sqlx

import "github.com/jmoiron/sqlx"

type DBClusterExt struct {
master *sqlx.DB
slaves []*sqlx.DB
counter uint64
}

func (c *DBClusterExt) QueryOnly(dest interface{}, query string, args ...interface{}) error {
return c.getSlave().Select(dest, query, args...)
}

func (c *DBClusterExt) ExecWrite(query string, args ...interface{}) (sql.Result, error) {
return c.master.Exec(query, args...)
}

// Репозиторий с sqlx
type OrderRepo struct {
db *DBClusterExt
}

func (r *OrderRepo) GetOrders(ctx context.Context, userID int) ([]Order, error) {
var orders []Order
err := r.db.QueryOnly(&orders,
"SELECT * FROM orders WHERE user_id = $1", userID,
)
return orders, err
}

func (r *OrderRepo) CreateOrder(ctx context.Context, order *Order) error {
query := `INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id`
return r.db.GetWrite().QueryRowx(query, order.UserID, order.Total, order.Status).Scan(&order.ID)
}

7. Конфигурация

func main() {
cluster, err := database.NewCluster(
"postgres://master:5432/myapp", // master
[]string{
"postgres://slave1:5432/myapp", // slave 1
"postgres://slave2:5432/myapp", // slave 2
},
)
if err != nil {
log.Fatal(err)
}

userRepo := NewUserRepo(cluster)
// Использование...
}

Итог

КомпонентНазначение
DBClusterМаршрутизация запросов
QueryContextЧтение из slave (round-robin)
ExecContextЗапись в master
BeginTxТранзакции на master
QueryAfterWriteЧтение из master после записи

Принцип: запросы на чтение направляются на реплики, запросы на запись — на master. Для критичных случаев (чтение сразу после записи) — читаем из master.

Вопрос 35. Что такое L3 и L7 балансировка?

Таймкод: 00:58:09

Ответ собеседника: неполный. Кандидат знает про балансировщики, упоминал ingress-контроллеры в Kubernetes. Знает, что L7 — это балансировка на уровне приложений (HTTP). Не смог точно назвать, что такое L3 (сетевой уровень, IP-адреса).

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

Собеседник знает про L7, но не раскрыл L3. Рассмотрим оба типа балансировки.

OSI модель

┌─────────────────────────────────────────────────────────┐
│ Уровень │ Название │ Примеры │
├─────────┼─────────────────┼─────────────────────────────┤
│ L7 │ Приложения │ HTTP, gRPC, DNS │
│ L6 │ Представление │ TLS, SSL, JPEG │
│ L5 │ Сессия │ NetBIOS, RPC │
│ L4 │ Транспорт │ TCP, UDP │
│ L3 │ Сетевой │ IP, ICMP, ARP │
│ L2 │ Канальный │ Ethernet, MAC │
│ L1 │ Физический │ Кабели, сигналы │
└─────────────────────────────────────────────────────────┘

L3 балансировка (Network Layer)

Работает на уровне IP-адресов:

┌─────────────┐
Client ────────────→│ L3 LB │
│ (IP: 1.2.3.4)│
└──────┬──────┘

┌────────────┼────────────┐
│ │ │
┌─────▼─────┐┌─────▼─────┐┌─────▼─────┐
│ Backend 1 ││ Backend 2 ││ Backend 3 │
│ 10.0.0.1 ││ 10.0.0.2 ││ 10.0.0.3 │
└───────────┘└───────────┘└───────────┘
# L3 балансировка — работает с IP-пакетами
# Не видит содержимое запроса

# Пример: IPVS (IP Virtual Server) в Linux
ipvsadm -A -t 1.2.3.4:80 -s rr
ipvsadm -a -t 1.2.3.4:80 -r 10.0.0.1:80 -m
ipvsadm -a -t 1.2.3.4:80 -r 10.0.0.2:80 -m

# -s rr — round-robin
# -m — masquerading (NAT)

L4 балансировка (Transport Layer)

Работает на уровне TCP/UDP соединений:

Client ──TCP──→ L4 LB ──TCP──→ Backend 1

└──TCP──→ Backend 2
# L4 балансировка — видит порты и соединения
# Пример: HAProxy (L4 режим)

# haproxy.cfg (L4)
frontend tcp_front
bind *:80
mode tcp
default_backend tcp_back

backend tcp_back
mode tcp
balance roundrobin
server backend1 10.0.0.1:80 check
server backend2 10.0.0.2:80 check

L7 балансировка (Application Layer)

Работает на уровне HTTP-запросов:

Client ──HTTP──→ L7 LB ──HTTP──→ Backend 1 (api.example.com)
│ └──HTTP──→ Backend 2 (static.example.com)

└──HTTP──→ Backend 3 (/api/users/*)
# L7 балансировка — видит URL, заголовки, cookies
# Пример: NGINX Ingress Controller (Kubernetes)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: api.example.com
http:
paths:
- path: /users
pathType: Prefix
backend:
service:
name: user-service
port:
number: 80
- path: /orders
pathType: Prefix
backend:
service:
name: order-service
port:
number: 80

Сравнение L3/L4/L7

ХарактеристикаL3L4L7
УровеньIPTCP/UDPHTTP/gRPC
ВидитIP-адресаПорты, соединенияURL, заголовки, body
СкоростьБыстрееБыстроМедленнее
ГибкостьНизкаяСредняяВысокая
SSL terminationНетНетДа
МаршрутизацияПо IPПо портуПо URL, header
ПримерыIPVS, ECMPHAProxy (tcp), NLBNGINX, ALB, Ingress

L7 балансировка в Kubernetes

# Ingress с L7 маршрутизацией
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
annotations:
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- example.com
secretName: tls-secret
rules:
- host: example.com
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: api-v1
port:
number: 80
- path: /api/v2
pathType: Prefix
backend:
service:
name: api-v2
port:
number: 80

Практический пример: NGINX как L7 балансировщик

upstream backend {
least_conn; # алгоритм балансировки
server 10.0.0.1:8080 weight=3;
server 10.0.0.2:8080 weight=2;
server 10.0.0.3:8080 backup;
}

server {
listen 80;
server_name example.com;

# Маршрутизация по URL
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

# Маршрутизация по заголовку
location / {
if ($http_x_api_version = "v2") {
proxy_pass http://backend_v2;
}
proxy_pass http://backend;
}
}

Итог

  • L3 — балансировка на уровне IP, быстрая, но простая
  • L4 — балансировка на уровне TCP/UDP, видит соединения
  • L7 — балансировка на уровне HTTP, самая гибкая, видит URL и заголовки

Для веб-приложений обычно используется L7 (NGINX, ALB, Ingress), для высоконагруженных систем — комбинация L3/L4 + L7.

Вопрос 36. Что такое скейлинг? Как он работает в Kubernetes?

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

Ответ собеседника: правильный. Скейлинг — это увеличение количества экземпляров приложения для обработки большей нагрузки. В Kubernetes это означает увеличение количества подов (например, с одного до трёх).

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

Ответ собеседника верен, но неполон. Рассмотрим все виды скейлинга в Kubernetes.

Типы скейлинга

1. Horizontal Pod Autoscaler (HPA)

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 5 минут стабилизации
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 50
periodSeconds: 60
# Проверка HPA
kubectl get hpa
kubectl describe hpa myapp-hpa

2. Vertical Pod Autoscaler (VPA)

# vpa.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: myapp-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
updatePolicy:
updateMode: "Auto" # Auto, Initial, Off
resourcePolicy:
containerPolicies:
- containerName: myapp
minAllowed:
cpu: 100m
memory: 128Mi
maxAllowed:
cpu: 2
memory: 2Gi

3. Cluster Autoscaler

# Cluster Autoscaler автоматически добавляет/удаляет ноды
# Когда Pod не может быть запланирован из-за нехватки ресурсов → добавляет ноду
# Когда нода недоиспользуется → удаляет ноду

# Пример конфигурации (GKE)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-autoscaler
spec:
template:
spec:
containers:
- name: cluster-autoscaler
image: k8s.gcr.io/autoscaling/cluster-autoscaler:v1.25.0
command:
- ./cluster-autoscaler
- --nodes=1:10:pool-1 # min:max:node-pool

4. Manual Scaling

# Ручное масштабирование
kubectl scale deployment myapp --replicas=5

# Через редактирование
kubectl edit deployment myapp
# Изменить spec.replicas: 5

5. KEDA (Kubernetes Event-Driven Autoscaling)

# keda-scaledobject.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: myapp-scaledobject
spec:
scaleTargetRef:
name: myapp
minReplicaCount: 1
maxReplicaCount: 50
triggers:
# Масштабирование по длине очереди Kafka
- type: kafka
metadata:
bootstrapServers: kafka:9092
consumerGroup: myapp-group
topic: orders
lagThreshold: "100"

# Масштабирование по метрикам Prometheus
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: http_requests_total
threshold: "1000"
query: sum(rate(http_requests_total{service="myapp"}[2m]))

Архитектура скейлинга

┌─────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Metrics │ │ HPA │ │
│ │ Server │────→│ Controller │ │
│ │ (Prometheus)│ │ │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Deployment │ │
│ │ replicas: 2→5 │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Cluster Autoscaler │ │
│ │ Добавляет/удаляет ноды при нехватке ресурсов │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Метрики для скейлинга

# custom-metrics-api.yaml
# Масштабирование по кастомным метрикам
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-custom-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: myapp
minReplicas: 2
maxReplicas: 20
metrics:
# Внешние метрики
- type: External
external:
metric:
name: pubsub.googleapis.com|subscription|num_undelivered_messages
selector:
matchLabels:
resource.labels.subscription_id: my-subscription
target:
type: AverageValue
averageValue: "30"

# Кастомные метрики из Prometheus
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "1000"

Итог

Тип скейлингаЧто масштабируетсяТриггер
HPAКоличество PodCPU, memory, custom metrics
VPAРесурсы Pod (CPU/memory)Использование ресурсов
Cluster AutoscalerКоличество нодНехватка ресурсов для Pod
KEDAКоличество PodСобытия (Kafka, RabbitMQ, etc.)
ManualКоличество PodРучное управление

Принцип: HPA для горизонтального масштабирования, VPA для вертикального, Cluster Autoscaler для инфраструктуры, KEDA для event-driven архитектур.

Вопрос 37. Что такое rate limiting и зачем он нужен?

Таймкод: 00:59:13

Ответ собеседника: правильный. Rate limiting — это ограничение на количество вызовов методов API, которые нельзя вызывать чаще определённого порогового значения. При превышении лимита возвращается ошибка (например, 429 Too Many Requests). Это нужно для защиты приложения от перегрузки и обеспечения высокой доступности.

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

Ответ собеседника верен. Дополним алгоритмами и практическими примерами.

Зачем нужен rate limiting

  • Защита от DDoS — предотвращение перегрузки сервера
  • Fair usage — равное распределение ресурсов между пользователями
  • Защита от злоупотреблений — брутфорс, скапинг данных
  • Контроль затрат — ограничение вызовов платных API

Алгоритмы rate limiting

1. Token Bucket

┌─────────────────────────────────────────┐
│ Token Bucket │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ T │ │ T │ │ T │ │ T │ │ T │ ← max │
│ └───┘ └───┘ └───┘ └───┘ └───┘ 5 │
│ ┌───┐ ┌───┐ │
│ │ T │ │ T │ ← current tokens: 2 │
│ └───┘ └───┘ │
│ │
│ Refill rate: 1 token/sec │
└─────────────────────────────────────────┘

Request → take token → if no tokens → reject (429)
type TokenBucket struct {
tokens float64
maxTokens float64
rate float64 // tokens per second
lastTime time.Time
mu sync.Mutex
}

func NewTokenBucket(maxTokens, rate float64) *TokenBucket {
return &TokenBucket{
tokens: maxTokens,
maxTokens: maxTokens,
rate: rate,
lastTime: time.Now(),
}
}

func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()

now := time.Now()
elapsed := now.Sub(tb.lastTime).Seconds()
tb.lastTime = now

// Добавляем токены
tb.tokens += elapsed * tb.rate
if tb.tokens > tb.maxTokens {
tb.tokens = tb.maxTokens
}

// Проверяем наличие токена
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}

2. Leaky Bucket

┌─────────────────────────────────────────┐
│ Leaky Bucket │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ R │ │ R │ │ R │ │ R │ ← queue │
│ └───┘ └───┘ └───┘ └───┘ max: 5 │
│ ↓ ↓ ↓ ↓ │
│ ═══════════════════════════ │
│ ↓ leak rate: 2 req/sec │
└─────────────────────────────────────────┘

Requests → queue → process at constant rate
If queue full → reject (429)
type LeakyBucket struct {
queue chan struct{}
interval time.Duration
}

func NewLeakyBucket(rate int, interval time.Duration) *LeakyBucket {
lb := &LeakyBucket{
queue: make(chan struct{}, rate),
interval: interval,
}

go lb.process()
return lb
}

func (lb *LeakyBucket) process() {
ticker := time.NewTicker(lb.interval)
defer ticker.Stop()

for range ticker.C {
select {
case <-lb.queue:
// Обработка запроса
default:
}
}
}

func (lb *LeakyBucket) Allow() bool {
select {
case lb.queue <- struct{}{}:
return true
default:
return false
}
}

3. Fixed Window Counter

┌─────────────────────────────────────────┐
│ Fixed Window │
│ │
│ Window 1 (00:00-00:01) │
│ ┌───┬───┬───┬───┬───┐ │
│ │ 1 │ 2 │ 3 │ 4 │ 5 │ ← count: 5 │
│ └───┴───┴───┴───┴───┘ max: 100 │
│ │
│ Window 2 (00:01-00:02) │
│ ┌───┬───┬───┐ │
│ │ 1 │ 2 │ 3 │ ← count: 3 │
│ └───┴───┴───┘ │
└─────────────────────────────────────────┘
type FixedWindow struct {
count int
maxCount int
window time.Duration
resetAt time.Time
mu sync.Mutex
}

func NewFixedWindow(maxCount int, window time.Duration) *FixedWindow {
return &FixedWindow{
maxCount: maxCount,
window: window,
resetAt: time.Now().Add(window),
}
}

func (fw *FixedWindow) Allow() bool {
fw.mu.Lock()
defer fw.mu.Unlock()

now := time.Now()
if now.After(fw.resetAt) {
fw.count = 0
fw.resetAt = now.Add(fw.window)
}

if fw.count < fw.maxCount {
fw.count++
return true
}
return false
}

4. Sliding Window Log

type SlidingWindow struct {
logs []time.Time
maxCount int
window time.Duration
mu sync.Mutex
}

func NewSlidingWindow(maxCount int, window time.Duration) *SlidingWindow {
return &SlidingWindow{
logs: make([]time.Time, 0, maxCount),
maxCount: maxCount,
window: window,
}
}

func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()

now := time.Now()
cutoff := now.Add(-sw.window)

// Удаляем старые записи
idx := 0
for i, t := range sw.logs {
if t.After(cutoff) {
idx = i
break
}
}
sw.logs = sw.logs[idx:]

if len(sw.logs) < sw.maxCount {
sw.logs = append(sw.logs, now)
return true
}
return false
}

Middleware для HTTP-сервера

func RateLimiterMiddleware(limiter *TokenBucket) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", "0")
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}

next.ServeHTTP(w, r)
})
}
}

// Использование
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", handler)

limiter := NewTokenBucket(100, 10) // 100 токенов, 10/сек

server := &http.Server{
Addr: ":8080",
Handler: RateLimiterMiddleware(limiter)(mux),
}

log.Fatal(server.ListenAndServe())
}

Rate limiting по клиенту (Redis)

func RedisRateLimiter(redisClient *redis.Client, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := r.RemoteAddr
key := fmt.Sprintf("ratelimit:%s", clientIP)

pipe := redisClient.Pipeline()
now := time.Now().UnixNano()
windowStart := now - window.Nanoseconds()

pipe.ZRemRangeByScore(key, "0", strconv.FormatInt(windowStart, 10))
pipe.ZAdd(key, redis.Z{Score: float64(now), Member: now})
pipe.ZCard(key)
pipe.Expire(key, window)

results, err := pipe.Exec()
if err != nil {
next.ServeHTTP(w, r)
return
}

count := results[2].(*redis.IntCmd).Val()
if int(count) > maxRequests {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}

next.ServeHTTP(w, r)
})
}
}

Итог

АлгоритмПлюсыМинусы
Token BucketДопускает burst, гибкийСложнее реализовать
Leaky BucketСтабильный rateНе допускает burst
Fixed WindowПростойBurst на границе окон
Sliding WindowТочныйБольше памяти

Для production рекомендуется использовать Redis-based rate limiting с sliding window для распределённых систем.

Вопрос 38. Что такое кэширование и зачем оно нужно? Назови стратегию кэширования.

Таймкод: 01:00:13

Ответ собеседника: неполный. Кэширование используется для хранения часто запрашиваемых данных ближе к приложению, чтобы не обращаться каждый раз к источнику данных. Это полезно для данных, которые редко меняются. Кандидат не смог назвать конкретную стратегию кэширования (например, LRU, TTL, write-through, write-back).

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

Собеседник верно описал суть кэширования, но не назвал конкретные стратегии. Рассмотрим все основные.

Зачем нужно кэширование

  • Ускорение ответов — данные из памяти вместо диска/сети
  • Снижение нагрузки — меньше запросов к БД и внешним сервисам
  • Повышение доступности — работа при недоступности основного источника

Стратегии кэширования

1. Cache-Aside (Lazy Loading)

┌──────┐ ┌───────┐ ┌──────┐
│ App │────→│ Cache │ │ DB │
│ │←────│ │ │ │
│ │ └───────┘ │ │
│ │──────────────────→│ │
│ │←──────────────────│ │
└──────┘ └──────┘

1. App проверяет cache
2. Если miss → читает из DB
3. Записывает в cache
4. Возвращает данные
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
// 1. Проверяем кэш
cacheKey := fmt.Sprintf("user:%d", id)
if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil {
var user User
if err := json.Unmarshal([]byte(cached), &user); err == nil {
return &user, nil
}
}

// 2. Cache miss — читаем из БД
user, err := s.db.GetUser(ctx, id)
if err != nil {
return nil, err
}

// 3. Записываем в кэш
data, _ := json.Marshal(user)
s.cache.Set(ctx, cacheKey, data, time.Hour)

return user, nil
}

2. Read-Through

┌──────┐ ┌───────┐ ┌──────┐
│ App │────→│ Cache │────→│ DB │
│ │←────│ │←────│ │
└──────┘ └───────┘ └──────┘

1. App читает из cache
2. Cache сам обращается к DB при miss
3. Cache возвращает данные
type ReadThroughCache struct {
db *sql.DB
redis *redis.Client
ttl time.Duration
}

func (c *ReadThroughCache) Get(ctx context.Context, key string, fetch func() (interface{}, error)) (interface{}, error) {
// Проверяем кэш
if cached, err := c.redis.Get(ctx, key).Result(); err == nil {
return cached, nil
}

// Cache miss — загружаем данные
data, err := fetch()
if err != nil {
return nil, err
}

// Сохраняем в кэш
c.redis.Set(ctx, key, data, c.ttl)
return data, nil
}

3. Write-Through

┌──────┐ ┌───────┐ ┌──────┐
│ App │────→│ Cache │────→│ DB │
│ │ │ │ │ │
└──────┘ └───────┘ └──────┘

1. App пишет в cache
2. Cache синхронно пишет в DB
3. Возврат после записи в оба места
func (s *Service) UpdateUser(ctx context.Context, user *User) error {
// 1. Обновляем БД
if err := s.db.UpdateUser(ctx, user); err != nil {
return err
}

// 2. Обновляем кэш
cacheKey := fmt.Sprintf("user:%d", user.ID)
data, _ := json.Marshal(user)
s.cache.Set(ctx, cacheKey, data, time.Hour)

return nil
}

4. Write-Behind (Write-Back)

┌──────┐ ┌───────┐ ┌──────┐
│ App │────→│ Cache │────→│ DB │
│ │ │ │ ──→ │ │
└──────┘ └───────┘ async└──────┘

1. App пишет в cache
2. Cache возвращает OK сразу
3. Cache асинхронно пишет в DB
type WriteBehindCache struct {
db *sql.DB
redis *redis.Client
queue chan WriteOp
}

type WriteOp struct {
Key string
Value interface{}
}

func (c *WriteBehindCache) Set(ctx context.Context, key string, value interface{}) error {
// 1. Записываем в кэш сразу
data, _ := json.Marshal(value)
if err := c.redis.Set(ctx, key, data, time.Hour).Err(); err != nil {
return err
}

// 2. Ставим в очередь на запись в БД
c.queue <- WriteOp{Key: key, Value: value}
return nil
}

func (c *WriteBehindCache) processWrites() {
for op := range c.queue {
// Асинхронная запись в БД
c.db.Exec("UPDATE ...", op.Value)
}
}

5. Write-Around

┌──────┐ ┌───────┐ ┌──────┐
│ App │ │ Cache │ │ DB │
│ │──────────────────→│ │
│ │ └───────┘ │ │
└──────┘ └──────┘

1. App пишет напрямую в DB
2. Кэш не затрагивается
3. При следующем чтении — cache miss и запись в кэш

Стратегии вытеснения (Eviction)

// LRU (Least Recently Used) — вытеснение давно не используемых
// TTL (Time To Live) — истечение срока жизни
// LFU (Least Frequently Used) — вытеснение редко используемых
// FIFO (First In First Out) — вытеснение по порядку добавления

// Redis настройка eviction
// maxmemory-policy allkeys-lru
// maxmemory-policy volatile-ttl

Проблемы кэширования

1. Cache Stampede (Thundering Herd)

// Проблема: множество запросов при истечении TTL
// Решение: singleflight

import "golang.org/x/sync/singleflight"

var sf singleflight.Group

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", id)

// Проверяем кэш
if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil {
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil
}

// Только один запрос идёт в БД
result, err, _ := sf.Do(cacheKey, func() (interface{}, error) {
user, err := s.db.GetUser(ctx, id)
if err != nil {
return nil, err
}
data, _ := json.Marshal(user)
s.cache.Set(ctx, cacheKey, data, time.Hour)
return user, nil
})

if err != nil {
return nil, err
}
return result.(*User), nil
}

2. Накладные расходы на кэш

// Проблема: кэш может быть недоступен
// Решение: circuit breaker

func (s *Service) GetFromCache(ctx context.Context, key string) (string, error) {
if s.cb.IsOpen() {
return "", ErrCacheUnavailable
}

result, err := s.cache.Get(ctx, key).Result()
if err != nil {
s.cb.RecordFailure()
return "", err
}

s.cb.RecordSuccess()
return result, nil
}

Итог

СтратегияКогда использовать
Cache-AsideЧтение, гибкость
Read-ThroughПростота для чтения
Write-ThroughКонсистентность важна
Write-BehindВысокая нагрузка на запись
Write-AroundРедкие повторные чтения
ВытеснениеОписание
LRUУдаляет давно не используемые
TTLУдаляет по истечении времени
LFUУдаляет редко используемые

Вопрос 39. Общая оценка кандидата по итогам собеседования (краткий фидбек от интервьюера)

Таймкод: 01:08:40

Ответ собеседника: правильный. Кандидат пришёл из другой сферы (1С), недавно начал изучать Go, но уже имеет хорошее понимание стандартных типов (слайсы, мапы, структуры, пустые структуры). Хорошо понимает интерфейсы, утиную типизацию, каналы, горутины, контексты. Работал с Docker, Kubernetes, знает про multi-stage build, поды, деплойменты. Писал тесты на Go (юнит и интеграционные с testcontainers). Слабые стороны: не разбирается в error группах, путает понятия pointer/value receiver, слабо знает runtime (управление рантаймом, GC алгоритм), поверхностно отвечал про HTTP/1 vs HTTP/2, не знает стратегии кэширования, не смог предложить решение для разделения чтения/записи между репликами. Общая оценка: для джуна/мидла без опыта на Go — нормальный уровень, но не хватает глубины и практики.

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

Оценка интервьюера объективна и детализирована. Структурируем по категориям.

Сильные стороны кандидата

1. Основы Go

✅ Стандартные типы данных
- Слайсы (slices)
- Мапы (maps)
- Структуры (structs)
- Пустые структуры (struct{})

✅ Интерфейсы и утиная типизация
✅ Каналы и горутины
✅ Контексты (context.Context)

2. Инфраструктура и DevOps

✅ Docker
- Multi-stage builds
- Оптимизация образов

✅ Kubernetes
- Поды (Pods)
- Деплойменты (Deployments)
- Базовые концепции

3. Тестирование

✅ Юнит-тесты
✅ Интеграционные тесты с Testcontainers

Слабые стороны кандидата

1. Продвинутые концепции Go

❌ Error группы (errgroup)
❌ Pointer vs Value receiver
❌ Runtime (GC, управление памятью)

2. Сетевые протоколы

❌ HTTP/1 vs HTTP/2 различия
❌ Стратегии кэширования
❌ Разделение чтения/записи (CQRS, read replicas)

Рекомендации кандидату для развития

1. Error Groups

import "golang.org/x/sync/errgroup"

func processItems(items []Item) error {
g, ctx := errgroup.WithContext(context.Background())

for _, item := range items {
item := item // capture range variable
g.Go(func() error {
return processItem(ctx, item)
})
}

return g.Wait() // возвращает первую ошибку
}

2. Pointer vs Value Receiver

type Counter struct {
mu sync.Mutex
count int
}

// Value receiver — работает с копией, не изменяет оригинал
func (c Counter) GetCount() int {
return c.count
}

// Pointer receiver — изменяет оригинал
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}

// Когда использовать pointer receiver:
// 1. Нужно изменять состояние
// 2. Структура большая (избегаем копирования)
// 3. Консистентность интерфейса (если один метод — pointer, то все)

3. Стратегии кэширования

Cache-Aside (Lazy Loading)
Read-Through
Write-Through
Write-Behind (Write-Back)
Write-Around

Eviction policies: LRU, TTL, LFU, FIFO

4. Разделение чтения/записи

┌──────┐ ┌─────────────┐
│ App │────→│ Primary DB │ ← Write
│ │ └──────┬──────┘
│ │ │ replication
│ │ ┌──────▼──────┐
│ │────→│ Replica DB │ ← Read
└──────┘ └─────────────┘

Реализация:
1. Два подключения к БД
2. Read queries → replica
3. Write queries → primary
type DB struct {
primary *sql.DB
replica *sql.DB
}

func (db *DB) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return db.replica.QueryContext(ctx, query, args...)
}

func (db *DB) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return db.primary.ExecContext(ctx, query, args...)
}

Итоговая оценка

КритерийОценка
Основы GoХорошо
Интерфейсы и конкурентностьХорошо
Docker/KubernetesХорошо
ТестированиеХорошо
Продвинутый GoТребует развития
Сетевые протоколыТребует развития
Архитектурные паттерныТребует развития

Рекомендация: Кандидат подходит на позицию junior/middle разработчика. Для роста рекомендуется углубить знания в runtime Go, изучить продвинутые паттерны конкурентности, разобраться с архитектурными решениями для высоконагруженных систем.

Вопрос 40. Инвариантность и принцип подстановки Лисков (LSP) в Go

Таймкод: 01:22:15

Ответ собеседника: неполный. Кандидат не смог дать точный ответ про инвариантность в контексте наследования в Go. Про L в SOLID (Liskov Substitution Principle) кандидат не знал, предположил, что это Dependency Inversion (D в SOLID).

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

Принцип подстановки Барбары Лисков (LSP)

LSP гласит: объекты подтипов должны быть заменяемы объектами базового типа без нарушения корректности программы.

// Классический пример нарушения LSP в языках с наследованием
// В Go это решается через интерфейсы

// Плохой пример (если бы было наследование):
type Bird interface {
Fly()
Walk()
}

type Ostrich struct{} // Страус не летает!

func (o Ostrich) Fly() {
panic("Ostrich can't fly!") // Нарушение LSP
}

func (o Ostrich) Walk() {}

// Хороший пример в Go:
type Walker interface {
Walk()
}

type Flyer interface {
Fly()
}

type Sparrow struct{}

func (s Sparrow) Walk() {}
func (s Sparrow) Fly() {}

type Ostrich struct{}

func (o Ostrich) Walk() {}

// Использование:
func MakeItWalk(w Walker) {
w.Work() // Работает и для Sparrow, и для Ostrich
}

Инвариантность в Go

В Go нет классического наследования, но есть embedding и интерфейсы. Инвариантность проявляется в следующем:

1. Ковариантность интерфейсов

// Интерфейс с методом, возвращающим конкретный тип
type Animal interface {
Name() string
}

type Dog struct {
name string
}

func (d Dog) Name() string {
return d.name
}

// Dog реализует Animal — это ковариантность
var a Animal = Dog{name: "Rex"}

2. Инвариантность параметров функций

// Функция принимает конкретный тип
func FeedDog(d Dog) {}

// Нельзя передать Animal вместо Dog — инвариантность
var a Animal = Dog{name: "Rex"}
// FeedDog(a) // Ошибка компиляции!

3. Embedding и LSP

type Base struct {
value int
}

func (b Base) Get() int {
return b.value
}

// Embedding — не наследование!
type Derived struct {
Base // Встраивание
extra int
}

// Derived "наследует" метод Get() от Base
d := Derived{Base: Base{value: 42}, extra: 10}
fmt.Println(d.Get()) // 42

// Но это не полиморфизм:
// var b Base = d // Ошибка компиляции!

4. Правильное использование LSP в Go

// Определяем интерфейс
type Reader interface {
Read(p []byte) (n int, err error)
}

// Разные реализации
type FileReader struct{}
func (fr FileReader) Read(p []byte) (int, error) { return 0, nil }

type NetworkReader struct{}
func (nr NetworkReader) Read(p []byte) (int, error) { return 0, nil }

type BufferReader struct{}
func (br BufferReader) Read(p []byte) (int, error) { return 0, nil }

// Функция работает с любым Reader — LSP соблюдён
func ProcessData(r Reader) error {
buf := make([]byte, 1024)
_, err := r.Read(buf)
return err
}

// Все реализации взаимозаменяемы:
ProcessData(FileReader{})
ProcessData(NetworkReader{})
ProcessData(BufferReader{})

5. Нарушение LSP в Go

// Плохой пример: интерфейс с избыточными требованиями
type Writer interface {
Write(p []byte) (n int, err error)
Flush() // Не все Writers умеют Flush!
}

type NetworkWriter struct{}
func (nw NetworkWriter) Write(p []byte) (int, error) { return 0, nil }
func (nw NetworkWriter) Flush() {}

type SimpleWriter struct{}
func (sw SimpleWriter) Write(p []byte) (int, error) { return 0, nil }
// SimpleWriter не реализует Writer — нарушение LSP!

// Хороший пример: разделение интерфейсов
type Writer interface {
Write(p []byte) (n int, err error)
}

type Flusher interface {
Flush()
}

// Композиция интерфейсов
type WriteFlusher interface {
Writer
Flusher
}

Итог

КонцепцияВ Go
НаследованиеНет, есть embedding
ПолиморфизмЧерез интерфейсы
LSPСоблюдается через правильное проектирование интерфейсов
ИнвариантностьПараметры функций инвариантны
КовариантностьВозвращаемые типы ковариантны

Ключевые моменты:

  1. В Go нет наследования в классическом смысле — есть embedding и интерфейсы
  2. LSP применяется к интерфейсам: любая реализация интерфейса должна быть взаимозаменяема
  3. Инвариантность проявляется в том, что параметры функций принимают только указанный тип
  4. Правильное проектирование интерфейсов (маленькие, сфокусированные) помогает соблюдать LSP

Вопрос 41. Что происходит с горутиной при попытке установить сетевое соединение или прочитать файл?

Таймкод: 01:25:46

Ответ собеседника: неполный. Кандидат начал отвечать про установку сетевого соединения и чтение файла, но не раскрыл суть вопроса. Не упомянул, что горутина блокируется при блокирующих операциях I/O, а Go runtime переключает её на другой поток ОС.

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

Модель горутин и I/O в Go

Горутины — это легковесные потоки, управляемые Go runtime, а не операционной системой. При блокирующих операциях I/O происходит следующее:

1. Сетевые операции (неблокирующие через netpoller)

┌─────────────────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ G1 │ │ G2 │ │ G3 │ Горутины │
│ │ (HTTP) │ │ (DB) │ │ (Calc) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────▼────────────▼────────────▼────┐ │
│ │ Scheduler (M:N) │ │
│ └────┬────────────┬────────────┬────┘ │
│ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
│ │ OS │ │ OS │ │ OS │ Потоки ОС │
│ │ Thread1 │ │ Thread2 │ │ Thread3 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Netpoller │ ← epoll/kqueue/IOCP │
│ │ (non-blocking I/O) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

2. Что происходит при сетевом соединении

// Когда горутина вызывает блокирующую операцию:
conn, err := net.Dial("tcp", "example.com:80")

// Go runtime делает следующее:
// 1. Переводит сокет в неблокирующий режим
// 2. Регистрирует в netpoller (epoll на Linux)
// 3. Горутина переходит в состояние "waiting"
// 4. Поток ОС освобождается для других горутин
// 5. Когда данные готовы — netpoller уведомляет runtime
// 6. Горутина возобновляется

3. Что происходит при чтении файла

// Файловые операции — особый случай!
data, err := os.ReadFile("file.txt")

// В отличие от сетевого I/O, файловые операции:
// 1. Блокируют поток ОС (нет настоящего async file I/O в Linux)
// 2. Go runtime создаёт дополнительный поток ОС
// 3. Горутина блокируется в этом потоке
// 4. Другие горутины продолжают работать в других потоках

4. Состояния горутины

// Горутина может находиться в состояниях:
// - Running: выполняется на потоке ОС
// - Runnable: готова к выполнению, ждёт в очереди
// - Waiting: заблокирована (I/O, каналы, мьютексы, sleep)

// Пример переключения состояний:
func worker() {
// Running: горутина выполняется

time.Sleep(time.Second)
// Waiting: горутина "спит", поток ОС свободен

conn, _ := net.Dial("tcp", "example.com:80")
// Waiting: горутина ждёт соединения через netpoller

buf := make([]byte, 1024)
conn.Read(buf)
// Waiting: горутина ждёт данных от сети
}

5. Netpoller под капотом

// Go runtime использует:
// - Linux: epoll
// - macOS/BSD: kqueue
// - Windows: IOCP

// Упрощённая схема работы netpoller:
type Netpoller struct {
epollFd int
}

func (np *Netpoller) Add(fd int, events int) {
// Регистрируем файловый дескриптор в epoll
syscall.EpollCtl(np.epollFd, syscall.EPOLL_CTL_ADD, fd, &event)
}

func (np *Netpoller) Wait() []Goroutine {
// Блокирующий вызов epoll_wait
// Возвращает список готовых файловых дескрипторов
// Для каждого — находим соответствующую горутину
// Переводим горутину в состояние Runnable
}

6. Практический пример

func handleConnection(conn net.Conn) {
defer conn.Close()

// Горутина блокируется здесь, но поток ОС свободен
buf := make([]byte, 4096)
n, err := conn.Read(buf) // ← Waiting state

if err != nil {
return
}

// Обработка данных
processData(buf[:n])
}

func main() {
ln, _ := net.Listen("tcp", ":8080")

for {
// Горутина блокируется здесь, но поток ОС свободен
conn, _ := ln.Accept() // ← Waiting state

// Для каждого соединения — новая горутина
go handleConnection(conn)
}
}

7. Разница между сетевым и файловым I/O

// Сетевое I/O — неблокирующее через netpoller
func networkIO() {
conn, _ := net.Dial("tcp", "example.com:80")
buf := make([]byte, 1024)
conn.Read(buf) // Горутина не блокирует поток ОС!
}

// Файловое I/O — блокирующее (в Linux)
func fileIO() {
f, _ := os.Open("file.txt")
buf := make([]byte, 1024)
f.Read(buf) // Горутина блокирует поток ОС!
// Go runtime создаёт дополнительный поток для этого
}

Итог

ОперацияПоведениеПоток ОС
Сетевое I/OНеблокирующее через netpollerНе блокируется
Файловое I/OБлокирующееБлокируется (runtime создаёт доп. поток)
КаналыБлокирующиеНе блокируется (горутина переключается)
SleepБлокирующийНе блокируется
МьютексыБлокирующиеНе блокируется

Ключевые моменты:

  1. Сетевые операции не блокируют потоки ОС благодаря netpoller
  2. Файловые операции блокируют потоки ОС (в Linux)
  3. Go runtime автоматически управляет переключением горутин
  4. Программист пишет блокирующий код, а runtime делает его неблокирующим

Вопрос 42. Как лучше устанавливать два и более соединений с базой данных (master и slave)?

Таймкод: 01:35:55

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

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

Подходы к маршрутизации запросов (Read/Write Splitting)

1. Маршрутизация на уровне приложения

type DB struct {
primary *sql.DB // Master — запись
replica *sql.DB // Slave — чтение
}

func NewDB(primaryDSN, replicaDSN string) (*DB, error) {
primary, err := sql.Open("postgres", primaryDSN)
if err != nil {
return nil, err
}

replica, err := sql.Open("postgres", replicaDSN)
if err != nil {
return nil, err
}

// Настройка пулов соединений
primary.SetMaxOpenConns(25)
primary.SetMaxIdleConns(5)

replica.SetMaxOpenConns(50) // Больше для чтения
replica.SetMaxIdleConns(10)

return &DB{primary: primary, replica: replica}, nil
}

func (db *DB) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return db.replica.QueryContext(ctx, query, args...)
}

func (db *DB) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return db.primary.ExecContext(ctx, query, args...)
}

// Использование
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
// Чтение — на replica
row := s.db.Query(ctx, "SELECT * FROM users WHERE id = $1", id)
// ...
}

func (s *Service) CreateUser(ctx context.Context, user *User) error {
// Запись — на primary
_, err := s.db.Exec(ctx, "INSERT INTO users ...", user.Name)
return err
}

2. Маршрутизация на уровне инфраструктуры (рекомендуется)

┌──────────┐ ┌─────────────┐ ┌──────────────┐
│ App │────→│ Proxy │────→│ Primary │
│ │ │ (Odyssey, │ │ (Master) │
│ Single │ │ PgBouncer, │ └──────────────┘
│ DSN │ │ HAProxy) │
│ │ │ │ ┌──────────────┐
└──────────┘ │ Маршрути- │────→│ Replica 1 │
│ зация │ └──────────────┘
│ запросов │
│ │ ┌──────────────┐
│ │────→│ Replica 2 │
└─────────────┘ └──────────────┘

3. Использование PgBouncer с маршрутизацией

# pgbouncer.ini
[databases]
mydb = host=primary.db.com port=5432 dbname=mydb
mydb_replica = host=replica.db.com port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25

4. Использование Odyssey (Yandex)

# odyssey.yml
listen:
port: 6432
backlog: 128

storage:
type: postgres
server: primary.db.com
port: 5432
database: mydb

replica_servers:
- host: replica1.db.com
port: 5432
- host: replica2.db.com
port: 5432

routing:
mode: read_write_split
read_targets: [replica_servers]
write_targets: [storage]

5. Продвинутый подход с автоматической маршрутизацией

type ReadWriteDB struct {
primary *sql.DB
replicas []*sql.DB
counter uint32 // Для round-robin
}

func (db *ReadWriteDB) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
// Round-robin между репликами
idx := atomic.AddUint32(&db.counter, 1) % uint32(len(db.replicas))
return db.replicas[idx].QueryContext(ctx, query, args...)
}

func (db *ReadWriteDB) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return db.primary.ExecContext(ctx, query, args...)
}

// Транзакции всегда на primary
func (db *ReadWriteDB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
return db.primary.BeginTx(ctx, opts)
}

6. Учёт задержки репликации

type Service struct {
db *ReadWriteDB
}

func (s *Service) GetUserProfile(ctx context.Context, userID int) (*Profile, error) {
// Для критичных данных — читаем с primary
if s.requiresFreshData(ctx) {
return s.getProfileFromPrimary(ctx, userID)
}
// Для остальных — с реплики
return s.getProfileFromReplica(ctx, userID)
}

func (s *Service) CreateOrder(ctx context.Context, order *Order) error {
// Запись на primary
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

// После записи — чтение с primary для консистентности
err = s.insertOrder(ctx, tx, order)
if err != nil {
return err
}

return tx.Commit()
}

Сравнение подходов

ПодходПлюсыМинусы
ПриложениеПростота, контрольДублирование логики, сложность поддержки
ProxyПрозрачность, централизацияДополнительный компонент
ГибридныйГибкостьСложность

Рекомендация: Для production используйте прокси (PgBouncer, Odyssey) для маршрутизации. Приложение работает с одним DSN, а прокси распределяет запросы.