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

Открытое собеседование на Go-разработчика | Тренировочные интервью

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

Сегодня мы разберём собеседование на позицию Go-разработчика, в ходе которого интервьюер последовательно проверял знания кандидата по ключевым аспектам языка — от основ ООП и структур данных (слайсы, map, замыкания) до конкурентности (горутины, каналы, sync-примитивы) и системного дизайна (проектирование сервиса коротких ссылок с кэшированием и репликацией). Кандидат продемонстрировал уверенное владение теорией, практический опыт работы в команде и умение рассуждать над архитектурными решениями, хотя в некоторых моментах (например, при обсуждении инверсии зависимостей или деталей работы Redis) чувствовался недостаток глубины, что характерно для уровня мидл-разработчика с перспективой роста.

Вопрос 1. Как реализованы три столпа ООП (наследование, инкапсуляция, полиморфизм) в Go?

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

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

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

Ответ собеседника полностью корректен. Раскрою каждый аспект подробнее с примерами.

1. Наследование → Композиция и встраивание (embedding)

Go намеренно отказался от классического наследования (extends). Вместо этого используется композиция и встраивание структур.

Встраивание позволяет «наследовать» методы и поля встроенной структуры, но без отношения «является» (is-a), а скорее «содержит» (has-a):

type Animal struct {
Name string
}

func (a *Animal) Speak() string {
return "..."
}

// Dog «встраивает» Animal — получает его методы
type Dog struct {
Animal
Breed string
}

// Dog может переопределить метод
func (d *Dog) Speak() string {
return "Woof!"
}

func main() {
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Name) // прямой доступ к полю Animal
fmt.Println(d.Speak()) // "Woof!" — переопределённый метод
fmt.Println(d.Animal.Speak()) // "..." — метод оригинальной структуры
}

Важные нюансы встраивания:

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

2. Инкапсуляция — видимость на уровне пакетов

В Go нет модификаторов public/private/protected. Видимость определяется первой буквой идентификатора:

package mypackage

// Экспортируемое (публичное) — доступно извне
type UserService struct {
repo UserRepository // неэкспортируемое поле
Logger *log.Logger // экспортируемое поле
}

// Экспортируемый метод
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.find(id) // вызов неэкспортируемого метода
}

// Неэкспортируемый метод — доступен только внутри пакета
func (s *UserService) validate(u *User) error {
return nil
}

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

  • Инкапсуляция работает на уровне пакета, а не на уровне структуры.
  • Внутри пакета все идентификаторы доступны, независимо от регистра.
  • Для контроля доступа к полям используются геттеры/сеттеры, когда нужна валидация или логика.

3. Полиморфизм — интерфейсы с утиной типизацией

Интерфейсы в Go реализуются неявно — структура автоматически удовлетворяет интерфейсу, если имеет все его методы:

type Speaker interface {
Speak() string
}

type Dog struct{}
func (d Dog) Speak() string { return "Woof!" }

type Cat struct{}
func (c Cat) Speak() string { return "Meow!" }

type Robot struct{}
func (r Robot) Speak() string { return "Beep boop!" }

// Полиморфная функция — принимает любой Speaker
func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}

func main() {
MakeSound(Dog{}) // "Woof!"
MakeSound(Cat{}) // "Meow!"
MakeSound(Robot{}) // "Beep boop!"
}

Преимущества неявной реализации:

  • Интерфейсы можно определять после создания типов — нет необходимости менять существующий код.
  • Пакеты могут определять интерфейсы для своих нужд, не зная о конкретных реализациях.
  • Это позволяет создавать маленькие, сфокусированные интерфейсы (принцип из Go Proverbs: «The bigger the interface, the weaker the abstraction»).

Практический паттерн — маленькие интерфейсы:

// Вместо большого интерфейса с множеством методов
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}

// Используем композицию маленьких интерфейсов
type Reader interface {
Read(p []byte) (n int, err error)
}

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

// Функция принимает только Reader — минимальное требование
func Process(r Reader) error {
buf := make([]byte, 1024)
_, err := r.Read(buf)
return err
}

Этот подход делает код более гибким, тестируемым и слабо связанным — что является одной из ключевых философий Go.

Вопрос 2. Чем отличается массив от слайса в Go? Можно ли использовать переменную для задания длины массива?

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

Ответ собеседника: Правильный. Массив — это набор элементов фиксированной длины, которая является частью типа. Слайс — это структура, содержащая указатель на массив, длину (length) и вместимость (capacity). Использовать переменную для длины массива нельзя — длина должна быть константой, иначе будет ошибка компиляции.

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

Ответ собеседника полностью верен. Раскрою тему глубже с техническими деталями.

1. Массив (Array) — значение фиксированного размера

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

// Длина массива — константа времени компиляции
var size = 5
arr1 := [5]int{1, 2, 3, 4, 5} // OK

// Ошибка компиляции: size не является константой
// arr2 := [size]int{} // non-constant array bound size

// Типы разной длины несовместимы
var a [5]int
var b [10]int
// a = b // cannot use b (type [10]int) as type [5]int

// Массив — тип-значение: присваивание копирует всё
c := a // полная копия массива
c[0] = 100 // a[0] не изменится

Ключевые свойства массивов:

  • Выделяются в стеке (или инлайнятся), если не «убегают» через escape analysis.
  • Передача в функцию копирует весь массив — это дорого для больших массивов.
  • Сравнимы через ==, если элементы сравнимы.
  • Редко используются напрямую; слайсы — основной инструмент.

2. Слайс (Slice) — динамическая обёртка над массивом

Слайс — это структура из трёх полей (header):

// Внутреннее представление (упрощённо)
type slice struct {
ptr *byte // указатель на первый элемент нижележащего массива
len int // длина (количество доступных элементов)
cap int // вместимость (размер нижележащего массива)
}
// Создание слайсов
s1 := []int{1, 2, 3} // литерал
s2 := make([]int, 5) // len=5, cap=5, zero-valued
s3 := make([]int, 3, 10) // len=3, cap=10

// Срез существующего слайса — разделяют память
original := []int{1, 2, 3, 4, 5}
sub := original[1:4] // [2, 3, 4], cap зависит от оригинала

sub[0] = 100
// original[1] тоже стало 100 — оба слайса указывают на один массив

3. Рост слайса и аллокации

При добавлении элементов за пределы capacity происходит реаллокация:

s := make([]int, 0, 2)
fmt.Println(len(s), cap(s)) // 0 2

s = append(s, 1)
s = append(s, 2)
fmt.Println(len(s), cap(s)) // 2 2

s = append(s, 3) // capacity исчерпан → реаллокация
fmt.Println(len(s), cap(s)) // 3 4 (обычно удвоение)

Стратегия роста: Go runtime удваивает capacity при нехватке места (точная стратегия зависит от размера элемента и версии Go, но удвоение — стандартный случай).

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

// Ошибка 1: утечка памяти при срезе большого массива
func processFirstN(data []int, n int) []int {
return data[:n] // весь data остаётся в памяти!
}

// Правильно — копируем нужные данные
func processFirstNSafe(data []int, n int) []int {
result := make([]int, n)
copy(result, data[:n])
return result
}

// Ошибка 2: append может создать новый underlying array
a := make([]int, 0, 5)
b := append(a, 1)
a = append(a, 2)
// b[0] может быть 2 или 1 — зависит от capacity!
// Если cap не исчерпан, оба слайса делят массив

5. Когда использовать массивы

Массивы полезны в специфических случаях:

// Криптографические ключи фиксированного размера
type Key [32]byte

// Матрицы фиксированного размера
type Matrix3x3 [3][3]float64

// Статические таблицы без аллокаций
var crcTable [256]uint32

Итоговая таблица различий:

ХарактеристикаМассивСлайс
ДлинаЧасть типа, фиксированаДинамическая
Передача в функциюКопируется целикомКопируется только header (24 байта)
СравнениеЧерез ==Только с nil (для == используют reflect.DeepEqual или slices.Equal)
ИспользованиеРедкоОсновной тип для коллекций

Вопрос 3. Как работает функция append в Go? Как увеличивается capacity слайса при переполнении?

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

Ответ собеседника: Правильный. При вызове append, если capacity недостаточно, выделяется новый массив большего размера, данные копируются, и возвращается новый слайс. Размер увеличивается примерно в два раза для небольших слайсов, но точный алгоритм зависит от длины — функция growslice учитывает текущий размер.

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

Ответ собеседника корректен. Раскрою внутреннюю механику подробнее.

1. Механика append — пошагово

// append — встроенная функция (built-in), не метод
slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...) // распаковка слайса

Что происходит внутри append:

  1. Проверяется, достаточно ли capacity для новых элементов.
  2. Если достаточно — элементы записываются в существующий underlying array, возвращается слайс с увеличенным len.
  3. Если недостаточно — вызывается growslice, создаётся новый массив, данные копируются, возвращается новый слайс.

Критически важно: append может вернуть слайс с другим underlying array. Всегда присваивайте результат:

// Неправильно — если произойдёт реаллокация, slice будет указывать на старый массив
append(slice, item)

// Правильно
slice = append(slice, item)

2. Алгоритм growslice — детали роста

Точная стратегия роста находится в runtime/slice.go в функции growslice:

// Упрощённая логика growslice (актуальна для Go 1.21+)
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
newcap := oldCap
doublecap := newcap + newcap

if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
newcap = doublecap // удвоение для маленьких слайсов
} else {
// Для больших слайсов — рост на ~25%
for newcap < newLen {
newcap += (newcap + 3*threshold) / 4
}
}
}

// Округление с учётом размера элемента (memory rounding)
capmem := roundupsize(uintptr(newcap) * sizeOfElem)
newcap = int(capmem / sizeOfElem)

return makeSlice(newcap)
}

Стратегия роста по порогам:

Текущий capacityСтратегия роста
< 256Удвоение (×2)
≥ 256Рост на 25% (×1.25) с округлением

Примеры роста:

s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=4 cap=4
// len=5 cap=8
// len=6 cap=8
// len=7 cap=8
// len=8 cap=8
// len=9 cap=16
// len=10 cap=16

3. Подводные камни append

// Проблема 1: разделение памяти после append
a := make([]int, 3, 5)
a[0], a[1], a[2] = 1, 2, 3

b := append(a, 4) // cap достаточно, реаллокации нет
b[0] = 100 // a[0] тоже стало 100!

c := append(a, 5, 6) // cap не хватает → реаллокация
c[0] = 200 // a[0] не изменился — разные массивы

// Проблема 2: append к nil-слайсу
var s []int // nil слайс
s = append(s, 1) // OK — создаётся новый массив
fmt.Println(s) // [1]

// Проблема 3: утечка памяти при срезе
func getFirst100(data []int) []int {
return data[:100] // data целиком остаётся в памяти
}

func getFirst100Safe(data []int) []int {
result := make([]int, 100)
copy(result, data[:100])
return result
}

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

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

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

// Хорошо — одна аллокация
func buildGood(n int) []int {
result := make([]int, 0, n) // capacity задан, len = 0
for i := 0; i < n; i++ {
result = append(result, i)
}
return result
}

// Ещё лучше — прямая индексация, если знаем точный размер
func buildBest(n int) []int {
result := make([]int, n)
for i := 0; i < n; i++ {
result[i] = i
}
return result
}

5. Сложность append

  • Amortized O(1) — благодаря экспоненциальному росту, средняя стоимость вставки константна.
  • Worst case O(n) — при реаллокации нужно скопировать все элементы.
  • Memory overhead — до 100% неиспользуемой памяти при удвоении (в среднем ~50%).

Практический совет: при обработке больших данных используйте make([]T, 0, estimatedSize) для минимизации реаллокаций. Это одна из самых частых оптимизаций производительности в Go.

Вопрос 4. Есть две функции: одна использует append внутри себя, другая сортирует слайс через sort.Ints. Какая из них модифицирует исходный слайс, а какая возвращает новый?

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

Ответ собеседника: Правильный. Функция с append может вернуть новый слайс (при переполнении capacity), и исходный слайс не изменяется. Функция sort.Ints модифицирует исходный слайс на месте (in-place), не создавая новый.

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

Ответ собеседника верен. Разберём оба случая детальнее с примерами.

1. append — поведение зависит от capacity

// Случай 1: capacity достаточно — модифицирует исходный массив
func addElementEnoughCap(s []int, val int) {
s = append(s, val) // модифицирует underlying array!
}

original := make([]int, 2, 5)
original[0], original[1] = 1, 2
addElementEnoughCap(original, 3)
// original[2] == 3 — исходный массив изменён
// НО: len(original) всё ещё 2, потому что передаётся копия header


// Случай 2: capacity недостаточно — создаётся новый массив
func addElementOverflow(s []int, val int) []int {
return append(s, val)
}

a := []int{1, 2, 3} // len=3, cap=3
b := addElementOverflow(a, 4)
// a — прежний слайс (len=3, cap=3)
// b — новый слайс с другим underlying array
a[0] = 100
// b[0] не изменился

Ключевой момент: сам слайс (header) передаётся по значению. Даже если append не вызывает реаллокацию, изменение len не видно вызывающей стороне. Но изменения элементов в underlying array видны.

2. sort.Ints — всегда модифицирует на месте

func main() {
data := []int{3, 1, 4, 1, 5, 9, 2, 6}
backup := data // копируется header, НЕ данные!

sort.Ints(data) // in-place сортировка

fmt.Println(data) // [1 1 2 3 4 5 6 9]
fmt.Println(backup) // [1 1 2 3 4 5 6 9] — тоже отсортирован!
}

