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

Middle собеседование на Golang с использованием AI

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

Сегодня мы разберём моковое собеседование на позицию Go-разработчика уровня Junior-Middle, в ходе которого кандидат с опытом работы в HR-платформе демонстрирует знания языка Go, баз данных и архитектурных подходов, активно при этом используя AI-инструмент как шпаргалку. Интервьюер последовательно проверяет глубину понимания кандидатом ключевых тем — от горутин и каналов до уровней изоляции транзакций и паттернов микросервисов, фиксируя как сильные стороны, так и пробелы в фундаментальных знаниях.

Вопрос 1. Расскажи про своё резюме и последнее место работы.

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

Ответ собеседника: Правильный. Последнее место работы — компания Alberris, работает там уже 2 года. Развивают HR-платформу, связанную с зарплатными платежами и трудоустройством.

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

Отличный старт для интервью. Кандидат кратко и по делу описал текущее место работы и сферу деятельности компании. Для более полного ответа на этот вопрос рекомендуется также упомянуть:

  • Конкретные обязанности и зона ответственности — какие сервисы разрабатывал, какие задачи решал.
  • Ключевые достижения — метрики, улучшения, оптимизации (например, снижение latency, увеличение throughput, рефакторинг монолита на микросервисы).
  • Технологический стек — Go, какие фреймворки, базы данных, брокеры сообщений, инструменты мониторинга.
  • Архитектурные решения — участие в проектировании системы, выбор технологий, code review, менторинг.
  • Масштаб системы — количество пользователей, RPS, объём данных.

Пример развёрнутого ответа:

> «Последние два года работаю в Alberris — это HR-платформа для зарплатных платежей и трудоустройства. Мои основные обязанности — разработка и поддержка микросервисов на Go, проектирование API, интеграция с внешними платёжными системами. За это время я перевёл ключевой сервис с монолитной архитектуры на микросервисную, что позволило снизить время деплоя с 30 минут до 3 минут. Также внедрил систему метрик на базе Prometheus и Grafana, что сократило время обнаружения инцидентов на 60%.»

Вопрос 2. Расскажи подробнее о проектах, над которыми работал.

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

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

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

Хороший ответ — кандидат описал предметную область, указал на сложности и разнообразие бизнес-логики. Для более полного ответа стоит раскрыть следующие аспекты:

Архитектура системы

Зарплатный проект — это batch-обработка с ночным запуском. Важно рассказать, как организован пайплайн расчётов: откуда поступают исходные данные (часы, клики, ставки), как они агрегируются, какие правила применяются для каждого типа занятости, и как формируются итоговые выплаты.

Обработка разных типов занятости

Каждый тип занятости имеет свою модель расчёта:

  • По ТК — оклад, надбавки, НДФЛ, страховые взносы, удержания.
  • ИП — работа через договоры ГПХ, налогообложение по УСН или патенту, вычета не применяются.
  • Самозанятые — налог на профессиональный доход (4-6%), минимальная отчётность.
  • Складские сотрудники — почасовая оплата + оплата за клики, сложная бизнес-логика с индивидуальными правилами.

Технические сложности

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

Пример архитектурного решения на Go

type EmployeeType int

const (
EmployeeTypeStaff EmployeeType = iota
EmployeeTypeIE
EmployeeTypeSelfEmployed
EmployeeTypeWarehouse
)

type PayrollCalculator interface {
Calculate(ctx context.Context, employee Employee, period PayrollPeriod) (Payment, error)
}

type WarehouseCalculator struct {
hourlyRateRepo HourlyRateRepository
clickRateRepo ClickRateRepository
businessRulesRepo BusinessRulesRepository
}

func (c *WarehouseCalculator) Calculate(ctx context.Context, emp Employee, period PayrollPeriod) (Payment, error) {
hours, err := c.hourlyRateRepo.GetHours(ctx, emp.ID, period)
if err != nil {
return Payment{}, fmt.Errorf("get hours: %w", err)
}

clicks, err := c.clickRateRepo.GetClicks(ctx, emp.ID, period)
if err != nil {
return Payment{}, fmt.Errorf("get clicks: %w", err)
}

rules, err := c.businessRulesRepo.GetRules(ctx, emp.WarehouseID)
if err != nil {
return Payment{}, fmt.Errorf("get business rules: %w", err)
}

payment := rules.Apply(hours, clicks)
return payment, nil
}

func NewPayrollService(calculators map[EmployeeType]PayrollCalculator) *PayrollService {
return &PayrollService{calculators: calculators}
}

func (s *PayrollService) ProcessPayroll(ctx context.Context, employees []Employee, period PayrollPeriod) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(50) // ограничиваем параллелизм

for _, emp := range employees {
emp := emp
g.Go(func() error {
calc, ok := s.calculators[emp.Type]
if !ok {
return fmt.Errorf("unknown employee type: %d for employee %s", emp.Type, emp.ID)
}
payment, err := calc.Calculate(ctx, emp, period)
if err != nil {
return fmt.Errorf("calculate for employee %s: %w", emp.ID, err)
}
return s.paymentRepo.Save(ctx, payment)
})
}
return g.Wait()
}

Здесь используется паттерн «Стратегия» для разных типов расчётов, errgroup для параллельной обработки с ограничением конкурентности, и чёткая обработка ошибок.

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

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

Ответ собеседника: Правильный. Используется PostgreSQL, Kafka для синхронной обработки событий и Redis для кэширования. Это основной стек.

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

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

PostgreSQL — основное хранилище данных

Используется как основная реляционная БД для хранения данных о сотрудниках, типах занятости, настройках выплат, истории расчётов и проведённых платежей. Для зарплатной системы критически важны ACID-транзакции и целостность финансовых данных. Типичные паттерны использования:

