Skip to content

intocode/100-go-interview-questions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 

Repository files navigation

100 вопросов для собеседования Go-разработчика

Сборник вопросов для подготовки к техническому собеседованию на позицию Go-разработчика.

Содержание

Язык Go

Основы

  1. Что такое zero value в Go и какие типы имеют какие значения по умолчанию?
  2. В каком порядке выполняются defer и когда вычисляются их аргументы?
  3. Как устроен слайс внутри и чем он отличается от массива?
  4. Как устроена map в Go и что произойдёт при конкурентной записи?
  5. Когда использовать panic/recover и почему recover работает только в defer?
  6. Что такое строка в Go и почему она иммутабельна?
  7. Что такое embedding в Go и чем оно отличается от наследования?
  8. Что такое пустая структура struct{} и когда её использовать?
  9. В чём разница между pointer receiver и value receiver?
  10. В чём разница между make и new?
  11. Можно ли сравнивать структуры в Go и что такое ограничение comparable?
  12. Как работает wrapping ошибок и зачем нужны errors.Is и errors.As?
  13. Что такое замыкание (closure) и какая типичная ошибка связана с циклами?
  14. Что такое функция init() и в каком порядке она вызывается?
  15. Что такое type assertion и type switch? Когда возникает panic?
  16. Как реализовано ООП в Go?
  17. Что такое iota и как она работает?
  18. Что такое теги структур (struct tags) и как они работают?
  19. Что такое variadic functions и как передать слайс в такую функцию?
  20. Что такое build tags (build constraints) и для чего они используются?
  21. Как работает range и какие особенности при итерации по разным типам?
  22. Как работает функция copy для слайсов?
  23. В чём разница между nil-слайсом и пустым слайсом?
  24. Можно ли вызвать метод на nil-указателе и когда это работает?
  25. Что такое дженерики (generics) в Go и какие ограничения у type parameters?
  26. Какие типы данных могут быть ключами map в Go?
  27. Зачем нужен strings.Builder и почему конкатенация через + неэффективна в цикле?
  28. В чём разница между nil interface и interface с nil-значением внутри?
  29. Как работают Go Modules и для чего нужны файлы go.mod и go.sum?
  30. Как растёт capacity слайса при append и почему?
  31. Как defer взаимодействует с именованными возвращаемыми значениями?

Конкурентность

  1. Что такое горутина и чем она отличается от потока ОС?
  2. Что такое каналы в Go и чем отличаются буферизованные от небуферизованных?
  3. Как работает select и что произойдёт, если все каналы заблокированы?
  4. Что такое data race и как её избежать в Go?
  5. Для чего используется пакет context и какие основные типы контекстов существуют?
  6. Что такое sync.WaitGroup и как его правильно использовать?
  7. Что такое sync.Once и когда его использовать?
  8. Что такое deadlock и как он возникает в Go?
  9. Что такое пакет sync/atomic и когда его использовать вместо мьютексов?
  10. В чём разница между sync.Mutex и sync.RWMutex?
  11. Что такое утечка горутин (goroutine leak) и как её обнаружить?
  12. Что такое singleflight и когда его использовать?
  13. Что произойдёт при записи/чтении в nil-канал и в закрытый канал?
  14. Как реализовать graceful shutdown HTTP-сервера в Go?
  15. В чём разница между конкурентностью и параллелизмом?
  16. Как правильно остановить горутину?
  17. Почему нельзя использовать time.After в цикле select и как это приводит к утечке памяти?
  18. Что такое sync.Pool и когда его использовать?
  19. Как ограничить количество одновременно работающих горутин?
  20. Что такое sync.Cond и когда его использовать вместо каналов?
  21. Что такое errgroup.Group и чем он отличается от sync.WaitGroup?
  22. Что такое паттерны Fan-Out и Fan-In в Go?

Внутреннее устройство и производительность

  1. Что такое модель GMP в планировщике Go?
  2. В чём разница между стеком и кучей в Go и почему аллокация в куче дороже?
  3. Как работает сборщик мусора в Go?
  4. Что такое pprof и как его использовать для профилирования Go-приложения?
  5. Почему порядок итерации по map не детерминирован?
  6. Что такое escape analysis и как понять, что переменная попала в кучу?
  7. Что такое рефлексия (reflection) в Go и когда её использовать?
  8. Как проверить на этапе компиляции, что тип реализует интерфейс?
  9. Как работает тестирование в Go и что такое table-driven tests?
  10. Что такое выравнивание структур (struct alignment) и как оно влияет на размер?
  11. Как устроен интерфейс внутри и чем отличается iface от eface?
  12. Как писать бенчмарки в Go и что такое b.N?

HTTP и REST API

  1. Как работает http.ListenAndServe и ServeMux внутри?
  2. Что такое идемпотентность HTTP-методов и какие методы являются идемпотентными?
  3. Чем отличаются HTTP-методы PUT и PATCH?
  4. Какие основные HTTP-статус-коды должен знать backend-разработчик?
  5. Что такое CORS и зачем он нужен?
  6. Что такое REST и какие принципы REST-архитектуры?
  7. В чём основные отличия HTTP/2 от HTTP/1.1?
  8. Какие способы версионирования REST API существуют и какой предпочтительнее?
  9. Что такое HATEOAS и почему его редко используют на практике?
  10. Что такое Idempotency Key и зачем он нужен в API?

Базы данных

  1. Какие уровни изоляции транзакций существуют в базах данных?
  2. Что такое N+1 проблема в базах данных и как её решить?
  3. Что такое индекс в базе данных и когда его НЕ стоит использовать?
  4. Что такое ACID в контексте баз данных?
  5. Что такое connection pooling и зачем он нужен?
  6. Что такое CAP-теорема и почему нельзя получить все три свойства одновременно?
  7. Чем отличается синхронная репликация от асинхронной?
  8. Чем отличается шардирование от партиционирования базы данных?
  9. Какие типы JOIN существуют в SQL и чем они отличаются?
  10. Чем отличается оптимистичная блокировка от пессимистичной?

Архитектура и микросервисы

  1. Чем микросервисная архитектура отличается от монолита?
  2. Что такое Circuit Breaker и зачем он нужен в микросервисах?
  3. Что такое API Gateway и зачем он нужен в микросервисах?
  4. Какие алгоритмы rate limiting существуют и чем они отличаются?
  5. Что такое Load Balancer и какие алгоритмы балансировки нагрузки существуют?
  6. Что такое gRPC и чем он отличается от REST?
  7. Что такое Dead Letter Queue и зачем она нужна?
  8. Что такое reverse proxy и зачем он нужен?

Сети, безопасность и инфраструктура

  1. В чём разница между JWT и сессионной аутентификацией?
  2. Чем Docker-контейнер отличается от виртуальной машины?
  3. Что такое multi-stage build в Docker и зачем он нужен для Go-приложений?
  4. Чем отличаются сигналы SIGTERM и SIGKILL в Linux?
  5. Чем отличается TCP от UDP и что такое TCP handshake?
  6. Что происходит, когда вводишь URL в браузер и нажимаешь Enter?
  7. Что такое OWASP Top 10 и какие уязвимости туда входят?

Язык Go

Основы

1. Что такое zero value в Go и какие типы имеют какие значения по умолчанию?

Zero value — значение, которым Go инициализирует переменную, если не указано иное. Переменная в Go никогда не бывает "неинициализированной" — она всегда имеет zero value своего типа.

Zero values для основных типов:

  • Числа (int, float64, и т.д.) — 0
  • boolfalse
  • string"" (пустая строка)
  • Указатели, func, interface, slice, map, channil
  • Структуры — все поля инициализированы своими zero values

Практические следствия:

  • var m map[string]int — nil-map, чтение вернёт zero value, но запись вызовет panic
  • var s []int — nil-slice, len(s) == 0, append работает корректно
  • Можно объявить структуру и сразу использовать: var wg sync.WaitGroup

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

defer откладывает выполнение функции до выхода из текущей функции. Несколько defer выполняются в порядке LIFO (последним пришёл — первым ушёл).

Аргументы defer вычисляются сразу при объявлении, а не при выполнении:

x := 1
defer fmt.Println(x) // выведет 1, не 2
x = 2

3. Как устроен слайс внутри и чем он отличается от массива?

Слайс — это структура из трёх полей: указатель на underlying array, длина (len) и ёмкость (cap).

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Отличия от массива:

  • Массив — значение фиксированного размера, копируется целиком при передаче
  • Слайс — ссылочный тип, при передаче копируется только структура (24 байта), не данные
  • При append сверх capacity создаётся новый массив (обычно с удвоенной ёмкостью)

4. Как устроена map в Go и что произойдёт при конкурентной записи?

Map — хеш-таблица. Данные организованы в массив бакетов, каждый из которых хранит до 8 пар ключ-значение. При коллизиях хешей создаются дополнительные бакеты, образующие цепочку.

Конкурентный доступ:

  • Одновременное чтение — безопасно
  • Одновременные чтение и запись или две записи — data race, программа упадёт с fatal error: concurrent map writes
  • Go специально детектирует конкурентную запись в map и завершает программу

Решения:

  • sync.Mutex или sync.RWMutex для защиты доступа
  • sync.Map — потокобезопасная map из стандартной библиотеки (эффективна при редких записях и частых чтениях)

5. Когда использовать panic/recover и почему recover работает только в defer?

panic — аварийное завершение программы при невосстановимых ошибках. recover — перехват паники для graceful shutdown.

Когда использовать panic:

  • Невозможно продолжить работу (не удалось открыть обязательный конфиг, порт занят)
  • Ошибка программиста (nil-указатель там, где он невозможен по контракту)

Когда НЕ использовать:

  • Для обычных ошибок — возвращай error
  • Для валидации пользовательского ввода

Почему recover только в defer: при панике Go разворачивает стек и выполняет только отложенные функции. Обычный код после panic не выполняется.

defer func() {
    if r := recover(); r != nil {
        log.Printf("recovered: %v", r)
    }
}()

Важно: recover перехватывает панику только в той же горутине, где она произошла.


6. Что такое строка в Go и почему она иммутабельна?

Строка в Go — это неизменяемая последовательность байт. Внутри это структура из двух полей: указатель на данные и длина.

type stringStruct struct {
    str unsafe.Pointer
    len int
}

Почему иммутабельна:

  • Безопасность: строку можно передавать между горутинами без копирования
  • Строки могут разделять память (substring ссылается на часть оригинала)
  • Хеш строки можно вычислить один раз и кэшировать

rune vs byte:

  • []byte — отдельные байты, для ASCII и бинарных данных
  • []rune — Unicode code points (символы), для работы с UTF-8 текстом
  • len("привет") вернёт 12 (байты), не 6 (символы)

Изменение строки: нужно конвертировать в []byte или []rune, изменить, конвертировать обратно. Каждая конвертация — аллокация.


7. Что такое embedding в Go и чем оно отличается от наследования?

Embedding (встраивание) — механизм композиции в Go: один тип включает другой без указания имени поля. Встроенный тип «продвигает» свои поля и методы во внешний тип.