sort.Ints работает в-place, потому что принимает []int, а не возвращает новый слайс. Он использует быструю сортировку (quicksort) с оптимизациями.

3. Как вернуть отсортированную копию

// Если нужно сохранить оригинал
func sortedCopy(original []int) []int {
copy := make([]int, len(original))
// copy(copy, original) — ручное копирование
// Или через append:
result := append([]int(nil), original...)
sort.Ints(result)
return result
}

original := []int{3, 1, 4, 1, 5}
sorted := sortedCopy(original)
// original не изменён

4. Сравнение подходов к мутации

ФункцияМодифицирует вход?Возвращает новый?Примечание
appendЗависит от capДа (новый header)Элементы могут быть общими
sort.IntsДаНетЧистая in-place мутация
slices.Sort (Go 1.21+)ДаНетЗамена sort.Ints
append([]T(nil), s...)НетДаКопирование через append

5. Практические паттерны

// Паттерн: функция не должна мутировать вход
func processImmutable(input []int) []int {
// Создаём копию для работы
working := make([]int, len(input))
copy(working, input)

// Работаем с копией
sort.Ints(working)
working = append(working, 42)

return working
}

// Паттерн: явное указание мутации в имени
func sortInPlace(data []int) {
sort.Ints(data)
}

// Паттерн: метод-модификатор vs метод-преобразователь
type IntSlice []int

func (s *IntSlice) Sort() { // мутирует
sort.Ints(*s)
}

func (s IntSlice) Sorted() IntSlice { // возвращает копию
result := make(IntSlice, len(s))
copy(result, s)
sort.Ints(result)
return result
}

Важно помнить: при работе со слайсами всегда учитывайте, что передаётся копия header (указатель, len, cap), а не копия данных. Это означает, что мутации элементов видны всем слайсам, ссылающимся на один underlying array, пока не произойдёт реаллокация через append.

Вопрос 5. Что такое map в Go? Какие типы могут быть ключами? Может ли структура быть ключом map?

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

Ответ собеседника: Правильный. Map — это хеш-таблица для хранения пар ключ-значение. Ключом может быть любой сравнимый тип (поддерживающий == и !=). Функции, слайсы и map не могут быть ключами. Структура может быть ключом, если все её поля являются сравнимыми типами. Если структура содержит слайс или map в качестве поля — она не может быть ключом. Также при создании map через var без make обращение к нему вызовет panic.

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

Ответ собеседника полностью корректен. Дополню техническими деталями и примерами.

1. Map — внутренняя структура

Map в Go — это хеш-таблица, реализованная как массив «бакетов» (buckets). Каждый бакет хранит до 8 пар ключ-значение.

// Внутреннее представление (упрощённо)
type hmap struct {
count int // количество элементов
B uint8 // log2 количества бакетов (2^B)
buckets unsafe.Pointer // массив бакетов
oldbuckets unsafe.Pointer // для постепенного роста
// ...
}

Создание map:

// Через make — основной способ
m1 := make(map[string]int)
m2 := make(map[string]int, 100) // hint на начальную ёмкость

// Через литерал
m3 := map[string]int{
"alice": 30,
"bob": 25,
}

// nil map — чтение возвращает zero value, запись вызывает panic
var m4 map[string]int
val := m4["key"] // OK: val == 0
// m4["key"] = 1 // panic: assignment to entry in nil map

2. Сравнимые типы — полный список

Могут быть ключами:

  • Все примитивные типы: bool, int*, uint*, float*, complex*, string
  • Указатели (*T)
  • Каналы (chan T)
  • Интерфейсы (если динамический тип сравним)
  • Массивы сравнимых типов ([N]T)
  • Структуры, содержащие только сравнимые поля

Не могут быть ключами:

  • Слайсы ([]T)
  • Map (map[K]V)
  • Функции (func(...))
// Примеры допустимых ключей
valid1 := map[[3]int]string{{1,2,3}: "array key"}
valid2 := map[chan int]string{}
valid3 := map[*int]string{}

type Point struct {
X, Y int
}
valid4 := map[Point]string{{1, 2}: "point"}

// Недопустимые ключи — ошибка компиляции
// bad1 := map[[]int]string{} // invalid map key type
// bad2 := map[map[string]int]string{} // invalid map key type
// bad3 := map[func()]string{} // invalid map key type

type BadStruct struct {
Data []int
}
// bad4 := map[BadStruct]string{} // invalid map key type

3. Структуры как ключи — нюансы

type UserKey struct {
ID int
Name string
}

// Структура полностью сравнима — работает
users := map[UserKey]string{
{ID: 1, Name: "Alice"}: "admin",
{ID: 2, Name: "Bob"}: "user",
}

// Сравнение структур происходит по всем полям
key1 := UserKey{ID: 1, Name: "Alice"}
key2 := UserKey{ID: 1, Name: "Alice"}
fmt.Println(key1 == key2) // true
fmt.Println(users[key1]) // "admin"

// Структура с несравнимым полем
type BadKey struct {
ID int
Tags []string // слайс — не сравним!
}
// m := map[BadKey]string{} // ошибка компиляции

4. Порядок итерации и производительность

// Map НЕ упорядочен — порядок итерации случайный
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v) // порядок непредсказуем
}

// Рост map — постепенная эвакуация
// При переполнении Go создаёт новые бакеты и постепенно
// переносит элементы, чтобы избежать долгих пауз

5. Паттерны работы с map

// Проверка существования ключа
if val, ok := m["key"]; ok {
fmt.Println("found:", val)
}

// Безопасное удаление
delete(m, "key") // не паникует даже если ключа нет

// Синхронизация — map НЕ потокобезопасен
// Для concurrent доступа используйте sync.Map или mutex
var mu sync.RWMutex
safeMap := make(map[string]int)

func get(key string) int {
mu.RLock()
defer mu.RUnlock()
return safeMap[key]
}

func set(key string, val int) {
mu.Lock()
defer mu.Unlock()
safeMap[key] = val
}

6. sync.Map для concurrent сценариев

// sync.Map — когда ключи редко пересекаются между горутинами
var m sync.Map

m.Store("key", 42)
val, ok := m.Load("key")
m.Delete("key")
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // продолжить итерацию
})

Итог: map в Go — это хеш-таблица с требованием сравнимости ключей. Структуры могут быть ключами, если все их поля сравнимы. Для конкурентного доступа требуется синхронизация или sync.Map.

Вопрос 6. Безопасна ли map для конкурентного использования в Go? Как обеспечить потокобезопасность при работе с map?

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

Ответ собеседника: Правильный. Map не является потокобезопасной — при конкурентном доступе может возникнуть race condition и panic. Для обеспечения безопасности нужно использовать мьютексы (sync.Mutex) или специализированную обёртку sync.Map.

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

Ответ собеседника корректен. Раскрою тему подробнее с примерами и рекомендациями.

1. Почему map небезопасна и что происходит при конкурентном доступе

Начиная с Go 1.6, runtime детектирует конкурентную запись в map и вызывает fatal error (не panic, а именно fatal — его нельзя перехватить через recover):

// Это приведёт к крашу
func dangerous() {
m := make(map[string]int)

go func() {
for i := 0; i < 1000; i++ {
m["key"] = i // concurrent write
}
}()

go func() {
for i := 0; i < 1000; i++ {
_ = m["key"] // concurrent read
}
}()
}
// fatal error: concurrent map writes
// или fatal error: concurrent map read and map write

2. Способы обеспечения потокобезопасности

А. sync.RWMutex — рекомендуемый подход в большинстве случаев

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

func NewSafeMap() *SafeMap {
return &SafeMap{
data: make(map[string]int),
}
}

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

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

func (m *SafeMap) Delete(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
}