-- Таблица с результатами расчётов — каждый расчёт должен быть воспроизводим
CREATE TABLE payroll_calculations (
id BIGSERIAL PRIMARY KEY,
employee_id BIGINT NOT NULL REFERENCES employees(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
base_amount NUMERIC(12,2) NOT NULL,
bonuses NUMERIC(12,2) DEFAULT 0,
deductions NUMERIC(12,2) DEFAULT 0,
tax_amount NUMERIC(12,2) DEFAULT 0,
total_amount NUMERIC(12,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'calculated',
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
idempotency_key UUID NOT NULL UNIQUE,
version INT NOT NULL DEFAULT 1
);

CREATE INDEX idx_payroll_emp_period ON payroll_calculations(employee_id, period_start, period_end);
CREATE INDEX idx_payroll_status ON payroll_calculations(status) WHERE status = 'calculated';

Kafka — брокер событий

Kafka используется для асинхронной обработки событий, а не для синхронной — это важно уточнить. Типичные сценарии:

  • Событие «часы сотрудника загружены» → триггер на пересчёт.
  • Событие «выплата проведена» → уведомление смежных систем.
  • Событие «сотрудник добавлен/изменён» → обновление кэшей и связанных сервисов.

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

type PaymentEvent struct {
EmployeeID string `json:"employee_id"`
Amount int64 `json:"amount"` // в копейках, чтобы избежать float
Currency string `json:"currency"`
Status string `json:"status"`
idempotencyKey string `json:"idempotency_key"`
OccurredAt time.Time `json:"occurred_at"`
}

func (p *PaymentProducer) PublishPayment(ctx context.Context, event PaymentEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal payment event: %w", err)
}

msg := &kafka.Message{
Topic: "payments.completed",
Key: []byte(event.EmployeeID),
Value: payload,
Headers: []kafka.Header{
{Key: "idempotency-key", Value: []byte(event.IdempotencyKey)},
},
}

return p.writer.WriteMessages(ctx, *msg)
}

Redis — кэширование и блокировки

Используется для:

  • Кэширования справочников (ставки, тарифы, бизнес-правила), которые редко меняются, но часто читаются.
  • Распределённых блокировок для предотвращения параллельного расчёта одного и того же сотрудника.
  • Хранения rate limiter для внешних API.
func (s *PayrollService) AcquireCalculationLock(ctx context.Context, employeeID string, period string) (bool, error) {
key := fmt.Sprintf("payroll:lock:%s:%s", employeeID, period)
// SET NX с TTL — если ключ не существует, установить его
ok, err := s.redisClient.SetNX(ctx, key, "locked", 5*time.Minute).Result()
if err != nil {
return false, err
}
return ok, nil
}

Дополнительные технологии, которые стоит упомянуть

Для полноты картины рекомендуется рассказать и о других инструментах:

  • Мониторинг: Prometheus + Grafana для метрики, Jaeger или Tempo для трассировки.
  • Логирование: структурированные логи через slog/zap, агрегация в ELK или Loki.
  • CI/CD: GitLab CI, GitHub Actions или Jenkins.
  • Контейнеризация: Docker, Kubernetes для оркестрации.
  • gRPC или REST: для межсервисного взаимодействия.
  • Миграции: goose, migrate для управления схемой БД.

Вопрос 4. Что такое горутины и чем они отличаются от тредов (потоков ОС)?

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

Ответ собеседника: Правильный. Горутины — это легковесные потоки, которые регулируются рантаймом Go. Треды операционной системы делят память процесса, а горутины работают в рамках рантайма по GMP-модели на логических процессах тредов ОС.

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

Кандидат верно описал ключевые отличия и упомянул GMP-модель. Для полноты картины стоит раскрыть тему глубже.

Горутина (Goroutine)

Горутина — это легковесная единица конкурентного исполнения, управляемая рантаймом Go, а не операционной системой. Это ключевое отличие: ОС не знает о существовании горутин.

Сравнение горутин и тредов ОС

ХарактеристикаГорутинаТред ОС
Размер стекаНачинается с 2–8 КБ, растёт динамическиФиксированный, обычно 1–8 МБ
СозданиеНаносекунды, несколько сотен байт памятиМикросекунды, выделение стека через ОС
Переключение контекстаКооперативное + вытеснение (с Go 1.14), управляется рантаймомВытесняющее, управляется планировщиком ОС
Максимальное количествоСотни тысячи, миллионыТысячи (ограничено памятью на стеки)
КоммуникацияКаналы (CSP), shared memory с мьютексамиShared memory, примитивы синхронизации ОС

GMP-модель

Go использует планировщик на основе трёх абстракций:

  • G (Goroutine) — сама горутина с указателем на стек, состоянием и контекстом.
  • M (Machine) — тред ОС, на котором исполняются горутины.
  • P (Processor) — логический процессор, который управляет очередью горутин. Количество P по умолчанию равно GOMAXPROCS (числу ядер CPU).

Планировщик распределяет G по P, а P привязаны к M. Это позволяет эффективно использовать доступные ядра без создания избыточного количества тредов ОС.

Проблемы, которые нужно знать

  • Goroutine leak — горутина, которая никогда не завершится (например, заблокированный канал).
  • Work stealing — если у P нет горутин в очереди, он «ворует» горутины у других P.
  • Syscall blocking — при блокирующем системном вызове M отсоединяется от P, и рантайм создаёт новый M.
// Пример: запуск горутины и ожидание завершения
func processItems(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(100) // ограничение параллелизма

for _, item := range items {
item := item // захват переменной цикла
g.Go(func() error {
return processItem(ctx, item)
})
}

return g.Wait() // ждём завершения всех горутин, возвращаем первую ошибку
}

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

Вопрос 5. Что управляет горутинами (одним словом)?

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

Ответ собеседника: Правильный. Шедулер Go.

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

Ответ верный. Горутинами управляет планировщик Go (Go scheduler), который является частью рантайма (runtime) языка.

Планировщик Go — это кооперативно-вытесняющий планировщик, встроенный непосредственно в бинарный файл программы. Он отвечает за:

  • Распределение горутин (G) по логическим процессорам (P).
  • Привязку логических процессоров к тредам ОС (M).
  • Переключение контекста между горутинами.
  • Обработку блокирующих операций (syscall, каналы, мьютексы).
  • Work stealing — балансировку нагрузки между процессорами.

Планировщик запускается автоматически при старте программы и работает прозрачно для разработчика. Единственная настройка, доступная из кода — runtime.GOMAXPROCS(n), которая задаёт количество логических процессоров (по умолчанию равно числу ядер CPU).

// Установка количества логических процессоров
runtime.GOMAXPROCS(runtime.NumCPU())

Важно понимать, что планировщик Go — это не отдельный процесс или библиотека, а неотъемлемая часть рантайма, которая компилируется в каждый бинарник Go-программы.

Вопрос 6. Какие типы данных есть в Go?

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

Ответ собеседника: Неполный. Int (int8, int32, int64), float, string, булевые значения, каналы.

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

Кандидат перечислил часть типов, но ответ неполный. В Go типы данных делятся на несколько категорий.

Базовые (примитивные) типы

  • Целочисленные: int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, byte (alias для uint8), rune (alias для int32).
  • С плавающей точкой: float32, float64.
  • Комплексные: complex64, complex128.
  • Строковые: string (неизменяемая последовательность байт, UTF-8).
  • Булев: bool (true / false).
  • Указатели: *T — указатель на значение типа T.

Составные (композитные) типы

  • Массивы: [N]T — фиксированный размер, является значимым типом.
  • Срезы: []T — динамический размер, ссылочный тип, построен над массивом.
  • Карты (словари): map[K]V — хеш-таблица пар ключ-значение.
  • Структуры: struct — набор полей с разными типами.
  • Каналы: chan T — типизированный канал для коммуникации между горутинами.
  • Функции: func — функциональный тип, функции являются объектами первого класса.
  • Интерфейсы: interface — набор сигнатур методов, реализуются неявно.

Ссылочные типы

К ссылочным типам относятся: срезы (slice), карты (map), каналы (chan), указатели (pointer), функции (func). Они хранят ссылку на данные, а не сами данные.

Пользовательские типы

type UserID uint64 // именованный тип на основе uint64
type PayrollCalculator interface { // интерфейс
Calculate(ctx context.Context, emp Employee) (Payment, error)
}

type Employee struct { // структура
ID UserID
Name string
Type EmployeeType
CreatedAt time.Time
}

type EmployeeType int // перечисление через константы
const (
TypeStaff EmployeeType = iota
TypeIE
TypeSelfEmployed
)

Специальные типы

  • any (alias для interface{}) — пустой интерфейс, принимает любое значение.
  • error — встроенный интерфейс с методом Error() string.

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

Вопрос 7. Расскажи всё, что знаешь про строки в Go. Что будет, если взять len от строки с русскими или китайскими буквами?

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

Ответ собеседника: Неполный. Строки — неизменяемый тип данных, под капотом это структура из указателя на байты и длины. len вернёт длину массива байт. Кириллица весит 2 байта, латиница — 1 байт. Символы юникода могут занимать от 1 до 4 байт. Не упомянуто явно, что len вернёт количество байт, а не количество символов — для строки с русскими буквами len будет больше количества символов.

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

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

Устройство строки в Go

Строка в Go — это неизменяемая последовательность байт. Под капотом строка представлена структурой reflect.StringHeader:

type StringHeader struct {
Data uintptr // указатель на массив байт
Len int // длина в байтах
}

Строки хранят данные в кодировке UTF-8. Это значит, что один символ Unicode (rune) может занимать от 1 до 4 байт:

  • Латиница, цифры, базовые символы: 1 байт.
  • Кириллица, арабский, иврит: 2 байта.
  • Китайские, японские, корейские иероглифы: 3 байта.
  • Эмодзи и редкие символы: 4 байта.

Ключевое различие: len vs utf8.RuneCountInString

s := "Привет"

fmt.Println(len(s)) // 12 — количество байт
fmt.Println(utf8.RuneCountInString(s)) // 6 — количество символов (рун)

s2 := "你好"
fmt.Println(len(s2)) // 6 — количество байт
fmt.Println(utf8.RuneCountInString(s2)) // 2 — количество символов

s3 := "Hello"
fmt.Println(len(s3)) // 5 — байты и символы совпадают
fmt.Println(utf8.RuneCountInString(s3)) // 5

Итерация по строке

// Неправильно: итерация по байтам
for i := 0; i < len(s); i++ {
fmt.Printf("%c", s[i]) // выведет мусор для многобайтовых символов
}

// Правильно: range автоматически декодирует руны
for i, r := range s {
fmt.Printf("index=%d, rune=%c, size=%d\n", i, r, utf8.RuneLen(r))
}

Преобразование между типами

// Строка → слайс рун (символов)
runes := []rune("Привет") // [1055 1088 1080 1074 1077 1090]
fmt.Println(len(runes)) // 6

// Слайс рун → строка
s := string(runes) // "Привет"

// Строка → слайс байт
bytes := []byte("Привет") // [208 159 208 184 208 178 208 181 209 130]
fmt.Println(len(bytes)) // 12

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

  • Индексация s[i] возвращает байт, а не символ.
  • Срез s[0:3] для строки с кириллицей может обрезать символ пополам, получив невалидный UTF-8.
  • Для корректной работы с символами нужно конвертировать в []rune или использовать utf8 пакет.
  • Сравнение строк через == работает побайтово и корректно для UTF-8.
// Безопасная обрезка строки по количеству символов
func Truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen])
}

Вопрос 8. Что такое тип данных rune и что такое alias (алиас) в Go?

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

Ответ собеседника: Правильный. Rune — это тип из юникода, UTF-8, по сути int32. Alias — это синоним типа данных, более человеческое название для типа. Например, alias byte — это uint8.

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

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

Тип rune

rune — это встроенный псевдоним (alias) для типа int32. Он используется для представления одного символа Unicode code point. В Go нет отдельного символьного типа (в отличие от C, где есть char), и rune выполняет эту роль.

var r rune = 'A'
fmt.Printf("%T, %d, %c\n", r, r, r) // int32, 65, A

var r2 rune = 'П'
fmt.Printf("%T, %d, %c\n", r2, r2, r2) // int32, 1055, П

var r3 rune = '你'
fmt.Printf("%T, %d, %c\n", r3, r3, r3) // int32, 20320, 你

Alias (псевдоним) типа

В Go есть два способа создать новый тип на основе существующего:

1. Type alias (псевдоним) — синтаксис =:

type byte = uint8 // полная замена, byte и uint8 — один и тот же тип
type rune = int32 // полная замена, rune и int32 — один и тот же тип

При alias обозначения взаимозаменяемы. Можно использовать byte везде, где ожидается uint8, и наоборот, без приведения типов.

2. Type definition (определение нового типа) — без =:

type UserID uint64 // новый тип, основанный на uint64
type EmployeeType int // новый тип, основанный на int

При определении нового типа создаётся отдельный тип с собственным набором методов. Приведение между UserID и uint64 требует явного преобразования:

type UserID uint64

func process(id uint64) {}

var uid UserID = 42
process(uint64(uid)) // явное приведение обязательно
process(uid) // ошибка компиляции: cannot use uid (type UserID) as type uint64

