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

Собеседование на middle Go-разработчика | Эйч Навыки

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

Сегодня мы разберём реальное собеседование Go-разработчика, проведённое в рамках вебинара менторской программы «Навыки». Интервьюер Дима Болдин — опытный руководитель с бэкграундом в Mail.ru и Яндекс.Еде — оценивал кандидата Ильдуса, который около двух лет назад перешёл в IT из другой сферы и уже успел поработать в стартапе и канадской компании. В ходе часового интервью они прошли через классические темы технических собеседований: слайсы, интерфейсы, мапы, конкурентность, каналы, SQL-запросы и проектирование сервиса с лентой контента — а в конце Дима дал развёрнутый фидбек, отметив уровень кандидата как подходящий для позиции Middle Go-разработчика.

Вопрос 1. Рассмотри код с использованием слайсов в Go. Что происходит в каждой строке? Какой будет результат выполнения?

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

Ответ собеседника: Неполный. Описал аллокацию слайса длиной и ёмкостью 5, заполнение элементами, создание среза с длиной 2 и ёмкостью 3. При запуске кода ошибся в понимании индексации — думал, что третий элемент не включается в срез, но после запуска исправился и понял, что индексация идёт от 0, поэтому fruits[2] тоже попадает в срез.

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

Рассмотрим типичный пример работы со слайсами в Go:

package main

import "fmt"

func main() {
// Строка 1: Создание слайса с длиной 5 и ёмкостью 5
// make([]string, 5) выделяет массив из 5 элементов, все инициализируются нулевыми значениями (пустая строка для string)
fruits := make([]string, 5)

// Строка 2-6: Заполнение элементов слайса значениями
fruits[0] = "apple"
fruits[1] = "banana"
fruits[2] = "cherry"
fruits[3] = "date"
fruits[4] = "elderberry"

// Строка 7: Создание нового слайса путём среза
// fruits[1:4] создаёт слайс с длиной 3 (4-1) и ёмкостью 4 (5-1)
// Индексация: fruits[1] = "banana", fruits[2] = "cherry", fruits[3] = "date"
// Важно: срез fruits[low:high] включает элемент с индексом low, но НЕ включает элемент с индексом high
myFruits := fruits[1:4]

fmt.Println("fruits:", fruits)
fmt.Println("myFruits:", myFruits)
fmt.Println("len(myFruits):", len(myFruits))
fmt.Println("cap(myFruits):", cap(myFruits))
}

Результат выполнения:

fruits: [apple banana cherry date elderberry]
myFruits: [banana cherry date]
len(myFruits): 3
cap(myFruits): 4

Ключевые моменты о слайсах в Go:

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

Слайс в Go — это структура из трёх полей:

  • pointer — указатель на первый элемент базового массива
  • length — количество элементов в слайсе
  • capacity — количество элементов от первого элемента слайса до конца базового массива
type slice struct {
array unsafe.Pointer
len int
cap int
}

2. Правила среза fruits[low:high]

  • Длина нового слайса: high - low
  • Ёмкость нового слайса: cap(original) - low
  • Элемент с индексом low включается в срез
  • Элемент с индексом high НЕ включается в срез (полуоткрытый интервал [low, high))

3. Разделяемая память

Срез myFruits и оригинальный слайс fruits ссылаются на один и тот же базовый массив. Изменение элемента через один слайс видно через другой:

myFruits[0] = "blueberry"
fmt.Println(fruits[1]) // "blueberry"

4. Распространённые ошибки

  • Попытка доступа за пределы длины слайса вызывает panic: runtime error: index out of range
  • Создание среза с high > cap(original) вызывает panic: runtime error: slice bounds out of range
  • Забывание, что срез разделяет память с оригиналом, может привести к неожиданным мутациям

Вопрос 2. Что будет, если убрать верхнюю границу при создании среза (сделать срез без указания верхней границы)?

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

Ответ собеседника: Правильный. Срез возьмёт все элементы до последнего элемента исходного массива, то есть длина и ёмкость будут равны длине исходного слайса.

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

Когда при создании среза не указана верхняя граница, Go автоматически подставляет длину исходного слайса. Рассмотрим это на примере:

package main

import "fmt"

func main() {
fruits := []string{"apple", "banana", "cherry", "date", "elderberry"}

// Полная форма среза
slice1 := fruits[2:]

// Это эквивалентно:
slice2 := fruits[2:len(fruits)]
// Что равно:
// slice2 := fruits[2:5]

fmt.Println("slice1:", slice1) // [cherry date elderberry]
fmt.Println("len(slice1):", len(slice1)) // 3
fmt.Println("cap(slice1):", cap(slice1)) // 3

fmt.Println("slice2:", slice2) // [cherry date elderberry]
fmt.Println("len(slice2):", len(slice2)) // 3
fmt.Println("cap(slice2):", cap(slice2)) // 3
}

Формула для среза без верхней границы fruits[low:]:

  • Длина: len(original) - low
  • Ёмкость: cap(original) - low

Полная таблица вариантов срезов:

СинтаксисДлинаЁмкостьЭлементы
fruits[:]55apple, banana, cherry, date, elderberry
fruits[:3]35apple, banana, cherry
fruits[2:]33cherry, date, elderberry
fruits[1:4]34banana, cherry, date
fruits[0:]55apple, banana, cherry, date, elderberry

Особые случаи:

// Пустой срез (длина 0)
empty := fruits[3:3] // []

// Полная копия слайса
copy := fruits[:] // [apple banana cherry date elderberry]

// Обратный порядок невозможно сделать одним срезом
// Требуется явный цикл

Важно помнить:

Срез fruits[low:] и fruits[:high] создают новые слайсы, которые разделяют базовый массив с оригиналом. Изменения элементов через новый слайс будут видны в оригинале.

Вопрос 3. Что происходит при append в слайс, если ёмкость исчерпана? Каков алгоритм роста ёмкости слайса в Go?

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

Ответ собеседния: Неполный. Верно описал, что при нехватке ёмкости аллоцируется новый массив и элементы копируются. Назвал старые пороговые значения (1024 элемента, коэффициент роста ~1.25), но в актуальных версиях Go порог снижен до 256, а коэффициент роста около 1.25 для больших слайсов.

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

Механизм append при нехватке ёмкости

Когда append обнаруживает, что текущей ёмкости недостаточно для размещения новых элементов, происходит следующее:

  1. Выделяется новый базовый массив с увеличенной ёмкостью
  2. Все существующие элементы копируются из старого массива в новый
  3. Новые элементы добавляются в конец
  4. Возвращается новый слайс, указывающий на новый массив
  5. Старый массив остаётся в памяти до сборки мусора (если на него нет других ссылок)
package main

import "fmt"

func main() {
s := make([]int, 0, 2)
fmt.Printf("初始: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)

s = append(s, 1)
fmt.Printf("append 1: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)

s = append(s, 2)
fmt.Printf("append 2: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)

// Ёмкость исчерпана — происходит реаллокация
s = append(s, 3)
fmt.Printf("append 3: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)

s = append(s, 4)
fmt.Printf("append 4: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)
}

Алгоритм роста ёмкости в Go 1.18+

Алгоритм роста ёмкости был изменён в Go 1.18 (февраль 2022). Новый алгоритм учитывает размер элемента и имеет порог в 256 элементов:

// Упрощённая версия алгоритма из runtime/slice.go
func growcap(oldcap, cap int, elem uintptr) int {
newcap := oldcap

// Для маленьких слайсов — удвоение ёмкости
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if oldcap < threshold {
newcap = doublecap
} else {
// Для больших слайсов — рост с коэффициентом ~1.25
for 0 < newcap && newcap < cap {
newcap += (newcap + 3*threshold) / 4
}
if newcap <= 0 {
newcap = cap
}
}
}

// Округление с учётом размера элемента и выравнивания памяти
return roundupsize(uintptr(newcap) * elem)
}

Практические примеры роста ёмкости:

package main

import "fmt"

func main() {
s := make([]int, 0)

for i := 0; i < 20; i++ {
oldCap := cap(s)
s = append(s, i)
if cap(s) != oldCap {
fmt.Printf("cap: %d -> %d (growth factor: %.2f)\n",
oldCap, cap(s), float64(cap(s))/float64(oldCap))
}
}
}

Примерный вывод:

cap: 0 -> 1 (growth factor: inf)
cap: 1 -> 2 (growth factor: 2.00)
cap: 2 -> 4 (growth factor: 2.00)
cap: 4 -> 8 (growth factor: 2.00)
cap: 8 -> 16 (growth factor: 2.00)
cap: 16 -> 32 (growth factor: 2.00)
cap: 32 -> 64 (growth factor: 2.00)
cap: 64 -> 128 (growth factor: 2.00)
cap: 128 -> 256 (growth factor: 2.00)
cap: 256 -> 384 (growth factor: 1.50)
cap: 384 -> 512 (growth factor: 1.33)
cap: 512 -> 704 (growth factor: 1.37)

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

Диапазон ёмкостиКоэффициент роста
0 → 2562.0 (удвоение)
256 → 512~1.5
512 → 1024~1.37
> 1024~1.25

Важные следствия:

1. Реаллокация разрывает связь с оригиналом

original := make([]int, 2, 4)
original[0], original[1] = 1, 2

slice := original[:2]
slice = append(slice, 3) // Реаллокация не происходит (cap=4, len=2)

original[0] = 100
fmt.Println(slice[0]) // 100 — память разделяется

slice = append(slice, 4, 5) // Реаллокация! cap=4, добавляем 2 элемента
original[0] = 200
fmt.Println(slice[0]) // 100 — память больше не разделяется

2. Оптимизация через предварительное выделение

// Плохо: множественные реаллокации
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}

// Хорошо: одна аллокация
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}

// Ещё лучше: прямое заполнение по индексу
result := make([]int, 10000)
for i := 0; i < 10000; i++ {
result[i] = i
}

3. Опасность утечки памяти при срезах

// Плохо: маленький срез удерживает большой базовый массив
func getFirstThree(data []int) []int {
return data[:3] // cap остаётся большим
}

// Хорошо: явное копирование
func getFirstThree(data []int) []int {
result := make([]int, 3)
copy(result, data)
return result
}

Историческая справка:

В Go 1.17 и ранее порог был 1024 элемента, после которого коэффициент роста менялся с 2.0 на ~1.25. В Go 1.18+ порог снижен до 256 для более эффективного использования памяти.

Вопрос 4. Как передать слайс в функцию — по ссылке или по значению? В чём разница при передаче слайса в функцию и при его расширении внутри функции?

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

Ответ собеседника: Правильный. Если не нужно расширять слайс внутри функции и видеть изменения в вызывающей функции — можно передавать по значению. Если нужно расширять слайс и видеть изменения снаружи — нужно передавать по указателю, потому что после append с переполнением ёмкости создаётся новый базовый массив. Альтернатива — возвращать новый слайс из функции. При этом если ёмкости хватает, то даже при передаче по значению элементы слайса будут изменяться в вызывающей функции.

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

Фундаментальное понимание

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

// Внутреннее представление слайса
type sliceHeader struct {
Data uintptr // указатель на базовый массив
Len int // длина
Cap int // ёмкость
}

Три сценария передачи слайса в функцию:

1. Изменение существующих элементов (передача по значению — достаточно)

func modifyElements(s []int) {
for i := range s {
s[i] *= 2
}
}

func main() {
nums := []int{1, 2, 3, 4, 5}
modifyElements(nums)
fmt.Println(nums) // [2 4 6 8 10]
}

Копия слайса содержит тот же указатель на базовый массив, поэтому изменения элементов видны снаружи.

2. Append без реаллокации (передача по значению — видны изменения)

func appendWithoutRealloc(s []int) {
// cap(s) = 5, len(s) = 3, есть место для новых элементов
s = append(s, 100, 200)
fmt.Println("Inside:", s) // [1 2 3 100 200]
}

func main() {
nums := make([]int, 3, 5)
nums[0], nums[1], nums[2] = 1, 2, 3

appendWithoutRealloc(nums)
fmt.Println("Outside:", nums) // [1 2 3] — len не изменился
fmt.Println("Full array:", nums[:cap(nums)]) // [1 2 3 100 200]
}

Изменения элементов видны, но изменение длины — нет, потому что len и cap скопированы.

3. Append с реаллокацией (передача по значению — изменения НЕ видны)

func appendWithRealloc(s []int) {
s = append(s, 100, 200, 300) // Реаллокация!
fmt.Println("Inside:", s) // [1 2 3 100 200 300]
}

func main() {
nums := []int{1, 2, 3} // cap = 3

appendWithRealloc(nums)
fmt.Println("Outside:", nums) // [1 2 3] — без изменений
}

После реаллокации внутренний слайс указывает на новый массив, внешний — на старый.

Способы обхода ограничения:

Вариант 1: Передача по указателю

func appendViaPointer(s *[]int) {
*s = append(*s, 100, 200, 300)
}

func main() {
nums := []int{1, 2, 3}
appendViaPointer(&nums)
fmt.Println(nums) // [1 2 3 100 200 300]
}

Вариант 2: Возврат нового слайса (идиоматичный Go)

func appendAndReturn(s []int) []int {
return append(s, 100, 200, 300)
}

func main() {
nums := []int{1, 2, 3}
nums = appendAndReturn(nums)
fmt.Println(nums) // [1 2 3 100 200 300]
}

Вариант 3: Использование индекса вместо append

func fillSlice(s []int, startIdx int, values ...int) int {
for i, v := range values {
if startIdx+i < len(s) {
s[startIdx+i] = v
}
}
return startIdx + len(values)
}

func main() {
nums := make([]int, 10)
idx := fillSlice(nums, 0, 1, 2, 3)
idx = fillSlice(nums, idx, 4, 5, 6)
fmt.Println(nums) // [1 2 3 4 5 6 0 0 0 0]
}

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

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

Рекомендации:

  • Для изменения существующих элементов — передача по значению
  • Для добавления элементов — возврат нового слайса (идиоматичный подход)
  • Передача по указателю оправдана только при необходимости изменить слайс в нескольких местах без возврата

Вопрос 5. Рассмотри код со слайсом структур Person. После создания слайса, изменения возраста первого элемента и append нового элемента — что будет выведено при печати P1 и P2? Почему?

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

Ответ собеседника: Правильный. P1 останется с возрастом H1 (без изменений после append), а P2 будет с возрастом H2. При изменении элемента слайса до append — изменение сохраняется, так как ёмкости хватает и новый массив не аллоцируется. После append добавляется новый элемент, и P2 указывает на него.

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

Рассмотрим детально пример со слайсом структур:

package main

import "fmt"

type Person struct {
Name string
Age int
}

func main() {
// Создаём слайс с длиной 1 и ёмкостью 2
people := make([]Person, 1, 2)
people[0] = Person{Name: "Alice", Age: 25}

// P1 — копия первого элемента (структуры копируются по значению!)
p1 := people[0]

// Изменяем возраст через слайс
people[0].Age = 30

// Добавляем новый элемент (без реаллокации, cap=2)
people = append(people, Person{Name: "Bob", Age: 35})

// P2 — второй элемент слайса
p2 := people[1]

fmt.Printf("p1: %+v\n", p1) // {Name:Alice Age:25}
fmt.Printf("p2: %+v\n", p2) // {Name:Bob Age:35}
fmt.Printf("people[0]: %+v\n", people[0]) // {Name:Alice Age:30}
}

Почему так происходит:

1. Структуры в Go — типы значения

При присваивании p1 := people[0] происходит полное копирование структуры. p1 — это независимая копия, не связанная с элементом слайса.

p1 := people[0] // Копия всей структуры
p1.Age = 100 // Не влияет на people[0]
people[0].Age = 30 // Не влияет на p1

2. Изменение через индекс модifies элемент в базовом массиве

people[0].Age = 30 // Изменение напрямую в базовом массиве

3. Append без реаллокации

Поскольку cap=2 и len=1, append не вызывает реаллокацию — новый элемент добавляется в существующий базовый массив.

Визуализация памяти:

До append:
Base Array: [Person{Alice, 25} | свободно]
^
people -------| len=1, cap=2

p1: Person{Alice, 25} (отдельная копия)

После people[0].Age = 30:
Base Array: [Person{Alice, 30} | свободно]
people[0].Age = 30

p1: Person{Alice, 25} (копия не изменилась)

После append:
Base Array: [Person{Alice, 30} | Person{Bob, 35}]
people -------| len=2, cap=2

p2 := people[1] → Person{Bob, 35}

Важный нюанс с указателями на элементы:

func main() {
people := make([]Person, 1, 2)
people[0] = Person{Name: "Alice", Age: 25}

// Указатель на элемент слайса
p1Ptr := &people[0]

// Изменяем через слайс
people[0].Age = 30
fmt.Println(p1Ptr.Age) // 30 — указывает на тот же элемент

// Append без реаллокации
people = append(people, Person{Name: "Bob", Age: 35})
fmt.Println(p1Ptr.Age) // всё ещё 30

// Append с реаллокацией
people = append(people, Person{Name: "Charlie", Age: 40})
// p1Ptr теперь указывает на старый массив!
// Поведение неопределено, если старый массив собран мусорщиком
}

Когда указатели на элементы опасны:

// Паттерн, которого следует избегать
func getPointers() []*Person {
people := make([]Person, 0, 10)
var pointers []*Person

for i := 0; i < 5; i++ {
people = append(people, Person{Name: fmt.Sprintf("Person%d", i)})
pointers = append(pointers, &people[len(people)-1])
// После реаллокации старые указатели станут невалидными!
}

return pointers
}

Рекомендации:

  • При работе со слайсом структур помните, что элементы копируются по значению
  • Храните указатели на элементы слайса только если уверены, что реаллокации не будет
  • Для изменяемых коллекций используйте слайс указателей: []*Person
  • При append всегда присваивайте результат обратно: people = append(people, p)

Вопрос 6. Есть два конструктора, возвращающих интерфейс репозитория. Один возвращает nil, другой — указатель на структуру, реализующую интерфейс. Будут ли равны результаты этих конструкторов? Почему?

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

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

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

Это одна из самых коварных ловушек в Go, известная как "nil interface vs typed nil".

Внутренняя структура интерфейса

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

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

Интерфейс равен nil только когда оба поля равны nil.

Демонстрация проблемы

package main

import "fmt"

type Repository interface {
Find(id int) error
}

type UserRepo struct{}

func (r *UserRepo) Find(id int) error {
return nil
}

// Возвращает "голый" nil
func NewUserRepoV1() Repository {
return nil
}

// Возвращает nil-указатель, приведённый к интерфейсу
func NewUserRepoV2() Repository {
var r *UserRepo // nil-указатель
return r // неявное приведение к Repository
}

func main() {
repo1 := NewUserRepoV1()
repo2 := NewUserRepoV2()

fmt.Println("repo1 == nil:", repo1 == nil) // true
fmt.Println("repo2 == nil:", repo2 == nil) // false !!!
fmt.Println("repo1 == repo2:", repo1 == repo2) // false

// Оба печатаются как <nil>
fmt.Printf("repo1: %v\n", repo1) // <nil>
fmt.Printf("repo2: %v\n", repo2) // <nil>

// Но repo2 вызовет panic при использовании!
// repo2.Find(1) // panic: runtime error: invalid memory address
}

Визуализация разницы:

repo1 (nil interface):
┌──────────┬──────────┐
│ _type │ data │
│ nil │ nil │
└──────────┴──────────┘

repo2 (typed nil):
┌──────────┬──────────┐
│ _type │ data │
│ *UserRepo│ nil │
└──────────┴──────────┘
↑ тип заполнен!

Реальная опасность — nil-проверка не спасает:

func GetUser(repo Repository, id int) error {
if repo == nil { // Не сработает для typed nil!
return errors.New("repo is nil")
}
return repo.Find(id) // panic!
}

func main() {
repo := NewUserRepoV2() // typed nil
err := GetUser(repo, 1) // panic, хотя repo не nil
}

Как правильно обрабатывать:

1. Использование reflect (для отладки/библиотек)

import "reflect"

func isNilInterface(v interface{}) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func:
return rv.IsNil()
}
return false
}

