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

Собеседование на Golang 260к

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

Сегодня мы разберём техническое собеседование на позицию Go-разработчика, в ходе которого кандидат решает алгоритмические задачи, отвечает на вопросы по языку Go и обсуждает архитектурные подходы. Интервьюер оценивает как практические навыки кодирования, так и глубину понимания конкурентности, структур данных и проектирования систем.

Вопрос 1. Написать функцию, которая принимает на вход слайс целых чисел и меняет порядок чисел на противоположный in place (без выделения дополнительного массива), а в main продемонстрировать работу на простом примере.

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

Ответ собеседника: Правильный. Реализована функция reverse, которая проходит циклом до середины слайса и меняет элементы местами по индексам: элемент i с элементом n-1-i. Используется два указателя — левый и правый. В main продемонстрирована работа на слайсе [1,2,3,4,5]. Результат корректный — слайс перевёрнут.

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

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

Алгоритм работы:

  1. Инициализируем два индекса: left = 0 (начало слайса) и right = len(s) - 1 (конец слайса).
  2. На каждой итерации меняем элементы s[left] и s[right] местами.
  3. Сдвигаем left вправо, right влево.
  4. Повторяем, пока left < right.

Сложность алгоритма: O(n/2) = O(n) по времени, O(1) по памяти.

Реализация:

package main

import "fmt"

// reverse разворачивает слайс целых чисел in place
func reverse(nums []int) {
left, right := 0, len(nums)-1
for left < right {
nums[left], nums[right] = nums[right], nums[left]
left++
right--
}
}

func main() {
data := []int{1, 2, 3, 4, 5}
fmt.Println("До: ", data)
reverse(data)
fmt.Println("После:", data)
}

Вывод:

До: [1 2 3 4 5]
После: [5 4 3 2 1]

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

  • В Go слайс — это ссылочный тип, поэтому изменения внутри функции reverse видны в вызывающем коде. Возвращать слайс не обязательно, но можно для удобства цепочки вызовов.
  • Обмен значений в Go выполняется одной строкой: nums[left], nums[right] = nums[right], nums[left] — это идиоматический способ, без использования временной переменной.
  • Для слайса чётной длины (например, [1, 2, 3, 4]) алгоритм корректно обработает все пары. Для нечётной длины средний элемент останется на месте, что и требуется.
  • Если слайс пустой или содержит один элемент, цикл не выполнится ни разу — это корректное поведение.

Обобщённая версия с generics (Go 1.18+):

func reverseGeneric[T any](s []T) {
for left, right := 0, len(s)-1; left < right; left, right = left+1, right-1 {
s[left], s[right] = s[right], s[left]
}
}

Это позволяет разворачивать слайсы любого типа, не только int.

Вопрос 2. Зачем использовать отдельную переменную для хранения длины слайса вместо прямого вычисления len(nums)/2 в условии цикла?

Таймкод: 00:04:01

Ответ собеседника: Правильный. Переменная n используется для вычисления длины слайса, делённой пополам. В ходе обсуждения выяснилось, что можно обойтись без отдельной переменной и просто использовать len(nums)/2 напрямую в цикле.

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

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

Вариант с отдельной переменной (менее предпочтительный):

func reverse(nums []int) {
n := len(nums)
for i := 0; i < n/2; i++ {
nums[i], nums[n-1-i] = nums[n-1-i], nums[i]
}
}

Здесь n вычисляется один раз и сохраняется в локальную переменную. Однако len(nums) для слайса — это O(1) операция (длина хранится в заголовке слайса), поэтому вызывать её в условии цикла многократно — не проблема производительности.

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

func reverse(nums []int) {
for left, right := 0, len(nums)-1; left < right; left, right = left+1, right-1 {
nums[left], nums[right] = nums[right], nums[left]
}
}

Этот вариант считается более идиоматичным и читаемым:

  • Нет промежуточной переменной n.
  • Логика симметрична: два указателя движутся навстречу друг другу.
  • Условие left < right интуитивно понятно — цикл идёт, пока указатели не встретились или не пересеклись.

Когда сохранение len в переменную имеет смысл:

  1. Читаемость — если длина используется многократно в теле функции, сохранение в переменную сокращает дублирование.
  2. Сложные выражения — если вычисление границы нетривиально, переменная с понятным именем улучшает понимание кода.
  3. Исторически — в некоторых языках (например, C с массивами, переданными как указатели) sizeof или аналогичные операции могут быть дорогими, поэтому кэширование было важно. В Go для слайсов это не актуально.

Вывод: в данном конкретном случае отдельная переменная не даёт существенных преимуществ. Вариант с двумя указателями и left < right — более чистый и идиоматичный подход в Go.

Вопрос 3. Является ли размещение переменной в стеке выделением памяти? Как в Go определяется, где размещать переменную — в стеке или в куче?

Таймкод: 00:05:48

Ответ собеседника: Правильный. Переменная размещается в стеке. В Go есть механизм escape analysis (анализ ухода), который определяет, должна ли переменная размещаться в стеке или в куче. Если переменная не «убегает» из функции, она размещается в стеке.

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

Размещение в стеке — это тоже выделение памяти, просто механизм управления этой памятью отличается от кучи.

Стек vs Куча — ключевые различия:

  • Стек — память выделяется и освобождается автоматически при входе/выходе из функции. Операции выделения и освобождения — просто сдвиг указателя стека (stack pointer). Это очень быстро. Размер стека ограничен (в Go начинается с 2 КБ на горутину, может расти).
  • Куча — память управляется сборщиком мусора (GC). Выделение медленнее, чем в стеке, и требует последующей сборки мусора. Размер ограничен доступной оперативной памятью.

Escape Analysis в Go:

Escape analysis — это этап компиляции, на котором компилятор Go анализирует, «убегает» ли переменная за пределы текущего стекового фрейма. Если да — переменная размещается в куче. Если нет — в стеке.

Признаки «убегания» переменной:

  1. Возврат указателя на локальную переменную:
func createUser() *User {
u := User{name: "Alice"} // u "убегает" в кучу
return &u
}

Если бы u осталась в стеке, после возврата из функции стековый фрейм был бы уничтожен, и указатели бы стали невалидными.

  1. Передача указателя в функцию, которая сохраняет его:
var global *int

func setGlobal() {
x := 42 // x "убегает" в кучу, т.к. указатель сохраняется в глобальной переменной
global = &x
}
  1. Размещение в структуре, которая сама убегает:
type Container struct {
data *int
}

func newContainer() *Container {
val := 100 // val "убегает" в кучу
return &Container{data: &val} // Container убегает через return, val — через вложенность
}
  1. Захват замыканием:
func counter() func() int {
n := 0 // n "убегает" в кучу, т.к. захвачена замыканием
return func() int {
n++
return n
}
}
  1. Передача в интерфейс (иногда):
func printVal(v interface{}) {
fmt.Println(v)
}

func example() {
x := 42 // может убежать в кучу при передаче в interface{}
printVal(x)
}

Как проверить, куда размещается переменная:

Компилятор Go поддерживает флаг -gcflags="-m" для вывода информации об escape analysis:

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

Пример вывения:

./main.go:5:6: u escapes to heap
./main.go:10:2: moved to heap: x

Оптимизации компилятора:

Современный компилятор Go достаточно умён и размещает переменные в стеке, когда это безопасно. Например:

func sum() int {
x := 42 // останется в стеке — не убегает
return x
}

Здесь x возвращается по значению, а не по указателю, поэтому стекового размещения достаточно.

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

  • Не пытайтесь вручную оптимизировать размещение — доверяйте компилятору.
  • Если профилирование показывает проблемы с аллокациями в куче, используйте go tool pprof и go tool trace для анализа.
  • Используйте sync.Pool для переиспользования объектов, если аллокации в куче становятся узким местом.
  • Флаг -gcflags="-m=2" даёт более подробный вывод для отладки сложных случаев.

Вопрос 4. Чем отличаются Array, Slice и Map в Go? Когда они применяются?

Таймкод: 00:06:41

Ответ собеседника: Правильный. Array — это массив фиксированной длины, изменяемый по элементам, но не по размеру. Slice — надстройка над массивом, содержит указатель на массив, длину и ёмкость. Map — хэш-таблица (словарь), неупорядоченная коллекция пар ключ-значение. Массив используется, когда размер известен заранее; slice — когда нужна динамическая длина; map — для быстрого доступа по ключу.

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

Array (Массив)

Массив в Go — это последовательность элементов одного типа фиксированной длины. Длина является частью типа: [3]int и [5]int — это разные типы.

var arr [5]int // массив из 5 нулей
arr2 := [3]string{"a", "b", "c"} // инициализация
arr3 := [...]int{1, 2, 3} // компилятор вычисляет длину автоматически

Ключевые особенности:

  • Длина фиксирована на этапе компиляции, изменить нельзя.
  • Передаётся в функции по значению (копируется весь массив).
  • Размер выделяется в стеке (если не убегает).
  • Индексация: O(1).

Когда использовать: когда количество элементов известно заранее и не меняется. Например, матрицы фиксированного размера, буфёры известного объёма, криптографические ключи.


Slice (Срез)

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

// Создание
s1 := []int{1, 2, 3} // литерал
s2 := make([]int, 5) // длина 5, ёмкость 5, заполнен нулями
s3 := make([]int, 0, 10) // длина 0, ёмкость 10
s4 := arr[1:4] // слайс из массива

// Добавление
s1 = append(s1, 4) // добавление элемента

Ключевые особенности:

  • Динамическая длина — append увеличивает слайс при необходимости.
  • Передаётся в функции по ссылке на заголовок (заголовок копируется, но базовый массив общий).
  • При переполнении ёмкости append выделяет новый массив с увеличенной ёмкостью (обычно ×2).
  • Индексация: O(1).

Важные нюансы работы с ёмкостью:

s := make([]int, 0, 4)
s = append(s, 1) // len=1, cap=4
s = append(s, 2) // len=2, cap=4
s = append(s, 3) // len=3, cap=4
s = append(s, 4) // len=4, cap=4
s = append(s, 5) // len=5, cap=8 (удвоение!)

Когда использовать: в 95% случаев при работе с последовательностями элементов. Слайс — основная коллекция в Go для списков, очередей, стеков.


Map (Карта)

Map — это хэш-таблица, реализующая ассоциативный массив (словарь) с произвольными ключами.

// Создание
m1 := map[string]int{"a": 1, "b": 2}
m2 := make(map[string]int) // пустая map
m3 := make(map[string]int, 100) // с подсказкой начальной ёмкости

// Операции
m1["c"] = 3 // добавление/обновление
val := m1["a"] // чтение, val = 1
val, ok := m1["z"] // безопасное чтение: val=0, ok=false
delete(m1, "b") // удаление

// Итерирование (порядок не гарантирован!)
for key, value := range m1 {
fmt.Println(key, value)
}

Ключевые особенности:

  • Ключи должны быть сравнимыми типами (== и !=). Слайсы, map и функции — не могут быть ключами.
  • Порядок итерации не гарантируется и может меняться между запусками.
  • Не потокобезопасна — для конкурентного доступа нужна синхронизация (sync.Mutex, sync.RWMutex) или sync.Map.
  • Средняя сложность операций: O(1) для вставки, удаления и поиска.
  • nil map можно читать (вернёт zero value), но запись в nil map вызовет panic.

Когда использовать: для быстрого поиска по ключу, подсчёта частот, кэширования, хранения конфигураций, индексов.


Сравнительная таблица:

ХарактеристикаArraySliceMap
РазмерФиксированныйДинамическийДинамический
Тип храненияЗначениеСсылка на массивХэш-таблица
ИндексацияПо индексуПо индексуПо ключу
ПорядокСохраняетсяСохраняетсяНе гарантирован
Сложность доступаO(1)O(1)O(1) средняя
Передача в функциюКопияКопия заголовкаСсылка
Ключиint (индекс)int (индекс)Любой comparable тип

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

  • Array: фиксированные таблицы коэффициентов, буфёры известного размера, ключи шифрования.
  • Slice: списки пользователей, результаты запросов к БД, очереди задач, буферы для чтения/записи.
  • Map: кэши, счётчики (map[string]int), индексы, конфигурации (map[string]string), графы смежности.

Вопрос 5. Если взять один массив и создать два слайса — один на первую половину, другой на вторую половину — куда будут указывать указатели этих слайсов?

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

Ответ собеседника: Правильный. Указатель первого слайса будет указывать на начало массива, а указатель второго слайса — на середину массива. Длина обоих слайсов будет равна половине длины массива.

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

Давайте разберём это подробно, потому что здесь есть важные нюансы о внутреннем устройстве слайсов.

Внутренняя структура слайса:

В Go слайс представлен структурой (упрощённо):

type slice struct {
ptr uintptr // указатель на первый элемент слайса в базовом массиве
len int // длина слайса
cap int // ёмкость слайса
}

Пример разбиения массива:

arr := [6]int{10, 20, 30, 40, 50, 60}