func (m *SafeMap) Len() int {
m.mu.RLock()
def

#### **Вопрос 7**. Как определить собственные методы для типа из внешнего пакета в Go?

**Таймкод:** <YouTubeSeekTo id="sZSNf5eVnRA" time="00:19:34"/>

**Ответ собеседника:** **Правильный**. Нельзя напрямую добавить методы к типу из другого пакета. Нужно создать собственный тип через type alias или встраивание (embedding) оригинального типа в новую структуру, а затем добавлять методы к этой новой структуре.

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

Ответ собеседника верен. Разберём все способы подробнее с примерами.

**1. Ограничение Go — методы только для локальных типов**

В Go нельзя добавить метод к типу, определённому в другом пакете. Это фундаментальное ограничение системы типов:

```go
package main

import "time"

// ОШИБКА компиляции: нельзя добавить метод к чужому типу
func (t time.Time) IsWeekend() bool {
// cannot define new methods on non-local type time.Time
}

2. Способ 1: Type definition (создание нового типа)

package main

import (
"fmt"
"time"
)

// MyTime — новый тип на основе time.Time
// НЕ алиас, а полностью новый тип без методов оригинала
type MyTime time.Time

func (mt MyTime) IsWeekend() bool {
weekday := time.Time(mt).Weekday()
return weekday == time.Saturday || weekday == time.Sunday
}

func (mt MyTime) FormatRu() string {
return time.Time(mt).Format("02.01.2006")
}

func main() {
now := MyTime(time.Now())
fmt.Println(now.IsWeekend())
fmt.Println(now.FormatRu())

// Для использования методов time.Time нужен явный каст
t := time.Time(now)
fmt.Println(t.Add(24 * time.Hour))
}

Важно: type definition создаёт полностью новый тип — все методы оригинала теряются. Нужен явный каст time.Time(mt) для доступа к методам оригинала.

3. Способ 2: Type alias (алиас типа)

// Type alias — просто другое имя для того же типа
type MyString = string

func main() {
var s MyString = "hello"
fmt.Println(len(s)) // методы string доступны
fmt.Println(s[0]) // индексация работает
}

Ограничение alias: к alias нельзя добавить методы — это тот же тип, просто с другим именем. Alias используется для рефакторинга и читаемости, а не для расширения функциональности.

4. Способ 3: Встраивание (embedding) — предпочтительный подход

package main

import (
"fmt"
"time"
)

// EnhancedTime встраивает time.Time — получает все его методы
type EnhancedTime struct {
time.Time
}

func NewEnhancedTime(t time.Time) EnhancedTime {
return EnhancedTime{Time: t}
}

// Собственные методы
func (et EnhancedTime) IsWeekend() bool {
w := et.Weekday() // прямой доступ к методам time.Time
return w == time.Saturday || w == time.Sunday
}

func (et EnhancedTime) DaysUntil(other EnhancedTime) int {
diff := other.Time.Sub(et.Time)
return int(diff.Hours() / 24)
}

func (et EnhancedTime) StartOfDay() EnhancedTime {
y, m, d := et.Date()
return NewEnhancedTime(time.Date(y, m, d, 0, 0, 0, 0, et.Location()))
}

func main() {
now := NewEnhancedTime(time.Now())

// Методы time.Time доступны напрямую
fmt.Println(now.Format(time.RFC3339))
fmt.Println(now.Add(48 * time.Hour))

// Собственные методы
fmt.Println(now.IsWeekend())
fmt.Println(now.StartOfDay())
}

Преимущество embedding: все методы оригинального типа доступны напрямую без каста.

5. Способ 4: Обёртка с указателем

type EnhancedTimePtr struct {
*time.Time
}

func NewFromPtr(t *time.Time) EnhancedTimePtr {
return EnhancedTimePtr{Time: t}
}

func (et EnhancedTimePtr) IsBusinessHours() bool {
h := et.Hour()
w := et.Weekday()
return h >= 9 && h < 18 && w >= time.Monday && w <= time.Friday
}

6. Сравнение подходов

ПодходМетоды оригиналаКастИспользование
Type definitionНетОбязательныйКогда нужна изоляция
Type aliasДаНе нуженТолько для переименования
EmbeddingДаНе нуженРасширение функциональности
Pointer embeddingДаНе нуженРабота с указателями

7. Практический пример — расширение sql.NullString

import "database/sql"

type EnhancedNullString struct {
sql.NullString
}

func (s EnhancedNullString) OrDefault(defaultValue string) string {
if s.Valid {
return s.String
}
return defaultValue
}

func (s EnhancedNullString) IsEmpty() bool {
return !s.Valid || s.String == ""
}

// Использование
func getUserRole(id int) EnhancedNullString {
var role sql.NullString
db.QueryRow("SELECT role FROM users WHERE id = ?", id).Scan(&role)
return EnhancedNullString{NullString: role}
}

func main() {
role := getUserRole(42)
fmt.Println(role.OrDefault("guest"))
}

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

Вопрос 8. Какие типы каналов существуют в Go? Как работают буферизированные и небуферизированные каналы? Как правильно закрывать канал?

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

Ответ собеседника: Правильный. Существуют буферизированные и небуферизированные каналы. Небуферизированные блокируют отправку до момента чтения и блокируют чтение до момента отправки. Буферизированные имеют очередь заданного размера — блокируют отправку только когда буфер полон. Канал закрывается через close(). При чтении из закрытого канала можно получить две значения: данные и флаг ok (false если канал закрыт). Для закрытия канала в качестве сигнального используют chan struct{}, так как пустая структура не занимает памяти. Попытка записи в закрытый канал вызывает panic. Закрывать канал должен только отправитель.

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

Ответ собеседника полностью корректен. Дополню техническими деталями и паттернами.

1. Типы каналов по направлению

// Двунаправленный канал (по умолчанию)
ch := make(chan int)

// Только для отправки (send-only)
func send(ch chan<- int) {
ch <- 42
// val := ch // ошибка: нельзя читать
}

// Только для чтения (receive-only)
func receive(ch <-chan int) {
val := <-ch
// ch <- 42 // ошибка: нельзя писать
}

// Преобразование направлений
var bidirectional chan int
sendOnly := chan<- int(bidirectional)
receiveOnly := <-chan int(bidirectional)

2. Небуферизированные каналы — синхронизация

// Без буфера — отправка блокируется до получения
ch := make(chan int)

// Это заблокируется навсегда в одной горутине (deadlock)
// ch <- 42
// val := <-ch

// Правильно — нужны минимум две горутины
go func() {
ch <- 42 // блокируется пока кто-то не прочитает
}()

val := <-ch // блокируется пока кто-то не отправит

Характеристики небуферизированных каналов:

  • Гарантия синхронизации: отправитель знает, что получатель получил данные
  • Емкость = 0
  • Используются для сигнализации и координации

3. Буферизированные каналы — очередь сообщений

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

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

// Чтение из буфера
val := <-ch // 1
val = <-ch // 2

Характеристики буферизированных каналов:

  • Отправка блокируется только при полном буфере
  • Чтение блокируется только при пустом буфере
  • Используются для ограничения пропускной способности (rate limiting)

4. Закрытие каналов — детали и паттерны

// Базовое закрытие
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

// Чтение из закрытого канала
val, ok := <-ch // val=1, ok=true
val, ok = <-ch // val=2, ok=true
val, ok = <-ch // val=0 (zero value), ok=false

// Итерация по закрытому каналу
for val := range ch {
fmt.Println(val) // 1, 2, затем цикл завершится
}

Важные правила:

// 1. Запись в закрытый канал — panic
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
close(ch)
// ch <- 1 // panic: send on closed channel

// 2. Двойное закрытие — panic
close(ch)
// close(ch) // panic: close of closed channel

// 3. Закрытие nil канала — panic
var nilCh chan int
// close(nilCh) // panic: close of nil channel

5. Паттерны работы с каналами

А. Сигнальный канал через struct{}

// struct{} занимает 0 байт — идеально для сигналов
done := make(chan struct{})

go func() {
// выполняем работу
time.Sleep(time.Second)
close(done) // сигнал завершения
}()

<-done // ждём завершения

Б. Fan-out — распределение работы

func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("worker %d processing job %d\n", id, job)
time.Sleep(time.Millisecond * 100)
results <- job * 2
}
}

func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)

// Запускаем 3 воркера
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}

// Отправляем работу
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // сигнал: работы больше нет

// Собираем результаты
for a := 1; a <= 9; a++ {
<-results
}
}

В. Graceful shutdown

func main() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

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

go func() {
<-sigChan
fmt.Println("shutting down...")
cancel()
}()

// Запускаем сервис
if err := runServer(ctx); err != nil {
log.Fatal(err)
}
}

func runServer(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// работаем
}
}
}

Г. select с таймаутом

ch := make(chan int)

select {
case val := <-ch:
fmt.Println("received:", val)
case <-time.After(5 * time.Second):
fmt.Println("timeout")
}

6. Кто должен закрывать канал

Правило: отправитель закрывает канал. Если отправителей несколько — используйте sync.WaitGroup или отдельный канал-координатор:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)

output := func(c <-chan int) {
defer wg.Done()
for val := range c {
select {
case merged <- val:
case <-done:
return
}
}
}

wg.Add(len(cs))
for _, c := range cs {
go output(c)
}

go func() {
wg.Wait()
close(merged) // закрываем когда все отправители завершены
}()

return merged
}

Вопрос 9. Для чего используется default в select? Что такое замыкание (closure) в Go?

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

Ответ собеседника: Правильный. Default в select выполняется, если ни один из каналов не готов, что позволяет избежать блокировки. Замыкание — это функция, которая захватывает и использует переменные из внешней области видимости. Важно помнить, что при захвате переменной цикла все замыкания получают ссылку на одну и ту же переменную, поэтому в конце будет последнее значение. Также стоит помнить, что в Go по умолчанию используется передача значением.

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

Ответ собеседника полностью верен. Раскрою оба аспекта подробнее с примерами.

1. Default в select — неблокирующие операции

// Без default — select блокируется до готовности любого канала
ch := make(chan int)
select {
case val := <-ch:
fmt.Println("received:", val)
// заблокируется навсегда, если никто не отправит
}

// С default — неблокирующий select
select {
case val := <-ch:
fmt.Println("received:", val)
default:
fmt.Println("no data available") // выполнится сразу
}

Практические применения default:

// Неблокирующее чтение
func tryReceive(ch chan int) (int, bool) {
select {
case val := <-ch:
return val, true
default:
return 0, false
}
}

// Неблокирующая отправка
func trySend(ch chan int, val int) bool {
select {
case ch <- val:
return true
default:
return false // буфер полон или нет получателя
}
}

// Паттерн: проверка завершения без блокировки
func processWithCancellation(ctx context.Context, dataCh <-chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("cancelled")
return
case val, ok := <-dataCh:
if !ok {
fmt.Println("channel closed")
return
}
process(val)
default:
// Нет данных — делаем другую работу
doBackgroundWork()
time.Sleep(time.Millisecond * 10)
}
}
}

2. Замыкания (closures) — захват переменных

Замыкание — это функция, которая ссылается на переменные из внешней области видимости. В Go замыкания захватывают переменные по ссылке, а не по значению.

// Простое замыкание
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3

3. Классическая ловушка — замыкание в цикле

// НЕПРАВИЛЬНО: все горутины видят одну переменную i
func bad() {
var funcs []func()
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // все напечатают 5 (или другое последнее значение)
}()
}
time.Sleep(time.Second)
}

// ПРАВИЛЬНО 1: передаём как аргумент
func good1() {
for i := 0; i < 5; i++ {
go func(val int) {
fmt.Println(val) // 0, 1, 2, 3, 4
}(i)
}
}

// ПРАВИЛЬНО 2: локальная переменная внутри цикла
func good2() {
for i := 0; i < 5; i++ {
val := i // новая переменная на каждой итерации
go func() {
fmt.Println(val) // 0, 1, 2, 3, 4
}()
}
}

4. Замыкания и указатели

// Захват указателя
func pointerClosure() {
data := &Data{Value: 0}

increment := func() {
data.Value++ // модифицирует оригинальную структуру
}

increment()
fmt.Println(data.Value) // 1
}

// Захват слайса (уже ссылочный тип)
func sliceClosure() {
items := []int{1, 2, 3}

add := func(val int) {
items = append(items, val) // переприсваивает локальную переменную
}

add(4)
fmt.Println(items) // [1 2 3 4]
}

5. Практические паттерны с замыканиями

А. Middleware-паттерн

type Handler func(http.ResponseWriter, *http.Request)

func loggingMiddleware(next Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}
}

func authMiddleware(next Handler) Handler {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}

Б. Фабрика функций

func multiplier(factor int) func(int) int {
return func(x int) int {
return x * factor
}
}

double := multiplier(2)
triple := multiplier(3)

fmt.Println(double(5)) // 10
fmt.Println(triple(5)) // 15

В. Мемоизация

func memoize(fn func(int) int) func(int) int {
cache := make(map[int]int)
return func(n int) int {
if val, ok := cache[n]; ok {
return val
}
result := fn(n)
cache[n] = result
return result
}
}

fib := memoize(func(n int) int {
if n < 2 {
return n
}
return fib(n-1) + fib(n-2)
})

6. Передача по значению vs по ссылке

// Go передаёт всё по значению, но некоторые типы содержат ссылку внутри

// Структура — копируется целиком
func modifyStruct(s MyStruct) {
s.Field = 10 // не влияет на оригинал
}

// Указатель — копируется указатель, данные общие
func modifyPointer(s *MyStruct) {
s.Field = 10 // модифицирует оригинал
}

// Слайс — копируется header (ptr, len, cap), но данные общие
func modifySlice(s []int) {
s[0] = 10 // модифицирует оригинал
s = append(s, 4) // локальное изменение header
}

// Map — копируется указатель на hash table
func modifyMap(m map[string]int) {
m["key"] = 10 // модифицирует оригинал
}

Итог: default в select позволяет писать неблокирующий код, а замыкания — мощный инструмент, требующий понимания захвата переменных по ссылке. Ловушка с циклом — одна из самых частых ошибок у начинающих Go-разработчиков.

Вопрос 10. Что такое горутина (goroutine)? Почему она считается легковесной по сравнению с потоком ОС? Какой начальный размер стека у горутины и может ли он расти?

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

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

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

Ответ собеседника корректен. Дополню техническими деталями о планировщике и управлении горутинами.

1. Горутина vs поток ОС — сравнение

ХарактеристикаГорутинаПоток ОС
Начальный стек2 КБ1-8 МБ (зависит от ОС)
Переключение контекста~200 нс (user space)~1-10 мкс (kernel space)
СозданиеНаносекундыМикросекунды
Максимальное количествоСотни тысячТысячи
ПланировщикGo runtime (M:N)Ядро ОС (1:1)

2. Модель планирования M:N

Go использует модель M:N — M горутин на N потоках ОС:

// Упрощённая модель планировщика
// G = goroutine (горутина)
// M = machine (поток ОС)
// P = processor (процессорный контекст)

// GOMAXPROCS определяет количество P (по умолчанию = числу CPU)
runtime.GOMAXPROCS(4) // 4 потока ОС для выполнения горутин
// Можно запустить сотни тысяч горутин
func main() {
for i := 0; i < 100000; i++ {
go func(id int) {
// работа
}(i)
}
time.Sleep(time.Second)
}

3. Рост стека горутины

// Начальный стек: 2 КБ (с Go 1.4+)
// Максимальный размер: 1 ГБ (ограничение runtime)

// Стек растёт автоматически через stack splitting
func recursive(n int) {
if n == 0 {
return
}
var buf [1024]byte // выделяем память на стеке
_ = buf
recursive(n - 1)
}

// Go runtime проверяет перед каждым вызовом функции:
// достаточно ли стека? Если нет — выделяется новый сегмент.

Механизм роста стека:

  1. Перед вызовом функции проверяется указатель стека.
  2. Если мало места — вызывается morestack.
  3. Выделяется новый сегмент стека (обычно удвоенного размера).
  4. Данные копируются, старый сегмент освобождается.

4. Переключение контекста — почему это дёшево

// Переключение горутины происходит в user space:
// 1. Сохраняются регистры (PC, SP, и т.д.)
// 2. Загружаются регистры новой горутины
// 3. Нет перехода в ядро ОС

// Триггеры переключения:
// - Канальные операции (send/recieve)
// - time.Sleep, time.After
// - syscall (может заблокировать поток)
// - runtime.Gosched()
// - Вызов функций (cooperative scheduling с Go 1.14+)

5. Практические примеры

// Правильное использование горутин
func processItems(items []int) {
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1)
go func(val int) { // передаём по значению!
defer wg.Done()
process(val)
}(item)
}

wg.Wait()
}

// Ограничение параллельности через semaphore
func processWithLimit(items []int, maxConcurrent int) {
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1)
sem <- struct{}{} // захватываем слот

go func(val int) {
defer wg.Done()
defer func() { <-sem }() // освобождаем слот
process(val)
}(item)
}

wg.Wait()
}

6. Мониторинг горутин

// Количество запущенных горутин
numGoroutines := runtime.NumGoroutine()
fmt.Println("goroutines:", numGoroutines)

// Статистика по памяти
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
fmt.Printf("Alloc = %v MiB\n", memStats.Alloc / 1024 / 1024)

Итог: горутины — ключевая особенность Go, позволяющая писать конкурентный код без сложности потоков ОС. Легковесность достигается за счёт user-space планировщика, маленького начального стека и эффективного переключения контекста.

Вопрос 11. Что такое sync.WaitGroup и для чего он используется? Какими способами можно завершить горутины извне?

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

Ответ собеседника: Правильный. WaitGroup — это механизм синхронизации для ожидания завершения группы горутин. Метод Add увеличивает счётчик, Done уменьшает, Wait блокирует до обнуления счётчика. Завершить горутины извне можно: 1) завершить main-горутину; 2) использовать сигналы ОС; 3) использовать канал для сигнала закрытия; 4) использовать контекст (context.Context) с отменой.

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

Ответ собеседника полностью верен. Раскрою оба аспекта подробнее с примерами.

1. sync.WaitGroup — детальное использование

// Базовый паттерн
func processItems(items []int) {
var wg sync.WaitGroup

for _, item := range items {
wg.Add(1) // увеличиваем ДО запуска горутины
go func(val int) {
defer wg.Done() // уменьшаем при завершении
process(val)
}(item)
}

wg.Wait() // блокируемся пока счётчик не станет 0
}

Важные правила использования:

// Правило 1: Add должен быть вызван до запуска горутины
// (иначе Wait может завершиться раньше)

// НЕПРАВИЛЬНО:
go func() {
wg.Add(1) // гонка с Wait!
defer wg.Done()
work()
}()

// ПРАВИЛЬНО:
wg.Add(1)
go func() {
defer wg.Done()
work()
}()

// Правило 2: WaitGroup нельзя копировать после первого использования
// Передавайте по указателю
func process(wg *sync.WaitGroup) { // *sync.WaitGroup, не sync.WaitGroup
defer wg.Done()
work()
}

2. Способы завершения горутин извне

А. Закрытие канала (самый простой способ)

func worker(done chan struct{}) {
for {
select {
case <-done:
fmt.Println("shutting down")
return
default:
// работа
time.Sleep(time.Millisecond * 100)
}
}
}

func main() {
done := make(chan struct{})
go worker(done)

time.Sleep(time.Second)
close(done) // сигнал завершения всем горутинам, слушающим этот канал
time.Sleep(time.Millisecond * 50) // даём время на graceful shutdown
}

Б. context.Context — рекомендуемый способ

func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
default:
// работа
}
}
}

// Отмена через таймаут
func withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

go worker(ctx)
<-ctx.Done()
}

// Ручная отмена
func withCancel() {
ctx, cancel := context.WithCancel(context.Background())

go worker(ctx)

time.Sleep(time.Second)
cancel() // отменяем все горутины, использующие этот контекст
}

// Каскадная отмена
func parentChild() {
parentCtx, parentCancel := context.WithCancel(context.Background())
defer parentCancel()

childCtx, childCancel := context.WithCancel(parentCtx)
defer childCancel()

go worker(childCtx)

parentCancel() // отменяет и parentCtx, и childCtx
}

В. Сигналы ОС

func gracefulShutdown() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

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

go worker(ctx)

<-sigChan
fmt.Println("received signal, shutting down...")
cancel()

// Даём время на завершение
time.Sleep(time.Second)
}

Г. Комбинированный подход — graceful shutdown сервера

type Server struct {
httpServer *http.Server
done chan struct{}
}

func (s *Server) Start() error {
go s.handleSignals()
return s.httpServer.ListenAndServe()
}

func (s *Server) handleSignals() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

<-sigChan
fmt.Println("shutting down gracefully...")

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

if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}

close(s.done)
}

3. Паттерн worker pool с завершением

func workerPool(numWorkers int, jobs <-chan int, results chan<- int, ctx context.Context) {
var wg sync.WaitGroup

for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
results <- process(job)
}
}
}(i)
}

wg.Wait()
close(results)
}

4. Сравнение способов завершения

СпособПлюсыМинусыКогда использовать
Закрытие каналаПростойОдин канал — одна горутинаПростые случаи
context.ContextКаскадная отмена, стандартНемного сложнееСервисы, HTTP-серверы
Сигналы ОССтандартный graceful shutdownТолько для внешних сигналовДемоны, серверы
runtime.Goexit()Завершает текущую горутинуНе извнеВнутренняя логика

Итог: WaitGroup — основной инструмент для ожидания завершения горутин. Для завершения извне рекомендуется context.Context — он поддерживает каскадную отмену, таймауты и является стандартом в экосистеме Go.

Вопрос 12. Задача проектирования: предложите архитектуру сервиса коротких ссылок (типа bit.ly) с нагрузкой 10 000 RPS, где преобладает чтение. Какие хранилища и кэши вы бы использовали?

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

Ответ собеседника: Правильный. Предложена архитектура: два экземпляра базы данных — мастер для записи и слейв для чтения. Для кэширования используется Redis (key-value хранилище). При запросе сначала проверяется кэш (Redis), при промахе — чтение из базы данных. Для обеспечения консистентности между мастером и слейвом используется асинхронная репликация. При промахе кэша и базы можно перегенерировать ссылку. Для масштабирования Redis предлагается использовать кластеризацию (шардирование) для распределения нагрузки.

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

Ответ собеседника корректен и покрывает основные аспекты. Разверну архитектуру подробнее.

1. Высокоуровневная архитектура

┌─────────────────┐
│ Load Balancer │
│ (Nginx/HAProxy)│
└────────┬────────┘

┌──────────────┼──────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ API GW │ │ API GW │ │ API GW │
│ (stateless)│ │ (stateless)│ │ (stateless)│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└──────────────┼──────────────┘

┌────────┴────────┐
│ Application │
│ Servers (Go) │
│ (stateless) │
└────────┬────────┘

┌──────────────┼──────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Redis │ │ Redis │ │ PostgreSQL│
│ Cluster │ │ Cluster │ │ Primary │
│ (cache) │ │ (cache) │ │ + Replica│
└───────────┘ └───────────┘ └───────────┘

2. Генерация коротких ссылок

package main

import (
"crypto/rand"
"encoding/base64"
)

// Способ 1: Base62 кодирование автоинкрементного ID
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

func encodeBase62(n uint64) string {
if n == 0 {
return string(base62Chars[0])
}

var result []byte
for n > 0 {
result = append(result, base62Chars[n%62])
n /= 62
}

// Реверсируем
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}

return string(result)
}

// Способ 2: Случайная генерация с проверкой коллизий
func generateRandomShortCode(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}

encoded := base64.URLEncoding.EncodeToString(bytes)
return encoded[:length], nil
}

// Способ 3: Использование счётчика (Redis INCR или Snowflake ID)
func generateFromCounter(counterID uint64) string {
// 7 символов base62 = 62^7 ≈ 3.5 триллиона комбинаций
return encodeBase62(counterID)
}

3. Схема базы данных

CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) NOT NULL UNIQUE,
original_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_short_code ON urls(short_code);
CREATE INDEX idx_user_id ON urls(user_id);
CREATE INDEX idx_expires_at ON urls(expires_at) WHERE expires_at IS NOT NULL;

-- Для аналитики (отдельная таблица или ClickHouse)
CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
url_id BIGINT REFERENCES urls(id),
clicked_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
referer TEXT,
country VARCHAR(2)
);

CREATE INDEX idx_clicks_url_id ON clicks(url_id);
CREATE INDEX idx_clicks_clicked_at ON clicks(clicked_at);

4. Слой кэширования

package cache

import (
"context"
"encoding/json"
"time"

"github.com/redis/go-redis/v9"
)

type URLCache struct {
client *redis.Client
ttl time.Duration
}

type CachedURL struct {
OriginalURL string `json:"original_url"`
IsActive bool `json:"is_active"`
}

func NewURLCache(redisAddr string, ttl time.Duration) *URLCache {
client := redis.NewClient(&redis.Options{
Addr: redisAddr,
PoolSize: 100,
MinIdleConns: 10,
ReadTimeout: time.Millisecond * 100,
WriteTimeout: time.Millisecond * 100,
})

return &URLCache{
client: client,
ttl: ttl,
}
}

func (c *URLCache) Get(ctx context.Context, shortCode string) (*CachedURL, error) {
key := "url:" + shortCode
data, err := c.client.Get(ctx, key).Bytes()

if err == redis.Nil {
return nil, nil // cache miss
}
if err != nil {
return nil, err
}

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

return &url, nil
}

func (c *URLCache) Set(ctx context.Context, shortCode string, url *CachedURL) error {
key := "url:" + shortCode
data, err := json.Marshal(url)
if err != nil {
return err
}

return c.client.Set(ctx, key, data, c.ttl).Err()
}

func (c *URLCache) Delete(ctx context.Context, shortCode string) error {
key := "url:" + shortCode
return c.client.Del(ctx, key).Err()
}

5. Основной сервис

package service

import (
"context"
"database/sql"
"errors"
"time"
)

type URLService struct {
db *sql.DB
cache *URLCache
}

type CreateURLRequest struct {
OriginalURL string
UserID *int64
CustomCode *string
ExpiresIn *time.Duration
}

type CreateURLResponse struct {
ShortCode string
ShortURL string
ExpiresAt *time.Time
}

func (s *URLService) CreateShortURL(ctx context.Context, req CreateURLRequest) (*CreateURLResponse, error) {
var shortCode string

if req.CustomCode != nil {
shortCode = *req.CustomCode
// Проверяем уникальность
exists, err := s.codeExists(ctx, shortCode)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("custom code already exists")
}
} else {
shortCode = s.generateUniqueCode(ctx)
}

var expiresAt *time.Time
if req.ExpiresIn != nil {
t := time.Now().Add(*req.ExpiresIn)
expiresAt = &t
}

_, err := s.db.ExecContext(ctx,
`INSERT INTO urls (short_code, original_url, user_id, expires_at)
VALUES ($1, $2, $3, $4)`,
shortCode, req.OriginalURL, req.UserID, expiresAt,
)
if err != nil {
return nil, err
}

// Кэшируем новую ссылку
s.cache.Set(ctx, shortCode, &CachedURL{
OriginalURL: req.OriginalURL,
IsActive: true,
})

return &CreateURLResponse{
ShortCode: shortCode,
ShortURL: "https://short.ly/" + shortCode,
ExpiresAt: expiresAt,
}, nil
}

func (s *URLService) ResolveURL(ctx context.Context, shortCode string) (string, error) {
// 1. Проверяем кэш
cached, err := s.cache.Get(ctx, shortCode)
if err != nil {
// Логируем ошибку, но продолжаем — cache miss не должен ломать сервис
log.Printf("cache error: %v", err)
}

if cached != nil {
if !cached.IsActive {
return "", errors.New("URL is inactive")
}
// Асинхронно увеличиваем счётчик кликов
go s.incrementClickCount(shortCode)
return cached.OriginalURL, nil
}

// 2. Cache miss — читаем из базы
var originalURL string
var isActive bool
var expiresAt sql.NullTime

err = s.db.QueryRowContext(ctx,
`SELECT original_url, is_active, expires_at
FROM urls WHERE short_code = $1`,
shortCode,
).Scan(&originalURL, &isActive, &expiresAt)

if err == sql.ErrNoRows {
return "", errors.New("URL not found")
}
if err != nil {
return "", err
}

if !isActive {
return "", errors.New("URL is inactive")
}

if expiresAt.Valid && expiresAt.Time.Before(time.Now()) {
return "", errors.New("URL expired")
}

// 3. Обновляем кэш
s.cache.Set(ctx, shortCode, &CachedURL{
OriginalURL: originalURL,
IsActive: true,
})

return originalURL, nil
}

6. Стратегия кэширования для 10 000 RPS

// Cache-Aside с прогревом горячих ключей

// Настройки Redis кластера
redisConfig := &redis.ClusterOptions{
Addrs: []string{
"redis-node-1:6379",
"redis-node-2:6379",
"redis-node-3:6379",
},
PoolSize: 100,
MinIdleConns: 20,
MaxRetries: 3,
ReadTimeout: time.Millisecond * 50,
WriteTimeout: time.Millisecond * 50,
PoolTimeout: time.Millisecond * 100,
}

// TTL стратегия
const (
hotURLTTL = 24 * time.Hour // Популярные ссылки
defaultTTL = 4 * time.Hour // Обычные ссылки
prewarmTTL = 1 * time.Hour // Для прогрева
)

// Прогрев кэша при деплое или перезагрузке
func (s *URLService) prewarmCache(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx,
`SELECT short_code, original_url
FROM urls
WHERE is_active = true
ORDER BY click_count DESC
LIMIT 100000`)
if err != nil {
return err
}
defer rows.Close()

for rows.Next() {
var code, url string
if err := rows.Scan(&code, &url); err != nil {
continue
}
s.cache.Set(ctx, code, &CachedURL{
OriginalURL: url,
IsActive: true,
})
}

return nil
}

7. Масштабирование и отказоустойчивость

# docker-compose.yml для локальной разработки
version: '3.8'
services:
app:
build: .
deploy:
replicas: 4
environment:
- DB_HOST=postgres-primary
- REDIS_HOST=redis-cluster

postgres-primary:
image: postgres:15
volumes:
- pg_primary_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: shortener

postgres-replica:
image: postgres:15
command: |
bash -c "
pg_basebackup -h postgres-primary -D /var/lib/postgresql/data -U replicator -v -P -W
echo 'standby_mode = on' >> /var/lib/postgresql/data/recovery.conf
"

redis-cluster:
image: grokzen/redis-cluster:latest
environment:
CLUSTER_ENABLED: "yes"
CLUSTER_REQUIRE_FULL_COVERAGE: "false"

8. Мониторинг и метрики

// Метрики для Prometheus
var (
cacheHits = prometheus.NewCounter(
prometheus.CounterOpts{
Name: "url_cache_hits_total",
Help: "Total number of cache hits",
},
)

cacheMisses = prometheus.NewCounter(
prometheus.CounterOpts{
Name: "url_cache_misses_total",
Help: "Total number of cache misses",
},
)

resolveDuration = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "url_resolve_duration_seconds",
Help: "URL resolve duration",
Buckets: prometheus.DefBuckets,
},
)
)

Итог: для 10 000 RPS с преобладанием чтения оптимальна архитектура с Redis-кластером как основным кэшем (95%+ hit rate), PostgreSQL с репликацией для персистентности, и stateless-приложением на Go. Ключевые метрики: latency < 10ms для cache hit, < 50ms для cache miss, availability 99.9%.

Вопрос 13. Какие проблемы могут возникнуть при чтении из реплики (слейва), если репликация асинхронная? Как обеспечить консистентность?

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

Ответ собеседния: Правильный. При асинхронной репликации возможно рассинхронизация данных — запись произошла в мастер, но ещё не дошла до слейва. Это проблема eventual consistency. Для критичных данных можно использовать синхронную репликацию или читать из мастера. В данном случае, если ссылка не найдена, можно перегенерировать её, что допустимо для бизнеса.

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

Ответ собеседника верен. Раскрою проблемы и решения подробнее.

1. Проблемы асинхронной репликации

А. Replication lag (задержка репликации)

-- На мастере: запись прошла
INSERT INTO urls (short_code, original_url) VALUES ('abc123', 'https://example.com');

-- На слейве: данные могут появиться через 100мс - 10с
SELECT * FROM urls WHERE short_code = 'abc123';
-- Может вернуть 0 строк, если репликация не успела

Типичные значения lag:

  • Нормальный: 10-100 мс
  • При нагрузке: 100мс - 1с
  • Проблемы: > 1с

Б. Конкретные сценарии проблем

// Сценарий 1: Пользователь создал ссылку и сразу перешёл по ней
// POST /api/urls → мастер → OK
// GET /abc123 → слейв → 404 Not Found!

// Сценарий 2: Обновление ссылки
// PUT /api/urls/abc123 → мастер → OK
// GET /abc123 → слейв → старая версия данных

// Сценарий 3: Удаление ссылки
// DELETE /api/urls/abc123 → мастер → OK
// GET /abc123 → слейв → всё ещё возвращает данные

2. Стратегии обеспечения консистентности

А. Чтение из мастера после записи (read-your-writes)

type URLService struct {
primaryDB *sql.DB // мастер
replicaDB *sql.DB // реплика
cache *URLCache
}

func (s *URLService) CreateAndGet(ctx context.Context, req CreateURLRequest) (*CreateURLResponse, error) {
// Запись в мастер
shortCode := s.generateUniqueCode()

_, err := s.primaryDB.ExecContext(ctx,
`INSERT INTO urls (short_code, original_url, user_id) VALUES ($1, $2, $3)`,
shortCode, req.OriginalURL, req.UserID,
)
if err != nil {
return nil, err
}

// Кэшируем — это гарантирует консистентность
s.cache.Set(ctx, shortCode, &CachedURL{
OriginalURL: req.OriginalURL,
IsActive: true,
})

return &CreateURLResponse{
ShortCode: shortCode,
ShortURL: "https://short.ly/" + shortCode,
}, nil
}

func (s *URLService) ResolveURL(ctx context.Context, shortCode string) (string, error) {
// 1. Сначала кэш
cached, _ := s.cache.Get(ctx, shortCode)
if cached != nil {
return cached.OriginalURL, nil
}

// 2. Читаем из реплики
var originalURL string
err := s.replicaDB.QueryRowContext(ctx,
`SELECT original_url FROM urls WHERE short_code = $1 AND is_active = true`,
shortCode,
).Scan(&originalURL)

if err == sql.ErrNoRows {
// 3. Fallback на мастер для свежих данных
err = s.primaryDB.QueryRowContext(ctx,
`SELECT original_url FROM urls WHERE short_code = $1 AND is_active = true`,
shortCode,
).Scan(&originalURL)
}

if err != nil {
return "", err
}

// Обновляем кэш
s.cache.Set(ctx, shortCode, &CachedURL{
OriginalURL: originalURL,
IsActive: true,
})

return originalURL, nil
}

Б. Синхронная репликация

-- PostgreSQL: synchronous replication
-- postgresql.conf
synchronous_standby_names = 'replica1'
synchronous_commit = on -- или 'remote_apply' для максимальной консистентности

-- Транзакция не завершится, пока слейв не подтвердит получение
BEGIN;
INSERT INTO urls (short_code, original_url) VALUES ('abc123', 'https://example.com');
COMMIT; -- ждёт подтверждения от слейва

В. Мониторинг replication lag

// Запрос для проверки lag в PostgreSQL
const checkLagQuery = `
SELECT
CASE
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
THEN 0
ELSE EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))
END AS lag_seconds
FROM pg_stat_wal_receiver;
`

func (s *URLService) checkReplicationLag(ctx context.Context) (float64, error) {
var lag float64
err := s.replicaDB.QueryRowContext(ctx, checkLagQuery).Scan(&lag)
return lag, err
}

3. Паттерн Stale-While-Revalidate

func (s *URLService) ResolveWithGracePeriod(ctx context.Context, shortCode string) (string, error) {
cached, _ := s.cache.Get(ctx, shortCode)

if cached != nil {
// Проверяем, не устарел ли кэш
age := s.cache.GetAge(ctx, shortCode)

if age < 5*time.Minute {
// Свежий кэш — возвращаем
return cached.OriginalURL, nil
}

// Устревший кэш — возвращаем, но обновляем в фоне
go s.refreshCache(shortCode)
return cached.OriginalURL, nil
}

// Нет кэша — читаем из мастера
return s.resolveFromMaster(ctx, shortCode)
}

4. Сравнение стратегий консистентности

СтратегияКонсистентностьLatencyСложностьКогда использовать
Read from masterStrongВышеНизкаяКритичные данные
Cache + fallbackEventualНизкаяСредняяБольшинство случаев
Sync replicationStrongВысокаяНизкаяФинансы, платежы
Stale-while-revalidateEventualОчень низкаяВысокаяВысоконагруженные чтения

5. Рекомендация для сервиса коротких ссылок

Для сервиса типа bit.ly оптимальна комбинация:

// 1. Запись → мастер + инвалидация/установка кэша
// 2. Чтение → кэш → реплика → мастер (fallback)
// 3. TTL кэша достаточно большой (часы)
// 4. Мониторинг replication lag

// Это даёт:
// - 99.9% запросов обслуживаются из кэша (< 5ms)
// - 0.09% из реплики (< 20ms)
// - 0.01% fallback на мастер (< 50ms)
// - Eventual consistency с окном < 1с

Итог: асинхронная репликация создаёт окно eventual consistency. Для сервиса коротких ссылок это приемлемо — используйте кэш как основной источник, реплику как fallback, мастер для записи. Для критичных данных рассмотрите синхронную репликацию или чтение из мастера.

Вопрос 14. Всегда ли нужно читать из базы данных, если данные статичные? Какую стратегию кэширования можно применить?

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

Ответ собеседника: Правильный. Нет, не всегда. Для статичных данных (короткие ссылки редко меняются) можно использовать кэширование в приложении (in-memory cache) с вытеснением по времени (TTL) или по стратегии LRU. Это снизит нагрузку на базу данных и ускорит ответы.

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

Ответ собеседника верен. Разберём стратегии кэширования подробнее.

1. Уровни кэширования

Запрос → [L1: In-Memory Cache] → [L2: Redis] → [L3: Database]
~1μs ~1ms ~10ms
10K-100K entries millions unlimited

2. In-Memory Cache в приложении

А. Простой TTL-кэш

package cache

import (
"sync"
"time"
)

type MemoryCache struct {
mu sync.RWMutex
items map[string]*cacheEntry
ttl time.Duration
maxSize int
}

type cacheEntry struct {
value interface{}
expiresAt time.Time
}

func NewMemoryCache(ttl time.Duration, maxSize int) *MemoryCache {
c := &MemoryCache{
items: make(map[string]*cacheEntry),
ttl: ttl,
maxSize: maxSize,
}

// Запускаем очистку устаревших записей
go c.cleanup()

return c
}

func (c *MemoryCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

entry, ok := c.items[key]
if !ok {
return nil, false
}

if time.Now().After(entry.expiresAt) {
return nil, false
}

return entry.value, true
}

func (c *MemoryCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()

// Проверяем размер кэша
if len(c.items) >= c.maxSize {
c.evict()
}

c.items[key] = &cacheEntry{
value: value,
expiresAt: time.Now().Add(c.ttl),
}
}

func (c *MemoryCache) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, entry := range c.items {
if now.After(entry.expiresAt) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}

Б. LRU Cache (Least Recently Used)

package cache

import (
"container/list"
"sync"
)

type LRUCache struct {
mu sync.RWMutex
items map[string]*list.Element
list *list.List
capacity int
}

type lruEntry struct {
key string
value interface{}
}

func NewLRUCache(capacity int) *LRUCache {
return &LRUCache{
items: make(map[string]*list.Element),
list: list.New(),
capacity: capacity,
}
}

func (c *LRUCache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()

elem, ok := c.items[key]
if !ok {
return nil, false
}

// Перемещаем в начало (самый свежий)
c.list.MoveToFront(elem)
return elem.Value.(*lruEntry).value, true
}

func (c *LRUCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()

// Если ключ уже есть — обновляем
if elem, ok := c.items[key]; ok {
c.list.MoveToFront(elem)
elem.Value.(*lruEntry).value = value
return
}

// Добавляем новый
entry := &lruEntry{key: key, value: value}
elem := c.list.PushFront(entry)
c.items[key] = elem

// Вытесняем самый старый если превышен лимит
if c.list.Len() > c.capacity {
oldest := c.list.Back()
if oldest != nil {
c.list.Remove(oldest)
delete(c.items, oldest.Value.(*lruEntry).key)
}
}
}

В. Использование библиотеки (ristretto)

import "github.com/dgraph-io/ristretto"

func NewRistrettoCache() *ristretto.Cache {
cache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // количество ключей для отслеживания частоты
MaxCost: 1 << 30, // 1 GB
BufferItems: 64, // размер буфера для записи
})
if err != nil {
panic(err)
}
return cache
}

3. Многоуровневое кэширование

type MultiLevelCache struct {
l1 *ristretto.Cache // In-memory, ~1μs
l2 *redis.Client // Redis, ~1ms
db *sql.DB // Database, ~10ms
}

func (c *MultiLevelCache) Get(ctx context.Context, key string) (string, error) {
// L1: In-Memory
if val, ok := c.l1.Get(key); ok {
metrics.L1Hits.Inc()
return val.(string), nil
}

// L2: Redis
val, err := c.l2.Get(ctx, key).Result()
if err == nil {
metrics.L2Hits.Inc()
// Обновляем L1
c.l1.Set(key, val, 1)
return val, nil
}

// L3: Database
var originalURL string
err = c.db.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_code = $1", key,
).Scan(&originalURL)

if err != nil {
return "", err
}

metrics.L3Hits.Inc()

// Обновляем L2 и L1
c.l2.Set(ctx, key, originalURL, 24*time.Hour)
c.l1.Set(key, originalURL, 1)

return originalURL, nil
}

4. Стратегии инвалидации кэша

// Cache-Aside (Lazy Loading)
func (s *URLService) ResolveURL(ctx context.Context, shortCode string) (string, error) {
// Проверяем кэш
if val, ok := s.cache.Get(shortCode); ok {
return val, nil
}

// Читаем из БД
url, err := s.db.GetURL(ctx, shortCode)
if err != nil {
return "", err
}

// Обновляем кэш
s.cache.Set(shortCode, url)
return url, nil
}

// Write-Through
func (s *URLService) CreateURL(ctx context.Context, req CreateURLRequest) error {
// Записываем в БД
shortCode, err := s.db.CreateURL(ctx, req)
if err != nil {
return err
}

// Сразу обновляем кэш
s.cache.Set(shortCode, req.OriginalURL)
return nil
}

// Write-Behind (Write-Back)
func (s *URLService) CreateURLAsync(ctx context.Context, req CreateURLRequest) error {
// Обновляем кэш сразу
shortCode := generateCode()
s.cache.Set(shortCode, req.OriginalURL)

// Асинхронно пишем в БД
go s.db.CreateURL(context.Background(), req)

return nil
}

5. Сравнение стратегий кэширования

СтратегияПлюсыМинусыКогда использовать
Cache-AsideПростота, гибкостьПервый запрос всегда медленныйЧтение > Запись
Write-ThroughКонсистентностьМедленная записьВажна консистентность
Write-BehindБыстрая записьРиск потери данныхВысокая нагрузка на запись
TTLАвтоматическая очисткаДанные могут быть устаревшимиСтатичные данные

6. Рекомендация для сервиса коротких ссылок

// Оптимальная стратегия для bit.ly:
// 1. In-memory cache (LRU) для горячих ссылок
// 2. Redis для всех ссылок
// 3. Долгий TTL (24-48 часов)
// 4. Инвалидация при удалении/обновлении

type URLResolver struct {
hotCache *ristretto.Cache // 10K горячих ссылок
redis *redis.Client // Все ссылки
db *sql.DB // Персистентное хранилище
}

func (r *URLResolver) Resolve(ctx context.Context, code string) (string, error) {
// L1: Hot cache (< 1μs)
if url, ok := r.hotCache.Get(code); ok {
return url.(string), nil
}

// L2: Redis (< 5ms)
url, err := r.redis.Get(ctx, "url:"+code).Result()
if err == nil {
r.hotCache.Set(code, url, 1)
return url, nil
}

// L3: Database (< 50ms)
url, err = r.db.GetURL(ctx, code)
if err != nil {
return "", err
}

// Обновляем оба уровня кэша
r.redis.Set(ctx, "url:"+code, url, 48*time.Hour)
r.hotCache.Set(code, url, 1)

return url, nil
}

Итог: для статичных данных используйте многоуровневое кэширование — in-memory для горячих данных, Redis для всех данных, БД как персистентное хранилище. Стратегия Cache-Aside с долгим TTL оптимальна для сервиса коротких ссылок.

Вопрос 15. Как масштабировать приложение и базы данных в зависимости нагрузки? Как реализовать автоскейлинг?

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

Ответ собеседника: Правильный. Можно использовать балансировщик нагрузки (ingress controller) с автоскейлингом на основе метрик (CPU, RPS). Kubernetes поддерживает Horizontal Pod Autoscaler (HPA) для автоматического масштабирования подов. Для баз данных можно использовать реплики для чтения и шардирование для записи.

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

Ответ собеседника корректен. Раскрою тему подробнее с конфигурациями.

1. Масштабирование приложения в Kubernetes

А. Horizontal Pod Autoscaler (HPA)

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: url-shortener-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: url-shortener
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "1000"
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60

Б. Deployment с resource limits

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: url-shortener
spec:
replicas: 3
selector:
matchLabels:
app: url-shortener
template:
metadata:
labels:
app: url-shortener
spec:
containers:
- name: app
image: url-shortener:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20

В. Cluster Autoscaler

# cluster-autoscaler.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-autoscaler
namespace: kube-system
spec:
template:
spec:
containers:
- name: cluster-autoscaler
image: k8s.gcr.io/autoscaling/cluster-autoscaler:v1.25.0
command:
- ./cluster-autoscaler
- --v=4
- --stderrthreshold=info
- --cloud-provider=aws
- --skip-nodes-with-local-storage=false
- --expander=least-waste
- --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled

2. Кастомные метрики для автоскейлинга

// metrics.go - экспорт метрик для Prometheus
package metrics

import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)

var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests",
},
[]string{"method", "endpoint", "status"},
)

httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint"},
)

activeConnections = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "active_connections",
Help: "Number of active connections",
},
)

cacheHitRate = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cache_hit_rate",
Help: "Cache hit rate by level",
},
[]string{"level"},
)
)
# prometheus-adapter-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-adapter-config
data:
config.yaml: |
rules:
- seriesQuery: 'http_requests_total{namespace="default"}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^(.*)_total"
as: "${1}_per_second"
metricsQuery: 'rate(<<.Series>>{<<.LabelMatchers>>}[2m])'

3. Масштабирование базы данных

А. Read Replicas

// db.go - пул соединений с маршрутизацией
package database

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

type DBCluster struct {
primary *sql.DB
replicas []*sql.DB
mu sync.RWMutex
current int // round-robin counter
}

func NewDBCluster(primaryConn string, replicaConns []string) (*DBCluster, error) {
primary, err := sql.Open("postgres", primaryConn)
if err != nil {
return nil, err
}

primary.SetMaxOpenConns(50)
primary.SetMaxIdleConns(10)

cluster := &DBCluster{
primary: primary,
replicas: make([]*sql.DB, 0, len(replicaConns)),
}

for _, conn := range replicaConns {
replica, err := sql.Open("postgres", conn)
if err != nil {
return nil, err
}
replica.SetMaxOpenConns(100)
replica.SetMaxIdleConns(20)
cluster.replicas = append(cluster.replicas, replica)
}

return cluster, nil
}

func (c *DBCluster) Primary() *sql.DB {
return c.primary
}

func (c *DBCluster) Replica() *sql.DB {
c.mu.Lock()
defer c.mu.Unlock()

if len(c.replicas) == 0 {
return c.primary
}

replica := c.replicas[c.current%len(c.replicas)]
c.current++
return replica
}

func (c *DBCluster) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
return c.Replica().QueryContext(ctx, query, args...)
}

func (c *DBCluster) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return c.Primary().ExecContext(ctx, query, args...)
}

Б. Шардирование

// sharding.go - шардирование по short_code
package sharding

import (
"database/sql"
"hash/fnv"
)

type ShardedDB struct {
shards []*sql.DB
numShards int
}

func NewShardedDB(connStrings []string) (*ShardedDB, error) {
shards := make([]*sql.DB, len(connStrings))

for i, conn := range connStrings {
db, err := sql.Open("postgres", conn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(50)
shards[i] = db
}

return &ShardedDB{
shards: shards,
numShards: len(shards),
}, nil
}

func (s *ShardedDB) getShard(key string) *sql.DB {
h := fnv.New32a()
h.Write([]byte(key))
return s.shards[h.Sum32()%uint32(s.numShards)]
}

func (s *ShardedDB) GetURL(ctx context.Context, shortCode string) (string, error) {
shard := s.getShard(shortCode)
var url string
err := shard.QueryRowContext(ctx,
"SELECT original_url FROM urls WHERE short_code = $1", shortCode,
).Scan(&url)
return url, err
}

func (s *ShardedDB) CreateURL(ctx context.Context, shortCode, url string) error {
shard := s.getShard(shortCode)
_, err := shard.ExecContext(ctx,
"INSERT INTO urls (short_code, original_url) VALUES ($1, $2)",
shortCode, url,
)
return err
}

4. Масштабирование Redis

// redis_cluster.go
package cache

import (
"github.com/redis/go-redis/v9"
)

func NewRedisCluster(addrs []string) *redis.ClusterClient {
return redis.NewClusterClient(&redis.ClusterOptions{
Addrs: addrs,
PoolSize: 100,
MinIdleConns: 20,
MaxRetries: 3,
ReadOnly: true, // читать с реплик
RouteRandomly: true,
})
}

5. Стратегии масштабирования

КомпонентВертикальноеГоризонтальноеАвтоскейлинг
ApplicationУвеличить CPU/RAMДобавить подыHPA по CPU/RAM/RPS
PostgreSQLУвеличить инстансRead replicasРучное или через operator
RedisУвеличить инстансCluster modeRedis Cluster resharding
CacheУвеличить памятьШардированиеАвтоматическое

6. Мониторинг и алертинг

# prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: url-shortener-alerts
spec:
groups:
- name: url-shortener
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate detected"

- alert: HighLatency
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "P95 latency > 100ms"

- alert: HighCPUUsage
expr: avg(rate(container_cpu_usage_seconds_total{pod=~"url-shortener.*"}[5m])) by (pod) > 0.8
for: 10m
labels:
severity: warning
annotations:
summary: "Pod CPU usage > 80%"

Итог: автоскейлинг в Kubernetes реализуется через HPA с метриками CPU, RPS и кастомными метриками. Для баз данных используйте read replicas для масштабирования чтения и шардирование для записи. Redis Cluster обеспечивает горизонтальное масштабирование кэша.

Вопрос 16. Что такое gRPC и чем он отличается от HTTP/REST? Почему gRPC эффективнее?

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

Ответ собеседника: Правильный. gRPC — это бинарный протокол на основе HTTP/2. Преимущества: 1) Бинарная сериализация (Protobuf) вместо JSON — меньший размер пакетов; 2) Поддержка потоковой передачи (streaming); 3) Мультиплексирование запросов в одном соединении; 4) Автоматическая генерация кода из .proto файлов. Это делает gRPC эффективнее для внутренней коммуникации между сервисами.

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

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

1. Сравнение gRPC и REST

ХарактеристикаgRPCREST (HTTP/JSON)
ПротоколHTTP/2HTTP/1.1 или HTTP/2
ФорматProtobuf (бинарный)JSON/XML (текстовый)
Контракт.proto файлы (строгий)OpenAPI/Swagger (опциональный)
StreamingНативныйНет (WebSocket отдельно)
КодогенерацияВстроеннаяЧерез сторонние инструменты
Браузерная поддержкаЧерез gRPC-WebНативная
Размер сообщения~5-10x меньшеБольше из-за текста

2. Protobuf vs JSON — размер и скорость

// user.proto
syntax = "proto3";

package user;

service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
rpc CreateUser(stream CreateUserRequest) returns (CreateUserResponse);
}

message User {
int64 id = 1;
string name = 2;
string email = 3;
repeated string tags = 4;
Address address = 5;
}

message Address {
string street = 1;
string city = 2;
string country = 3;
}

message GetUserRequest {
int64 id = 1;
}

message ListUsersRequest {
int32 page = 1;
int32 page_size = 2;
}

message CreateUserRequest {
string name = 1;
string email = 2;
}

message CreateUserResponse {
repeated User users = 1;
}
// Сравнение размера сообщений
func compareSize() {
user := User{
ID: 12345,
Name: "John Doe",
Email: "john@example.com",
Tags: []string{"admin", "user"},
}

// JSON: ~150 байт
jsonData, _ := json.Marshal(user)
fmt.Printf("JSON: %d bytes\n", len(jsonData))

// Protobuf: ~30 байт (5x меньше)
protoData, _ := proto.Marshal(&user)
fmt.Printf("Protobuf: %d bytes\n", len(protoData))
}

3. Типы RPC в gRPC

// server.go
package main

import (
"context"
"io"
"log"
"net"

"google.golang.org/grpc"
pb "path/to/your/proto"
)

type userServiceServer struct {
pb.UnimplementedUserServiceServer
}

// Unary RPC — один запрос, один ответ
func (s *userServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.userRepo.GetByID(ctx, req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
}
return convertToProto(user), nil
}

// Server Streaming — один запрос, поток ответов
func (s *userServiceServer) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
users, err := s.userRepo.List(stream.Context(), int(req.Page), int(req.PageSize))
if err != nil {
return err
}

for _, user := range users {
if err := stream.Send(convertToProto(user)); err != nil {
return err
}
}
return nil
}

// Client Streaming — поток запросов, один ответ
func (s *userServiceServer) CreateUser(stream pb.UserService_CreateUserServer) error {
var users []*User

for {
req, err := stream.Recv()
if err == io.EOF {
// Клиент закончил отправку
return stream.SendAndClose(&pb.CreateUserResponse{
Users: convertToProtoList(users),
})
}
if err != nil {
return err
}

user := &User{Name: req.Name, Email: req.Email}
if err := s.userRepo.Create(stream.Context(), user); err != nil {
return err
}
users = append(users, user)
}
}

// Bidirectional Streaming — поток запросов и ответов
func (s *userServiceServer) Chat(stream pb.UserService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}

response := &pb.ChatMessage{
User: "server",
Message: "Echo: " + msg.Message,
}
if err := stream.Send(response); err != nil {
return err
}
}
}

func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}

server := grpc.NewServer(
grpc.UnaryInterceptor(unaryInterceptor),
grpc.StreamInterceptor(streamInterceptor),
)
pb.RegisterUserServiceServer(server, &userServiceServer{})

log.Println("gRPC server starting on :50051")
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

4. Клиент gRPC

// client.go
package main

import (
"context"
"io"
"log"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
pb "path/to/your/proto"
)

func main() {
conn, err := grpc.Dial("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(1024 * 1024 * 10), // 10 MB
),
)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

client := pb.NewUserServiceClient(conn)

// Unary call
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

user, err := client.GetUser(ctx, &pb.GetUserRequest{Id: 123})
if err != nil {
log.Fatalf("could not get user: %v", err)
}
log.Printf("User: %v", user)

// Server streaming
stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{Page: 1, PageSize: 10})
if err != nil {
log.Fatalf("could not list users: %v", err)
}

for {
user, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("error receiving: %v", err)
}
log.Printf("User: %v", user)
}
}

5. Интерцепторы (middleware)

// interceptors.go
package main

import (
"context"
"log"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// Unary interceptor для логирования
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()

resp, err := handler(ctx, req)

log.Printf("method=%s duration=%s err=%v",
info.FullMethod,
time.Since(start),
err,
)

return resp, err
}

// Unary interceptor для аутентификации
func authInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Пропускаем методы без аутентификации
if info.FullMethod == "/user.UserService/HealthCheck" {
return handler(ctx, req)
}

md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "metadata not provided")
}

tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Errorf(codes.Unauthenticated, "token not provided")
}

if !validateToken(tokens[0]) {
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
}

return handler(ctx, req)
}

// Stream interceptor
func streamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Printf("stream started: %s", info.FullMethod)
err := handler(srv, ss)
log.Printf("stream ended: %s, err=%v", info.FullMethod, err)
return err
}

6. Когда использовать gRPC vs REST

// Используйте gRPC для:
// - Внутренней коммуникации между микросервисами
// - Высоконагруженных систем (низкая латентность)
// - Streaming данных
// - Строгих контрактов между сервисами

// Используйте REST для:
// - Публичных API (браузеры, мобильные приложения)
// - Простых CRUD операций
// - Когда важна читаемость и отладка
// - Интеграции с внешними системами

// API Gateway паттерн: REST снаружи, gRPC внутри
// Client → REST API Gateway → gRPC → Microservices

7. gRPC-Web для браузеров

// grpc-web-wrapper.go
package main

import (
"net/http"

"github.com/improbable-eng/grpc-web/go/grpcweb"
"google.golang.org/grpc"
)

func main() {
grpcServer := grpc.NewServer()
// регистрируем сервисы...

wrappedServer := grpcweb.WrapServer(grpcServer,
grpcweb.WithOriginFunc(func(origin string) bool {
return origin == "https://yourdomain.com"
}),
)

httpServer := http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if wrappedServer.IsGrpcWebRequest(r) {
wrappedServer.ServeHTTP(w, r)
return
}
// Обычный REST handler
http.NotFound(w, r)
}),
}

httpServer.ListenAndServe()
}

Итог: gRPC эффективнее REST благодаря бинарной сериализации Protobuf, HTTP/2 с мультиплексированием и нативной поддержке streaming. Используйте gRPC для внутренней коммуникации между сервисами, а REST — для публичных API.

Вопрос 17. Какие шаги вы бы включили в CI/CD pipeline для сервиса? Какие тесты необходимы?

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

Ответ собеседника: Правильный. Pipeline должен включать: 1) Юнит-тесты; 2) Интеграционные тесты; 3) Сборку (build); 4) Деплой в staging; 5) Нагрузочное тестирование; 6) Деплой в production. Обязательно автоматизированное тестирование перед каждым деплоем. Для сервиса коротких ссылок можно обойтись юнит- и интеграционными тестами без сложных E2E.

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

Ответ собеседника корректен. Раскрою pipeline подробнее с конфигурациями.

1. Полная схема CI/CD Pipeline

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Commit │───▶│ Lint │───▶│ Unit Tests │───▶│ Build │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘


┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Production │◀───│ Canary │◀───│ Staging │◀───│ Integration │
│ Deploy │ │ Deploy │ │ Deploy │ │ Tests │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘

2. GitHub Actions Pipeline

# .github/workflows/ci-cd.yaml
name: CI/CD Pipeline

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

env:
GO_VERSION: '1.21'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
# ============ CI ============
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}

- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m

unit-tests:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}

- name: Run unit tests
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out

- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: coverage.out

build:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4

- name: Build Docker image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

- name: Push to registry
if: github.ref == 'refs/heads/main'
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

integration-tests:
runs-on: ubuntu-latest
needs: build
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}

- name: Run integration tests
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
REDIS_URL: redis://localhost:6379
run: go test -v -tags=integration ./tests/integration/...

# ============ CD ============
deploy-staging:
runs-on: ubuntu-latest
needs: integration-tests
if: github.ref == 'refs/heads/main'
environment: staging
steps:
- uses: actions/checkout@v4

- name: Deploy to staging
run: |
kubectl set image deployment/url-shortener \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
--namespace=staging

- name: Run smoke tests
run: |
sleep 30
curl -f https://staging.short.ly/health || exit 1

load-tests:
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4

- name: Run k6 load tests
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load/script.js
flags --out json=results.json

deploy-production:
runs-on: ubuntu-latest
needs: load-tests
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Canary deployment (10%)
run: |
kubectl apply -f k8s/canary.yaml
sleep 300 # 5 минут мониторинга

- name: Full deployment
run: |
kubectl set image deployment/url-shortener \
app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
--namespace=production

- name: Verify deployment
run: |
kubectl rollout status deployment/url-shortener --namespace=production

3. Типы тестов

А. Юнит-тесты

// service/url_service_test.go
package service

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockURLRepository struct {
mock.Mock
}

func (m *MockURLRepository) GetByCode(ctx context.Context, code string) (*URL, error) {
args := m.Called(ctx, code)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*URL), args.Error(1)
}

func (m *MockURLRepository) Create(ctx context.Context, url *URL) error {
return m.Called(ctx, url).Error(0)
}

func TestURLService_ResolveURL(t *testing.T) {
tests := []struct {
name string
code string
mockSetup func(*MockURLRepository, *MockCache)
wantURL string
wantErr bool
}{
{
name: "cache hit",
code: "abc123",
mockSetup: func(repo *MockURLRepository, cache *MockCache) {
cache.On("Get", mock.Anything, "abc123").
Return(&CachedURL{OriginalURL: "https://example.com", IsActive: true}, nil)
},
wantURL: "https://example.com",
wantErr: false,
},
{
name: "cache miss, db hit",
code: "def456",
mockSetup: func(repo *MockURLRepository, cache *MockCache) {
cache.On("Get", mock.Anything, "def456").Return(nil, nil)
repo.On("GetByCode", mock.Anything, "def456").
Return(&URL{OriginalURL: "https://example.org", IsActive: true}, nil)
cache.On("Set", mock.Anything, "def456", mock.Anything).Return(nil)
},
wantURL: "https://example.org",
wantErr: false,
},
{
name: "not found",
code: "notexist",
mockSetup: func(repo *MockURLRepository, cache *MockCache) {
cache.On("Get", mock.Anything, "notexist").Return(nil, nil)
repo.On("GetByCode", mock.Anything, "notexist").
Return(nil, errors.New("not found"))
},
wantURL: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := new(MockURLRepository)
cache := new(MockCache)
tt.mockSetup(repo, cache)

svc := NewURLService(repo, cache)
url, err := svc.ResolveURL(context.Background(), tt.code)

if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantURL, url)
}

repo.AssertExpectations(t)
cache.AssertExpectations(t)
})
}
}

Б. Интеграционные тесты

// tests/integration/url_test.go
//go:build integration

package integration

import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)

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

// Запускаем PostgreSQL в контейнере
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:15"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)

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

// Инициализируем приложение с тестовой БД
app := setupTestApp(connStr)
defer app.Shutdown()

// Создаём короткую ссылку
createReq := map[string]string{
"original_url": "https://example.com/very/long/path",
}
body, _ := json.Marshal(createReq)

resp, err := http.Post(app.URL+"/api/urls", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusCreated, resp.StatusCode)

var createResp struct {
ShortCode string `json:"short_code"`
ShortURL string `json:"short_url"`
}
json.NewDecoder(resp.Body).Decode(&createResp)
assert.NotEmpty(t, createResp.ShortCode)

// Резолвим короткую ссылку
resp, err = http.Get(app.URL + "/" + createResp.ShortCode)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "https://example.com/very/long/path", resp.Header.Get("Location"))
}

В. Нагрузочные тесты (k6)

// tests/load/script.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
stages: [
{ duration: '1m', target: 100 }, // Разогрев
{ duration: '3m', target: 1000 }, // Наращивание
{ duration: '5m', target: 5000 }, // Пиковая нагрузка
{ duration: '2m', target: 10000 }, // Максимум
{ duration: '2m', target: 0 }, // Снижение
],
thresholds: {
http_req_duration: ['p(95)<100'], // 95% запросов < 100ms
http_req_failed: ['rate<0.01'], // < 1% ошибок
},
};

const BASE_URL = __ENV.BASE_URL || 'https://staging.short.ly';

// Предварительно созданные короткие ссылки для тестирования
const SHORT_CODES = [
'abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'BCD890',
];

export default function () {
// 90% чтений, 10% записей
if (Math.random() < 0.9) {
// GET запрос — резолв ссылки
const code = SHORT_CODES[Math.floor(Math.random() * SHORT_CODES.length)];
const res = http.get(`${BASE_URL}/${code}`, {
redirects: 0, // Не следовать редиректам
});

check(res, {
'status is 302': (r) => r.status === 302,
'has location header': (r) => r.headers['Location'] !== undefined,
});
} else {
// POST запрос — создание ссылки
const payload = JSON.stringify({
original_url: `https://example.com/test/${Date.now()}`,
});