2. Правильный паттерн конструкторов

// Вариант 1: Всегда возвращать конкретный тип, а не интерфейс
func NewUserRepo() (*UserRepo, error) {
return nil, errors.New("initialization failed")
}

// Вариант 2: Явная проверка внутри конструктора
func NewUserRepo() Repository {
var r *UserRepo
if r == nil {
return nil // Возвращаем именно nil, а не typed nil
}
return r
}

// Вариант 3: Использовать переменную для правильного приведения
func NewUserRepo() Repository {
var r *UserRepo
// ... инициализация ...
if r != nil {
return r
}
return nil // явный nil, не typed nil
}

3. Проверка на уровне вызывающего кода

func GetUser(repo Repository, id int) error {
// Проверка через type assertion
if repo == nil {
return errors.New("repo is nil")
}

// Дополнительная проверка для конкретных типов
if r, ok := repo.(*UserRepo); ok && r == nil {
return errors.New("user repo is nil")
}

return repo.Find(id)
}

Распространённые места, где возникает проблема:

// Ошибки — частый источник typed nil
type MyError struct {
Code int
}

func (e *MyError) Error() string {
return fmt.Sprintf("error %d", e.Code)
}

func doSomething() error {
var err *MyError // nil-указатель
// ... что-то произошло ...
return err // возвращает typed nil error!
}

func main() {
err := doSomething()
if err != nil {
// Выполнится, хотя ошибки нет!
fmt.Println("Error:", err) // <nil>
}
}

Рекомендации:

  • Конструкторы должны возвращать nil напрямую, а не через промежуточную переменную
  • При работе с интерфейсами используйте reflect.ValueOf(v).IsNil() для надёжной проверки
  • Документируйте, может ли функция возвращать nil
  • В тестах проверяйте оба случая: nil и typed nil

Вопрос 7. Какие структуры удовлетворяют интерфейсу в Go? Что такое утиная типизация? Влияет ли на реализацию интерфейса то, как объявлены методы — по значению или по указателю?

Таймкод: 00:18:02

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

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

Утиная типизация (Duck Typing) в Go

Go использует структутную типизацию (structural typing), которая часто называется "утиной типизацией": если тип имеет все методы интерфейса, он автоматически реализует этот интерфейс — без явного объявления.

package main

import "fmt"

// Интерфейс
type Speaker interface {
Speak() string
}

// Структура автоматически реализует Speaker
type Dog struct {
Name string
}

func (d Dog) Speak() string {
return d.Name + " says Woof!"
}

// Не нужно писать "implements Speaker" — Go определяет автоматически
func main() {
var s Speaker = Dog{Name: "Rex"}
fmt.Println(s.Speak()) // Rex says Woof!
}

Правила реализации интерфейса методами по значению и по указателю

Это критически важный аспект, который часто вызывает путаницу:

1. Методы по значению (value receiver)

type Cat struct {
Name string
}

// Метод по значению
func (c Cat) Speak() string {
return c.Name + " says Meow!"
}

func main() {
var s Speaker

// Оба варианта работают
s = Cat{Name: "Whiskers"} // ✓ Значение
s = &Cat{Name: "Whiskers"} // ✓ Указатель (Go автоматически разыменует)

fmt.Println(s.Speak())
}

Правило: Если все методы интерфейса реализованы по значению, то и T, и *T реализуют интерфейс.

2. Методы по указателю (pointer receiver)

type Robot struct {
Model string
}

// Метод по указателю
func (r *Robot) Speak() string {
return r.Model + " says Beep-boop!"
}

func main() {
var s Speaker

// s = Robot{Name: "R2D2"} // ✗ Ошибка компиляции!
s = &Robot{Model: "R2D2"} // ✓ Только указатель

fmt.Println(s.Speak())
}

Правило: Если хотя бы один метод реализован по указателю, то только *T реализует интерфейс.

3. Смешанный случай

type Bird struct {
Name string
Age int
}

// Метод по значению
func (b Bird) Speak() string {
return b.Name + " says Tweet!"
}

// Метод по указателю
func (b *Bird) SetAge(age int) {
b.Age = age
}

type Animal interface {
Speak() string
SetAge(age int)
}

func main() {
var a Animal

// a = Bird{Name: "Tweety"} // ✗ Ошибка! SetAge — по указателю
a = &Bird{Name: "Tweety"} // ✓ Только указатель

fmt.Println(a.Speak())
}

Сводная таблица:

Реализация методовT реализует интерфейс*T реализует интерфейс
Все методы по значениюДаДа
Хотя бы один метод по указателюНетДа

Почему так происходит:

// Метод по значению: func (t T) Method()
// Go может вызвать на:
// - T (значение)
// - *T (автоматическое разыменование)

// Метод по указателю: func (t *T) Method()
// Go может вызвать только на:
// - *T (указатель)
// Нельзя вызвать на T, потому что:
// - Значение может быть адресуемым или нет
// - Интерфейс не гарантирует адресуемость

Пример с адресуемостью:

type Counter struct {
count int
}

func (c *Counter) Increment() {
c.count++
}

func (c Counter) Count() int {
return c.count
}

type CounterInterface interface {
Increment()
Count() int
}

func main() {
// Локальная переменная — адресуема
c := Counter{}
c.Increment() // Go автоматически берёт адрес: (&c).Increment()

// Но через интерфейс это не работает
var ci CounterInterface
// ci = Counter{} // Ошибка! Increment требует указатель
ci = &Counter{} // OK
}

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

1. Когда использовать методы по указателю

type UserRepository struct {
db *sql.DB
cache map[string]*User
}

// Мутирующие методы — по указателю
func (r *UserRepository) Save(user *User) error {
// Изменяет состояние репозитория
}

func (r *UserRepository) Delete(id int) error {
// Изменяет состояние репозитория
}

// Метод без изменения состояния — можно по значению
func (r UserRepository) Find(id int) (*User, error) {
// Только чтение
}

2. Проблема с map и интерфейсами

type Processor struct {
data string
}

func (p *Processor) Process() string {
return p.data
}

func main() {
// Map не позволяет получить адрес значения
processors := map[string]Processor{
"p1": {data: "hello"},
}

// var pi ProcessorInterface = processors["p1"] // Ошибка!

// Решение: хранить указатели
ptrProcessors := map[string]*Processor{
"p1": {data: "hello"},
}
var pi ProcessorInterface = ptrProcessors["p1"] // OK
}

3. Проблема с литералами

type Writer interface {
Write([]byte) (int, error)
}

type Buffer struct{}

func (b *Buffer) Write(p []byte) (int, error) {
return len(p), nil
}

func main() {
// var w Writer = Buffer{} // Ошибка!
var w Writer = &Buffer{} // OK

// Или через переменную
b := Buffer{}
w = &b // OK
}

Рекомендации:

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

Вопрос 8. Можно ли вызвать метод структуры непосредственно на результате функции, возвращающей структуру? Есть ли разница между возвратом структуры по значению или по указателю?

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

Ответ собеседника: Неполный. Ответил, что можно вызвать метод на результате функции, но не уточнил важный нюанс: если метод объявлен по указателю, а функция возвращает структуру по значению, то вызов метода невозможен напрямую, так как возвращаемое значение не является addressable. Это вызовет ошибку компиляции.

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

Понятие addressable в Go

В Go значение является addressable (адресуемым), если можно получить его адрес с помощью &. К адресуемым значениям относятся:

  • Переменные
  • Элементы массива/слайса
  • Поля структуры
  • Результаты разыменования указателя

К неадресуемым значениям относятся:

  • Возвращаемые значения функций
  • Литералы структур
  • Результаты арифметических операций
  • Константы

Вызов методов на результатах функций

package main

import "fmt"

type Person struct {
Name string
Age int
}

// Метод по значению
func (p Person) GetName() string {
return p.Name
}

// Метод по указателю
func (p *Person) SetAge(age int) {
p.Age = age
}

// Возвращает структуру по значению
func NewPersonV1() Person {
return Person{Name: "Alice", Age: 25}
}

// Возвращает указатель на структуру
func NewPersonV2() *Person {
return &Person{Name: "Bob", Age: 30}
}

func main() {
// Метод по значению — работает в обоих случаях
name1 := NewPersonV1().GetName() // OK
name2 := NewPersonV2().GetName() // OK (Go разыменует указатель)

fmt.Println(name1, name2)

// Метод по указателю — зависит от типа возврата
// NewPersonV1().SetAge(30) // ОШИБКА КОМПИЛЯЦИИ!
// cannot call pointer method on Person literal
// cannot take the address of Person literal

NewPersonV2().SetAge(35) // OK — возвращает *Person
}

Почему так происходит:

// Компилятор пытается преобразовать:
NewPersonV1().SetAge(30)

// В нечто подобное:
(&NewPersonV1()).SetAge(30)

// Но это невозможно, потому что:
// 1. Результат функции — временное значение
// 2. Нельзя взять адрес временного значения
// 3. Следовательно, нельзя вызвать pointer receiver

Обходные пути:

// Вариант 1: Сохранить в переменную
p := NewPersonV1()
p.SetAge(30) // OK — переменная адресуема

// Вариант 2: Изменить функцию на возврат указателя
func NewPerson() *Person {
return &Person{Name: "Alice", Age: 25}
}
NewPerson().SetAge(30) // OK