type Logger struct{}
func (l Logger) Log(msg string) { fmt.Println(msg) }

type Server struct {
    Logger // embedding — можно вызывать s.Log() напрямую
}

Отличие от наследования:

  • Нет иерархии типов — Server НЕ является Logger
  • При вызове метода receiver остаётся встроенный тип, не внешний
  • Внешний тип может переопределить метод — тогда вызывается его версия

Виды embedding:

  • Структура в структуру — продвижение полей и методов
  • Интерфейс в интерфейс — объединение методов (io.ReadWriter = Reader + Writer)
  • Интерфейс в структуру — структура автоматически реализует интерфейс (частично, опасный паттерн)

8. Что такое пустая структура struct{} и когда её использовать?

Пустая структура struct{} — это тип без полей, который занимает 0 байт памяти. Go гарантирует, что unsafe.Sizeof(struct{}{}) == 0.

Основные применения:

  • Множество (Set): map[string]struct{} вместо map[string]bool экономит память — не нужно хранить булевы значения
  • Сигнальные каналы: chan struct{} для уведомлений без передачи данных (done-каналы, стоп-сигналы)
  • Семафоры: буферизованный канал chan struct{} как ограничитель конкурентности
// Множество
set := make(map[string]struct{})
set["key"] = struct{}{}
_, exists := set["key"]

// Сигнальный канал
done := make(chan struct{})
close(done) // сигнал всем слушателям

Почему не chan bool: для сигнала важен сам факт события, а не значение. struct{} явно показывает намерение и не требует решать, что передать — true или false.


9. В чём разница между pointer receiver и value receiver?

Value receiver (func (t T) Method()) — метод работает с копией значения. Изменения внутри метода не влияют на оригинал.

Pointer receiver (func (t *T) Method()) — метод работает с оригиналом через указатель. Изменения сохраняются.

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

  • Метод изменяет состояние структуры
  • Структура большая — копирование дорого
  • Нужна консистентность (если хотя бы один метод с pointer receiver, делай все с pointer receiver)

Важный нюанс: тип с pointer receiver нельзя присвоить интерфейсу через значение:

type Stringer interface { String() string }
type MyType struct{}
func (m *MyType) String() string { return "hello" }

var s Stringer = MyType{}  // ошибка компиляции
var s Stringer = &MyType{} // ок

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

new(T) — выделяет память под zero value типа T и возвращает указатель *T. Память инициализирована нулями.

make(T, args) — создаёт и инициализирует slice, map или channel. Возвращает сам тип T (не указатель).

p := new(int)         // *int, указывает на 0
s := make([]int, 5)   // []int с len=5, cap=5
m := make(map[string]int) // инициализированная map
ch := make(chan int)  // инициализированный канал

Ключевое отличие: new только выделяет память, make создаёт готовую к использованию структуру данных. Попытка использовать new(map[string]int) вернёт указатель на nil-map, в которую нельзя писать.


11. Можно ли сравнивать структуры в Go и что такое ограничение comparable?

Структуры в Go можно сравнивать через == и !=, только если все их поля сравнимы. Сравнение происходит поле за полем.

Сравнимые типы:

  • Числа, строки, булевы значения
  • Указатели и каналы
  • Массивы со сравнимыми элементами
  • Структуры со сравнимыми полями

Несравнимые типы:

  • Слайсы, мапы, функции
  • Структуры, содержащие несравнимые поля
type Point struct { X, Y int }
p1, p2 := Point{1, 2}, Point{1, 2}
fmt.Println(p1 == p2) // true

type Data struct { Values []int }
// d1 == d2 — ошибка компиляции: слайс несравним

Ограничение comparable — предопределённый constraint для дженериков, указывающий, что тип должен поддерживать == и !=. Используется для ключей map и в функциях поиска:

func Contains[T comparable](slice []T, item T) bool {
    for _, v := range slice {
        if v == item {
            return true
        }
    }
    return false
}

Важный нюанс (Go 1.20+): интерфейсы формально сравнимы, но сравнение двух интерфейсов с несравнимыми значениями внутри вызовет panic в рантайме.


12. Как работает wrapping ошибок и зачем нужны errors.Is и errors.As?

Wrapping (Go 1.13+) — добавление контекста к ошибке с сохранением оригинала. Используй %w в fmt.Errorf:

if err != nil {
    return fmt.Errorf("failed to connect: %w", err)
}

errors.Is — проверяет, есть ли в цепочке ошибок конкретная ошибка (sentinel):

if errors.Is(err, sql.ErrNoRows) {
    // обработка "не найдено"
}

errors.As — извлекает ошибку определённого типа из цепочки:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}

Зачем wrapping: позволяет добавить контекст ("что делали"), сохраняя возможность проверить причину ("что пошло не так").


13. Что такое замыкание (closure) и какая типичная ошибка связана с циклами?

Замыкание — функция, которая захватывает переменные из внешней области видимости. В Go анонимные функции являются замыканиями.

Типичная ошибка — захват переменной цикла:

for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i) // все горутины выведут 3
    }()
}

Все горутины захватывают одну и ту же переменную i, которая к моменту выполнения уже равна 3.

Решения:

  • Передать как аргумент: go func(i int) { ... }(i)
  • Создать локальную копию: i := i внутри цикла
  • В Go 1.22+ переменная цикла создаётся заново на каждой итерации (проблема исправлена)

14. Что такое функция init() и в каком порядке она вызывается?

init() — специальная функция, которая автоматически выполняется при инициализации пакета, до вызова main().

Порядок инициализации:

  1. Инициализация импортированных пакетов (рекурсивно, в порядке импорта)
  2. Инициализация глобальных переменных пакета
  3. Вызов всех init() функций пакета

Особенности:

  • В одном файле может быть несколько init() — выполняются в порядке объявления
  • В одном пакете может быть несколько файлов с init() — порядок файлов не гарантирован (зависит от компилятора)
  • init() нельзя вызвать явно
var x = compute() // 1. сначала глобальные переменные

func init() {
    // 2. потом init()
}

func main() {
    // 3. потом main()
}

15. Что такое type assertion и type switch? Когда возникает panic?

Type assertion — приведение интерфейса к конкретному типу. Извлекает значение из интерфейсной переменной.

var i interface{} = "hello"
s := i.(string) // s == "hello"

Panic возникает, если тип не совпадает:

var i interface{} = "hello"
n := i.(int) // panic: interface conversion: interface {} is string, not int

Безопасная форма (comma-ok idiom) — возвращает второе значение ok:

s, ok := i.(string) // ok == true, s == "hello"
n, ok := i.(int)    // ok == false, n == 0 (zero value), без panic

Type switch — проверка нескольких типов сразу:

switch v := i.(type) {
case string:
    fmt.Println("строка:", v)
case int:
    fmt.Println("число:", v)
default:
    fmt.Println("неизвестный тип")
}

16. Как реализовано ООП в Go?

В Go нет классического ООП — нет классов, наследования и конструкторов. Вместо этого используются:

  • Структуры (struct) — вместо классов, объединяют данные
  • Методы — функции с receiver'ом, привязаны к типу
  • Embedding — вместо наследования, композиция типов
  • Интерфейсы — неявная реализация (duck typing), полиморфизм

Go следует принципу "композиция вместо наследования". Тип реализует интерфейс автоматически, если имеет все его методы — явное implements не требуется.


17. Что такое iota и как она работает?

iota — встроенный идентификатор для генерации последовательных целых чисел в блоке констант. Начинается с 0 и увеличивается на 1 для каждой следующей константы.

const (
    Sunday = iota  // 0
    Monday         // 1
    Tuesday        // 2
)

Особенности:

  • Сбрасывается в 0 в каждом новом блоке const
  • Можно использовать в выражениях: 1 << iota для степеней двойки
  • Пропуск значения через _: _ = iota пропустит число, но счётчик увеличится
const (
    _  = iota             // 0 (пропущен)
    KB = 1 << (10 * iota) // 1024
    MB                    // 1048576
)

18. Что такое теги структур (struct tags) и как они работают?

Теги структур — строковые метаданные, прикреплённые к полям структуры. Записываются в обратных кавычках после типа поля.

type User struct {
    ID   int    `json:"id" db:"user_id"`
    Name string `json:"name,omitempty"`
}

Как работают:

  • Теги не влияют на поведение Go напрямую — это просто строки
  • Библиотеки читают теги через рефлексию (reflect.StructField.Tag)
  • Формат: ключ:"значение", несколько тегов разделяются пробелом

Распространённые теги:

  • json — сериализация в JSON
  • xml — сериализация в XML
  • db / gorm — маппинг на колонки БД
  • validate — правила валидации
  • yaml — сериализация в YAML

19. Что такое variadic functions и как передать слайс в такую функцию?

Variadic function — функция, принимающая переменное количество аргументов одного типа. Объявляется с помощью ... перед типом последнего параметра.

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

sum(1, 2, 3)       // вызов с отдельными аргументами
sum([]int{1,2,3}...) // передача слайса через spread оператор

Важные особенности:

  • Внутри функции variadic параметр — это слайс (nums имеет тип []int)
  • Для передачи слайса используется ... после переменной (spread оператор)
  • Variadic параметр должен быть последним в списке параметров

Частая ошибка: попытка передать слайс напрямую без ...:

s := []int{1, 2, 3}
sum(s)    // ошибка компиляции
sum(s...) // правильно

20. Что такое build tags (build constraints) и для чего они используются?

Build tags (ограничения сборки) — комментарии в начале файла, которые определяют, должен ли файл включаться в сборку.

Синтаксис (Go 1.17+):

//go:build linux && amd64