const res = http.post(`${BASE_URL}/api/urls`, payload, {
headers: { 'Content-Type': 'application/json' },
});

check(res, {
'status is 201': (r) => r.status === 201,
'has short_code': (r) => JSON.parse(r.body).short_code !== undefined,
});
}

sleep(0.1); // 100ms между запросами
}

4. Dockerfile

# Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Кэширование зависимимостей
COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server

# Минимальный образ
FROM alpine:3.18

RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app

COPY --from=builder /app/server .

EXPOSE 8080

USER 1000:1000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["./server"]

5. Makefile для локальной разработки

# Makefile
.PHONY: all test lint build run

all: lint test build

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

test-integration:
docker-compose -f docker-compose.test.yml up -d
go test -v -tags=integration ./tests/integration/...
docker-compose -f docker-compose.test.yml down

lint:
golangci-lint run ./...

build:
CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/server ./cmd/server

run:
go run ./cmd/server

docker-build:
docker build -t url-shortener:latest .

docker-run:
docker-compose up -d

load-test:
k6 run tests/load/script.js

Итог: CI/CD pipeline должен включать lint, unit tests, build, integration tests, deploy to staging, load tests и deploy to production. Для сервиса коротких ссылок критичны нагрузочные тесты — они проверяют кэширование и производительность при 10K+ RPS.