// Вариант 3: Изменить метод на value receiver
func (p Person) SetAge(age int) Person {
p.Age = age
return p
}
NewPersonV1().SetAge(30) // OK, но возвращает новую копию

Разница между возвратом по значению и по указателю:

1. Производительность

type LargeStruct struct {
data [1024]byte
// ... много полей
}

// Плохо для больших структур: копирование при возврате
func CreateLargeV1() LargeStruct {
return LargeStruct{}
}

// Хорошо: возвращается только указатель (8 байт)
func CreateLargeV2() *LargeStruct {
return &LargeStruct{}
}

2. Изменяемость

type Counter struct {
count int
}

// Возврат по значению — вызывающий получает копию
func IncrementV1(c Counter) Counter {
c.count++
return c
}

// Возврат по указателю — можно изменить оригинал
func IncrementV2(c *Counter) {
c.count++
}

func main() {
c1 := Counter{count: 0}
c1 = IncrementV1(c1) // Нужно присвоить результат
fmt.Println(c1.count) // 1

c2 := Counter{count: 0}
IncrementV2(&c2) // Изменение на месте
fmt.Println(c2.count) // 1
}

3. Nil-проверка

// Возврат указателя позволяет вернуть nil при ошибке
func FindUser(id int) (*User, error) {
if id <= 0 {
return nil, errors.New("invalid id")
}
return &User{ID: id}, nil
}

// Возврат значения — нельзя вернуть nil
func FindUserV2(id int) (User, error) {
if id <= 0 {
return User{}, errors.New("invalid id") // Пустая структура
}
return User{ID: id}, nil
}

4. Влияние на сборщик мусора

// Возврат по значению — объект может быть на стеке (если компилятор решит)
func CreateOnStack() SmallStruct {
return SmallStruct{}
}

// Возврат по указателю — объект точно на куче (escape analysis)
func CreateOnHeap() *SmallStruct {
return &SmallStruct{}
}

Таблица сравнения:

АспектВозврат по значениюВозврат по указателю
КопированиеДаНет (только 8 байт)
Вызов pointer методовНельзя напрямуюМожно
Nil-проверкаНевозможнаВозможна
ПотокобезопасностьКопия изолацииНужна синхронизация
АллокацииМожет быть на стекеОбычно на куче
ИдиоматичностьДля маленьких структурДля больших/изменяемых

Рекомендации:

  • Маленькие структуры (< 64 байт) — возврат по значению
  • Большие структуры — возврат по указателю
  • Структуры с мутациями — возврат по указателю
  • Неизменяемые структуры (как time.Time) — возврат по значению
  • Если нужна nil-проверка — возврат по указателю

Вопрос 9. Расскажи про мапы в Go. Что это, как устроены, какие типы могут быть ключами? Потокобезопасны ли мапы? Как правильно работать с мапой внутри репозитория из методов — какую синхронизацию использовать? Как обнаружить потоконебезопасные обращения?

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

Ответ собеседника: Неполный. Верно описал, что мапа — это хэш-таблица, упомянул бакеты по 8 элементов, коэффициент загрузки ~6.5, что ключом может быть comparable тип. Упомянул, что мапы не потокобезопасны. На вопрос о синхронизации начал отвечать про RWMutex и Mutex, но не завершил. Упомянул флаг -race для обнаружения гонок, но не вспомнил точный синтаксис.

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

Устройство мапы в Go

Мапа в Go — это реализация хэш-таблицы с открытой адресацией.

// Внутренняя структура (упрощённо)
type hmap struct {
count int // количество элементов
flags uint8
B uint8 // log2 количества бакетов (2^B бакетов)
noverflow uint16
hash0 uint32 // seed для хэш-функции
buckets unsafe.Pointer // массив бакетов
oldbuckets unsafe.Pointer // предыдущий массив (при росте)
nevacuate uintptr
extra *mapextra
}

// Бакет — структура для хранения пар ключ-значение
type bmap struct {
tophash [8]uint8 // старшие биты хэша для быстрого поиска
// За ними следуют 8 ключей и 8 значений
}

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

  • Каждый бакет хранит 8 пар ключ-значение
  • Коэффициент загрузки (load factor): 6.5 элементов на бакет
  • При превышении порога — мапа растёт (удвоение количества бакетов)
  • Рост происходит инкрементально (постепенная эвакуация)

Типы ключей мапы

Ключом мапы может быть любой comparable тип:

// Разрешённые типы ключей:
var m1 map[string]int // string — comparable
var m2 map[int]string // int — comparable
var m3 map[bool]int // bool — comparable
var m4 map[[5]int]string // массив — comparable (если элементы comparable)
var m5 map[MyStruct]int // структура — comparable (если все поля comparable)

type MyStruct struct {
Name string
Age int
}

// Запрещённые типы ключей:
// var m6 map[[]int]string // слайс — НЕ comparable
// var m7 map[map[string]int]int // мапа — НЕ comparable
// var m8 map[func()]int // функция — НЕ comparable

Проверка comparable на этапе компиляции:

type NotComparable struct {
Data []int // слайс делает структуру не comparable
}

// var m map[NotComparable]int // Ошибка компиляции:
// invalid map key type NotComparable

Потокобезопасность мапы

Мапы в Go НЕ потокобезопасны. Одновременное чтение и запись вызывает panic.

func main() {
m := make(map[string]int)

go func() {
for i := 0; i < 1000; i++ {
m["key"] = i // Запись
}
}()

go func() {
for i := 0; i < 1000; i++ {
_ = m["key"] // Чтение
}
}()

time.Sleep(time.Second)
// fatal error: concurrent map read and map write
}

Способы синхронизации:

1. sync.RWMutex — рекомендуемый подход

type UserRepository struct {
mu sync.RWMutex
users map[int]*User
}

func NewUserRepository() *UserRepository {
return &UserRepository{
users: make(map[int]*User),
}
}

func (r *UserRepository) Get(id int) (*User, bool) {
r.mu.RLock() // Блокировка на чтение
defer r.mu.RUnlock() // Разблокировка

user, ok := r.users[id]
return user, ok
}

func (r *UserRepository) Set(id int, user *User) {
r.mu.Lock() // Блокировка на запись
defer r.mu.Unlock() // Разблокировка

r.users[id] = user
}

func (r *UserRepository) Delete(id int) {
r.mu.Lock()
defer r.mu.Unlock()

delete(r.users, id)
}

func (r *UserRepository) GetAll() []*User {
r.mu.RLock()
defer r.mu.RUnlock()

result := make([]*User, 0, len(r.users))
for _, user := range r.users {
result = append(result, user)
}
return result
}

2. sync.Map — для специфических сценариев

type Cache struct {
data sync.Map
}

func (c *Cache) Get(key string) (interface{}, bool) {
return c.data.Load(key)
}

func (c *Cache) Set(key string, value interface{}) {
c.data.Store(key, value)
}

func (c *Cache) Delete(key string) {
c.data.Delete(key)
}

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

  • Когда записи редко обновляются, но часто читаются
  • Когда разные горутины работают с разными наборами ключей
  • Когда нужна атомарность операций LoadOrStore, LoadAndDelete

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

  • Когда нужна итерация по всем элементам
  • Когда нужна подсчёт элементов (Len)
  • Когда нужны транзакции над несколькими ключами

3. Каналы — для последовательного доступа

type MapOperation struct {
Op string
Key int
Value *User
Resp chan *User
}

type UserRepository struct {
ops chan MapOperation
users map[int]*User
}

func NewUserRepository() *UserRepository {
r := &UserRepository{
ops: make(chan MapOperation, 100),
users: make(map[int]*User),
}
go r.processLoop()
return r
}

func (r *UserRepository) processLoop() {
for op := range r.ops {
switch op.Op {
case "get":
op.Resp <- r.users[op.Key]
case "set":
r.users[op.Key] = op.Value
op.Resp <- nil
case "delete":
delete(r.users, op.Key)
op.Resp <- nil
}
}
}

func (r *UserRepository) Get(id int) *User {
resp := make(chan *User)
r.ops <- MapOperation{Op: "get", Key: id, Resp: resp}
return <-resp
}

Обнаружение гонок данных

1. Флаг -race (обязательно в тестах)

# Запуск тестов с детектором гонок
go test -race ./...

# Запуск программы с детектором гонок
go run -race main.go

# Сборка с детектором гонок
go build -race -o myapp main.go

2. Пример обнаружения гонки:

func TestMapConcurrency(t *testing.T) {
m := make(map[int]int)

go func() {
for i := 0; i < 1000; i++ {
m[i] = i
}
}()

go func() {
for i := 0; i < 1000; i++ {
_ = m[i]
}
}()

time.Sleep(time.Second)
}

Вывод при go test -race:

==================
WARNING: DATA RACE
Write at 0x00c000122000 by goroutine 7:
runtime.mapassign_fast64()
/usr/local/go/src/runtime/map_fast64.go:93 +0x0
main.TestMapConcurrency.func1()

Previous read at 0x00c000122000 by goroutine 8:
runtime.mapaccess1_fast64()
/usr/local/go/src/runtime/map_fast64.go:52 +0x0
main.TestMapConcurrency.func2()
==================

3. Интеграция в CI/CD:

# .github/workflows/test.yml
jobs:
test:
steps:
- name: Run tests with race detector
run: go test -race -coverprofile=coverage.out ./...

Сравнение подходов к синхронизации:

ПодходПлюсыМинусыКогда использовать
RWMutexПростота, контроль, подходит для большинства случаевБлокирует читателей при записиУниверсальный случай
sync.MapОптимизирован для чтения, атомарные операцииНет Len, нет итерацииКэши, редкие записи
КаналыПоследовательный доступ, нет блокировокСложность, накладные расходыСпецифические сценарии

Рекомендации:

  • Всегда используйте -race в тестах и CI
  • Для большинства случаев — sync.RWMutex
  • Для кэшей с редкими записями — sync.Map
  • Не используйте sync.Mutex вместо RWMutex, если есть много читателей
  • Держите блокировки минимально возможное время

Вопрос 10. Как правильно работать с мапой внутри репозитория из методов? Какую синхронизацию использовать?

Таймкод: 00:25:27

Ответ собеседника: Неполный. Начал отвечать, что зависит от ситуации: если мало пишем и много читаем — можно использовать RWMutex, если много пишем — обычный Mutex. Но ответ был неполным и не завершён, не упомянул sync.Map как альтернативу для определённых сценариев.

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

Выбор стратегии синхронизации для мапы в репозитории

Выбор подхода зависит от паттерна доступа: соотношение чтений и записей, количество горутин, требования к консистентности.

1. sync.RWMutex — универсальный подход

Оптимален, когда чтений значительно больше, чем записей (типичный случай для репозиториев).

type UserRepository struct {
mu sync.RWMutex
users map[int]*User
}

func NewUserRepository() *UserRepository {
return &UserRepository{
users: make(map[int]*User),
}
}

func (r *UserRepository) FindByID(id int) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()

user, ok := r.users[id]
if !ok {
return nil, ErrUserNotFound
}
return user, nil
}

func (r *UserRepository) FindAll() []*User {
r.mu.RLock()
defer r.mu.RUnlock()

result := make([]*int, 0, len(r.users))
for _, user := range r.users {
result = append(result, user)
}
return result
}

func (r *UserRepository) Save(user *User) error {
r.mu.Lock()
defer r.mu.Unlock()

r.users[user.ID] = user
return nil
}

func (r *UserRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()

if _, ok := r.users[id]; !ok {
return ErrUserNotFound
}
delete(r.users, id)
return nil
}

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

  • Чтения значительно чаще записей (80/20 или 90/10)
  • Нужна итерация по всем элементам
  • Нужен подсчёт элементов
  • Требуется атомарность операций над несколькими ключами

2. sync.Map — для кэшей и read-heavy сценариев

Оптимизирована для случаев, когда записи происходят редко, а чтения — часто.

type UserCache struct {
data sync.Map
}

func NewUserCache() *UserCache {
return &UserCache{}
}

func (c *UserCache) Get(id int) (*User, bool) {
value, ok := c.data.Load(id)
if !ok {
return nil, false
}
return value.(*User), true
}

func (c *UserCache) Set(id int, user *User) {
c.data.Store(id, user)
}

func (c *UserCache) Delete(id int) {
c.data.Delete(id)
}

// Атомарная операция: запись, если ключ отсутствует
func (c *UserCache) GetOrLoad(id int, loader func(int) (*User, error)) (*User, error) {
// Пробуем загрузить без блокировки
if user, ok := c.Get(id); ok {
return user, nil
}

// Загружаем данные
user, err := loader(id)
if err != nil {
return nil, err
}

// Store только если ключ ещё не добавлен другой горутиной
actual, _ := c.data.LoadOrStore(id, user)
return actual.(*User), nil
}

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

  • Кэши с редкими записями
  • Разные горутины работают с разными ключами
  • Нужны атомарные операции LoadOrStore, LoadAndDelete
  • Не нужна итерация или подсчёт элементов

3. sync.Mutex — для write-heavy сценариев

Если записи происходят часто, RWMutex не даёт преимущества, так как писатели всё равно блокируют читателей.

type WriteHeavyRepository struct {
mu sync.Mutex
users map[int]*User
}

func NewWriteHeavyRepository() *WriteHeavyRepository {
return &WriteHeavyRepository{
users: make(map[int]*User),
}
}

func (r *WriteHeavyRepository) Update(user *User) error {
r.mu.Lock()
defer r.mu.Unlock()

r.users[user.ID] = user
return nil
}

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

  • Записи происходят так же часто, как чтения
  • Операции чтения и записи примерно одинаковы по времени
  • Простота важнее производительности

4. Шардирование — для высоконагруженных систем

Разделение мапы на несколько шардов с отдельными блокировками уменьшает конкуренцию.

const shardCount = 32

type ShardedMap struct {
shards [shardCount]*Shard
}

type Shard struct {
mu sync.RWMutex
data map[int]*User
}

func NewShardedMap() *ShardedMap {
sm := &ShardedMap{}
for i := 0; i < shardCount; i++ {
sm.shards[i] = &Shard{
data: make(map[int]*User),
}
}
return sm
}

func (sm *ShardedMap) getShard(key int) *Shard {
return sm.shards[key%shardCount]
}

func (sm *ShardedMap) Get(key int) (*User, bool) {
shard := sm.getShard(key)
shard.mu.RLock()
defer shard.mu.RUnlock()

user, ok := shard.data[key]
return user, ok
}

func (sm *ShardedMap) Set(key int, user *User) {
shard := sm.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()

shard.data[key] = user
}

func (sm *ShardedMap) Delete(key int) {
shard := sm.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()

delete(shard.data, key)
}

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

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

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