Применения:

  • Платформенно-специфичный код (//go:build windows или //go:build darwin)
  • Разделение интеграционных тестов: //go:build integration, запуск через go test -tags=integration
  • Feature flags: разные версии продукта (free/pro)

Важно: build tag должен быть в самом начале файла, перед package. Имя файла тоже может служить тегом: file_linux.go включится только при сборке под Linux.


21. Как работает range и какие особенности при итерации по разным типам?

range — конструкция для итерации по массивам, слайсам, map, строкам и каналам. Возвращает индекс и копию элемента.

Особенности по типам:

  • slice/array: for i, v := range sv это копия элемента, изменение v не влияет на оригинал
  • map: порядок итерации не детерминирован; безопасно удалять текущий ключ во время итерации
  • string: итерация по рунам (не байтам); индекс — позиция байта, значение — rune
  • channel: for v := range ch — читает до закрытия канала

Типичная ошибка — взятие адреса переменной цикла:

for _, v := range items {
    results = append(results, &v) // все указатели на одну переменную!
}

Решение: создать локальную копию v := v или использовать индекс &items[i].


22. Как работает функция copy для слайсов?

Встроенная функция copy(dst, src) копирует элементы из src в dst и возвращает количество скопированных элементов. Копируется min(len(dst), len(src)) элементов — функция никогда не расширяет destination-слайс.

src := []int{1, 2, 3}
dst := make([]int, 2)
n := copy(dst, src) // n == 2, dst == [1, 2]

Важные особенности:

  • copy работает корректно, даже если слайсы перекрываются (общий underlying array)
  • Возвращаемое значение — количество фактически скопированных элементов, не длина src
  • Для строк: copy([]byte, string) — копирует байты строки в слайс

23. В чём разница между nil-слайсом и пустым слайсом?

Nil-слайс — слайс, у которого все три поля (указатель, len, cap) равны нулю/nil:

var s []int // nil-слайс: s == nil, len(s) == 0, cap(s) == 0

Пустой слайс — слайс с len=0, но с ненулевым указателем на массив:

s := []int{}           // пустой слайс: s != nil, len(s) == 0
s := make([]int, 0)    // тоже пустой слайс

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

  • append работает одинаково для обоих
  • При сериализации в JSON: nil-слайс → null, пустой слайс → []
  • Проверка s == nil — true только для nil-слайса

Рекомендация: используй len(s) == 0 для проверки пустоты, а не s == nil.


24. Можно ли вызвать метод на nil-указателе и когда это работает?

Да, в Go можно вызвать метод на nil-указателе, если метод явно обрабатывает случай nil. Это не вызывает panic, пока метод не обращается к полям структуры.

type Node struct {
    Value int
    Next  *Node
}

func (n *Node) IsNil() bool {
    return n == nil // работает корректно
}

func (n *Node) GetValue() int {
    return n.Value // panic, если n == nil
}

Когда это полезно:

  • Рекурсивные структуры (деревья, списки): n.Size() может вернуть 0 для nil-узла
  • Graceful degradation: метод сам решает, что делать с nil

Почему работает: при вызове метода receiver передаётся как обычный аргумент — nil это валидное значение для указателя.


25. Что такое дженерики (generics) в Go и какие ограничения у type parameters?

Дженерики (Go 1.18+) — механизм параметризации типов, позволяющий писать функции и структуры, работающие с разными типами данных.

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

Type constraint — ограничение, определяющее допустимые типы для параметра:

  • any — любой тип (алиас для interface{})
  • comparable — типы, поддерживающие == и !=
  • constraints.Ordered — типы с операторами сравнения (<, >)

Ограничения дженериков в Go:

  • Нельзя использовать type parameter как тип в type assertion (x.(T))
  • Методы не могут иметь собственные type parameters (только у типа)
  • Нет специализации — нельзя написать особую реализацию для конкретного типа

26. Какие типы данных могут быть ключами map в Go?

Ключом map может быть любой comparable (сравнимый) тип — тип, для которого определены операции == и !=.

Могут быть ключами:

  • Числа, строки, bool
  • Указатели и каналы
  • Массивы фиксированной длины (если элементы сравнимы)
  • Структуры (если все поля сравнимы)

Не могут быть ключами:

  • Слайсы
  • Мапы
  • Функции
type Point struct { X, Y int }
m := make(map[Point]string)
m[Point{1, 2}] = "ok" // структура как ключ — работает

// map[[]int]string — ошибка компиляции: slice не может быть ключом

Почему именно comparable: для вставки и поиска в хеш-таблице нужно сравнивать ключи. Типы без операции сравнения не могут участвовать в этом процессе.


27. Зачем нужен strings.Builder и почему конкатенация через + неэффективна в цикле?

strings.Builder — тип для эффективного построения строк путём минимизации аллокаций памяти.

Проблема с оператором +: строки в Go иммутабельны, поэтому каждая конкатенация создаёт новую строку. В цикле это приводит к O(n²) аллокациям — на каждой итерации копируется вся накопленная строка.

// Плохо — O(n²) по памяти
s := ""
for i := 0; i < 1000; i++ {
    s += "a" // каждый раз новая аллокация
}

// Хорошо — O(n) по памяти
var b strings.Builder
for i := 0; i < 1000; i++ {
    b.WriteString("a")
}
result := b.String()

Как работает: strings.Builder хранит внутренний []byte-буфер. Новые данные дописываются в буфер, аллокация происходит только при расширении (по стратегии удвоения).

Особенности:

  • String() возвращает строку без копирования данных
  • Нельзя копировать после использования — panic при попытке изменить копию
  • Не потокобезопасен — нужна внешняя синхронизация

28. В чём разница между nil interface и interface с nil-значением внутри?

Это классическая ловушка на собеседованиях. Интерфейс в Go состоит из двух частей: типа и значения. Интерфейс равен nil только когда обе части равны nil.

var i interface{} = nil
fmt.Println(i == nil) // true — и тип, и значение nil

var p *int = nil
var j interface{} = p
fmt.Println(j == nil) // false! — тип *int, значение nil

Почему так происходит: когда nil-указатель присваивается интерфейсу, интерфейс запоминает тип указателя. Теперь у интерфейса есть тип (*int), хотя значение — nil.

Частая ошибка — возврат конкретного nil-указателя как error:

func getUser() error {
    var err *MyError = nil
    return err // интерфейс error будет != nil!
}

if err := getUser(); err != nil {
    // этот блок ВЫПОЛНИТСЯ, хотя err содержит nil
}

Как избежать: возвращай nil явно, а не через переменную конкретного типа:

func getUser() error {
    return nil // правильно
}

29. Как работают Go Modules и для чего нужны файлы go.mod и go.sum?

Go Modules — система управления зависимостями, появившаяся в Go 1.11. Модуль — это коллекция пакетов с версионированием.

go.mod содержит:

  • Путь модуля (module path) — идентификатор и путь для импорта
  • Версию Go
  • Список зависимостей с минимальными требуемыми версиями (require)
  • Замены (replace) и исключения (exclude)

go.sum содержит криптографические хеши всех зависимостей — гарантирует, что загруженный код не был изменён (защита от supply chain атак).

Основные команды:

  • go mod init — создать модуль
  • go mod tidy — добавить недостающие и удалить неиспользуемые зависимости
  • go get package@version — добавить/обновить зависимость
  • go mod download — скачать зависимости без сборки

Версионирование (SemVer): v1.2.3 — major.minor.patch. При изменении major (v2+) путь модуля должен включать версию: github.com/user/repo/v2.


30. Как растёт capacity слайса при append и почему?

При вызове append, если capacity слайса недостаточна, Go аллоцирует новый underlying array с увеличенной ёмкостью и копирует данные.

Стратегия роста (Go 1.18+):

  • Для маленьких слайсов (capacity < 256) — удвоение
  • Для больших слайсов — плавное уменьшение коэффициента роста до ~1.25
s := make([]int, 0)
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// cap: 1, 2, 4, 8, 8, 8, 8, 8, 16, 16

Почему именно так:

  • Удвоение для маленьких слайсов минимизирует количество аллокаций (амортизированная сложность O(1))
  • Меньший коэффициент для больших слайсов экономит память — удвоение терабайтного слайса неразумно

Практический совет: если известно количество элементов заранее — используй make([]T, 0, n) для предаллокации. Это избавляет от промежуточных аллокаций и копирований.


31. Как defer взаимодействует с именованными возвращаемыми значениями?

defer может изменить возвращаемое значение функции, если оно именованное. Это происходит потому, что именованные возвращаемые значения — это обычные переменные, доступные в defer.

func example() (result int) {
    defer func() { result++ }()
    return 0 // вернёт 1, не 0
}

Последовательность выполнения return x:

  1. Значение x присваивается именованной переменной result
  2. Выполняются все defer функции (могут изменить result)
  3. Функция возвращает текущее значение result

Без именованного возврата defer не может изменить результат:

func example() int {
    result := 0
    defer func() { result++ }()
    return result // вернёт 0, defer изменит локальную копию
}

Практическое применение: добавление контекста к ошибке или закрытие ресурсов с проверкой:

func readFile(path string) (data []byte, err error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer func() {
        if closeErr := f.Close(); closeErr != nil && err == nil {
            err = closeErr
        }
    }()
    return io.ReadAll(f)
}

Конкурентность

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

Горутина — легковесный поток выполнения, управляемый рантаймом Go, а не операционной системой. Основные отличия:

  • Размер стека горутины начинается с 2 КБ (у потока ОС обычно 1-8 МБ)
  • Переключение между горутинами дешевле — не требует системных вызовов
  • Тысячи горутин могут выполняться на нескольких потоках ОС (модель M:N)

33. Что такое каналы в Go и чем отличаются буферизованные от небуферизованных?

Канал — типобезопасный механизм коммуникации между горутинами.

Небуферизованный канал (make(chan T)):

  • Отправитель блокируется до тех пор, пока получатель не прочитает значение
  • Гарантирует синхронизацию между горутинами

Буферизованный канал (make(chan T, N)):

  • Отправитель блокируется только когда буфер заполнен
  • Получатель блокируется только когда буфер пуст

34. Как работает select и что произойдёт, если все каналы заблокированы?

select позволяет ждать операции на нескольких каналах одновременно. Выполняется первый готовый case; если готовы несколько — выбирается случайный.

Если все каналы заблокированы:

  • Без defaultselect блокируется до разблокировки любого канала
  • С default — сразу выполняется default, блокировки нет

35. Что такое data race и как её избежать в Go?

Data race (гонка данных) — ситуация, когда две или более горутин одновременно обращаются к одной переменной, и хотя бы одна из них выполняет запись.

Способы избежать:

  • sync.Mutex
  • sync.RWMutex
  • sync/atomic
  • Каналы

Как обнаружить: флаг -race при сборке или запуске (go run -race, go test -race).


36. Для чего используется пакет context и какие основные типы контекстов существуют?

Пакет context координирует горутины: передаёт дедлайны и сигналы отмены, а также значения в рамках запроса. Основное применение — корректное завершение долгих операций (HTTP-запросы, запросы к БД).

Основные функции создания:

  • context.Background() — пустой корневой контекст
  • context.TODO() — placeholder, когда непонятно какой контекст использовать
  • context.WithCancel(parent) — отмена вручную через cancel()
  • context.WithTimeout(parent, duration) — автоматическая отмена через время
  • context.WithDeadline(parent, time) — отмена в конкретный момент времени
  • context.WithValue(parent, key, val) — передача значений

37. Что такое sync.WaitGroup и как его правильно использовать?

sync.WaitGroup — примитив синхронизации для ожидания завершения группы горутин.

Методы:

  • Add(n) — увеличивает счётчик на n (вызывать до запуска горутины)
  • Done() — уменьшает счётчик на 1 (обычно через defer)
  • Wait() — блокируется, пока счётчик не станет равен 0
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // работа
    }()
}
wg.Wait()

Частые ошибки:

  • Вызов Add внутри горутины — гонка с Wait
  • Передача WaitGroup по значению — копирует счётчик, синхронизация ломается
  • Забытый DoneWait зависнет навсегда

38. Что такое sync.Once и когда его использовать?

sync.Once гарантирует, что функция выполнится ровно один раз, независимо от количества вызовов и горутин.

Типичное применение:

  • Lazy initialization (ленивая инициализация)
  • Singleton-ресурсы: пул соединений к БД, логгер, конфиг
var once sync.Once
var instance *DB