Зачем нужны псевдонимы и новые типы?

  • Читаемость: byte понятнее, чем uint8, когда работаешь с байтовыми данными. rune понятнее, чем int32, когда работаешь с символами.
  • Безопасность типов: определение нового типа предотвращает случайную передачу UserID туда, где ожидается OrderID, даже если оба основаны на uint64.
  • Методы: новому типу можно добавить методы, которых нет у базового типа.
type Money int64 // сумма в копейках

func (m Money) String() string {
rubles := m / 100
kopecks := m % 100
return fmt.Sprintf("%d.%02d ₽", rubles, kopecks)
}

func (m Money) Add(other Money) Money {
return m + other
}

func (m Money) Tax(percent float64) Money {
return Money(float64(m) * (1 - percent/100))
}

Встроенные псевдонимы Go: byte = uint8, rune = int32, any = interface{} (с Go 1.18).

Вопрос 9. Что использовать для соединения большого количества строк?

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

Ответ собеседника: Правильный. Для соединения большого количества строк лучше использовать strings.Builder из стандартного пакета. Можно использовать оператор плюс (конкатенация), но это плохо для памяти, так как каждый раз создаётся новая строка.

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

Кандидат верно указал на strings.Builder как основной инструмент. Раскроем тему подробнее.

Проблема конкатенации через +

Строки в Go неизменяемы. Каждая операция s += "часть" создаёт новую строку, копируя содержимое обеих исходных. Для N строк это даёт квадратичную сложность O(N²) по времени и памяти.

// Плохо: O(N²) по памяти и времени
var result string
for _, s := range parts {
result += s // каждый раз новое выделение памяти и копирование
}

strings.Builder — рекомендуемый способ

strings.Builder накапливает байты в внутреннем буфере и минимизирует аллокации за счёт стратегии роста буфера.

// Хорошо: O(N) по памяти и времени
var builder strings.Builder
builder.Grow(estimatedSize) // предварительное выделение, если известен примерный размер

for _, s := range parts {
builder.WriteString(s)
}

result := builder.String()

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

ПодходСложностьКогда использовать
+ (конкатенация)O(N²)2–3 строки, простые случаи
strings.BuilderO(N)Цикл, динамическое построение строки
strings.JoinO(N)Есть готовый слайс строк и разделитель
fmt.SprintfO(N)Форматированный вывод
bytes.BufferO(N)Нужен io.Writer/io.Reader интерфейс

strings.Join — когда есть готовый слайс

parts := []string{"Hello", " ", "World", "!"}
result := strings.Join(parts, "") // "Hello World!"

bytes.Buffer — когда нужен io.Writer

var buf bytes.Buffer
buf.WriteString("Hello")
buf.Write([]byte(" World"))
buf.WriteByte('!')

result := buf.String()

bytes.Buffer и strings.Builder похожи, но strings.Builder безопаснее: он не позволяет читать буфер напрямую (только через .String()), что предотвращает случайные мутации. С Go 1.20 strings.Builder также имеет метод Grow() для предварительного выделения памяти.

Практический пример

func BuildPayrollReport(employees []Employee) string {
var b strings.Builder
b.Grow(len(employees) * 100) // примерная оценка размера

b.WriteString("=== Payroll Report ===\n")
for _, emp := range employees {
fmt.Fprintf(&b, "ID: %d, Name: %s, Amount: %.2f\n",
emp.ID, emp.Name, emp.PaymentAmount)
}
b.WriteString("=== End of Report ===\n")

return b.String()
}

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

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

Ответ собеседника: Правильный. Массив имеет фиксированную длину и размер. Слайс — динамическая структура, указывающая на массив. Под капотом слайс содержит указатель на данные, длину и capacity. Элемент добавляется с помощью append. Если длина меньше capacity — элемент добавляется в существующий массив. Если capacity заполнена — создаётся новый массив. Не всегда увеличивается в два раза: при достижении определённого объёма множитель становится меньше, чтобы не заполнить всю оперативную память.

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

Кандидат дал полный и точный ответ. Дополним деталями.

Массив vs Слайс

// Массив: фиксированный размер, является значимым типом (копируется при присваивании)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
arr2 := arr // полная копия, arr и arr2 — независимые

// Слайс: динамический размер, ссылочный тип (заголовок копируется, данные — общие)
slc := []int{1, 2, 3, 4, 5}
slc2 := slc // slc и slc2 указывают на одни и те же данные
ХарактеристикаМассивСлайс
РазмерФиксирован, часть типа [5]intДинамический
Передача в функциюКопируется целикомКопируется заголовок (24 байта)
Тип данныхЗначимый (value type)Ссылочный (reference type)

Устройство слайса под капотом

type SliceHeader struct {
Data uintptr // указатель на первый элемент массива
Len int // текущая длина (количество элементов)
Cap int // ёмкость (размер массива в памяти)
}

Добавление элементов

slc := make([]int, 0, 4) // len=0, cap=4

slc = append(slc, 1) // len=1, cap=4 — без реаллокации
slc = append(slc, 2) // len=2, cap=4
slc = append(slc, 3, 4) // len=4, cap=4 — заполнен

slc = append(slc, 5) // len=5, cap=8 — реаллокация!

Стратегия роста capacity

Реальная стратегия роста в Go runtime сложнее, чем просто «удвоение»:

  • Для маленьких слайсов (до ~256 элементов) — capacity удваивается.
  • Для больших слайсов — множитель уменьшается (примерно до 1.25x).
  • Точная формула зависит от размера элемента и платформы, код находится в runtime.growslice().
// Можно наблюдать за ростом capacity
slc := []int{}
for i := 0; i < 20; i++ {
slc = append(slc, i)
fmt.Printf("len=%d cap=%d\n", len(slc), cap(slc))
}
// 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=8 cap=8
// len=9 cap=16
// ... и так далее

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

// Опасность: два слайса на один массив
original := make([]int, 3, 5)
original[0], original[1], original[2] = 1, 2, 3

subset := original[:2] // len=2, cap=5 — делит память с original
subset = append(subset, 999) // original[2] тоже станет 999!

fmt.Println(original) // [1 2 993] — сюрприз!
// Безопасное копирование
subset := make([]int, 2)
copy(subset, original[:2])
subset = append(subset, 999) // original не затронут
// Добавление слайса к слайсу
a := []int{1, 2, 3}
b := []int{4, 5, 6}
a = append(a, b...) // тройные точки для распаковки

Вопрос 11. Что будет при обращении к элементу неинициализированного слайса/массива?

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

Ответ собеседника: Правильный. Будет паника — index out of bounds, так индекс выходит за пределы массива.

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

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

Неинициализированный (nil) слайс

var slc []int // nil слайс: len=0, cap=0, Data=nil
fmt.Println(slc) // []
fmt.Println(slc == nil) // true

// Обращение по индексу — паника
_ = slc[0] // panic: runtime error: index out of range [0] with length 0

// Но append работает с nil слайсом!
slc = append(slc, 42) // создаёт новый базовый массив, slc = [42]

Пустой (не nil) слайс

slc := []int{} // пустой, но не nil: len=0, cap=0, Data≠nil
fmt.Println(slc == nil) // false

_ = slc[0] // такая же паника: index out of range [0] with length 0

Массив

Массив всегда имеет фиксированный размер и не может быть nil. При объявлении без инициализации он заполняется нулевыми значениями:

var arr [5]int // [0, 0, 0, 0, 0] — нулевые значения
_ = arr[0] // 0 — безопасно
_ = arr[4] // 0 — безопасно
_ = arr[5] // panic: index out of range [5] with length 5

Ключевое различие

СитуацияРезультат
var slc []int; slc[0]panic: index out of range
var arr [5]int; arr[0]0 — нулевое значение
var arr [5]int; arr[5]panic: index out of range
var slc []int; append(slc, 1)[1] — работает корректно

Защита от паники

if len(slc) > 0 {
first := slc[0]
// безопасная работа с first
}

// Или проверка индекса
func safeGet(slc []int, index int) (int, bool) {
if index < 0 || index >= len(slc) {
return 0, false
}
return slc[index], true
}

Почему append работает с nil слайсом, а индексация — нет?

append — это специальная встроенная функция, которая проверяет ёмкость и при необходимости выделяет новый массив. Индексация slc[i] — это прямое обращение к памяти через указатель в SliceHeader.Data, и если длина равна 0, любой индекс выходит за допустимые границы.

Вопрос 12. Что такое map в Go? Что такое бакеты в map? Можно ли взять указатель на элемент map? Что такое эвакуация в map?

Таймкод: 00:10:44

Ответ собеседника: Неполный. Map — это хэш-таблица с ключами и значениями. Ключ проходит через хэш-функцию. Поиск, чтение и запись происходят за константное время. В бакете 8 элементов. Брать указатель на элемент map нельзя, потому что при росте map происходит эвакуация — перенос элементов, и указатель станет невалидным, что вызовет панику. Не упомянуто точное количество бакетов по умолчанию.

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

Кандидат верно описал основные аспекты, но не раскрыл ряд важных деталей.

Map — хеш-таблица

map[K]V — это встроенный тип хеш-таблицы. Под капотом указатель на структуру hmap из runtime.

m := make(map[string]int, 100) // второй аргумент — hint на количество элементов
m["alice"] = 42
m["bob"] = 37

v, ok := m["alice"] // v=42, ok=true — идиоматическая проверка наличия
v, ok = m["charlie"] // v=0, ok=false

Бакеты (buckets)

Map хранит данные в бакетах. Каждый бакет — это структура, содержащая:

  • 8 пар ключ-значение — максимальная вместимость одного бакета.
  • 8 байт tophash — старшие биты хеша каждого ключа, для быстрого поиска внутри бакета.
  • Массив смещений ключей и значений — ключи и значения хранятся отдельно друг от друга (оптимизация для GC).