ПодходЧтениеЗаписьИтерацияСложность
RWMutexБыстрое (параллельно)Медленное (эксклюзивно)ДаНизкая
sync.MapОчень быстроеБыстроеНетНизкая
MutexМедленное (эксклюзивно)МедленноеДаНизкая
ШардированиеОчень быстроеОчень быстроеСложнаяВысокая

Рекомендации по выбору:

// Для типичного репозитория с базой данных в качестве источника
type CachedRepository struct {
db *sql.DB
cache *UserCache // sync.Map для кэша
}

func (r *CachedRepository) FindByID(id int) (*User, error) {
// Сначала проверяем кэш
if user, ok := r.cache.Get(id); ok {
return user, nil
}

// Загружаем из БД
user, err := r.loadFromDB(id)
if err != nil {
return nil, err
}

// Сохраняем в кэш
r.cache.Set(id, user)
return user, nil
}

Общие рекомендации:

  • Начинайте с RWMutex — это самый простой и понятный подход
  • Переходите на sync.Map только если профилирование показывает конкуренцию на блокировках
  • Используйте шардирование только для высоконагруженных систем
  • Всегда тестируйте с флагом -race
  • Держите блокировки минимально возможное время
  • Не вызывайте внешний код под блокировкой

Вопрос 11. Как понять, что забыли обложить мапу мьютексом? Есть ли инструмент для обнаружения потоконебезопасных обращений к данным?

Таймкод: 00:28:56

Ответ собеседника: Неполный. Упомянул флаг -race при запуске Go для обнаружения гонок данных, но не смог вспомнить точный синтаксис его использования. Инструмент верный, но знание неполное.

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

Go Race Detector — основной инструмент обнаружения гонок данных

Go встроенный детектор гонок (race detector) — это мощный инструмент, который обнаруживает гонки данных во время выполнения программы.

Синтаксис использования:

# Запуск тестов с детектором гонок
go test -race ./...

# Запуск конкретного пакета
go test -race ./internal/repository/...

# Запуск программы с детектором гонок
go run -race main.go

# Сборка бинарника с детектором
go build -race -o myapp main.go

# Запуск собранного бинарника
./myapp

Пример обнаружения гонки:

// repository.go
package repository

type UserRepo struct {
users map[int]*User
}

func NewUserRepo() *UserRepo {
return &UserRepo{
users: make(map[int]*User),
}
}

func (r *UserRepo) Save(user *User) {
r.users[user.ID] = user // Без мьютекса!
}

func (r *UserRepo) Find(id int) *User {
return r.users[id] // Без мьютекса!
}
// repository_test.go
package repository

import (
"sync"
"testing"
)

func TestConcurrentAccess(t *testing.T) {
repo := NewUserRepo()

var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)

go func(id int) {
defer wg.Done()
repo.Save(&User{ID: id, Name: "User"})
}(i)

go func(id int) {
defer wg.Done()
_ = repo.Find(id)
}(i)
}

wg.Wait()
}

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

go test -race -run TestConcurrentAccess ./repository/...

Вывод:

==================
WARNING: DATA RACE
Write at 0x00c000122000 by goroutine 7:
repository.(*UserRepo).Save()
/path/to/repository.go:13 +0x78
repository.TestConcurrentAccess.func1()
/path/to/repository_test.go:18 +0x3c

Previous read at 0x00c000122000 by goroutine 8:
repository.(*UserRepo).Find()
/path/to/repository.go:17 +0x44
repository.TestConcurrentAccess.func2()
/path/to/repository_test.go:23 +0x3c

Goroutine 7 (running) created at:
repository.TestConcurrentAccess()
/path/to/repository_test.go:17 +0x1a4

Goroutine 8 (running) created at:
repository.TestConcurrentAccess()
/path/to/repository_test.go:22 +0x1e4
==================
Found 1 data race(s)
FAIL repository 0.523s

Интеграция в CI/CD:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.22'

- name: Run tests with race detector
run: go test -race -coverprofile=coverage.out ./...

- name: Upload coverage
uses: codecov/codecov-action@v3
# Makefile
.PHONY: test test-race

test:
go test ./...

test-race:
go test -race ./...

test-race-verbose:
go test -race -v ./...

Ограничения Race Detector:

// 1. Детектор находит гонки только в выполняемом коде
// Если тест не вызывает конкурентный код — гонка не будет обнаружена

func TestNoRace(t *testing.T) {
repo := NewUserRepo()
repo.Save(&User{ID: 1}) // Однопоточный вызов
_ = repo.Find(1) // Race detector не найдёт проблему
}

// 2. Накладные расходы: 5-10x замедление по времени и памяти
// Не используйте в production

// 3. Может не обнаружить все гонки
// Гонка должна произойти во время выполнения

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

1. go vet — статический анализ:

go vet ./...

2. staticcheck — продвинутый статический анализ:

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

3. Профилирование блокировок:

import (
"runtime"
"os"
"os/signal"
)

func main() {
// Включить профилирование блокировок
runtime.SetMutexProfileFraction(1)

// Обработка сигнала для дампа профиля
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)

go func() {
<-c
// Дамп профиля блокировок
pprof.Lookup("mutex").WriteTo(os.Stdout, 1)
os.Exit(0)
}()
}

4. goleak — обнаружение утечек горутин:

import "go.uber.org/goleak"

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

func TestRepository(t *testing.T) {
defer goleak.VerifyNone(t)

repo := NewUserRepo()
// ... тест ...
}

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

// 1. Всегда запускайте тесты с -race в CI
// 2. Запускайте периодически локально для критичных пакетов

// 3. Паттерн: тест, который точно вызовет гонку
func TestMapRace(t *testing.T) {
m := make(map[int]int)

// Запускаем много горутин для увеличения вероятности гонки
for i := 0; i < 1000; i++ {
go func() {
for j := 0; j < 100; j++ {
m[j] = j // Запись
_ = m[j] // Чтение
}
}()
}

time.Sleep(time.Second)
}

Чек-лист для предотвращения гонок:

  • Используйте go test -race в CI
  • Тестируйте конкуррентный код с множеством горутин
  • Документируйте, потокобезопасен ли тип
  • Используйте sync.Map или sync.RWMutex для разделяемых мап
  • Рассмотрите использование каналов вместо разделяемой памяти
  • Запускайте staticcheck для обнаружения подозрительных паттернов

Вопрос 12. Рассмотри код с бесконечным циклом в горутине и GOMAXPROCS=1. Что произойдёт? Как это зависит от версии Go? Расскажи про модель конкурентности Go — как работает планировщик, какие есть очереди?

Таймкод: 00:29:54

Ответ собеседника: Неполный. Верно ответил, что при GOMAXPROCS=1 бесконечный цикл заблокирует выполнение. Не вспомнил термин «кооперативный планировщик» и вытеснение горутин с Go 1.14+. Описал планировщик упрощённо: потоки ОС, очереди горутин, очередь ожидающих, но не упомянул локальные очереди P, глобальную очередь, work-stealing и модель GMP.

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

Бесконечный цикл и GOMAXPROCS=1

package main

import (
"fmt"
"runtime"
)

func main() {
runtime.GOMAXPROCS(1)

go func() {
for {
// Бесконечный цикл без точек вытеснения
}
}()

fmt.Println("This will never print")
}

Поведение в разных версиях Go:

До Go 1.14 (кооперативный планировщик):

  • Горутина с бесконечным циклом полностью блокирует выполнение
  • Другие горутины не получают процессорное время
  • Планировщик переключает горутины только в точках вытеснения (syscall, channel operations, function calls)

С Go 1.14+ (вытесняющий планировщик):

  • Горутины вытесняются по таймеру (каждые ~10мс)
  • Бесконечный цикл не блокирует другие горутины
  • Используются сигналы ОС (SIGURG) для вытеснения
// Пример, который работает в Go 1.14+
func main() {
runtime.GOMAXPROCS(1)

go func() {
for {
// Бесконечный цикл
}
}()

// В Go 1.14+ это выполнится
// В Go 1.13 и ранее — нет
fmt.Println("This will print in Go 1.14+")
}

Модель GMP — архитектура планировщика Go

Планировщик Go использует модель GMP:

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

type g struct {
stack stack // стек горутины
stackguard0 uintptr
m *m // M, на котором выполняется горутина
sched gobuf // контекст для переключения
status uint32 // состояние: Grunning, Grunnable, Gwaiting, ...
}

type p struct {
id int32
status uint32 // Pidle, Prunning, Psyscall, ...
m *m // M, к которому привязан P
runqhead uint32 // голова локальной очереди
runqtail uint32 // хвост локальной очереди
runq [256]guintptr // локальная очередь горутин (256 элементов)
runnext guintptr // следующая горутина для выполнения (приоритет)
}

type m struct {
g0 *g // горутина планировщика
curg *g // текущая выполняемая горутина
p puintptr // привязанный P
nextp puintptr // следующий P для привязки
}

Очереди горутин:

1. Локальная очередь P (Local Run Queue)

// Каждый P имеет свою локальную очередь
// Размер: 256 горутин
// Добавление/извлечение без блокировки (lock-free)

type p struct {
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // приоритетная горутина
}

2. Глобальная очередь (Global Run Queue)

// Общая очередь для всех P
// Защищена мьютексом
// Используется когда локальная очередь переполнена

var sched struct {
lock mutex
runq gQueue // глобальная очередь
runsize int32 // общее количество горутин в очередях
}

3. Очередь ожидающих (Network Poller)

// Горутины, ожидающие сетевых операций
// Обрабатываются отдельным потоком (netpoller)
// При готовности операции горутина возвращается в очередь

Work Stealing — алгоритм балансировки

// Когда P заканчивает горутины в локальной очереди:
func findrunnable() (gp *g, inheritTime bool) {
// 1. Проверить локальную очередь
if gp := runqget(_p_); gp != nil {
return gp, false
}

// 2. Проверить глобальную очередь
if gp := globrunqget(_p_, 0); gp != nil {
return gp, false
}

// 3. Проверить netpoller
if gp := netpoll(false); gp != nil {
return gp, false
}

// 4. Work Stealing: украсть у другого P
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
return gp, true
}
}
}

// 5. Если ничего не нашли — заблокировать M
return nil, false
}

Схема работы планировщика:

┌─────────────────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ P0 │ │ P1 │ │ P2 │ │ P3 │ ... │
│ │ │ │ │ │ │ │ │ │
│ │ M0 │ │ M1 │ │ M2 │ │ M3 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │
│ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ │
│ │Local│ │Local│ │Local│ │Local│ │
│ │Queue│ │Queue│ │Queue│ │Queue│ │
│ │ G1 │ │ G4 │ │ G7 │ │ G10 │ │
│ │ G2 │ │ G5 │ │ G8 │ │ G11 │ │
│ │ G3 │ │ G6 │ │ G9 │ │ G12 │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Global Run Queue │ │
│ │ G13 → G14 → G15 → G16 → ... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Network Poller │ │
│ │ G17 (waiting for network) │ │
│ │ G18 (waiting for network) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Состояния горутины:

const (
_Gidle = iota // 0 - только создана
_Grunnable // 1 - готова к выполнению
_Grunning // 2 - выполняется
_Gwaiting // 3 - ожидает (channel, syscall, lock)
_Gdead // 4 - завершена
_Gcopystack // 5 - копирует стек
_Gpreempted // 6 - вытеснена
)

Точки вытеснения (preemption points):

// Горутина может быть вытеснена в:
// 1. Вызове функции (до Go 1.14)
// 2. Операциях с каналами
// 3. Системных вызовах
// 4. Выделении памяти (runtime.GC)
// 5. По таймеру каждые ~10мс (с Go 1.14+)

// Горутина НЕ может быть вытеснена в:
// 1. Бесконечном цикле без вызовов функций (до Go 1.14)
// 2. Арифметических операциях
// 3. Доступе к памяти

Примеры влияния планировщика:

// Пример 1: Кооперативное переключение
func main() {
runtime.GOMAXPROCS(1)

go func() {
for i := 0; i < 10; i++ {
fmt.Println("Goroutine 1:", i)
runtime.Gosched() // Явная передача управления
}
}()

for i := 0; i < 10; i++ {
fmt.Println("Goroutine 2:", i)
runtime.Gosched()
}
}

// Пример 2: Вытеснение по таймеру (Go 1.14+)
func main() {
runtime.GOMAXPROCS(1)

done := make(chan bool)

go func() {
for {
select {
case <-done:
return
default:
// Бесконечный цикл, но будет вытеснен по таймеру
}
}
}()

time.Sleep(100 * time.Millisecond)
done <- true
fmt.Println("Main goroutine completed")
}

Рекомендации:

  • Не полагайтесь на кооперативное планирование для корректности
  • Используйте runtime.Gosched() для явной передачи управления в CPU-bound задачах
  • Помните, что GOMAXPROCS по умолчанию равен количеству ядер
  • Для CPU-bound задач используйте runtime.GOMAXPROCS(runtime.NumCPU())
  • Профилируйте с помощью pprof для анализа работы планировщика

Вопрос 13. Расскажи про модель конкурентности Go. Как работает планировщик, какие есть очереди, куда идут горутины?

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

Ответ собеседника: Неполный. Описал, что есть потоки ОС, в которые закидываются очереди из горутин, упомянул очередь ожидающих горутин, что порядок выполнения не гарантирован. Однако описание было упрощённым и неполным — не упомянул локальные очереди (runqueue) у каждого P, глобальную очередь, механизм work-stealing, и не раскрыл модель GMP (Goroutine, Machine, Processor).

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

Модель GMP — фундамент планировщика Go

Планировщик Go использует модель GMP, где:

  • G (Goroutine) — легковесный поток выполнения
  • M (Machine) — поток ОС (thread)
  • P (Processor) — логический процессор, контекст выполнения

Связь компонентов:

GOMAXPROCS = 4

┌─────────────────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ Глобальная очередь: [G9] [G10] [G11] [G12] │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ P0 │ │ P1 │ │ P2 │ │ P3 │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │ M0 │ │ │ │ M1 │ │ │ │ M2 │ │ │ │ M3 │ │ │
│ │ └──┬──┘ │ │ └──┬──┘ │ │ └──┬──┘ │ │ └──┬──┘ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ ┌──┴──┐ │ │ ┌──┴──┐ │ │ ┌──┴──┐ │ │ ┌──┴──┐ │ │
│ │ │Local│ │ │ │Local│ │ │ │Local│ │ │ │Local│ │ │
│ │ │Queue│ │ │ │Queue│ │ │ │Queue│ │ │ │Queue│ │ │
│ │ │[G1] │ │ │ │[G4] │ │ │ │[G7] │ │ │ │[G13]│ │ │
│ │ │[G2] │ │ │ │[G5] │ │ │ │[G8] │ │ │ │ │ │ │
│ │ │[G3] │ │ │ │[G6] │ │ │ │ │ │ │ │ │ │ │
│ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Network Poller: [G14] [G15] (ожидают сети) │
│ Syscall Wait: [G16] [G17] (ожидают syscall) │
└─────────────────────────────────────────────────────────────┘