func GetDB() *DB {
    once.Do(func() {
        instance = connectToDB()
    })
    return instance
}

39. Что такое deadlock и как он возникает в Go?

Deadlock (взаимоблокировка) — ситуация, когда горутины заблокированы навсегда, ожидая друг друга.

Четыре условия возникновения:

  1. Взаимное исключение — ресурс нельзя использовать одновременно
  2. Удержание и ожидание — горутина держит ресурс и ждёт другой
  3. Невозможность отобрать — ресурс нельзя принудительно освободить
  4. Циклическое ожидание — замкнутая цепочка ожиданий

Типичные причины в Go:

  • Запись в небуферизованный канал без читателя
  • Чтение из канала, в который никто не пишет
  • Взаимная блокировка мьютексов (A ждёт B, B ждёт A)
  • Забытый close(ch) при использовании range
ch := make(chan int)
ch <- 1 // deadlock: некому читать

Отличие от data race: deadlock — программа зависает, data race — неопределённое поведение при конкурентном доступе.


40. Что такое пакет sync/atomic и когда его использовать вместо мьютексов?

Пакет sync/atomic предоставляет низкоуровневые атомарные операции над примитивными типами (int32, int64, uint32, uint64, uintptr, unsafe.Pointer).

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

  • atomic.Load* / atomic.Store* — атомарное чтение/запись
  • atomic.Add* — атомарное сложение
  • atomic.Swap* — атомарная замена с возвратом старого значения
  • atomic.CompareAndSwap* (CAS) — замена только если текущее значение равно ожидаемому
var counter int64
atomic.AddInt64(&counter, 1) // потокобезопасный инкремент

Когда использовать вместо мьютексов:

  • Простые операции с одной переменной (счётчики, флаги)
  • Нужна максимальная производительность (atomic быстрее mutex)
  • Реализация lock-free алгоритмов

Go 1.19+: добавлены типы atomic.Int64, atomic.Bool и другие с более удобным API.


41. В чём разница между sync.Mutex и sync.RWMutex?

sync.Mutex — эксклюзивная блокировка. В любой момент времени только одна горутина может владеть мьютексом.

sync.RWMutex — блокировка чтения-записи. Позволяет либо одному писателю, либо множеству читателей одновременно.

Методы RWMutex:

  • Lock() / Unlock() — эксклюзивная блокировка для записи
  • RLock() / RUnlock() — разделяемая блокировка для чтения

Когда использовать RWMutex:

  • Чтений значительно больше, чем записей (кэши, конфиги)
  • Операция чтения не изменяет данные

42. Что такое утечка горутин (goroutine leak) и как её обнаружить?

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

Частые причины:

  • Чтение из канала, в который никто не пишет и не закрывает
  • HTTP-клиент без таймаута — горутина ждёт ответа бесконечно
  • Отсутствие обработки ctx.Done() в долгих операциях
  • Забытый close(ch) при использовании range

Как обнаружить:

  • runtime.NumGoroutine() — мониторинг количества горутин
  • pprofgo tool pprof http://localhost:6060/debug/pprof/goroutine покажет стектрейсы заблокированных горутин

Как избежать: всегда передавай context.Context в долгие операции и проверяй ctx.Done() в select.


43. Что такое singleflight и когда его использовать?

singleflight — пакет из golang.org/x/sync/singleflight, который гарантирует, что для одного ключа одновременно выполняется только один запрос. Остальные горутины ждут результата первого вызова.

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

  • Cache stampede — при инвалидации кэша множество запросов одновременно идут в БД
  • Дорогие вычисления или внешние API с одинаковыми параметрами
  • Защита backend'а от дублирующих запросов

Важно: singleflight не заменяет кэш — он только предотвращает дублирование во время выполнения запроса. После завершения следующий вызов снова выполнит функцию.


44. Что произойдёт при записи/чтении в nil-канал и в закрытый канал?

nil-канал:

  • Запись — блокировка навсегда (deadlock, если нет других горутин)
  • Чтение — блокировка навсегда
  • Закрытие — panic

Закрытый канал:

  • Запись — panic
  • Чтение — возвращает оставшиеся значения из буфера, затем zero value с ok = false
  • Повторное закрытие — panic
v, ok := <-closedCh // ok == false, если канал пуст и закрыт

45. Как реализовать graceful shutdown HTTP-сервера в Go?

Graceful shutdown — корректное завершение сервера: перестать принимать новые соединения, дождаться обработки текущих запросов, освободить ресурсы.

srv := &http.Server{Addr: ":8080", Handler: handler}  // см. [HTTP-сервер][http-server]

go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

// Ожидание сигнала завершения (см. [Сигналы][signals])
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 := srv.Shutdown(ctx); err != nil {
    log.Fatal(err)
}

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

  • Shutdown не прерывает активные соединения — ждёт их завершения
  • Таймаут нужен, чтобы не ждать вечно зависшие соединения
  • После Shutdown сервер возвращает http.ErrServerClosed из ListenAndServe
  • Для WebSocket и long-polling нужна дополнительная логика отмены через контекст запроса

46. В чём разница между конкурентностью и параллелизмом?

Конкурентность (concurrency) — это композиция независимо выполняющихся процессов. Это про структуру программы: как разбить задачу на независимые части, которые могут выполняться в произвольном порядке.

Параллелизм (parallelism) — это одновременное выполнение вычислений. Это про исполнение: несколько операций буквально происходят в один момент времени на разных ядрах CPU.

Ключевое различие:

  • Конкурентность — про дизайн программы (dealing with lots of things at once)
  • Параллелизм — про исполнение (doing lots of things at once)

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

Go и конкурентность: Go спроектирован для конкурентности (горутины, каналы, select). Параллелизм — это бонус, который получается автоматически при запуске на многоядерном процессоре с GOMAXPROCS > 1.


47. Как правильно остановить горутину?

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

1. Через канал:

stop := make(chan struct{})
go func() {
    for {
        select {
        case <-stop:
            return
        default:
            // работа
        }
    }
}()
close(stop) // сигнал остановки

2. Через context.Context:

ctx, cancel := context.WithCancel(context.Background())
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // работа
        }
    }
}()
cancel() // отмена

Предпочтительный способcontext.Context, так как он поддерживает таймауты, дедлайны и передаётся по цепочке вызовов.

Частая ошибка: запуск горутины без механизма остановки — приводит к утечке горутин.


48. Почему нельзя использовать time.After в цикле select и как это приводит к утечке памяти?

time.After(d) при каждом вызове создаёт новый канал и горутину таймера, которые живут до истечения времени. В цикле это приводит к накоплению таймеров.

// Утечка памяти — каждую итерацию создаётся новый таймер
for {
    select {
    case msg := <-ch:
        process(msg)
    case <-time.After(time.Second): // утечка!
        log.Println("timeout")
    }
}

Решение — использовать time.NewTimer с явным сбросом:

timer := time.NewTimer(time.Second)
defer timer.Stop()

for {
    select {
    case msg := <-ch:
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(time.Second)
        process(msg)
    case <-timer.C:
        timer.Reset(time.Second)
        log.Println("timeout")
    }
}

Альтернатива — time.NewTicker для периодических событий.


49. Что такое sync.Pool и когда его использовать?

sync.Pool — это кэш временных объектов, которые можно переиспользовать между аллокациями. Позволяет снизить нагрузку на GC при частом создании и уничтожении однотипных объектов.

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func process() {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)
    // использование buf
}

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

  • Частые аллокации короткоживущих объектов (буферы, временные структуры)
  • Высоконагруженные сервисы с большим количеством горутин
  • Снижение давления на GC (меньше объектов для сборки)

Важные особенности:

  • Объекты могут быть удалены из пула в любой момент (между циклами GC) — нельзя полагаться на их сохранность
  • Перед возвратом объекта в пул нужно очищать его состояние (Reset())
  • Пул не ограничивает количество объектов — это кэш, а не connection pool

50. Как ограничить количество одновременно работающих горутин?

Используй буферизованный канал как семафор: размер буфера = максимальное число параллельных горутин.

sem := make(chan struct{}, 10) // максимум 10 горутин

for _, url := range urls {
    sem <- struct{}{} // занять слот (блокируется, если 10 горутин уже работают)
    go func(u string) {
        defer func() { <-sem }() // освободить слот
        fetch(u)
    }(url)
}

Принцип: отправка в канал блокируется, когда буфер заполнен. Горутина не запустится, пока одна из работающих не освободит слот чтением из канала.

Альтернативы:

  • errgroup.SetLimit(n) — ограничение в errgroup
  • golang.org/x/sync/semaphore — взвешенный семафор для разного «веса» задач

51. Что такое sync.Cond и когда его использовать вместо каналов?

sync.Cond — примитив синхронизации, реализующий условную переменную (condition variable). Позволяет горутинам ждать наступления определённого условия и получать уведомления об изменениях.

Методы:

  • Wait() — атомарно освобождает мьютекс и блокирует горутину до сигнала
  • Signal() — пробуждает одну ожидающую горутину
  • Broadcast() — пробуждает все ожидающие горутины

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

c.L.Lock()
for !condition() {
    c.Wait() // всегда в цикле, не в if
}
// работа с данными
c.L.Unlock()

Когда использовать вместо каналов:

  • Координация по булевому/числовому условию без передачи данных
  • Нужно пробудить либо одну, либо все горутины (каналы так не умеют)
  • Много ожидающих горутин — sync.Cond эффективнее по памяти

Почему Wait() вызывается в цикле: после пробуждения условие может снова стать ложным (spurious wakeup или другая горутина успела раньше).


52. Что такое errgroup.Group и чем он отличается от sync.WaitGroup?

errgroup.Group из пакета golang.org/x/sync/errgroup — расширение sync.WaitGroup с поддержкой обработки ошибок и отмены контекста.

Ключевые отличия от WaitGroup:

  • Возвращает первую ошибку из группы горутин через g.Wait()
  • При использовании errgroup.WithContext автоматически отменяет контекст при первой ошибке
  • SetLimit(n) ограничивает количество одновременно работающих горутин
  • Не нужно вызывать Add() — вызов Go() сам увеличивает счётчик
g, ctx := errgroup.WithContext(context.Background())

for _, url := range urls {
    g.Go(func() error {
        return fetch(ctx, url) // при ошибке ctx отменится
    })
}

if err := g.Wait(); err != nil {
    // первая ошибка из любой горутины
}

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

  • sync.WaitGroup — когда нужно просто дождаться завершения горутин без обработки ошибок
  • errgroup.Group — когда важно получить ошибку и/или отменить остальные горутины при сбое

53. Что такое паттерны Fan-Out и Fan-In в Go?

Fan-Out — распределение задач из одного источника на несколько горутин-обработчиков для параллельной обработки.

Fan-In — объединение результатов из нескольких каналов в один.

// Fan-Out: запуск N воркеров, читающих из одного канала задач
for i := 0; i < numWorkers; i++ {
    go worker(jobs, results)
}