Вопрос 18. Кандидат оценил свой уровень как solid middle. Из минусов Go отметил error handling, но сейчас это уже не проблема. Готовится к собеседованиям по принципу «не готовиться» — полагается на реальный опыт.

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

Ответ собеседника: Правильный. Кандидат оценил свой уровень как solid middle. Из минусов Go отметил, что пару лет назад не нравился error handling, но сейчас это уже не проблема. Готовится к собеседованиям по принципу «не готовиться» — полагается на реальный опыт и знания, полученные из практики. Для подготовки рекомендует статьи на Хабре и документацию Go.

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

Это финальный вопрос интервью — вопросы кандидата к компании и самооценка. Разберём, как лучше отвечать на подобные вопросы.

1. Как оценивать свой уровень

Честная самооценка с аргументацией:

Junior: Базовый синтаксис, простые задачи, нужен ментор
Middle: Уверенная работа с Go, понимание конкурентности,
может самостоятельно решать задачи
Senior: Проектирование систем, оптимизация, менторинг,
глубокое понимание runtime и экосистемы

Пример хорошего ответа:

> «Оцениваю себя как solid middle. Уверенно работаю с Go — конкурентность, каналы, контексты, профилирование. Могу самостоятельно спроектировать и реализовать сервис. До senior не хватает опыта проектирования сложных распределённых систем и менторинга других разработчиков.»