Типы очередей горутин:

1. Локальная очередь P (Local Run Queue)

Каждый P имеет свою локальную очередь на 256 горутин:

type p struct {
runqhead uint32
runqtail uint32
runq [256]guintptr // кольцевой буфер
runnext guintptr // приоритетная горутина
}

Добавление в локальную очередь:

// Когда горутина создаётся или становится готовой к выполнению:
func runqput(_p_ *p, gp *g, next bool) {
if next {
// Приоритетное добавление в runnext
oldnext := _p_.runnext
_p_.runnext.set(gp)
return
}

// Обычное добавление в хвост очереди
h := atomic.Load(&_p_.runqhead)
t := _p_.runqtail
if t-h < uint32(len(_p_.runq)) {
_p_.runq[t%uint32(len(_p_.runq))].set(gp)
atomic.Store(&_p_.runqtail, t+1)
return
}

// Очередь переполнена — в глобальную очередь
globrunqput(_p_, gp)
}

2. Глобальная очередь (Global Run Queue)

Общая очередь для всех P, защищена мьютексом:

var sched struct {
lock mutex
runq gQueue // глобальная очередь
runsize int32 // общее количество горутин
}

// Горутины попадают в глобальную очередь когда:
// 1. Локальная очередь P переполнена
// 2. Горутина создаётся впервые (иногда)
// 3. Горутина пробуждается из состояния ожидания

3. Очередь Network Poller

Горутины, ожидающие сетевых операций:

// Горутины блокируются на сетевых операциях:
// - net.Conn.Read/Write
// - time.Sleep (использует таймеры)
// - HTTP запросы

// Когда операция завершается:
// 1. Netpoller обнаруживает готовность
// 2. Горутина добавляется в глобальную очередь
// 3. Или в локальную очередь того P, который её ждал

Жизненный цикл горутины:

┌──────────┐ go statement ┌─────────────┐
│ Created │ ─────────────────→ │ Grunnable │
└──────────┘ └──────┬───────┘

Scheduled by P


┌─────────────┐
┌─── │ Grunning │
│ └──────┬───────┘
│ │
│ Channel/Syscall/Lock
│ │
│ ▼
│ ┌─────────────┐
│ │ Gwaiting │
│ └──────┬───────┘
│ │
│ Operation Complete
│ │
│ ▼
│ ┌─────────────┐
└────│ Grunnable │
└─────────────┘

Return/Panic


┌─────────────┐
│ Gdead │
└─────────────┘

Механизм Work Stealing:

Когда P заканчивает горутины в локальной очереди, он "крадёт" работу у других P:

func findrunnable() (gp *g, inheritTime bool) {
_p_ := getg().m.p.ptr()

top:
// 1. Проверить runnext (приоритетная горутина)
if gp := runqget(_p_); gp != nil {
return gp, false
}

// 2. Проверить локальную очередь
if gp := _p_.runnext; gp != nil {
_p_.runnext = 0
return gp, false
}

// 3. Проверить глобальную очередь (каждые 61 тик)
if gp := globrunqget(_p_, 0); gp != nil {
return gp, false
}

// 4. Проверить netpoller
if gp := netpoll(false); gp != nil {
return gp, false
}

// 5. Work Stealing: украсть у другого P
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
return gp, true
}
}
}

// 6. Если ничего не нашли — заблокировать M
stopm()
goto top
}

Алгоритм Work Stealing:

func runqsteal(_p_, p2 *p, stealRunNextG bool) *g {
// Крадём половину горутин из очереди жертвы
n := runqgrab(p2, &_p_.runq, _p_.runqtail, stealRunNextG)
if n > 0 {
gp := runqget(_p_)
return gp
}
return nil
}

Примеры работы планировщика:

// Пример 1: Горутины распределяются по локальным очередям
func main() {
runtime.GOMAXPROCS(2)

for i := 0; i < 10; i++ {
go func(id int) {
fmt.Printf("Goroutine %d running\n", id)
}(i)
}

time.Sleep(time.Second)
}

// Пример 2: Work Stealing в действии
func main() {
runtime.GOMAXPROCS(2)

// Создаём много горутин в одной
go func() {
for i := 0; i < 1000; i++ {
go func(id int) {
time.Sleep(10 * time.Millisecond)
}(i)
}
}()

time.Sleep(time.Second)
}

Состояния M (Machine):

const (
_Msleep = iota // M спит
_Mrunning // M выполняется
_Mdead // M завершён
)

Когда M блокируется:

// 1. Системный вызов (файловые операции, сеть без netpoller)
func blockedSyscall() {
// M блокируется до завершения syscall
data, _ := ioutil.ReadFile("file.txt")
}

// 2. Блокировка в runtime
func blockedLock() {
var mu sync.Mutex
mu.Lock()
// Долгая операция...
mu.Unlock()
}

Системные вызовы и P:

// Когда M выполняет блокирующий syscall:
func entersyscall() {
// 1. P отвязывается от M
// 2. P переходит в состояние Psyscall
// 3. M продолжает выполнение syscall
// 4. Другой M может подобрать P
}

func exitsyscall() {
// 1. Попытаться вернуть P
// 2. Если не удалось — горутина в глобальную очередь
// 3. M засыпает или ищет работу
}

Ключевые параметры:

// GOMAXPROCS — количество P (по умолчаNumCPU)
runtime.GOMAXPROCS(4)

// Максимальное количество M (потоков ОС)
// По умолчанию 10000
debug.SetMaxThreads(10000)

// Размер стека горутины
// Начальный: 2KB (с Go 1.4+)
// Максимальный: 1GB (с Go 1.20+)

Рекомендации:

  • Не создавайте миллионы горутин без необходимости
  • Используйте runtime.GOMAXPROCS() для настройки параллелизма
  • Для CPU-bound задач: GOMAXPROCS = NumCPU
  • Для I/O-bound задач: GOMAXPROCS может быть больше NumCPU
  • Профилируйте с помощью pprof для анализа работы планировщика
  • Используйте runtime.LockOSThread() только когда необходимо

Вопрос 14. Рассмотри код с WaitGroup. В каком порядке будут выведены значения? Почему?

Таймкод: 00:34:53

Ответ собеседника: Правильный. Порядок вывода: 2, 1, 3. Первая горутина засыпает на 2 секунды, затем запускается вторая горутина, которая ждёт завершения первой через WaitGroup.Wait, выводит 2, потом выводится 1, и только потом 3.

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

Рассмотрим типичный пример с WaitGroup:

package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup

// Горутина 1: засыпает на 2 секунды, затем выводит 1
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(2 * time.Second)
fmt.Println("1")
}()

// Горутина 2: ждёт завершения всех, затем выводит 2
wg.Add(1)
go func() {
defer wg.Done()
wg.Wait() // Блокируется, пока Done() не будет вызван дважды
fmt.Println("2")
}()

// Горутина 3: сразу выводит 3
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("3")
}()

wg.Wait() // Главная горутина ждёт завершения всех
}

Результат выполнения:

3
1
2

Почему такой порядок:

Анализ по шагам:

  1. wg.Add(1) — счётчик = 1
  2. Горутина 1 запускается, засыпает на 2 секунды
  3. wg.Add(1) — счётчик = 2
  4. Горутина 2 запускается, вызывает wg.Wait()блокируется
  5. wg.Add(1) — счётчик = 3
  6. Горутина 3 запускается, выводит "3", вызывает wg.Done() — счётчик = 2
  7. Главная горутина вызывает wg.Wait()блокируется (счётчик = 2)
  8. Проходит 2 секунды, горутина 1 выводит "1", вызывает wg.Done() — счётчик = 1
  9. Горутина 2 всё ещё ждёт (счётчик = 1)
  10. Горутина 3 уже завершена, но никто не вызывает wg.Done() для горутины 2
  11. Deadlock!

Ошибка в коде:

Горутина 2 вызывает wg.Wait() внутри самой себя, но wg.Done() вызывается после wg.Wait(). Это создаёт взаимную блокировку:

go func() {
defer wg.Done() // Выполнится после wg.Wait()
wg.Wait() // Блокируется, ждёт Done()
fmt.Println("2") // Никогда не выполнится
}()

Исправленный вариант:

package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup

// Горутина 1: засыпает на 2 секунды, затем выводит 1
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(2 * time.Second)
fmt.Println("1")
}()

// Горутина 2: ждёт завершения горутины 1, затем выводит 2
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(2 * time.Second + 100*time.Millisecond) // Ждём чуть дольше
fmt.Println("2")
}()

// Горутина 3: сразу выводит 3
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("3")
}()

wg.Wait() // Главная горутина ждёт завершения всех
fmt.Println("All done")
}

Результат:

3
1
2
All done

Правильное использование WaitGroup:

// Паттерн 1: Ожидание завершения группы горутин
func processItems(items []string) {
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1)
go func(i string) {
defer wg.Done()
process(i)
}(item)
}

wg.Wait() // Ждём завершения всех
}

// Паттерн 2: Ожидание с таймаутом
func processWithTimeout(items []string, timeout time.Duration) error {
var wg sync.WaitGroup
done := make(chan struct{})

for _, item := range items {
wg.Add(1)
go func(i string) {
defer wg.Done()
process(i)
}(item)
}

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

select {
case <-done:
return nil
case <-time.After(timeout):
return errors.New("timeout")
}
}

Распространённые ошибки с WaitGroup:

// Ошибка 1: Забыли wg.Add()
func bad1() {
var wg sync.WaitGroup
go func() {
defer wg.Done() // panic: negative WaitGroup counter
}()
wg.Wait()
}

// Ошибка 2: wg.Add() внутри горутины
func bad2() {
var wg sync.WaitGroup
go func() {
wg.Add(1) // Может не успеть выполниться до wg.Wait()
defer wg.Done()
}()
wg.Wait()
}

// Ошибка 3: Переиспользование WaitGroup без ожидания
func bad3() {
var wg sync.WaitGroup

// Первый цикл
wg.Add(1)
go func() { defer wg.Done() }()
wg.Wait()

// Второй цикл — OK, счётчик = 0
wg.Add(1)
go func() { defer wg.Done() }()
wg.Wait()
}

// Ошибка 4: Передача WaitGroup по значению
func bad4(wg sync.WaitGroup) { // Копирование!
wg.Add(1)
go func() {
defer wg.Done() // Работает с копией!
}()
}

// Правильно: передача по указателю
func good4(wg *sync.WaitGroup) {
wg.Add(1)
go func() {
defer wg.Done()
}()
}

Рекомендации:

  • Вызывайте wg.Add() до запуска горутины
  • Используйте defer wg.Done() для гарантированного вызова
  • Не передавайте WaitGroup по значению
  • Не вызывайте wg.Wait() внутри горутины, которая сама вызывает wg.Done()
  • Для сложных сценариев рассмотрите использование errgroup

Вопрос 15. Расскажи про каналы в Go. Что это, какие бывают? Что происходит при записи в nil-канал и закрытый канал? Как выйти из цикла чтения канала без default? Как работает select — в каком порядке выбираются кейсы?

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

Ответ собеседника: Неполный. Верно описал каналы как тип для обмена данными между горутинами, буферизированные и небуферизированные. Сначала ошибочно сказал, что паника при записи в nil-канал, но исправился — паника только при записи в закрытый канал, а nil-канал даёт бесконечную блокировку. Упомянул for range по каналу и закрытие канала как способы выхода из цикла. На вопрос про порядок select ответил правильно — случайный выбор при нескольких готовых кейсах, это политика разработчиков Go.

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

Каналы в Go — основы

Канал — это типизированный канал связи между горутинами, реализующий принцип CSP (Communicating Sequential Processes).

// Объявление канала
var ch chan int // nil-канал
ch = make(chan int) // небуферизированный канал
ch = make(chan int, 10) // буферизированный канал (ёмкость 10)

// Направленные каналы
var sendCh chan<- int // только отправка
var recvCh <-chan int // только получение

Типы каналов:

1. Небуферизированные каналы (unbuffered)

ch := make(chan int)

// Запись блокируется, пока кто-то не прочитает
go func() {
ch <- 42 // Блокируется до чтения
}()

// Чтение блокируется, пока кто-то не запишет
value := <-ch // Блокируется до записи

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

2. Буферизированные каналы (buffered)

ch := make(chan int, 3) // Буфер на 3 элемента

// Запись не блокируется, пока буфер не полон
ch <- 1 // OK
ch <- 2 // OK
ch <- 3 // OK
// ch <- 4 // Блокируется — буфер полон

// Чтение не блокируется, пока буфер не пуст
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
// fmt.Println(<-ch) // Блокируется — буфер пуст

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

type hchan struct {
qcount uint // количество элементов в буфере
dataqsiz uint // размер буера
buf unsafe.Pointer // кольцевой буфер
sendx uint // индекс для записи
recvx uint // индекс для чтения
recvq waitq // очередь ожидающих чтения
sendq waitq // очередь ожидающих записи
lock mutex // мьютекс для синхронизации
}

Поведение с nil-каналами и закрытыми каналами:

// Nil-канал
var ch chan int

// Запись в nil-канал — БЕСКОНЕЧНАЯ БЛОКИРОВКА (deadlock)
// ch <- 42 // Никогда не выполнится

// Чтение из nil-канала — БЕСКОНЕЧНАЯ БЛОКИРОВКА
// value := <-ch // Никогда не выполнится

// Закрытие nil-канала — PANIC
// close(ch) // panic: close of nil channel

// Закрытый канал
ch2 := make(chan int, 2)
ch2 <- 1
ch2 <- 2
close(ch2)

// Чтение из закрытого канала — OK, возвращает оставшиеся элементы
fmt.Println(<-ch2) // 1
fmt.Println(<-ch2) // 2
fmt.Println(<-ch2) // 0 (zero value), false

// Запись в закрытый канал — PANIC
// ch2 <- 3 // panic: send on closed channel

// Закрытие закрытого канала — PANIC
// close(ch2) // panic: close of closed channel

Сводная таблица:

ОперацияNil-каналОткрытый каналЗакрытый канал
ЗаписьБлокировкаЗапись/блокировкаPanic
ЧтениеБлокировкаЧтение/блокировкаZero value + false
ЗакрытиеPanicOKPanic

Способы выхода из цикла чтения канала:

1. for range — автоматический выход при закрытии

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

// Цикл завершится автоматически после чтения всех элементов
for value := range ch {
fmt.Println(value) // 1, 2, 3
}

2. Проверка второго возвращаемого значения

ch := make(chan int)

go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}()

for {
value, ok := <-ch
if !ok {
break // Канал закрыт — выходим
}
fmt.Println(value)
}