// Fan-In: объединение нескольких каналов в один
func merge(cs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, c := range cs {
        wg.Add(1)
        go func(ch <-chan int) {
            defer wg.Done()
            for v := range ch {
                out <- v
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

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

  • Fan-Out — CPU-bound или I/O-bound задачи, которые можно выполнять параллельно (обработка файлов, HTTP-запросы)
  • Fan-In — сбор результатов от нескольких источников данных

Важно: порядок результатов не гарантирован — горутины завершаются в произвольном порядке.


Внутреннее устройство и производительность

54. Что такое модель GMP в планировщике Go?

GMP — модель планировщика рантайма Go, состоящая из трёх компонентов:

  • G (Goroutine) — горутина со своим стеком и состоянием
  • M (Machine) — поток ОС, выполняющий код
  • P (Processor) — логический процессор с локальной очередью горутин

P связывает G и M: у каждого P есть очередь готовых горутин, M берёт горутины из очереди своего P. Количество P задаётся через GOMAXPROCS (по умолчанию — число ядер CPU).

Зачем нужен P: до Go 1.1 была модель GM, где все горутины хранились в глобальной очереди с блокировкой. P добавляет локальные очереди — это уменьшает конкуренцию за глобальный лок.

Если очередь P пуста, M крадёт горутины из очередей других P.


55. В чём разница между стеком и кучей в Go и почему аллокация в куче дороже?

Стек — область памяти горутины для локальных переменных и вызовов функций. Работает по принципу LIFO: при выходе из функции память освобождается автоматически сдвигом указателя (очень быстро).

Куча — общая область памяти для всех горутин. Используется, когда переменная должна пережить функцию или её размер неизвестен на этапе компиляции. Управляется сборщиком мусора.

Почему куча дороже:

  • Нужен поиск свободного блока нужного размера
  • Требуется синхронизация (куча общая для всех горутин)
  • Создаёт нагрузку на GC — сборщик должен отслеживать и освобождать объекты

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

Go использует конкурентный сборщик мусора с трёхцветной маркировкой (tricolor mark-and-sweep).

Три цвета объектов:

  • Белый — не проверен, потенциально мусор
  • Серый — проверен, но ссылки ещё не обработаны
  • Чёрный — проверен, все ссылки обработаны

Фазы GC:

  1. Mark Setup (STW) — короткая пауза для подготовки
  2. Marking (concurrent) — маркировка достижимых объектов параллельно с программой
  3. Mark Termination (STW) — короткая пауза для завершения
  4. Sweeping (concurrent) — освобождение белых объектов

Write barrier: при изменении указателей во время маркировки барьер записи помечает объекты серым, чтобы не потерять живые объекты.

STW-паузы в современном Go — микросекунды (обычно <100μs), большая часть работы выполняется конкурентно.


57. Что такое pprof и как его использовать для профилирования Go-приложения?

pprof — встроенный инструмент для профилирования CPU, памяти и горутин в Go.

Подключение в HTTP-сервере:

import _ "net/http/pprof"
// автоматически регистрирует /debug/pprof/*

Основные профили:

  • cpu — где тратится процессорное время
  • heap — текущие аллокации в куче
  • goroutine — стектрейсы всех горутин (для поиска утечек)
  • block — где горутины блокируются на синхронизации
  • mutex — конкуренция за мьютексы

Анализ:

go tool pprof http://localhost:6060/debug/pprof/heap

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

  • Высокое потребление CPU — профиль cpu
  • Утечки памяти — профиль heap с опцией inuse_space
  • Утечки горутин — профиль goroutine

58. Почему порядок итерации по map не детерминирован?

Go намеренно рандомизирует порядок итерации по map. Это сделано, чтобы разработчики не полагались на случайный порядок, который может измениться между версиями Go или запусками программы.

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v) // порядок разный при каждом запуске
}

Технические причины:

  • При rehashing (увеличении map) элементы перераспределяются между bucket'ами
  • Начальная позиция итерации выбирается случайно
  • Это защита от атак hash collision (DoS), так как seed хеширования случаен

Если нужен стабильный порядок: отсортируй ключи отдельно и итерируй по ним.


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

Escape analysis — анализ компилятора, определяющий, где размещать переменную: на стеке или в куче.

Переменная попадает в кучу (escapes), если:

  • Возвращается указатель на локальную переменную
  • Переменная захватывается замыканием
  • Передаётся в interface{}
  • Размер слайса неизвестен на этапе компиляции
  • Объект слишком большой для стека

Как проверить:

go build -gcflags='-m' main.go

Вывод покажет escapes to heap для переменных, которые не удалось разместить на стеке.


60. Что такое рефлексия (reflection) в Go и когда её использовать?

Рефлексия — механизм для работы с типами и значениями во время выполнения программы. Пакет reflect позволяет узнать тип переменной, получить и изменить её значение, вызвать методы по имени.

Основные типы:

  • reflect.Type — описание типа (имя, размер, методы, поля структуры)
  • reflect.Value — значение, с которым можно работать динамически
v := reflect.ValueOf(42)
fmt.Println(v.Kind())  // int
fmt.Println(v.Int())   // 42

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

  • Сериализация/десериализация (encoding/json, gorm)
  • Dependency injection, ORM, валидация структур по тегам
  • Обобщённый код до появления дженериков

Почему избегать:

  • Медленнее прямого обращения (в 10-100 раз)
  • Ошибки компилятор не поймает — паника в рантайме
  • Код сложнее читать и поддерживать

61. Как проверить на этапе компиляции, что тип реализует интерфейс?

Используй присваивание к пустой переменной интерфейсного типа с помощью blank identifier:

var _ io.Reader = (*MyType)(nil)

Если MyType не реализует io.Reader, компилятор выдаст ошибку. Эта проверка:

  • Не создаёт реального объекта (используется nil)
  • Не занимает память в рантайме (blank identifier _ игнорируется)
  • Выполняется только на этапе компиляции

Зачем нужно:

  • Интерфейсы в Go реализуются неявно — легко случайно сломать контракт
  • Позволяет получить ошибку сразу, а не в рантайме при type assertion
  • Документирует намерение: "этот тип должен реализовывать этот интерфейс"

62. Как работает тестирование в Go и что такое table-driven tests?

Go имеет встроенную поддержку тестирования через пакет testing. Тестовые файлы именуются *_test.go, тестовые функции — func TestXxx(t *testing.T).

Запуск тестов:

  • go test — текущий пакет
  • go test ./... — все пакеты рекурсивно
  • go test -v — verbose вывод
  • go test -run TestName — конкретный тест

Table-driven tests — идиоматичный подход в Go, когда тестовые случаи описываются в слайсе структур:

func TestAdd(t *testing.T) {
    tests := []struct {
        a, b, want int
    }{
        {1, 2, 3},
        {0, 0, 0},
        {-1, 1, 0},
    }
    for _, tc := range tests {
        got := Add(tc.a, tc.b)
        if got != tc.want {
            t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
        }
    }
}

Преимущества: легко добавлять новые случаи, читаемость, один цикл вместо копирования кода.


63. Что такое выравнивание структур (struct alignment) и как оно влияет на размер?

Выравнивание (alignment) — требование процессора, чтобы данные располагались в памяти по адресам, кратным их размеру. Из-за этого компилятор добавляет padding (пустые байты) между полями структуры.

type Bad struct {
    a bool   // 1 байт + 7 padding
    b int64  // 8 байт
    c bool   // 1 байт + 7 padding
}
// unsafe.Sizeof(Bad{}) == 24

type Good struct {
    b int64  // 8 байт
    a bool   // 1 байт
    c bool   // 1 байт + 6 padding
}
// unsafe.Sizeof(Good{}) == 16

Правило оптимизации: располагай поля от большего к меньшему — так padding минимален.

Как проверить:

  • unsafe.Sizeof(s) — размер структуры в байтах
  • unsafe.Alignof(s) — требуемое выравнивание
  • unsafe.Offsetof(s.field) — смещение поля от начала структуры

Когда важно: при большом количестве объектов в памяти (кэши, массивы структур). Неоптимальный порядок полей может удвоить потребление памяти.


64. Как устроен интерфейс внутри и чем отличается iface от eface?

Интерфейс в Go — это пара указателей. В рантайме существуют две структуры в зависимости от типа интерфейса:

eface (empty interface) — для пустого интерфейса interface{} или any:

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

iface (interface) — для интерфейсов с методами:

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

itab содержит информацию о типе и таблицу методов (vtable):

  • inter — указатель на тип интерфейса
  • _type — указатель на конкретный тип
  • fun — массив указателей на методы

Практические следствия:

  • Размер любого интерфейса — 16 байт (два указателя)
  • Присваивание значения интерфейсу вызывает аллокацию, если значение не помещается в указатель
  • Сравнение интерфейсов сравнивает и тип, и значение
  • nil-интерфейс и интерфейс с nil-значением — разные вещи
var i interface{} = nil  // i == nil (true)
var p *int = nil
var j interface{} = p    // j != nil (true), но j содержит nil-указатель

65. Как писать бенчмарки в Go и что такое b.N?

Бенчмарки в Go пишутся в файлах *_test.go с сигнатурой func BenchmarkXxx(b *testing.B). Запуск: go test -bench=.

func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = "hello" + "world"
    }
}

b.N — количество итераций, автоматически подбираемое Go для получения статистически значимого результата. Чем быстрее операция, тем больше b.N.

Важные методы:

  • b.ResetTimer() — сбросить таймер после setup-кода
  • b.StopTimer() / b.StartTimer() — исключить код из измерений
  • b.ReportAllocs() — показать аллокации памяти

Как избежать оптимизации компилятором: результат операции нужно присваивать глобальной переменной или использовать, иначе компилятор может удалить «бесполезный» код.


HTTP и REST API

66. Как работает http.ListenAndServe и ServeMux внутри?

http.ListenAndServe открывает TCP-соединение, в бесконечном цикле принимает входящие соединения и для каждого запускает отдельную горутину.

ServeMux — маршрутизатор (мультиплексор), который хранит соответствие паттернов и обработчиков в map[string]muxEntry. При входящем запросе ищет наиболее подходящий паттерн и вызывает соответствующий Handler.ServeHTTP.

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

  • DefaultServeMux — глобальный ServeMux, используется если в ListenAndServe передать nil вместо handler
  • HandlerFunc — адаптер, позволяющий использовать обычную функцию как http.Handler
  • Каждый запрос обрабатывается в отдельной горутине — при 10000 одновременных запросов будет 10000 горутин
// HandlerFunc — это тип с методом ServeHTTP
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

67. Что такое идемпотентность HTTP-методов и какие методы являются идемпотентными?

Идемпотентность — свойство метода, при котором многократное выполнение одного и того же запроса даёт тот же результат, что и однократное.

Идемпотентные методы:

  • GET, HEAD, OPTIONS — безопасные (не изменяют данные) и идемпотентные
  • PUT, DELETE — изменяют данные, но идемпотентные

Неидемпотентные методы:

  • POST — создаёт новый ресурс при каждом вызове
  • PATCH — не гарантирует идемпотентность (зависит от реализации)