2. Вопросы к компании — что спрашивать

О технической стороне:

- Какой стек технологий? (Go версии, фреймворки, базы данных)
- Есть ли микросервисная архитектура? Как серщаются между собой?
- Как устроен CI/CD pipeline?
- Есть ли код-ревью? Какой процесс?
- Как мониторите продакшн? (метрики, алерты, трейсинг)

О процессах:

- Как устроен процесс разработки? (Scrum, Kanban)
- Как часто релизы?
- Есть ли on-call?
- Как распределяется ответственность за сервисы?

О развитии:

- Есть ли бюджет на конференции и курсы?
- Есть ли внутренние tech talks?
- Какие перспективы роста?
- Есть ли менторинг?

3. Что не нравится в Go — как отвечать

А. Error handling (самая частая претензия)

// Что может не нравиться:
if err != nil {
return err
}
if err != nil {
return err
}
if err != nil {
return err
}

// Как с этим жить:
// 1. Оборачивайте ошибки с контекстом
if err != nil {
return fmt.Errorf("failed to get user %d: %w", id, err)
}

// 2. Используйте sentinel errors
var ErrNotFound = errors.New("not found")

// 3. Используйте errors.Is / errors.As
if errors.Is(err, ErrNotFound) {
// handle not found
}

Б. Отсутствие исключений — плюс или минус?

