РЕАЛЬНОЕ Golang СОБЕСЕДОВАНИЕ на MIDDLE BACKEND РАЗРАБОТЧИКА В ФИНТЕХ! ЗАДАЧИ по Go, SQL!
Сегодня мы разберём собеседование на позицию middle Go-разработчика в финтех-компанию, в ходе которого кандидат продемонстрировал уверенное владение базовыми концепциями языка Go — слайсы, горутины, примитивы синхронизации, паники и модель GMP — а также показал практические навыки написания многопоточного кода и работы с SQL-запросами, включая джоины, агрегацию, пагинацию и курсорный подход. Интервьюер последовательно проверял как теоретическую подготовку, так и способность применять знания на практике, предлагая задачи на понимание поведения кода, написание параллельного загрузчика данных и составление SQL-запросов с нюансами оптимизации и безопасности.
Вопрос 1. Что выведет данная программа на Go? Объясните поведение слайса при передаче в функцию modifySlice.
Таймкод: 00:01:25
Ответ собеседника: Правильный. Программа выведет 100 2 3. Слайс передаётся по значению, но это значение содержит указатель на базовый массив. В функции modifySlice переменной S присваивается новый слайс после append, но это изменение видно только внутри функции. Однако нулевой элемент изменяется на 100, так как S всё ещё указывает на тот же базовый массив.
Правильный ответ:
Программа выведет 100 2 3. Разберём это поведение детально.
Устройство слайса в Go
Слайс в Go — это структура (заголовок), содержащая три поля:
ptr— указатель на первый элемент базового массиваlen— длина слайсаcap— вместимость слайса
При передаче слайса в функцию копируется именно этот заголовок (по значению), а не данные в базовом массиве. Поэтому и вызывающая сторона, и функция работают с одним и тем же базовым массивом через разные копии заголовка.
Что происходит в функции modifySlice
func modifySlice(s []int) {
s[0] = 100 // Модифицирует базовый массив — видно в main
s = append(s, 4) // Может создать новый базовый массив
// s внутри функции теперь указывает на другой массив (возможно)
}
Строка s[0] = 100 изменяет нулевой элемент базового массива, на который указывают и слайс в main, и слайс в modifySlice. Поэтому это изменение видно после возврата из функции.
Строка s = append(s, 4) добавляет элемент 4. Если вместимости (cap) исходного слайса хватает, то append запишет 4 в тот же базовый массив, и слайс в main этого не увидит, потому что его len остался равным 3. Если вместимости не хватит, append выделит новый базовый массив, и локальная переменная s будет указывать уже на него — связь с оригинальным слайсом в main полностью оборвётся.
Ключевые выводы
- Изменение элементов по индексу (
s[i] = val) видно вызывающей стороне, потому что базовый массив общий. - Изменение заголовка слайса (присваивание нового слайса через
append, нарезка и т.д.) видно только внутри функции, потому что копируется сам заголовок. - Чтобы изменения заголовка были видны снаружи, нужно либо возвращать новый слайс из функции, либо принимать указатель на слайс (
*[]int).
Пример с возвратом слайса
func modifySlice(s []int) []int {
s = append(s, 4)
return s
}
func main() {
s := []int{1, 2, 3}
s = modifySlice(s)
fmt.Println(s) // [1 2 3 4]
}
Пример с указателем на слайс
func modifySlice(s *[]int) {
*s = append(*s, 4)
}
func main() {
s := []int{1, 2, 3}
modifySlice(&s)
fmt.Println(s) // [1 2 3 4]
}
Вопрос 2. Что такое слайс в Go и из каких полей состоит его структура?
Таймкод: 00:04:54
Ответ собеседника: Правильный. Слайс — это динамический массив в Go. Его структура состоит из трёх полей: указатель на базовый массив, длина (length) и ёмкость (capacity). Длина — количество элементов, ёмкость — объём до которого можно увеличивать длину без аллокации.
Правильный ответ:
Слайс в Go — это гибкая, динамически изменяемая обзорная структура (view) поверх базового массива. В отличие от массива, чей размер фиксирован на этапе компиляции, слайс может расти и уменьшаться во время выполнения.
Заголовок слайса (Slice Header)
Внутри слайс представлен структурой runtime.SliceHeader (или reflect.SliceHeader):
type SliceHeader struct {
Data uintptr // Указатель на первый элемент базового массива
Len int // Текущее количество элементов в слайсе
Cap int // Вместимость — максимальное количество элементов
// без перевыделения памяти
}
Три ключевых поля
Data (указатель на базовый массив) — адрес в памяти, где хранятся элементы. Именно благодаря этому указателю несколько слайсов могут разделять один и тот же базовый массив.
Len (длина) — количество элементов, доступных в слайсе для чтения и записи. Доступ по индексу s[i] допустим при 0 <= i < Len.
Cap (вместимость) — общее количество элементов в базовом массиве, начиная с первого элемента слайса. append добавляет элементы, пока Len < Cap, без выделения новой памяти.
Как устроен слайс в памяти
arr := [6]int{10, 20, 30, 40, 50, 60}
s := arr[1:4] // слайс элементы с индексами 1, 2, 3
// s.Data → указывает на arr[1]
// s.Len = 3 (элементы 20, 30, 40)
// s.Cap = 5 (от arr[1] до конца массива arr)
Создание слайсов
// Через литерал
s1 := []int{1, 2, 3} // Len=3, Cap=3
// Через make
s2 := make([]int, 3, 5) // Len=3, Cap=5, заполнен нулями
// Через нарезку массива или другого слайса
arr := [5]int{1, 2, 3, 4, 5}
s3 := arr[1:4] // Len=3, Cap=4
// nil-слайс
var s4 []int // Data=nil, Len=0, Cap=0
Рост слайса при append
Когда append обнаруживает, что Len == Cap, он выделяет новый базовый массив с увеличенной вместимостью (обычно удвоение для маленьких слайсов, затем меньший коэффициент), копирует существующие элементы и возвращает новый заголовок:
s := make([]int, 3, 3)
s = append(s, 4) // Cap исчерпан → выделен новый массив,
// вероятно Cap=6 (зависит от версии Go и эвристик)
Важные нюансы
- Два слайса, созданные из одного базового массива, разделяют данные. Изменение элемента через один слайс видно через другой.
nil-слайс (var s []int) и пустой слайс ([]int{}) ведут себя похоже приappend, но отличаются при сравнении сnil.- Функция
len(s)иcap(s)работают за O(1), так как значения хранятся в заголовке, а не вычисляются.
Вопрос 3. Как передаются в функции слайсы, массивы и структуры в Go?
Таймкод: 00:05:52
Ответ собеседника: Правильный. В Go всё передаётся по значению. Слайс передаётся по значению, но этим значением является структура, содержащая указатель на базовый массив. Массив передаётся полностью по значению, то есть копируется весь массив.
Правильный ответ:
Go последовательно использует передачу по значению для всех типов. Однако в зависимости от типа данных это имеет разные практические последствия.
Слайс
Копируется заголовок слайса (указатель, длина, вместимость — 24 байта на 64-битной системе), но не базовый массив. Поэтому:
- Изменение элементов по индексу внутри функции видно вызывающей стороне (общий базовый массив).
- Изменение заголовка слайса (append, нарезка, присваивание нового слайса) видно только внутри функции.
- Добавление элементов может как затронуть исходный базовый массив (если Cap позволяет), так и создать новый (если Cap исчерпан).
func modify(s []int) {
s[0] = 999 // Видно снаружи
s = append(s, 4) // Не видно снаружи (изменён заголовок)
}
Массив
Копируется полностью — все элементы массива дублируются в памяти. Для большого массива это дорого и может повлиять на производительность.
func modifyArr(a [5]int) {
a[0] = 999 // Копия изменена, оригинал нет
}
func main() {
arr := [5]int{1, 2, 3, 4, 5}
modifyArr(arr)
fmt.Println(arr[0]) // 1 — оригинал не изменился
}
Размер массива является частью его типа: [3]int и [5]int — разные типы. Это делает работу с массивами в функциях менее гибкой.
Структура (struct)
Копируется побайтово — все поля дублируются. Если структура содержит указатели, слайсы, мапы или каналы, копируются сами эти значения (указатель, заголовок слайса и т.д.), но не данные, на которые они ссылаются.
type Server struct {
Name string
Addr string
Clients []int
}
func modify(s Server) {
s.Name = "modified" // Копия — не видно снаружи
s.Clients[0] = 999 // Базовый массив общий — видно снаружи!
}
Когда использовать указатели
Для массивов и больших структур обычно передают указатель (*[5]int, *Server), чтобы избежать копирования и иметь возможность модифицировать оригинал:
func modifyArr(a *[5]int) {
a[0] = 999 // Изменяет оригинал
}
func modify(s *Server) {
s.Name = "modified" // Изменяет оригинал
}
Сводная таблица
| Тип | Что копируется | Видны ли изменения элементов снаружи? | Видны ли изменения самого значения снаружи? |
|---|---|---|---|
| Слайс | Заголовок (24 байта) | Да (общий базовый массив) | Нет (заголовок скопирован) |
| Массив | Все элементы | Нет (полная копия) | Нет |
| Структура | Все поля побайтово | Да, если поле содержит указатель/слайс/мапу | Нет |
Вопрос 4. Не меняя функцию modifySlice, можно ли сделать так, чтобы в main вывелось 100 2 3 4?
Таймкод: 00:06:53
Ответ собеседника: Неполный. Кандидат не смог найти решение. Подсказка была о том, что при append элемент 4 записывается в базовый массив, так что capacity равен 4, но длина слайса в main остаётся 3. Нужно изменить длину слайса в main, чтобы увидеть добавленный элемент.
Правильный ответ:
Да, это возможно. Ключ к решению — создать слайс в main с len=3, cap=4, чтобы при append внутри modifySlice элемент 4 записался в тот же базовый массив, не вызывая перевыделения памяти. После возврата из функции нужно «раскрыть» слайс до длины 4 через нарезку.
Решение
func modifySlice(s []int) {
s[0] = 100
s = append(s, 4)
}
func main() {
// Создаём слайс с len=3, cap=4
s := make([]int, 3, 4)
s[0], s[1], s[2] = 1, 2, 3
modifySlice(s)
// Раскрываем слайс до cap, чтобы увидеть элемент,
// записанный append в базовый массив
s = s[:cap(s)]
fmt.Println(s) // [100 2 3 4]
}
Пошаговое объяснение
1. s := make([]int, 3, 4) — создаём слайс с тремя элементами и вместимостью 4. Базовый массив имеет 4 ячейки, последняя пока пуста (нулевое значение).
2. При вызове modifySlice(s) заголовок слайса копируется. Внутри функции s[0] = 100 изменяет нулевой элемент общего базового массива — это видно и в main.
3. s = append(s, 4) внутри функции: len=3, cap=4, место есть, поэтому append записывает 4 в четвёртую ячейку того же базового массива и возвращает заголовок с len=4. Но этот новый заголовок присваивается локальной переменной s внутри функции — в main заголовок по-прежнему имеет len=3.
4. После возврата в main выполняем s = s[:cap(s)]. Это создаёт новый заголовок с len=4, указывающий на тот же базовый массив. Теперь s включает все четыре элемента.
Почему это работает
Элемент 4 физически находится в базовом массиве с момента append внутри функции. Проблема была только в том, что Len слайса в main равнялся 3 и не включал четвёртую ячейку. Нарезка s[:cap(s)] увеличивает Len до Cap, делая элемент видимым.
Важное условие
Если бы cap совпадал с len (например, s := []int{1, 2, 3} с cap=3), то append внутри функции выделил бы новый базовый массив, и элемент 4 записался бы уже в него — в main он был бы недоступен никакими манипуляциями с исходным слайсом. Поэтому запас по cap критически важен для данного приёма.
Вопрос 5. Что выведет данная программа с мапой и слайсом? Есть ли подвох?
Таймкод: 00:08:43
Ответ собеседника: Правильный. Программа выведет мапу с рандомными ключами типа float32 и значениями из слайса. Подвох в том, что генератор случайных чисел может сгенерировать повторяющиеся ключи, тогда некоторые значения перезапишутся и в мапе окажется меньше 4 элементов.
Правильный ответ:
Программа создаёт мапу, где ключами служат случайные числа типа float32, а значениями — элементы слайса. Ожидаемый результат — мапа с 4 элементами, но фактический размер мапы может оказаться меньше.
Подвох: коллизии ключей
Генератор rand.Float32() возвращает значения типа float32 — 32-битное число с плавающей точкой. Количество различных значений float32 конечно (около 4 миллиардов), но при генерации всего 4 случайных чисел вероятность коллизии (совпадения двух ключей) ненулевая, хотя и небольшая. Если два ключа совпадут, второе значение перезапишет первое, и в мапе окажется 3 элемента вместо 4.
func main() {
rand.Seed(time.Now().UnixNano())
m := make(map[float32]int)
s := []int{10, 20, 30, 40}
for _, v := range s {
m[rand.Float32()] = v
}
fmt.Println(m)
fmt.Println("len:", len(m)) // Может быть 3 или меньше!
}
Дополнительные нюансы
Порядок итерации по мапе не определён. Даже если мапа содержит все 4 элемента, при выводе через fmt.Println(m) порядок пар ключ-значение будет произвольным и может отличаться между запусками.
NaN как ключ мапы. Если бы вместо float32 использовался float64 и генератор вернул NaN, возникла бы интересная ситуация: NaN != NaN по стандарту IEEE 754, что приводит к неожиданному поведению — можно добавлять бесконечно много записей с ключом NaN, и ни одна из них не будет найдена при обычном поиске. С float32 это тоже актуально, если генератор вернёт NaN.
Точность float32. Числа float32 имеют ограниченную точность (около 7 значащих десятичных цифр). Два сгенерированных числа могут оказаться равными после округления, даже если генератор выдал разные битовые паттерны.
Практический вывод
Использовать случайные числа с плавающей точкой в качестве ключей мапы — плохая практика. Для уникальных ключей лучше использовать инкрементальный счётник, uuid, или хотя бы rand.Int() с большим диапазоном.
Как гарантировать 4 элемента
m := make(map[float32]int)
s := []int{10, 20, 30, 40}
for i, v := range s {
key := rand.Float32()
for _, exists := m[key]; exists; {
key = rand.Float32() // Генерируем заново при коллизии
}
m[key] = v
}
Или проще — использовать индекс в качестве ключа и отказаться от случайных ключей, если они не несут семантической нагрузки.
Вопрос 6. Насколько хорошо использовать float32 для хранения курсов валют в финансовых операциях?
Таймкод: 00:11:01
Ответ собеседника: Неполный. Кандидат предположил, что лучше хранить курс валюты в качестве ключа, но не смог назвать проблему с использованием float для финансовых операций. Проблема в потере точности при арифметических операциях с плавающей точкой, что критично для финансовых расчётов.
Правильный ответ:
Использовать float32 (и float64) для хранения и расчёта финансовых величин — это антипаттерн, который может привести к реальным денежным потерям.
Проблема: потеря точности
Числа с плавающей точкой по стандарту IEEE 754 не могут точно представить многие десятичные дроби. float32 имеет всего 23 бита мантиссы (~7 десятичных значащих цифр), float64 — 52 бита (~15-16 цифр). Даже float64 не может точно представить 0.1:
package main
import "fmt"
func main() {
var sum float64
for i := 0; i < 10; i++ {
sum += 0.1
}
fmt.Printf("%.20f\n", sum) // 0.99999999999999988898
fmt.Println(sum == 1.0) // false!
}
Для финансовых расчётов это неприемлемо: расхождение в копейку при миллионах транзакций может составить тысячи рублей.
Решение 1: целочисленное представление (наименьшая единица)
Храним суммы в копейках/центах как int64:
type Money struct {
Amount int64 // Сумма в копейках
Currency string // "RUB", "USD", "EUR"
}
func NewMoneyRubles(rubles int64, kopecks int64) Money {
return Money{Amount: rubles*100 + kopecks, Currency: "RUB"}
}
func (m Money) Rubles() int64 {
return m.Amount / 100
}
func (m Money) Kopecks() int64 {
return m.Amount % 100
}
func (m Money) Add(o Money) (Money, error) {
if m.Currency != o.Currency {
return Money{}, fmt.Errorf("currency mismatch")
}
return Money{Amount: m.Amount + o.Amount, Currency: m.Currency}, nil
}
Арифметика на int64 точна: 100 + 200 = 300 копейки без ошибок.
Решение 2: тип decimal
Для случаев, где целочисленное представление неудобно (курсы валют, процентные ставки), используют библиотеки десятичной арифметики:
import "github.com/shopspring/decimal"
func main() {
price := decimal.NewFromFloat(9.99)
quantity := decimal.NewFromInt(3)
total := price.Mul(quantity)
fmt.Println(total) // 29.97 — точно
}
Тип decimal.Decimal хранит число как целое масштабированное значение с фиксированной точкой, что исключает ошибки округления.
Решение 3: строковое представление в БД
При хранении в базе данных используйте тип NUMERIC / DECIMAL (а не REAL / FLOAT):
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
amount NUMERIC(19, 4) NOT NULL, -- 15 цифр до запятой, 4 после
currency CHAR(3) NOT NULL,
exchange_rate NUMERIC(19, 8) -- Курс с высокой точностью
);
Сравнение подходов
| Подход | Точность | Производительность | Сложность |
|---|---|---|---|
float32/float64 | Низкая | Высокая (аппаратная) | Простая, но ошибочная |
int64 (копейки) | Абсолютная | Высокая | Средняя |
decimal.Decimal | Настраиваемая | Ниже (софтварная) | Средняя |
NUMERIC в БД | Настраиваемая | Ниже | Средняя |
Практическая рекомендация
Для хранения денежных сумм используйте int64 в минимальных единицах (копейки, центы). Для курсов валют, процентных ставок и промежуточных расчётов — decimal.Decimal или аналоги. Никогда не используйте float для денег в production-коде.
Вопрос 7. Почему банки хранят денежные суммы в целых числах (умножая на 100), а не в float32?
Таймкод: 00:12:52
Ответ собеседника: Правильный. Кандидат предположил, что проблема связана с неточностью хранения чисел с плавающей точкой и возможными погрешностями при арифметических операциях. В банковской сфере даже тысячные доли копейки могут создать серьёзные проблемы при массовых вычислениях.
Правильный ответ:
Ответ собеседателя верен по сути. Дополним его деталями и примерами.
Причина: детерминизм и точность
Целочисленная арифметика абсолютно точна и детерминирована. Операция 100 + 200 всегда даст 300 — на любом процессоре, в любом языке, в любой точке мира. С плавающей точкой это не так: результат может зависеть от архитектуры процессора, настроек FPU, оптимизаций компилятора и порядка вычислений.
Конкретные проблемы float в финансах
Накопление ошибок при массовых расчётах. Банк обрабатывает миллионы транзакций в день. Ошибка в 0.0000001 на транзакцию при миллиарде операций даёт отклонение в 100 единиц валюты.
// Демонстрация проблемы
func main() {
var total float64
for i := 0; i < 1_000_000; i++ {
total += 0.01
}
fmt.Printf("%.20f\n", total) // 9999.99999999915735310971
// Ожидали 10000.00, получили 9999.99999999916
}
Несоблюдение свойств арифметики. Для float не всегда выполняется (a + b) - b == a:
var a float64 = 0.1
var b float64 = 0.2
fmt.Println((a + b) - b == a) // false!
В бухгалтерском учёте это неприемлемо: если придёшь и уйдёшь с той же суммы, баланс должен быть нулевым.
Проблемы сравнения. Нельзя использовать == для сравнения денежных сумм в float:
func transfer(from, to *float64, amount float64) {
*from -= amount
*to += amount
// Проверка: from + to должно быть неизменным
// Но из-за ошибок округления это может не выполняться
}
Как работают банки на практике
Хранение в минимальной единице (копейки, центы):
// 10050 означает 100 рублей 50 копеек
type Account struct {
Balance int64
}
func (a *Account) Deposit(kopecks int64) {
a.Balance += kopecks // Точная операция
}
func (a *Account) Withdraw(kopecks int64) error {
if a.Balance < kopecks {
return errors.New("insufficient funds")
}
a.Balance -= kopecks // Точная операция
return nil
}
Деление — единственная сложность
При делении целых чисел возникает вопрос округления. Банки используют стандартные правила округления (обычно банковское округование — к ближайшему чётному):
func divideKopecks(total int64, parts int64) int64 {
// Округление к ближайшему: (a + b/2) / b для положительных чисел
return (total + parts/2) / parts
}
Регуляторные требования
Центральные банки и финансовые регуляторы (PCI DSS, стандарты ЦБ) предписывают использовать точные типы данных для денежных расчётов. Использование float может привести к несоответствию нормативным требованиям и аудиторским замечаниям.
Вопрос 8. Что такое паника в Go, как её обрабатывать и можно ли избежать?
Таймкод: 00:14:39
Ответ собеседника: Правильный. Паника — аварийная ситуация, например, выход за пределы слайса, запись в закрытый канал, деление на ноль. Программа останавливается без обработки. Обрабатывается с помощью defer и recover в той же горутине, где произошла паника.
Правильный ответ:
Паника в Go — это механизм аварийного завершения выполнения, аналогичный исключениям в других языках, но с принципиально иной философией использования.
Что вызывает панику
Паника возникает как при явном вызове panic(), так и при выполнении недопустимых операций:
// Явная паника
func divide(a, b int) int {
if b == 0 {
panic("division by zero")
}
return a / b
}
// Неявная паника — выход за границы слайса
var s []int
_ = s[0] // panic: runtime error: index out of range
// Неявная паника — запись в закрытый канал
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
// Неявная паника — разыменование nil-указателя
var p *int
_ = *p // panic: runtime error: invalid memory address
Что происходит при панике
Когда паника возникает, Go немедленно прекращает нормальное выполнение текущей горутины и начинает «разворачивать» стек вызовов (stack unwinding). При этом выполняются все отложенные через defer функции в порядке LIFO. Если recover не вызван, программа завершается с выводом стека вызовов (stack trace).
Обработка паники: defer + recover
recover работает только внутри defer и только в той горутине, где произошла паника:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
if b == 0 {
panic("division by zero")
}
return a / b, nil
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Println("Error:", err) // Error: panic recovered: division by zero
}
}
Критическое ограничение: паника в горутине
Паника, не пойманная внутри горутины, убивает всю программу:
func main() {
go func() {
panic("goroutine panic") // Убьёт всю программу!
}()
time.Sleep(time.Second)
}
Правильный подход — оборачивать каждую горутину в recover:
func safeGo(f func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
}
}()
f()
}()
}
func main() {
safeGo(func() {
panic("handled panic") // Логгируется, программа живёт
})
time.Sleep(time.Second)
}
Паника vs ошибки: когда что использовать
Go придерживается чёткой философии: ошибки — для ожидаемых проблем, паника — для неисправимых.
Используйте возврат ошибок, когда:
- Файл не найден
- Нет соединения с сетью
- Невалидные входные данные
- Таймаут операции
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
return data, nil
}
Используйте panic, когда:
- Программа в заведомо некорректном состоянии
- Нарушены инварианты, которые не должны нарушаться
- Ошибка программиста, а не пользователя
func NewClient(addr string) *Client {
if addr == "" {
panic("address cannot be empty") // Ошибка программиста
}
// ...
}
Как избежать паники
- Проверяйте границы перед доступом к элементам слайса.
- Проверяйте nil перед разыменованием указателей.
- Не закрывайте канал из отправителя — используйте
sync.Onceили паттерны владения каналом. - Используйте
recoverна границах — в обработчиках HTTP, воркерах, горутинах. - Возвращайте ошибки вместо
panicв библиотечном коде.
Middleware для HTTP-сервера
func PanicRecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Важно помнить
recoverвнеdeferвозвращаетnilи не имеет эффекта.recoverв другой горутине не поймает панику.- Паника — не замена обработки ошибок. Злоупотребление
panicделает код хрупким и непредсказуемым.
Вопрос 9. Сработает ли программа с паникой в горутине и как её исправить?
Таймкод: 00:15:35
Ответ собеседника: Правильный. Программа не сработает, потому что паника происходит в другой горутине, а defer/recover находится в main. Нужно перенести defer с recover внутрь горутины, где может возникнуть паника.
Правильный ответ:
Программа упадёт. Паника в горутине — это аварийное завершение всей программы, если она не перехвачена внутри этой же горутины.
Проблема
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
go func() {
panic("goroutine panic") // Убьёт всю программу
}()
time.Sleep(time.Second)
}
recover в main не видит панику в дочерней горутине. Каждая горутина имеет свой стек, и recover может перехватить панику только в том стеке, где он вызван.
Исправление: recover внутри горутины
func main() {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // Поймали!
}
}()
panic("goroutine panic")
}()
time.Sleep(time.Second)
fmt.Println("program continues")
}
Универсальная обёртка для безопасных горутин
func GoSafely(f func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v\n%s", r, debug.Stack())
}
}()
f()
}()
}
func main() {
GoSafely(func() {
panic("this is handled")
})
time.Sleep(time.Second)
fmt.Println("program continues normally")
}
Продвинутый вариант с обработчиком паник
type PanicHandler func(r interface{}, stack []byte)
func GoSafelyWith(handler PanicHandler, f func()) {
go func() {
defer func() {
if r := recover(); r != nil {
stack := make([]byte, 4096)
n := runtime.Stack(stack, false)
handler(r, stack[:n])
}
}()
f()
}()
}
func main() {
GoSafelyWith(func(r interface{}, stack []byte) {
log.Printf("PANIC: %v\nStack trace:\n%s", r, stack)
// Отправить алерт, метрику и т.д.
}, func() {
panic("critical failure")
})
time.Sleep(time.Second)
}
Практические рекомендации
- Каждая горутина должна иметь свой recover — это базовое правило.
- Не полагайтесь на recover в main — он поймает только панику в главной горутине.
- Логируйте stack trace — без него диагностика паники крайне затруднена.
- Рассмотрите метрики — инкрементируйте счётчик паник для мониторинга.
- Перезапускайте воркеры — если горутина-воркер упала, запустите новую.
var panicCounter uint64
func WorkerPool(numWorkers int, tasks <-chan func()) {
for i := 0; i < numWorkers; i++ {
go func(id int) {
defer func() {
if r := recover(); r != nil {
atomic.AddUint64(&panicCounter, 1)
log.Printf("worker %d panic: %v", id, r)
// Перезапускаем воркера
WorkerPool(1, tasks)
}
}()
for task := range tasks {
task()
}
}(i)
}
}
Ключевой принцип
Паника в горутине изолирована от других горутин. Recover работает только в рамках одного стека вызовов. Проектируйте код так, чтобы каждая горутина сама отвечала за перехват паник.
Вопрос 10. Зачем нужен time.Sleep в функции main и что произойдёт, если его убрать?
Таймкод: 00:16:12
Ответ собеседника: Правильный. Если убрать time.Sleep, главная горутина завершится раньше, чем выполнится запущенная горутина. Это приведёт к недетерминированному поведению — горутина может не выполниться вообще.
Правильный ответ:
time.Sleep в main — это антипаттерн, используемый только для демонстрации. В production-коде он не должен присутствовать.
Почему горутина может не выполниться
Когда main завершается, программа завершается целиком — все горутины принудительно останавливаются. Go runtime не ждёт завершения горутин:
func main() {
go func() {
fmt.Println("this may never print")
}()
// main завершается → горутина убивается
}
Планировщик Go (GMP-модель) распределяет горутины по потокам ОС. Горутина может быть запланирована, но не успеть выполниться до завершения main. time.Sleep даёт «запас времени», но не гарантию.
Правильные способы синхронизации
sync.WaitGroup — стандартный инструмент для ожидания завершения горутин:
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("goroutine completed")
}()
wg.Wait() // Ждём завершения горутины
}
Канал — идиоматический способ сигнализировать о завершении:
func main() {
done := make(chan struct{})
go func() {
defer close(done)
fmt.Println("goroutine completed")
}()
<-done // Блокируемся, пока канал не закроется
}
Контекст — для управления временем жизни горутин:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go worker(ctx)
<-ctx.Done()
fmt.Println("done")
}
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped")
return
default:
// Работа
}
}
}
Почему time.Sleep — плохо
Ненадёжность. Сleep не гарантирует завершения горутины. На загруженной системе или при сборке мусора горутина может не успеть выполниться за отведённое время.
Неэффективность. Горутина может завершиться за 1 микросекунду, а программа будет ждать 1 секунду. Или наоборот — горутине нужно 5 секунд, а Sleep стоит на 1 секунду.
Недетерминизм. Результат может отличаться между запусками, на разных машинах, при разных нагрузках.
// ПЛОХО
func main() {
go doWork()
time.Sleep(time.Second) // Может не хватить, а может быть избыточно
}
// ХОРОШО
func main() {
done := make(chan struct{})
go func() {
doWork()
close(done)
}()
<-done // Точная синхронизация
}
Исключения
Единственный допустимый случай time.Sleep в main — это примеры, тесты и быстрые прототипы. Даже в тестах лучше использовать sync.WaitGroup или каналы для надёжности.
Вопрос 11. Что такое горутина и какие у неё характеристики?
Таймкод: 00:16:41
Ответ собеседника: Правильный. Горутина — легковесный поток исполнения минимальным размером около 2 КБ. Можно создавать тысячи и миллионы горутин. Управляется планировщиком Go, не операционной системой. Максимальный размер стека зависит от ОС: до 1 ГБ на 64-битных и 256-512 МБ на 32-битных системах.
Правильный ответ:
Горутина — это легковесная единица параллельного выполнения, управляемая рантаймом Go, а не операционной системой.
Ключевые характеристики
Размер стека. Начальный размер стека горутины — 2 КБ (в современных версиях Go; ранее было 4 КБ и 8 КБ). Стек динамически растёт и сжимается по мере необходимости. Максимальный размер стека на 64-битных системах — 1 ГБ.
Управление планировщиком Go. Горутины выполняются на потоках ОС (M — machine), но планируются планировщиком Go (G — goroutine, P — processor). Это модель GMP:
- G — горутина
- M — поток ОС
- P — процессор (контекст планирования)
Количество P по умолчанию равно runtime.NumCPU().
Масштабируемость. Можно запускать сотни тысяч и миллионы горутин. Для сравнения: поток ОС занимает около 1-8 МБ стека.
func main() {
for i := 0; i < 1_000_000; i++ {
go func() {
// Каждая горутина — всего ~2 КБ начального стека
}()
}
time.Sleep(time.Second)
}
Создание горутины
go functionName() // Вызов функции
go func() { // Анонимная функция
// тело
}()
go func(x int) { // С параметрами
fmt.Println(x)
}(42)
Коммуникация между горутинами
Горутины взаимодействуют через каналы (channels) — идиома Go: «Don't communicate by sharing memory; share memory by communicating».
func main() {
ch := make(chan int)
go func() {
ch <- 42 // Отправка в канал
}()
value := <-ch // Получение из канала
fmt.Println(value)
}
Состояния горутины
Горутина может находиться в одном из состояний:
- Running — выполняется на потоке ОС
- Runnable — готова к выполнению, ждёт в очереди
- Waiting — заблокирована (канал, мьютекс, sleep, syscall)
Переключение контекста
Переключение между горутинами значительно дешевле, чем между потоками ОС, потому что происходит в пользовательском пространстве без перехода в ядро. Стоимость — порядка сотен наносекунд против микросекунд для потоков ОС.
Практические ограничения
- Горутина не имеет идентификатора, доступного программисту (намеренное решение языка).
- Нет возможности принудительно завершить горутину извне — только через каналы, контекст или закрытие ресурсов.
- Паника в горутине без
recoverубивает всю программу.
Сравнение с потоками ОС
| Характеристика | Горутина | Поток ОС |
|---|---|---|
| Начальный стек | ~2 КБ | ~1-8 МБ |
| Переключение контекста | ~200 нс | ~1-10 мкс |
| Создание 100 000 | Без проблем | Может исчерпать ресурсы |
| Планировщик | Go runtime | Ядро ОС |
Вопрос 12. Как работает планировщик Go и что такое модель GMP?
Таймкод: 00:18:04
Ответ собеседника: Правильный. Планировщик работает по модели GMP: G — горутины, M — потоки ОС (машины), P — логические процессоры. Количество P задаётся GOMAXPROCS. У каждого P есть локальная очередь и глобальная очередь с мьютексом. При блокирующих вызовах через 10 мкс создаётся новый тред. При сетевых вызовах горутина управляется netpoller. Есть механизм work stealing — если у P закончились задачи, он забирает половину у случайного процессора.
Правильный ответ:
Ответ собеседника в целом верен. Уточним и дополним некоторые детали.
Модель GMP подробно
G (Goroutine) — горутина. Структура содержит указатель на стек, состояние, приоритет и прочие метаданные. Начальный стек — 2 КБ.
M (Machine) — поток ОС. Максимальное количество M ограничено runtime/debug.SetMaxThreads() (по умолчанию 10 000). Одна M может выполнять только одну G в каждый момент времени.
P (Processor) — логический процессор, контекст планирования. Количество P задаётся GOMAXPROCS (по умолчанию runtime.NumCPU()). Каждый P имеет локальную очередь горутин (runqueue) длиной до 256.
Как работает планирование
┌─────────────────────────────────────────────┐
│ Global Run Queue │
│ (защищена мьютексом) │
├──────────┬──────────┬──────────┬────────────┤
│ P0 │ P1 │ P2 │ P3 │
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │
│ │ G1 │ │ │ G4 │ │ │ G7 │ │ │ G10 │ │
│ │ G2 │ │ │ G5 │ │ │ G8 │ │ │ G11 │ │
│ │ G3 │ │ │ G6 │ │ │ G9 │ │ └──────┘ │
│ └──────┘ │ └──────┘ │ └──────┘ │ │
│ [M0] │ [M1] │ [M2] │ [M3] │
└──────────┴──────────┴──────────┴────────────┘
Каждый P привязан к одной M и выполняет горутины из своей локальной очереди. Связка P+M называется «thread context».
Локальная и глобальная очереди
- Локальная очередь P — до 256 горутин. Доступ без мьютекса, поэтому добавление и извлечение быстрые.
- Глобальная очередь — общая для всех P, защищена мьютексом. Используется при переполнении локальной очереди и при
GoSched().
Work Stealing
Когда у P заканчиваются горутины в локальной очереди, он:
- Проверяет глобальную очередь.
- Если и там пусто — «крадёт» половину горутин из локальной очереди случайного P.
Это обеспечивает балансировку нагрузки без постоянной конкуренции за мьютексы.
Сетевые вызовы (netpoller)
При сетевых операциях (чтение/запись из сокета) горутина не блокирует поток ОС. Go использует механизм netpoller (epoll на Linux, kqueue на macOS, IOCP на Windows):
// Горутина блокируется на чтении из сокета
conn.Read(buf)
// 1. Go runtime регистрирует файловый дескриптор в netpoller
// 2. Горутина переходит в состояние Waiting
// 3. M освобождается и выполняет другую горутину
// 4. Когда данные готовы, netpoller пробуждает горутину
// 5. Горутина возвращается в очередь на выполнение
Блокирующие системные вызовы
При блокирующих syscall (файловый ввод-вывод, sleep) горутина блокирует поток ОС. Планировщик отвязывает P от текущей M и создаёт новую M (или берёт из пула), чтобы P продолжил выполнять другие горутины:
// Блокирующий syscall
data, _ := os.ReadFile("large_file")
// 1. M блокируется на syscall
// 2. P отвязывается от M
// 3. Создаётся/переиспользуется другая M для P
// 4. Когда syscall завершится, горутина вернётся в очередь
// 5. Исходная M отправляется в пул
Уточнение к ответу собеседника: новый поток создаётся не «через 10 мкс», а когда M блокируется на syscall и планировщику нужно продолжить выполнение горутин на освободившемся P. Таймаут в 10 мкс относится к другому механизму — периодической проверке (sysmon).
Sysmon (system monitor)
Фоновая горутина, которая работает каждые ~10 мкс и:
- Пробуждает горутины, у которых истёк таймаут (timer).
- Вызывает
runtime.Gosched()у горутин, выполняющихся слишком долго (>10 мкс для интерактивных), чтобы обеспечить честное планирование. - Обрабатывает сетевые события от netpoller.
Аффинность и миграция
Горутина может мигрировать между P (и, соответственно, между потоками ОС) при блокировке. Это прозрачно для программиста, но может влиять на производительность из-за потери кэша CPU.
Настройка GOMAXPROCS
import "runtime"
func main() {
// Установить количество логических процессоров
runtime.GOMAXPROCS(4)
// Узнать текущее значение
fmt.Println(runtime.GOMAXPROCS(0))
}
В контейнерах (Docker, Kubernetes) GOMAXPROCS автоматически определяется по cgroup limits начиная с Go 1.19.
Вопрос 13. Какие сущности многопоточности используются в Go помимо горутин?
Таймкод: 00:21:06
Ответ собеседника: Правильный. Используются каналы, атомики, WaitGroup, ErrGroup и другие примитивы синхронизации из пакета sync.
Правильный ответ:
Go предоставляет богатый набор примитивов для конкурентного программирования. Разберём их по категориям.
Каналы (Channels)
Основной инструмент коммуникации между горутинами. Каналы типизированы и потокобезопасны.
// Буферизированный канал
ch := make(chan int, 10)
// Небуферизированный канал (синхронный)
ch := make(chan int)
// Однонаправленные каналы
var sendCh chan<- int // Только отправка
var recvCh <-chan int // Только получение
// select для мультиплексирования
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case ch2 <- 42:
fmt.Println("sent to ch2")
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready")
}
sync.WaitGroup
Ожидание завершения группы горутин:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d done\n", id)
}(i)
}
wg.Wait()
sync.Mutex и sync.RWMutex
Защита разделяемых данных:
var mu sync.Mutex
var counter int
func increment() {
mu.Lock()
defer mu.Unlock()
counter++
}
// RWMutex для сценариев «много читателей, мало писателей»
var rwmu sync.RWMutex
var data map[string]string
func read(key string) string {
rwmu.RLock()
defer rwmu.RUnlock()
return data[key]
}
func write(key, value string) {
rwmu.Lock()
defer rwmu.Unlock()
data[key] = value
}
sync.Once
Гарантия однократного выполнения (полезно для ленивой инициализации):
var once sync.Once
var instance *Config
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig()
})
return instance
}
sync.Map
Потокобезопасная мапа для сценариев с частыми чтениями и редкими записями:
var m sync.Map
m.Store("key", "value")
v, ok := m.Load("key")
m.Delete("key")
m.Range(func(key, value interface{}) bool {
fmt.Println(key, value)
return true // continue iteration
})
sync.Cond
Условная переменная для сигнализации между горутинами:
var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false
// Ждём сигнала
go func() {
mu.Lock()
for !ready {
cond.Wait() // Атомарно разблокирует mu и ждёт сигнала
}
fmt.Println("ready!")
mu.Unlock()
}()
// Сигнализируем
mu.Lock()
ready = true
cond.Signal() // или cond.Broadcast() для всех
mu.Unlock()
sync.Pool
Пул объектов для переиспользования (снижает нагрузку на GC):
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 4096)
},
}
func process() {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf)
// Используем buf
}
atomic
Атомарные операции для lock-free программирования:
var counter atomic.Int64
func increment() {
counter.Add(1)
}
func getValue() int64 {
return counter.Load()
}
// CompareAndSwap для lock-free алгоритмов
var state atomic.Int32
state.CompareAndSwap(0, 1) // Если текущее значение 0, установить 1
errgroup.Group
Расширение WaitGroup с поддержкой ошибок и контекста:
import "golang.org/x/sync/errgroup"
func main() {
g, ctx := errgroup.WithContext(context.Background())
urls := []string{"http://example.com", "http://example.org"}
for _, url := range urls {
url := url // Захват переменной
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}
}
context.Context
Управление временем жизни, отменой и передачей значений:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
case result := <-workCh:
fmt.Println("result:", result)
}
}()
Семафоры через буферизированные каналы
// Ограничение до 10 параллельных горутин
sem := make(chan struct{}, 10)
for _, task := range tasks {
sem <- struct{}{} // Захват слота
go func(t Task) {
defer func() { <-sem }() // Освобождение слота
process(t)
}(task)
}
singleflight
Дедупликация одинаковых вызовов (из пакета golang.org/x/sync/singleflight):
var g singleflight.Group
func getData(key string) (string, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveFetch(key)
})
return v.(string), err
}
Вопрос 14. Что такое каналы в Go, какие виды бывают и для чего нужны?
Таймкод: 00:21:46
Ответ собеседника: Правильный. Каналы — примитивы синхронизации для обмена данными между горутинами. Бывают буферизированные и небуферизированные. Небуферизированные блокируют запись до появления читателя. Буферизированные не блокируют запись, пока буфер не заполнен. По каналам можно итерироваться range, который завершится при закрытии канала.
Правильный ответ:
Каналы — это типизированные потокобезопасные очереди для передачи данных между горутинами. Они реализуют философию Go: «Don't communicate by sharing memory; share memory by communicating».
Виды каналов
Небуферизированные (синхронные) каналы
Размер буфера = 0. Отправитель блокируется до тех пор, пока получатель не прочитает значение. Это гарантия синхронизации — «handshake» между горутинами.
ch := make(chan int)
go func() {
ch <- 42 // Блокируется до чтения
fmt.Println("sent")
}()
value := <-ch // Блокируется до отправки
fmt.Println(value) // 42
// "sent" напечатано после получения
Буферизированные каналы
Имеют внутренний буфер заданного размера. Отправитель блокируется только когда буфер полон. Получатель блокируется только когда буфер пуст.
ch := make(chan int, 3)
ch <- 1 // Не блокируется
ch <- 2 // Не блокируется
ch <- 3 // Не блокируется
ch <- 4 // Блокируется — буфер полон
fmt.Println(<-ch) // 1 — освобождает место
Однонаправленные каналы
Go позволяет ограничить направление канала в сигнатурах функций:
// Только для отправки
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
// Только для получения
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}
func main() {
ch := make(chan int, 5)
go producer(ch)
consumer(ch)
}
Операции с каналами
Отправка: ch <- value
Получение: value := <-ch или value, ok := <-ch
Закрытие: close(ch) — только отправитель, только один раз
// Проверка, открыт ли канал
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
}
// Чтение до закрытия
for v := range ch {
fmt.Println(v)
}
// Чтение из закрытого канала возвращает zero value
close(ch)
v := <-ch // v == 0 (zero value для int)
// Запись в закрытый канал — паника!
close(ch)
ch <- 1 // panic: send on closed channel
select — мультиплексирование каналов
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case ch2 <- 42:
fmt.Println("sent to ch2")
case v := <-ch3:
fmt=="ch3:", v)
case <-time.After(5 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready") // Неблокирующая проверка
}
Паттерны использования каналов
Fan-out (одна задача — многим воркерам):
func fanOut(input <-chan int, n int) []<-chan int {
channels := make([]<-chan int, n)
for i := 0; i < n; i++ {
ch := make(chan int)
channels[i] = ch
go func() {
defer close(ch)
for v := range input {
ch <- v * v
}
}()
}
return channels
}
Fan-in (результаты многих воркеров — в один канал):
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, ch := range channels {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
Конвейер (pipeline):
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Конвейер: генерация → возведение в квадрат → вывод
for n := range square(square(generate(2, 3, 4, 5))) {
fmt.Println(n) // 16, 81, 256, 625
}
}
Семафор через канал:
sem := make(chan struct{}, 10) // Максимум 10 параллельных
for _, task := range tasks {
sem <- struct{}{} // Захват
go func(t Task) {
defer func() { <-sem }() // Освобождение
process(t)
}(task)
}
Таймеры и тикеры:
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
select {
case <-timer.C:
fmt.Println("timer fired")
case <-ticker.C:
fmt.Println("tick")
}
Внутреннее устройство канала
Канал внутри — структура hchan из runtime:
- Буфер (кольцевой для буферизированных)
- Очередь ожидающих отправителей (sudog)
- Очередь ожидающих получателей (sudog)
- Мьютекс для защиты операций
Операции с каналами достаточно дороги из-за мьютекса и переключения контекста. Для высоконагруженных сценариев рассмотрите sync/atomic или lock-free структуры.
Вопрос 15. Зачем задавать направление каналов (read-only, write-only)?
Таймкод: 00:23:07
Ответ собеседника: Правильный. Направление — ограничение на уровне типа. Задаётся для семантической ясности и контроля: функция получает канал только на чтение или только на запись, что предотвращает непреднамеренное закрытие или запись.
Правильный ответ:
Однонаправленные каналы — это мощный инструмент типобезопасности, который делает код самодокументируемым и защищает от ошибок.
Три типа каналов
var ch chan int // Двунаправленный: чтение и запись
var send chan<- int // Только запись
var recv <-chan int // Только чтение
Неявное преобразование
Двунаправленный канал неявно преобразуется в однонаправленный, но не наоборот:
func main() {
ch := make(chan int)
go writer(ch) // chan int → chan<- int (неявно)
go reader(ch) // chan int → <-chan int (неявно)
}
func writer(ch chan<- int) {
ch <- 42 // OK
// <-ch // Ошибка компиляции: нельзя читать
}
func reader(ch <-chan int) {
v := <-ch // OK
// ch <- 42 // Ошибка компиляции: нельзя писать
// close(ch) // Ошибка компиляции: нельзя закрывать
}
Преимущества
Самодокументирование кода. Сигнатура функции сразу говорит, что она делает с каналом:
// Понятно: функция только производит данные
func produce(ch chan<- int) { ... }
// Понятно: функция только потребляет данные
func consume(ch <-chan int) { ... }
// Непонятно: что делает эта функция с каналом?
func process(ch chan int) { ... }
Защита от ошибок на этапе компиляции. Невозможно случайно закрыть канал из функции-читателя или записать в канал-только-для-чтения:
func worker(ch <-chan Task) {
for task := range ch {
process(task)
}
// close(ch) → Ошибка компиляции!
// ch <- Task{} → Ошибка компиляции!
}
Разделение ответственности. Владелец канала (тот, кто его создал и закроет) передаёт его потребителям как <-chan, а продюсерам как chan<-. Это делает владение каналом явным:
func main() {
ch := make(chan int)
go producer(ch) // main владеет каналом, передаёт для записи
consumer(ch) // main передаёт для чтения
close(ch) // Только main закрывает канал
}
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
}
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}
Практический пример: pipeline
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Типы каналов делают поток данных очевидным:
// generate → square → println
for n := range square(generate(2, 3, 4)) {
fmt.Println(n)
}
}
Когда использовать однонаправленные каналы
Всегда, когда функция работает с каналом только в одном направлении. Это стандартная практика в Go-коде: принимать chan<- или <-chan в параметрах и возвращать <-chan из функций-генераторов. Двунаправленный chan оставляйте только там, где действительно нужны обе операции.
Вопрос 16. Для чего нужна WaitGroup и как она работает?
Таймкод: 00:23:40
Ответ собеседника: Правильный. WaitGroup нужна для ожидания выполнения группы горутин перед переходом к другому блоку кода. Методы: Add — добавляет количество операций для ожидания, Done — вызывается при завершении операции, Wait — блокирует выполнение до завершения всех операций.
Правильный ответ:
sync.WaitGroup — это счётчик ожидания, который блокирует вызывающую горутину до завершения заданного количества операций.
Три метода
var wg sync.WaitGroup
wg.Add(delta) // Увеличить счётчик на delta (может быть отрицательным)
wg.Done() // Уменьшить счётчик на 1 (аналог wg.Add(-1))
wg.Wait() // Заблокироваться, пока счётчик не станет 0
Базовый пример
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d done\n", id)
}(i)
}
wg.Wait() // Ждём завершения всех 5 воркеров
fmt.Println("all workers completed")
}
Важные правила использования
Add должен быть вызван до запуска горутины (в той же горутине, что и Wait), иначе есть гонка: Wait может увидеть счётчик 0 и завершиться раньше, чем горутины начнут работу.
// ПРАВИЛЬНО
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
wg.Wait()
// НЕПРАВИЛЬНО — возможна гонка
go func() {
wg.Add(1) // Может выполниться после Wait()
defer wg.Done()
work()
}()
wg.Wait()
Done эквивалентен Add(-1), но семантически предпочтителен:
wg.Done() // Предпочтительно
wg.Add(-1) // Работает, но менее читаемо
WaitGroup не копируем — передаём по указателю:
func process(wg *sync.WaitGroup) { // Указатель!
defer wg.Done()
// ...
}
Паттерн: параллельная обработка с ограничением
func processTasks(tasks []Task, maxWorkers int) {
var wg sync.WaitGroup
sem := make(chan struct{}, maxWorkers)
for _, task := range tasks {
wg.Add(1)
sem <- struct{}{} // Захват слота
go func(t Task) {
defer wg.Done()
defer func() { <-sem }() // Освобождение слота
processTask(t)
}(task)
}
wg.Wait()
}
Паттерн: ожидание первой ошибки
func processWithErrorHandling(tasks []Task) error {
var wg sync.WaitGroup
errCh := make(chan error, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
if err := processTask(ctx, t); err != nil {
select {
case errCh <- err:
cancel() // Отменяем остальные
default:
}
}
}(task)
}
wg.Wait()
close(errCh)
return <-errCh
}
Внутреннее устройство
WaitGroup внутри хранит счётчик (атомарное значение) и семафор для блокировки Wait(). Когда счётчик достигает 0, все заблокированные на Wait() горутины пробуждаются.
Частые ошибки
// ОШИБКА: Add внутри горутины
go func() {
wg.Add(1) // Race condition!
defer wg.Done()
work()
}()
// ОШИБКА: забыли Done
go func() {
// defer wg.Done() — забыли!
work()
}()
// ОШИБКА: копирование WaitGroup
wg2 := wg // Копия — Done не влияет на оригинал
Когда использовать WaitGroup, а когда каналы
WaitGroup подходит, когда нужно просто дождаться завершения горутин. Каналы лучше, когда нужно передать результат или сигнал о завершении:
// WaitGroup — просто ждём
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); doWork() }()
wg.Wait()
// Канал — ждём и получаем результат
result := make(chan int, 1)
go func() { result <- doWork() }()
value := <-result
Вопрос 17. Что такое мьютексы в Go и для чего они нужны?
Таймкод: 00:24:30
Ответ собеседника: Правильный. Мьютекс — примитив синхронизации с методами Lock и Unlock. Lock используется для входа в критическую секцию, Unlock — для выхода. Только одна горутина может находиться в критической секции. Защищает от data races — ситуаций, когда несколько горутин обращаются к одному адресу памяти и хотя бы одно обращение — запись. Также упомянут RWMutex — позволяет нескольким горутинам читать одновременно, но запись эксклюзивна.
Правильный ответ:
Ответ собеседника полный и точный. Дополним практическими примерами и нюансами.
sync.Mutex
Мьютекс обеспечивает эксклюзивный доступ к критической секции. В любой момент времени только одна горутина может удерживать блокировку.
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
Важно: defer Unlock
Всегда используйте defer mu.Unlock() сразу после Lock(). Это гарантирует освобождение блокировки даже при панике:
func (c *SafeCounter) Safe() {
c.mu.Lock()
defer c.mu.Unlock() // Выполнится даже при panic
// Критическая секция
}
sync.RWMutex
Оптимизация для сценариев «много читателей, мало писателей»:
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // Множество читателей одновременно
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // Эксклюзивный доступ для записи
defer c.mu.Unlock()
c.items[key] = value
}
Когда использовать Mutex, а когда RWMutex
| Сценарий | Рекомендация |
|---|---|
| Больше записей, чем чтений | sync.Mutex |
| Больше чтений, чем записей | sync.RWMutex |
| Равное количество | sync.Mutex (проще, меньше оверхеда) |
RWMutex имеет больший оверхед, чем Mutex. Если чтения и записи примерно равны, Mutex может быть быстрее.
Data race — что это и как обнаружить
Data race возникает, когда две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет:
// DATA RACE — незащищённый доступ
var counter int
func main() {
go func() { counter++ }()
go func() { counter++ }()
}
Обнаружение через -race флаг:
go run -race main.go
go test -race ./...
Вложенные блокировки (deadlock)
Mutex в Go не рекурсивный — повторный Lock в той же горутине вызовет deadlock:
mu.Lock()
mu.Lock() // DEADLOCK — горутина заблокировала сама себя
Патриций: инкапсуляция блокировки
Мьютекс и защищаемые данные должны быть в одной структуре и не быть доступны напрямую:
// ПРАВИЛЬНО
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}
// НЕПРАВИЛЬНО — мьютекс отдельно, данные отдельно
var mu sync.Mutex
var m map[string]int // Любой может получить доступ без блокировки
Вопрос 18. Напишите функцию download, которая параллельно скачивает данные из URL с помощью fakeDownload. Должна вернуть слайс строк с результатами или склеенную ошибку. Если есть ошибки, всё равно вернуть успешные результаты.
Таймкод: 00:25:46
Ответ собеседника: Правильный. Кандидат реализовал функцию с использованием WaitGroup, мьютекса для защиты слайса результатов, горутин для параллельного скачивания. При ошибке пропускает добавление результата через continue, но добавляет ошибку в слайс ошибок. В конце возвращает результаты и склеенную ошибку через errors.Join. После обсуждения с интервьюером добавил проверку: при ошибке не добавлять результат в слайс.
Правильный ответ:
Решение собеседателя корректное. Приведём несколько вариантов реализации с разным уровнем сложности.
Вариант 1: Мьютекс + WaitGroup (базовый)
func download(urls []string) ([]string, error) {
var (
wg sync.WaitGroup
mu sync.Mutex
results []string
errs []error
)
wg.Add(len(urls))
for _, url := range urls {
go func(u string) {
defer wg.Done()
data, err := fakeDownload(u)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("download %s: %w", u, err))
return
}
results = append(results, data)
}(url)
}
wg.Wait()
return results, errors.Join(errs...)
}
Вариант 2: Каналы (идиоматический Go)
func download(urls []string) ([]string, error) {
type result struct {
data string
err error
}
ch := make(chan result, len(urls))
for _, url := range urls {
go func(u string) {
data, err := fakeDownload(u)
ch <- result{data: data, err: err}
}(url)
}
var results []string
var errs []error
for i := 0; i < len(urls); i++ {
r := <-ch
if r.err != nil {
errs = append(errs, r.err)
} else {
results = append(results, r.data)
}
}
return results, errors.Join(errs...)
}
Вариант 3: errgroup (наиболее идиоматический)
func download(urls []string) ([]string, error) {
g, _ := errgroup.WithContext(context.Background())
results := make([]string, len(urls))
var mu sync.Mutex
var errs []error
for i, url := range urls {
i, url := i, url
g.Go(func() error {
data, err := fakeDownload(url)
if err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("download %s: %w", url, err))
mu.Unlock()
return err
}
mu.Lock()
results[i] = data
mu.Unlock()
return nil
})
}
_ = g.Wait() // Игнорируем ошибку — собираем их вручную
return results, errors.Join(errs...)
}
Вариант 4: С ограничением параллелизма
func download(urls []string, maxWorkers int) ([]string, error) {
var (
wg sync.WaitGroup
mu sync.Mutex
results []string
errs []error
sem = make(chan struct{}, maxWorkers)
)
for _, url := range urls {
wg.Add(1)
sem <- struct{}{}
go func(u string) {
defer wg.Done()
defer func() { <-sem }()
data, err := fakeDownload(u)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("download %s: %w", u, err))
return
}
results = append(results, data)
}(url)
}
wg.Wait()
return results, errors.Join(errs...)
}
Ключевые моменты
- Возвращаем и результаты, и ошибки — даже при ошибках часть URL могла скачаться успешно.
- errors.Join (Go 1.20+) склеивает несколько ошибок в одну.
- Мьютекс защищает разделяемые слайсы —
resultsиerrsмодифицируются из разных горутин. - Захват переменных цикла —
url := urlили передача как аргумент функции, иначе все горутины увидят последнее значение. - defer wg.Done() — гарантирует вызов даже при панике в fakeDownload.
Вопрос 19. В чём отличие errors.Is и errors.As?
Таймкод: 00:42:21
Ответ собеседника: Неполный. errors.Is проверяет, содержит ли ошибка другую ошибку с учётом вложенности через обёртки, а не просто сравнивает на точное равенство. Ответ оборвался и не был полностью дан.
Правильный ответ:
errors.Is и errors.As — это два разных инструмента для работы с цепочками ошибок. Они решают разные задачи.
errors.Is — сравнение с конкретным значением ошибки
Проверяет, есть ли в цепочке ошибок ошибка, равная целевой. Работает через == или метод Is(target) error.
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
return fmt.Errorf("find user %d: %w", id, ErrNotFound)
}
func main() {
err := findUser(42)
// Прямое сравнение не работает из-за обёртки
fmt.Println(err == ErrNotFound) // false
// errors.Is проходит по цепочке и находит
fmt.Println(errors.Is(err, ErrNotFound)) // true
}
errors.Is рекурсивно разворачивает цепочку ошибок через Unwrap() и сравнивает каждую с целевой.
errors.As — приведение к типу ошибки
Проверяет, есть ли в цепочке ошибок ошибка заданного типа, и если да — заполняет переменную. Работает через type assertion.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
func validate(input string) error {
if input == "" {
return fmt.Errorf("validate: %w", &ValidationError{
Field: "input",
Message: "cannot be empty",
})
}
return nil
}
func main() {
err := validate("")
// errors.Is не подходит — мы ищем тип, не значение
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
// Field: input, Message: cannot be empty
}
}
Ключевое различие
errors.Is | errors.As | |
|---|---|---|
| Что ищет | Конкретное значение ошибки | Тип ошибки |
| Сравнение | == или Is() | Type assertion |
| Результат | bool | bool + заполненная переменная |
| Когда использовать | Сравнение с sentinel-ошибками (io.EOF, sql.ErrNoRows) | Извлечение деталей из структурированных ошибок |
Пример совместного использования
func processFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("process file %s: %w", path, err)
}
// ...
}
func main() {
err := processFile("missing.txt")
// Проверяем конкретное значение
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file not found")
}
// Извлекаем детали из структурированной ошибки
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("operation: %s, path: %s\n", pathErr.Op, pathErr.Path)
}
}
Как они работают внутри
Оба проходят по цепочке ошибок через Unwrap():
// Упрощённая реализация errors.Is
func Is(err, target error) bool {
for {
if err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
err = Unwrap(err)
if err == nil {
return false
}
}
}
// Упрощённая реализация errors.As
func As(err error, target interface{}) bool {
for {
if reflect.TypeOf(err).AssignableTo(targetType(target)) {
// Заполняем target значением
return true
}
err = Unwrap(err)
if err == nil {
return false
}
}
}
Частая ошибка: путаница с указателями
// НЕПРАВИЛЬНО — target должен быть указателем на указатель
var ve ValidationError
errors.As(err, &ve) // Не скомпилируется или не заполнит
// ПРАВИЛЬНО
var ve *ValidationError
errors.As(err, &ve) // Заполнит ve
Вопрос 20. Что такое индекс в БД, зачм он нужен и какие виды бывают?
Таймкод: 00:44:16
Ответ собеседника: Правильный. Индекс — структура данных, оптимизирующая запросы, но увеличивающая размер хранилища и накладные расходы на запись. В PostgreSQL дефолтный — B-tree, также есть Hash, GIN, BRIN, GiST.
Правильный ответ:
Индекс — это структура данных, которая ускоряет поиск строк в таблице за счёт дополнительного хранения отсортированных ссылок на данные.
Зачем нужен индекс
Без индекса СУБД выполняет полное сканирование таблицы (Seq Scan) — читает каждую строку и проверяет условие. Для таблицы в миллион строк это миллион операций. С индексом поиск выполняется за O(log n) для B-tree — около 20 операций для миллиона строк.
-- Без индEX: Seq Scan, O(n)
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- С индексом: Index Scan, O(log n)
CREATE INDEX idx_users_email ON users(email);
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
Виды индексов в PostgreSQL
B-tree (сбалансированное дерево) — индекс по умолчанию. Поддерживает сравнения =, <, >, <=, >=, BETWEEN, IN, LIKE 'prefix%'. Хранит данные в отсортированном виде.
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_name ON users(last_name, first_name); -- составной
Hash — поддерживает только точное равенство =. Быстрее B-tree для точечного поиска, но не поддерживает диапазонные запросы.
CREATE INDEX idx_users_email_hash ON users USING hash(email);
GIN (Generalized Inverted Index) — для полнотекстового поиска, массивов, JSONB, hstore. Хранит для каждого значения список строк, где оно встречается.
-- Поиск по JSONB
CREATE INDEX idx_orders_data ON orders USING gin(data);
-- Полнотекстовый поиск
CREATE INDEX idx_articles_search ON articles USING gin(to_tsvector('english', content));
GiST (Generalized Search Tree) — для геоданных, полнотекстового поиска, диапазонных типов. Поддерживает операторы пересечения, вхождения и т.д.
-- Геоданные с PostGIS
CREATE INDEX idx_places_location ON places USING gist(location);
BRIN (Block Range Index) — компактный индекс для больших таблиц с физическим порядком данных. Хранит минимум и максимум для диапазонов блоков. Очень маленький по размеру.
-- Для таблицы с временными рядами, где данные вставляются хронологически
CREATE INDEX idx_logs_created_at ON logs USING brin(created_at);
SP-GiST (Space-Partitioned GiST) — для данных с естественным разбиением (IP-адреса, телефоны, k-d деревья).
Составные (composite) индексы
-- Индекс по нескольким столбцам
CREATE INDEX idx_users_status_created ON users(status, created_at);
-- Работает для запросов:
SELECT * FROM users WHERE status = 'active';
SELECT * FROM users WHERE status = 'active' AND created_at > '2024-01-01';
-- НЕ работает для:
SELECT * FROM users WHERE created_at > '2024-01-01'; -- Нет status!
Правило: составной индекс работает для префикса столбцов. Порядок столбцов важен.
Покрывающий индекс (covering index)
Если индекс содержит все столбцы, нужные для запроса, СУБД не обращается к таблице:
CREATE INDEX idx_users_email_name ON users(email, first_name);
-- Index Only Scan — данные берутся только из индекса
EXPLAIN ANALYZE SELECT first_name FROM users WHERE email = 'test@example.com';
Частичный индекс (partial index)
Индекс только на часть строк:
-- Индексируем только активных пользователей
CREATE INDEX idx_users_active_email ON users(email) WHERE active = true;
Уникальный индекс
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
Недостатки индексов
- Дополнительное место на диске. Индекс может занимать от 10% до 100% размера таблицы.
- Замедление записи. Каждая INSERT, UPDATE, DELETE обновляет все индексы.
- Нужда в обслуживании. B-tree индексы могут разрастаться; требуется REINDEX или VACUUM.
Когда НЕ нужен индекс
- Маленькие таблицы (до нескольких тысяч строк)
- Столбцы с низкой кардинальностью (пол, статус — 2-3 значения)
- Столбцы, которые редко используются в WHERE/JOIN
- Таблицы с частой записью и редким чтением
Как проверить использование индекса
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE email = 'test@example.com';
Ищите Index Scan или Index Only Scan вместо Seq Scan или Bitmap Heap Scan.
Вопрос 21. В чём отличие простого и составного индекса? Влияет ли порядок полей в запросе на использование индекса?
Таймкод: 00:45:19
Ответ собеседника: Правильный. Составной индекс включает несколько колонок. Сортировка сначала по первой колонке, затем внутри одинаковых — по второй. Если не использовать левую часть индекса, он может не работать. Порядок полей в запросе в новых версиях PostgreSQL оптимизируется под капотом.
Правильный ответ:
Ответ собеседника верен. Раскроем тему подробнее с примерами.
Простой индекс
Индекс по одному столбцу:
CREATE INDEX idx_users_email ON users(email);
Ускоряет запросы с фильтрацией по email:
SELECT * FROM users WHERE email = 'test@example.com';
Составной (composite) индекс
Индекс по нескольким столбцам. Данные сортируются сначала по первому столбцу, затем внутри одинаковых значений первого — по второму, и так далее. Аналогия — телефонная книга: сначала по фамилии, потом по имени.
CREATE INDEX idx_users_status_created ON users(status, created_at);
Внутри B-tree выглядит примерно так:
(active, 2024-01-01) → row_id_1
(active, 2024-01-15) → row_id_2
(active, 2024-03-10) → row_id_3
(banned, 2024-02-01) → row_id_4
(inactive, 2024-01-20) → row_id_5
Правило префикса (leftmost prefix rule)
Составной индекс (A, B, C) может быть использован для:
-- ✅ Использует индекс (префикс A)
WHERE A = 1
-- ✅ Использует индекс (префикс A, B)
WHERE A = 1 AND B = 2
-- ✅ Использует индекс (префикс A, B, C)
WHERE A = 1 AND B = 2 AND C = 3
-- ❌ НЕ использует индекс (нет A — нет префикса)
WHERE B = 2
-- ❌ НЕ использует индекс (нет A — нет префикса)
WHERE B = 2 AND C = 3
-- ⚠️ Использует индекс частично (только A, без B)
WHERE A = 1 AND C = 3
Порядок полей в WHERE
Порядок условий в WHERE не важен — оптимизатор PostgreSQL переупорядочивает их сам:
-- Эти два запроса эквивалентны для оптимизатора:
WHERE status = 'active' AND created_at > '2024-01-01'
WHERE created_at > '2024-01-01' AND status = 'active'
Оба будут использовать индекс idx_users_status_created.
Что важно — это порядок столбцов в определении индекса:
-- Этот индекс:
CREATE INDEX idx_1 ON users(status, created_at);
-- Будет работать для:
WHERE status = 'active'
WHERE status = 'active' AND created_at > '2024-01-01'
-- НЕ будет работать для:
WHERE created_at > '2024-01-01' -- Нет status!
Как правильно выбирать порядок столбцов в составном индексе
Принцип: сначала столбцы с высокой кардинальностью (много уникальных значений) и те, что используются в равенствах, затем те, что в диапазонах.
-- Запрос:
SELECT * FROM orders WHERE user_id = 123 AND status = 'shipped' AND created_at > '2024-01-01';
-- Оптимальный индекс: user_id и status (равенства), затем created_at (диапазон)
CREATE INDEX idx_orders_optimal ON orders(user_id, status, created_at);
Index Only Scan
Если индекс содержит все столбцы, которые запрашиваются, PostgreSQL может не обращаться к таблице:
CREATE INDEX idx_users_email_status ON users(email, status);
-- Index Only Scan — данные берутся из индекса
EXPLAIN ANALYZE SELECT status FROM users WHERE email = 'test@example.com';
-- Index Scan — нужны дополнительные столбцы из таблицы
EXPLAIN ANALYZE SELECT first_name FROM users WHERE email = 'test@example.com';
Практический пример
-- Таблица заказов
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Запросы в приложении:
-- 1. Найти все заказы пользователя
SELECT * FROM orders WHERE user_id = ?;
-- 2. Найти активные заказы пользователя за период
SELECT * FROM orders
WHERE user_id = ? AND status = 'active' AND created_at BETWEEN ? AND ?;
-- 3. Найти все заказы за период (админка)
SELECT * FROM orders WHERE created_at BETWEEN ? AND ?;
-- Оптимальные индексы:
CREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at);
CREATE INDEX idx_orders_created_at ON orders(created_at);
Вопрос 22. Что такое покрывающий индекс и кластерный индекс?
Таймкод: 00:46:59
Ответ собеседника: Неполный. Кандидат знает покрывающий индекс — когда все нужные колонки содержатся в индексе через INCLUDE в PostgreSQL, что позволяет не обращаться к таблице. Про кластерный индекс не слышал.
Правильный ответ:
Покрывающий индекс (Covering Index)
Покрывающий индекс содержит все столбцы, необходимые для выполнения запроса, включая те, что не участвуют в фильтрации, а только в SELECT. Это позволяет СУБД не обращаться к самой таблице — все данные берутся из индекса.
В PostgreSQL для этого используется ключевое слово INCLUDE:
-- Создаём индекс с дополнительными столбцами
CREATE INDEX idx_orders_user_include
ON orders(user_id)
INCLUDE (status, total, created_at);
-- Этот запрос выполнится как Index Only Scan
SELECT status, total, created_at
FROM orders
WHERE user_id = 123;
Столбцы в INCLUDE не участвуют в сортировке и поиске — они просто хранятся «на полях» индекса для экономии обращений к таблице.
Без INCLUDE покрывающий индекс можно создать, добавив все нужные столбцы в определение индекса, но это увеличивает размер индекса и замедляет сортировку:
-- Работает, но status, total, created_at участвуют в B-tree сортировке
CREATE INDEX idx_orders_covering ON orders(user_id, status, total, created_at);
-- Лучше: INCLUDE добавляет столбцы без влияния на структуру B-tree
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (status, total, created_at);
Кластерный индекс (Clustered Index)
Кластерный индекс определяет физический порядок строк на диске. Таблица может иметь только один кластерный индекс, потому что данные не могут быть физически отсортированы по двум разным ключам одновременно.
PostgreSQL: CLUSTER
В PostgreSQL кластеризация выполняется командой CLUSTER и не поддерживается автоматически — после вставки новых строк порядок нарушается:
-- Кластеризовать таблицу по индексу
CLUSTER orders USING idx_orders_created_at;
-- Проверить кластеризацию
SELECT relname, relhasindex FROM pg_class WHERE relname = 'orders';
Команда CLUSTER физически переупорядочивает строки таблицы в соответствии с индексом. Это одноразовая операция — последующие INSERT/UPDATE не сохраняют порядок. Для поддержания нужна периодическая перекластеризация или использование pg_repack.
InnoDB (MySQL): первичный ключ как кластерный индекс
В MySQL/InnoDB таблица всегда кластеризована по первичному ключу. Данные физически хранятся в порядке первичного ключа:
-- В InnoDB эта таблица кластеризована по id
CREATE TABLE users (
id BIGINT PRIMARY KEY, -- Кластерный индекс
email VARCHAR(255),
name VARCHAR(255),
INDEX idx_email (email) -- Вторичный индекс (содержит ссылку на PK)
);
Во вторичных индексах InnoDB листовые узлы содержат значение первичного ключа, а не физический адрес строки. Поэтому поиск по вторичному индексу требует двух обращений: сначала найти PK во вторичном индексе, затем найти строку в кластерном индексе.
Сравнение
| Характеристика | Покрывающий индекс | Кластерный индекс |
|---|---|---|
| Что делает | Содержит все нужные столбцы | Определяет физический порядок строк |
| Количество | Может быть несколько | Один на таблицу |
| Обращение к таблице | Не нужно (Index Only Scan) | Не нужно для range scan по ключу |
| Поддержка порядка | Нет | Да (данные отсортированы) |
| PostgreSQL | INCLUDE | CLUSTER (одноразово) |
| MySQL/InnoDB | INCLUDE (MySQL 8.0.13+) | Первичный ключ (автоматически) |
Практический пример покрывающего индекса
-- Частый запрос: получить email и имя по user_id
SELECT email, first_name FROM users WHERE id = ?;
-- Покрывающий индекс
CREATE INDEX idx_users_covering ON users(id) INCLUDE (email, first_name);
-- EXPLAIN покажет: Index Only Scan
EXPLAIN ANALYZE SELECT email, first_name FROM users WHERE id = 12345;
Вопрос 23. Вывести всех пользователей с их продуктами, отсортированных по User ID. Какие виды JOIN знаете?
Таймкод: 00:49:42
Ответ собеседника: Правильный. Кандидат написал запрос с INNER JOIN и сортировкой. Знает: INNER JOIN, LEFT JOIN, RIGHT JOIN, SELF JOIN, CROSS JOIN.
Правильный ответ:
Запрос для вывода пользователей с продуктами
SELECT
u.id AS user_id,
u.name AS user_name,
p.id AS product_id,
p.name AS product_name,
p.price
FROM users u
INNER JOIN products p ON p.user_id = u.id
ORDER BY u.id;
Если нужны все пользователи, включая тех, у кого нет продуктов:
SELECT
u.id AS user_id,
u.name AS user_name,
p.id AS product_id,
p.name AS product_name,
p.price
FROM users u
LEFT JOIN products p ON p.user_id = u.id
ORDER BY u.id;
Виды JOIN
INNER JOIN — только строки с совпадениями в обеих таблицах:
SELECT u.name, p.name
FROM users u
INNER JOIN products p ON p.user_id = u.id;
-- Пользователи без продуктов не попадут в результат
LEFT JOIN (LEFT OUTER JOIN) — все строки из левой таблицы, с совпадениями из правой или NULL:
SELECT u.name, p.name
FROM users u
LEFT JOIN products p ON p.user_id = u.id;
-- Все пользователи, даже без продуктов (p.name = NULL)
RIGHT JOIN (RIGHT OUTER JOIN) — все строки из правой таблицы, с совпадениями из левой или NULL:
SELECT u.name, p.name
FROM users u
RIGHT JOIN products p ON p.user_id = u.id;
-- Все продукты, даже без владельца (u.name = NULL)
На практике RIGHT JOIN используется редко — обычно переписывают как LEFT JOIN с переставленными таблицами.
FULL OUTER JOIN — все строки из обеих таблиц, с NULL при отсутствии совпадения:
SELECT u.name, p.name
FROM users u
FULL OUTER JOIN products p ON p.user_id = u.id;
-- Все пользователи + все продукты, с NULL где нет совпадения
CROSS JOIN — декартово произведение (каждая строка левой таблицы с каждой строкой правой):
SELECT u.name, p.name
FROM users u
CROSS JOIN products p;
-- Если 3 пользователя и 5 продуктов → 15 строк
SELF JOIN — таблица соединяется сама с собой:
-- Найти коллег одного отдела
SELECT e1.name AS employee, e2.name AS colleague
FROM employees e1
INNER JOIN employees e2 ON e1.department_id = e2.department_id
WHERE e1.id != e2.id;
NATURAL JOIN — автоматическое соединение по столбцам с одинаковыми именами (использовать не рекомендуется — хрупко):
SELECT * FROM users NATURAL JOIN products;
-- Соединит по всем столбцам с совпадающими именами
LATERAL JOIN — подзапрос, который может ссылаться на столбцы предыдущих таблиц:
-- Для каждого пользователя взять 3 последних продукта
SELECT u.name, lp.name, lp.created_at
FROM users u
CROSS JOIN LATERAL (
SELECT p.name, p.created_at
FROM products p
WHERE p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 3
) AS lp;
Сводная таблица
| JOIN | Что возвращает |
|---|---|
| INNER | Только совпадения |
| LEFT | Все из левой + совпадения из правой |
| RIGHT | Все из правой + совпадения из левой |
| FULL | Все из обеих, NULL при отсутствии |
| CROSS | Декартово произведение |
| SELF | Таблица сама с собой |
| LATERAL | Подзапрос с ссылкой на внешние столбцы |
Вопрос 24. Вывести топ-3 админов по количеству заведённых продуктов за январь 2025 года, отсортированных по общему количеству продуктов.
Таймкод: 00:53:40
Ответ собеседника: Правильный. Кандидат написал запрос с JOIN, фильтрацией по дате через EXTRACT, GROUP BY user_id, COUNT, ORDER BY и LIMIT 3. Допустил ошибку с группировкой, но исправился.
Правильный ответ:
SELECT
u.id AS user_id,
u.name AS user_name,
COUNT(p.id) AS product_count
FROM users u
INNER JOIN products p ON p.user_id = u.id
WHERE u.role = 'admin'
AND p.created_at >= '2025-01-01'
AND p.created_at < '2025-02-01'
GROUP BY u.id, u.name
ORDER BY product_count DESC
LIMIT 3;
Разбор ключевых частей
Фильтрация по дате. Используем >= и < вместо BETWEEN или EXTRACT — это позволяет использовать индекс на created_at:
-- ✅ Лучше: SARGable, использует индекс
WHERE p.created_at >= '2025-01-01' AND p.created_at < '2025-02-01'
-- ⚠️ Работает, но не использует индекс (функция над столбцом)
WHERE EXTRACT(YEAR FROM p.created_at) = 2025
AND EXTRACT(MONTH FROM p.created_at) = 1
-- ⚠️ Альтернатива с daterange
WHERE p.created_at <@ '[2025-01-01, 2025-02-01)'::daterange
GROUP BY. Все неагрегированные столбцы в SELECT должны быть в GROUP BY. В PostgreSQL можно группировать по первичному ключу и не включать остальные столбцы таблицы, но для читаемости лучше указать явно:
-- ✅ Явно и понятно
GROUP BY u.id, u.name
-- ✅ В PostgreSQL: если id — первичный ключ
GROUP BY u.id -- name функционально зависит от id
Сортировка и ограничение. ORDER BY COUNT(p.id) DESC с LIMIT 3 даёт топ-3. При равном количестве продуктов порядок не определён — для детерминизма можно добавить вторичную сортировку:
ORDER BY product_count DESC, u.name ASC
LIMIT 3;
Обработка нулевых значений. Если нужны админы без продуктов (с нулём), используем LEFT JOIN:
SELECT
u.id AS user_id,
u.name AS user_name,
COUNT(p.id) AS product_count
FROM users u
LEFT JOIN products p ON p.user_id = u.id
AND p.created_at >= '2025-01-01'
AND p.created_at < '2025-02-01'
WHERE u.role = 'admin'
GROUP BY u.id, u.name
ORDER BY product_count DESC
LIMIT 3;
Обратите внимание: условие по дате перенесено в ON, а не в WHERE. Если бы оно осталось в WHERE, LEFT JOIN фактически превратился бы в INNER JOIN (строки с NULL отфильтровались бы).
Оптимизация. Для этого запроса полезны индексы:
-- Для фильтрации по дате и связи
CREATE INDEX idx_products_user_created ON products(user_id, created_at);
-- Для фильтрации админов
CREATE INDEX idx_users_role ON users(role) WHERE role = 'admin';
Вопрос 25. Что означает запись LIMIT 3 OFFSET 10? Какие подводные камни при использовании LIMIT/OFFSET и какие альтернативы существуют?
Таймкод: 01:02:51
Ответ собеседника: Правильный. LIMIT 3 OFFSET 10 — пропустить 10 строк и вернуть следующие 3. Подводный камень: при большом OFFSET СУБД читает все строки до офсета. Альтернатива — курсорная пагинация.
Правильный ответ:
LIMIT 3 OFFSET 10 означает: пропустить первые 10 строк результата и вернуть следующие 3 (строки 11, 12, 13).
Проблемы LIMIT/OFFSET
Производительность при большом OFFSET. СУБД не может «перепрыгнуть» напрямую к строке 100001 — она вынуждена прочитать и отсортировать все строки до OFFSET + LIMIT, а затем отбросить первые OFFSET строк:
-- Медленно на больших таблицах: читает 100005 строк, возвращает 5
SELECT * FROM products ORDER BY created_at DESC LIMIT 5 OFFSET 100000;
Нестабильность результатов (duplicates/gaps). Если данные меняются между запросами (вставки, удаления), одни строки могут появиться дважды, а другие — пропасть:
Запрос 1 (OFFSET 0): строки [1, 2, 3, 4, 5]
Между запросами: удалена строка 2
Запрос 2 (OFFSET 5): строки [7, 8, 9, 10, 11] ← строка 6 пропала!
Курсорная пагинация (keyset pagination)
Вместо смещения запоминаем значение последней строки и запрашиваем «всё после неё»:
-- Первая страница
SELECT id, name, created_at
FROM products
ORDER BY created_at DESC, id DESC
LIMIT 5;
-- Допустим, последняя строка: id=95, created_at='2024-03-10'
-- Следующая страница — без OFFSET!
SELECT id, name, created_at
FROM products
WHERE (created_at, id) < ('2024-03-10', 95)
ORDER BY created_at DESC, id DESC
LIMIT 5;
Преимущества курсорной пагинации:
- Производительность O(log n) вместо O(offset) — используется индекс.
- Стабильность — новые/удалённые записи не сдвигают уже выданные страницы.
- Предсказуемое время ответа независимо от номера страницы.
Недостатки:
- Нельзя перейти на произвольную страницу (только «следующая»/«предыдущая»).
- Требует уникального ключа для стабильной сортировки (обычно
id).
Реализация в API
type PaginatedResponse struct {
Items []Product `json:"items"`
NextCursor string `json:"next_cursor"` // base64(id, created_at)
HasMore bool `json:"has_more"`
}
func listProducts(cursor string, limit int) (*PaginatedResponse, error) {
query := `SELECT id, name, created_at FROM products`
args := []interface{}{}
if cursor != "" {
lastCreatedAt, lastID, err := decodeCursor(cursor)
if err != nil {
return nil, err
}
query += ` WHERE (created_at, id) < ($1, $2)`
args = append(args, lastCreatedAt, lastID)
}
query += ` ORDER BY created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit+1) // +1 для проверки HasMore
rows, err := db.Query(query, args...)
// ...
products := parseProducts(rows)
hasMore := len(products) > limit
if hasMore {
products = products[:limit]
}
var nextCursor string
if hasMore && len(products) > 0 {
last := products[len(products)-1]
nextCursor = encodeCursor(last.CreatedAt, last.ID)
}
return &PaginatedResponse{
Items: products,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
Когда использовать что
| Сценарий | Рекомендация |
|---|---|
| Перемотка на произвольную страницу | LIMIT/OFFSET |
| Бесконечная прокрутка, ленты | Курсорная пагинация |
| Небольшые таблицы (< 10000 строк) | LIMIT/OFFSET допустим |
| Экспорт данных | Курсор по первичному ключу |
Альтернатива: материализованные представления
Для сложных запросов с агрегацией, которые часто пагинируют, можно использовать материализованные представления (materialized views) для предварительного вычисления результата:
CREATE MATERIALIZED VIEW product_stats AS
SELECT user_id, COUNT(*) AS product_count
FROM products
GROUP BY user_id;
-- Пагинация по материализованному представлению — быстро
SELECT * FROM product_stats ORDER BY product_count DESC LIMIT 10 OFFSET 100;
Вопрос 26. Напишите запрос для курсорной пагинации по таблице product. Что важно учитывать для корректной работы?
Таймкод: 01:04:24
Ответ собеседника: Правильный. Кандидат написал запрос SELECT * FROM product WHERE id > $1 ORDER BY id LIMIT 20. Для корректной работы необходим ORDER BY по уникальному полю для стабильного порядка.
Правильный ответ:
Ответ собеседника корректен. Приведём полное решение с деталями.
Базовая курсорная пагинация по первичному ключу
-- Первая страница (курсор пустой)
SELECT id, name, price, created_at
FROM products
ORDER BY id ASC
LIMIT 21; -- +1 для определения hasMore
-- Следующие курсоры (после последнего id на странице)
SELECT id, name, price, created_at
FROM products
WHERE id > $1
ORDER BY id ASC
LIMIT 21;
$1 — значение id последней строки на предыдущей странице.
Курсорная пагинация по неуникальному полю
Если сортировка по неуникальному полю (например, created_at), нужен дополнительный уникальный столбец для стабильности:
-- Первая страница
SELECT id, name, price, created_at
FROM products
ORDER BY created_at DESC, id DESC
LIMIT 21;
-- Следующие страницы
SELECT id, name, price, created_at
FROM products
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;
$1 — created_at последней строки, $2 — id последней строки.
Что важно учитывать
Уникальный ключ в ORDER BY. Без уникального столбца в сортировке строки с одинаковым значением сортируемого поля могут появляться на разных страницах или пропускаться:
-- ❌ Нестабильно: если у нескольких строк одинаковый created_at
ORDER BY created_at DESC
-- ✅ Стабильно: id гарантирует детерминированный порядок
ORDER BY created_at DESC, id DESC
Индекс на столбцы сортировки. Курсорная пагинация эффективна только при наличии индекса:
-- Для сортировки по id (первичный ключ — индекс уже есть)
-- Ничего дополнительно не нужно
-- Для сортировки по created_at + id
CREATE INDEX idx_products_created_id ON products(created_at DESC, id DESC);
Оператор сравнения должен совпадать с направлением сортировки:
-- ASC: ищем "больше курсора"
WHERE id > $1 ORDER BY id ASC
-- DESC: ищем "меньше курсора"
WHERE id < $1 ORDER BY id DESC
Курсоризация в коде приложения
type Page struct {
Items []Product `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
const pageSize = 20
func getProducts(ctx context.Context, cursor string) (*Page, error) {
query := `
SELECT id, name, price, created_at
FROM products
`
args := []interface{}{}
if cursor != "" {
lastCreatedAt, lastID, err := decodeCursor(cursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
query += ` WHERE (created_at, id) < ($1, $2)`
args = append(args, lastCreatedAt, lastID)
}
query += ` ORDER BY created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, pageSize+1)
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var products []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.CreatedAt); err != nil {
return nil, err
}
products = append(products, p)
}
hasMore := len(products) > pageSize
if hasMore {
products = products[:pageSize]
}
var nextCursor string
if hasMore && len(products) > 0 {
last := products[len(products)-1]
nextCursor = encodeCursor(last.CreatedAt, last.ID)
}
return &Page{
Items: products,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
func encodeCursor(createdAt time.Time, id int64) string {
s := fmt.Sprintf("%s|%d", createdAt.Format(time.RFC3339Nano), id)
return base64.URLEncoding.EncodeToString([]byte(s))
}
func decodeCursor(cursor string) (time.Time, int64, error) {
b, err := base64.URLEncoding.DecodeString(cursor)
if err != nil {
return time.Time{}, 0, err
}
parts := strings.SplitN(string(b), "|", 2)
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return time.Time{}, 0, err
}
id, err := strconv.ParseInt(parts[1], 10, 64)
return t, id, err
}
Ограничения курсорной пагинации
- Нельзя перейти на произвольную страницу по номеру.
- Курсор должен быть неизменяемым — нельзя использовать поля, которые обновляются.
- Сложнее реализовать «общее количество страниц» — обычно показывают только «следующая/предыдущая».