Когда бакет переполняется, создаётся overflow bucket — дополнительный бакет, связанный со список с текущим.

// Под капотом (упрощённо)
type bmap struct {
tophash [bucketCnt]uint8 // bucketCnt = 8
// далее идут ключи, затем значения, затем указатель на overflow bucket
}

Количество бакетов

При создании map через make(map[K]V, hint) рантайм вычисляет количество бакетов как ближайшую степень двойки, достаточную для хранения hint элементов при load factor ≈ 6.5 (то есть в среднем 6.5 элементов на бакет до увеличения таблицы).

Почему нельзя брать указатель на элемент map

m := map[string]int{"alice": 42}

// Ошибка компиляции:
// p := &m["alice"] // cannot take the address of m["alice"]

Причины:

  1. Эвакуация (evacuation) — при росте map все элементы перемещаются в новые бакеты. Адрес памяти значения изменится, и указатель станет невалидным.
  2. Go runtime запрещает это на уровне компилятора — чтобы предотвратить неопределённое поведение.

Эвакуация (evacuation)

Когда load factor (количество элементов / количество бакетов) превышает порог ~6.5, map увеличивается вдвое:

  1. Выделяется новая таблица бакетов в 2 раза больше.
  2. Эвакуация происходит постепенно (incremental evacuation), а не за один раз — при каждой операции записи/удаления перемещаются 1–2 бакета. Это предотвращает большие задержки.
  3. Старые бакеты помечаются для GC после полной эвакуации.
// Наблюдение за ростом map
m := make(map[int]int)
for i := 0; i < 10000; i++ {
m[i] = i
if i%1000 == 0 {
fmt.Printf("elements: %d\n", len(m))
}
}

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

  • Map не потокобезопасен — параллельная запись вызывает панику (concurrent map writes). Для конкурентного доступа используйте sync.Map или sync.RWMutex.
  • Порядок итерации по map не определён и может меняться между итерациями.
  • Nil map можно читать (возвращает zero value), но запись вызовет панику.
var m map[string]int
v := m["key"] // v=0, без паники
m["key"] = 1 // panic: assignment to entry in nil map

Вопрос 13. Можно ли из нескольких горутин одновременно писать в map? Как это пофиксить? Когда лучше использовать sync.Map, RWMutex и обычный Mutex?

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

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

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

Кандидат верно ответил на все части вопроса. Раскроем тему с деталями и примерами.

Почему нельзя писать в map из нескольких горутин

Go runtime явно запрещает конкурентную запись в map — это приводит к панике:

m := make(map[int]int)

// Это вызовет панику: "concurrent map writes"
go func() { m[1] = 1 }()
go func() { m[2] = 2 }()

С версии Go 1.6 runtime содержит детектор, который ловит конкурентные записи и вызывает панику (а не undefined behavior, как в некоторых других языках).

Способы защиты

1. sync.Mutex — простой и быстрый

type SafeMap struct {
mu sync.Mutex
m map[string]int
}

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

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

func (s *SafeMap) Get(key string) (int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[key]
return v, ok
}

2. sync.RWMutex — когда много читателей, мало писателей

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

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

func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock() // множество читателей могут держать RLock одновременно
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}

RWMutex позволяет множеству горутин читать одновременно, но запись эксклюзивна. Подходит, когда соотношение чтение/запись ≥ 10:1.

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

var m sync.Map

// Запись
m.Store("key", 42)

// Чтение
if v, ok := m.Load("key"); ok {
fmt.Println(v.(int))
}

// Атомарная запись, если ключ отсутствует
actual, loaded := m.LoadOrStore("key", 100)

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

// Итерация
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // вернуть false для остановки
})

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

ИнструментКогда использовать
sync.Mutex + mapУниверсальный случай, простой код, умеренная конкурентность
sync.RWMutex + mapМного читателей, мало писателей (ratio ≥ 10:1)
sync.MapКлючи редко добавляются, но часто читаются; каждый горутина работает со своим набором ключей; нужны атомарные операции LoadOrStore, CompareAndSwap

Почему sync.Map не всегда лучше

sync.Map имеет накладные расходы на два внутренних хранилища (read map и dirty map), и при паттерне «много записей» проигрывает обычному map с мьютексом. Benchmarks показывают, что sync.Map выигрывает только в специфических сценариях:

  • Ключи записываются один раз, читаются многократно.
  • Разные горутины работают с непересекающимися наборами ключей.

Практический совет

В большинстве случаев начинайте с sync.Mutex + обычный map. Профилируйте, и только если мьютекс становится bottleneck, переходите на RWMutex или sync.Map в зависимости от паттерна доступа.

Вопрос 14. Что такое каналы в Go? Какие типы каналов существуют и чем они отличаются? Как передаются значений в каналах под капотом? Из чего состоит канал?

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

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

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

Кандидат хорошо описал типы каналов, но допустил неточность в механизме передачи данных.

Каналы в Go

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

ch := make(chan int) // небуферизированный
ch := make(chan int, 10) // буферизированный, размер буфера = 10

Типы каналов

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

ch := make(chan int)

// Отправка блокируется, пока кто-то не прочитает
ch <- 42 // блокировка до тех пор, пока другая горутина не вызовет <-ch

// Чтение блокируется, пока кто-то не отправит
v := <-ch // блокировка до тех пор, пока другая горутина не отправит значение

Небуферизированный канал гарантирует синхронизацию: отправитель и получатель должны быть готовы одновременно (handshake).

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

ch := make(chan int, 3)

ch <- 1 // не блокируется, буфер неполон
ch <- 2 // не блокируется
ch <- 3 // не блокируется
ch <- 4 // БЛОКИРОВКА — буфер полон

Отправитель блокируется, когда буфер заполнен. Получатель блокируется, когда буфер пуст.

3. Направленные каналы

func producer(ch chan<- int) { // только отправка
ch <- 42
}

func consumer(ch <-chan int) { // только чтение
v := <-ch
}

Направленные каналы используются для явного указания намерений в API функций.

Устройство канала под капотом

Канал — это указатель на структуру hchan в runtime:

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

Как передаются значения

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

  • Небуферизированный канал: отправитель напрямую копирует данные в стек получателя. Это очень эффективно — данные не проходят через heap.
  • Буферизированный канал: данные копируются в буфер канала (heap-allocated), а затем из буфера — в стек получателя.

Таким образом, в обоих случаях происходит копирование, но небуферизированный канал делает это более эффективно — минуя heap.

// Небуферизированный: sender → receiver stack (напрямую)
// Буферизированный: sender → channel buffer (heap) → receiver stack

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

// Worker pool
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}

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

for w := 0; w < 5; w++ {
go worker(w, jobs, results)
}

for j := 0; j < 20; j++ {
jobs <- j
}
close(jobs)

for r := 0; r < 20; r++ {
fmt.Println(<-results)
}
}
// Закрытие канала — только отправитель!
ch := make(chan int)
close(ch)

// Проверка закрытия
v, ok := <-ch // ok == false, если канал закрыт и пуст

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

  • Только отправитель должен закрывать канал. Закрытие получателем вызовет панику.
  • Запись в закрытый канал — паника.
  • Чтение из закрытого канала возвращает zero value и ok=false.
  • Чтение из nil канала блокирует навсегда. Запись в nil канал — тоже.

Вопрос 15. Какие ещё примитивы синхронизации есть в Go? Что такое атомики и sync.WaitGroup? Что такое errgroup и когда используется?

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

Ответ собеседника: Правильный. Есть атомики — работают по принципу Compare and Swap, находятся под капотом мьютексов. Есть sync.WaitGroup для ожидания завершения горутин. Errgroup — аналог WaitGroup, но если одна горутина вернула ошибку, все остальные отменяются через контекст. Используется вместе с контекстом.

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

Кандидат хорошо описал три примитива. Раскроем полный набор и добавим детали.

Полный набор примитивов синхронизации в Go

1. sync/atomic — атомарные операции

Атомики обеспечивают безблокировочные операции над целыми числами и указателями на уровне CPU (инструкции CAS, XADD и т.д.).

var counter atomic.Int64

// Атомарное инкрементирование — без мьютекса
counter.Add(1)

// Атомарное чтение
val := counter.Load()

// CompareAndSwap — основной примитив lock-free алгоритмов
old := counter.Load()
counter.CompareAndSwap(old, old+1)

// Для указателей
var ptr atomic.Pointer[Config]
ptr.Store(&newConfig)
config := ptr.Load()

Атомики быстрее мьютексов для простых операций (счётчики, флаги), но не подходят для сложной логики.

2. sync.WaitGroup — ожидание группы горутин

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
i := i
wg.Add(1) // увеличиваем счётчик
go func() {
defer wg.Done() // уменьшаем счётчик при завершении
doWork(i)
}()
}

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

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

  • Add() должен вызываться до запуска горутины (или из горутины, но до Wait()).
  • Done() эквивалент Add(-1).
  • Wait() блокирует до обнуления счётчика.

3. sync.Mutex и sync.RWMutex

Рассмотрены в вопросе 13.

4. sync.Cond — условная переменная

mu := sync.Mutex{}
cond := sync.NewCond(&mu)

// Горутина-ожидающая
mu.Lock()
for !condition {
cond.Wait() // атомарно освобождает мьютекс и засыпает
}
mu.Unlock()

// Горутина-сигнализирующая
mu.Lock()
condition = true
cond.Signal() // будит одну ожидающую горутину
// cond.Broadcast() // будит все ожидающие горутины
mu.Unlock()

5. sync.Once — однократное выполнение