// В Go ошибки — это значения, а не исключения
// Это делает поток управления предсказуемым

// Плюсы:
// - Явная обработка ошибок
// - Нет неожиданных panic
// - Легче понять, что может пойти не так

// Минусы:
// - Многословность
// - Повторяющийся код

В. Другие частые претензии

1. Нет дженериков (до Go 1.18) — сейчас есть
2. Нет enum — используйте iota + константы
3. Нет immutable структур — привыкаем
4. Длинное имя языка для поиска в интернете

4. Как готовиться к собеседованиям

Рекомендуемые ресурсы:

Документация:
- https://go.dev/doc/effective_go
- https://go.dev/blog/
- https://pkg.go.dev/std

Книги:
- "The Go Programming Language" (Donovan & Kernighan)
- "Concurrency in Go" (Katherine Cox-Buday)
- "100 Go Mistakes" (Teiva Harsanyi)

Практика:
- LeetCode (Easy/Medium на Go)
- Exercism.io (Go track)
- Реальные проекты на GitHub

Подготовка:
- Расскажите о своём проекте в формате STAR
- Подготовьте 3-5 историй о решённых проблемах
- Потренируйтесь объяснять код вслух

Итог: на финальные вопросы важно быть честным в самооценке, задавать осмысленные вопросы о компании и показать, что вы интересуетесь технологиями и хотите расти.

Вопрос 19. Как бы вы избежали кризиса, когда контейнеров становится много и нужно управлять их регистрацией и деплоем?

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

Ответ собеседника: Правильный. При большом количестве контейнеров в production необходимо использовать оркестратор (например, Kubernetes) для управления деплоем, масштабированием и мониторингом. Также полезно использовать внешние сервисы (managed services) вместо самостоятельного развёртывания, если это позволяет бизнес, чтобы сэкономить время и ресурсы команды. В компании есть отличная DevOps-команда, которая берёт на себя инфраструктурные задачи.

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

Ответ собеседника корректен. Раскрою тему управления контейнерами подробнее.

1. Эволюция управления контейнерами

Этап 1: Ручное управление (1-5 контейнеров)
├── docker-compose
├── Ручной деплой по SSH
└── Проблемы: нет автоматизации, человеческий фактор

Этап 2: Оркестрация (5-50 контейнеров)
├── Kubernetes / Docker Swarm
├── CI/CD pipeline
└── Проблемы: сложность настройки, нужен DevOps

Этап 3: Managed сервисы (50+ контейнеров)
├── AWS ECS/EKS, GCP GKE, Azure AKS
├── Serverless контейнеры (Fargate)
└── Проблемы: vendor lock-in, стоимость

2. Kubernetes — стандарт оркестрации

# Пример полного манифеста для сервиса
apiVersion: apps/v1
kind: Deployment
metadata:
name: url-shortener
labels:
app: url-shortener
spec:
replicas: 3
selector:
matchLabels:
app: url-shortener
template:
metadata:
labels:
app: url-shortener
spec:
containers:
- name: app
image: url-shortener:v1.2.3
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: url-shortener
spec:
selector:
app: url-shortener
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: url-shortener
annotations:
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts:
- short.ly
secretName: short-ly-tls
rules:
- host: short.ly
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: url-shortener
port:
number: 80

3. GitOps с ArgoCD

# argocd-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: url-shortener
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/company/url-shortener.git
targetRevision: HEAD
path: k8s/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

4. Helm для управления конфигурациями

# Chart.yaml
apiVersion: v2
name: url-shortener
version: 1.0.0
description: URL Shortener Service

# values.yaml
replicaCount: 3

image:
repository: ghcr.io/company/url-shortener
tag: "v1.2.3"
pullPolicy: IfNotPresent

resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi

autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 50
targetCPUUtilization: 70

ingress:
enabled: true
host: short.ly
tls: true

5. Managed сервисы как альтернатива

// AWS Fargate — serverless контейнеры
// Не нужно управлять нодами, платите за использование

// AWS App Runner — ещё проще
// Автоматический деплой из репозитория
// Автоматическое масштабирование
// Встроенный load balancer

// Google Cloud Run
// Масштабирование до нуля
// Платите только за запросы

6. Сравнение подходов