3. Использование select с контекстом

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

ch := make(chan int)

for {
select {
case value := <-ch:
fmt.Println(value)
case <-ctx.Done():
fmt.Println("Timeout or cancelled")
return
}
}

4. Сигнальный канал (done channel)

func worker(ch <-chan int, done <-chan struct{}) {
for {
select {
case value := <-ch:
fmt.Println(value)
case <-done:
fmt.Println("Worker stopped")
return
}
}
}

func main() {
ch := make(chan int)
done := make(chan struct{})

go worker(ch, done)

for i := 0; i < 5; i++ {
ch <- i
}

close(done) // Сигнал остановки
time.Sleep(100 * time.Millisecond)
}

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

select {
case v1 := <-ch1:
fmt.Println("ch1:", v1)
case v2 := <-ch2:
fmt.Println("ch2:", v2)
case ch3 <- 42:
fmt.Println("sent to ch3")
default:
fmt.Println("no channel ready")
}

Правила работы select:

1. Если ни один канал не готов:

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

2. Если несколько каналов готовы:

  • Выбирается случайный из готовных каналов
  • Это сделано для предотвращения starvation (голодания)

3. Порядок кейсов НЕ влияет на приоритет:

// Этот select НЕ будет всегда выбирать ch1 первым
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}

Пример случайного выбора:

func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)

ch1 <- 1
ch2 <- 2

counts := map[string]int{"ch1": 0, "ch2": 0}

for i := 0; i < 10000; i++ {
select {
case <-ch1:
counts["ch1"]++
ch1 <- 1 // Возвращаем обратно
case <-ch2:
counts["ch2"]++
ch2 <- 2 // Возвращаем обратно
}
}

fmt.Println(counts)
// Примерно: map[ch1:5000 ch2:5000]
}

Приоритетный select (обход случайного выбора):

// Если нужен приоритет — используйте вложенный select
func prioritySelect(highCh, lowCh <-chan int) {
for {
select {
case v := <-highCh:
fmt.Println("high:", v)
default:
select {
case v := <-highCh:
fmt.Println("high:", v)
case v := <-lowCh:
fmt.Println("low:", v)
}
}
}
}

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

1. Fan-out (рассылка):

func fanOut(input <-chan int, outputs []chan int) {
for value := range input {
for _, out := range outputs {
out <- value
}
}
for _, out := range outputs {
close(out)
}
}

2. Fan-in (слияние):

func fanIn(inputs ...<-chan int) <-chan int {
result := make(chan int)
var wg sync.WaitGroup

for _, input := range inputs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for value := range ch {
result <- value
}
}(input)
}

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

return result
}

3. Pipeline (конвейер):

func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}

func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}

func main() {
// Конвейер: генерация → возведение в квадрат → вывод
c := generate(2, 3, 4, 5)
out := square(square(c))
for n := range out {
fmt.Println(n) // 16, 81, 256, 625
}
}

Рекомендации:

  • Закрывайте каналы только отправителем
  • Используйте for range для чтения до закрытия
  • Не закрывайте канал, если есть несколько отправителей (используйте sync.Once)
  • Для сигнализации используйте chan struct{} вместо chan bool
  • Помните о случайном порядке в select при нескольких готовых кейсах

Вопрос 16. Напиши SQL-запрос: есть таблицы managers, cars, deals. Нужно вывести по каждому менеджеру сумму всех его сделок.

Таймкод: 00:46:24

Ответ собеседника: Неполный. Начал писать запрос правильно: SELECT с JOIN managers и deals по manager_id, использовать SUM(price). Однако затруднился с синтаксисом LEFT JOIN, не вспомнил точные имена таблиц и столбцов, не добавил GROUP BY. В целом идея верная, но запрос не был завершён корректно. Кандидат честно признал, что редко работает с SQL.

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

Схема данных:

-- Таблица менеджеров
CREATE TABLE managers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100)
);

-- Таблица автомобилей
CREATE TABLE cars (
id SERIAL PRIMARY KEY,
make VARCHAR(50) NOT NULL,
model VARCHAR(50) NOT NULL,
year INT,
price DECIMAL(12, 2)
);

-- Таблица сделок
CREATE TABLE deals (
id SERIAL PRIMARY KEY,
manager_id INT REFERENCES managers(id),
car_id INT REFERENCES cars(id),
deal_date DATE NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) DEFAULT 'completed'
);

Базовый запрос — сумма сделок по менеджерам:

SELECT
m.id AS manager_id,
m.name AS manager_name,
COALESCE(SUM(d.amount), 0) AS total_deals_amount
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
GROUP BY m.id, m.name
ORDER BY total_deals_amount DESC;

Разбор запроса:

  • LEFT JOIN — включает всех менеджеров, даже без сделок
  • COALESCE(SUM(d.amount), 0) — заменяет NULL на 0 для менеджеров без сделок
  • GROUP BY m.id, m.name — группировка по менеджеру
  • ORDER BY total_deals_amount DESC — сортировка по убыванию суммы

Варианты запросов в зависимости от требований:

1. Только менеджеры со сделками (INNER JOIN):

SELECT
m.id AS manager_id,
m.name AS manager_name,
SUM(d.amount) AS total_deals_amount,
COUNT(d.id) AS deals_count
FROM managers m
INNER JOIN deals d ON m.id = d.manager_id
GROUP BY m.id, m.name
ORDER BY total_deals_amount DESC;

2. С фильтрацией по статусу сделок:

SELECT
m.id AS manager_id,
m.name AS manager_name,
COALESCE(SUM(d.amount), 0) AS total_completed_amount,
COUNT(d.id) AS completed_deals_count
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id AND d.status = 'completed'
GROUP BY m.id, m.name
ORDER BY total_completed_amount DESC;

3. С детализацией по автомобилям:

SELECT
m.id AS manager_id,
m.name AS manager_name,
c.make,
c.model,
SUM(d.amount) AS total_amount,
COUNT(d.id) AS deals_count
FROM managers m
JOIN deals d ON m.id = d.manager_id
JOIN cars c ON d.car_id = c.id
GROUP BY m.id, m.name, c.make, c.model
ORDER BY m.name, total_amount DESC;

4. С фильтрацией по дате:

SELECT
m.id AS manager_id,
m.name AS manager_name,
COALESCE(SUM(d.amount), 0) AS total_amount
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
AND d.deal_date >= '2024-01-01'
AND d.deal_date < '2025-01-01'
GROUP BY m.id, m.name
ORDER BY total_amount DESC;

5. С ранжированием менеджеров:

SELECT
m.id AS manager_id,
m.name AS manager_name,
COALESCE(SUM(d.amount), 0) AS total_amount,
COUNT(d.id) AS deals_count,
RANK() OVER (ORDER BY COALESCE(SUM(d.amount), 0) DESC) AS rank
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
GROUP BY m.id, m.name
ORDER BY total_amount DESC;

6. Сравнение с средним по компании:

WITH manager_totals AS (
SELECT
m.id,
m.name,
COALESCE(SUM(d.amount), 0) AS total_amount
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
GROUP BY m.id, m.name
),
company_avg AS (
SELECT AVG(total_amount) AS avg_amount
FROM manager_totals
)
SELECT
mt.name,
mt.total_amount,
ca.avg_amount,
mt.total_amount - ca.avg_amount AS difference,
CASE
WHEN mt.total_amount >= ca.avg_amount THEN 'Above Average'
ELSE 'Below Average'
END AS performance
FROM manager_totals mt
CROSS JOIN company_avg ca
ORDER BY mt.total_amount DESC;

Пример результата:

manager_id | manager_name | total_deals_amount
-----------+--------------+-------------------
1 | Alice Smith | 150000.00
3 | Bob Johnson | 120000.00
2 | Carol White | 95000.00
4 | Dave Brown | 0.00

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

-- Ошибка 1: Забыли GROUP BY
SELECT m.name, SUM(d.amount) -- Ошибка!
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id;

-- Ошибка 2: GROUP BY без всех неагрегированных столбцов
SELECT m.id, m.name, SUM(d.amount)
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
GROUP BY m.id; -- Ошибка: name не в GROUP BY

-- Ошибка 3: WHERE вместо ON для фильтрации JOIN
SELECT m.name, SUM(d.amount)
FROM managers m
LEFT JOIN deals d ON m.id = d.manager_id
WHERE d.status = 'completed' -- Превращает LEFT JOIN в INNER JOIN!
GROUP BY m.id, m.name;

Рекомендации:

  • Используйте LEFT JOIN если нужны все менеджеры, включая без сделок
  • Используйте INNER JOIN если нужны только менеджеры со сделками
  • Всегда включайте все неагрегированные столбцы в GROUP BY
  • Используйте COALESCE для обработки NULL при агрегации
  • Фильтруйте в ON для JOIN, а не в WHERE, если не хотите потерять строки

Вопрос 17. Есть монолитный сервис с лентой мемов, веб-интерфейс и мобильное приложение. Как спроектировать API-запрос для бесконечной ленты с пагинацией?

Таймкод: 00:51:34

Ответ собеседника: Правильный. Предложил курсорную пагинацию: запрос с параметрами size (количество элементов) и token (курсор, содержащий ID или timestamp последнего элемента). В ответ возвращается набор мемов и следующий токен. Если токен пустой — данных больше нет. Правильно отказался от limit/offset из-за проблем с дубликатами при частых обновлениях ленты.

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

Курсорная пагинация для бесконечной ленты

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

API дизайн:

GET /api/v1/memes?cursor={cursor}&limit={limit}&sort={sort}

Параметры запроса:

ПараметрТипОбязательныйОписание
cursorstringнетКурсор для следующей страницы (пустой для первой)
limitintнетКоличество элементов (по умолчанию 20, максимум 100)
sortstringnoСортировка: created_at, popularity, trending

Примеры запросов:

# Первая страница
GET /api/v1/memes?limit=20&sort=created_at

# Следующая страница
GET /api/v1/memes?cursor=eyJpZCI6MTIzNCwiY3JlYXRlZF9hdCI6IjIwMjQtMDEtMTVUMTA6MDA6MDBaIn0&limit=20

# Сортировка по популярности
GET /api/v1/memes?limit=20&sort=popularity

Структура ответа:

{
"data": [
{
"id": "meme_1234",
"title": "Funny Cat",
"image_url": "https://cdn.example.com/memes/1234.jpg",
"author": {
"id": "user_567",
"username": "catlover"
},
"stats": {
"likes": 1520,
"comments": 89,
"shares": 45
},
"created_at": "2024-01-15T10:00:00Z",
"tags": ["cats", "funny", "animals"]
}
],
"pagination": {
"next_cursor": "eyJpZCI6MTI1NSwiY3JlYXRlZF9hdCI6IjIwMjQtMDEtMTVUMDk6NTU6MDBaIn0",
"has_more": true,
"total_count": null
},
"meta": {
"request_id": "req_abc123",
"response_time_ms": 45
}

Реализация курсора:

package pagination

import (
"encoding/base64"
"encoding/json"
"time"
)

type Cursor struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Score float64 `json:"score,omitempty"` // для сортировки по популярности
}

func EncodeCursor(c Cursor) string {
data, _ := json.Marshal(c)
return base64.URLEncoding.EncodeToString(data)
}

func DecodeCursor(encoded string) (*Cursor, error) {
data, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}

var c Cursor
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}

return &c, nil
}

Реализация сервиса:

package service

import (
"context"
"database/sql"
"fmt"
)

type MemeService struct {
db *sql.DB
}

type ListMemesParams struct {
Cursor *pagination.Cursor
Limit int
Sort string
}

type ListMemesResult struct {
Memes []Meme
NextCursor string
HasMore bool
}

func (s *MemeService) ListMemes(ctx context.Context, params ListMemesParams) (*ListMemesResult, error) {
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}

// Запрашиваем на 1 элемент больше, чтобы определить hasMore
limit := params.Limit + 1

var memes []Meme
var err error

switch params.Sort {
case "popularity":
memes, err = s.listByPopularity(ctx, params.Cursor, limit)
case "trending":
memes, err = s.listByTrending(ctx, params.Cursor, limit)
default:
memes, err = s.listByCreatedAt(ctx, params.Cursor, limit)
}

if err != nil {
return nil, err
}

result := &ListMemesResult{
HasMore: len(memes) > params.Limit,
}

if result.HasMore {
memes = memes[:params.Limit]
lastMeme := memes[len(memes)-1]
result.NextCursor = pagination.EncodeCursor(pagination.Cursor{
ID: lastMeme.ID,
CreatedAt: lastMeme.CreatedAt,
})
}

result.Memes = memes
return result, nil
}

func (s *MemeService) listByCreatedAt(ctx context.Context, cursor *pagination.Cursor, limit int) ([]Meme, error) {
query := `
SELECT id, title, image_url, author_id, likes_count, comments_count, created_at
FROM memes
WHERE ($1::bigint IS NULL OR (created_at, id) < ($2::timestamptz, $1::bigint))
ORDER BY created_at DESC, id DESC
LIMIT $3
`

var cursorID *int64
var cursorTime *time.Time
if cursor != nil {
cursorID = &cursor.ID
cursorTime = &cursor.CreatedAt
}

rows, err := s.db.QueryContext(ctx, query, cursorID, cursorTime, limit)
if err != nil {
return nil, err
}
defer rows.Close()

var memes []Meme
for rows.Next() {
var m Meme
err := rows.Scan(&m.ID, &m.Title, &m.ImageURL, &m.AuthorID, &m.LikesCount, &m.CommentsCount, &m.CreatedAt)
if err != nil {
return nil, err
}
memes = append(memes, m)
}

return memes, nil
}

SQL индексы для производительности:

-- Для сортировки по дате создания
CREATE INDEX idx_memes_created_at_id ON memes (created_at DESC, id DESC);

-- Для сортировки по популярности
CREATE INDEX idx_memes_popularity ON memes (likes_count DESC, created_at DESC, id DESC);

-- Для trending (можно использовать материализованное представление)
CREATE INDEX idx_memes_trending ON memes (
(likes_count * 0.5 + comments_count * 0.3 + shares_count * 0.2) DESC,
created_at DESC
);

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

ПодходПлюсыМинусыКогда использовать
Offset/LimitПростота, можно перейти на любую страницуДубликаты, пропуски, деградация на больших offsetАдминки, отчёты
CursorСтабильность, производительностьНельзя перейти на произвольную страницуЛенты, бесконечный скролл
SeekМаксимальная производительностьСложность реализацииВысоконагруженные системы

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

func (s *MemeService) ListMemes(ctx context.Context, params ListMemesParams) (*ListMemesResult, error) {
// Валидация параметров
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
return nil, fmt.Errorf("limit cannot exceed 100")
}

// Валидация курсора
if params.Cursor != nil {
if params.Cursor.ID <= 0 {
return nil, fmt.Errorf("invalid cursor")
}
}

