Собеседование на Golang 260к
Сегодня мы разберём техническое собеседование на позицию Go-разработчика, в ходе которого кандидат решает алгоритмические задачи, отвечает на вопросы по языку Go и обсуждает архитектурные подходы. Интервьюер оценивает как практические навыки кодирования, так и глубину понимания конкурентности, структур данных и проектирования систем.
Вопрос 1. Написать функцию, которая принимает на вход слайс целых чисел и меняет порядок чисел на противоположный in place (без выделения дополнительного массива), а в main продемонстрировать работу на простом примере.
Таймкод: 00:00:35
Ответ собеседника: Правильный. Реализована функция reverse, которая проходит циклом до середины слайса и меняет элементы местами по индексам: элемент i с элементом n-1-i. Используется два указателя — левый и правый. В main продемонстрирована работа на слайсе [1,2,3,4,5]. Результат корректный — слайс перевёрнут.
Правильный ответ:
Задача на разворот слайса in place — классическая задача с использованием двух указателей. Ключевое требование — не выделять дополнительный массив, то есть все манипуляции происходят в пределах исходного слайса.
Алгоритм работы:
- Инициализируем два индекса:
left = 0(начало слайса) иright = len(s) - 1(конец слайса). - На каждой итерации меняем элементы
s[left]иs[right]местами. - Сдвигаем
leftвправо,rightвлево. - Повторяем, пока
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 в переменную имеет смысл:
- Читаемость — если длина используется многократно в теле функции, сохранение в переменную сокращает дублирование.
- Сложные выражения — если вычисление границы нетривиально, переменная с понятным именем улучшает понимание кода.
- Исторически — в некоторых языках (например, 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 анализирует, «убегает» ли переменная за пределы текущего стекового фрейма. Если да — переменная размещается в куче. Если нет — в стеке.
Признаки «убегания» переменной:
- Возврат указателя на локальную переменную:
func createUser() *User {
u := User{name: "Alice"} // u "убегает" в кучу
return &u
}
Если бы u осталась в стеке, после возврата из функции стековый фрейм был бы уничтожен, и указатели бы стали невалидными.
- Передача указателя в функцию, которая сохраняет его:
var global *int
func setGlobal() {
x := 42 // x "убегает" в кучу, т.к. указатель сохраняется в глобальной переменной
global = &x
}
- Размещение в структуре, которая сама убегает:
type Container struct {
data *int
}
func newContainer() *Container {
val := 100 // val "убегает" в кучу
return &Container{data: &val} // Container убегает через return, val — через вложенность
}
- Захват замыканием:
func counter() func() int {
n := 0 // n "убегает" в кучу, т.к. захвачена замыканием
return func() int {
n++
return n
}
}
- Передача в интерфейс (иногда):
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) для вставки, удаления и поиска.
nilmap можно читать (вернёт zero value), но запись вnilmap вызовет panic.
Когда использовать: для быстрого поиска по ключу, подсчёта частот, кэширования, хранения конфигураций, индексов.
Сравнительная таблица:
| Характеристика | Array | Slice | Map |
|---|---|---|---|
| Размер | Фиксированный | Динамический | Динамический |
| Тип хранения | Значение | Ссылка на массив | Хэш-таблица |
| Индексация | По индексу | По индексу | По ключу |
| Порядок | Сохраняется | Сохраняется | Не гарантирован |
| Сложность доступа | 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):
- read — атомарно читаемое хранилище (аналог
atomic.Value), содержащее указатель на неизменяемую map. Чтение из него не требует блокировок. - 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:
- Частые записи в одни и те же ключи — мьютекс будет конкурировать, и sync.Map проиграет по производительности.
- Небольшое количество ключей — накладные расходы sync.Map не окупятся.
- Нужна типобезопасность — sync.Map работает с
interface{}, что требует приведения типов. - Нужен детерминированный порядок итерации —
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.
Это сделано намеренно, чтобы:
-
Предотвратить зависимость от порядка — если бы порядок был стабильным, разработчики начали бы на него полагаться, и при смене реализации хэш-таблицы код сломался бы.
-
Усложнить атаки типа Hash DoS — злоумышленник мог бы подобрать ключи, вызывающие коллизии и деградирующие производительность до O(n). Рандомизация начального сида хэш-функции затрудняет такие атаки.
-
Позволить менять внутреннюю реализацию — разработчики 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])
}
Альтернативы для упорядоченных данных:
Если порядок критичен для вашей задачи, рассмотрите:
- Отдельный слайс ключей — поддерживает порядок вставки:
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])
}
}
-
Сторонние библиотеки — например,
github.com/elliotchance/orderedmapилиgithub.com/iancoleman/orderedmapпредоставляют map с сохранением порядка вставки. -
Слайс структур — если данные не требуют быстрого поиска по ключу, отсортированный слайс может быть проще:
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 типам — типам, для которых определены операторы == и !=.
Сравнимые типы (могут быть ключами):
- Базовые типы:
int,float64,string,bool,complex128 - Указатели — сравниваются по адресу в памяти
- Каналы — сравниваются по идентификатору канала
- Интерфейсы — сравниваются по динамическому типу и значению
- Массивы — если тип элемента сравним, то и массив сравним
- Структуры — если все поля структуры сравнимы
Несравнимые типы (НЕ могут быть ключами):
- Слайсы —
[]int,[]stringи т.д. - Map —
map[K]V - Функции —
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:
- Переменная в каждом case имеет соответствующий тип:
switch v := i.(type) {
case int:
// здесь v имеет тип int, а не interface{}
fmt.Println(v + 1) // можно делать арифметику
case string:
// здесь v имеет тип string
fmt.Println(len(v)) // можно вызывать len()
}
- Можно проверять несколько типов в одном case:
switch v := i.(type) {
case int, int32, int64:
fmt.Printf("целое число: %v\n", v)
case string, []byte:
fmt.Printf("строковые данные: %v\n", v)
}
- Паттерн «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 Assertion | Type Switch | |
|---|---|---|
| Синтаксис | v.(T) | switch v := x.(type) |
| Количество типов | Один конкретный | Несколько альтернатив |
| Обработка ошибок | panic или ok | default ветка |
| Когда использовать | Заранее знаете тип | Нужно обработать несколько типов |
Частые ошибки:
// Ошибка: 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)
}
}
}
Важные правила работы с горутинами:
-
Горутина завершается при выходе из функции — нет способа принудительно убить горутину извне (используйте каналы или контекст для кооперативной остановки).
-
Утечка горутин — если горутина заблокирована навсегда (например, читает из никогда не закрывающегося канала), она остаётся в памяти:
// УТЕЧКА! Горутина никогда не завершится
go func() {
ch := make(chan int)
<-ch // блокировка навсегда — никто не напишет в канал
}()
-
Порядок выполнения горутин не гарантирован — не полагайтесь на порядок запуска.
-
Замыкания и циклы — классическая ловушка:
// НЕПРАВИЛЬНО — все горутины увидят одно значение 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
готово
Почему это работает корректно:
-
Без дубликатов: каждое значение из канала читается ровно один раз. Если оба консьюмера читают из одного канала, Go гарантирует, что каждое значение достанется только одному из них.
-
Без зависаний: продюсер пишет в канал из основной горутины. Небуферизированный канал блокирует отправку, пока консьюмер не прочитает — но консьюмеры уже запущены и готовы читать. После закрытия канала
rangeв консьюмерах завершается. -
Корректное завершение:
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: Чеклист диагностики
- Метрики приложения: latency, error rate, throughput, goroutine count, GC pause.
- Профилирование: CPU, heap, block профили.
- База данных: медленные запросы, планы выполнения, блокировки, размер таблиц и индексов.
- Инфраструктура: CPU, RAM, диск I/O, сеть, лимиты контейнеров.
- Зависимости: время ответа внешних сервисов, Redis, других микросервисов.
- Логи: ошибки, таймауты, предупреждения.
Резюме:
- Проблема «ничего не менялось, но стало медленнее» почти всегда связана с ростом данных или ростом нагрузки.
- Начинайте с метрик и логов — они покажут, где именно происходит замедление.
- Частые причины: отсутствие/неэффективность индексов, 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
Денормализация
Денормализация — преднамеренное нарушение нормальных форм для повышения производительности чтения.
Когда применять:
- Read-heavy нагрузка — чтение значительно преобладает над записью.
- Сложные JOIN становятся узким местом — запрос с 5+ JOIN работает секундами.
- Агрегация и отчёты — аналитические запросы, где важна скорость.
- Кэширование вычисляемых значений — храним уже посчитанное.
Примеры денормализации:
-- Добавляем дублирующее поле для избежания 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 из коробки | Нужна дополнительная логика |
Практические рекомендации:
- Начинайте с нормализации — 3НФ или BCNF как базовый дизайн.
- Профилируйте — измеряйте производительность запросов до и после денормализации.
- Денормализуйте точечно — только там, где есть измеримая проблема.
- Документируйте — какие поля дублируются, как и когда обновляются.
- Используйте материализованные представления — как компромисс для аналитических запросов.
- Рассмотрите кэширование на уровне приложения — 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:
- Улучшение читаемости — разбивает сложный запрос на логические блоки.
- Повторное использование — можно ссылаться на CTE несколько раз в одном запросе.
- Рекурсия — возможность строить иерархические запросы.
- Альтернатива подзапросам — чище, чем вложенные 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"
Кто создаёт миграции:
Миграции создаёт разработчик — тот, кто меняет схему БД. Процесс:
- Разработчик понимает, что нужна новая таблица/колонка/индекс.
- Создаёт пару файлов
.up.sqlи.down.sql. - Тестирует локально:
migrate up, проверяет результат,migrate down, проверяет откат. - Коммитит в Git вместе с кодом, который использует новую схему.
- Создаёт 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 — для интеграционных (проверка реальной работы с БД).
- Хорошая архитектура (интерфейсы для репозиториев) делает код тестируемым без привязки к конкретной реализации БД.