Почему это важно:

  • При сетевых сбоях идемпотентные запросы можно безопасно повторить
  • PUT /users/1 с телом {name: "Ivan"} — при повторе результат тот же
  • POST /users с тем же телом — создаст дубликат

Частая ошибка на собеседовании: считать DELETE неидемпотентным, потому что повторный вызов вернёт 404. Идемпотентность — про состояние сервера, а не про код ответа.


68. Чем отличаются HTTP-методы PUT и PATCH?

PUT — полная замена ресурса. Клиент отправляет полное представление объекта, сервер заменяет существующий ресурс целиком.

PATCH — частичное обновление. Клиент отправляет только изменённые поля.

PUT /users/1
{"name": "Ivan", "email": "ivan@example.com", "age": 30}

PATCH /users/1
{"age": 31}

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

  • PUT идемпотентен по спецификации, PATCH — не обязательно
  • PUT требует полный объект (пропущенные поля могут обнулиться)
  • PATCH экономит трафик при изменении одного поля

На практике: многие API используют PUT для любых обновлений. Важно документировать поведение и быть консистентным внутри проекта.


69. Какие основные HTTP-статус-коды должен знать backend-разработчик?

HTTP-статус-коды — трёхзначные числа в ответе сервера, показывающие результат обработки запроса.

2xx — успех:

  • 200 OK — запрос выполнен успешно
  • 201 Created — ресурс создан (ответ на POST)
  • 204 No Content — успех, но тело ответа пустое (ответ на DELETE)

4xx — ошибка клиента:

  • 400 Bad Request — некорректный запрос (валидация)
  • 401 Unauthorized — требуется аутентификация
  • 403 Forbidden — доступ запрещён (авторизация)
  • 404 Not Found — ресурс не найден
  • 409 Conflict — конфликт (например, дубликат)
  • 422 Unprocessable Entity — семантическая ошибка (часто для валидации)

5xx — ошибка сервера:

  • 500 Internal Server Error — необработанная ошибка на сервере
  • 502 Bad Gateway — проблема с upstream-сервером
  • 503 Service Unavailable — сервер временно недоступен
  • 504 Gateway Timeout — таймаут от upstream-сервера

70. Что такое CORS и зачем он нужен?

CORS (Cross-Origin Resource Sharing) — механизм, позволяющий браузеру выполнять запросы к другому домену (origin). По умолчанию браузер блокирует кросс-доменные запросы из соображений безопасности (Same-Origin Policy).

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

  1. Браузер отправляет preflight-запрос (OPTIONS) с заголовком Origin
  2. Сервер отвечает заголовками Access-Control-Allow-*
  3. Если origin разрешён — браузер выполняет основной запрос

Ключевые заголовки ответа:

  • Access-Control-Allow-Origin — разрешённые origins (* или конкретный домен)
  • Access-Control-Allow-Methods — разрешённые HTTP-методы
  • Access-Control-Allow-Headers — разрешённые заголовки запроса
  • Access-Control-Allow-Credentials — разрешить отправку cookies

Важно: CORS — это защита браузера, не сервера. curl и Postman игнорируют CORS. Backend должен дополнительно проверять авторизацию.


71. Что такое REST и какие принципы REST-архитектуры?

REST (Representational State Transfer) — архитектурный стиль для построения веб-сервисов, предложенный Роем Филдингом в 2000 году.

6 принципов REST:

  1. Client-Server — клиент и сервер разделены, развиваются независимо
  2. Stateless — сервер не хранит состояние клиента между запросами; каждый запрос содержит всю необходимую информацию
  3. Cacheable — ответы должны явно указывать, можно ли их кэшировать
  4. Uniform Interface — единообразный интерфейс (ресурсы идентифицируются через URI, манипуляция через представления, самоописательные сообщения, HATEOAS)
  5. Layered System — клиент не знает, общается он напрямую с сервером или через прокси/балансировщик
  6. Code on Demand (опционально) — сервер может отправлять исполняемый код клиенту

RESTful API — API, построенный по принципам REST. Использует HTTP-методы (GET, POST, PUT, DELETE), URI для идентификации ресурсов, JSON/XML для представления данных.


72. В чём основные отличия HTTP/2 от HTTP/1.1?

HTTP/2 — бинарный протокол (вместо текстового), что делает его компактнее и быстрее при парсинге.

Ключевые улучшения HTTP/2:

  • Мультиплексирование — несколько запросов идут параллельно по одному TCP-соединению, нет блокировки head-of-line
  • Сжатие заголовков (HPACK) — заголовки сжимаются и кэшируются, экономия трафика на повторяющихся заголовках
  • Server Push — сервер может отправить ресурсы до того, как клиент их запросит
  • Приоритизация потоков — клиент указывает важность запросов

Когда HTTP/2 даёт выигрыш: страницы с множеством мелких ресурсов (скрипты, стили, изображения). Для передачи одного большого файла разница минимальна.

Важно: семантика HTTP не изменилась — методы, статус-коды, заголовки остались прежними. HTTP/2 требует TLS в браузерах.


73. Какие способы версионирования REST API существуют и какой предпочтительнее?

Версионирование API — механизм поддержки нескольких версий API одновременно, позволяющий вносить breaking changes без поломки существующих клиентов.

Основные способы:

  • URI versioning — версия в пути: /api/v1/users, /api/v2/users. Самый распространённый, явный и простой для понимания
  • Query parameter — версия в параметре: /api/users?version=2. Менее явный, может конфликтовать с кэшированием
  • Header versioning — версия в заголовке: Accept: application/vnd.api.v2+json или кастомный API-Version: 2. Чистые URL, но сложнее тестировать и отлаживать
  • Media type (content negotiation) — версия в Accept header: Accept: application/vnd.company.myapp-v2+json. Соответствует REST-принципам, но сложен в реализации

Рекомендация: URI versioning (/v1/, /v2/) — стандарт индустрии. Используется в GitHub, Stripe, Google APIs. Прост в документации, тестировании и отладке через браузер.


74. Что такое HATEOAS и почему его редко используют на практике?

HATEOAS (Hypermedia as the Engine of Application State) — принцип REST, при котором сервер возвращает в ответе ссылки на возможные следующие действия. Клиент не знает заранее структуру API — он динамически узнаёт доступные операции из ответов.

{
  "id": 123,
  "name": "Ivan",
  "links": [
    { "rel": "self", "href": "/users/123" },
    { "rel": "orders", "href": "/users/123/orders" },
    { "rel": "delete", "href": "/users/123", "method": "DELETE" }
  ]
}

Зачем нужен:

  • Клиент не зависит от захардкоженных URL — при изменении структуры API клиент продолжает работать
  • API самодокументируется — клиент узнаёт доступные действия из ответа

Почему редко используют:

  • Увеличивает размер ответа
  • Клиенты (особенно мобильные) всё равно хардкодят URL для производительности
  • Сложнее реализовать и поддерживать
  • Большинство API достаточно стабильны, чтобы не менять структуру URL

На собеседовании: знание HATEOAS показывает понимание полной спецификации REST. Рой Филдинг считает, что без HATEOAS API нельзя называть RESTful.


75. Что такое Idempotency Key и зачем он нужен в API?

Idempotency Key — уникальный идентификатор, который клиент отправляет с запросом, чтобы сервер мог гарантировать однократную обработку операции при повторных запросах.

Проблема: POST-запросы не идемпотентны. Если клиент отправил запрос на создание заказа, но не получил ответ (таймаут, разрыв соединения), он не знает — заказ создан или нет. При повторе может создаться дубликат.

Решение — Idempotency Key:

  1. Клиент генерирует уникальный ключ (UUID) для каждой логической операции
  2. Ключ передаётся в заголовке: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  3. Сервер сохраняет ключ и результат операции (в Redis или БД)
  4. При повторном запросе с тем же ключом сервер возвращает сохранённый результат без повторного выполнения

Где используется:

  • Платёжные системы (Stripe, PayPal) — защита от двойного списания
  • Создание заказов — защита от дубликатов
  • Бронирование — защита от двойного бронирования

Важные нюансы:

  • Ключи должны иметь TTL (обычно 24 часа) — нельзя хранить вечно
  • При concurrent запросах с одним ключом нужна блокировка, чтобы второй запрос дождался результата первого
  • Сохранять нужно и успешные ответы, и ошибки бизнес-логики (но не сетевые ошибки)

Базы данных

76. Какие уровни изоляции транзакций существуют в базах данных?

Уровень изоляции определяет, какие аномалии допускаются при параллельном выполнении транзакций. От низшего к высшему:

Read Uncommitted — транзакция видит незакоммиченные изменения других транзакций (dirty read). На практике почти не используется.

Read Committed — транзакция видит только закоммиченные данные. Возможна аномалия non-repeatable read: повторное чтение той же строки может вернуть другие данные. Дефолт в PostgreSQL.

Repeatable Read — гарантирует одинаковый результат при повторном чтении строки. Возможна аномалия phantom read: новые строки, добавленные другой транзакцией, могут появиться в повторном запросе.

Serializable — полная изоляция, транзакции выполняются как будто последовательно. Самый медленный уровень.

Практика: для большинства задач достаточно Read Committed. Repeatable Read используют для отчётов и финансовых операций. Serializable — редко, только когда критична консистентность.


77. Что такое N+1 проблема в базах данных и как её решить?

N+1 — проблема производительности, когда для получения связанных данных выполняется 1 запрос на основную сущность и N дополнительных запросов на связанные записи.

// N+1 проблема: 1 запрос на users + N запросов на orders
users := db.Find(&users)
for _, u := range users {
    db.Where("user_id = ?", u.ID).Find(&u.Orders) // N запросов
}

Решения:

  • JOIN — получить все данные одним запросом
  • Preload (eager loading) — в GORM: db.Preload("Orders").Find(&users)
  • Batch loading — загрузить связанные данные пачкой: WHERE user_id IN (...)

Как обнаружить: включить логирование SQL-запросов и смотреть на количество запросов при обработке списка.


78. Что такое индекс в базе данных и когда его НЕ стоит использовать?

Индекс — структура данных (обычно B-tree), ускоряющая поиск строк по значению колонки. Без индекса БД сканирует всю таблицу (full table scan).

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

  • Колонки в WHERE, JOIN, ORDER BY
  • Колонки с высокой кардинальностью (много уникальных значений)
  • Первичные и внешние ключи (создаются автоматически)

Когда НЕ использовать:

  • Маленькие таблицы (< 1000 строк) — full scan быстрее
  • Колонки с низкой кардинальностью (boolean, enum с 2-3 значениями)
  • Таблицы с частыми INSERT/UPDATE — индексы замедляют запись
  • Колонки, которые редко участвуют в запросах

Цена индекса: занимает место на диске, замедляет INSERT/UPDATE/DELETE, так как БД должна обновлять индекс при каждом изменении данных.


79. Что такое ACID в контексте баз данных?

ACID — четыре свойства, гарантирующие надёжность транзакций в базе данных.

Atomicity (атомарность) — транзакция выполняется целиком или не выполняется вообще. При ошибке все изменения откатываются.