| Подход | Сложность | Гибкость | Стоимость | Когда использовать | |---|---|---|---| | Docker Compose | Низкая | Низкая | Минимум | Разработка, малые проекты | | Kubernetes | Высокая | Высокая | Средняя | Средние/крупные проекты | | Managed K8s (EKS/GKE) | Средняя | Высокая | Выше | Нет DevOps команды | | Serverless (Fargate) | Низкая | Средняя | Переменная | Нерегулярная нагрузка |

7. Рекомендации по предотвращению кризиса

// 1. Начинайте с простого
// Docker Compose → Managed K8s → Собственный кластер

// 2. Автоматизируйте с начала
// CI/CD, инфраструктура как код (Terraform/Pulumi)

// 3. Мониторинг с первого дня
// Prometheus + Grafana + алерты

// 4. Документируйте архитектуру
// ADR (Architecture Decision Records)

// 5. Планируйте масштабирование
// HPA, Cluster Autoscaler, мониторинг ресурсов

Итог: для управления большим количеством контейнеров используйте Kubernetes или managed сервисы (EKS, GKE, Fargate). Внедрите GitOps для автоматического деплоя, Helm для управления конфигурациями. Начинайте с простого и усложняйте по мере роста.

Вопрос 20. Дают ли алгоритмические задачки на собеседованиях? Зависит ли это от компании или отдела?

Таймкод: 01:01:27

Ответ собеседника: Правильный. Алгоритмические задачки дают по-разному — это зависит от компании, отдела и даже конкретного интервьюера. Например, в Яндексе и Такси практически гарантированно дают алгоритмы. В других компаниях это зависит от профиля отдела — например, если работа связана с системами рекомендаций, могут дать задачу на алгоритмы. В целом стандартов по рынку нет, всё зависит от конкретной команды.

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

Ответ собеседника точен. Дополню информацией о том, как подготовиться к разным форматам собеседований.

1. Типы собеседований по компаниям

А. Крупные tech-компании (FAANG-подобные)

Формат: 2-3 раунда алгоритмов + 1 системный дизайн
Платформы: LeetCode, HackerRank
Уровень: Medium-Hard
Фокус: Структуры данных, сложность, оптимизация

Примеры задач:
- Two Sum, Three Sum
- Binary Tree обходы
- Dynamic Programming
- Graph algorithms (BFS, DFS, Dijkstra)

Б. Российские компании (Яндекс, Тинькофф, VK)

Формат: 1-2 алгоритмических + проектирование + Go-specific
Платформы: Собственные системы или онлайн-редактор
Уровень: Medium
Фокус: Алгоритмы + знание языка + практические задачи

В. Стартапы и средние компании

Формат: Практические задачи, live coding
Фокус: Реальные задачи из работа
Пример: "Напишите rate limiter", "Спроектируйте API"

2. Как подготовиться к алгоритмам

// Базовые структуры данных в Go

// 1. Массив и слайс
func twoSum(nums []int, target int) []int {
seen := make(map[int]int)
for i, num := range nums {
if j, ok := seen[target-num]; ok {
return []int{j, i}
}
seen[num] = i
}
return nil
}

// 2. Стек
type Stack struct {
items []int
}

func (s *Stack) Push(val int) {
s.items = append(s.items, val)
}

func (s *Stack) Pop() int {
val := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return val
}

// 3. Очередь
type Queue struct {
items []int
}

func (q *Queue) Enqueue(val int) {
q.items = append(q.items, val)
}

func (q *Queue) Dequeue() int {
val := q.items[0]
q.items = q.items[1:]
return val
}

// 4. Связный список
type ListNode struct {
Val int
Next *ListNode
}

func reverseList(head *ListNode) *ListNode {
var prev *ListNode
curr := head
for curr != nil {
next := curr.Next
curr.Next = prev
prev = curr
curr = next
}
return prev
}

// 5. Двоичное дерево
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}

func inorderTraversal(root *TreeNode) []int {
var result []int
var dfs func(*TreeNode)
dfs = func(node *TreeNode) {
if node == nil {
return
}
dfs(node.Left)
result = append(result, node.Val)
dfs(node.Right)
}
dfs(root)
return result
}

3. Паттерны для решения задач

// 1. Sliding Window
func maxSumSubarray(nums []int, k int) int {
maxSum := 0
windowSum := 0

for i := 0; i < len(nums); i++ {
windowSum += nums[i]
if i >= k-1 {
if windowSum > maxSum {
maxSum = windowSum
}
windowSum -= nums[i-k+1]
}
}
return maxSum
}

// 2. Two Pointers
func isPalindrome(s string) bool {
s = strings.ToLower(s)
left, right := 0, len(s)-1

for left < right {
for left < right && !isAlphanumeric(s[left]) {
left++
}
for left < right && !isAlphanumeric(s[right]) {
right--
}
if s[left] != s[right] {
return false
}
left++
right--
}
return true
}

// 3. Binary Search
func search(nums []int, target int) int {
left, right := 0, len(nums)-1

for left <= right {
mid := left + (right-left)/2
if nums[mid] == target {
return mid
}
if nums[mid] < target {
left = mid + 1
} else {
right = mid - 1
}
}
return -1
}

// 4. BFS
func bfs(graph map[int][]int, start int) []int {
visited := make(map[int]bool)
queue := []int{start}
result := []int{}

for len(queue) > 0 {
node := queue[0]
queue = queue[1:]

if visited[node] {
continue
}
visited[node] = true
result = append(result, node)

for _, neighbor := range graph[node] {
if !visited[neighbor] {
queue = append(queue, neighbor)
}
}
}
return result
}

4. Ресурсы для подготовки

LeetCode:
- Top Interview Questions (Easy/Medium)
- Blind 75 — список из 75 задач
- Grind 169 — расширенный список

Книги:
- "Cracking the Coding Interview" (Gayle McDowell)
- "Elements of Programming Interviews"
- "Grokking Algorithms"

Практика:
- Exercism.io (Go track)
- Codewars
- HackerRank

5. Что спросить HR перед собеседованием

Рекомендуемые вопросы:
- Какой формат собеседования?
- Будут ли алгоритмические задачи?
- На каком языке можно решать?
- Сколько раундов?
- Что ещё будет помимо алгоритмов?

Итог: формат собеседований сильно зависит от компании. Крупные tech-компании делают акцент на алгоритмах, другие — на практических задачах и знании языка. Уточняйте формат у HR заранее и готовьтесь соответственно. Для Go-разработчика важно знать не только алгоритмы, но и особенности языка: конкурентность, работу с памятью, стандартную библиотеку.

Вопрос 21. Какие книги по системному дизайну вы рекомендуете? Как вы готовитесь к собеседованиям?

Таймкод: 01:05:41

Ответ собеседника: Правильный. Рекомендуются книги по системному дизайну (конкретные названия будут отправлены позже). Кандидат придерживается стратегии «не готовиться» — полагается на реальный опыт и знания, полученные из практики. Для подготовки рекомендует статьи на Хабре, документацию Go и статьи про планировщик горутин.

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

1. Книги по системному дизайну

А. Основные книги

1. "Designing Data-Intensive Applications" — Martin Kleppmann
— Библия системного дизайна
— Репликация, шардирование, консистентность
— Потоковая обработка данных

2. "System Design Interview" — Alex Xu (тома 1 и 2)
— Практические примеры проектирования
— URL shortener, chat system, rate limiter
— Схемы и диаграммы

3. "Building Microservices" — Sam Newman
— Проектирование микросервисной архитектуры
— Декомпозиция, API, deployment

4. "Site Reliability Engineering" — Google
— Как Google управляет распределёнными системами
— Monitoring, incident response, capacity planning

Б. Дополнительные ресурсы

5. "Understanding Distributed Systems" — Roberto Vitillo
6. "Database Internals" — Alex Petrov
7. "Release It!" — Michael Nygard
8. "The Art of Scalability" — Martin Abbott

Онлайн:
- System Design Primer (GitHub)
- High Scalability (блог)
- AWS Architecture Center

2. Ресурсы по Go

Книги:
- "The Go Programming Language" — Donovan & Kernighan
- "Concurrency in Go" — Katherine Cox-Bayuday
- "100 Go Mistakes" — Teiva Harsanyi
- "Go in Action" — William Kennedy

Документация:
- https://go.dev/doc/effective_go
- https://go.dev/blog/
- https://pkg.go.dev/std

Статьи:
- Go runtime scheduler (GMP model)
- Memory management in Go
- Profiling and optimization

3. Стратегия подготовки к собеседованиям

Фаза 1: Фундамент (1-2 недели)
├── Повторить основы Go
├── Конкурентность (горутины, каналы, контексты)
├── Стандартная библиотека
└── Сериализация/десериализация

Фаза 2: Алгоритмы (2-3 недели)
├── LeetCode Easy (50 задач)
├── LeetCode Medium (30 задач)
├── Основные паттерны
└── Сложность алгоритмов

Фаза 3: Системный дизайн (1-2 недели)
├── Прочитать DDIA (ключевые главы)
├── Проектирование 3-5 систем
├── Паттерны: CQRS, Event Sourcing, Sharding
└── Capacity estimation

Фаза 4: Практика (1 неделя)
├── Mock interviews
├── Live coding
├── Behavioral questions
└── Вопросы к компании

4. Как проходить собеседование по системному дизайну

Структура ответа (30-45 минут):

1. Уточнение требований (5 минут)
- Функциональные требования
- Нефункциональные требования
- Оценка нагрузки (RPS, данные, пользователи)

2. High-level дизайн (10 минут)
- Основные компоненты
- API design
- Data model

3. Детальный дизайн (15 минут)
- Глубокое погружение в ключевые компоненты
- Trade-offs
- Bottlenecks

4. Масштабирование (10 минут)
- Как масштабировать каждый компонент
- Monitoring, alerting
- Disaster recovery

5. Пример подготовки к задаче "Design URL Shortener"

Шаг 1: Требования
- 100M новых ссылок/день
- 10B редиректов/день
- 100:1 read/write ratio
- Availability: 99.99%

Шаг 2: Capacity Estimation
- Write: 100M/86400 ≈ 1200 RPS
- Read: 10B/86400 ≈ 120K RPS
- Storage: 100M * 500 bytes * 365 ≈ 18 TB/year

Шаг 3: API Design
- POST /api/urls → {short_code, short_url}
- GET /{short_code} → 302 redirect

Шаг 4: Data Model
- urls: short_code (PK), original_url, user_id, created_at
- clicks: url_id, timestamp, ip, user_agent

Шаг 5: Компоненты
- Load Balancer → API Servers → Cache → Database
- Redis for caching
- PostgreSQL with read replicas

Шаг 6: Оптимизации
- Cache hit rate > 95%
- CDN for static assets
- Async click counting

Итог: для подготовки к собеседованиям используйте книги DDIA и System Design Interview, практикуйтесь на LeetCode, изучайте реальные системы. Стратегия «не готовиться» может работать при наличии сильного практического опыта, но целенаправленная подготовка значительно увеличивает шансы на успех.

Вопрос 22. Как проходили ваши предыдущие собеседования? Сколько компаний вы проходили и почему не прошли?

Таймкод: 01:05:56

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

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

Это поведенческий вопрос — интервьюер оценивает честность, рефлексию и умение учиться на ошибках. Разберём, как лучше отвечать.

1. Как структурировать ответ

Хороший формат:

1. Контекст: сколько компаний, какие позиции
2. Что получилось: успешные этапы
3. Что не получилось: причины отказов
4. Выводы: что узнали, как улучшили

2. Пример хорошего ответа

> «Проходил собеседования в 4 компании за последний год. > > Компания 1 (стажировка Ozon): Прошёл технический скрининг, но не прошёл финальный этап. Причина — недостаточно глубокое знание алгоритмов для уровня стажировки. После этого активно готовился на LeetCode. > > Компания 2 (middle Go): Прошёл все технические раунды, но на этапе с HR выяснилось, что зарплатные ожидания не совпадают. > > Компания 3 (senior Go): Не прошёл системный дизайн — не хватило опыта проектирования распределённых систем. После этого прочитал DDIA и прошёл курс по системному дизайну. > > Выводы: Каждый отказ помог понять слабые места и целенаправленно их прокачать.»

3. Частые причины отказов и как о них говорить

Технические причины:
- Алгоритмы → «Не хватило подготовки, теперь решаю ежедневно»
- Системный дизайн → «Не было опыта, прочитал DDIA, теперь понимаю»
- Знание языка → «Углубил знания Go runtime и конкурентности»

Нетехнические причины:
- Зарплата → «Ожидания не совпали, я гибок»
- Культура → «Понял, что мне важен определённый формат работы»
- Тайминг → «Позиция была закрыта из-за заморозки найма»

4. Чего не стоит говорить

Избегайте:

- «Все компании были плохими» → Выглядит как оправдание
- «Я идеальный кандидат, просто не повезло» → Нет рефлексии
- Жалоб на интервьюеров → Неэтично
- Слишком подробностей о конкурентах → Конфиденциальность

5. Как показать рост

Формула: Проблема → Действие → Результат

Примеры:

«Не прошёл задачу на графы →
Изучил основные алгоритмы (BFS, DFS, Dijkstra) →
Теперь решаю Medium за 20-30 минут»

«Слабое знание баз данных →
Прочитал "Database Internals", прошёл курс по PostgreSQL →
На этом собеседовании уверенно прошёл рацион по БД»

Итог: на вопрос о предыдущих собеседованиях важно показать честность, рефлексию и способность учиться на ошибках. Отказы — это нормальная часть процесса, интервьюеры это понимают. Главное — продемонстрировать, что вы извлекли уроки и стали лучше.