var once sync.Once
var config *Config

func GetConfig() *Config {
once.Do(func() {
config = loadConfigFromFile() // выполнится ровно один раз
})
return config
}

sync.Once гарантирует, что функция будет вызвана ровно один раз, даже из множества горутин. Используется для ленивой инициализации синглтонов.

6. sync.Pool — пул объектов

var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}

func process(data []byte) {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)

buf.Write(data)
// работа с buf
}

sync.Pool кэширует временные объекты для уменьшения нагрузки на GC. Важно: объекты из пула могут быть собраны GC в любой момент — пул не даёт гарантий хранения.

7. golang.org/x/sync/errgroup — группа горутин с ошибками

import "golang.org/x/sync/errgroup"

func processItems(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // ограничение параллелизма (опционально)

for _, item := range items {
item := item
g.Go(func() error {
return processItem(ctx, item)
})
}

return g.Wait() // возвращает первую ненулевую ошибку
}

errgroup объединяет WaitGroup + контекст + обработку ошибок:

  • При первой ошибке контекст отменяется.
  • Wait() возвращает первую ненулевую ошибку.
  • SetLimit(n) ограничивает количество одновременно работающих горутин.

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

ПримитивСценарий
atomicСчётчики, флаги, lock-free структуры
MutexЗащита разделяемых данных
RWMutexМного чтений, мало записей
WaitGroupОжидание завершения N горутин без ошибок
errgroupПараллельные задачи с обработкой ошибок и отменой
CondОжидание наступления условия
OnceОднократная инициализация
PoolПереиспользование временных объектов

Вопрос 16. Что такое интерфейсы в Go? Что такое пустые интерфейсы и как они помогают, когда нужно передать в функцию разные типы данных? Как определить тип переменной, переданной как пустой интерфейс?

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

Ответ собеседника: Правильный. Интерфейсы — это контракт, описывающий набор методов. Любой тип, реализующий эти методы, автоматически удовлетворяет интерфейсу. Пустые интерфейсы (interface{}) не имеют методов и могут работать с любыми типами данных. Для определения типа переменной используется type switch и type assertion.

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

Кандидат верно ответил на все части вопроса. Раскроем тему с примерами и важными нюансами.

Интерфейсы в Go

Интерфейс — это набор сигнатур методов. Любой тип, который реализует все методы интерфейса, автоматически удовлетворяет этому интерфейсу (duck typing на этапе компиляции).

type Stringer interface {
String() string
}

type Employee struct {
ID int
Name string
}

// Employee реализует Stringer неявно — ключевое отличие от Java/C#
func (e Employee) String() string {
return fmt.Sprintf("Employee{id=%d, name=%s}", e.ID, e.Name)
}

// Функция принимает любой тип, реализующий Stringer
func Print(s Stringer) {
fmt.Println(s.String())
}

Устройство интерфейса под капотом

Интерфейс — это пара указателей (iface для непустых, eface для пустых):

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

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

При присваивании конкретного значения интерфейсу происходит копирование значения. Для больших структур лучше использовать указатели.

Пустой интерфейс (any)

// interface{} и any — одно и то же (any появился в Go 1.18)
func PrintAnything(v any) {
fmt.Printf("value: %v, type: %T\n", v, v)
}

PrintAnything(42) // value: 42, type: int
PrintAnything("hello") // value: hello, type: string
PrintAnything(Employee{}) // value: {0 }, type: main.Employee

Определение типа: type assertion

func process(v any) {
// Type assertion с проверкой
if s, ok := v.(string); ok {
fmt.Printf("String: %s\n", s)
return
}

if i, ok := v.(int); ok {
fmt.Printf("Int: %d\n", i)
return
}

// Без проверки — вызовет панику, если тип не совпадает
s := v.(string) // panic, если v не string
}

Type switch — для множества типов

func describe(v any) {
switch v := v.(type) {
case string:
fmt.Printf("string of length %d: %s\n", len(v), v)
case int:
fmt.Printf("int: %d\n", v)
case bool:
fmt.Printf("bool: %t\n", v)
case Stringer:
fmt.Printf("Stringer: %s\n", v.String())
case nil:
fmt.Println("nil")
default:
fmt.Printf("unknown type: %T\n", v)
}
}

Практический пример: полиморфная обработка

type Payer interface {
Pay(amount Money) error
}

type CreditCard struct {
Number string
Token string
}

func (c CreditCard) Pay(amount Money) error {
return chargeCard(c.Token, amount)
}

type CryptoWallet struct {
Address string
}

func (w CryptoWallet) Pay(amount Money) error {
return sendCrypto(w.Address, amount)
}

// Функция работает с любым способом оплаты
func Checkout(p Payer, amount Money) error {
return p.Pay(amount)
}

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

  • Nil interface ≠ interface с nil значением:
var p *CreditCard = nil
var payer Payer = p

fmt.Println(payer == nil) // false! Интерфейс содержит тип *CreditCard и nil значение
  • Интерфейсы реализуются неявно — нет ключевого слова implements.
  • Размер интерфейса: 16 байт (два указателя по 8 байт на 64-битной системе).
  • Для производительности критичных мест избегайте интерфейсов — виртуальный вызов дороже прямого.

Вопрос 17. Как читать из нескольких каналов одновременно? Как отменить горутины? Какие типы контекстов бывают? Почему context.Value считается нежелательным к использованием?

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

Ответ собеседника: Правильный. Для чтения из нескольких каналов используется select. Горутины отменяются через контекст. Типы контекстов: background, WithCancel, WithDeadline/WithTimeout, WithValue. context.Value нежелателен, потому что он неявный — непонятно что в нём лежит, программа становится похожей на спагетти, данные закидываются в одном месте и достаются в другом, что ухудшает читаемость.

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

Кандидат дал полный и точный ответ. Дополним примерами кода.

Чтение из нескольких каналов — select

func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // обнуляем, чтобы select больше не выбирал этот канал
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
if ch1 == nil && ch2 == nil {
return
}
}
}()
return out
}
// select с таймаутом
select {
case result := <-results:
fmt.Println("Got result:", result)
case <-time.After(5 * time.Second):
fmt.Println("Timeout!")
}
// select с дефолт — неблокирующий режим
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("No data available")
}

Отмена горутин через контекст

func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: cancelled: %v\n", id, ctx.Err())
return
default:
// основная работа
doWork()
}
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

for i := 0; i < 5; i++ {
go worker(ctx, i)
}

<-ctx.Done() // ждём отмены
}

Типы контекстов

1. context.Background() — корневой контекст, никогда не отменяется. Используется в main, init, обработчиках запросов.

ctx := context.Background()

2. context.TODO() — заглушка, когда не определились с контекстом.

3. context.WithCancel — создаёт отменяемый контекст.

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

go func() {
time.Sleep(5 * time.Second)
cancel() // отменяет все дочерние контексты
}()

4. context.WithTimeout — автоматическая отмена через заданное время.

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

5. context.WithDeadline — автоматическая отмена в конкретное время.

ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()

6. context.WithValue — хранение значений в контексте.

type contextKey string

const userIDKey contextKey = "userID"

ctx := context.WithValue(context.Background(), userIDKey, 42)
userID := ctx.Value(userIDKey).(int) // 42

Почему context.Value — антипаттерн

  1. Небезопасность типовValue() возвращает any, нужен type assertion.
  2. Неявная зависимость — промежуточные функции не знают, какие данные несёт контекст.
  3. Нарушает сигнатуру — данные передаются не через параметры, а через «скрытый» контекст.
  4. Сложно тестировать — нужно создавать контекст с правильными значениями.
// Плохо: непонятно, что нужно передать в контексте
func ProcessOrder(ctx context.Context, orderID int64) error {
userID := ctx.Value(userIDKey).(int64) // откуда это? паника, если нет
// ...
}

// Хорошо: явные параметры
func ProcessOrder(ctx context.Context, orderID int64, userID int64) error {
// ...
}

Допустимое использование WithValue: метаданные, которые пронизывают весь запрос — request ID, trace ID, токены аутентификации в middleware. Но бизнес-данные должны передаваться через параметры функций.

Вопрос 18. Что такое ошибки в Go? Как они обрабатываются? Что такое defer и для чего используется?

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

Ответ собеседника: Правильный. Ошибки — это интерфейс с методом Error(), возвращающим строку. Обработка ошибок явная, без исключений, ошибка всегда возвращается вторым значением. Defer откладывает вызов функции до выхода из текущей функции, используется для освобождения ресурсов (unlock, close, rollback). Работает в порядке LIFO.

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

Кандидат верно описал все три концепции. Раскроем подробнее с примерами.

Ошибки в Go

error — это встроенный интерфейс:

type error interface {
Error() string
}

Любой тип, реализующий метод Error() string, автоматически удовлетворяет интерфейсу error.

Создание ошибок

// Простая ошибка
err := errors.New("something went wrong")

// Форматированная ошибка
err := fmt.Errorf("user %d not found", userID)

// Кастомный тип ошибки
type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
}

Обработка ошибок

result, err := doSomething()
if err != nil {
return fmt.Errorf("do something: %w", err) // оборачивание ошибки
}

// Проверка типа ошибки
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("Field %s is invalid: %s\n", validationErr.Field, validationErr.Message)
}

// Проверка конкретной ошибки
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}

Оборачивание ошибок (error wrapping)

func processUser(id int64) error {
user, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %d: %w", id, err) // %w для оборачивания
}
// ...
}

// Разворачивание цепочки
if errors.Is(err, ErrNotFound) { // проверяет всю цепочку через Unwrap()
// обработка
}

Defer