Consistency (согласованность) — транзакция переводит БД из одного валидного состояния в другое. Нарушение constraints откатывает транзакцию.

Isolation (изолированность) — параллельные транзакции не влияют друг на друга. Уровень изоляции определяет степень видимости изменений.

Durability (долговечность) — после COMMIT данные сохранены даже при сбое питания. Обеспечивается записью в WAL (Write-Ahead Log).

На практике: ACID гарантируют реляционные БД (PostgreSQL, MySQL). NoSQL часто жертвуют частью ACID ради производительности (eventual consistency).


80. Что такое connection pooling и зачем он нужен?

Connection pool — кэш открытых соединений с базой данных, которые переиспользуются вместо создания нового соединения на каждый запрос.

Зачем нужен:

  • Установка TCP-соединения + SSL handshake + аутентификация в БД занимает 50-200ms
  • При 1000 запросов/сек без пула — 1000 соединений и 1000 handshake'ов
  • С пулом из 20 соединений — 20 handshake'ов при старте, дальше переиспользование

Параметры пула:

  • MaxOpenConns — максимум открытых соединений
  • MaxIdleConns — максимум простаивающих соединений
  • ConnMaxLifetime — время жизни соединения (защита от утечек памяти в БД)

В Go (database/sql):

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)

Типичная ошибка: слишком большой пул. PostgreSQL плохо справляется с сотнями соединений — лучше использовать PgBouncer для connection pooling на стороне БД.


81. Что такое CAP-теорема и почему нельзя получить все три свойства одновременно?

CAP-теорема утверждает, что распределённая система может гарантировать только два из трёх свойств одновременно:

  • Consistency (согласованность) — все узлы видят одинаковые данные в любой момент времени
  • Availability (доступность) — каждый запрос получает ответ (успех или ошибка)
  • Partition tolerance (устойчивость к разделению) — система продолжает работать при потере связи между узлами

Почему нельзя все три: при сетевом разделении (P) система должна выбрать — либо отвечать устаревшими данными (A без C), либо отказывать в запросах до восстановления связи (C без A).

Примеры:

  • CP-системы: PostgreSQL с синхронной репликацией, ZooKeeper — консистентность важнее доступности
  • AP-системы: Cassandra, DynamoDB — доступность важнее, eventual consistency
  • CA-системы: возможны только в сети без сбоев (на практике не существуют)

На собеседовании: часто спрашивают, какой выбор делает конкретная БД и почему.


82. Чем отличается синхронная репликация от асинхронной?

Репликация — копирование данных с основного сервера (master) на резервные (replica/slave) для отказоустойчивости и масштабирования чтения.

Асинхронная репликация: транзакция подтверждается сразу после записи на master, данные доезжают до реплик в фоне. Быстрее, но при падении master часть данных может быть потеряна — они не успели скопироваться.

Синхронная репликация: транзакция подтверждается только после записи на master и хотя бы одну реплику. Медленнее, но гарантирует сохранность данных. При падении реплики запись блокируется — нужно минимум две реплики.

Когда что использовать:

  • Асинхронная — большинство случаев, когда допустима потеря нескольких последних транзакций
  • Синхронная — финансовые системы, критичные данные

Важно: репликация защищает от физических сбоев (сгорел диск), но не от логических (некорректные данные скопируются на реплику). Для защиты от логических ошибок нужен бэкап.


83. Чем отличается шардирование от партиционирования базы данных?

Партиционирование (partitioning) — разделение большой таблицы на логические части (партиции) внутри одного сервера БД. Данные делятся по определённому критерию (дата, диапазон ID, хеш), но остаются на одной машине. Приложение работает с таблицей как обычно — БД сама направляет запросы в нужную партицию.

Шардирование (sharding) — распределение данных между несколькими независимыми серверами (шардами). Каждый шард содержит часть данных и работает автономно. Требует логики маршрутизации на уровне приложения или middleware.

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

  • Партиционирование — один сервер, шардирование — несколько серверов
  • Партиционирование прозрачно для приложения, шардирование требует изменений в коде
  • Шардирование даёт горизонтальное масштабирование, партиционирование — только ускорение запросов

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


84. Какие типы JOIN существуют в SQL и чем они отличаются?

INNER JOIN — возвращает только строки, где есть совпадение в обеих таблицах.

LEFT JOIN — все строки из левой таблицы + совпадающие из правой. Если совпадения нет, правая часть заполняется NULL.

RIGHT JOIN — все строки из правой таблицы + совпадающие из левой. Используется редко — обычно переписывают на LEFT JOIN, меняя порядок таблиц.

FULL OUTER JOIN — все строки из обеих таблиц. Где нет совпадения — NULL. PostgreSQL поддерживает, MySQL — нет (эмулируется через UNION).

CROSS JOIN — декартово произведение: каждая строка первой таблицы соединяется с каждой строкой второй. Результат = N × M строк.

-- INNER JOIN: только пользователи с заказами
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN: все пользователи, даже без заказов
SELECT u.name, o.total FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

На собеседовании часто спрашивают: отличие INNER от LEFT, когда использовать каждый тип, и почему SELECT * FROM a, b без WHERE — это CROSS JOIN.


85. Чем отличается оптимистичная блокировка от пессимистичной?

Два подхода к обработке конкурентного доступа к данным в базе.

Пессимистичная блокировка (pessimistic locking) — блокируем запись до завершения работы. Другие транзакции ждут освобождения. Реализуется через SELECT ... FOR UPDATE или LOCK IN SHARE MODE.

BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- блокировка строки
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT; -- освобождение блокировки

Оптимистичная блокировка (optimistic locking) — не блокируем, но проверяем перед записью, что данные не изменились. Реализуется через поле версии (version) или updated_at:

UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;  -- WHERE включает текущую версию
-- Если affected rows = 0, значит кто-то изменил данные → retry

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

  • Пессимистичная — высокая конкуренция за данные, конфликты часты (финансовые операции, бронирование)
  • Оптимистичная — конфликты редки, большинство транзакций успешны (редактирование профиля, комментарии)

На собеседовании часто спрашивают: как реализовать оптимистичную блокировку в коде, что делать при конфликте (retry с экспоненциальным backoff), и почему пессимистичная блокировка может привести к deadlock.


Архитектура и микросервисы

86. Чем микросервисная архитектура отличается от монолита?

Монолит — единое приложение, где все компоненты (UI, бизнес-логика, БД) работают в одном процессе и деплоятся вместе.

Микросервисы — набор небольших независимых сервисов, каждый отвечает за одну бизнес-функцию, имеет свою БД и деплоится отдельно.

Плюсы микросервисов:

  • Независимый деплой и масштабирование отдельных сервисов
  • Разные команды могут использовать разные технологии
  • Отказ одного сервиса не роняет всю систему

Минусы микросервисов:

  • Сложность распределённой системы (сеть, latency, консистентность)
  • Операционные затраты: мониторинг, логирование, трейсинг
  • Сложнее отлаживать и тестировать

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


87. Что такое Circuit Breaker и зачем он нужен в микросервисах?

Circuit Breaker (автоматический выключатель) — паттерн, предотвращающий каскадные сбои в распределённых системах. Если сервис недоступен, нет смысла слать ему запросы — это тратит ресурсы и увеличивает latency.

Три состояния:

  • Closed — запросы проходят нормально, ошибки считаются
  • Open — запросы блокируются, сразу возвращается ошибка (fail fast)
  • Half-Open — пропускается несколько тестовых запросов для проверки восстановления

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

  1. При превышении порога ошибок (например, 50% за 10 секунд) — переход в Open
  2. После таймаута (например, 30 секунд) — переход в Half-Open
  3. Если тестовые запросы успешны — возврат в Closed, иначе — обратно в Open

Зачем нужен:

  • Защита от каскадных сбоев (один упавший сервис не роняет всю систему)
  • Быстрый fail вместо ожидания таймаута
  • Даёт время проблемному сервису восстановиться

88. Что такое API Gateway и зачем он нужен в микросервисах?

API Gateway — единая точка входа для всех клиентских запросов в микросервисной архитектуре. Вместо того чтобы клиент обращался к десяткам сервисов напрямую, он работает только с Gateway.

Основные функции:

  • Маршрутизация — перенаправление запросов к нужному микросервису
  • Аутентификация/авторизация — проверка токенов в одном месте, а не в каждом сервисе
  • Rate limiting — ограничение количества запросов от клиента
  • Агрегация — объединение ответов нескольких сервисов в один
  • SSL termination — обработка HTTPS на Gateway, внутри сети — HTTP
  • Кэширование — кэширование частых запросов

Примеры: Kong, NGINX, AWS API Gateway, Traefik.

Недостатки: единая точка отказа (нужна репликация), дополнительная latency, усложнение инфраструктуры.


89. Какие алгоритмы rate limiting существуют и чем они отличаются?

Rate limiting — ограничение количества запросов к API за единицу времени. Основные алгоритмы:

Token Bucket — корзина с токенами. Токены добавляются с фиксированной скоростью. Каждый запрос забирает токен. Нет токена — запрос отклонён. Позволяет короткие всплески трафика (burst), пока в корзине есть токены.

Leaky Bucket — запросы попадают в очередь и обрабатываются с постоянной скоростью. Очередь переполнена — запрос отклонён. Сглаживает трафик, но не позволяет burst.

Fixed Window Counter — время делится на фиксированные окна (например, 1 минута). Счётчик запросов сбрасывается в начале каждого окна. Минус: на границе окон можно получить двойной лимит.

Sliding Window Log — хранит timestamp каждого запроса. Точный, но требует много памяти при высокой нагрузке.

Sliding Window Counter — комбинация Fixed Window и Sliding Window Log. Взвешивает счётчики предыдущего и текущего окна пропорционально времени.

На практике: Token Bucket — самый популярный выбор (используется в Stripe, AWS). Хорошо балансирует между точностью и допуском коротких всплесков. В распределённых системах состояние хранится в Redis.


90. Что такое Load Balancer и какие алгоритмы балансировки нагрузки существуют?

Load Balancer (балансировщик нагрузки) — компонент, распределяющий входящий трафик между несколькими серверами для повышения отказоустойчивости и масштабируемости.

Основные алгоритмы:

  • Round Robin — запросы распределяются по очереди: первый на сервер 1, второй на сервер 2, и так по кругу. Прост, но не учитывает нагрузку серверов
  • Weighted Round Robin — то же, но серверам назначаются веса пропорционально их мощности
  • Least Connections — запрос направляется на сервер с наименьшим числом активных соединений. Учитывает реальную нагрузку
  • Least Response Time — выбирается сервер с наименьшим временем ответа
  • IP Hash — хеш IP-адреса клиента определяет сервер. Один клиент всегда попадает на один сервер (полезно для stateful-приложений)

Sticky Sessions — опция, привязывающая пользователя к серверу на время сессии через cookie. Нужна для приложений с состоянием, но усложняет масштабирование.


91. Что такое gRPC и чем он отличается от REST?

gRPC — фреймворк для удалённого вызова процедур от Google. Использует HTTP/2 для транспорта и Protocol Buffers (protobuf) для сериализации данных.