first := arr[0:3] // [10, 20, 30]
second := arr[3:6] // [40, 50, 60]

Куда указывают указатели:

  • first.ptr → указывает на arr[0] (начало массива, элемент 10)
  • second.ptr → указывает на arr[3] (середина массива, элемент 40)

Визуально в памяти:

Массив arr: [10] [20] [30] [40] [50] [60]
↑ ↑
first.ptr second.ptr

first: ptr → arr[0], len=3, cap=6
second: ptr → arr[3], len=3, cap=3

Важный нюанс — ёмкость:

Обратите внимание, что cap(first) = 6, а не 3. Ёмкость слайса считается от его первого элемента до конца базового массива. Это значит, что first может быть расширен за пределы своих текущих границ вплоть до конца массива.

arr := [6]int{10, 20, 30, 40, 50, 60}
first := arr[0:3]

fmt.Println(len(first)) // 3
fmt.Println(cap(first)) // 6 (!)

// Можно расширить first без аллокации:
first = first[:5] // [10, 20, 30, 40, 50] — всё ещё тот же массив!

Практическое следствие — утечка памяти:

Если из большого слайса взять маленький подслайс, базовый массив останется в памяти целиком:

// Читаем большой файл
data := make([]byte, 1024*1024) // 1 МБ

// Берём маленький кусок
header := data[:10] // len=10, cap=1048576

// data больше не используется, но 1 МБ остаются в памяти!
// header удерживает ссылку на весь базовый массив

Решение — копирование:

header := make([]byte, 10)
copy(header, data[:10])
// Теперь header — независимый слайс с cap=10

Ещё один нюанс — общий базовый массив:

Оба слайса ссылаются на один и тот же базовый массив, поэтому изменения через один слайс видны через другой:

arr := [6]int{10, 20, 30, 40, 50, 60}
first := arr[0:3]
second := arr[3:6]

first[0] = 99
fmt.Println(arr) // [99 20 30 40 50 60]
fmt.Println(second) // [40 50 60] — second не затронут, т.к. это другая область

second[0] = 77
fmt.Println(arr) // [99 20 30 77 50 60]

Но если ёмкость позволяет расшириться:

arr := [6]int{10, 20, 30, 40, 50, 60}
first := arr[0:3] // cap=6

// Расширяем first за пределы его текущей длины
first = first[:4] // [10, 20, 30, 40] — теперь first включает элемент second[0]!
first[3] = 99
fmt.Println(arr) // [10 20 30 99 50 60]

Резюме:

  • Указатель каждого слайса указывает на свой первый элемент в базовом массиве.
  • Оба слайса разделяют один базовый массив — изменения видны обоим.
  • Ёмкость слайса может быть больше его длины — это важно учитывать для предотвращения утечек памяти.
  • Если нужна независимая копия — используйте copy.

Вопрос 6. Что такое sync.Map и когда она применяется?

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

Ответ собеседника: Правильный. sync.Map — это потокобезопасная мапа в Go, предназначенная для работы с нескольких горутин одновременно. Под капотом это структура данных, которая обеспечивает безопасное чтение и запись без необходимости явной синхронизации (мьютексов).

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

sync.Map — это потокобезопасная реализация ассоциативного массива из стандартной библиотеки Go, оптимизированная для определённых паттернов конкурентного доступа.

API sync.Map:

var m sync.Map

// Запись
m.Store("key1", 42)
m.Store("key2", "value")

// Чтение
val, ok := m.Load("key1") // val = 42, ok = true
val, ok = m.Load("missing") // val = nil, ok = false

// Удаление
m.Delete("key1")

// Запись, если ключа нет (atomic)
actual, loaded := m.LoadOrStore("key3", 100)
// actual = 100, loaded = false (ключа не было)

// Итерирование
m.Range(func(key, value interface{}) bool {
fmt.Println(key, value)
return true // false — остановить итерацию
})

Внутреннее устройство:

sync.Map использует двухуровневую структуру на основе подхода Read-Copy-Update (RCU):

  1. read — атомарно читаемое хранилище (аналог atomic.Value), содержащее указатель на неизменяемую map. Чтение из него не требует блокировок.
  2. dirty — мутабельная map, защищённая мьютексом. Сюда попадают новые записи.
// Упрощённая внутренняя структура
type Map struct {
mu sync.Mutex
read atomic.Value // содержит *readOnly
dirty map[interface{}]*entry
misses int
}

Алгоритм работы:

  • Load (чтение): сначала проверяем read без блокировки. Если ключ найден — возвращаем сразу. Если нет — лочим мьютекс, проверяем dirty, увеличиваем счётчик misses.
  • Store (запись): если ключ есть в read — обновляем атомарно. Если нет — лочим мьютекс и пишем в dirty.
  • Промotion: когда misses достигает размера dirty, dirty становится новым read (без мьютекса для чтения), а dirty сбрасывается.

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

sync.Map не является универсальной замене обычной map с мьютексом. Она оптимизирована для двух конкретных сценариев:

Сценарий 1: Ключи, которые пишутся один раз, но читаются многократно

// Идеальный пример: кэш конфигурации
var configCache sync.Map

func init() {
// Загружаем конфигурацию один раз при старте
configCache.Store("db_host", "localhost")
configCache.Store("db_port", 5432)
configCache.Store("max_connections", 100)
}

// Множество горутин читают конфигурацию многократно
func getDBHost() string {
val, _ := configCache.Load("db_host")
return val.(string)
}

Сценарий 2: Множество горутин читают и пишут в непересекающиеся наборы ключей

// Каждая горутина работает со своим набором ключей
var counters sync.Map

func worker(id int) {
key := fmt.Sprintf("worker_%d", id)
for i := 0; i < 1000000; i++ {
val, _ := counters.LoadOrStore(key, 0)
counters.Store(key, val.(int)+1)
}
}

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

  1. Частые записи в одни и те же ключи — мьютекс будет конкурировать, и sync.Map проиграет по производительности.
  2. Небольшое количество ключей — накладные расходы sync.Map не окупятся.
  3. Нужна типобезопасность — sync.Map работает с interface{}, что требует приведения типов.
  4. Нужен детерминированный порядок итерацииRange не гарантирует порядок.

Альтернатива — map + RWMutex:

type SafeMap struct {
mu sync.RWMutex
data map[string]int
}

func (m *SafeMap) Get(key string) (int, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
val, ok := m.data[key]
return val, ok
}

func (m *SafeMap) Set(key string, val int) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = val
}

RWMutex позволяет множественным читателям работать параллельно, блокируя только на запись. Для большинства сценариев это проще и понятнее, чем sync.Map.

Бенчмарки (ориентировочные):

// Многократное чтение существующих ключей
BenchmarkSyncMap_Read 50 ns/op
BenchmarkMapWithRWMutex_Read 80 ns/op — sync.Map быстрее

// Запись новых ключей
BenchmarkSyncMap_Write 200 ns/op
BenchmarkMapWithMutex_Write 150 ns/op — обычная map быстрее

// Смешанная нагрузка (50% чтение, 50% запись)
BenchmarkSyncMap_Mixed 180 ns/op
BenchmarkMapWithRWMutex_Mixed 160 ns/op — примерно одинаково

Резюме:

  • sync.Map — специализированный инструмент для конкретных паттернов (read-heavy, disjoint writes).
  • Для большинства задач обычная map + sync.RWMutex — более простое и предсказуемое решение.
  • sync.Map избавляет от необходимости писать обёртки с мьютексами, но теряет типобезопасность.
  • Перед использованием sync.Map профилируйте — убедитесь, что она действительно даёт выигрыш в вашем сценарии.

Вопрос 7. Если один раз пройти по мапе и получить последовательность ключей, будет ли порядок сохраняться при повторном проходе?

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

Ответ собеседника: Правильный. Нет, порядок не гарантируется. В Go мапа специально сделана неупорядоченной — используется рандомайзер, чтобы порядок ключей при итерации мог меняться, даже если мапа не изменялась. Это сделано, чтобы разработчики не полагались на порядок итерации по мапе.

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

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

Демонстрация:

package main

import "fmt"

func main() {
m := map[string]int{
"a": 1,
"b": 2,
"c": 3,
"d": 4,
"e": 5,
}

// Первая итерация
fmt.Println("Итерация 1:")
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}

// Вторая итерация (мапа не менялась!)
fmt.Println("\nИтерация 2:")
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
}

Возможный вывод:

Итерация 1:
c: 3
d: 4
e: 5
a: 1
b: 2

Итерация 2:
b: 2
a: 1
e: 5
c: 3
d: 4

Порядок разный, хотя мапа не изменялась!

Почему так сделано:

В спецификации Go прямо указано:

> The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next.

Это сделано намеренно, чтобы:

  1. Предотвратить зависимость от порядка — если бы порядок был стабильным, разработчики начали бы на него полагаться, и при смене реализации хэш-таблицы код сломался бы.

  2. Усложнить атаки типа Hash DoS — злоумышленник мог бы подобрать ключи, вызывающие коллизии и деградирующие производительность до O(n). Рандомизация начального сида хэш-функции затрудняет такие атаки.

  3. Позволить менять внутреннюю реализацию — разработчики Go могут оптимизировать реализацию map, не беспокоясь о сохранении порядка итерации.

Как устроена рандомизация:

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

// Упрощённая логика (псевдокод)
func mapiterinit(t *maptype, h *hmap, it *hmapiterator) {
it.t = t
it.h = h
it.B = h.B
it.bucket = fastrand() % (1 << h.B) // случайный стартовый бакет!
it.startBucket = it.bucket
// ...
}

Функция fastrand() возвращает псевдослучайное число, что делает начальную точку итерации непредсказуемой.

Важное замечание о «детерминированности»:

Внутри одной итерации (одного вызова range) порядок будет последовательным — элементы обходятся в определённом порядке бакетов. Но при следующем вызове range начальный бакет может быть другим.

Если нужен упорядоченный обход:

Нужно вручную извлечь ключи, отсортировать и пройти:

m := map[string]int{"c": 3, "a": 1, "b": 2}

// Собираем ключи
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}

// Сортируем
sort.Strings(keys) // [a, b, c]

// Итерируем в отсортированном порядке
for _, k := range keys {
fmt.Printf("%s: %d\n", k, m[k])
}

Альтернативы для упорядоченных данных:

Если порядок критичен для вашей задачи, рассмотрите:

  1. Отдельный слайс ключей — поддерживает порядок вставки:
type OrderedMap struct {
keys []string
data map[string]int
}

func (om *OrderedMap) Set(key string, val int) {
if _, exists := om.data[key]; !exists {
om.keys = append(om.keys, key)
}
om.data[key] = val
}

func (om *OrderedMap) Iterate(fn func(string, int)) {
for _, k := range om.keys {
fn(k, om.data[k])
}
}
  1. Сторонние библиотеки — например, github.com/elliotchance/orderedmap или github.com/iancoleman/orderedmap предоставляют map с сохранением порядка вставки.

  2. Слайс структур — если данные не требуют быстрого поиска по ключу, отсортированный слайс может быть проще:

type KV struct {
Key string
Value int
}

pairs := []KV{{"a", 1}, {"b", 2}, {"c", 3}}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].Key < pairs[j].Key
})

Резюме:

  • Порядок итерации по map не гарантирован и может меняться между вызовами range.
  • Это намеренное решение языка, а не баг.
  • Если нужен предсказуемый порядок — сортируйте ключи вручную или используйте специализированные структуры данных.
  • Никогда не полагайтесь на порядок итерации map в production-коде — это хрупкое предположение, которое может сломаться при обновлении Go.

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

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

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

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

В Go ключи map должны принадлежать к comparable типам — типам, для которых определены операторы == и !=.

Сравнимые типы (могут быть ключами):

  1. Базовые типы: int, float64, string, bool, complex128
  2. Указатели — сравниваются по адресу в памяти
  3. Каналы — сравниваются по идентификатору канала
  4. Интерфейсы — сравниваются по динамическому типу и значению
  5. Массивы — если тип элемента сравним, то и массив сравним
  6. Структуры — если все поля структуры сравнимы

Несравнимые типы (НЕ могут быть ключами):

  1. Слайсы[]int, []string и т.д.
  2. Mapmap[K]V
  3. Функцииfunc(), func(int) bool и т.д.

Примеры:

// КОРРЕКТНО — сравнимые типы
m1 := map[int]string{1: "one", 2: "two"}
m2 := map[string]int{"a": 1, "b": 2}
m3 := map[[3]int]string{{1,2,3}: "key"} // массив — сравним
m4 := map[bool]int{true: 1, false: 0}

// НЕКОРРЕКТНО — несравнимые типы (ошибка компиляции)
// m5 := map[[]int]string{{1,2}: "key"} // slice — ошибка!
// m6 := map[map[string]int]int{{"a":1}: 1} // map — ошибка!
// m7 := map[func()]int{func(){}: 1} // func — ошибка!

Структуры как ключи:

Структура является сравнимой, если все её поля сравнимы:

type Point struct {
X, Y int
}

// Point — сравним, т.к. int сравним
points := map[Point]string{
{0, 0}: "origin",
{1, 0}: "right",
{0, 1}: "up",
}

// Доступ по ключу-структуре
fmt.Println(points[Point{0, 0}]) // "origin"

Структуры с указателями:

Указатели сравнимы (сравниваются по адресу), поэтому структура с указателями — тоже сравнима:

type Config struct {
Name string
Value *int // указатель — сравним
}

c1 := Config{Name: "timeout", Value: new(int)}
c2 := Config{Name: "timeout", Value: new(int)}

m := map[Config]string{}
m[c1] = "first"
m[c2] = "second"

fmt.Println(len(m)) // 2 — разные адреса указателей!

Здесь c1 и c2 — разные ключи, потому что Value указывает на разные адреса в памяти, даже если значения по этим адресам одинаковые.

Структуры со слайсами (несравнимые):

type BadKey struct {
Name string
Tags []string // слайс — несравним!
}

// Ошибка компиляции:
// m := map[BadKey]string{} // invalid map key type BadKey

Структуры с вложенными структурами:

Вложенные структуры сравнимы, если все поля на всех уровнях сравнимы:

type Address struct {
City string
ZipCode string
}

type Person struct {
Name string
Address Address // вложенная структура — сравнима
}

// Person — сравним, все поля сравнимы
people := map[Person]int{
{Name: "Alice", Address: Address{City: "Moscow", ZipCode: "123456"}}: 30,
}

Структуры с указателями на структуры:

type Inner struct {
Value int
}

type Outer struct {
Name string
Inner *Inner // указатель на структуру — сравним
}

// Outer — сравним
m := map[Outer]string{
{Name: "test", Inner: &Inner{Value: 42}}: "ok",
}

Важный нюанс — сравнение указателей:

type WithPointer struct {
Data *int
}

x, y := 42, 42
a := WithPointer{Data: &x}
b := WithPointer{Data: &y}
c := WithPointer{Data: &x} // тот же адрес, что и у a

m := map[WithPointer]string{}
m[a] = "first"
m[b] = "second"
m[c] = "third" // перезапишет "first", т.к. &x == &x

fmt.Println(len(m)) // 2, не 3!

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

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

// Преобразование слайса в строку (для небольших слайсов)
sliceKey := fmt.Sprintf("%v", []int{1, 2, 3})
m := map[string]int{sliceKey: 42}

// Или используйте хэш
import "crypto/sha256"

func sliceToKey(s []int) string {
h := sha256.New()
for _, v := range s {
h.Write([]byte(fmt.Sprintf("%d,", v)))
}
return fmt.Sprintf("%x", h.Sum(nil))
}

m2 := map[string]int{sliceToKey([]int{1, 2, 3}): 42}

Резюме:

  • Ключи map должны быть comparable типами.
  • Слайсы, map и функции — не comparable, не могут быть ключами.
  • Структуры — comparable, если все поля (включая вложенные) comparable.
  • Указатели сравниваются по адресу, а не по значению — это важно учитывать при проектировании ключей.
  • Для несравнимых типов используйте сериализацию в строку или хэш-функцию.

Вопрос 9. Что такое type assertion и type switch в Go? Как они работают?

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

Ответ собеседника: Правильный. Type assertion (приведение типа) — возможность получить конкретный тип из интерфейсного значения, например v.(int). Результатом является значение конкретного типа и булевый флаг успешности. Type switch — конструкция switch для проверки типа интерфейса: в каждом кейсе указывается конкретный тип. Если ни один кейс не совпал и есть default, переменная остаётся интерфейсного типа.

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

Type Assertion (Утверждение типа)

Type assertion — это операция извлечения конкретного типа из значения интерфейса. Синтаксис: value.(T), где value — интерфейс, T — предполагаемый конкретный тип.

Два варианта использования:

var i interface{} = "hello"

// Вариант 1: без проверки — panic при несовпадении типа
s := i.(string)
fmt.Println(s) // "hello"

// Вариант 2: с проверкой — безопасный
s, ok := i.(string)
if ok {
fmt.Println("string:", s)
} else {
fmt.Println("не string")
}

// Попытка извлечь неверный тип — panic!
// n := i.(int) // panic: interface conversion: interface {} is string, not int

Как это работает под капотом:

Интерфейс в Go — это пара (тип, значение):

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

Type assertion проверяет, совпадает ли тип в tab с запрошенным типом T. Если да — возвращает данные. Если нет — panic (в одноформном варианте) или false (в двухформном).

Type Switch (Переключатель типов)

Type switch — это специализированная форма switch для определения конкретного типа интерфейсного значения:

func describe(v interface{}) {
switch val := v.(type) {
case int:
fmt.Printf("int: %d\n", val)
case string:
fmt.Printf("string: %s\n", val)
case bool:
fmt.Printf("bool: %v\n", val)
case []int:
fmt.Printf("slice of ints: %v\n", val)
case nil:
fmt.Println("nil value")
default:
fmt.Printf("unknown type: %T\n", val)
}
}

describe(42) // int: 42
describe("hello") // string: hello
describe(true) // bool: true
describe([]int{1}) // slice of ints: [1]
describe(3.14) // unknown type: float64

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

  1. Переменная в каждом case имеет соответствующий тип:
switch v := i.(type) {
case int:
// здесь v имеет тип int, а не interface{}
fmt.Println(v + 1) // можно делать арифметику
case string:
// здесь v имеет тип string
fmt.Println(len(v)) // можно вызывать len()
}
  1. Можно проверять несколько типов в одном case:
switch v := i.(type) {
case int, int32, int64:
fmt.Printf("целое число: %v\n", v)
case string, []byte:
fmt.Printf("строковые данные: %v\n", v)
}
  1. Паттерн «assertion внутри switch» с дополнительными условиями:
func process(v interface{}) {
switch val := v.(type) {
case int:
if val > 0 {
fmt.Println("положительное число")
} else {
fmt.Println("неположительное число")
}
case string:
if len(val) > 10 {
fmt.Println("длинная строка")
}
}
}

Практический пример — обработка ошибок:

func handleError(err error) {
switch e := err.(type) {
case *sql.ErrNoRows:
log.Println("запись не найдена:", e)
http.Error(w, "Not Found", 404)
case *pq.Error:
// PostgreSQL-специфичная ошибка
log.Printf("DB error: code=%s, message=%s\n", e.Code, e.Message)
http.Error(w, "Database Error", 500)
case net.Error:
if e.Timeout() {
log.Println("таймаут сети")
http.Error(w, "Gateway Timeout", 504)
}
default:
log.Println("неизвестная ошибка:", err)
http.Error(w, "Internal Error", 500)
}
}

Type assertion с интерфейсами (проверка реализации):

type Stringer interface {
String() string
}

func printIfStringer(v interface{}) {
if s, ok := v.(Stringer); ok {
fmt.Println(s.String())
} else {
fmt.Println("не реализует Stringer")
}
}

Разница между type assertion и type switch:

Type AssertionType Switch
Синтаксисv.(T)switch v := x.(type)
Количество типовОдин конкретныйНесколько альтернатив
Обработка ошибокpanic или okdefault ветка
Когда использоватьЗаранее знаете типНужно обработать несколько типов

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

// Ошибка: type assertion к конкретному типу, а не к интерфейсу
var w io.Writer = os.Stdout
// b := w.(bytes.Buffer) // panic! w содержит *os.File, не bytes.Buffer

// Правильно: проверяем с ok
if buf, ok := w.(*bytes.Buffer); ok {
// ...
}

// Ошибка: забыли ok — может быть panic
func risky(v interface{}) int {
return v.(int) // panic, если v не int!
}

// Правильно: безопасная версия
func safe(v interface{}) (int, bool) {
n, ok := v.(int)
return n, ok
}

Резюме:

  • Type assertion v.(T) — извлечение конкретного типа из интерфейса. Используйте двухформный вариант v, ok := v.(T) для безопасности.
  • Type switch — удобный способ обработать несколько возможных типов интерфейса в одном блоке.
  • Всегда обрабатывайте случай несовпадения типа — либо через ok, либо через default.
  • Type assertion работает не только с interface{}, но и с любым интерфейсным типом для проверки реализации дополнительных интерфейсов.

Вопрос 10. Что такое горутины в Go, для чего они используются и чем отличается конкурентность от параллельности?

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

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

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

Горутины (goroutines) — это легковесные потоки исполнения, управляемые рантаймом Go, а не операционной системой. Они являются фундаментальной единицей конкурентности в Go.

Создание и запуск горутин:

// Запуск функции в отдельной горутине
go process(data)

// Запуск анонимной функции
go func() {
fmt.Println("работаю в горутине")
}()

// С замыканием
for i := 0; i < 10; i++ {
go func(n int) {
fmt.Println("горутина:", n)
}(i) // передаём i как аргумент, а не захватываем замыканием!
}

Ключевые характеристики горутин:

ХарактеристикаГорутинаПоток ОС
Размер стекаНачинается с ~2 КБ, растёт динамическиОбычно 1-8 МБ, фиксирован
Создание~несколько нс~мкс
Переключение контекста~нс (в пользовательском пространстве)~мкс (через ядро ОС)
Максимальное количествоСотни тысяч — миллионыТысячи (ограничено памятью)

Конкурентность vs Параллельность:

Это принципиально разные понятия, и их часто путают:

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

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

Аналогия:

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

Примеры на Go:

// Конкурентный, но НЕ параллельный (один поток ОС)
func concurrent() {
go task1() // горутина 1
go task2() // горутина 2
// На одноядерной машине — конкурентно, но не параллельно
}

// Конкурентный И параллельный (несколько потоков ОС)
func parallel() {
runtime.GOMAXPROCS(4) // разрешаем использовать 4 потока ОС
go task1() // может выполняться на ядре 1
go task2() // может выполняться на ядре 2 одновременно с task1
}

Как работает планировщик Go (GOMAXPROCS):

┌─────────────────────────────────────────────┐
│ Go Runtime Scheduler │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ G1 │ │ G2 │ │ G3 │ │ G4 │ │ Горутины (G)
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │ │
│ ┌──▼─────────▼─────────▼─────────▼──┐ │
│ │ Глобальная очередь │ │
│ └──────────────────┬─────────────────┘ │
│ │ │
│ ┌──────────┐ ┌────▼───┐ ┌──────────┐ │
│ │ P1 │ │ P2 │ │ P3 │ │ Процессоры (P)
│ │ ┌──────┐ │ │┌──────┐│ │ ┌──────┐ │ │
│ │ │ LQ │ │ ││ LQ ││ │ │ LQ │ │ │ Локальные очереди
│ │ │ G5 │ │ ││ G8 ││ │ │ G11 │ │ │
│ │ │ G6 │ │ ││ G9 ││ │ │ G12 │ │ │
│ │ │ G7 │ │ ││ G10 ││ │ │ ... │ │ │
│ │ └──────┘ │ │└──────┘│ │ └──────┘ │ │
│ └──┬───────┘ └──┬─────┘ └──┬───────┘ │
│ │ │ │ │
│ ┌──▼───┐ ┌───▼──┐ ┌───▼──┐ │
│ │ M1 │ │ M2 │ │ M3 │ │ Потоки ОС (M)
│ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────┘
  • G (Goroutine) — горутина
  • M (Machine) — поток ОС
  • P (Processor) — логический процессор, связывает M с очередью G
  • GOMAXPROCS — количество P (по умолчанию = количеству ядер CPU)

Практические паттерны использования горутин:

1. Пул воркеров (Worker Pool):

func workerPool(jobs []int, numWorkers int) {
jobsCh := make(chan int, len(jobs))
resultsCh := make(chan int, len(jobs))

// Запускаем воркеров
for w := 0; w < numWorkers; w++ {
go func(id int) {
for job := range jobsCh {
result := process(job)
resultsCh <- result
}
}(w)
}

// Отправляем задачи
for _, job := range jobs {
jobsCh <- job
}
close(jobsCh)

// Собираем результаты
for i := 0; i < len(jobs); i++ {
<-resultsCh
}
}

2. Fan-out / Fan-in:

// Fan-out: распределяем задачи по нескольким горутинам
func fanOut(input <-chan int, n int) []<-chan int {
outputs := make([]<-chan int, n)
for i := 0; i < n; i++ {
ch := make(chan int)
outputs[i] = ch
go func() {
defer close(ch)
for val := range input {
ch <- process(val)
}
}()
}
return outputs
}

// Fan-in: объединяем результаты из нескольких каналов
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(channels))

for _, ch := range channels {
go func(c <-chan int) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

3. Graceful shutdown:

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

// Обработка сигналов ОС
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

go func() {
<-sigCh
fmt.Println("получен сигнал завершения...")
cancel() // отменяем контекст — все горутины узнают
}()

// Запускаем воркеров, слушающих контекст
for i := 0; i < 5; i++ {
go worker(ctx, i)
}

<-ctx.Done()
fmt.Println("завершение...")
}

func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("воркер %d завершается\n", id)
return
default:
// работа
time.Sleep(time.Second)
}
}
}

Важные правила работы с горутинами:

  1. Горутина завершается при выходе из функции — нет способа принудительно убить горутину извне (используйте каналы или контекст для кооперативной остановки).

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

// УТЕЧКА! Горутина никогда не завершится
go func() {
ch := make(chan int)
<-ch // блокировка навсегда — никто не напишет в канал
}()
  1. Порядок выполнения горутин не гарантирован — не полагайтесь на порядок запуска.

  2. Замыкания и циклы — классическая ловушка:

// НЕПРАВИЛЬНО — все горутины увидят одно значение i
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // может напечатать 10, 10, 10...
}()
}

// ПРАВИЛЬНО — передаём i как аргумент
for i := 0; i < 10; i++ {
go func(n int) {
fmt.Println(n) // каждое значение своё
}(i)
}

Резюме:

  • Горутины — легковесные потоки исполнения Go, управляемые рантаймом, а не ОС.
  • Конкурентность — это структура (работа с несколькими задачами), параллельность — это выполнение (одновременно на нескольких ядрах).
  • Go по умолчанию использует GOMAXPROCS = количество ядер, что позволяет конкурентным программам быть параллельными.
  • Горутины дешёвые (стартуют с 2 КБ стека), но утечки горутин — частая проблема.
  • Для координации горутин используйте каналы, sync.WaitGroup, context.Context и select.

Вопрос 11. Написать программу из трёх горутин: одна генерирует числа (например, от 1 до 10) и отправляет двум другим, а две другие выводят числа на экран. Результат — все числа выведены в любом порядке без дубликатов и зависаний.

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

Ответ собеседника: Неполный. Решение использовало WaitGroup для синхронизации горутин. Продюсер пишет числа в канал, два консьюмера читают из канала и печатают. Однако при попытке убрать горутину продюсера и запись в канал из основной горутины программа зависла (deadlock), потому что небуферизированный канал блокирует отправку, пока нет читателя. Проблема была в порядке запуска — нужно сначала запустить консьюмеров, а потом отправлять данные. Также обсуждалось, что буферизированный канал не решает проблему при неизвестном объёме данных.

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

Задача требует понимания нескольких ключевых аспектов: как организовать передачу данных от одного продюсера к нескольким консьюмерам без дубликатов и зависаний.

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

Правильное решение:

package main

import (
"fmt"
"sync"
)

func main() {
ch := make(chan int)
var wg sync.WaitGroup

// Запускаем двух консьюмеров
wg.Add(2)
for i := 1; i <= 2; i++ {
go func(id int) {
defer wg.Done()
for num := range ch {
fmt.Printf("консьюмер %d: %d\n", id, num)
}
}(i)
}

// Продюсер — пишет числа в канал
for i := 1; i <= 10; i++ {
ch <- i
}
close(ch) // закрываем канал — консьюмеры завершатся

wg.Wait() // ждём завершения консьюмеров
fmt.Println("готово")
}

Вывод (порядок может отличаться):

консьюмер 1: 1
консьюмер 2: 2
консьюмер 1: 3
консьюмер 2: 4
консьюмер 1: 5
консьюмер 2: 6
консьюмер 1: 7
консьюмер 2: 8
консьюмер 1: 9
консьюмер 2: 10
готово

Почему это работает корректно:

  1. Без дубликатов: каждое значение из канала читается ровно один раз. Если оба консьюмера читают из одного канала, Go гарантирует, что каждое значение достанется только одному из них.

  2. Без зависаний: продюсер пишет в канал из основной горутины. Небуферизированный канал блокирует отправку, пока консьюмер не прочитает — но консьюмеры уже запущены и готовы читать. После закрытия канала range в консьюмерах завершается.

  3. Корректное завершение: close(ch) сигнализирует консьюмерам об окончании данных. wg.Wait() гарантирует, что основная горутина дождётся завершения всех консьюмеров.

Если продюсер тоже должен быть в отдельной горутине:

func main() {
ch := make(chan int)
var wg sync.WaitGroup

// Консьюмеры
wg.Add(2)
for i := 1; i <= 2; i++ {
go func(id int) {
defer wg.Done()
for num := range ch {
fmt.Printf("консьюмер %d: %d\n", id, num)
}
}(i)
}

// Продюсер в отдельной горутине
go func() {
for i := 1; i <= 10; i++ {
ch <- i
}
close(ch)
}()

wg.Wait()
}

Типичные ошибки и их решения:

Ошибка 1: Deadlock при неправильном порядке

// НЕПРАВИЛЬНО — deadlock!
ch := make(chan int)

// Сначала пишем в канал (блокируется, т.к. нет читателей)
for i := 1; i <= 10; i++ {
ch <- i // ЗАВИСНЕТ здесь!
}

// Потом запускаем консьюмеров — но мы уже зависли
go consumer(ch)

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

Ошибка 2: Утечка горутин

// НЕПРАВИЛЬНО — консьюмеры никогда не завершатся
ch := make(chan int)
go consumer(ch) // range по ch ждёт закрытия канала

for i := 1; i <= 10; i++ {
ch <- i
}
// забыли close(ch) — консьюмер завис навсегда!

Решение: всегда закрывайте канал после окончания отправки.

Ошибка 3: Запись в закрытый канал

close(ch)
ch <- 42 // panic: send on closed channel

Решение: только продюсер должен закрывать канал. Никогда не пишите в закрытый канал.

Альтернативный подход — с буферизированным каналом:

func main() {
ch := make(chan int, 10) // буфер на все числа
var wg sync.WaitGroup

// Продюсер может работать в любом порядке
go func() {
for i := 1; i <= 10; i++ {
ch <- i // не блокируется, пока буфер не полон
}
close(ch)
}()

wg.Add(2)
for i := 1; i <= 2; i++ {
go func(id int) {
defer wg.Done()
for num := range ch {
fmt.Printf("консьюмер %d: %d\n", id, num)
}
}(i)
}

wg.Wait()
}

Буферизированный канал позволяет продюсеру отправить все данные без блокировки, независимо от порядка запуска горутин. Но для больших объёмов данных буфер может занять много памяти.

Паттерн с контекстом для отмены:

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

ch := make(chan int)
var wg sync.WaitGroup

// Консьюмеры с поддержкой отмены
wg.Add(2)
for i := 1; i <= 2; i++ {
go func(id int) {
defer wg.Done()
for {
select {
case num, ok := <-ch:
if !ok {
return // канал закрыт
}
fmt.Printf("консьюмер %d: %d\n", id, num)
case <-ctx.Done():
fmt.Printf("консьюмер %d: отмена\n", id)
return
}
}
}(i)
}

// Продюсер
go func() {
defer close(ch)
for i := 1; i <= 10; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}()

wg.Wait()
}

Резюме:

  • Один канал с несколькими читателями — каждое значение получит ровно один консьюмер (без дубликатов).
  • Порядок запуска важен для небуферизированных каналов: сначала читатели, потом писатели.
  • Закрытие канала — сигнал для завершения консьюмеров.
  • sync.WaitGroup — для ожидания завершения всех горутин.
  • Буферизированный канал снимает ограничение на порядок запуска, но требует памяти.

Вопрос 12. Есть тяжёлый запрос (ручка), который стал отдавать данных значительно медленнее (например, вместо 2 секунд — минуту). Ничего не менялось в коде. Куда смотреть и что делать?

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

Ответ собеседника: Правильный. Сначала посмотреть логи и метрики, убедиться, что проблема именно в этой ручке. Возможные причины: высокая нагрузка на сервер (CPU, RAM, диск), проблемы с базой данных (отсутствие индексов, медленные запросы, блокировки). Можно использовать индексы (B-tree для диапазонных операций, хэш-индексы для точечного сравнения). Нужно проверить метрики нагрузки и проанализировать план выполнения запроса.

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

Когда ручка замедляется без изменений в коде, проблема почти всегда во внешних факторах. Нужен системный подход к диагностике.

Шаг 1: Подтвердить проблему и локализовать

Прежде чем искать причину, убедитесь, что проблема реальна и именно в этой ручке:

  • Проверьте метрики: P50, P95, P99 latency для этой ручки за последние дни/недели.
  • Сравните с другими ручками — проблема изолирована или системная?
  • Проверьте, есть ли корреляция с временем суток (рост нагрузки?) или днём недели.

Шаг 2: Построить карту зависимостей

Тяжёлая ручка обычно зависит от нескольких компонентов:

HTTP Request

├── Application Server (Go)
│ ├── SQL Query 1 (основная таблица)
│ ├── SQL Query 2 (справочник)
│ ├── Redis Cache
│ └── External API call

├── Database Server
│ ├── CPU / RAM / Disk I/O
│ └── Lock contention

└── Network
├── Latency до БД
└── Latency до внешних сервисов

Шаг 3: Диагностика по уровням

А. Уровень приложения (Go)

// Добавьте профилирование, если ещё нет
import _ "net/http/pprof"

// Или используйте OpenTelemetry для трейсинг
func heavyHandler(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "heavyHandler")
defer span.End()

// Трейсинг подзапросов
ctx1, span1 := tracer.Start(ctx, "db.query.main")
data := db.QueryContext(ctx1, "SELECT ...")
span1.End()

ctx2, span2 := tracer.Start(ctx, "redis.get")
cache := redisClient.Get(ctx2, key)
span2.End()
}

На что смотреть:

  • CPU профиль: какая функция потребляет больше всего CPU?
  • Heap профиль: есть ли утечки памяти, рост аллокаций?
  • Block профиль: где горутины блокируются (mutex, channel, I/O)?
# Сбор профилей
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/block

Б. Уровень базы данных

Частая причина — деградация производительности SQL-запросов из-за роста данных.

-- PostgreSQL: найти медленные запросы
SELECT query, calls, mean_time, total_time, rows
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 20;

-- Проверить блокировки
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.query AS blocked_query,
blocking_locks.pid AS blocking_pid,
blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

-- Анализ плана выполнения
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE user_id = 12345
AND created_at > '2024-01-01'
ORDER BY created_at DESC
LIMIT 100;

На что смотреть в плане выполнения:

  • Seq Scan вместо Index Scan — отсутствует индекс или планировщик не использует его.
  • Nested Loop с большим количеством строк — возможно, нужен Hash Join или Merge Join.
  • Sort с большим объёмом данных — не хватает памяти для сортировки (work_mem).
  • Buffers: shared hit/read — много read означает, что данные не в кэше БД.

В. Типичные проблемы с индексами:

-- Проблема: индекс есть, но планировщик его не использует
-- Причина: селективность низкая (слишком много строк соответствуют условию)

-- Решение: составной индекс с правильным порядком полей
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);

-- Проблема: индекс не покрывает запрос (Index Only Scan невозможен)
-- Решение: включить нужные поля в индекс
CREATE INDEX idx_orders_covering
ON orders (user_id, created_at DESC)
INCLUDE (status, total_amount);

-- Проблема: индекс "протух" (статистика устарела)
ANALYZE orders;

-- Проблема: bloat (раздутие) индекса после массовых обновлений/удалений
REINDEX INDEX CONCURRENTLY idx_orders_user_created;

Г. Уровень инфраструктуры

# Нагрузка на сервер
top -H -p $(pgrep -f your-app) # потоки приложения
iostat -x 1 10 # I/O диска
vmstat 1 10 # память, swap, CPU
ss -s # сетевые соединения

# Проверить лимиты
ulimit -a
cat /proc/$(pgrep -f your-app)/limits

На что смотреть:

  • CPU throttling — контейнер упирается в CPU limit (cgroups).
  • Memory pressure — OOM killer, swap usage.
  • Disk I/O wait — диск не справляется (особенно актуально для БД).
  • Network saturation — пропускная способность сети.

Шаг 4: Типичные причины и решения

Причина 1: Рост данных без обновления индексов

-- Таблица выросла с 1 млн до 100 млн строк
-- Старый индекс стал неэффективен

-- Решение: партиционирование
CREATE TABLE orders (
id BIGSERIAL,
user_id INT NOT NULL,
created_at TIMESTAMP NOT NULL,
status VARCHAR(20),
total_amount DECIMAL(10,2)
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2024_q1 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- и т.д.

Причина 2: Lock contention (конкуренция за блокировки)

-- Долгая транзакция блокирует другие
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... долго думает ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Решение: уменьшить время транзакции, использовать SELECT FOR UPDATE точечно
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- быстрые операции
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Причина 3: N+1 запросов

// ПЛОХО: N+1 запросов
users := db.Query("SELECT id, name FROM users LIMIT 100")
for _, user := range users {
orders := db.Query("SELECT * FROM orders WHERE user_id = ?", user.ID)
// 100 дополнительных запросов!
}

// ХОРОШО: один запрос с JOIN
rows := db.Query(`
SELECT u.id, u.name, o.id as order_id, o.total_amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id IN (SELECT id FROM users LIMIT 100)
`)

// ИЛИ: batch-запрос
userIDs := []int{1, 2, 3, ...}
orders := db.Query("SELECT * FROM orders WHERE user_id = ANY($1)", pq.Array(userIDs))

Причина 4: Отсутствие кэширования

// Добавляем кэширование
func getExpensiveData(ctx context.Context, key string) (*Data, error) {
// Пробуем кэш
if cached, err := redisClient.Get(ctx, key).Result(); err == nil {
var data Data
if err := json.Unmarshal([]byte(cached), &data); err == nil {
return &data, nil
}
}

// Кэш пуст — идём в БД
data, err := fetchFromDB(ctx, key)
if err != nil {
return nil, err
}

// Сохраняем в кэш
encoded, _ := json.Marshal(data)
redisClient.Set(ctx, key, encoded, 5*time.Minute)

return data, nil
}

Причина 5: Размер соединений с БД

// Проверьте настройки пула соединений
db.SetMaxOpenConns(25) // максимум открытых соединений
db.SetMaxIdleConns(10) // максимум простаивающих соединений
db.SetConnMaxLifetime(5 * time.Minute) // время жизни соединения

// Мониторинг пула
stats := db.Stats()
fmt.Printf("Open: %d, InUse: %d, Idle: %d, WaitCount: %d\n",
stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount)
// Если WaitCount растёт — не хватает соединений

Шаг 5: Чеклист диагностики

  1. Метрики приложения: latency, error rate, throughput, goroutine count, GC pause.
  2. Профилирование: CPU, heap, block профили.
  3. База данных: медленные запросы, планы выполнения, блокировки, размер таблиц и индексов.
  4. Инфраструктура: CPU, RAM, диск I/O, сеть, лимиты контейнеров.
  5. Зависимости: время ответа внешних сервисов, Redis, других микросервисов.
  6. Логи: ошибки, таймауты, предупреждения.

Резюме:

  • Проблема «ничего не менялось, но стало медленнее» почти всегда связана с ростом данных или ростом нагрузки.
  • Начинайте с метрик и логов — они покажут, где именно происходит замедление.
  • Частые причины: отсутствие/неэффективность индексов, N+1 запросы, lock contention, нехватка ресурсов.
  • Используйте профилирование Go (pprof) и анализ планов запросов (EXPLAIN ANALYZE) как основные инструменты диагностики.
  • Профилактика: мониторинг, алерты на рост latency, регулярный анализ медленных запросов, нагрузочное тестирование.

Вопрос 13. Что такое нормализация и денормализация базы данных? В чём смысл и импакт?

Таймкод: 00:32:42

Ответ собеседника: Правильный. Нормализация — это процесс структурирования базы данных для обеспечения целостности данных и устранения избыточности. Существуют формы нормализации (1НФ, 2НФ, 3НФ и т.д.), каждая со своими требованиями. Например, 1НФ требует, чтобы каждая ячейка содержала одно значение; 3НФ устраняет транзитивные зависимости. Денормализация — обратный процесс, когда данные дублируются для повышения производительности чтения.

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

Нормализация — это процесс организации данных в базе для минимизации избыточности и аномалий при вставке, обновлении и удалении. Денормализация — преднамеренное введение избыточности для повышения производительности чтения.

Нормализация: основные формы

1НФ (Первая нормальная форма)

Каждая ячейка содержит только одно атомарное значение. Нет повторяющихся групп.

-- НЕ 1НФ: несколько телефонов в одной ячейке
CREATE TABLE users_bad (
id INT,
name VARCHAR(100),
phones VARCHAR(255) -- "123-456, 789-012"
);

-- 1НФ: каждое значение в отдельной строке
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100)
);

CREATE TABLE user_phones (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
phone VARCHAR(20)
);

2НФ (Вторая нормальная форма)

Таблица в 1НФ + все неключевые атрибуты полностью зависят от всего первичного ключа (не от его части).

-- НЕ 2НФ: составной ключ (order_id, product_id),
-- но product_name зависит только от product_id
CREATE TABLE order_items_bad (
order_id INT,
product_id INT,
product_name VARCHAR(100), -- зависит только от product_id!
quantity INT,
PRIMARY KEY (order_id, product_id)
);

-- 2НФ: разделяем на две таблицы
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100)
);

CREATE TABLE order_items (
order_id INT,
product_id INT REFERENCES products(product_id),
quantity INT,
PRIMARY KEY (order_id, product_id)
);

3НФ (Третья нормальная форма)

Таблица в 2НФ + нет транзитивных зависимостей (неключевой атрибут не зависит от другого неключевого атрибута).

-- НЕ 3НФ: department_name завит от department_id,
-- который не является частью первичного ключа
CREATE TABLE employees_bad (
employee_id INT PRIMARY KEY,
name VARCHAR(100),
department_id INT,
department_name VARCHAR(100), -- транзитивная зависимость!
department_location VARCHAR(100)
);

-- 3НФ: выносим department в отдельную таблицу
CREATE TABLE departments (
department_id INT PRIMARY KEY,
department_name VARCHAR(100),
department_location VARCHAR(100)
);

CREATE TABLE employees (
employee_id INT PRIMARY KEY,
name VARCHAR(100),
department_id INT REFERENCES departments(department_id)
);

BCNF (Нормальная форма Бойса-Кодда)

Усиленная 3НФ: для каждой функциональной зависимости X → Y, X должен быть суперключом. На практике большинство таблиц в 3НФ уже в BCNF.

Импакт нормализации:

АспектВлияние
Целостность данныхУстраняет аномалии обновления, вставки, удаления
Размер базыМеньше данных за счёт отсутствия дублирования
ЗаписьБыстрее — обновляете одно место, а не много
ЧтениеМедленнее — нужны JOIN для сборки данных
Сложность схемыБольше таблиц, больше связей
СогласованностьВыше — данные хранятся в одном месте

Аномалии, которые устраняет нормализация:

-- Аномалия обновления: меняем отдел в одном месте, забываем в другом
UPDATE employees_bad SET department_name = 'Engineering' WHERE department_id = 1;
-- Нужно обновить ВСЕ строки с department_id = 1

-- Аномалия вставки: нельзя создать отдел без сотрудников
INSERT INTO departments (department_id, department_name) VALUES (5, 'HR');
-- В нормализованной схеме — без проблем

-- Аномалия удаления: удаляем последнего сотрудника — теряем информацию об отделе
DELETE FROM employees_bad WHERE department_id = 3;
-- В нормализованной схеме — отдел остаётся в таблице departments

Денормализация

Денормализация — преднамеренное нарушение нормальных форм для повышения производительности чтения.

Когда применять:

  1. Read-heavy нагрузка — чтение значительно преобладает над записью.
  2. Сложные JOIN становятся узким местом — запрос с 5+ JOIN работает секундами.
  3. Агрегация и отчёты — аналитические запросы, где важна скорость.
  4. Кэширование вычисляемых значений — храним уже посчитанное.

Примеры денормализации:

-- Добавляем дублирующее поле для избежания JOIN
CREATE TABLE orders_denorm (
order_id INT PRIMARY KEY,
user_id INT,
user_name VARCHAR(100), -- дублируется из users.name
user_email VARCHAR(100), -- дублируется из users.email
total_amount DECIMAL(10,2),
created_at TIMESTAMP
);

-- Добавляем агрегирующее поле (кэш)
CREATE TABLE products_denorm (
product_id INT PRIMARY KEY,
name VARCHAR(100),
price DECIMAL(10,2),
total_sold INT DEFAULT 0, -- агрегированное значение
avg_rating DECIMAL(3,2), -- агрегированное значение
last_sold_at TIMESTAMP -- агрегированное значение
);

-- Храним массив/JSON вместо связанной таблицы
CREATE TABLE articles (
article_id INT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
tags JSONB, -- ['go', 'database', 'performance']
author_name VARCHAR(100), -- дублируется из authors
comment_count INT DEFAULT 0 -- агрегированное значение
);

Поддержание согласованности при денормализации:

Денормализованные данные нужно обновлять при изменении источника:

-- Триггер для автоматического обновления дублирующего поля
CREATE OR REPLACE FUNCTION update_order_user_info()
RETURNS TRIGGER AS $$
BEGIN
UPDATE orders_denorm
SET user_name = NEW.name,
user_email = NEW.email
WHERE user_id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_users_update
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_order_user_info();

-- Или обновление через приложение (event-driven)
// При изменении пользователя — публикуем событие
func updateUser(db *sql.DB, user User) error {
tx, _ := db.Begin()
defer tx.Rollback()

_, err := tx.Exec("UPDATE users SET name = $1 WHERE id = $2", user.Name, user.ID)
if err != nil {
return err
}

// Обновляем дублирующие поля
_, err = tx.Exec("UPDATE orders_denorm SET user_name = $1 WHERE user_id = $2",
user.Name, user.ID)
if err != nil {
return err
}

// Публикуем событие для других сервисов
eventBus.Publish("user.updated", user)

return tx.Commit()
}

Материализованные представления (Materialized Views):

Компромисс между нормализацией и денормализацией — храним результат сложного запроса:

-- Создаём материализованное представление
CREATE MATERIALIZED VIEW order_summary AS
SELECT
o.order_id,
u.name AS user_name,
u.email AS user_email,
COUNT(oi.product_id) AS item_count,
SUM(oi.quantity * oi.price) AS total_amount,
MAX(o.created_at) AS order_date
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, u.name, u.email;

-- Создаём индекс для быстрого поиска
CREATE INDEX idx_order_summary_user ON order_summary(user_name);

-- Обновляем по расписанию или вручную
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;

Сравнение подходов:

АспектНормализацияДенормализация
ЦелостностьВысокаяТребует дополнительных механизмов
Скорость записиБыстраяМедленнее (обновление дубликатов)
Скорость чтенияМедленнее (JOIN)Быстрая (один SELECT)
Размер БДМеньшеБольше
Сложность схемыВысокаяНизкая
СогласованностьACID из коробкиНужна дополнительная логика

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

  1. Начинайте с нормализации — 3НФ или BCNF как базовый дизайн.
  2. Профилируйте — измеряйте производительность запросов до и после денормализации.
  3. Денормализуйте точечно — только там, где есть измеримая проблема.
  4. Документируйте — какие поля дублируются, как и когда обновляются.
  5. Используйте материализованные представления — как компромисс для аналитических запросов.
  6. Рассмотрите кэширование на уровне приложения — Redis, Memcached как альтернатива денормализации БД.

Резюме:

  • Нормализация обеспечивает целостность данных и устраняет аномалии, но требует JOIN при чтении.
  • Денормализация ускоряет чтение за счёт дублирования данных, но усложняет поддержание согласованности.
  • На практике используют комбинированный подход: нормализованная схема для записи + денормализованные пре#### Вопрос 14. Что такое CTE (Common Table Expression) и что такое рекурсивный CTE?

Таймкод: 00:33:35

Ответ собеседника: Правильный. CTE (Common Tab#### Вопрос 14. Что такое CTE (Common Table Expression) и что такое рекурсивный CTE?

Таймкод: 00:33:35

Ответ собеседника: Правильный. CTE (Common Table Expression) — это подзапрос (WITH), который позволяет получить промежуточный результат для упрощения основного запроса. Рекурсивный CTE — это CTE, который ссылается на себя для построения иерархических данных, например, дерева подчинённых сотрудников (начальник → подчинённые → подчинённые подчинённых).

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

CTE (Common Table Expression) — это именованный временный результирующий набор, существующий только в рамках одного запроса. Объявляется с помощью ключевого слова WITH.

Базовый синтаксис:

WITH cte_name AS (
SELECT ...
)
SELECT * FROM cte_name;

Зачем нужен CTE:

  1. Улучшение читаемости — разбивает сложный запрос на логические блоки.
  2. Повторное использование — можно ссылаться на CTE несколько раз в одном запросе.
  3. Рекурсия — возможность строить иерархические запросы.
  4. Альтернатива подзапросам — чище, чем вложенные SELECT.

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

Простой CTE — замена подзапроса:

-- Без CTE: вложенный подзапрос
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

-- С CTE: читаемее
WITH avg_salary AS (
SELECT AVG(salary) AS avg_val FROM employees
)
SELECT e.name, e.salary
FROM employees e, avg_salary
WHERE e.salary > avg_salary.avg_val;

Несколько CTE в одном запросе:

WITH
high_earners AS (
SELECT department_id, COUNT(*) AS cnt
FROM employees
WHERE salary > 100000
GROUP BY department_id
),
dept_budget AS (
SELECT department_id, SUM(salary) AS total
FROM employees
GROUP BY department_id
)
SELECT
d.name AS department,
h.cnt AS high_earner_count,
b.total AS total_budget
FROM departments d
LEFT JOIN high_earners h ON h.department_id = d.id
LEFT JOIN dept_budget b ON b.department_id = d.id
ORDER BY b.total DESC;

CTE для дедупликации:

WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC
) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);

Рекурсивный CTE

Рекурсивный CTE состоит из двух частей: anchor member (начальное значение) и recursive member (рекурсивная часть, ссылающаяся на себя).

WITH RECURSIVE cte_name AS (
-- Anchor member: начальное значение
SELECT ...
UNION ALL
-- Recursive member: ссылка на себя
SELECT ... FROM cte_name WHERE ...
)
SELECT * FROM cte_name;

Пример 1: Иерархия сотрудников

WITH RECURSIVE subordinates AS (
-- Anchor: начинаем с конкретного начальника
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE id = 1 -- CEO

UNION ALL

-- Recursive: находим подчинённых каждого уровня
SELECT e.id, e.name, e.manager_id, s.level + 1
FROM employees e
INNER JOIN subordinates s ON e.manager_id = s.id
)
SELECT
id,
REPEAT(' ', level - 1) || name AS org_chart,
level
FROM subordinates
ORDER BY level, name;

Результат:

id | org_chart | level
---|------------------|-------
1 | CEO | 1
2 | CTO | 2
3 | CFO | 2
4 | Dev Lead | 3
5 | Backend Dev | 3
6 | Frontend Dev | 3
7 | Accountant | 3

Пример 2: Генерация последовательности чисел

WITH RECURSIVE numbers AS (
SELECT 1 AS n -- Anchor
UNION ALL
SELECT n + 1 -- Recursive
FROM numbers
WHERE n < 100
)
SELECT n FROM numbers;

Пример 3: Обход дерева категорий

WITH RECURSIVE category_tree AS (
-- Anchor: корневые категории
SELECT id, name, parent_id, name AS path
FROM categories
WHERE parent_id IS NULL

UNION ALL

-- Recursive: дочерние категории
SELECT c.id, c.name, c.parent_id,
ct.path || ' > ' || c.name
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, name, path
FROM category_tree
ORDER BY path;

Результат:

id | name | path
---|---------------|---------------------------
1 | Electronics | Electronics
2 | Computers | Electronics > Computers
3 | Laptops | Electronics > Computers > Laptops
4 | Desktops | Electronics > Computers > Desktops
5 | Clothing | Clothing
6 | Men | Clothing > Men

Пример 4: Поиск всех путей (граф)

-- Таблица рёбер графа
CREATE TABLE routes (
from_city VARCHAR(50),
to_city VARCHAR(50),
distance INT
);

-- Найти все пути из Москвы в Владивосток
WITH RECURSIVE paths AS (
-- Anchor: начинаем из Москвы
SELECT
from_city,
to_city,
distance AS total_distance,
from_city || ' -> ' || to_city AS path,
1 AS hops
FROM routes
WHERE from_city = 'Moscow'

UNION ALL

-- Recursive: продолжаем путь
SELECT
p.from_city,
r.to_city,
p.total_distance + r.distance,
p.path || ' -> ' || r.to_city,
p.hops + 1
FROM paths p
INNER JOIN routes r ON p.to_city = r.from_city
WHERE p.hops < 5 -- ограничение глубины для предотвращения циклов
AND p.path NOT LIKE '%' || r.to_city || '%' -- избегаем циклов
)
SELECT path, total_distance, hops
FROM paths
WHERE to_city = 'Vladivostok'
ORDER BY total_distance;

Важные нюансы рекурсивных CTE:

1. Ограничение глубины — защита от бесконечной рекурсии:

WITH RECURSIVE cte AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM cte WHERE n < 1000 -- условие остановки!
)
SELECT * FROM cte;

-- PostgreSQL: можно установить лимит
SET max_recursion_depth = 1000;

2. Обнаружение циклов:

WITH RECURSIVE paths AS (
SELECT
from_city, to_city,
ARRAY[from_city] AS visited -- массив посещённых узлов
FROM routes
WHERE from_city = 'A'

UNION ALL

SELECT
p.from_city, r.to_city,
p.visited || r.to_city
FROM paths p
INNER JOIN routes r ON p.to_city = r.from_city
WHERE NOT r.to_city = ANY(p.visited) -- не посещаем уже посещённые
)
SELECT * FROM paths;

3. Разница между UNION и UNION ALL:

-- UNION ALL — быстрее, но может давать дубликаты
WITH RECURSIVE cte AS (
SELECT 1 AS n
UNION ALL
SELECT n FROM cte WHERE n < 5
)
SELECT * FROM cte; -- 1, 1, 1, 1, 1, 1... (дубликаты!)

-- UNION — удаляет дубликаты, но медленнее
WITH RECURSIVE cte AS (
SELECT 1 AS n
UNION
SELECT n FROM cte WHERE n < 5
)
SELECT * FROM cte; -- 1 (без дубликатов)

Для рекурсивных CTE почти всегда используется UNION ALL — он значительно быстрее, а дубликаты обычно не возникают при правильном проектировании.

CTE vs Подзапрос vs Временная таблица:

ХарактеристикаCTEПодзапросВременная таблица
ЧитаемостьВысокаяНизкаяВысокая
РекурсияДаНетНет (нужен цикл)
Область видимостиОдин запросОдин запросСессия/транзакция
ИндексыНетНетМожно создать
МатериализацияОпциональноНетДа (данные на диске)
Повторное использованиеВ рамках запросаНетВ рамках сессии

Материализация CTE (PostgreSQL 12+):

-- По умолчанию CTE встраивается (inline) в основной запрос
WITH cte AS (
SELECT * FROM large_table WHERE condition = true
)
SELECT * FROM cte WHERE id > 100;

-- MATERIALIZED: результат CTE вычисляется один раз и кэшируется
WITH cte AS MATERIALIZED (
SELECT * FROM large_table WHERE complex_condition = true
)
SELECT * FROM cte WHERE id > 100
UNION ALL
SELECT * FROM cte WHERE id <= 100; -- CTE вычисляется только раз!

-- NOT MATERIALIZED: принудительное встраивание (поведение по умолчанию)
WITH cte AS NOT MATERIALIZED (
SELECT * FROM small_table
)
SELECT * FROM cte;

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

  • CTE исполь#### Вопрос 15. Что такое миграции базы данных? Как они хранятся, кто их создаёт и когда запускаются?

Таймкод: 00:35:01

Ответ собеседника: Правильный. Миграции — это файлы, описывающие изменения схемы базы данных (создание/изменение таблиц, индексов и т.д.). Они хранятся в системе контроля версий (Git). Разработчик сам создаёт файл миграции. Локально миграции запускаются вручную при необходимости. В production обычно запускаются автоматически в процессе CI/CD при деплое.

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

Миграции базы данных — это версионируемые скрипты, описывающие изменения схемы БД (создание/изменение/удаление таблиц, индексов, ограничений, функций). Они позволяют переводить БД из одного состояния в другое детер#### Вопрос 15. Что такое миграции базы данных? Как они хранятся, кто их создаёт и когда запускаются?

Таймкод: 00:35:01

Ответ собеседника: Правильный. Миграции — это файлы, описывающие изменения схемы базы данных (создание/изменение таблиц, индексов и т.д.). Они хранятся в системе контроля версий (Git). Разработчик сам создаёт файл миграции. Локально миграции запускаются вручную при необходимости. В production обычно запускаются автоматически в процессе CI/CD при деплое.

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

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

Зачем нужны миграции:

  • Воспроизводимость — любой разработчик может создать актуальную схему БД с нуля.
  • Версионирование — история изменений схемы хранится в Git вместе с кодом.
  • Командная работа — разные разработчики могут независимо создавать миграции, а система применяет их в правильном порядке.
  • Откат — возможность откатить изменения при необходимости.

Как хранятся миграции:

Типичная структура проекта:

project/
├── migrations/
│ ├── 0001_create_users_table.up.sql
│ ├── 0001_create_users_table.down.sql
│ ├── 0002_add_email_index.up.sql
│ ├── 0002_add_email_index.down.sql
│ ├── 0003_create_orders_table.up.sql
│ ├── 0003_create_orders_table.down.sql
│ └── ...
├── cmd/
├── internal/
└── go.mod

Каждая миграция состоит из двух файлов:

  • .up.sql — применяет изменение
  • .down.sql — откатывает изменение

Пример содержимого миграций:

-- migrations/0001_create_users_table.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users (email);
-- migrations/0001_create_users_table.down.sql
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;
-- migrations/0002_add_user_status.up.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active';
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP;

CREATE INDEX idx_users_status ON users (status);
-- migrations/0002_add_user_status.down.sql
DROP INDEX IF EXISTS idx_users_status;
ALTER TABLE users DROP COLUMN IF EXISTS updated_at;
ALTER TABLE users DROP COLUMN IF EXISTS status;

Таблица отслеживания миграций:

Система миграций создаёт служебную таблицу, которая хранит информацию о применённых миграциях:

-- Таблица создаётся автоматически при первом запуске миграций
CREATE TABLE schema_migrations (
version BIGINT NOT NULL PRIMARY KEY, -- номер миграции
dirty BOOLEAN NOT NULL DEFAULT false -- флаг незавершённой миграции
);

Пример содержимого после применения миграций:

version | dirty
---------+-------
1 | f
2 | f
3 | f

Популярные инструменты миграций:

1. golang-migrate (самый популярный для Go):

# Установка
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# Создание новой миграции
migrate create -ext sql -dir migrations -seq create_orders_table

# Применение миграций
migrate -path migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" up

# Откат одной миграции
migrate -path migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" down 1

# Откат всех миграций
migrate -path migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" down -all

# Принудительная установка версии (при "dirty" состоянии)
migrate -path migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" force 3

Интеграция с Go-приложением:

package main

import (
"database/sql"
"log"

_ "github.com/lib/pq"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)

func runMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}

m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres", driver)
if err != nil {
return err
}

if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}

return nil
}

func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/dbname?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()

if err := runMigrations(db); err != nil {
log.Fatal("миграции не применены:", err)
}

log.Println("миграции применены успешно")
// ... запуск приложения
}

2. Goose:

# Создание миграции
goose -dir migrations create add_user_status sql

# Применение
goose -dir migrations postgres "user=postgres dbname=mydb sslmode=disable" up

3. Pressly Goose (Go-обёртка):

import (
"database/sql"
"github.com/pressly/goose/v3"
)

func runMigrations(db *sql.DB) error {
goose.SetDialect("postgres")

// Применить все миграции
return goose.Up(db, "migrations")
}

4. SQLC + миграции (генерация Go-кода из SQL):

# sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "./queries/"
schema: "./migrations/"
gen:
go:
package: "db"
out: "internal/db"

Кто создаёт миграции:

Миграции создаёт разработчик — тот, кто меняет схему БД. Процесс:

  1. Разработчик понимает, что нужна новая таблица/колонка/индекс.
  2. Создаёт пару файлов .up.sql и .down.sql.
  3. Тестирует локально: migrate up, проверяет результат, migrate down, проверяет откат.
  4. Коммитит в Git вместе с кодом, который использует новую схему.
  5. Создаёт Pull Request.

Когда запускаются миграции:

Локальная разработка:

# При старте проекта — применить все миграции
migrate -path migrations -database "postgres://localhost/mydb" up

# После переключения на ветку с новыми миграциями
git pull
migrate -path migrations -database "postgres://localhost/mydb" up

# Откат для тестирования
migrate -path migrations -database "postgres://localhost/mydb" down 1

CI/CD pipeline:

# .github/workflows/deploy.yml (упрощённо)
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Run database migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
migrate -path migrations -database "$DATABASE_URL" up

deploy:
needs: migrate
runs-on: ubuntu-latest
steps:
- name: Deploy application
run: |
# деплой приложения только после успешных миграций
kubectl apply -f deployment.yml

Важно: миграции запускаются ДО деплоя нового кода, чтобы схема БД была готова к работе нового кода.

При старте приложения:

func main() {
db := connectToDB()

// Вариант 1: миграции при старте
runMigrations(db)

// Вариант 2: отдельный контейнер в Docker Compose
// (миграции в init-контейнере, приложение ждёт готовности БД)

startHTTPServer(db)
}

Лучшие практики:

1. Каждая миграция — атомарная:

-- ПЛОХО: несколько несвязанных изменений в одной миграции
CREATE TABLE orders (...);
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
CREATE INDEX idx_products_name ON products (name);

-- ХОРОШО: одна миграция = одно логическое изменение
-- 0003_create_orders_table.up.sql
CREATE TABLE orders (...);

-- 0004_add_user_phone.up.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- 0005_add_products_name_index.up.sql
CREATE INDEX idx_products_name ON products (name);

2. Миграции должны быть идемпотентными (где возможно):

-- Используйте IF NOT EXISTS / IF EXISTS
CREATE TABLE IF NOT EXISTS users (...);
DROP TABLE IF EXISTS old_table;
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);

-- ALTER с проверкой (PostgreSQL)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'status'
) THEN
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
END IF;
END $$;

3. Безопасные миграции для production:

-- ПЛОХО: блокирует таблицу надолго
ALTER TABLE users ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active';
-- Для большой таблицы это может заблокировать все операции на минуты

-- ХОРОШО: добавляем колонку без DEFAULT (быстро), потом заполняем
-- Шаг 1: добавляем nullable колонку (мгновенно)
ALTER TABLE users ADD COLUMN status VARCHAR(20);

-- Шаг 2: заполняем значения батчами (не блокирует всю таблицу)
UP#### **Вопрос 16**. Что такое DDD (Domain-Driven Design)? Каковы основные положения и зачем оно нужно?

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

Ответ собеседника: Неполный. DDD (Domain-Driven Design) — предметно-ориентированный дизайн. В центре находится доменная модель. Нейминг берётся из бизнес-домены: бизнес описывает свои модели, а разработчик старается сделать так, чтобы программные сущности не сильно отличались от реальных бизнес-понятий. Например, в маркетплейсе сущность — карточка товара, и все операции описываются в терминах этой сущности. Однако ответ неполный — не упомянуты ключевые концепции DDD: ограниченные контексты (Bounded Context), Агрегаты, События домена (Domain Events), Репозитории, Сервисы домена.

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

DDD (Domain-Driven Design) — подход к проектированию программного обеспечения, при котором структура и язык кода максимально близки к бизнес-предметной области. Основная идея: код должен говорить на языке бизнеса, а не на языке технологий.

Зачем нужно DDD:

  • Снижение когнитивной нагрузки — разработчик и бизнес говорят на одном языке (Ubiquitous Language).
  • Управление сложностью — большая система разбивается на понятные ограниченные контексты.
  • Гибкость к изменениям — изменения в бизнес-логике локализо#### Вопрос 16. Что такое DDD (Domain-Driven Design)? Каковы основные положения и зачем оно нужно?

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

Ответ собеседника: Неполный. DDD (Domain-Driven Design) — предметно-ориентированный дизайн. В центре находится доменная модель. Нейминг берётся из бизнес-домены: бизнес описывает свои модели, а разработчик старается сделать так, чтобы программные сущности не сильно отличались от реальных бизнес-понятий. Например, в маркетплейсе сущность — карточка товара, и все операции описываются в терминах этой сущности. Однако ответ неполный — не упомянуты ключевые концепции DDD: ограниченные контексты (Bounded Context), Агрегаты, События домена (Domain Events), Репозитории, Сервисы домена.

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

DDD (Domain-Driven Design) — подход к проектированию программного обеспечения, при котором структура и язык кода максимально близки к предметной области бизнеса. Основная идея: код должен отражать бизнес-процессы и правила, а не технические детали реализации.

Зачем нужно DDD:

  • Сложные бизнес-правила становятся явными в коде, а не размазаны по слоям.
  • Команда разработки и бизнес говорят на одном языке (Ubiquitous Language).
  • Система легче эволюционирует вместе с бизнесом.
  • Границы ответственности чётко определены.

Ключевые концепции DDD:

1. Ubiquitous Language (Единый язык)

Разработчики и бизнес используют одни и те же термины. Если бизнес говорит «заказ перешёл в статус "отгружен"», в коде это именно Order.Shipped, а не Order.Status = 3.

// ПЛОХО: технические термины, непонятные бизнесу
type Order struct {
ID int
Status int // 1 = new, 2 = paid, 3 = shipped — что это?
}

// ХОРОШО: язык бизнеса
type Order struct {
ID OrderID
Status OrderStatus // OrderStatus — enum с понятными значениями
}

type OrderStatus int

const (
OrderStatusNew OrderStatus = iota // "Новый"
OrderStatusPaid // "Оплачен"
OrderStatusShipped // "Отгружен"
OrderStatusDelivered // "Доставлен"
OrderStatusCancelled // "Отменён"
)

2. Bounded Context (Ограниченный контекст)

Разделение системы на изолированные контексты, каждый со своей моделью и языком. Один и тот же бизнес-объект может выглядеть по-разному в разных контекстах.

┌─────────────────────────────────────────────────┐
│ E-commerce System │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Catalog │ │ Ordering │ │
│ │ Context │ │ Context │ │
│ │ │ │ │ │
│ │ Product │ │ Order │ │
│ │ - name │ │ - items │ │
│ │ - price │ │ - status │ │
│ │ - category │ │ - total │ │
│ │ - image │ │ - shipping │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Billing │ │ Shipping │ │
│ │ Context │ │ Context │ │
│ │ │ │ │ │
│ │ Invoice │ │ Shipment │ │
│ │ - amount │ │ - tracking │ │
│ │ - due_date │ │ - carrier │ │
│ │ - status │ │ - address │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────┘

В контексте Catalog — Product с атрибутами для витрины (описание, фото, категория). В контексте Ordering — OrderItem с ценой на момент заказа и количестве. Это разные модели одного и того же «товара».

3. Entity (Сущность)

Объект, определяемый уникальным идентификатором, а не атрибутами. Два пользователя с одинаковым именем и email — разные сущности.

type Order struct {
ID OrderID // идентификатор — определяет сущность
CustomerID CustomerID
Items []OrderItem
Status OrderStatus
Total Money
CreatedAt time.Time
}

func (o *Order) CanBeCancelled() bool {
return o.Status == OrderStatusNew || o.Status == OrderStatusPaid
}

func (o *Order) Cancel() error {
if !o.CanBeCancelled() {
return ErrCannotCancelOrder
}
o.Status = OrderStatusCancelled
o.AddDomainEvent(&OrderCancelledEvent{OrderID: o.ID})
return nil
}

4. Value Object (Объект-значение)

Объект, определяемый своими атрибутами, без уникального идентификатора. Неизменяем. Два адреса с одинаковыми полями — один и тот же адрес.

type Address struct {
Country string
City string
Street string
Building string
Apartment string
PostalCode string
}

// Value Object — неизменяемый, сравнивается по значению
func (a Address) Equals(other Address) bool {
return a.Country == other.Country &&
a.City == other.City &&
a.Street == other.Street &&
a.Building == other.Building
}

// Изменение = создание нового объекта
func (a Address) WithApartment(apt string) Address {
newAddr := a
newAddr.Apartment = apt
return newAddr
}

type Money struct {
Amount decimal.Decimal
Currency string
}

func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, ErrCurrencyMismatch
}
return Money{
Amount: m.Amount.Add(other.Amount),
Currency: m.Currency,
}, nil
}

5. Aggregate (Агрегат)

Кластер связанных объектов (Entity + Value Object), которые рассматриваются как единое целое для изменений данных. У агрегата есть корень (Aggregate Root) — единственная точка входа для модификации.

// Order — Aggregate Root
type Order struct {
ID OrderID
CustomerID CustomerID
items []OrderItem // приватное поле — нельзя менять напрямую
Status OrderStatus
Total Money
CreatedAt time.Time
events []DomainEvent
}

// Только через методы корня можно менять элементы
func (o *Order) AddItem(productID ProductID, quantity int, price Money) error {
if o.Status != OrderStatusNew {
return ErrOrderAlreadyPlaced
}

item := OrderItem{
ProductID: productID,
Quantity: quantity,
UnitPrice: price,
}
o.items = append(o.items, item)
o.recalculateTotal()

o.AddDomainEvent(&OrderItemAddedEvent{
OrderID: o.ID,
ProductID: productID,
Quantity: quantity,
})
return nil
}

func (o *Order) recalculateTotal() {
total := Money{Currency: "USD"}
for _, item := range o.items {
itemTotal := item.UnitPrice.Multiply(decimal.NewFromInt(int64(item.Quantity)))
total = total.Add(itemTotal)
}
o.Total = total
}

// Внешний код не может напрямую менять items — только через методы Order
func (o *Order) GetItems() []OrderItem {
// Возвращаем копию, чтобы защитить инвариант
result := make([]OrderItem, len(o.items))
copy(result, o.items)
return result
}

Правила агрегата:

  • Внешний код ссылается только на корень агрегата.
  • Внутренние объекты не ссылаются на внешние напрямую.
  • Изменения внутри агрегата — через корень.
  • Агрегат загружается и сохраняется целиком.

6. Domain Event (Событие домены)

Фиксация того, что что-то значимое произошло в домене. Именуется в прошедшем времени.

type DomainEvent interface {
OccurredAt() time.Time
}

type OrderPlacedEvent struct {
OrderID OrderID
CustomerID CustomerID
Total Money
occurredAt time.Time
}

func (e OrderPlacedEvent) OccurredAt() time.Time {
return e.occurredAt
}

// В агрегате
func (o *Order) Place() error {
if len(o.items) == 0 {
return ErrEmptyOrder
}
if o.Status != OrderStatusNew {
return ErrOrderAlreadyPlaced
}

o.Status = OrderStatusPaid
o.AddDomainEvent(&OrderPlacedEvent{
OrderID: o.ID,
CustomerID: o.CustomerID,
Total: o.Total,
occurredAt: time.Now(),
})
return nil
}

func (o *Order) AddDomainEvent(event DomainEvent) {
o.events = append(o.events, event)
}

func (o *Order) PullDomainEvents() []DomainEvent {
events := o.events
o.events = nil
return events
}

7. Repository (Репозитрий)

Абстракция для сохранения и загрузки агрегатов. Скрывает детали хранения (SQL, NoSQL, файлы).

// Интерфейс репозитория — определён в домене
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
FindByCustomerID(ctx context.Context, customerID CustomerID, limit, offset int) ([]*Order, error)
}

// Реализация — в инфраструктуре
type PostgresOrderRepository struct {
db *sql.DB
}

func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

// Сохраняем заказ
_, err = tx.ExecContext(ctx, `
INSERT INTO orders (id, customer_id, status, total_amount, total_currency)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET status = $3, total_amount = $4, total_currency = $5
`, order.ID, order.CustomerID, order.Status, order.Total.Amount, order.Total.Currency)
if err != nil {
return err
}

// Сохраняем позиции заказа
for _, item := range order.GetItems() {
_, err = tx.ExecContext(ctx, `
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)
`, order.ID, item.ProductID, item.Quantity, item.UnitPrice.Amount)
if err != nil {
return err
}
}

return tx.Commit()
}

func (r *PostgresOrderRepository) FindByID(ctx context.Context, id OrderID) (*Order, error) {
var order Order
var totalAmount decimal.Decimal
var totalCurrency string

err := r.db.QueryRowContext(ctx, `
SELECT id, customer_id, status, total_amount, total_currency
FROM orders WHERE id = $1
`, id).Scan(&order.ID, &order.CustomerID, &order.Status, &totalAmount, &totalCurrency)
if err != nil {
return nil, err
}

order.Total = Money{Amount: totalAmount, Currency: totalCurrency}

// Загружаем позиции
rows, err := r.db.QueryContext(ctx, `
SELECT product_id, quantity, unit_price
FROM order_items WHERE order_id = $1
`, id)
// ... заполняем items

return &order, nil
}

8. Domain Service (Сервис домены)

Бизнес-логика, которая не принадлежит конкретной сущности.

// Расчёт скидки — не принадлежит ни Order, ни Customer
type PricingService struct {
discountRepo DiscountRepository
}

func (s *PricingService) CalculateOrderTotal(
ctx context.Context,
order *Order,
customer *Customer,
) (Money, error) {
baseTotal := order.Total

// Применяем скидку по программе лояльности
discount, err := s.discountRepo.FindForCustomer(ctx, customer.ID)
if err != nil {
return Money{}, err
}

if discount != nil && discount.IsActive() {
discountAmount := baseTotal.Amount.Mul(discount.Percentage)
baseTotal = Money{
Amount: baseTotal.Amount.Sub(discountAmount),
Currency: baseTotal.Currency,
}
}

return baseTotal, nil
}

Типичная структура проекта с DDD:

project/
├── internal/
│ ├── domain/ # Ядро домены
│ │ ├── order/
│ │ │ ├── order.go # Entity (Aggregate Root)
│ │ │ ├── order_item.go # Entity
│ │ │ ├── order_status.go # Value Object
│ │ │ ├── events.go # Domain Events
│ │ │ ├── repository.go # Интерфейс репозитория
│ │ │ └── errors.go # Доменные ошибки
│ │ ├── customer/
│ │ │ ├── customer.go
│ │ │ └── repository.go
│ │ └── pricing/
│ │ ├── pricing_service.go # Domain Service
│ │ └── discount.go
│ ├── application/ # Слой приложения
│ │ ├── order/
│ │ │ ├── place_order.go # Use Case
│ │ │ ├── cancel_order.go
│ │ │ └── handlers.go # HTTP handlers
│ │ └── events/
│ │ └── handlers.go # Обработчики доменных событий
│ ├── infrastructure/ # Инфраструктура
│ │ ├── persistence/
│ │ │ ├── postgres/
│ │ │ │ └── order_repository.go # Реализация репозитория
│ │ │ └── migrations/
│ │ ├── messaging/
│ │ │ └── kafka/
│ │ └── http/
│ │ └── server.go
│ └── cmd/
│ └── main.go
└── go.mod

Пример Use Case (прикладной слой):

type PlaceOrderHandler struct {
orderRepo OrderRepository
customerRepo CustomerRepository
pricingSvc *PricingService
eventBus EventBus
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
// Загружаем агрегаты
customer, err := h.customerRepo.FindByID(ctx, cmd.CustomerID)
if err != nil {
return err
}

// Создаём заказ (доменная логика)
order := NewOrder(customer.ID)
for _, item := range cmd.Items {
product := h.getProduct(item.ProductID)
order.AddItem(item.ProductID, item.Quantity, product.Price)
}

// Применяем ценообразование (domain service)
finalTotal, err := h.pricingSvc.CalculateOrderTotal(ctx, order, customer)
if err != nil {
return err
}
order.SetTotal(finalTotal)

// Размещаем заказ (доменная логика)
if err := order.Place(); err != nil {
return err
}

// Сохраняем агрегат
if err := h.orderRepo.Save(ctx, order); err != nil {
return err
}

// Публикуем доменные события
for _, event := range order.PullDomainEvents() {
h.eventBus.Publish(ctx, event)
}

return nil
}

Когда применять DDD:

  • Сложная бизнес-логика — много правил, инвариантов, состояний.
  • Большая команда — нужно чёткое разделение ответственности.
  • Долгоживущий проект — система будет эволюционировать годами.
  • Несколько поддоменов — разные части системы имеют разные модели.

Когда НЕ применять DDD:

  • Простые CRUD-приложения — оверхед не окупится.
  • Прототипы и MVP — скорость важнее архитектуры.
  • Маленькая команда, короткий проект — стоимость DDD превысит выгоду.

Резюме:

  • DDD ставит бизнес-логику в центр архитектуры.
  • Ключевые концепции: Ubiquitous Language, Bounded Context, Entity, Value Object, Aggregate, Domain Event, Repository, Domain Service.
  • Код отражает бизнес-процессы, а не технические детали.
  • DDD — не серебряная пуля, а инструмент для сложных предметных областей.
  • Стоимость DDD окупается на больших, сложных, долгоживущих проектах.

Вопрос 17. Какие виды тестов можно написать? Зачем они нужны? Как тестировать функцию записи в базу данных, если база недоступна?

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

Ответ собеседника: Правильный. Можно писать unit-тесты (на уровне функций/методов), интеграционные тесты (e2e), которые прогоняют сущность через весь цикл операций в приложении. Для тестирования функции записи в базу, когда база недоступна, используются моки (mock) и стабы (stub) — подмена реального соединения с базой на фейковое, чтобы изолировать тестируемый код от внешних зависимостей. Также можно использовать in-memory базу данных (например, SQLite) для тестов.

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

Пирамида тестирования:

/ \
/ E2E \ ← мало, медленные, хрупкие
/--------\
/ Интеграц.\ ← среднее количество
/--------------\
/ Unit-тесты \ ← много, быстрые, изолированные
/------------------\

1. Unit-тесты (Модульные тесты)

Тестируют отдельные функции/методы в изоляции от внешних зависимостей. Быстрые (миллисекунды), многочисленные, детерминированные.

package order

import (
"testing"
"github.com/stretchr/testify/assert"
)

func TestOrder_AddItem(t *testing.T) {
tests := []struct {
name string
order *Order
productID ProductID
quantity int
price Money
wantErr bool
wantTotal Money
}{
{
name: "add item to new order",
order: NewOrder(CustomerID(1)),
productID: ProductID(100),
quantity: 2,
price: Money{Amount: decimal.NewFromInt(50), Currency: "USD"},
wantErr: false,
wantTotal: Money{Amount: decimal.NewFromInt(100), Currency: "USD"},
},
{
name: "add item to placed order",
order: placedOrder(), // уже размещённый заказ
productID: ProductID(100),
quantity: 1,
price: Money{Amount: decimal.NewFromInt(50), Currency: "USD"},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.order.AddItem(tt.productID, tt.quantity, tt.price)

if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.True(t, tt.order.Total.Equals(tt.wantTotal))
}
})
}
}

func TestMoney_Add(t *testing.T) {
m1 := Money{Amount: decimal.NewFromInt(100), Currency: "USD"}
m2 := Money{Amount: decimal.NewFromInt(50), Currency: "USD"}

result, err := m1.Add(m2)
assert.NoError(t, err)
assert.True(t, result.Amount.Equal(decimal.NewFromInt(150)))

// Разные валюты — ошибка
m3 := Money{Amount: decimal.NewFromInt(50), Currency: "EUR"}
_, err = m1.Add(m3)
assert.Error(t, err)
}

2. Integration-тесты (Интеграционные тесты)

Тестируют взаимодействие нескольких компонентов — например, сервис + база данных, или обработчик HTTP + сервис + репозиторий.

package repository

import (
"context"
"database/sql"
"testing"

_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestOrderRepository_SaveAndFind(t *testing.T) {
ctx := context.Background()

// Запускаем PostgreSQL в Docker-контейнере
pgContainer, err := postgres.RunContainer(ctx,
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
postgres.WithInitScripts("../../../migrations/0001_init.up.sql"),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)

connStr, err := pgContainer.ConnectionString(ctx)
require.NoError(t, err)

db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()

repo := NewPostgresOrderRepository(db)

// Создаём заказ
order := domain.NewOrder(domain.CustomerID(1))
order.AddItem(domain.ProductID(100), 2,
domain.Money{Amount: decimal.NewFromInt(50), Currency: "USD"})

// Сохраняем
err = repo.Save(ctx, order)
require.NoError(t, err)

// Загружаем
found, err := repo.FindByID(ctx, order.ID)
require.NoError(t, err)

assert.Equal(t, order.ID, found.ID)
assert.Equal(t, order.Total.Amount, found.Total.Amount)
assert.Len(t, found.GetItems(), 1)
}

3. E2E-тесты (End-to-End)

Тестируют полный поток — от HTTP-запроса до базы данных и обратно. Медленные, хрупкие, но проверяют реальные сценарии.

func TestPlaceOrder_E2E(t *testing.T) {
// Запускаем всё приложение с тестовой БД
app := setupTestApp(t)
defer app.Shutdown()

// Отправляем HTTP-запрос
resp := app.Post("/api/orders", `{
"customer_id": 1,
"items": [
{"product_id": 100, "quantity": 2}
]
}`)
assert.Equal(t, 201, resp.StatusCode)

// Проверяем ответ
var result map[string]interface{}
json.Unmarshal(resp.Body, &result)
orderID := result["order_id"]

// Проверяем, что заказ в БД
order := app.GetOrderByID(orderID)
assert.Equal(t, "paid", order.Status)
}

Тестирование функции записи в БД без реальной базы:

Подход 1: Мокирование репозитория (Unit-тест)

// Интерфейс репозитория
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
}

// Мок-реализация
type MockOrderRepository struct {
orders map[OrderID]*Order
saveCalled bool
savedOrder *Order
saveErr error
}

func NewMockOrderRepository() *MockOrderRepository {
return &MockOrderRepository{
orders: make(map[OrderID]*Order),
}
}

func (m *MockOrderRepository) Save(ctx context.Context, order *Order) error {
m.saveCalled = true
m.savedOrder = order
if m.saveErr != nil {
return m.saveErr
}
m.orders[order.ID] = order
return nil
}

func (m *MockOrderRepository) FindByID(ctx context.Context, id OrderID) (*Order, error) {
order, ok := m.orders[id]
if !ok {
return nil, ErrOrderNotFound
}
return order, nil
}

// Тест с моком
func TestPlaceOrderHandler_Handle(t *testing.T) {
mockRepo := NewMockOrderRepository()
handler := NewPlaceOrderHandler(mockRepo)

cmd := PlaceOrderCommand{
CustomerID: 1,
Items: []OrderItemCommand{
{ProductID: 100, Quantity: 2},
},
}

err := handler.Handle(context.Background(), cmd)

assert.NoError(t, err)
assert.True(t, mockRepo.saveCalled, "Save должен быть вызван")
assert.NotNil(t, mockRepo.savedOrder)
assert.Equal(t, OrderStatusPaid, mockRepo.savedOrder.Status)
}

Подход 2: Мокирование через testify/mock

import "github.com/stretchr/testify/mock"

type MockOrderRepository struct {
mock.Mock
}

func (m *MockOrderRepository) Save(ctx context.Context, order *Order) error {
args := m.Called(ctx, order)
return args.Error(0)
}

func (m *MockOrderRepository) FindByID(ctx context.Context, id OrderID) (*Order, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*Order), args.Error(1)
}

func TestPlaceOrderHandler_WithTestifyMock(t *testing.T) {
mockRepo := new(MockOrderRepository)

expectedOrder := &Order{ID: OrderID(1), Status: OrderStatusPaid}
mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*order.Order")).
Return(nil)

handler := NewPlaceOrderHandler(mockRepo)
err := handler.Handle(context.Background(), PlaceOrderCommand{
CustomerID: 1,
})

assert.NoError(t, err)
mockRepo.AssertCalled(t, "Save", mock.Anything, mock.Anything)
mockRepo.AssertNumberOfCalls(t, "Save", 1)
}

Подход 3: In-memory база данных (SQLite)

import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)

func TestOrderRepository_WithSQLite(t *testing.T) {
// Создаём in-memory базу
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
defer db.Close()

// Применяем схему
_, err = db.Exec(`
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL,
total_amount REAL NOT NULL,
total_currency TEXT NOT NULL
);
CREATE TABLE order_items (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL
);
`)
require.NoError(t, err)

repo := NewSQLiteOrderRepository(db)

order := NewOrder(CustomerID(1))
order.AddItem(ProductID(100), 2, Money{Amount: decimal.NewFromInt(50), Currency: "USD"})

err = repo.Save(order)
require.NoError(t, err)

found, err := repo.FindByID(order.ID)
require.NoError(t, err)
assert.Equal(t, order.ID, found.ID)
}

Подход 4: Testcontainers (Docker-контейнеры для тестов)

func TestOrderRepository_WithRealPostgres(t *testing.T) {
ctx := context.Background()

pgContainer, err := postgres.RunContainer(ctx,
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
postgres.WithInitScripts("migrations/0001_init.up.sql"),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)

connStr, err := pgContainer.ConnectionString(ctx)
require.NoError(t, err)

db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()

repo := NewPostgresOrderRepository(db)

// Полноценный тест с реальным PostgreSQL
order := NewOrder(CustomerID(1))
order.AddItem(ProductID(100), 2, Money{Amount: decimal.NewFromInt(50), Currency: "USD"})
order.Place()

err = repo.Save(order)
require.NoError(t, err)

found, err := repo.FindByID(order.ID)
require.NoError(t, err)
assert.Equal(t, OrderStatusPaid, found.Status)
assert.True(t, found.Total.Amount.Equal(decimal.NewFromInt(100)))
}

Подход 5: sqlmock (мокирование SQL-запросов)

import "github.com/DATA-DOG/go-sqlmock"

func TestOrderRepository_WithSqlmock(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()

repo := NewPostgresOrderRepository(db)

order := NewOrder(CustomerID(1))
order.AddItem(ProductID(100), 2, Money{Amount: decimal.NewFromInt(50), Currency: "USD"})

// Ожидаем конкретные SQL-запросы
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO orders").
WithArgs(order.ID, order.CustomerID, order.Status, order.Total.Amount, order.Total.Currency).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO order_items").
WithArgs(order.ID, ProductID(100), 2, decimal.NewFromInt(50)).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err = repo.Save(order)
require.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
}

Сравнение подходов:

ПодходСкоростьИзоляцияРеалистичностьКогда использовать
Мок интерфейсаМгновенноПолнаяНизкаяUnit-тесты бизнес-логики
testify/mockМгновенноПолнаяНизкаяПроверка взаимодействий
SQLiteБыстроВысокаяСредняяИнтеграционные тесты без Docker
TestcontainersМедленноНизкаяВысокаяИнтеграционные тесты с реальной БД
sqlmockБыстроВысокаяСредняяПроверка SQL-запросов

Резюме:

  • Unit-тесты — быстрые, изолированные, тестируют бизнес-логику без внешних зависимостей.
  • Integration-тесты — проверяют взаимодействие компонентов (сервис + БД).
  • E2E-тесты — проверяют полный поток через всё приложение.
  • Для тестирования записи в БД без реальной базы используются: моки интерфейсов, in-memory БД (SQLite), sqlmock, Testcontainers.
  • Моки подходят для unit-тестов (проверка логики), Testcontainers — для интеграционных (проверка реальной работы с БД).
  • Хорошая архитектура (интерфейсы для репозиториев) делает код тестируемым без привязки к конкретной реализации БД.