defer откладывает выполнение функции до момента возврата из текущей функции. Вызовы складываются в стек и выполняются в порядке LIFO (Last In, First Out).

func processFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close() // выполнится последним

mu.Lock()
defer mu.Unlock() // выполнится первым

// основная работа...
return nil
}
// Порядок выполнения при return: 1) mu.Unlock() 2) f.Close()

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

// Аргументы вычисляются сразу при объявлении defer
func example() {
i := 0
defer fmt.Println(i) // напечатает 0, а не 1
i++
}
// Defer с именованным возвратом — может изменить возвращаемое значение
func divide(a, b int) (result int, err error) {
defer func() {
if err != nil {
result = 0 // модифицирует именованный возврат
}
}()

if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// Defer в цикле — ловушка!
func bad(files []string) {
for _, f := range files {
fh, _ := os.Open(f)
defer fh.Close() // все файлы закроются только в конце функции!
}
}

func good(files []string) {
for _, f := range files {
func() {
fh, _ := os.Open(f)
defer fh.Close() // закроется в конце каждой итерации
// работа с файлом
}()
}
}

Recover — для перехвата паник

func safeHandler() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
log.Printf("stack trace:\n%s", debug.Stack())
}
}()
riskyOperation()
return nil
}

recover() работает только внутри defer и позволяет перехватить панику, предотвращая краш программы.

Вопрос 19. Что такое сборщик мусора (garbage collector) в Go и как он работает? какой алгоритм используется?

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

Ответ собеседника: Правильный. Сборщик мусора работает с областью памяти «куча». Находит объекты, на которых нет достижимых ссылок, и освобождает память. Используется трёхцветный алгоритм Mark and Sweep: чёрные объекты — имеют ссылки, серые — видны, но не просканированы, белые — мусор.

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

Кандидат верно описал алгоритм. Дополним деталями о реализации в Go.

Garbage Collector в Go

Go использует конкурентный, трицветный маркировочно-очистительный (concurrent tri-color mark-and-sweep) сборщик мусора. Он работает одновременно с программой (concurrent), минимизируя паузы (stop-the-world).

Трёхцветный алгоритм

Объекты в куче маркируются тремя цветами:

  • Белые — потенциальный мусор, пока не достигнут маркировщиком.
  • Серые — достигнуты, но их ссылки ещё не просканированы.
  • Чёрные — достигнуты и все их ссылки просканированы, точно живые.

Фазы работы GC

1. Mark Setup (STW — короткая остановка)

Программа ненадолго останавливается. GC включает write barrier и инициализирует корневые объекты (глобальные переменные, стеки горутин) как серые.

2. Concurrent Mark (параллельно с программой)

GC обходит граф объектов: серые объекты сканируются, их потомки помечаются серыми, сам объект становится чёрным. Write barrier отслеживает изменения ссылок во время маркировки.

3. Mark Termination (STW — короткая остановка)

Финализация маркировки. Обработка оставшихся серых объектов. Обычно занимает < 100 мкс в современных версиях Go.

4. Concurrent Sweep (параллельно с программой)

Белые объекты (мусор) возвращаются в пул свободной памяти. Происходит параллельно с выполнением программы.

Write Barrier

Критический механизм, обеспечивающий корректность конкурентной маркировки. Когда программа изменяет ссылку (например, *ptr = newValue), write barrier помечает затронутые объекты как серые, чтобы GC не пропустил живые объекты.

Триггер GC

GC запускается, когда размер кучи удваивается относительно последнего цикла сборки. Это регулируется параметром GOGC (по умолчанию 100):

GOGC=100 # запуск GC, когда куча выросла на 100% после последней сборки
GOGC=50 # более агрессивная сборка
GOGC=off # отключение GC

Оптимизация работы с GC

// Уменьшение аллокаций — главный способ снизить нагрузку на GC
// Плохо: аллокация в горячем цикле
func process(items []Item) {
for _, item := range items {
buf := make([]byte, 1024) // аллокация на каждой итерации
_ = buf
}
}

// Хороше: переиспользование буфера
func process(items []Item) {
buf := make([]byte, 1024) // одна аллокация
for _, item := range items {
buf = buf[:0] // сброс без реаллокации
_ = buf
}
}

// sync.Pool для временных объектов
var bufferPool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}

Метрики GC

var stats runtime.MemStats
runtime.ReadMemStats(&stats)

fmt.Printf("GC cycles: %d\n", stats.NumGC)
fmt.Printf("Total GC pause: %v\n", time.Duration(stats.PauseTotalNs))
fmt.Printf("Heap alloc: %d MB\n", stats.HeapAlloc/1024/1024)
fmt.Printf("Next GC at: %d MB\n", stats.NextGC/1024/1024)

Эволюция GC в Go

  • Go 1.0–1.3: Простой mark-and-sweep с длительными STW паузами.
  • Go 1.5: Полностью конкурентный GC, паузы сократились до миллисекунд.
  • Go 1.8: Паузы < 100 мкс благодаря элиминации STW в mark termination.
  • Go 1.12+: Улучшенный write barrier (hybrid barrier).
  • Go 1.19: Soft memory limit через GOMEMLIMIT.

Современный GC в Go — один из лучших среди языков с автоматическим управлением памятью, обеспечивая паузы менее 100 микросекунд даже при больших кучах.

Вопрос 20. Какие аксиомы каналов в Go? Что будет при записи в закрытый канал, чтении из закрытого канала, работе с nil-каналом?

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

Ответ собеседника: Правильный. Запись в закрытый канал вызывает панику. Чтение из закрытого канала возвращает zero value. Чтение/запись в nil-канал вызывает блокировку навсегда (или панику).

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

Кандидат верно описал поведение. Уточним детали и добавим примеры.

Аксиомы каналов в Go

1. Запись в закрытый канал — паника

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

ch <- 2 // panic: send on closed channel

Это всегда паника, даже если буфер не заполнен. Единственный безопасный способ закрытия — отправитель закрывает канал, и только один раз.

2. Чтение из закрытого канала

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

// Сначала читаются оставшиеся значения из буфера
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

// После опустошения буфера — zero value и ok=false
v, ok := <-ch
fmt.Println(v, ok) // 0 false

v, ok = <-ch
fmt.Println(v, ok) // 0 false — можно читать бесконечно

Закрытый канал никогда не блокирует чтение — он всегда возвращает zero value. Это свойство используется для паттерна завершения горутин.

3. Nil-канал (чтение и запись — блокировка навсегда)

var ch chan int // nil канал

ch <- 1 // блокировка навсегда (deadlock)
v := <-ch // блокировка навсегда (deadlock)

Nil-канал не имеет буфера и не связан с каким-либо каналом. Любая операция на нём блокирует горутину навсегда. Это используется в select для «отключения» case:

var chA chan int = make(chan int, 1)
var chB chan int = nil // отключён

select {
case v := <-chA:
fmt.Println("A:", v)
case v := <-chB: // никогда не сработает
fmt.Println("B:", v)
}

4. Закрытие nil-канала — паника

var ch chan int
close(ch) // panic: close of nil channel

5. Закрытие уже закрытого канала — паника

ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel

Сводная таблица аксиом

ОперацияОткрытый каналЗакрытый каналNil канал
ЗаписьOK / блокировкаПаникаБлокировка навсегда
ЧтениеOK / блокировкаZero valueБлокировка навсегда
ЗакрытиеOKПаникаПаника
len/capТекущие значенияТекущие значения0

Практический паттерн: завершение горутин

func worker(done chan struct{}) {
for {
select {
case <-done:
fmt.Println("Worker: shutting down")
return
default:
doWork()
}
}
}

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

time.Sleep(5 * time.Second)
close(done) // сигнал завершения — все горутины, читающие из done, получат zero value
time.Sleep(time.Second) // даём время на graceful shutdown
}

Использование chan struct{} для сигналов завершения — идиоматический паттерн в Go, так как struct{} занимает 0 байт памяти.

Вопрос 21. Что такое escape analysis в Go?

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

Ответ собеседника: Правильный. Escape analysis — это анализ на этапе компиляции, который определяет, где аллоцировать объект — на стеке или в куче.

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

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

Escape Analysis (анализ убегания)

Escape analysis — это оптимизация компилятора Go, которая определяет, где размещать объект: на стеке (быстро, автоматически освобождается) или в куче (медленнее, требует GC).

Когда объект «убегает» в кучу

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

// 2. Запись в интерфейс
func Stringify() Stringer {
e := Employee{ID: 1} // убегает в кучу, потому что интерфейс может храниться дольше
return e
}

// 3. Запись в канал
func sendToChannel(ch chan *User) {
u := User{Name: "Bob"} // убегает в кучу — канал может быть прочитан позже
ch <- &u
}

// 4. Запись в слайс/карту, которая переживает функцию
func store(m map[string]*User) {
u := User{Name: "Charlie"} // убегает в кучу
m["charlie"] = &u
}

// 5. Замыкание, захватывающее переменuous
func counter() func() int {
i := 0 // убегает в кучу — замыкание живёт дольше функции
return func() int {
i++
return i
}
}

Когда объект остаётся на стеке

func sum(a, b int) int {
result := a + b // остаётся на стеке — не убегает
return result
}

func process() {
u := User{Name: "Dave"} // может остаться на стеке, если компилятор докажет, что указатель не убегает
fmt.Println(u.Name)
}

Как проверить решение компилятора

# Флаг -m показывает решение escape analysis
go build -gcflags="-m" main.go

# Пример вывода:
# ./main.go:5:6: u escapes to heap
# ./main.go:10:2: moved to heap: u

Почему это важно

  • Стек: выделение — одна инструкция (сдвиг указателя стека), освобождение — автоматическое при выходе из функции. Нет нагрузки на GC.
  • Куча: выделение через mallocgc, освобождение через GC. Накладные расходы на аллокацию и последующую сборку мусора.

Практический совет

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

# Если pprof показывает много аллокаций в горячем месте — проверьте escape analysis
go build -gcflags="-m" ./...

Вопрос 22. Что такое реляционная база данных и за счёт чего она реляционная? Что такое ACID? Какие типы индексов существуют? Что произойдёт, если повесить индексы на каждое поле таблицы?

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

Ответ собеседника: Правильный. Реляционная база данных работает с таблицами и связями между ними (от англ. relation — связь). ACID: атомарность (все операции выполняются или ни одна), консистентность (данные согласованы), изоляция (транзакции изолированы друг от друга), durability (после коммита данные не теряются). Типы индексов: B-tree (по умолчанию), Hash (через хэш-функцию), GIN (для JSON, массивов). Если повесить индексы на каждое поле — замедляются операции записи (insert, update, delete), так как индексы перестраиваются при каждой записи.

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

Кандидат дал полный и точный ответ. Дополним деталями и примерами.

Реляционная БД

Название происходит от понятия «relation» (отношение) из теории множеств. Данные организованы в таблицы (relations), строки — это кортежи (tuples), столбцы — атрибуты (attributes). «Реляционность» — это именно наличие связей между таблицами через ключи.

-- Пример: связь между таблицами
CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
department_id INT REFERENCES departments(id) -- связь (relation)
);

CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);

ACID

Atomicity (Атомарность) — транзакция выполняется целиком или не выполняется вообще.

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- обе операции применятся или ни одна

Consistency (Согласованность) — после транзакции данные находятся в валидном состоянии (соблюдены все ограничения, триггеры, FK).

Isolation (Изолированность) — параллельные транзакции не влияют друг на друга. Уровни изоляции в PostgreSQL:

УровеньDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedВозможенВозможенВозможен
Read Committed (default)НевозможенВозможенВозможен
Repeatable ReadНевозможенНевозможенВозможен*
SerializableНевозможенНевозможенНевозможен

*В PostgreSQL Repeatable Read также защищает от phantom read.

Durability (Надёжность) — после COMMIT данные гарантированно сохранены, даже при сбое. Обеспечивается через WAL (Write-Ahead Log).

Типы индексов в PostgreSQL

-- B-tree (по умолчанию) — для сравнений и диапазонов
CREATE INDEX idx_employees_name ON employees(name);
-- Подходит для: =, <, >, BETWEEN, ORDER BY, LIKE 'prefix%'

-- Hash — только для точного совпадения
CREATE INDEX idx_employees_email_hash ON employees USING hash(email);
-- Подходит только для: =

-- GIN (Generalized Inverted Index) — для JSONB, массивов, полнотекстового поиска
CREATE INDEX idx_employees_tags ON employees USING gin(tags);
-- Подходит для: @>, ?, ?|, ?& (операции с JSONB/массивами)

-- GiST (Generalized Search Tree) — для геоданных, полнотекстового поиска
CREATE INDEX idx_locations ON locations USING gist(coordinates);

-- BRIN (Block Range Index) — для больших таблиц с физическим порядком
CREATE INDEX idx_logs_created ON logs USING brin(created_at);
-- Компактный индекс для данных, которые коррелируют с физическим расположением

Индексы на каждое поле — последствия

-- Представим таблицу с 10 полями, на каждое из которых повешен индекс
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
amount NUMERIC(12,2),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
address TEXT,
city VARCHAR(100),
phone VARCHAR(20),
email VARCHAR(255)
);

-- 9 индексов (кроме PK, который уже проиндексирован)
CREATE INDEX idx_customer ON orders(customer_id);
CREATE INDEX idx_status ON orders(status);
CREATE INDEX idx_amount ON orders(amount);
CREATE INDEX idx_created ON orders(created_at);
CREATE INDEX idx_updated ON orders(updated_at);
CREATE INDEX idx_address ON orders(address);
CREATE INDEX idx_city ON orders(city);
CREATE INDEX idx_phone ON orders(phone);
CREATE INDEX idx_email ON orders(email);

Последствия:

  • INSERT — при вставке каждой строки обновляются 9 индексов. Время вставки растёт линейно от количества индексов.
  • UPDATE — при изменении индексированного поля перестраивается соответствующий индекс.
  • DELETE — пометка записей во всех индексах.
  • Место на диске — индексы занимают дополнительное место, иногда сопоставимое с размером таблицы.
  • Планировщик запросов — больше индексов = больше вариантов для планировщика = больше времени на выбор плана.

Правильный подход к индексам

-- Создавайте индексы под конкретные запросы
-- Если часто ищем по customer_id и status вместе:
CREATE INDEX idx_customer_status ON orders(customer_id, status);

-- Покрывающий индекс (Index Only Scan) — данные берутся из индекса без обращения к таблице
CREATE INDEX idx_status_covering ON orders(status) INCLUDE (amount, created_at);

-- Частичный индекс — индексируем только нужные строки
CREATE INDEX idx_pending_orders ON orders(created_at) WHERE status = 'pending';

Анализируйте медленные запросы через EXPLAIN ANALYZE и добавляйте индексы только там, где они действительно нужны.

Вопрос 23. Какие уровни изоляции транзакций существуют в общем и какие есть в PostgreSQL? Что такое локи (блокировки)? Чем пессимистичная блокировка отличается от оптимистичной?

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

Ответ собеседника: Правильный. Четыре уровня изоляции: Read Uncommitted (самый низкий), Read Committed (читает только закоммиченные данные), Repeatable Read (убирает грязное чтение), Serializable (убирает все аномалии). В PostgreSQL доступны: Read Committed, Repeatable Read, Serializable. Локи — механизм блокировки. Пессимистичная блокировка блокирует и чтение, и запись. Оптимистичная — использует версию (каунтер), при конфликте версий транзакция откатывается. Также есть блокировки по объектам: по таблице, и по строкам (share locks).

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

Кандидат хорошо ответил. Уточним детали и дополним примерами.

Уровни изоляции (SQL Standard)

УровеньDirty ReadNon-Repeatable ReadPhantom ReadSerialization Anomaly
Read UncommittedВозможенВозможенВозможенВозможен
Read CommittedНевозможенВозможенВозможенВозможен
Repeatable ReadНевозможенНевозможенВозможенВозможен
SerializableНевозможенНевозможенНевозможенНевозможен

PostgreSQL реализация

PostgreSQL формально поддерживает все 4 уровня, но Read Uncommitted работает как Read Committed (это задокументированное поведение). Реально доступны три:

-- По умолчанию в PostgreSQL
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- Для конкретной транзакции
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ... операции ...
COMMIT;

Важная особенность PostgreSQL: на уровне Repeatable Read предотвращаются не только non-repeatable read, но и phantom read (в отличие от стандарта).

Аномалии параллельного доступа

-- Dirty Read: T1 читает незакоммиченные данные T2
-- Non-Repeatable Read: T1 дважды читает одну строку, но видит разные значения (T2 обновил и закоммитил)
-- Phantom Read: T1 дважды выполняет запрос с WHERE, но видит разное количество строк (T2 добавил/удалил)
-- Serialization Anomaly: результат параллельного выполнения не эквивалентен никакому последовательному порядку

Блокировки (Locks) в PostgreSQL

PostgreSQL использует многоуровневую систему блокировок:

Уровень строк (Row-level locks):

-- FOR UPDATE — эксклюзивная блокировка строк
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Другие транзакции не могут изменить или заблокировать эту строку
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- FOR SHARE — разделяемая блокировка (другие могут читать, но не писать)
SELECT * FROM accounts WHERE id = 1 FOR SHARE;

-- FOR NO KEY UPDATE — мягче, чем FOR UPDATE (не блокирует SELECT FOR KEY SHARE)
-- FOR KEY SHARE — мягче, чем FOR SHARE

Уровень таблицы (Table-level locks):

-- ACCESS SHARE — SELECT (совместим со всеми кроме ACCESS EXCLUSIVE)
-- ROW SHARE — SELECT FOR UPDATE/SHARE
-- ROW EXCLUSIVE — INSERT, UPDATE, DELETE
-- SHARE UPDATE EXCLUSIVE — VACUUM, CREATE INDEX CONCURRENTLY
-- SHARE — CREATE INDEX
-- SHARE ROW EXCLUSIVE — триггеры
-- EXCLUSIVE — редко используется
-- ACCESS EXCLUSIVE — DROP TABLE, TRUNCATE, ALTER TABLE (блокирует всё)

Пессимистичная vs Оптимистичная блокировка

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

-- Пессимистичная блокировка в SQL
BEGIN;
SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE;
-- Строка заблокирована, другие транзакции ждут

UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 42;
COMMIT;
-- Блокировка снята
// Пессимистичная блокировка в Go (через мьютекс)
type Inventory struct {
mu sync.Mutex
items map[int]int
}

func (inv *Inventory) Reserve(productID int) error {
inv.mu.Lock()
defer inv.mu.Unlock()

if inv.items[productID] <= 0 {
return errors.New("out of stock")
}
inv.items[productID]--
return nil
}

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

-- Оптимистичная блокировка через версионное поле
CREATE TABLE inventory (
product_id INT PRIMARY KEY,
quantity INT NOT NULL,
version INT NOT NULL DEFAULT 1
);

-- Транзакция 1
BEGIN;
SELECT quantity, version FROM inventory WHERE product_id = 42;
-- quantity=10, version=1

-- Транзакция 2 (параллельно)
BEGIN;
SELECT quantity, version FROM inventory WHERE product_id = 42;
-- quantity=10, version=1
UPDATE inventory SET quantity = 9, version = 2 WHERE product_id = 42 AND version = 1;
COMMIT; -- успех, version обновлена

-- Транзакция 1 пытается закоммитить
UPDATE inventory SET quantity = 9, version = 2 WHERE product_id = 42 AND version = 1;
-- 0 rows affected! version уже не 1
-- Нужно повторить транзакцию или сообщить об ошибке
// Оптимистичная блокировка в Go
type InventoryItem struct {
ProductID int
Quantity int
Version int
}

func (r *Repo) UpdateQuantity(ctx context.Context, productID, delta int) error {
for retries := 0; retries < 3; retries++ {
item, err := r.GetItem(ctx, productID)
if err != nil {
return err
}

item.Quantity += delta
item.Version++

rows, err := r.db.ExecContext(ctx,
`UPDATE inventory SET quantity = $1, version = $2 WHERE product_id = $3 AND version = $4`,
item.Quantity, item.Version, productID, item.Version-1,
)
if err != nil {
return err
}

if affected, _ := rows.RowsAffected(); affected == 1 {
return nil // успех
}
// Конфликт версий — повторяем
}
return errors.New("max retries exceeded")
}

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

КритерийПессимистичнаяОптимистичная
Частота конфликтовВысокаяНизкая
Время удержания блокировкиДолгоеКороткое
Накладные расходыБлокировки, ожиданиеПовторные попытки
Типичный сцайБанковские переводыРедактирование профиля, корзина

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

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

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

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

Кандидат дал полный и точный ответ. Дополним конкретикой.

Когда использовать монолит

  • Стартап / MVP — нужно быстро проверить гипотезы, продукт меняется каждый день.
  • Маленькая команда (1–5 разработчиков) — нет смысла тратить ресурсы на инфраструктуру микросервисов.
  • Простая доменная логика — нет чётких границ между подсистемами.
  • Ограниченный бюджет — один деплой, один мониторинг, один стек логирования.

Когда использовать микросервисы

  • Большие команды (50+ разработчиков) — нужно разделить ответственность, чтобы команды не блокировали друг друга.
  • Различные требования к масштабированию — один сервис требует 100 реплик, другой — 2.
  • Разные технологические требования — один сервис пишется на Go, другой на Python для ML.
  • Независимый деплой — нужно обновлять часть системы без остановки всего.
  • Различные требования к доступности — критичный сервис должен быть устойчивее, чем вспомогательный.

Признаки того, что пора переходить

  • Время деплоя монолита превышает 30 минут.
  • Один сбой приводит к падению всей системы.
  • Команды ждут друг друга для выката фич.
  • Невозможно масштабировать отдельные компоненты независимо.
  • Кодовая база стала настолько большой, что новые разработчики не могут в ней ориентироваться.
  • Разные части системы имеют разные паттерны нагрузки.

Почему реплики монолита — не всегда решение

Монолит с 10 репликами:
├── Каждая реплика содержит ВСЁ (auth, billing, notifications, search...)
├── Каждая реплика потребляет много памяти
├── Масштабируем ВСЁ, даже если нагрузка только на один модуль
└── Один баг в любом модуле может убить все реплики

Микросервисы:
├── Auth: 2 реплики (мало нагрузки)
├── Billing: 20 реплик (много нагрузки)
├── Notifications: 5 реплик
├── Search: 10 реплик
└── Сбой в notifications не влияет на billing

Цена микросервисов

Микросервисы — не бесплатные. Они добавляют:

  • Сетевые вызовы вместо вызовов функций (latency, ошибки сети).
  • Распределённые транзакции вместо ACID (нужна saga, eventual consistency).
  • Операционную сложность — service discovery, circuit breaker, distributed tracing.
  • Сложность тестирования — нужно мокать зависимости, использовать contract tests.
  • Дублирование данных — каждый сервис хранит свои данные.

Промежуточный вариант: модульный монолит

Модульный монолит — это компромисс: код разделён на чёткие модули с явными интерфейсами, но деплоится как единое приложение. Позволяет перейти к микросервисам позже, когда границы уже определены.

// Модульный монолит: чёткие границы модулей
package billing

type Service interface {
Charge(ctx context.Context, userID int64, amount Money) error
GetBalance(ctx context.Context, userID int64) (Money, error)
}

// Реализация изолирована, взаимодействие через интерфейсы
// При переходе к микросервисам — заменяем вызов интерфейса на gRPC/HTTP

Рекомендация: Начинайте с монолита. Переходите к микросервисам, когда боль от монолита превышает боль от микросервисов. Не делайте микросервисы «потому что модно».

Вопрос 25. Что такое транзакционность в микросервисах? Что такое паттерн Saga и какие виды саг существуют?

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

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

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

Кандидат верно описал паттерн. Дополним деталями и примерами.

Проблема распределённых транзакций

В монолите одна транзакция БД охватывает все операции. В микросервисах каждый сервис имеет свою базу данных (database per service), и нет единого координатора транзакций. Классический двухфазный коммит (2PC) не подходит из-за блокировок и низкой доступности.

Паттерн Saga

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

Пример: оформление заказа

1. Order Service: создать заказ (статус PENDING)
2. Payment Service: списать деньги
3. Inventory Service: зарезервировать товар
4. Notification Service: отправить подтверждение
5. Order Service: обновить статус на COMPLETED

Если шаг 3 завершился неудачей:

Компенсации:
3'. Inventory Service: отменить резервирование
2'. Payment Service: вернуть деньги
1'. Order Service: обновить статус на CANCELLED

Хореография (Choreography)

Каждый сервис слушает события и сам решает, что делать дальше.

// Order Service публикует событие
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
order, err := s.orderRepo.Create(ctx, req)
if err != nil {
return err
}

return s.eventBus.Publish(ctx, "order.created", OrderCreatedEvent{
OrderID: order.ID,
UserID: req.UserID,
Amount: req.TotalAmount,
Items: req.Items,
})
}

// Payment Service слушает order.created
func (s *PaymentService) HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error {
payment, err := s.charge(ctx, event.UserID, event.Amount)
if err != nil {
return s.eventBus.Publish(ctx, "payment.failed", PaymentFailedEvent{
OrderID: event.OrderID,
Reason: err.Error(),
})
}

return s.eventBus.Publish(ctx, "payment.completed", PaymentCompletedEvent{
OrderID: event.OrderID,
PaymentID: payment.ID,
})
}

// Inventory Service слушает payment.completed
func (s *InventoryService) HandlePaymentCompleted(ctx context.Context, event PaymentCompletedEvent) error {
err := s.reserveStock(ctx, event.OrderID)
if err != nil {
return s.eventBus.Publish(ctx, "inventory.failed", InventoryFailedEvent{
OrderID: event.OrderID,
})
}

return s.eventBus.Publish(ctx, "inventory.reserved", InventoryReservedEvent{
OrderID: event.OrderID,
})
}

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

Оркестрация (Orchestration)

Центральный оркестратор управляет последовательностью шагов.

type OrderSaga struct {
orderService OrderService
paymentService PaymentService
inventoryService InventoryService
eventBus EventBus
}

type SagaState struct {
OrderID string `json:"order_id"`
Step string `json:"step"`
Status string `json:"status"` // pending, completed, compensating, failed
}

func (s *OrderSaga) Execute(ctx context.Context, req CreateOrderRequest) error {
state := &SagaState{OrderID: generateID(), Step: "create_order"}

// Шаг 1: Создать заказ
order, err := s.orderService.Create(ctx, req)
if err != nil {
return fmt.Errorf("create order: %w", err)
}
state.OrderID = order.ID
state.Step = "charge_payment"

// Шаг 2: Списать деньги
payment, err := s.paymentService.Charge(ctx, order.UserID, order.TotalAmount)
if err != nil {
s.compensateCreateOrder(ctx, state)
return fmt.Errorf("charge payment: %w", err)
}
state.Step = "reserve_inventory"

// Шаг 3: Зарезервировать товар
err = s.inventoryService.Reserve(ctx, order.ID, order.Items)
if err != nil {
s.compensatePayment(ctx, state, payment.ID)
s.compensateCreateOrder(ctx, state)
return fmt.Errorf("reserve inventory: %w", err)
}

state.Step = "completed"
state.Status = "completed"
return nil
}

func (s *OrderSaga) compensateCreateOrder(ctx context.Context, state *SagaState) {
s.orderService.Cancel(ctx, state.OrderID)
}

func (s *OrderSaga) compensatePayment(ctx context.Context, state *SagaState, paymentID string) {
s.paymentService.Refund(ctx, paymentID)
}

Плюсы оркестрации: проще отслеживать статус, легче тестировать, нет циклических зависимостей. Минусы: оркестратор — единая точка отказа, может стать «бог-классом».

Сравнение

КритерийХореографияОркестрация
СвязанностьСлабаяСильная (оркестратор знает всех)
Отслеживание статусаСложноЛегко
Единая точка отказаНетДа (оркестратор)
Сложность добавления шагаСредняяПросто
ТестированиеСложноПроще

Важные принципы

  • Идемпотентность — каждый шаг и компенсация должны быть идемпотентны (повторный вызов даёт тот же результат).
  • Компенсирующие операции не всегда могут отменить всё (например, отправленное письмо нельзя «отправить обратно»). Нужно проектировать с учётом этого.
  • Eventual consistency — Saga гарантирует конечную согласованность, а не мгновенную.