Ключевые отличия от REST:

  • Протокол: gRPC — бинарный (protobuf), REST — текстовый (JSON). Бинарный формат компактнее и быстрее парсится
  • Контракт: gRPC требует строгой схемы (.proto файл), REST — опционально (OpenAPI)
  • Транспорт: gRPC работает только по HTTP/2, REST — по HTTP/1.1 или HTTP/2
  • Стриминг: gRPC поддерживает 4 типа вызовов: unary, server streaming, client streaming, bidirectional streaming. REST — только запрос-ответ

Когда выбирать gRPC:

  • Межсервисная коммуникация в микросервисах (низкая latency)
  • Двунаправленный стриминг (чаты, real-time данные)
  • Строгая типизация критична

Когда выбирать REST:

  • Публичное API для внешних клиентов (браузеры, мобильные приложения)
  • Простая отладка (JSON читаем человеком)
  • Кэширование на уровне HTTP

92. Что такое Dead Letter Queue и зачем она нужна?

Dead Letter Queue (DLQ) — очередь для сообщений, которые не удалось обработать. Вместо потери или бесконечного retry неуспешные сообщения направляются в отдельную очередь для анализа и ручной обработки.

Когда сообщение попадает в DLQ:

  • Превышено максимальное число попыток обработки
  • Истёк TTL сообщения
  • Потребитель явно отклонил сообщение (reject/nack без requeue)
  • Очередь переполнена

Типичный паттерн:

  1. Сообщение обрабатывается основным consumer'ом
  2. При ошибке — retry с exponential backoff (например, 3 попытки)
  3. После исчерпания попыток — перенаправление в DLQ
  4. Алерт для разработчиков, ручной разбор причин

Реализация в разных системах:

  • RabbitMQ — через Dead Letter Exchange (DLX), настраивается на уровне очереди
  • Kafka — нет встроенной DLQ, реализуется отдельным топиком и логикой в consumer'е
  • AWS SQS — нативная поддержка DLQ с настройкой maxReceiveCount

Зачем нужна: позволяет не терять данные, не блокировать очередь «ядовитыми» сообщениями и анализировать причины сбоев.


93. Что такое reverse proxy и зачем он нужен?

Reverse proxy (обратный прокси) — сервер, который принимает запросы от клиентов и перенаправляет их на backend-серверы. Клиент не знает о существовании backend-серверов и думает, что общается напрямую с прокси.

Отличие от forward proxy: forward proxy работает от имени клиента (браузер → proxy → интернет), reverse proxy работает от имени сервера (клиент → proxy → backend).

Зачем нужен:

  • Балансировка нагрузки — распределяет запросы между несколькими backend-серверами
  • SSL-терминация — расшифровывает HTTPS на прокси, backend получает обычный HTTP
  • Кеширование — хранит статику и ответы, снижая нагрузку на backend
  • Безопасность — скрывает реальные IP-адреса backend-серверов, защищает от DDoS
  • Единая точка входа — один внешний IP для множества внутренних сервисов

Примеры: Nginx, HAProxy, Traefik, Envoy.

На собеседовании могут спросить: чем reverse proxy отличается от load balancer (reverse proxy — более широкое понятие, балансировка — одна из функций), и почему Nginx часто ставят перед Go-приложением (SSL-терминация, статика, защита от медленных клиентов).


Сети, безопасность и инфраструктура

94. В чём разница между JWT и сессионной аутентификацией?

Сессионная аутентификация (stateful): сервер создаёт сессию и хранит её в памяти или БД. Клиенту возвращается session ID в cookie. При каждом запросе сервер ищет сессию в хранилище.

JWT (stateless): сервер генерирует подписанный токен с данными пользователя. Токен хранится на клиенте и отправляется в заголовке Authorization. Сервер проверяет подпись, но не хранит токен.

JWT выбирают когда:

  • Нужна горизонтальная масштабируемость (нет общего хранилища сессий)
  • Микросервисы — токен проверяется без обращения к auth-сервису
  • Мобильные приложения — cookie неудобны

Сессии выбирают когда:

  • Критична возможность мгновенного отзыва (logout, бан пользователя)
  • Высокие требования к безопасности (банки, госорганы)
  • Нужен полный контроль над авторизацией каждого запроса

Проблема JWT: токен нельзя отозвать до истечения срока. Решения: короткий exp + refresh token, или blacklist в Redis (теряется stateless-преимущество).


95. Чем Docker-контейнер отличается от виртуальной машины?

Виртуальная машина (VM) запускает полноценную гостевую ОС поверх гипервизора. Каждая VM включает своё ядро, системные библиотеки и приложение. Запуск занимает минуты, потребляет гигабайты RAM.

Docker-контейнер использует ядро хост-системы и изолирует только процессы через механизмы Linux: namespaces (изоляция ресурсов) и cgroups (ограничение ресурсов). Запуск за секунды, потребление — мегабайты.

Когда VM лучше: нужна полная изоляция (разные ОС, безопасность), запуск Windows на Linux. Когда контейнеры лучше: микросервисы, CI/CD, быстрое масштабирование.


96. Что такое multi-stage build в Docker и зачем он нужен для Go-приложений?

Multi-stage build — возможность использовать несколько инструкций FROM в одном Dockerfile. Каждая стадия может иметь свой базовый образ, а артефакты копируются между стадиями через COPY --from.

Зачем нужен для Go:

  • Первая стадия — образ с Go SDK для сборки бинарника
  • Вторая стадия — минимальный образ (scratch, distroless, alpine) только с бинарником
  • Итоговый образ: ~10-30 МБ вместо ~1 ГБ с SDK
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o main .

FROM alpine:latest
COPY --from=builder /app/main /main
ENTRYPOINT ["/main"]

Преимущества:

  • Меньший размер образа — быстрее pull/push, меньше места на диске
  • Безопасность — в финальном образе нет компилятора и исходников
  • Кэширование — изменение кода не инвалидирует слой с зависимостями

97. Чем отличаются сигналы SIGTERM и SIGKILL в Linux?

Сигналы — механизм межпроцессного взаимодействия в Unix/Linux для уведомления процессов о событиях.

SIGTERM (15) — «вежливый» запрос на завершение. Процесс может перехватить сигнал, выполнить cleanup (закрыть файлы, соединения, сохранить данные) и корректно завершиться. Команда kill PID по умолчанию отправляет именно SIGTERM.

SIGKILL (9) — принудительное завершение. Процесс не может перехватить или игнорировать этот сигнал — ядро немедленно убивает процесс. Используется как крайняя мера, когда процесс не отвечает на SIGTERM.

Почему важно для backend-разработчика:

  • Kubernetes отправляет SIGTERM при остановке пода, затем ждёт grace period (по умолчанию 30 секунд) и отправляет SIGKILL
  • Graceful shutdown в Go: перехватываем SIGTERM через signal.Notify, завершаем текущие запросы, закрываем соединения
  • SIGKILL не даёт времени на cleanup — возможна потеря данных, незакрытые соединения

Другие важные сигналы:

  • SIGINT (2) — прерывание с терминала (Ctrl+C)
  • SIGHUP (1) — разрыв соединения с терминалом, часто используется для перечитывания конфига
  • SIGSTOP/SIGCONT — приостановка и возобновление процесса

98. Чем отличается TCP от UDP и что такое TCP handshake?

TCP (Transmission Control Protocol) — надёжный протокол с установлением соединения. Гарантирует доставку данных в правильном порядке, повторяет потерянные пакеты, контролирует перегрузку сети.

UDP (User Datagram Protocol) — ненадёжный протокол без соединения. Отправляет пакеты без подтверждения доставки. Быстрее TCP, но пакеты могут теряться, дублироваться или приходить в неправильном порядке.

TCP handshake (трёхстороннее рукопожатие) — процесс установления TCP-соединения:

  1. SYN — клиент отправляет пакет с флагом SYN и начальным номером последовательности
  2. SYN-ACK — сервер отвечает пакетом с флагами SYN и ACK, подтверждая получение и отправляя свой номер последовательности
  3. ACK — клиент отправляет подтверждение, соединение установлено

Когда использовать TCP: HTTP/HTTPS, передача файлов, email, базы данных — везде, где важна целостность данных.

Когда использовать UDP: DNS-запросы, видео-стриминг, онлайн-игры, VoIP — где скорость важнее надёжности и допустима потеря отдельных пакетов.


99. Что происходит, когда вводишь URL в браузер и нажимаешь Enter?

Классический вопрос, проверяющий понимание всего стека технологий. Основные этапы:

  1. Парсинг URL — браузер разбирает URL на протокол, домен, порт, путь
  2. DNS-резолвинг — поиск IP-адреса: кэш браузера → кэш ОС → файл hosts → DNS-сервер
  3. TCP-соединение — трёхстороннее рукопожатие (SYN → SYN-ACK → ACK)
  4. TLS-хендшейк — для HTTPS: обмен сертификатами, согласование ключей шифрования
  5. HTTP-запрос — браузер отправляет GET-запрос с заголовками (Host, User-Agent, Accept)
  6. Обработка на сервере — роутинг, выполнение кода, запросы к БД, формирование ответа
  7. HTTP-ответ — сервер возвращает статус-код, заголовки и тело (HTML)
  8. Рендеринг — браузер парсит HTML, строит DOM, загружает CSS/JS, отрисовывает страницу

Что ожидают на собеседовании: глубина ответа зависит от уровня. Junior — базовый flow. Middle — детали DNS, TCP, кэширование. Senior — оптимизации, CDN, connection pooling, HTTP/2.


100. Что такое OWASP Top 10 и какие уязвимости туда входят?

OWASP Top 10 — стандартный документ, описывающий десять наиболее критичных уязвимостей веб-приложений. Обновляется раз в несколько лет на основе реальных данных об атаках.

Ключевые уязвимости (версия 2021, актуальна для собеседований):

  • A01: Broken Access Control — отсутствие проверки прав доступа: пользователь может получить данные или выполнить действия, которые ему недоступны (IDOR, отсутствие проверки ролей)
  • A02: Cryptographic Failures — небезопасное хранение данных: пароли в открытом виде, слабые алгоритмы шифрования, отсутствие HTTPS
  • A03: Injection — внедрение кода (SQL, NoSQL, OS, LDAP) через пользовательский ввод без санитизации
  • A04: Insecure Design — архитектурные ошибки, которые невозможно исправить только кодом
  • A05: Security Misconfiguration — дефолтные пароли, открытые debug-эндпоинты, избыточные права
  • A06: Vulnerable Components — использование библиотек с известными уязвимостями
  • A07: Authentication Failures — слабые пароли, отсутствие rate-limiting на логин, неправильная работа с сессиями

Что спрашивают на собеседованиях: обычно просят назвать 3-5 уязвимостей и объяснить, как от них защититься. Особенно важны SQL Injection (параметризованные запросы), XSS (экранирование вывода, CSP) и Broken Access Control (проверка прав на backend).


Ссылки

About

No description, website, or topics provided.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors