Сборник вопросов для подготовки к техническому собеседованию на позицию Go-разработчика.
Основы
- Что такое zero value в Go и какие типы имеют какие значения по умолчанию?
- В каком порядке выполняются defer и когда вычисляются их аргументы?
- Как устроен слайс внутри и чем он отличается от массива?
- Как устроена map в Go и что произойдёт при конкурентной записи?
- Когда использовать panic/recover и почему recover работает только в defer?
- Что такое строка в Go и почему она иммутабельна?
- Что такое embedding в Go и чем оно отличается от наследования?
- Что такое пустая структура
struct{}и когда её использовать? - В чём разница между pointer receiver и value receiver?
- В чём разница между make и new?
- Можно ли сравнивать структуры в Go и что такое ограничение comparable?
- Как работает wrapping ошибок и зачем нужны errors.Is и errors.As?
- Что такое замыкание (closure) и какая типичная ошибка связана с циклами?
- Что такое функция init() и в каком порядке она вызывается?
- Что такое type assertion и type switch? Когда возникает panic?
- Как реализовано ООП в Go?
- Что такое iota и как она работает?
- Что такое теги структур (struct tags) и как они работают?
- Что такое variadic functions и как передать слайс в такую функцию?
- Что такое build tags (build constraints) и для чего они используются?
- Как работает range и какие особенности при итерации по разным типам?
- Как работает функция copy для слайсов?
- В чём разница между nil-слайсом и пустым слайсом?
- Можно ли вызвать метод на nil-указателе и когда это работает?
- Что такое дженерики (generics) в Go и какие ограничения у type parameters?
- Какие типы данных могут быть ключами map в Go?
- Зачем нужен strings.Builder и почему конкатенация через + неэффективна в цикле?
- В чём разница между nil interface и interface с nil-значением внутри?
- Как работают Go Modules и для чего нужны файлы go.mod и go.sum?
- Как растёт capacity слайса при append и почему?
- Как defer взаимодействует с именованными возвращаемыми значениями?
Конкурентность
- Что такое горутина и чем она отличается от потока ОС?
- Что такое каналы в Go и чем отличаются буферизованные от небуферизованных?
- Как работает
selectи что произойдёт, если все каналы заблокированы? - Что такое data race и как её избежать в Go?
- Для чего используется пакет
contextи какие основные типы контекстов существуют? - Что такое sync.WaitGroup и как его правильно использовать?
- Что такое sync.Once и когда его использовать?
- Что такое deadlock и как он возникает в Go?
- Что такое пакет sync/atomic и когда его использовать вместо мьютексов?
- В чём разница между sync.Mutex и sync.RWMutex?
- Что такое утечка горутин (goroutine leak) и как её обнаружить?
- Что такое singleflight и когда его использовать?
- Что произойдёт при записи/чтении в nil-канал и в закрытый канал?
- Как реализовать graceful shutdown HTTP-сервера в Go?
- В чём разница между конкурентностью и параллелизмом?
- Как правильно остановить горутину?
- Почему нельзя использовать time.After в цикле select и как это приводит к утечке памяти?
- Что такое sync.Pool и когда его использовать?
- Как ограничить количество одновременно работающих горутин?
- Что такое sync.Cond и когда его использовать вместо каналов?
- Что такое errgroup.Group и чем он отличается от sync.WaitGroup?
- Что такое паттерны Fan-Out и Fan-In в Go?
Внутреннее устройство и производительность
- Что такое модель GMP в планировщике Go?
- В чём разница между стеком и кучей в Go и почему аллокация в куче дороже?
- Как работает сборщик мусора в Go?
- Что такое pprof и как его использовать для профилирования Go-приложения?
- Почему порядок итерации по map не детерминирован?
- Что такое escape analysis и как понять, что переменная попала в кучу?
- Что такое рефлексия (reflection) в Go и когда её использовать?
- Как проверить на этапе компиляции, что тип реализует интерфейс?
- Как работает тестирование в Go и что такое table-driven tests?
- Что такое выравнивание структур (struct alignment) и как оно влияет на размер?
- Как устроен интерфейс внутри и чем отличается iface от eface?
- Как писать бенчмарки в Go и что такое b.N?
- Как работает http.ListenAndServe и ServeMux внутри?
- Что такое идемпотентность HTTP-методов и какие методы являются идемпотентными?
- Чем отличаются HTTP-методы PUT и PATCH?
- Какие основные HTTP-статус-коды должен знать backend-разработчик?
- Что такое CORS и зачем он нужен?
- Что такое REST и какие принципы REST-архитектуры?
- В чём основные отличия HTTP/2 от HTTP/1.1?
- Какие способы версионирования REST API существуют и какой предпочтительнее?
- Что такое HATEOAS и почему его редко используют на практике?
- Что такое Idempotency Key и зачем он нужен в API?
- Какие уровни изоляции транзакций существуют в базах данных?
- Что такое N+1 проблема в базах данных и как её решить?
- Что такое индекс в базе данных и когда его НЕ стоит использовать?
- Что такое ACID в контексте баз данных?
- Что такое connection pooling и зачем он нужен?
- Что такое CAP-теорема и почему нельзя получить все три свойства одновременно?
- Чем отличается синхронная репликация от асинхронной?
- Чем отличается шардирование от партиционирования базы данных?
- Какие типы JOIN существуют в SQL и чем они отличаются?
- Чем отличается оптимистичная блокировка от пессимистичной?
- Чем микросервисная архитектура отличается от монолита?
- Что такое Circuit Breaker и зачем он нужен в микросервисах?
- Что такое API Gateway и зачем он нужен в микросервисах?
- Какие алгоритмы rate limiting существуют и чем они отличаются?
- Что такое Load Balancer и какие алгоритмы балансировки нагрузки существуют?
- Что такое gRPC и чем он отличается от REST?
- Что такое Dead Letter Queue и зачем она нужна?
- Что такое reverse proxy и зачем он нужен?
- В чём разница между JWT и сессионной аутентификацией?
- Чем Docker-контейнер отличается от виртуальной машины?
- Что такое multi-stage build в Docker и зачем он нужен для Go-приложений?
- Чем отличаются сигналы SIGTERM и SIGKILL в Linux?
- Чем отличается TCP от UDP и что такое TCP handshake?
- Что происходит, когда вводишь URL в браузер и нажимаешь Enter?
- Что такое OWASP Top 10 и какие уязвимости туда входят?
Zero value — значение, которым Go инициализирует переменную, если не указано иное. Переменная в Go никогда не бывает "неинициализированной" — она всегда имеет zero value своего типа.
Zero values для основных типов:
- Числа (
int,float64, и т.д.) —0 bool—falsestring—""(пустая строка)- Указатели,
func,interface, slice, map,chan—nil - Структуры — все поля инициализированы своими zero values
Практические следствия:
var m map[string]int— nil-map, чтение вернёт zero value, но запись вызовет panicvar s []int— nil-slice,len(s) == 0,appendработает корректно- Можно объявить структуру и сразу использовать:
var wg sync.WaitGroup
defer откладывает выполнение функции до выхода из текущей функции. Несколько defer выполняются в порядке LIFO (последним пришёл — первым ушёл).
Аргументы defer вычисляются сразу при объявлении, а не при выполнении:
x := 1
defer fmt.Println(x) // выведет 1, не 2
x = 2Слайс — это структура из трёх полей: указатель на underlying array, длина (len) и ёмкость (cap).
type slice struct {
array unsafe.Pointer
len int
cap int
}Отличия от массива:
- Массив — значение фиксированного размера, копируется целиком при передаче
- Слайс — ссылочный тип, при передаче копируется только структура (24 байта), не данные
- При
appendсверх capacity создаётся новый массив (обычно с удвоенной ёмкостью)
Map — хеш-таблица. Данные организованы в массив бакетов, каждый из которых хранит до 8 пар ключ-значение. При коллизиях хешей создаются дополнительные бакеты, образующие цепочку.
Конкурентный доступ:
- Одновременное чтение — безопасно
- Одновременные чтение и запись или две записи — data race, программа упадёт с
fatal error: concurrent map writes - Go специально детектирует конкурентную запись в map и завершает программу
Решения:
sync.Mutexилиsync.RWMutexдля защиты доступаsync.Map— потокобезопасная map из стандартной библиотеки (эффективна при редких записях и частых чтениях)
panic — аварийное завершение программы при невосстановимых ошибках. recover — перехват паники для graceful shutdown.
Когда использовать panic:
- Невозможно продолжить работу (не удалось открыть обязательный конфиг, порт занят)
- Ошибка программиста (nil-указатель там, где он невозможен по контракту)
Когда НЕ использовать:
- Для обычных ошибок — возвращай
error - Для валидации пользовательского ввода
Почему recover только в defer: при панике Go разворачивает стек и выполняет только отложенные функции. Обычный код после panic не выполняется.
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v", r)
}
}()Важно: recover перехватывает панику только в той же горутине, где она произошла.
Строка в 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, изменить, конвертировать обратно. Каждая конвертация — аллокация.
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) - Интерфейс в структуру — структура автоматически реализует интерфейс (частично, опасный паттерн)
Пустая структура 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.
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{} // ок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, в которую нельзя писать.
Структуры в 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 в рантайме.
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: позволяет добавить контекст ("что делали"), сохраняя возможность проверить причину ("что пошло не так").
Замыкание — функция, которая захватывает переменные из внешней области видимости. В 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+ переменная цикла создаётся заново на каждой итерации (проблема исправлена)
init() — специальная функция, которая автоматически выполняется при инициализации пакета, до вызова main().
Порядок инициализации:
- Инициализация импортированных пакетов (рекурсивно, в порядке импорта)
- Инициализация глобальных переменных пакета
- Вызов всех
init()функций пакета
Особенности:
- В одном файле может быть несколько
init()— выполняются в порядке объявления - В одном пакете может быть несколько файлов с
init()— порядок файлов не гарантирован (зависит от компилятора) init()нельзя вызвать явно
var x = compute() // 1. сначала глобальные переменные
func init() {
// 2. потом init()
}
func main() {
// 3. потом main()
}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), без panicType switch — проверка нескольких типов сразу:
switch v := i.(type) {
case string:
fmt.Println("строка:", v)
case int:
fmt.Println("число:", v)
default:
fmt.Println("неизвестный тип")
}В Go нет классического ООП — нет классов, наследования и конструкторов. Вместо этого используются:
- Структуры (struct) — вместо классов, объединяют данные
- Методы — функции с receiver'ом, привязаны к типу
- Embedding — вместо наследования, композиция типов
- Интерфейсы — неявная реализация (duck typing), полиморфизм
Go следует принципу "композиция вместо наследования". Тип реализует интерфейс автоматически, если имеет все его методы — явное implements не требуется.
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
)Теги структур — строковые метаданные, прикреплённые к полям структуры. Записываются в обратных кавычках после типа поля.
type User struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name,omitempty"`
}Как работают:
- Теги не влияют на поведение Go напрямую — это просто строки
- Библиотеки читают теги через рефлексию (
reflect.StructField.Tag) - Формат:
ключ:"значение", несколько тегов разделяются пробелом
Распространённые теги:
json— сериализация в JSONxml— сериализация в XMLdb/gorm— маппинг на колонки БДvalidate— правила валидацииyaml— сериализация в YAML
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...) // правильно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.
range — конструкция для итерации по массивам, слайсам, map, строкам и каналам. Возвращает индекс и копию элемента.
Особенности по типам:
- slice/array:
for i, v := range s—vэто копия элемента, изменениеvне влияет на оригинал - map: порядок итерации не детерминирован; безопасно удалять текущий ключ во время итерации
- string: итерация по рунам (не байтам); индекс — позиция байта, значение —
rune - channel:
for v := range ch— читает до закрытия канала
Типичная ошибка — взятие адреса переменной цикла:
for _, v := range items {
results = append(results, &v) // все указатели на одну переменную!
}Решение: создать локальную копию v := v или использовать индекс &items[i].
Встроенная функция 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)— копирует байты строки в слайс
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.
Да, в 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 это валидное значение для указателя.
Дженерики (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 (только у типа)
- Нет специализации — нельзя написать особую реализацию для конкретного типа
Ключом map может быть любой comparable (сравнимый) тип — тип, для которого определены операции == и !=.
Могут быть ключами:
- Числа, строки, bool
- Указатели и каналы
- Массивы фиксированной длины (если элементы сравнимы)
- Структуры (если все поля сравнимы)
Не могут быть ключами:
- Слайсы
- Мапы
- Функции
type Point struct { X, Y int }
m := make(map[Point]string)
m[Point{1, 2}] = "ok" // структура как ключ — работает
// map[[]int]string — ошибка компиляции: slice не может быть ключомПочему именно comparable: для вставки и поиска в хеш-таблице нужно сравнивать ключи. Типы без операции сравнения не могут участвовать в этом процессе.
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 при попытке изменить копию
- Не потокобезопасен — нужна внешняя синхронизация
Это классическая ловушка на собеседованиях. Интерфейс в 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 // правильно
}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.
При вызове 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) для предаллокации. Это избавляет от промежуточных аллокаций и копирований.
defer может изменить возвращаемое значение функции, если оно именованное. Это происходит потому, что именованные возвращаемые значения — это обычные переменные, доступные в defer.
func example() (result int) {
defer func() { result++ }()
return 0 // вернёт 1, не 0
}Последовательность выполнения return x:
- Значение
xприсваивается именованной переменнойresult - Выполняются все
deferфункции (могут изменитьresult) - Функция возвращает текущее значение
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)
}Горутина — легковесный поток выполнения, управляемый рантаймом Go, а не операционной системой. Основные отличия:
- Размер стека горутины начинается с 2 КБ (у потока ОС обычно 1-8 МБ)
- Переключение между горутинами дешевле — не требует системных вызовов
- Тысячи горутин могут выполняться на нескольких потоках ОС (модель M:N)
Канал — типобезопасный механизм коммуникации между горутинами.
Небуферизованный канал (make(chan T)):
- Отправитель блокируется до тех пор, пока получатель не прочитает значение
- Гарантирует синхронизацию между горутинами
Буферизованный канал (make(chan T, N)):
- Отправитель блокируется только когда буфер заполнен
- Получатель блокируется только когда буфер пуст
select позволяет ждать операции на нескольких каналах одновременно. Выполняется первый готовый case; если готовы несколько — выбирается случайный.
Если все каналы заблокированы:
- Без
default—selectблокируется до разблокировки любого канала - С
default— сразу выполняетсяdefault, блокировки нет
Data race (гонка данных) — ситуация, когда две или более горутин одновременно обращаются к одной переменной, и хотя бы одна из них выполняет запись.
Способы избежать:
sync.Mutexsync.RWMutexsync/atomic- Каналы
Как обнаружить: флаг -race при сборке или запуске (go run -race, go test -race).
Пакет context координирует горутины: передаёт дедлайны и сигналы отмены, а также значения в рамках запроса. Основное применение — корректное завершение долгих операций (HTTP-запросы, запросы к БД).
Основные функции создания:
context.Background()— пустой корневой контекстcontext.TODO()— placeholder, когда непонятно какой контекст использоватьcontext.WithCancel(parent)— отмена вручную черезcancel()context.WithTimeout(parent, duration)— автоматическая отмена через времяcontext.WithDeadline(parent, time)— отмена в конкретный момент времениcontext.WithValue(parent, key, val)— передача значений
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по значению — копирует счётчик, синхронизация ломается - Забытый
Done—Waitзависнет навсегда
sync.Once гарантирует, что функция выполнится ровно один раз, независимо от количества вызовов и горутин.
Типичное применение:
- Lazy initialization (ленивая инициализация)
- Singleton-ресурсы: пул соединений к БД, логгер, конфиг
var once sync.Once
var instance *DB
func GetDB() *DB {
once.Do(func() {
instance = connectToDB()
})
return instance
}Deadlock (взаимоблокировка) — ситуация, когда горутины заблокированы навсегда, ожидая друг друга.
Четыре условия возникновения:
- Взаимное исключение — ресурс нельзя использовать одновременно
- Удержание и ожидание — горутина держит ресурс и ждёт другой
- Невозможность отобрать — ресурс нельзя принудительно освободить
- Циклическое ожидание — замкнутая цепочка ожиданий
Типичные причины в Go:
- Запись в небуферизованный канал без читателя
- Чтение из канала, в который никто не пишет
- Взаимная блокировка мьютексов (A ждёт B, B ждёт A)
- Забытый
close(ch)при использованииrange
ch := make(chan int)
ch <- 1 // deadlock: некому читатьОтличие от data race: deadlock — программа зависает, data race — неопределённое поведение при конкурентном доступе.
Пакет 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.
sync.Mutex — эксклюзивная блокировка. В любой момент времени только одна горутина может владеть мьютексом.
sync.RWMutex — блокировка чтения-записи. Позволяет либо одному писателю, либо множеству читателей одновременно.
Методы RWMutex:
Lock()/Unlock()— эксклюзивная блокировка для записиRLock()/RUnlock()— разделяемая блокировка для чтения
Когда использовать RWMutex:
- Чтений значительно больше, чем записей (кэши, конфиги)
- Операция чтения не изменяет данные
Утечка горутин — ситуация, когда горутина запущена, но никогда не завершится: заблокирована навсегда или забыта без механизма остановки. Утечки накапливаются и приводят к исчерпанию памяти.
Частые причины:
- Чтение из канала, в который никто не пишет и не закрывает
- HTTP-клиент без таймаута — горутина ждёт ответа бесконечно
- Отсутствие обработки
ctx.Done()в долгих операциях - Забытый
close(ch)при использованииrange
Как обнаружить:
runtime.NumGoroutine()— мониторинг количества горутинpprof—go tool pprof http://localhost:6060/debug/pprof/goroutineпокажет стектрейсы заблокированных горутин
Как избежать: всегда передавай context.Context в долгие операции и проверяй ctx.Done() в select.
singleflight — пакет из golang.org/x/sync/singleflight, который гарантирует, что для одного ключа одновременно выполняется только один запрос. Остальные горутины ждут результата первого вызова.
Когда использовать:
- Cache stampede — при инвалидации кэша множество запросов одновременно идут в БД
- Дорогие вычисления или внешние API с одинаковыми параметрами
- Защита backend'а от дублирующих запросов
Важно: singleflight не заменяет кэш — он только предотвращает дублирование во время выполнения запроса. После завершения следующий вызов снова выполнит функцию.
nil-канал:
- Запись — блокировка навсегда (deadlock, если нет других горутин)
- Чтение — блокировка навсегда
- Закрытие — panic
Закрытый канал:
- Запись — panic
- Чтение — возвращает оставшиеся значения из буфера, затем zero value с
ok = false - Повторное закрытие — panic
v, ok := <-closedCh // ok == false, если канал пуст и закрыт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 нужна дополнительная логика отмены через контекст запроса
Конкурентность (concurrency) — это композиция независимо выполняющихся процессов. Это про структуру программы: как разбить задачу на независимые части, которые могут выполняться в произвольном порядке.
Параллелизм (parallelism) — это одновременное выполнение вычислений. Это про исполнение: несколько операций буквально происходят в один момент времени на разных ядрах CPU.
Ключевое различие:
- Конкурентность — про дизайн программы (dealing with lots of things at once)
- Параллелизм — про исполнение (doing lots of things at once)
Конкурентная программа может выполняться на одном ядре, переключаясь между задачами. Параллельное выполнение требует нескольких ядер.
Go и конкурентность: Go спроектирован для конкурентности (горутины, каналы, select). Параллелизм — это бонус, который получается автоматически при запуске на многоядерном процессоре с GOMAXPROCS > 1.
Горутину нельзя принудительно завершить извне — она должна сама проверять сигнал остановки и завершаться. Основные способы:
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, так как он поддерживает таймауты, дедлайны и передаётся по цепочке вызовов.
Частая ошибка: запуск горутины без механизма остановки — приводит к утечке горутин.
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 для периодических событий.
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
Используй буферизованный канал как семафор: размер буфера = максимальное число параллельных горутин.
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)— ограничение в errgroupgolang.org/x/sync/semaphore— взвешенный семафор для разного «веса» задач
sync.Cond — примитив синхронизации, реализующий условную переменную (condition variable). Позволяет горутинам ждать наступления определённого условия и получать уведомления об изменениях.
Методы:
Wait()— атомарно освобождает мьютекс и блокирует горутину до сигналаSignal()— пробуждает одну ожидающую горутинуBroadcast()— пробуждает все ожидающие горутины
Правильный паттерн использования:
c.L.Lock()
for !condition() {
c.Wait() // всегда в цикле, не в if
}
// работа с данными
c.L.Unlock()Когда использовать вместо каналов:
- Координация по булевому/числовому условию без передачи данных
- Нужно пробудить либо одну, либо все горутины (каналы так не умеют)
- Много ожидающих горутин —
sync.Condэффективнее по памяти
Почему Wait() вызывается в цикле: после пробуждения условие может снова стать ложным (spurious wakeup или другая горутина успела раньше).
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— когда важно получить ошибку и/или отменить остальные горутины при сбое
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 — сбор результатов от нескольких источников данных
Важно: порядок результатов не гарантирован — горутины завершаются в произвольном порядке.
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.
Стек — область памяти горутины для локальных переменных и вызовов функций. Работает по принципу LIFO: при выходе из функции память освобождается автоматически сдвигом указателя (очень быстро).
Куча — общая область памяти для всех горутин. Используется, когда переменная должна пережить функцию или её размер неизвестен на этапе компиляции. Управляется сборщиком мусора.
Почему куча дороже:
- Нужен поиск свободного блока нужного размера
- Требуется синхронизация (куча общая для всех горутин)
- Создаёт нагрузку на GC — сборщик должен отслеживать и освобождать объекты
Go использует конкурентный сборщик мусора с трёхцветной маркировкой (tricolor mark-and-sweep).
Три цвета объектов:
- Белый — не проверен, потенциально мусор
- Серый — проверен, но ссылки ещё не обработаны
- Чёрный — проверен, все ссылки обработаны
Фазы GC:
- Mark Setup (STW) — короткая пауза для подготовки
- Marking (concurrent) — маркировка достижимых объектов параллельно с программой
- Mark Termination (STW) — короткая пауза для завершения
- Sweeping (concurrent) — освобождение белых объектов
Write barrier: при изменении указателей во время маркировки барьер записи помечает объекты серым, чтобы не потерять живые объекты.
STW-паузы в современном Go — микросекунды (обычно <100μs), большая часть работы выполняется конкурентно.
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
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 хеширования случаен
Если нужен стабильный порядок: отсортируй ключи отдельно и итерируй по ним.
Escape analysis — анализ компилятора, определяющий, где размещать переменную: на стеке или в куче.
Переменная попадает в кучу (escapes), если:
- Возвращается указатель на локальную переменную
- Переменная захватывается замыканием
- Передаётся в
interface{} - Размер слайса неизвестен на этапе компиляции
- Объект слишком большой для стека
Как проверить:
go build -gcflags='-m' main.goВывод покажет escapes to heap для переменных, которые не удалось разместить на стеке.
Рефлексия — механизм для работы с типами и значениями во время выполнения программы. Пакет 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 раз)
- Ошибки компилятор не поймает — паника в рантайме
- Код сложнее читать и поддерживать
Используй присваивание к пустой переменной интерфейсного типа с помощью blank identifier:
var _ io.Reader = (*MyType)(nil)Если MyType не реализует io.Reader, компилятор выдаст ошибку. Эта проверка:
- Не создаёт реального объекта (используется
nil) - Не занимает память в рантайме (blank identifier
_игнорируется) - Выполняется только на этапе компиляции
Зачем нужно:
- Интерфейсы в Go реализуются неявно — легко случайно сломать контракт
- Позволяет получить ошибку сразу, а не в рантайме при type assertion
- Документирует намерение: "этот тип должен реализовывать этот интерфейс"
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)
}
}
}Преимущества: легко добавлять новые случаи, читаемость, один цикл вместо копирования кода.
Выравнивание (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)— смещение поля от начала структуры
Когда важно: при большом количестве объектов в памяти (кэши, массивы структур). Неоптимальный порядок полей может удвоить потребление памяти.
Интерфейс в 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-указательБенчмарки в 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.ListenAndServe открывает TCP-соединение, в бесконечном цикле принимает входящие соединения и для каждого запускает отдельную горутину.
ServeMux — маршрутизатор (мультиплексор), который хранит соответствие паттернов и обработчиков в map[string]muxEntry. При входящем запросе ищет наиболее подходящий паттерн и вызывает соответствующий Handler.ServeHTTP.
Ключевые моменты:
DefaultServeMux— глобальныйServeMux, используется если вListenAndServeпередатьnilвместо handlerHandlerFunc— адаптер, позволяющий использовать обычную функцию какhttp.Handler- Каждый запрос обрабатывается в отдельной горутине — при 10000 одновременных запросов будет 10000 горутин
// HandlerFunc — это тип с методом ServeHTTP
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}Идемпотентность — свойство метода, при котором многократное выполнение одного и того же запроса даёт тот же результат, что и однократное.
Идемпотентные методы:
GET,HEAD,OPTIONS— безопасные (не изменяют данные) и идемпотентныеPUT,DELETE— изменяют данные, но идемпотентные
Неидемпотентные методы:
POST— создаёт новый ресурс при каждом вызовеPATCH— не гарантирует идемпотентность (зависит от реализации)
Почему это важно:
- При сетевых сбоях идемпотентные запросы можно безопасно повторить
PUT /users/1с телом{name: "Ivan"}— при повторе результат тот жеPOST /usersс тем же телом — создаст дубликат
Частая ошибка на собеседовании: считать DELETE неидемпотентным, потому что повторный вызов вернёт 404. Идемпотентность — про состояние сервера, а не про код ответа.
PUT — полная замена ресурса. Клиент отправляет полное представление объекта, сервер заменяет существующий ресурс целиком.
PATCH — частичное обновление. Клиент отправляет только изменённые поля.
PUT /users/1
{"name": "Ivan", "email": "ivan@example.com", "age": 30}
PATCH /users/1
{"age": 31}Ключевые различия:
- PUT идемпотентен по спецификации, PATCH — не обязательно
- PUT требует полный объект (пропущенные поля могут обнулиться)
- PATCH экономит трафик при изменении одного поля
На практике: многие API используют PUT для любых обновлений. Важно документировать поведение и быть консистентным внутри проекта.
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-сервера
CORS (Cross-Origin Resource Sharing) — механизм, позволяющий браузеру выполнять запросы к другому домену (origin). По умолчанию браузер блокирует кросс-доменные запросы из соображений безопасности (Same-Origin Policy).
Как работает:
- Браузер отправляет preflight-запрос (OPTIONS) с заголовком
Origin - Сервер отвечает заголовками
Access-Control-Allow-* - Если 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 должен дополнительно проверять авторизацию.
REST (Representational State Transfer) — архитектурный стиль для построения веб-сервисов, предложенный Роем Филдингом в 2000 году.
6 принципов REST:
- Client-Server — клиент и сервер разделены, развиваются независимо
- Stateless — сервер не хранит состояние клиента между запросами; каждый запрос содержит всю необходимую информацию
- Cacheable — ответы должны явно указывать, можно ли их кэшировать
- Uniform Interface — единообразный интерфейс (ресурсы идентифицируются через URI, манипуляция через представления, самоописательные сообщения, HATEOAS)
- Layered System — клиент не знает, общается он напрямую с сервером или через прокси/балансировщик
- Code on Demand (опционально) — сервер может отправлять исполняемый код клиенту
RESTful API — API, построенный по принципам REST. Использует HTTP-методы (GET, POST, PUT, DELETE), URI для идентификации ресурсов, JSON/XML для представления данных.
HTTP/2 — бинарный протокол (вместо текстового), что делает его компактнее и быстрее при парсинге.
Ключевые улучшения HTTP/2:
- Мультиплексирование — несколько запросов идут параллельно по одному TCP-соединению, нет блокировки head-of-line
- Сжатие заголовков (HPACK) — заголовки сжимаются и кэшируются, экономия трафика на повторяющихся заголовках
- Server Push — сервер может отправить ресурсы до того, как клиент их запросит
- Приоритизация потоков — клиент указывает важность запросов
Когда HTTP/2 даёт выигрыш: страницы с множеством мелких ресурсов (скрипты, стили, изображения). Для передачи одного большого файла разница минимальна.
Важно: семантика HTTP не изменилась — методы, статус-коды, заголовки остались прежними. HTTP/2 требует TLS в браузерах.
Версионирование 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. Прост в документации, тестировании и отладке через браузер.
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.
Idempotency Key — уникальный идентификатор, который клиент отправляет с запросом, чтобы сервер мог гарантировать однократную обработку операции при повторных запросах.
Проблема: POST-запросы не идемпотентны. Если клиент отправил запрос на создание заказа, но не получил ответ (таймаут, разрыв соединения), он не знает — заказ создан или нет. При повторе может создаться дубликат.
Решение — Idempotency Key:
- Клиент генерирует уникальный ключ (UUID) для каждой логической операции
- Ключ передаётся в заголовке:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 - Сервер сохраняет ключ и результат операции (в Redis или БД)
- При повторном запросе с тем же ключом сервер возвращает сохранённый результат без повторного выполнения
Где используется:
- Платёжные системы (Stripe, PayPal) — защита от двойного списания
- Создание заказов — защита от дубликатов
- Бронирование — защита от двойного бронирования
Важные нюансы:
- Ключи должны иметь TTL (обычно 24 часа) — нельзя хранить вечно
- При concurrent запросах с одним ключом нужна блокировка, чтобы второй запрос дождался результата первого
- Сохранять нужно и успешные ответы, и ошибки бизнес-логики (но не сетевые ошибки)
Уровень изоляции определяет, какие аномалии допускаются при параллельном выполнении транзакций. От низшего к высшему:
Read Uncommitted — транзакция видит незакоммиченные изменения других транзакций (dirty read). На практике почти не используется.
Read Committed — транзакция видит только закоммиченные данные. Возможна аномалия non-repeatable read: повторное чтение той же строки может вернуть другие данные. Дефолт в PostgreSQL.
Repeatable Read — гарантирует одинаковый результат при повторном чтении строки. Возможна аномалия phantom read: новые строки, добавленные другой транзакцией, могут появиться в повторном запросе.
Serializable — полная изоляция, транзакции выполняются как будто последовательно. Самый медленный уровень.
Практика: для большинства задач достаточно Read Committed. Repeatable Read используют для отчётов и финансовых операций. Serializable — редко, только когда критична консистентность.
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-запросов и смотреть на количество запросов при обработке списка.
Индекс — структура данных (обычно B-tree), ускоряющая поиск строк по значению колонки. Без индекса БД сканирует всю таблицу (full table scan).
Когда использовать:
- Колонки в WHERE, JOIN, ORDER BY
- Колонки с высокой кардинальностью (много уникальных значений)
- Первичные и внешние ключи (создаются автоматически)
Когда НЕ использовать:
- Маленькие таблицы (< 1000 строк) — full scan быстрее
- Колонки с низкой кардинальностью (boolean, enum с 2-3 значениями)
- Таблицы с частыми INSERT/UPDATE — индексы замедляют запись
- Колонки, которые редко участвуют в запросах
Цена индекса: занимает место на диске, замедляет INSERT/UPDATE/DELETE, так как БД должна обновлять индекс при каждом изменении данных.
ACID — четыре свойства, гарантирующие надёжность транзакций в базе данных.
Atomicity (атомарность) — транзакция выполняется целиком или не выполняется вообще. При ошибке все изменения откатываются.
Consistency (согласованность) — транзакция переводит БД из одного валидного состояния в другое. Нарушение constraints откатывает транзакцию.
Isolation (изолированность) — параллельные транзакции не влияют друг на друга. Уровень изоляции определяет степень видимости изменений.
Durability (долговечность) — после COMMIT данные сохранены даже при сбое питания. Обеспечивается записью в WAL (Write-Ahead Log).
На практике: ACID гарантируют реляционные БД (PostgreSQL, MySQL). NoSQL часто жертвуют частью ACID ради производительности (eventual consistency).
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 на стороне БД.
CAP-теорема утверждает, что распределённая система может гарантировать только два из трёх свойств одновременно:
- Consistency (согласованность) — все узлы видят одинаковые данные в любой момент времени
- Availability (доступность) — каждый запрос получает ответ (успех или ошибка)
- Partition tolerance (устойчивость к разделению) — система продолжает работать при потере связи между узлами
Почему нельзя все три: при сетевом разделении (P) система должна выбрать — либо отвечать устаревшими данными (A без C), либо отказывать в запросах до восстановления связи (C без A).
Примеры:
- CP-системы: PostgreSQL с синхронной репликацией, ZooKeeper — консистентность важнее доступности
- AP-системы: Cassandra, DynamoDB — доступность важнее, eventual consistency
- CA-системы: возможны только в сети без сбоев (на практике не существуют)
На собеседовании: часто спрашивают, какой выбор делает конкретная БД и почему.
Репликация — копирование данных с основного сервера (master) на резервные (replica/slave) для отказоустойчивости и масштабирования чтения.
Асинхронная репликация: транзакция подтверждается сразу после записи на master, данные доезжают до реплик в фоне. Быстрее, но при падении master часть данных может быть потеряна — они не успели скопироваться.
Синхронная репликация: транзакция подтверждается только после записи на master и хотя бы одну реплику. Медленнее, но гарантирует сохранность данных. При падении реплики запись блокируется — нужно минимум две реплики.
Когда что использовать:
- Асинхронная — большинство случаев, когда допустима потеря нескольких последних транзакций
- Синхронная — финансовые системы, критичные данные
Важно: репликация защищает от физических сбоев (сгорел диск), но не от логических (некорректные данные скопируются на реплику). Для защиты от логических ошибок нужен бэкап.
Партиционирование (partitioning) — разделение большой таблицы на логические части (партиции) внутри одного сервера БД. Данные делятся по определённому критерию (дата, диапазон ID, хеш), но остаются на одной машине. Приложение работает с таблицей как обычно — БД сама направляет запросы в нужную партицию.
Шардирование (sharding) — распределение данных между несколькими независимыми серверами (шардами). Каждый шард содержит часть данных и работает автономно. Требует логики маршрутизации на уровне приложения или middleware.
Ключевые различия:
- Партиционирование — один сервер, шардирование — несколько серверов
- Партиционирование прозрачно для приложения, шардирование требует изменений в коде
- Шардирование даёт горизонтальное масштабирование, партиционирование — только ускорение запросов
Когда что использовать: партиционирование — когда таблица большая, но один сервер справляется. Шардирование — когда нужно масштабироваться за пределы одной машины (терабайты данных, высокая нагрузка на запись).
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.
Два подхода к обработке конкурентного доступа к данным в базе.
Пессимистичная блокировка (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.
Монолит — единое приложение, где все компоненты (UI, бизнес-логика, БД) работают в одном процессе и деплоятся вместе.
Микросервисы — набор небольших независимых сервисов, каждый отвечает за одну бизнес-функцию, имеет свою БД и деплоится отдельно.
Плюсы микросервисов:
- Независимый деплой и масштабирование отдельных сервисов
- Разные команды могут использовать разные технологии
- Отказ одного сервиса не роняет всю систему
Минусы микросервисов:
- Сложность распределённой системы (сеть, latency, консистентность)
- Операционные затраты: мониторинг, логирование, трейсинг
- Сложнее отлаживать и тестировать
Когда выбирать монолит: старт проекта, маленькая команда, неясные границы домена. Микросервисы — когда система выросла, команда большая, нужно независимое масштабирование.
Circuit Breaker (автоматический выключатель) — паттерн, предотвращающий каскадные сбои в распределённых системах. Если сервис недоступен, нет смысла слать ему запросы — это тратит ресурсы и увеличивает latency.
Три состояния:
- Closed — запросы проходят нормально, ошибки считаются
- Open — запросы блокируются, сразу возвращается ошибка (fail fast)
- Half-Open — пропускается несколько тестовых запросов для проверки восстановления
Как работает:
- При превышении порога ошибок (например, 50% за 10 секунд) — переход в Open
- После таймаута (например, 30 секунд) — переход в Half-Open
- Если тестовые запросы успешны — возврат в Closed, иначе — обратно в Open
Зачем нужен:
- Защита от каскадных сбоев (один упавший сервис не роняет всю систему)
- Быстрый fail вместо ожидания таймаута
- Даёт время проблемному сервису восстановиться
API Gateway — единая точка входа для всех клиентских запросов в микросервисной архитектуре. Вместо того чтобы клиент обращался к десяткам сервисов напрямую, он работает только с Gateway.
Основные функции:
- Маршрутизация — перенаправление запросов к нужному микросервису
- Аутентификация/авторизация — проверка токенов в одном месте, а не в каждом сервисе
- Rate limiting — ограничение количества запросов от клиента
- Агрегация — объединение ответов нескольких сервисов в один
- SSL termination — обработка HTTPS на Gateway, внутри сети — HTTP
- Кэширование — кэширование частых запросов
Примеры: Kong, NGINX, AWS API Gateway, Traefik.
Недостатки: единая точка отказа (нужна репликация), дополнительная latency, усложнение инфраструктуры.
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.
Load Balancer (балансировщик нагрузки) — компонент, распределяющий входящий трафик между несколькими серверами для повышения отказоустойчивости и масштабируемости.
Основные алгоритмы:
- Round Robin — запросы распределяются по очереди: первый на сервер 1, второй на сервер 2, и так по кругу. Прост, но не учитывает нагрузку серверов
- Weighted Round Robin — то же, но серверам назначаются веса пропорционально их мощности
- Least Connections — запрос направляется на сервер с наименьшим числом активных соединений. Учитывает реальную нагрузку
- Least Response Time — выбирается сервер с наименьшим временем ответа
- IP Hash — хеш IP-адреса клиента определяет сервер. Один клиент всегда попадает на один сервер (полезно для stateful-приложений)
Sticky Sessions — опция, привязывающая пользователя к серверу на время сессии через cookie. Нужна для приложений с состоянием, но усложняет масштабирование.
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
Dead Letter Queue (DLQ) — очередь для сообщений, которые не удалось обработать. Вместо потери или бесконечного retry неуспешные сообщения направляются в отдельную очередь для анализа и ручной обработки.
Когда сообщение попадает в DLQ:
- Превышено максимальное число попыток обработки
- Истёк TTL сообщения
- Потребитель явно отклонил сообщение (reject/nack без requeue)
- Очередь переполнена
Типичный паттерн:
- Сообщение обрабатывается основным consumer'ом
- При ошибке — retry с exponential backoff (например, 3 попытки)
- После исчерпания попыток — перенаправление в DLQ
- Алерт для разработчиков, ручной разбор причин
Реализация в разных системах:
- RabbitMQ — через Dead Letter Exchange (DLX), настраивается на уровне очереди
- Kafka — нет встроенной DLQ, реализуется отдельным топиком и логикой в consumer'е
- AWS SQS — нативная поддержка DLQ с настройкой maxReceiveCount
Зачем нужна: позволяет не терять данные, не блокировать очередь «ядовитыми» сообщениями и анализировать причины сбоев.
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-терминация, статика, защита от медленных клиентов).
Сессионная аутентификация (stateful): сервер создаёт сессию и хранит её в памяти или БД. Клиенту возвращается session ID в cookie. При каждом запросе сервер ищет сессию в хранилище.
JWT (stateless): сервер генерирует подписанный токен с данными пользователя. Токен хранится на клиенте и отправляется в заголовке Authorization. Сервер проверяет подпись, но не хранит токен.
JWT выбирают когда:
- Нужна горизонтальная масштабируемость (нет общего хранилища сессий)
- Микросервисы — токен проверяется без обращения к auth-сервису
- Мобильные приложения — cookie неудобны
Сессии выбирают когда:
- Критична возможность мгновенного отзыва (logout, бан пользователя)
- Высокие требования к безопасности (банки, госорганы)
- Нужен полный контроль над авторизацией каждого запроса
Проблема JWT: токен нельзя отозвать до истечения срока. Решения: короткий exp + refresh token, или blacklist в Redis (теряется stateless-преимущество).
Виртуальная машина (VM) запускает полноценную гостевую ОС поверх гипервизора. Каждая VM включает своё ядро, системные библиотеки и приложение. Запуск занимает минуты, потребляет гигабайты RAM.
Docker-контейнер использует ядро хост-системы и изолирует только процессы через механизмы Linux: namespaces (изоляция ресурсов) и cgroups (ограничение ресурсов). Запуск за секунды, потребление — мегабайты.
Когда VM лучше: нужна полная изоляция (разные ОС, безопасность), запуск Windows на Linux. Когда контейнеры лучше: микросервисы, CI/CD, быстрое масштабирование.
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, меньше места на диске
- Безопасность — в финальном образе нет компилятора и исходников
- Кэширование — изменение кода не инвалидирует слой с зависимостями
Сигналы — механизм межпроцессного взаимодействия в 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— приостановка и возобновление процесса
TCP (Transmission Control Protocol) — надёжный протокол с установлением соединения. Гарантирует доставку данных в правильном порядке, повторяет потерянные пакеты, контролирует перегрузку сети.
UDP (User Datagram Protocol) — ненадёжный протокол без соединения. Отправляет пакеты без подтверждения доставки. Быстрее TCP, но пакеты могут теряться, дублироваться или приходить в неправильном порядке.
TCP handshake (трёхстороннее рукопожатие) — процесс установления TCP-соединения:
- SYN — клиент отправляет пакет с флагом SYN и начальным номером последовательности
- SYN-ACK — сервер отвечает пакетом с флагами SYN и ACK, подтверждая получение и отправляя свой номер последовательности
- ACK — клиент отправляет подтверждение, соединение установлено
Когда использовать TCP: HTTP/HTTPS, передача файлов, email, базы данных — везде, где важна целостность данных.
Когда использовать UDP: DNS-запросы, видео-стриминг, онлайн-игры, VoIP — где скорость важнее надёжности и допустима потеря отдельных пакетов.
Классический вопрос, проверяющий понимание всего стека технологий. Основные этапы:
- Парсинг URL — браузер разбирает URL на протокол, домен, порт, путь
- DNS-резолвинг — поиск IP-адреса: кэш браузера → кэш ОС → файл hosts → DNS-сервер
- TCP-соединение — трёхстороннее рукопожатие (SYN → SYN-ACK → ACK)
- TLS-хендшейк — для HTTPS: обмен сертификатами, согласование ключей шифрования
- HTTP-запрос — браузер отправляет GET-запрос с заголовками (Host, User-Agent, Accept)
- Обработка на сервере — роутинг, выполнение кода, запросы к БД, формирование ответа
- HTTP-ответ — сервер возвращает статус-код, заголовки и тело (HTML)
- Рендеринг — браузер парсит HTML, строит DOM, загружает CSS/JS, отрисовывает страницу
Что ожидают на собеседовании: глубина ответа зависит от уровня. Junior — базовый flow. Middle — детали DNS, TCP, кэширование. Senior — оптимизации, CDN, connection pooling, HTTP/2.
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).