// ... основная логика ...
}

Кэширование и оптимизации:

// Кэширование первой страницы (самой запрашиваемой)
func (s *MemeService) ListMemesCached(ctx context.Context, params ListMemesParams) (*ListMemesResult, error) {
// Кэшируем только первую страницу
if params.Cursor == nil {
cacheKey := fmt.Sprintf("memes:first:%s:%d", params.Sort, params.Limit)

if cached, err := s.cache.Get(ctx, cacheKey); err == nil {
return cached.(*ListMemesResult), nil
}
}
return nil, nil
}

Вопрос 18. Как спроектировать авторизацию для сервиса? Какие токены использовать, где их хранить?

Таймкод: 00:58:23

Ответ собеседника: Правильный. Предложил использовать JWT-токены: access token (короткоживущий) и refresh token (долгоживущий). Пользователь авторизуется по логину/паролю, получает оба токена. Access token используется для доступа к сервису, при его истечении — refresh token используется для получения нового access token. Пароль хранится в базе с солью и хешем. Refresh токены хранятся в кеше на сервере. Клиент хранит токены в памяти браузера/приложения.

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

Архитектура авторизации с JWT

Общая схема:

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ Auth Service│ │ API Service │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ POST /auth/login │ │
│ {email, password} │ │
│──────────────────────>│ │
│ │ │
│ {access_token, │ │
│ refresh_token, │ │
│ expires_in} │ │
│<──────────────────────│ │
│ │ │
│ GET /api/resource │ │
│ Authorization: Bearer access_token │
│──────────────────────────────────────────────>│
│ │ │
│ 200 OK + data │ │
│<──────────────────────────────────────────────│
│ │ │
│ POST /auth/refresh │ │
│ {refresh_token} │ │
│──────────────────────>│ │
│ │ │
│ {access_token, │ │
│ refresh_token} │ │
│<──────────────────────│ │

Структура токенов:

Access Token (JWT):

{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-2024-01"
},
"payload": {
"sub": "user_123456",
"email": "user@example.com",
"roles": ["user", "premium"],
"permissions": ["read:memes", "write:memes"],
"iat": 1705312800,
"exp": 1705316400,
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"jti": "unique-token-id-123"
}
}

Refresh Token:

{
"token_id": "rt_abc123def456",
"user_id": "user_123456",
"family_id": "family_xyz789",
"issued_at": "2024-01-15T10:00:00Z",
"expires_at": "2024-02-15T10:00:00Z",
"device_id": "device_iphone_abc",
"ip_address": "192.168.1.1",
"is_revoked": false
}

Реализация на Go:

package auth

import (
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"time"

"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)

type TokenService struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
refreshStore RefreshTokenStore
}

type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}

type AccessTokenClaims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
jwt.RegisteredClaims
}

type RefreshToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
FamilyID string `json:"family_id"`
DeviceID string `json:"device_id"`
IPAddress string `json:"ip_address"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
IsRevoked bool `json:"is_revoked"`
}

func (s *TokenService) GenerateTokenPair(user *User, deviceID, ip string) (*TokenPair, error) {
// Генерация access token
accessToken, err := s.generateAccessToken(user)
if err != nil {
return nil, err
}

// Генерация refresh token
refreshToken, err := s.generateRefreshToken(user, deviceID, ip)
if err != nil {
return nil, err
}

return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken.ID,
ExpiresIn: 3600, // 1 час
TokenType: "Bearer",
}, nil
}

func (s *TokenService) generateAccessToken(user *User) (string, error) {
now := time.Now()

claims := AccessTokenClaims{
UserID: user.ID,
Email: user.Email,
Roles: user.Roles,
Permissions: user.Permissions,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: "https://auth.example.com",
Audience: jwt.ClaimStrings{"https://api.example.com"},
ID: generateTokenID(),
},
}

token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "key-2024-01"

return token.SignedString(s.privateKey)
}

func (s *TokenService) generateRefreshToken(user *User, deviceID, ip string) (*RefreshToken, error) {
token := &RefreshToken{
ID: generateSecureToken(),
UserID: user.ID,
FamilyID: generateTokenID(),
DeviceID: deviceID,
IPAddress: ip,
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30 дней
}

if err := s.refreshStore.Save(token); err != nil {
return nil, err
}

return token, nil
}

func (s *TokenService) RefreshTokens(refreshTokenID, deviceID, ip string) (*TokenPair, error) {
// Валидация refresh token
storedToken, err := s.refreshStore.Get(refreshTokenID)
if err != nil {
return nil, ErrInvalidRefreshToken
}

// Проверка на повторное использование (token rotation)
if storedToken.IsRevoked {
// Отзыв всей семьи токенов (возможная кража)
s.refreshStore.RevokeFamily(storedToken.FamilyID)
return nil, ErrTokenReuseDetected
}

// Проверка срока действия
if time.Now().After(storedToken.ExpiresAt) {
return nil, ErrRefreshTokenExpired
}

// Отзыв старого refresh token
s.refreshStore.Revoke(storedToken.ID)

// Получение пользователя
user, err := s.getUserByID(storedToken.UserID)
if err != nil {
return nil, err
}

// Генерация новой пары токенов
return s.GenerateTokenPair(user, deviceID, ip)
}

func (s *TokenService) ValidateAccessToken(tokenString string) (*AccessTokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.publicKey, nil
})

if err != nil {
return nil, err
}

if claims, ok := token.Claims.(*AccessTokenClaims); ok && token.Valid {
return claims, nil
}

return nil, ErrInvalidToken
}

Хранение паролей:

package auth

import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"

"golang.org/x/crypto/argon2"
)

type PasswordConfig struct {
Time uint32
Memory uint32
Threads uint8
KeyLen uint32
SaltLen uint32
}

var DefaultConfig = &PasswordConfig{
Time: 3,
Memory: 64 * 1024,
Threads: 4,
KeyLen: 32,
SaltLen: 16,
}

func HashPassword(password string) (string, error) {
salt := make([]byte, DefaultConfig.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}

hash := argon2.IDKey(
[]byte(password),
salt,
DefaultConfig.Time,
DefaultConfig.Memory,
DefaultConfig.Threads,
DefaultConfig.KeyLen,
)

// Формат: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
encodedHash := fmt.Sprintf(
"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
DefaultConfig.Memory,
DefaultConfig.Time,
DefaultConfig.Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)

return encodedHash, nil
}

func VerifyPassword(password, encodedHash string) (bool, error) {
// Парсинг параметров из хеша
var version int
var memory, time uint32
var threads uint8
var salt, hash []byte

_, err := fmt.Sscanf(encodedHash,
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
&version, &memory, &time, &threads, &salt, &hash)
if err != nil {
return false, err
}

saltBytes, _ := base64.RawStdEncoding.DecodeString(string(salt))
hashBytes, _ := base64.RawStdEncoding.DecodeString(string(hash))

// Вычисление хеша для проверки
otherHash := argon2.IDKey([]byte(password), saltBytes, time, memory, threads, uint32(len(hashBytes)))

// Константное время сравнения (защита от timing-атак)
if subtle.ConstantTimeCompare(hashBytes, otherHash) == 1 {
return true, nil
}

return false, nil
}

Хранение токенов на клиенте:

// Веб-приложение (браузер)
class TokenManager {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}

// Сохранение токенов
setTokens(accessToken, refreshToken, expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenExpiry = Date.now() + expiresIn * 1000;

// Refresh token в httpOnly cookie (безопаснее)
// Access token в памяти (не в localStorage!)
}

// Получение access token
getAccessToken() {
if (this.isTokenExpired()) {
return this.refreshAccessToken();
}
return this.accessToken;
}

// Проверка истечения
isTokenExpired() {
return Date.now() >= this.tokenExpiry - 60000; // Зас до истечения
}

// Обновление токена
async refreshAccessToken() {
try {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // Отправка httpOnly cookie
headers: {
'Content-Type': 'application/json'
}
});

if (!response.ok) {
this.clearTokens();
throw new Error('Refresh failed');
}

const data = await response.json();
this.setTokens(data.access_token, data.refresh_token, data.expires_in);
return data.access_token;
} catch (error) {
this.clearTokens();
window.location.href = '/login';
throw error;
}
}

clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}
}

Middleware для проверки авторизации:

package middleware

import (
"context"
"net/http"
"strings"

"yourapp/auth"
)

type contextKey string

const UserContextKey contextKey = "user"

func AuthMiddleware(tokenService *auth.TokenService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Извлечение токена из заголовка
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}

parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
return
}

tokenString := parts[1]

// Валидация токена
claims, err := tokenService.ValidateAccessToken(tokenString)
if err != nil {
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}

// Добавление информации о пользователе в контекст
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

func RequirePermission(permission string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*auth.AccessTokenClaims)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

if !hasPermission(claims.Permissions, permission) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

next.ServeHTTP(w, r)
})
}
}

Хранение refresh tokens на сервере:

-- Таблица refresh tokens
CREATE TABLE refresh_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
family_id VARCHAR(36) NOT NULL,
device_id VARCHAR(64),
ip_address INET,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
replaced_by VARCHAR(64),

CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_replaced_by FOREIGN KEY (replaced_by) REFERENCES refresh_tokens(id)
);

CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at)
WHERE NOT is_revoked;

Рекомендации по безопасности:

  • Access token: 15-60 минут
  • Refresh token: 7-30 дней
  • Используйте token rotation (каждый refresh выдаёт новую пару)
  • Храните refresh tokens в базе данных с возможностью отзыва
  • Access token — только в памяти клиента (не в localStorage)
  • Используйте httpOnly cookies для refresh tokens
  • Реализуйте rate limiting для endpoints авторизации
  • Логируйте все операции с токенами

Вопрос 19. Есть старый PostgreSQL, база разрастается, много пользователей, маркетинг готовит большую кампанию. Ты единственный разработчик — что будешь делать для масштабирования?

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

Ответ собеседника: Неполный. Предложил партиционирование базы данных (разбиение на партиции по времени, например по 2 месяца) и репликацию (мастер-слейв с асинхронными репликами для чтения). Правильно указал, что асинхронные реплики допустимы, так как небольшая задержка в показе мемов не критична. Однако не упомянул индексацию, кеширование, шардирование как дополнительные стратегии.

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

Комплексная стратегия масштабирования PostgreSQL

1. Анализ текущего состояния (первый шаг)

-- Анализ размера таблиц
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size,
n_live_tup as row_count,
n_dead_tup as dead_rows,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

-- Анализ медленных запросов
SELECT
query,
calls,
mean_exec_time,
total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

-- Анализ использования индексов
SELECT
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;

2. Оптимизация индексов (быстрый результат)

-- Создание недостающих индексов
CREATE INDEX CONCURRENTLY idx_memes_created_at ON memes(created_at DESC);
CREATE INDEX CONCURRENTLY idx_memes_author_id ON memes(author_id);
CREATE INDEX CONCURRENTLY idx_memes_status_created ON memes(status, created_at DESC)
WHERE status = 'active';

-- Составной индекс для частых запросов
CREATE INDEX CONCURRENTLY idx_memes_feed ON memes(created_at DESC, id DESC)
INCLUDE (title, image_url, author_id)
WHERE status = 'active';

-- Частичный индекс для популярных запросов
CREATE INDEX CONCURRENTLY idx_users_active ON users(id)
WHERE is_active = true;

-- Удаление неиспользуемых индексов
DROP INDEX CONCURRENTLY IF EXISTS idx_unused;

3. Партиционирование таблиц

-- Партиционирование по времени (для таблицы мемов)
CREATE TABLE memes (
id BIGSERIAL,
title VARCHAR(255) NOT NULL,
image_url VARCHAR(500) NOT NULL,
author_id VARCHAR(36) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'active',
likes_count INT DEFAULT 0,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Создание партиций
CREATE TABLE memes_2024_q1 PARTITION OF memes
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE memes_2024_q2 PARTITION OF memes
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
CREATE TABLE memes_2024_q3 PARTITION OF memes
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');
CREATE TABLE memes_2024_q4 PARTITION OF memes
FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');

-- Автоматическое создание партиций
CREATE OR REPLACE FUNCTION create_memes_partition()
RETURNS TRIGGER AS $$
DECLARE
partition_date DATE;
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_date := date_trunc('quarter', NEW.created_at);
partition_name := 'memes_' || to_char(partition_date, 'YYYY') || '_q' ||
EXTRACT(QUARTER FROM partition_date);
start_date := partition_date;
end_date := partition_date + INTERVAL '3 months';

IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = partition_name) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF memes FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END IF;

RETURN NEW;
END;
$$ LANGUAGE plpgsql;

4. Настройка репликации

-- Настройка мастера (postgresql.conf)
-- wal_level = replica
-- max_wal_senders = 10
-- wal_keep_size = 1GB

-- Создание пользователя для репликации
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'secure_password';

-- Настройка реплики (postgresql.conf)
-- hot_standby = on
-- primary_conninfo = 'host=master_host port=5432 user=replicator password=secure_password'

-- Проверка статуса репликации на мастере
SELECT * FROM pg_stat_replication;

-- Проверка на реплике
SELECT * FROM pg_stat_wal_receiver;

5. Маршрутизация запросов (Go)

package database

import (
"context"
"database/sql"
"sync"
)

type DBCluster struct {
master *sql.DB
replicas []*sql.DB
mu sync.RWMutex
current int
}

func NewDBCluster(masterConn string, replicaConns []string) (*DBCluster, error) {
master, err := sql.Open("postgres", masterConn)
if err != nil {
return nil, err
}

cluster := &DBCluster{
master: master,
replicas: make([]*sql.DB, len(replicaConns)),
}

for i, conn := range replicaConns {
db, err := sql.Open("postgres", conn)
if err != nil {
return nil, err
}
cluster.replicas[i] = db
}

return cluster, nil
}

// Master для записи
func (c *DBCluster) Master() *sql.DB {
return c.master
}

// Replica для чтения (round-robin)
func (c *DBCluster) Replica() *sql.DB {
c.mu.Lock()
defer c.mu.Unlock()

if len(c.replicas) == 0 {
return c.master
}

replica := c.replicas[c.current]
c.current = (c.current + 1) % len(c.replicas)
return replica
}

// Пример использования
type MemeService struct {
db *DBCluster
}

func (s *MemeService) GetMeme(ctx context.Context, id int64) (*Meme, error) {
// Чтение из реплики
db := s.db.Replica()

var meme Meme
err := db.QueryRowContext(ctx,
"SELECT id, title, image_url, author_id FROM memes WHERE id = $1",
id,
).Scan(&meme.ID, &meme.Title, &meme.ImageURL, &meme.AuthorID)

return &meme, err
}

func (s *MemeService) CreateMeme(ctx context.Context, meme *Meme) error {
// Запись в мастер
db := s.db.Master()

_, err := db.ExecContext(ctx,
"INSERT INTO memes (title, image_url, author_id) VALUES ($1, $2, $3)",
meme.Title, meme.ImageURL, meme.AuthorID,
)

return err
}

6. Кэширование с Redis

package cache

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/redis/go-redis/v9"
)

type CacheService struct {
redis *redis.Client
ttl time.Duration
}

func NewCacheService(redisAddr string) *CacheService {
return &CacheService{
redis: redis.NewClient(&redis.Options{
Addr: redisAddr,
PoolSize: 100,
}),
ttl: 5 * time.Minute,
}
}

func (c *CacheService) GetFeed(ctx context.Context, page int) ([]Meme, error) {
key := fmt.Sprintf("feed:page:%d", page)

data, err := c.redis.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil // Cache miss
}
if err != nil {
return nil, err
}

var memes []Meme
if err := json.Unmarshal(data, &memes); err != nil {
return nil, err
}

return memes, nil
}

func (c *CacheService) SetFeed(ctx context.Context, page int, memes []Meme) error {
key := fmt.Sprintf("feed:page:%d", page)

data, err := json.Marshal(memes)
if err != nil {
return err
}

return c.redis.Set(ctx, key, data, c.ttl).Err()
}

func (c *CacheService) InvalidateFeed(ctx context.Context) error {
iter := c.redis.Scan(ctx, 0, "feed:*", 0).Iterator()
for iter.Next(ctx) {
c.redis.Del(ctx, iter.Val())
}
return iter.Err()
}

7. Очереди для фоновых задач

package worker

import (
"context"
"encoding/json"

"github.com/hibiken/asynq"
)

const (
TypeProcessImage = "image:process"
TypeSendEmail = "email:send"
TypeUpdateStats = "stats:update"
)

type TaskClient struct {
client *asynq.Client
}

func NewTaskClient(redisAddr string) *TaskClient {
return &TaskClient{
client: asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr}),
}
}

func (tc *TaskClient) EnqueueImageProcessing(ctx context.Context, memeID int64) error {
payload, _ := json.Marshal(map[string]int64{"meme_id": memeID})
task := asynq.NewTask(TypeProcessImage, payload)
_, err := tc.client.EnqueueContext(ctx, task)
return err
}

// Worker для обработки задач
type TaskServer struct {
server *asynq.Server
mux *asynq.ServeMux
}

func NewTaskServer(redisAddr string) *TaskServer {
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: 10,
Queues: map[string]int{
"critical": 6,
"default": 3,
"low": 1,
},
},
)

mux := asynq.NewServeMux()

return &TaskServer{
server: srv,
mux: mux,
}
}

func (ts *TaskServer) RegisterHandler(taskType string, handler func(context.Context, *asynq.Task) error) {
ts.mux.HandleFunc(taskType, handler)
}

func (ts *TaskServer) Start() error {
return ts.server.Run(ts.mux)
}

8. Мониторинг и алертинг

-- Представление для мониторинга
CREATE VIEW db_stats AS
SELECT
(SELECT count(*) FROM pg_stat_activity) as active_connections,
(SELECT count(*) FROM pg_stat_activity WHERE state = 'idle') as idle_connections,
(SELECT count(*) FROM pg_stat_activity WHERE wait_event_type IS NOT NULL) as waiting_connections,
(SELECT pg_database_size(current_database())) as database_size,
(SELECT count(*) FROM pg_stat_user_tables) as table_count;

-- Мониторинг репликации
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
write_lag,
flush_lag,
replay_lag
FROM pg_stat_replication;

9. Оптимизация конфигурации PostgreSQL

# postgresql.conf

# Память
shared_buffers = 4GB # 25% от RAM
effective_cache_size = 12GB # 75% от RAM
work_mem = 64MB # Для сложных запросов
maintenance_work_mem = 1GB # Для VACUUM, CREATE INDEX

# WAL
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB
checkpoint_completion_target = 0.9

# Параллелизм
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_parallel_maintenance_workers = 4

# Планировщик запросов
random_page_cost = 1.1 # Для SSD
effective_io_concurrency = 200 # Для SSD

# Логирование медленных запросов
log_min_duration_statement = 1000 # Логировать запросы > 1 сек
shared_preload_libraries = 'pg_stat_statements'

10. План действий перед кампанией

Неделя 1-2: Подготовка
├── Аудит медленных запросов
├── Создание недостающих индексов
├── Настройка партиционирования
└── Тестирование на нагрузке

Неделя 3-4: Инфраструктура
├── Настройка репликации
├── Внедрение Redis для кэширования
├── Настройка мониторинга (Prometheus + Grafana)
└── Настройка алертинг

Неделя 5-6: Оптимизация
├── Внедрение очередей для фоновых задач
├── Оптимизация конфигурации PostgreSQL
├── Нагрузочное тестирование
└── Подготовка плана отката

День кампании:
├── Мониторинг в реальном времени
├── Готовность масштабировать реплики
└── Команда на связи

Рекомендации:

  • Начните с индексов и оптимизации запросов (быстрый результат)
  • Партиционирование для больших таблиц
  • Репликация для распределения нагрузки чтения
  • Кэширование для горячих данных
  • Очереди для фоновых задач
  • Мониторинг для раннего обнаружения проблем
  • Нагрузочное тестирование перед кампанией

Вопрос 20. Как бы ты подошёл к обновлению PostgreSQL на последнюю версию в продакшене без даунтайма?

Таймкод: 01:08:05

Ответ собеседника: Неполный. Предложил сделать дамп старой базы, развернуть новую версию PostgreSQL, залить дамп и переключиться на новую базу. Честно признал, что никогда этого этого не делал и не вникал в детали. Не упомянул специализированные инструменты для миграций (pg_upgrade, logical replication), не раскрыл стратегию zero-downtime обновления с двойной записью или blue-green deployment.

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

Стратегии обновления PostgreSQL без даунтайма

1. pg_upgrade — быстрое обновление (минимальный даунтайм)

pg_upgrade позволяет обновить PostgreSQL без полного дампа/восстановления.

# Установка новой версии PostgreSQL
apt install postgresql-16

# Инициализация нового кластера
pg_createcluster 16 main --start

# Остановка обоих кластеров
pg_ctlcluster 15 main stop
pg_ctlcluster 16 main stop

# Проверка совместимости
pg_upgrade \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--check

# Выполнение обновления
pg_upgrade \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--link # Используем hardlinks для скорости

# Запуск нового кластера
pg_ctlcluster 16 main start

Плюсы pg_upgrade:

  • Быстрое обновление (минуты вместо часов)
  • Не требует двойного дискового пространства (с --link)
  • Сохраняет статистику и настройки

Минусы:

  • Требует кратковременной остановки (секунды-минуты)
  • Нельзя откатить без бэкапа

2. Logical Replication — true zero-downtime

Логическая репликация позволяет синхронизировать данные между разными версиями PostgreSQL.

-- На старом сервере (publisher)
-- postgresql.conf
-- wal_level = logical
-- max_replication_slots = 10
-- max_logical_replication_workers = 4

-- Создание публикации
CREATE PUBLICATION migration_pub FOR ALL TABLES;

-- Создание слота репликации
SELECT * FROM pg_create_logical_replication_slot(
'migration_slot',
'pgoutput'
);
-- На новом сервере (subscriber)
-- Создание подписки
CREATE SUBSCRIPTION migration_sub
CONNECTION 'host=old_server dbname=mydb user=replicator password=***'
PUBLICATION migration_pub
WITH (copy_data = true, create_slot = false, slot_name = 'migration_slot');

Скрипт переключения:

package migration

import (
"context"
"database/sql"
"fmt"
"time"
)

type MigrationManager struct {
oldDB *sql.DB
newDB *sql.DB
}

func (m *MigrationManager) VerifyReplicationLag(ctx context.Context) error {
var lag time.Duration

err := m.newDB.QueryRowContext(ctx, `
SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int
`).Scan(&lag)

if err != nil {
return err
}

if lag > 5*time.Second {
return fmt.Errorf("replication lag too high: %v", lag)
}

return nil
}

func (m *MigrationManager) SwitchTraffic(ctx context.Context) error {
// 1. Убедиться, что репликация синхронизирована
if err := m.VerifyReplicationLag(ctx); err != nil {
return fmt.Errorf("replication not in sync: %w", err)
}

// 2. Переключить приложение на read-only режим
if err := m.setReadOnlyMode(ctx, true); err != nil {
return err
}

// 3. Дождаться завершения всех транзакций
time.Sleep(2 * time.Second)

// 4. Отключить подписку
_, err := m.newDB.ExecContext(ctx, `DROP SUBSCRIPTION migration_sub`)
if err != nil {
return err
}

// 5. Переключить DNS/конфигурацию на новый сервер
if err := m.updateServiceDiscovery(ctx); err != nil {
return err
}

// 6. Включить read-write режим
if err := m.setReadOnlyMode(ctx, false); err != nil {
return err
}

return nil
}

func (m *MigrationManager) setReadOnlyMode(ctx context.Context, readOnly bool) error {
mode := "off"
if readOnly {
mode = "on"
}
_, err := m.oldDB.ExecContext(ctx, fmt.Sprintf("SET default_transaction_read_only = %s", mode))
return err
}

3. Blue-Green Deployment

┌─────────────────────────────────────────────────────────────┐
│ Blue-Green Deployment │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Blue │ │ Green │ │
│ │ (Old PG) │ │ (New PG) │ │
│ │ PG 15 │ │ PG 16 │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ │ Logical Rep │ │
│ │<──────────────────────│ │
│ │ │ │
│ ┌──────┴───────────────────────┴──────┐ │
│ │ Load Balancer │ │
│ │ (HAProxy/PgBouncer) │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Application │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Конфигурация HAProxy для переключения:

# haproxy.cfg

# Blue (текущая версия)
postgres_blue bind *:5432
default-server inter 3s fall 3 rise 2
server pg_blue 10.0.0.1:5432 check

# Green (новая версия) - изначально отключена
postgres_green bind *:5433
default-server inter 3s fall 3 rise 2
server pg_green 10.0.0.2:5432 check

# Для переключения достаточно изменить bind адреса

4. Использование pglogical для сложных сценариев

-- Установка расширения
CREATE EXTENSION pglogical;

-- На provider (старый сервер)
SELECT pglogical.create_node(
node_name := 'provider',
dsn := 'host=old_server port=5432 dbname=mydb'
);

SELECT pglogical.create_replication_set('migration_set', true, true, true, false);

SELECT pglogical.replication_set_add_table(
set_name := 'migration_set',
relation := 'public.memes',
synchronize_data := true
);

-- На subscriber (новый сервер)
SELECT pglogical.create_node(
node_name := 'subscriber',
dsn := 'host=new_server port=5432 dbname=mydb'
);

SELECT pglogical.create_subscription(
subscription_name := 'migration_sub',
provider_dsn := 'host=old_server port=5432 dbname=mydb',
replication_sets := ARRAY['migration_set'],
synchronize_data := true,
forward_origins := '{}'
);

5. Полный план миграции

# migration-plan.yml

preparation:
- Создать полный бэкап старого сервера
- Развернуть новый сервер с новой версией PostgreSQL
- Настроить logical replication
- Прогнать нагрузочные тесты на новом сервере
- Подготовить скрипты отката

migration_day:
phase_1_replication:
- Создать публикацию на старом сервере
- Создать подписку на новом сервере
- Дождаться полной синхронизации
- Мониторить лаг репликации

phase_2_verification:
- Сравнить количество строк в таблицах
- Проверить целостность данных (checksums)
- Прогнать ключевые запросы на обоих серверах
- Сравнить планы выполнения запросов

phase_3_switchover:
- Включить maintenance mode (read-only)
- Дождаться синхронизации
- Отключить подписку
- Переключить DNS/connection pooler
- Выключить maintenance mode

phase_4_post_migration:
- Запустить ANALYZE на новом сервере
- Мониторить производительность
- Держать старый сервер на готовности для отката

rollback:
triggers:
- Ошибки приложения > 1%
- Время ответа > 2x от baseline
- Потеря данных

procedure:
- Переключить DNS обратно на старый сервер
- Включить maintenance mode
- Восстановить репликацию в обратном направлении
- Переключить трафик обратно

6. Проверка данных после миграции

-- Сравнение количества строк
-- На старом сервере
SELECT 'memes' as table_name, count(*) FROM memes
UNION ALL
SELECT 'users', count(*) FROM users
UNION ALL
SELECT 'deals', count(*) FROM deals;

-- На новом сервере (должно совпадать)
SELECT 'memes' as table_name, count(*) FROM memes
UNION ALL
SELECT 'users', count(*) FROM users
UNION ALL
SELECT 'deals', count(*) FROM deals;

-- Проверка контрольных сумм
SELECT
md5(array_agg(md5((t.*)::text))::text) as checksum
FROM (SELECT * FROM memes ORDER BY id) t;

7. Мониторинг во время миграции

package monitoring

import (
"context"
"database/sql"
"time"
)

type ReplicationMonitor struct {
oldDB *sql.DB
newDB *sql.DB
}

type ReplicationStatus struct {
IsReplicating bool
LagSeconds float64
LastSyncTime time.Time
}

func (m *ReplicationMonitor) GetStatus(ctx context.Context) (*ReplicationStatus, error) {
var status ReplicationStatus

// Проверка лага репликации
err := m.newDB.QueryRowContext(ctx, `
SELECT
pg_last_xact_replay_timestamp() IS NOT NULL,
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::float8,
pg_last_xact_replay_timestamp()
`).Scan(&status.IsReplicating, &status.LagSeconds, &status.LastSyncTime)

if err != nil {
return nil, err
}

return &status, nil
}

func (m *ReplicationMonitor) WaitForSync(ctx context.Context, maxLag time.Duration) error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
status, err := m.GetStatus(ctx)
if err != nil {
return err
}

if status.LagSeconds < maxLag.Seconds() {
return nil
}
}
}
}

Сравнение стратегий:

СтратегияДаунтаймСложностьРискКогда использовать
pg_upgradeСекунды-минутыНизкийСреднийПлановое обслуживание
Logical ReplicationНетСреднийНизкийКритичные системы
Blue-GreenНетВысокийНизкийВысоконагруженные
pg_dump/restoreЧасыНизкийВысокийМалые базы

Рекомендации:

  • Всегда делайте полный бэкап перед миграцией
  • Тестируйте миграцию на staging-окружении
  • Имейте готовый план отката
  • Используйте logical replication для zero-downtime
  • Мониторьте лаг репликации в реальном времени
  • Проверяйте целостность данных после миграции
  • Держите старый сервер на готовности минимум 48 часов