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

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

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

Сегодня мы разберём реальное собеседование по Go, проведённое в формате открытого стрима: кандидат Максим, имеющий опыт в тестировании и начинающий изучать Go, отвечает на вопросы интервьюера Димы — от основ языка и внутреннего устройства слайсов до планировщика, каналов, мьютексов и даже system design. Несмотря на некоторые пробелы в деталях, Максим продемонстрировал уверенное понимание ключевых концепций Go, за что получил высокую оценку и рекомендацию претендовать на позицию middle-разработчика.

Вопрос 1. Расскажите о своём опыте работы с Go: как часто используете, какие задачи решаете, какие технологии и концепции Go применяете?

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

Ответ собеседника: неполный. Познакомился с Go с версии 1.13, периодически возвращается к нему с разным успехом. В среднем пару раз в месяц пишет небольшие утилиты для себя: генерация данных, парсинг логов, заглушки. Нет опыта ревью кода и продакшн-разработки на Go. Активно работает с HTTP-сервисами, файлами, базами данных, иногда использует каналы, мапы, слайсы, интерфейсы и горутины.

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

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

Частота использования и контекст

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

Типичные задачи

  • Разработка и поддержка REST/gRPC-сервисов с использованием стандартного net/http, chi, gin или grpc-go.
  • Работа с базами данных через database/sql, pgx, sqlx, gorm — включая миграции, оптимизацию запросов, пулинг соединений.
  • Построение конвейеров обработки данных с помощью каналов и горутин: парсинг потоков данных, ETL-процессы, обработка событий из Kafka/NATS.
  • Написание CLI-инструментов для автоматизации деплоя, мониторинга, миграций.
  • Интеграция с внешними API, написание HTTP-клиентов с retry-логикой, circuit breaker, таймаутами.
  • Работа с конфигурацией (Viper), логированием (zap, slog), метриками (Prometheus), трейсинг (OpenTelemetry).

Ключевые концепции и технологии

  • Конкурентность: активно использую горутины, каналы, sync.WaitGroup, sync.Mutex, sync.Map, context.Context для управления жизненным циклом горутин и отмены операций.
  • Интерфейсы и композиция: предпочитаю маленькие интерфейсы (как рекомендует идиоматика Go), встраивание структур, инъекцию зависимостей через конструкторы.
  • Обработка ошибок: явная проверка ошибок, обёртывание через fmt.Errorf с %w, использование errors.Is / errors.As для анализа цепочек ошибок.
  • Тестирование: unit-тесты с testing, моки через gomock или testify/mock, бенчмарки, интеграционные тесты с реальной БД в Docker.
  • Профилирование и отладка: pprof, trace, анализ аллокаций, гонок (-race), оптимизация горячих путей.
  • Модули и зависимости: управление через go mod, версионирование, vendoring при необходимости.
  • Паттерны проектирования: graceful shutdown, worker pool, fan-out/fan-in, pipeline, decorator.

Пример кода: типичный HTTP-сервис с контекстом и graceful shutdown

package main

import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "OK")
})

server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}

// Graceful shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan

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

if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
}()

log.Println("Starting server on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
log.Println("Server stopped gracefully")
}

Такой ответ демонстрирует не только знание синтаксиса, но и понимание экосистемы, лучших практик и архитектурных решений, применяемых в реальных проектах на Go.

Вопрос 2. Какова ваша цель участия в этом интервью?

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

Ответ собеседника: правильный. Цель — стать разработчиком, узнать, на какой уровень можно претендовать.

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

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

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

Цель участия в интервью — получить объективную оценку текущего уровня знаний и навыков в Go-разработке, понять, на какую позицию (junior, middle, senior) я могу претендовать, и выявить зоны роста. Я хочу работать в команде, где смогу не только применять имеющийся опыт, но и развиваться через ревью кода, обсуждение архитектурных решений и работу над реальными продакшн-задачами. Также важно понять, насколько мой стиль работы и подходы соответствуют культуре и ожиданиям команды.

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

Вопрос 3. Объясните, как работают слайсы в Go: что происходит при добавлении элементов, как устроен внутренне, что будет при присваивании одного слайса другому и при добавлении элементов сверх ёмкости?

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

Ответ собеседника: правильный. Слайс — это структура с тремя полями: указатель на базовый массив, длина и ёмкость. При добавлении элементов через append, если ёмкости хватает, элемент добавляется в тот же базовый массив. При присваивании одного слайса другому копируется заголовок, оба слайса указывают на один базовый массив (shared memory). Если ёмкости не хватает, создаётся новый базовый массив с увеличенной ёмкостью (обычно удвоение до порога ~256 с Go 1.18), данные копируются, и слайс начинает указывать на новый массив.

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

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

Внутреннее устройство слайса

Слайс в Go — это структура (заголовок), содержащая три поля:

  • ptr — указатель на первый элемент базового массива
  • len — текущая длина слайса (количество доступных элементов)
  • cap — ёмкость (общее количество элементов в базовом массиве от начала слайса до конца массива)
// Примерная структура (из runtime/slice.go)
type slice struct {
array unsafe.Pointer
len int
cap int
}

Присваивание слайсов (shared backing array)

При присваивании b := a копируется только заголовок слайса. Оба слайса указывают на один и тот же базовый массив. Изменение элемента через один слайс видно через другой.

a := []int{1, 2, 3, 4, 5}
b := a
b[0] = 100
fmt.Println(a[0]) // 100 — изменилось!

Стратегия роста ёмкости

Начиная с Go 1.18, стратегия роста изменилась для борьбы с фрагментацией памяти:

  • Для слайсов с ёмкостью < 256 — удвоение (newcap = old.cap * 2)
  • Для слайсов с ёмкостью >= 256 — рост на 25% + некоторая корректировка (newcap = old.cap + (old.cap + 3*256) / 4)

Это означает, что для больших слайсов

Вопрос 4. Что такое куча (heap) и стек (stack), чем они отличаются и как работает сборщик мусора в Go?

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

Ответ собеседника: неполный. Куча и стек — это две разные области памяти. Стек — работает быстро, просто создавать и удалять данные, но ограничен в размером. Куча — более гибкая, но требует сборки мусора. Сборщик мусора в Go работает по алгоритму три-цветной маркировки: строится граф объектов, корневые элементы красятся в серый, затем рекурсивно в чёрный, а неиспользуемые белые объекты удаляются. Запускается при увеличении размера кучи в два раза, регулируется переменной GOGC. Полностью отключить GC нельзя, но можно настроить.

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

Ответ кандидата содержит правильные базовые идеи, но не раскрывает ряд важных аспектов: escape analysis, конкретику работы GC в Go (concurrent mark-and-sweep, write barrier, STW), а также практические следствия для разработчика.

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

Стек:

  • Управляется компилятором автоматически — выделение и освобождение происходит при входе/выходе из функции
  • Очень быстрое выделение (один сдвиг указателя стека)
  • Ограничен по размеру (обычно 1-8 МБ на горутину в Go)
  • Данные живут только в рамках текущего вызова функции
  • Нет накладных расходов на сборку мусора

Куча:

  • Глобальная область памяти, доступная из любой точки программы
  • Требует управления временем жизни (в Go — сборщик мусора)
  • Медленнее выделение по сравнению со стеком
  • Нет ограничения по размеру (кроме доступной системной памяти)
  • Объекты живут, пока на них есть ссылки

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

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

// Убегает в кучу — возвращаем указатель
func newPerson() *Person {
p := Person{name: "Alice"} // escapes to heap
return &p
}

// Остаётся на стеке — возвращаем значение
func createPerson() Person {
p := Person{name: "Bob"} // stays on stack
return p
}

Проверить можно флагом: go build -gcflags="-m" main.go

Сборщик мусора в Go: подробности

Go использует конкурентный три-цветный mark-and-sweep сборщик мусора:

Три цвета объектов:

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

Фазы работы GC:

  1. STW (Stop The World) — короткая пауза для запуска сборщика, включение write barrier
  2. Concurrent Mark — параллельно с основным кодом: обход графа объектов от корней (глобальные переменные, стеки горутин)
  3. Write Barrier — барьер записи, который отслеживает изменения ссылок во время маркировки, чтобы не пропустить живые объекты
  4. STW Mark Termination — финальная пауза для завершения маркировки
  5. Concurrent Sweep — параллельная очистка белых (недостижимых) объектов

Триггер запуска GC:

GC запускается, когда размер кучи достигает порога, рассчитанного по формуле:

trigger = heap_marked + heap_marked * GOGC / 100

По умолчанию GOGC=100, что означает запуск при удвоении размера кучи с прошлого цикла.

Настройка GC:

// Увеличить порог срабатывания (реже, но дольше паузы)
debug.SetGCPercent(200)

// Принудительный запуск
runtime.GC()

// Статистика по GC
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("GC cycles: %d, Pause total: %v\n", stats.NumGC, stats.PauseTotalNs)

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

  • Минимизируйте аллокации в горячих путях — используйте sync.Pool для переиспользования объектов
  • Избегайте ненужных указателей — они создают работу для GC
  • Используйте pprof для анализа аллокаций: go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
  • Для latency-sensitive сервисов настраивайте GOGC и мониторьте GC pause time

Вопрос 5. Что такое интерфейс в Go, как он устроен внутри?

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

Ответ собеседника: правильный. Интерфейс в Go — структура, содержащая два значения: указатель на тип данных (type), который хранится в интерфейсе, и указатель на сами данные (value). Если метод возвращает интерфейс, то даже nil-указатель будет обёрнут в интерфейс с ненулевым типом, и сравнение с nil даст false.

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

Ответ кандидата корректный и затрагивает важный нюанс с nil-интерфейсами. Однако стоит дополнить его более детальным описанием внутренней структуры и дополнительными аспектами.

Внутреннее устройство интерфейса

Интерфейс в Go представлен двумя структурами в runtime:

iface — интерфейс с методами:

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

eface — пустой интерфейс (interface{} / any):

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

Структура itab (interface table):

type itab struct {
inter *interfacetype // описание интерфейса
_type *_type // описание конкретного типа
hash uint32 // хеш типа (для оптимизации type assertion)
_ [4]byte // padding
fun [1]uintptr // массив указателей на методы (размер зависит от количества методов)
}

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

  1. Когда конкретный тип присваивается интерфейсу, компилятор создаёт itab — таблицу соответствия методов интерфейса к методам конкретного типа
  2. itab кэшируется в карте runtime.itabTable для повторного использования
  3. При вызове метода через интерфейс происходит разыменование: interface.tab.fun[i]

Nil-интерфейс vs интерфейс с nil-значением

Классическая ловушка в Go:

type MyError struct{}

func (e *MyError) Error() string { return "my error" }

func getError() error {
var e *MyError = nil
return e // возвращает интерфейс с type=*MyError, data=nil
}

func main() {
err := getError()
fmt.Println(err == nil) // false! Интерфейс не nil, т.к. тип заполнен
fmt.Println(err != nil) // true
fmt.Println(err) // <nil> — метод Error() вызывается на nil-ресивере
}

Правильная обработка:

func getError() error {
var e *MyError = nil
if e != nil {
return e
}
return nil // возвращаем настоящий nil-интерфейс
}

Пустой интерфейс interface{} / any

Не содержит методов, поэтому не нуждается в itab. Любой тип автоматически реализует пустой интерфейс. Используется для хранения произвольных значений, но требует type assertion или reflection для извлечения значения.

func printValue(v any) {
switch val := v.(type) {
case int:
fmt.Printf("int: %d\n", val)
case string:
fmt.Printf("string: %s\n", val)
default:
fmt.Printf("unknown type: %T\n", val)
}
}

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

Вызов метода через интерфейс медленнее прямого вызова из-за косвенного обращения через itab. В горячих путях это может быть значимо. Компилятор выполняет devirtualization — если может определить конкретный тип на этапе компиляции, заменяет косвенный вызов на прямой.

Вопрос 6. Расскажите о планировщике Go: что такое горутины, процессоры (P), треды (M), локальные и глобальные очереди, work stealing, кооперативная и вытесняющая многозадачность.

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

Ответ собеседника: неполный. Горутины — легковесные потоки, управляемые планировщиком Go. Планировщик занимается их распределением и запуском. Есть реальные треды ОС (M), на которые маппятся горутины. Процессор (P) содержит очередь горутин и занимается их выполнением. По умолчанию количество P равно количеству ядер, регулируется через GOMAXPROCS. Используется кооперативная многозадачность с вытеснением: есть отдельный поток-монитор, который следит, чтобы горутина не выполнялась дольше 10 мс. При системных вызовах горутина блокируется, а после возврата попадает в глобальную очередь, откуда процессоры её забирают. Если у процессора закончилась работа, он идёт в глобальную очередь или крадёт половину очереди у другого процессора (work stealing). Горутина в статусе runnable попадает в локальную очередь, а в состоянии ожидания — в глобальную. Каждый 61-й тик процессор обращается к глобальной очереди, иначе — к локальной.

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

Ответ кандидата содержит много правильных элементов, но есть неточности и неполнота в описании механизмов. Ниже приведён развёрнутый и корректный ответ.

Модель GMP (Goroutine, Machine, Processor)

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

G (Goroutine):

  • Легковесный поток выполнения, управляемый рантаймом Go
  • Стек начинается с ~2 КБ, растёт и сжимается динамически
  • Состояния: runnable (готова к выполнению), running (выполняется), waiting (заблокирована), dead, syscall
  • Хранит контекст выполнения: указатель стека, счётчик программы, ссылку на M

M (Machine / OS Thread):

  • Реальный поток операционной системы
  • По умолчанию ограничен GOMAXPROCS (с Go 1.5 — количество ядер)
  • Выполняет инструкции горутин
  • Может быть заблокирован на системных вызовах

P (Processor):

  • Логический процессор, владеет локальной очередью горутин (runqueue)
  • Количество P по умолчанию равно runtime.GOMAXPROCS(0)
  • Содержит локальную очередь на 256 горутин (кольцевой буфер)
  • M должно быть привязано к P для выполнения горутин

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

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

  • Каждый P имеет свою локальную очередь
  • Новые горутины попадают в локальную очередь текущего P
  • Быстрый доступ без синхронизации (lock-free для владельца P)

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

  • Общая для всех P, защищена мьютексом
  • Горутины попадают сюда при:
    • Пробуждении после блокировки (channel, syscall)
    • Переполнении локальной очереди
    • Вызове runtime.Gosched()
  • Каждый 61-й планировочный тик P проверяет глобальную очередь (чтобы избежать голодания)

Work Stealing (кража работы)

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

  1. Сначала проверяет глобальную очередь
  2. Если пуста — случайным образом выбирает другой P и крадёт половину его локальной очереди
  3. Это обеспечивает балансировку нагрузки между процессорами
// Упрощённая логика work stealing (из runtime/proc.go)
func findrunnable() (gp *g, inheritTime bool) {
_p_ := getg().m.p.ptr()

// 1. Проверить локальную очередь
gp := runqget(_p_)
if gp != nil {
return gp, false
}

// 2. Проверить глобальную очередь (каждый 61-й тик)
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
gp := globrunqget(_p_, 0)
if gp != nil {
return gp, false
}
}

// 3. Work stealing у других P
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
gp := runqsteal(_p_, allp[enum.position()], stealRunNextG)
if gp != nil {
return gp, true
}
}
}

return nil, false
}

Кооперативная vs Вытесняющая многозадачность

До Go 1.14 — кооперативная:

  • Горутина отдавала управление только в определённых точках: вызовы функций, операции с каналами, системные вызовы
  • Долго работающая горутина без вызовов могла монополизировать P

С Go 1.14 — асинхронное вытеснение (preemption):

  • Сигнал SIGURG от потока-монитора (sysmon)
  • Горутина вытесняется, если выполняется дольше 10 мс в цикле без вызовов функций
  • Вытеснение происходит в безопасных точках (safe points)
// Пример горутины, которая будет вытеснена
func busyLoop() {
for {
// Тяжёлые вычисления без вызовов функций
// Через 10 мс будет вытеснена sysmon
}
}

Поток-монитор (sysmon):

Отдельная горутина, работающая без P, выполняет:

  • Вытеснение горутин, работающих >10 мс
  • Проверку deadlock
  • Запуск GC при необходимости
  • Возврат заблокированных M из системных вызовов (netpoller)

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

При блокирующем системном вызове:

  1. M отсоединяется от P
  2. P переходит к другому M или создаёт новый M
  3. Горутина блокируется через netpoller (epoll/kqueue/IOCP)
  4. Когда I/O готово, горутина возвращается в глобальную очередь
// Пример: HTTP-запрос блокирует горутину, но не M
func handler(w http.ResponseWriter, r *http.Request) {
resp, _ := http.Get("https://api.example.com") // блокирующий вызов
// M не блокируется — P переключается на другую горутину
defer resp.Body.Close()
}

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

  • Не создавайте миллионы горутин без необходимости — каждая потребляет память
  • Используйте GOMAXPROCS для ограничения параллелизма в контейнерах
  • Избегайте долгих вычислений без yield — используйте runtime.Gosched()
  • Мониторьте метрики: sched.latency, go.goroutines, threadcreate

Вопрос 7. Что будет выведено при запуске программы, где запускается 5000 горутин, каждая печатает значение переменной i из цикла, и почему? Как исправить, чтобы вывелось 5000?

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

Ответ собеседника: правильный. Ничего или случайные значения, потому что main завершается до завершения горутин, и переменная i — одна и та же для всех горутин (замыкание). В Go до версии 22 переменная цикла была общей для всех итераций. Для исправления нужно использовать sync.WaitGroup: объявить wg, вызвать wg.Add(1) перед каждой горутиной, wg.Done() внутри горутины и wg.Wait() в main. Также нужно передавать i как параметр функции, чтобы избежать проблемы замыкания.

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

Ответ кандидата корректный, но стоит уточнить версию Go и дополнить примерами кода.

Проблема: замыкание и переменная цикла

func main() {
for i := 0; i < 5000; i++ {
go func() {
fmt.Println(i) // замыкание захватывает переменную i
}()
}
time.Sleep(time.Second) // ненадёжный способ ожидания
}

Что произойдёт:

  1. Проблема замыкания (до Go 1.22): Все горутины захватывают одну и ту же переменную i. К моменту выполнения горутины, i уже равна 5000 (или другому значению). Вывод будет непредсказуемым — много повторяющихся значений, не все числа от 0 до 4999.

  2. Проблема завершения main: Когда main завершается, все горутины принудительно убиваются. time.Sleep — ненадёжный способ ожидания.

Исправление до Go 1.22:

func main() {
var wg sync.WaitGroup

for i := 0; i < 5000; i++ {
wg.Add(1)
go func(i int) { // передаём i как параметр
defer wg.Done()
fmt.Println(i)
}(i) // копия значения передаётся в функцию
}

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

Альтернативный способ (локальная переменная):

for i := 0; i < 5000; i++ {
wg.Add(1)
i := i // создаём локальную копию
go func() {
defer wg.Done()
fmt.Println(i)
}()
}

Начиная с Go 1.22:

В Go 1.22 (февраль 2024) изменилась семантика переменных цикла. Теперь каждая итерация создаёт новую переменную:

// Go 1.22+ — это работает корректно
func main() {
var wg sync.WaitGroup

for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // каждая горутина видит своё значение i
}()
}

wg.Wait()
}

Однако полагаться на это в production-коде без явного указания версии Go в go.mod не рекомендуется.

Ключевые правила:

  • Всегда используйте sync.WaitGroup или каналы для синхронизации
  • Передавайте переменные цикла как параметры функции
  • Не используйте time.Sleep для ожидания горутин
  • Указывайте go 1.22 в go.mod, если полагаетесь на новую семантику

Вопрос 8. Какие средства синхронизации в Go вы знаете? В каких случаях использовать мьютексы, а в каких каналы? Чем sync.Map отличается от обычной map с мьютексом?

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

Ответ собеседника: неполный. В Go есть каналы, мьютексы (sync.Mutex), sync.Map, WaitGroup, Once, Cond. Каналы — основной способ коммуникации между горутинами. Мьютексы используются для защиты общих данных при конкурентном доступе, например, при работе с мапой из нескольких горутин. sync.Map — конкуренто-безопасная мапа, оптимизированная для случаев с большим количеством ядер, когда горутины работают с разными ключами. Выбор между мьютексами и каналами зависит от задачи: каналы — для передачи данных и координации, мьютексы — для защиты общего состояния.

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

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

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

Базовые примитивы из sync:

  • sync.Mutex — взаимное исключение, базовый мьютекс
  • sync.RWMutex — мьютекс с разделением на читателей и писателей
  • sync.WaitGroup — ожидание завершения группы горутин
  • sync.Once — гарантия однократного выполнения
  • sync.Cond — условная переменная для сигнализации между горутинами
  • sync.Map — конкуренто-безопасная мапа
  • sync.Pool — пул объектов для переиспользования

Атомарные операции из sync/atomic:

var counter atomic.Int64
counter.Add(1)
counter.Load()
counter.CompareAndSwap(old, new)

Каналы:

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

Мьютексы vs Каналы: когда что использовать

Используйте каналы когда:

  • Нужна передача владения данными (owner pattern)
  • Координация работы горутин (fan-out, fan-in, pipeline)
  • Реализация таймаутов и отмены через select
  • Очереди задач и worker pools
// Pipeline pattern с каналами
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}

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

Используйте мьютексы когда:

  • Нужна защита общего состояния (кэш, счётчики, конфигурация)
  • Частые короткие операции чтения/записи
  • Сложная логика с несколькими полями, которые должны обновляться атомарно
type Cache struct {
mu sync.RWMutex
items map[string]*Item
}

func (c *Cache) Get(key string) (*Item, bool) {
c.mu.RLock() // блокировка на чтение
defer c.mu.RUnlock()
item, ok := c.items[key]
return item, ok
}

func (c *Cache) Set(key string, item *Item) {
c.mu.Lock() // эксклюзивная блокировка
defer c.mu.Unlock()
c.items[key] = item
}

sync.Map vs map + мьютекс

Внутреннее устройство sync.Map:

sync.Map использует два мапа:

  • read — атомарно читаемая мапа (read-only, без блокировок)
  • dirty — полная мапа, защищённая мьютексом

При чтении сначала проверяется read, при промахе — dirty с блокировкой. Промахи отслеживаются счётчиком misses, и после порога dirty копируется в read.

type Map struct {
mu sync.Mutex
read atomic.Pointer[readOnly]
dirty map[any]*entry
misses int
}

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

  • Ключи редко добавляются, но часто читаются (стабильный набор ключей)
  • Разные горутины работают с разными ключами (минимум конкуренции)
  • Кэш с TTL, конфигурация, реестр сервисов
var cache sync.Map

// Store
cache.Set("key", value)

// Load
if val, ok := cache.Load("key"); ok {
item := val.(*Item)
}

// LoadOrStore — атомарная операция
actual, loaded := cache.LoadOrStore("key", newValue)

Когда использовать map + мьютекс:

  • Частые записи и чтения одних и тех же ключей
  • Нужна итерация по всем элементам
  • Сложные операции над несколькими ключами
  • Меньшие накладные расходы при малом количестве горутин
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}

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

func (s *SafeMap[K, V]) Range(f func(K, V) bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for k, v := range s.m {
if !f(k, v) {
break
}
}
}

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

sync.Map показывает преимущество при >4 ядрах и стабильном наборе ключей. Для других случаев map + RWMutex часто быстрее из-за меньших аллокаций.

Другие примитивы:

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

var once sync.Once
var instance *Config

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

sync.Cond — сигнализация:

type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
}

func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu)
return q
}

func (q *Queue) Put(item int) {
q.mu.Lock()
q.items = append(q.items, item)
q.mu.Unlock()
q.cond.Signal() // сигнализируем ожидающим
}

func (q *Queue) Get() int {
q.mu.Lock()
for len(q.items) == 0 {
q.cond.Wait() // ждём сигнала
}
item := q.items[0]
q.items = q.items[1:]
q.mu.Unlock()
return item
}

Вопрос 9. Что произойдёт при записи в небуферизированный канал, если нет читателя? А что при записи/чтении из nil-канала?

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

Ответ собеседника: правильный. При записи в небуферизированный канал без читателя программа зависнет (deadlock), потому что запись блокируется до появления читателя. Горутина-писатель вызовет gopark и встанет в очередь пишущих горутин канала, ожидая читателя. Программа выйдет с ошибкой fatal error: all goroutines are asleep - deadlock. При записи или чтении из nil-канала горутина блокируется навсегда (не паникует, просто паркуется).

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

Ответ кандидата корректный. Дополним его деталями о внутреннем устройстве каналов и практическими примерами.

Внутреннее устройство канала

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

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

Небуферизованный канал без читателя

func main() {
ch := make(chan int)
ch <- 42 // блокировка — нет читателя
fmt.Println("never printed")
}

Что происходит:

  1. Горутина пытается записать в канал
  2. Планировщик проверяет recvq — очередь читателей пуста
  3. Горутина вызывает gopark() — паркуется и добавляется в sendq
  4. Если все горутины заблокированы — fatal error: all goroutines are asleep - deadlock!

С читателем — работает:

func main() {
ch := make(chan int)

go func() {
val := <-ch // читатель заблокирован, ждёт данные
fmt.Println("received:", val)
}()

ch <- 42 // данные передаются напрямую читателю
}

Nil-канал

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

// Все операции блокируются навсегда без паники:
ch <- 42 // блокировка навсегда
<-ch // блокировка навсегда
close(ch) // panic: close of nil channel

Практическое применение nil-каналов:

Nil-каналы полезны в select для отключения case:

func merge(stop <-chan struct{}, inputs ...<-chan int) <-chan int {
out := make(chan int)

for _, in := range inputs {
go func(ch <-chan int) {
for {
select {
case <-stop:
return
case val, ok := <-ch:
if !ok {
return
}
select {
case out <- val:
case <-stop:
return
}
}
}
}(in)
}

return out
}

// Динамическое отключение case через nil-канал
func dynamicSelect(enableSend bool) {
var sendCh chan int
if enableSend {
sendCh = make(chan int, 1)
}
// если enableSend == false, sendCh остаётся nil
// и case sendCh <- val никогда не сработает

select {
case sendCh <- 42:
fmt.Println("sent")
default:
fmt.Println("skipped")
}
}

Закрытие канала:

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

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

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

Базовые правила:

  • Небуферизованный канал синхронизирует отправителя и получателя
  • Nil-канал блокирует чтение/запись навсегда (полезно в select)
  • Закрывать канал должен отправитель, не получатель
  • Запись в закрытый канал вызывает панику
  • Чтение из закрытого канала возвращает zero value и false

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

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

Ответ собеседника: правильный. Для мержа нескольких каналов в один нужно создать выходной канал, запустить по одной горутине на каждый входной канал, которые читают из него и пишут в выходной. Использовать WaitGroup для ожидания завершения всех горутин и закрытия выходного канала. Select не подходит, потому что он не поддерживает динамические кейсы — количество каналов должно быть известно на этапе компиляции.

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

Ответ кандидата корректный. Дополним его полной реализацией и альтернативными подходами.

Реализация merge с отдельными горутинами:

func Merge[T any](channels ...<-chan T) <-chan T {
if len(channels) == 0 {
out := make(chan T)
close(out)
return out
}

out := make(chan T)
var wg sync.WaitGroup

// Запускаем по горутине на каждый входной канал
for _, ch := range channels {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

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

return out
}

Использование:

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

go func() {
defer close(ch1)
for i := 1; i <= 3; i++ {
ch1 <- i
}
}()

go func() {
defer close(ch2)
for i := 10; i <= 30; i += 10 {
ch2 <- i
}
}()

go func() {
defer close(ch3)
for i := 100; i <= 300; i += 100 {
ch3 <- i
}
}()

for val := range Merge(ch1, ch2, ch3) {
fmt.Println(val)
}
}

Почему не подходит select:

// ❌ Так нельзя — количество каналов динамическое
func BadMerge[T any](channels ...<-chan T) <-chan T {
out := make(chan T)
go func() {
for {
select {
// Нельзя динамически добавить case для каждого каналу
// case val := <-channels[0]: // хардкод
// case val := <-channels[1]: // хардкод
}
}
}()
return out
}

Ограничения select:

  • Количество case должно быть известно на этапе компиляции
  • Нельзя использовать channels[i] в case — только конкретные переменные
  • Для динамического списка каналов пришлось бы генерировать код

Альтернатива с reflect (медленнее, но без горутин на канал):

func MergeWithReflect[T any](channels ...<-chan T) <-chan T {
out := make(chan T)

go func() {
defer close(out)

cases := make([]reflect.SelectCase, len(channels))
for i, ch := range channels {
cases[i] = reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(ch),
}
}

for len(cases) > 0 {
i, val, ok := reflect.Select(cases)
if !ok {
// Канал закрыт — удаляем из списка
cases = append(cases[:i], cases[i+1:]...)
continue
}
out <- val.Interface().(T)
}
}()

return out
}

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

ПодходПлюсыМинусы
Горутины на каналПростой, идиоматичный, быстрыйМного горутин при большом числе каналов
reflect.SelectОдна горутинаМедленнее из-за reflection, сложнее код
Рекурсивный mergeO(log n) горутинСложнее реализация

Рекурсивный merge (оптимальное число горутин):

func MergeOptimized[T any](channels ...<-chan T) <-chan T {
if len(channels) == 0 {
out := make(chan T)
close(out)
return out
}
if len(channels) == 1 {
return channels[0]
}

mid := len(channels) / 2
return mergeTwo(
MergeOptimized(channels[:mid]...),
MergeOptimized(channels[mid:]...),
)
}

func mergeTwo[T any](a, b <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
var wg sync.WaitGroup
wg.Add(2)

for _, ch := range []<-chan T{a, b} {
go func(c <-chan T) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}

wg.Wait()
}()
return out
}

Этот подход создаёт O(log n) уровней вложенности вместо O(n) горутин.

Вопрос 11. Как устроен буферизированный канал внутри? Как работают указатели чтения и записи? Что происходит при заполнении буфера?

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

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

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

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

Внутренняя структура канала (hchan)

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

type waitq struct {
first *sudog
last *sudog
}

Кольцевой буфер (ring buffer)

Буфер канала — это кольцевой массив фиксированного размера. Указатели sendx и recvx циклически обходят массив:

Буфер размером 6 (dataqsiz = 6):

Изначально: sendx=0, recvx=0, qcount=0
[_, _, _, _, _, _]
^sendx/recvx

После 3 записей: sendx=3, recvx=0, qcount=3
[A, B, C, _, _, _]
^recvx ^sendx

После 2 чтений: sendx=3, recvx=2, qcount=1
[A, B, C, _, _, _]
^recvx ^sendx

После 4 записей: sendx=1, recvx=2, qcount=5
[F, B, C, D, E, _]
^sendx
^recvx

Операции с указателями:

// Запись в буфер
sendx++
if sendx == dataqsiz {
sendx = 0 // циклический возврат
}
qcount++

// Чтение из буфера
recvx++
if recvx == dataqsiz {
recvx = 0 // циклический возврат
}
qcount--

Сценарии работы буферизированного канала

1. Буфер не полон, нет ожидающих читателей:

ch := make(chan int, 3)
ch <- 1 // запись в буфер, sendx++, qcount++
ch <- 2 // запись в буфер

Горутина не блокируется, значение помещается в buf[sendx].

2. Буфер заполнен (qcount == dataqsiz):

ch := make(chan int, 1)
ch <- 1 // буфер заполнен
ch <- 2 // горутина паркуется!

Что происходит:

  1. Блокировка hchan.lock
  2. Проверка: qcount == dataqsiz → буфер полон
  3. Создаётся sudog с указателем на значение для записи
  4. sudog добавляется в sendq
  5. Горутина вызывает gopark() — паркуется
  6. Разблокировка hchan.lock

3. Буфер пуст (qcount == 0), нет ожидающих писателей:

ch := make(chan int, 3)
val := <-ch // горутина паркуется!

Горутина-читатель паркуется и добавляется в recvq.

4. Оптимизация прямой передачи (direct handoff):

Когда горутина пишет в канал и есть запаркованный читатель в recvq:

ch := make(chan int, 1)

// Горутина 1: ждёт чтения
go func() {
val := <-ch // паркуется в recvq
}()

// Горутина 2: пишет
ch <- 42 // прямая передача читателю без записи в буфер!

Алгоритм прямой передачи:

  1. Блокировка hchan.lock
  2. Проверка: recvq.first != nil → есть ожидающий читатель
  3. Значение копируется напрямую в память читателя
  4. Читатель пробуждается (goready())
  5. Буфер не используется вообще

5. Запись при наличии свободного места и ожидающего читателя:

Если буфер не полон, но есть запаркованный читатель:

  1. Значение берётся из буфера (сдвигает recvx)
  2. Новое значение записывается на освободившееся место
  3. Запаркованный читатель получает значение из буфера

Схема принятия решения при записи:

func chansend(c *hchan, ep unsafe.Pointer) {
lock(&c.lock)

// 1. Есть ожидающий читатель? → прямая передача
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func() { unlock(&c.lock) })
return
}

// 2. Буфер не полон? → запись в буфер
if c.qcount < c.dataqsiz {
qp := chanbuf(c, c.sendx)
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return
}

// 3. Буфер полон → паркуем горутину
gp := getg()
mysg := acquireSudog()
mysg.elem = ep
mysg.g = gp
c.sendq.enqueue(mysg)
gopark(chanparkcommit, nil, waitReasonChanSend, traceEvGoBlockSend)
}

Схема принятия решения при чтении:

func chanrecv(c *hchan, ep unsafe.Pointer) (selected, received bool) {
lock(&c.lock)

// 1. Есть ожидающий писатель? → прямое чтение
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep)
return
}

// 2. Буфер не пуст? → чтение из буфера
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
typedmemmove(c.elemtype, ep, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&c.lock)
return
}

// 3. Буфер пуст → паркуем горутину
gp := getg()
mysg := acquireSudog()
mysg.g = gp
c.recvq.enqueue(mysg)
gopark(chanparkcommit, nil, waitReasonChanReceive, traceEvGoBlockRecv)
}

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

  • Размер буфера фиксирован при создании канала — изменить нельзя
  • Прямая передача быстрее, чем через буфер
  • Буфер полезен для сглаживания burst-нагрузки
  • Слишком большой буфер может маскировать проблемы с производительностью

Вопрос 12. Проведите ревью кода функции редактирования пользователя. Какие проблемы вы видите?

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

Ответ собеседника: неполный. В коде есть несколько проблем: 1) Некорректная обработка ошибок — ошибки игнорируются или обрабатываются неправильно, после проверки ошибок нет return, выполнение продолжается. 2) Передаётся SQL-запрос напрямую в репозиторий — нарушение паттерна репозиторий, возможны SQL-инъекции. 3) Нет проверки на существование пользователя по ID перед обновлением. 4) Ошибки валидации возвращаются неконкретно. 5) Нейминг переменных не соответствует конвенции Go. 6) Тело запроса парсится в map[string][]byte вместо структуры. 7) Накопление ошибок через map вместо возврата сразу. 8) Смешение доменной модели с моделью БД — нужно разделение на слои. 9) Нет обработки сигналов (graceful shutdown).

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

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

Структура ревью кода

1. Обработка ошибок (Error Handling)

Проблемы:

  • Игнорирование ошибок: result, _ := db.Exec(...) — ошибка теряется
  • Отсутствие return после обработки ошибки — выполнение продолжается
  • Неинформативные сообщения об ошибках
// ❌ Плохо
result, _ := db.Exec(query)
if err != nil {
log.Println("error")
}
// выполнение продолжается

// ✅ Хорошо
result, err := db.Exec(query, args...)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}

2. Безопасность (Security)

  • SQL-инъекции при конкатенации строк
  • Отсутствие валидации входных данных
  • Потенциальная утечка чувствительных данных в логах
// ❌ SQL-инъекция
query := "UPDATE users SET name = '" + name + "' WHERE id = " + id

// ✅ Параметризованный запрос
query := "UPDATE users SET name = $1 WHERE id = $2"
result, err := db.Exec(query, name, id)

3. Архитектура и разделение слоёв

// ❌ Смешение слоёв
func UpdateUser(w http.ResponseWriter, r *http.Request) {
// парсинг запроса
// валидация
// бизнес-логика
// прямой вызов SQL
// формирование ответа
}

// ✅ Разделение ответственности
type UserService struct {
repo UserRepository
validator Validator
}

func (s *UserService) Update(ctx context.Context, req UpdateRequest) (*User, error) {
if err := s.validator.Validate(req); err != nil {
return nil, ErrInvalidRequest(err)
}
return s.repo.Update(ctx, req.ToDomain())
}

4. Валидация входных данных

// ❌ Нет валидации
func UpdateUser(data map[string][]byte) error {
name := string(data["name"])
// используем как есть
}

// ✅ Структурированная валидация
type UpdateUserRequest struct {
ID int64 `json:"id" validate:"required,gt=0"`
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
}

func (r UpdateUserRequest) Validate() error {
validate := validator.New()
return validate.Struct(r)
}

5. Работа с контекстом

// ❌ Нет контекста
func UpdateUser(id int, data map[string][]byte) error {
db.Exec("UPDATE ...")
}

// ✅ С контекстом
func (r *UserRepo) Update(ctx context.Context, user *User) error {
result, err := r.db.ExecContext(ctx, query, user.Name, user.ID)
if err != nil {
return fmt.Errorf("update user %d: %w", user.ID, err)
}

rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}

6. Транзакционность

// ❌ Нет транзакций
func UpdateUserAndProfile(userID int, userData, profileData map[string][]byte) error {
db.Exec("UPDATE users ...", userData)
db.Exec("UPDATE profiles ...", profileData) // может упасть
}

// ✅ С транзакцией
func (r *UserRepo) UpdateWithProfile(ctx context.Context, user *User, profile *Profile) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

if err := r.updateUser(tx, user); err != nil {
return err
}
if err := r.updateProfile(tx, profile); err != nil {
return err
}

return tx.Commit()
}

7. Нейминг и конвенции Go

// ❌ Не по конвенции
func update_user(u User_dto) error {}
var UserName string
type user_update_request struct {}

// ✅ По конвенции
func UpdateUser(u UserDTO) error {}
var userName string
type UserUpdateRequest struct {}

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

// Проверка существования пользователя
func (r *UserRepo) Update(ctx context.Context, user *User) error {
result, err := r.db.ExecContext(ctx,
"UPDATE users SET name = $1 WHERE id = $2 AND deleted_at IS NULL",
user.Name, user.ID)
if err != nil {
return err
}

rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}

9. Типизированные ошибки

// ❌ Строковые ошибки
return errors.New("user not found")

// ✅ Типизированные ошибки
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailExists = errors.New("email already exists")
)

// Или кастомные типы
type ValidationError struct {
Field string
Message string
}

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

10. Graceful shutdown

// ✅ Корректное завершение
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler()}

go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig

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

srv.ListenAndServe()
}

Чек-лист для ревью:

  • Все ошибки обработаны и обёрнуты с контекстом
  • Нет SQL-инъекций (параметризованные запросы)
  • Валидация входных данных на границе
  • Контекст пробрасывается через все слои
  • Транзакции для атомарных операций
  • Разделение слоёв (handler → service → repository)
  • Типизированные ошибки
  • Проверка RowsAffected для update/delete
  • Graceful shutdown для сервера
  • Логирование с контекстом (structured logging)

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

Таймкод: 01:03:26

Ответ собеседника: правильный. Структуры с одинаковыми полями, но в разном порядке, могут иметь разный размер из-за выравнивания (padding). Go выравнивает поля по размеру слова (8 байт на 64-битной системе). В структуре с полями разного размера компилятор добавляет padding для выравнивания. Например, bool (1 байт) + int64 (8 байт) + bool (1 байт) займёт 24 байта из-за padding, а int64 + bool + bool займёт 16 байт, так как два bool помещаются в одну ячейку.

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

Ответ кандидата корректный. Дополним его более детальным объяснением правил выравнивания и практическими примерами.

Правила выравнивания в Go

Базовые принципы:

  1. Каждое поле выравнивается по своему размеру (alignment = размер типа)
  2. Адрес поля должен быть кратен его размеру выравнивания
  3. Общий размер структуры выравнивается по размеру наибольшего поля
  4. Компилятор Go не переупорядочивает поля — порядок определяется программистом

Размеры типов и выравнивание:

Тип Размер Выравнивание
bool 1 1
int8/uint8 1 1
int16 2 2
int32 4 4
int64 8 8
float32 4 4
float64 8 8
string 16 8 (указатель + длина)
slice 24 8 (указатель + len + cap)
pointer 8 8
interface 16 8
map 8 8 (указатель)
chan 8 8 (указатель)

Пример 1: Влияние порядка полей

package main

import (
"fmt"
"unsafe"
)

type BadOrder struct {
A bool // 1 байт + 7 padding
B int64 // 8 байт
C bool // 1 байт + 7 padding
} // Итого: 24 байта

type GoodOrder struct {
B int64 // 8 байт
A bool // 1 байт
C bool // 1 байт + 6 padding
} // Итого: 16 байт

func main() {
fmt.Println("BadOrder:", unsafe.Sizeof(BadOrder{})) // 24
fmt.Println("GoodOrder:", unsafe.Sizeof(GoodOrder{})) // 16

fmt.Println("BadOrder.A offset:", unsafe.Offsetof(BadOrder{}.A)) // 0
fmt.Println("BadOrder.B offset:", unsafe.Offsetof(BadOrder{}.B)) // 8
fmt.Println("BadOrder.C offset:", unsafe.Offsetof(BadOrder{}.C)) // 16

fmt.Println("GoodOrder.B offset:", unsafe.Offsetof(GoodOrder{}.B)) // 0
fmt.Println("GoodOrder.A offset:", unsafe.Offsetof(GoodOrder{}.A)) // 8
fmt.Println("GoodOrder.C offset:", unsafe.Offsetof(GoodOrder{}.C)) // 9
}

Пример 2: Сложная структура

type ComplexStruct struct {
Flag1 bool // 1 + 1 padding
Small int16 // 2
Flag2 bool // 1 + 3 padding
Medium int32 // 4
Big int64 // 8
Flag3 bool // 1 + 7 padding
} // Итого: 32 байта

// Оптимизированная версия
type OptimizedStruct struct {
Big int64 // 8
Medium int32 // 4
Small int16 // 2
Flag1 bool // 1
Flag2 bool // 1
Flag3 bool // 1 + 1 padding
} // Итого: 24 байта

Правило оптимизации:

Группируйте поля по размеру, начиная с наибольших:

  1. int64, float64, string, slice, pointer — 8 байт
  2. int32, float32 — 4 байта
  3. int16 — 2 байта
  4. int8, bool — 1 байт

Пример 3: Вложенные структуры

type Inner struct {
X int32 // 4 + 4 padding
Y int64 // 8
} // 16 байт, выравнивание 8

type Outer struct {
A byte // 1 + 7 padding
Inn Inner // 16
B byte // 1 + 7 padding
} // 32 байта

Проверка выравнивания:

func printStructLayout[T any]() {
var v T
t := reflect.TypeOf(v)

fmt.Printf("Type: %s, Size: %d\n", t.Name(), unsafe.Sizeof(v))
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf(" %s: offset=%d, size=%d, align=%d\n",
f.Name, f.Offset, f.Type.Size(), f.Type.Align())
}
fmt.Println()
}

// Использование
printStructLayout[BadOrder]()
printStructLayout[GoodOrder]()

Практическое значение:

  • В горячих путях с миллионами аллокаций экономия памяти существенна
  • Меньшие структуры лучше кэшируются (cache-friendly)
  • При сериализации/десериализации порядок полей влияет на совместимость
// Бенчмарк показывает разницу
func BenchmarkBadOrder(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]BadOrder, 1000000)
}
}

func BenchmarkGoodOrder(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = make([]GoodOrder, 1000000)
}
}
// GoodOrder использует на 33% меньше памяти

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

  • Go не переупорядочивает поля автоматически (в отличие от некоторых компиляторов C)
  • reflect.Type.Align() возвращает требуемое выравнивание типа
  • unsafe.Alignof() — выравнивание для конкретного выражения
  • unsafe.Sizeof() — полный размер включая padding

Вопрос 14. Что такое escape analysis в Go? Какие объекты попадают в кучу, а какие остаются на стеке? Как можно уменьшить количество аллокаций?

Таймкод: 01:07:33

Ответ собеседника: неполный. Escape analysis — это процесс, который определяет, где хранить переменные: на стеке или в куче. Объект попадает в кучу, если: его размер превышает определённый порог (примерно 64 байта), он шарится между разными горутинами (например, каналы), или на него есть ссылки за пределами функции. Для уменьшения аллокаций можно заранее выделять память для слайсов и мап с известной ёмкостью (make([]T, 0, capacity)), использовать sync.Pool для переиспользования объектов. Также есть хак с созданием баласта (пустого слайса большого размера) в main для увеличения начального размера кучи, чтобы GC срабатывал реже.

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

Ответ кандидата содержит правильные идеи, но некоторые утверждения неточны. Ниже приведён развёрнутый и корректный ответ.

Escape Analysis: что это и как работает

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

Когда переменная убегает в кучу (escapes to heap):

// 1. Возврат указателя на локальную переменную
func newPerson() *Person {
p := Person{name: "Alice"} // escapes to heap
return &p
}

// 2. Запись в слайс/мапу, которая живёт дольше
var cache []int

func addToCache(val int) {
cache = append(cache, val) // val может убежать при реаллокации
}

// 3. Отправка в канал
func sendToChannel(ch chan *Person) {
p := Person{name: "Bob"} // escapes to heap
ch <- &p
}

// 4. Передача в интерфейс
func printValue(v interface{}) {
fmt.Println(v) // v убежит в кучу из-за boxing
}

// 5. Замыкание, захватывающее переменную
func makeCounter() func() int {
count := 0 // escapes to heap
return func() int {
count++
return count
}
}

// 6. Слишком большой объект (зависит от компилятора)
func bigArray() {
var arr [10000]int // может убежать из-за размера
_ = arr
}

Когда переменная остаётся на стеке:

// 1. Возврат значения (не указателя)
func createPerson() Person {
return Person{name: "Alice"} // stays on stack
}

// 2. Локальное использование без утечки ссылки
func sum(nums []int) int {
total := 0 // stays on stack
for _, n := range nums {
total += n
}
return total
}

// 3. Маленькие массивы, не передаваемые наружу
func localArray() int {
var arr [10]int // stays on stack
arr[0] = 42
return arr[0]
}

Проверка escape analysis:

# Показать все escape-анализ решения
go build -gcflags="-m" main.go

# Более подробный вывод
go build -gcflags="-m -m" main.go

Вывод:

./main.go:5:6: p escapes to heap
./main.go:6:9: ... argument does not escape

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

1. Предварительное выделение памяти:

// ❌ Много аллокаций при росте
func buildItems(n int) []Item {
var items []Item
for i := 0; i < n; i++ {
items = append(items, Item{ID: i})
}
return items
}

// ✅ Одна аллокация
func buildItems(n int) []Item {
items := make([]Item, 0, n)
for i := 0; i < n; i++ {
items = append(items, Item{ID: i})
}
return items
}

2. Переиспользование объектов через sync.Pool:

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

func processRequest(data []byte) string {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset() // важно сбросить перед использованием

buf.Write(data)
return buf.String()
}

3. Избегание boxing в интерфейсы:

// ❌ Аллокация при каждом вызове
func logValue(v interface{}) {
fmt.Println(v) // boxing → heap allocation
}

// ✅ Использование конкретных типов
func logString(v string) {
fmt.Println(v) // no allocation
}

// ✅ Или generics (Go 1.18+)
func logValue[T any](v T) {
fmt.Println(v) // мономорфизация без boxing
}

4. Стековые аллокации в горячих путях:

// ❌ Убегает в кучу
func process(data []byte) *Result {
r := &Result{}
r.Process(data)
return r
}

// ✅ Возвращаем значение
func process(data []byte) Result {
var r Result
r.Process(data)
return r
}

5. Использование arena (Go 1.20+, экспериментально):

import "arena"

func processBatch(items []Item) {
a := arena.NewArena()
defer a.Free() // освободить всё разом

for _, item := range items {
// Все аллокации в arena будут освобождены вместе
buf := arena.MakeSlice[byte](a, 1024, 1024)
_ = buf
}
}

6. Ballast-паттерн (увеличение начального размера кучи):

func main() {
// Создаём "балласт" — большой объект, который живёт всё время программы
// Это увеличивает начальную кучу, и GC запускается реже
ballast := make([]byte, 1<<30) // 1 ГБ
runtime.KeepAlive(ballast)

// ... основная программа
}

7. Оптимизация строк:

// ❌ Аллокация при каждом вызове
func buildKey(userID int) string {
return fmt.Sprintf("user:%d", userID)
}

// ✅ Использование strconv (меньше аллокаций)
func buildKey(userID int) string {
return "user:" + strconv.Itoa(userID)
}

// ✅ Или strings.Builder для сложных случаев
func buildKey(parts ...string) string {
var b strings.Builder
b.Grow(64) // предварительное выделение
for i, p := range parts {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(p)
}
return b.String()
}

Мониторинг аллокаций:

// Через runtime
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Alloc: %d MB, TotalAlloc: %d MB, NumGC: %d\n",
stats.Alloc/1024/1024,
stats.TotalAlloc/1024/1024,
stats.NumGC)

// Через pprof
import _ "net/http/pprof"
// go tool pprof http://localhost:6060/debug/pprof/heap

// Бенчмарк с аллокациями
func BenchmarkProcess(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = process(make([]byte, 1024))
}
}

Ключевые метрики:

  • alloc_space — общее количество аллоцированных байт
  • alloc_objects — количество аллокаций
  • Цель: минимизировать оба показателя в горячих путях

Вопрос 15. Что такое инлайнинг (inlining) функций в Go и зачем он нужен?

Таймкод: 01:10:21

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

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

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

Как работает инлайнинг

Без инлайнинга вызов функции требует:

  1. Создание нового стекового фрейма
  2. Копирование аргументов
  3. Передача управления (call/ret)
  4. Возврат результата

С инлайнингом тело функции подставляется прямо в место вызова:

// Исходный код
func Add(a, b int) int {
return a + b
}

func main() {
result := Add(3, 4)
}

// После инлайнинга (концептуально)
func main() {
result := 3 + 4 // тело Add подставлено напрямую
}

Когда компилятор применяет инлайнинг:

Компилятор Go использует эвристику на основе "стоимости" функции (cost budget):

  • Простые функции с небольшим количеством инструкций — инлайнятся
  • Сложные функции с циклами, множеством ветвлений — нет
  • Порог стоимости по умолчанию: 80 (можно изменить флагом)
// ✅ Будет инлайнена — простая функция
func Max(a, b int) int {
if a > b {
return a
}
return b
}

// ❌ Не будет инлайнена — слишком сложная
func ComplexFunction(data []byte) error {
// много логики, циклов, вызовов других функций
for _, b := range data {
if err := validate(b); err != nil {
return err
}
if err := process(b); err != nil {
return err
}
}
return nil
}

Когда инлайнинг невозможен:

// Рекурсивные функции
func Fibonacci(n int) int {
if n <= 1 {
return n
}
return Fibonacci(n-1) + Fibonacci(n-2) // нельзя инлайнить
}

// Функции с recover
func safeCall() {
defer func() {
recover() // нельзя инлайнить с recover
}()
}

// Функции с замыканиями (в некоторых случаях)
func makeCounter() func() int {
count := 0
return func() int { // замыкание может не инлайниться
count++
return count
}
}

// go:noinline директива
//go:noinline
func MustNotInline() {
// эта функция не будет инлайнена
}

Управление инлайнингом:

# Показать решения компилятора об инлайнинге
go build -gcflags="-m" main.go

# Вывод:
# ./main.go:3:6: can inline Add
# ./main.go:3:6: inlining call to Add

# Изменить порог инлайнинга (по умолчанию 80)
go build -gcflags="-l=4" main.go # агрессивный инлайнинг
go build -gcflags="-l=0" main.go # стандартный
go build -gcflags="-l=-1" main.go # отключить инлайнинг

Преимущества инлайнинга:

  1. Устранение накладных расходов на вызов:

    • Нет создания стекового фрейма
    • Нет копирования аргументов
    • Нет инструкций call/ret
  2. Дополнительные оптимизации:

    • Constant folding — вычисление констант на этапе компиляции
    • Dead code elimination — удаление недостижимого кода
    • Bounds check elimination — устранение проверок границ
func main() {
// После инлайнинга Max и подстановки констант:
// result := Max(3, 4)
// → result := 3 > 4 ? 3 : 4
// → result := 4 (constant folding)
result := Max(3, 4)
}
  1. Devirtualization вызовов через интерфейсы:
// Если компилятор знает конкретный тип, может инлайнить метод
func Process(items []Processor) {
for _, item := range items {
item.Process() // может быть инлайнен, если тип известен
}
}

Недостатки инлайнинга:

  1. Увеличение размера бинарника — тело функции дублируется в каждом месте вызова
  2. Ухудшение кэша инструкций — больший код может не помещаться в L1i кэш
  3. Время компиляции — агрессивный инлайнинг увеличивает время сборки

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

  • Пишите маленькие, чистые функции — компилятор сам решит об инлайнинге
  • Не пытайтесь вручную инлайнить код — компилятор умнее
  • Используйте go:noinline только для отладки или бенчмарков
  • Профилируйте перед оптимизацией — go tool pprof
// Бенчмарк для проверки влияния инлайнинга
func BenchmarkInline(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Add(i, i+1) // будет инлайнена
}
}

//go:noinline
func AddNoInline(a, b int) int {
return a + b
}

func BenchmarkNoInline(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = AddNoInline(i, i+1) // не будет инлайнена
}
}

Вопрос 16. Спроектируйте систему платежей с подпискками. Есть несколько провайдеров платежей (Яндекс.Касса, App Store, Google Play и др.), около 100 платежей в день, 10 млн пользователей. Нужно принимать вебхуки от провайдеров, начислять внутреннюю валюту и отвечать на запросы о текущем тарифе пользователя.

Таймкод: 01:11:50

Ответ собеседника: неполный. Предложена архитектура: вебхук-приёмник для обработки уведомлений от провайдеров, база данных (PostgreSQL) для хранения информации о подписках пользователей (user_id, tariff_id, last_payment_date, next_payment_date, status), API-ручка для получения текущего тарифа. Тарифные планы предлагается хранить в статическом конфиге. Для проверки активности подписок — периодическая джоба, которая ходит в API провайдеров (App Store, Google Play) с токенами покупок. Для обработки вебхуков предложено использовать Kafka как буфер для надёжности. Обсуждались проблемы: отказ базы (реплика, переключение), недоступность внешних API (retry-механизм с экспоненциальным бэкоффом, очередь задач), важность логирования для восстановления данных. Не все аспекты проработаны: идемпотентность, мониторинг, обработка ошибок.

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

Ответ кандидата покрывает основные компоненты, но не детализирует критически важные аспекты: идемпотентность, консистентность, обработку ошибок и масштабирование. Ниже приведена полная архитектура.

Общая архитектура

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Yandex │ │ App Store │ │ Google Play│
│ Kassa │ │ │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────┬───────┴───────┬───────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Webhook │ │ Webhook │
│ Receiver │ │ Receiver │
│ (per │ │ (per │
│ provider) │ │ provider) │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬───────┘

┌──────▼──────┐
│ Kafka │
│ (events) │
└──────┬──────┘

┌──────▼──────┐
│ Payment │
│ Processor │
│ (consumer) │
└──────┬──────┘

┌────────────┼────────────┐
│ │ │
┌──────▼──────┐ │ ┌───────▼───────┐
│ PostgreSQL │ │ │ Redis │
│ (source of │ │ │ (cache for │
│ truth) │ │ │ tariffs) │
└─────────────┘ │ └───────────────┘

┌──────▼──────┐
│ Tariff │
│ Service │
│ (API) │
└─────────────┘

1. Webhook Receiver (приём вебхуков)

package webhook

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/yourorg/payments/internal/kafka"
"github.com/yourorg/payments/internal/models"
)

type YandexKassaWebhook struct {
Event string `json:"event"`
Object struct {
ID string `json:"id"`
Status string `json:"status"`
Amount struct {
Value string `json:"value"`
Currency string `json:"currency"`
} `json:"amount"`
Metadata struct {
UserID int64 `json:"user_id"`
} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
} `json:"object"`
}

type WebhookHandler struct {
kafkaProducer *kafka.Producer
secretKey []byte
}

func (h *WebhookHandler) HandleYandexKassa(w http.ResponseWriter, r *http.Request) {
// 1. Читаем тело
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
defer r.Body.Close()

// 2. Верифицируем подпись
if !h.verifySignature(body, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Signature")) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// 3. Парсим вебхук
var webhook YandexKassaWebhook
if err := json.Unmarshal(body, &webhook); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

// 4. Создаём событие
event := models.PaymentEvent{
ID: generateEventID(),
Provider: "yandex_kassa",
Type: webhook.Event,
Payload: body,
CreatedAt: time.Now().UTC(),
}

// 5. Отправляем в Kafka (async, не блокируем ответ провайдеру)
if err := h.kafkaProducer.Publish(r.Context(), "payment-events", event.ID, event); err != nil {
// Логируем, но отвечаем 200 — провайдер повторит вебхук
log.Printf("failed to publish event: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}

// 6. Отвечаем 200 быстро
w.WriteHeader(http.StatusOK)
}

func (h *WebhookHandler) verifySignature(body []byte, forwardedFor, signature string) bool {
mac := hmac.New(sha256.New, h.secretKey)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}

2. Payment Processor (обработка событий)

package processor

import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"

"github.com/yourorg/payments/internal/kafka"
"github.com/yourorg/payments/internal/models"
"github.com/yourorg/payments/internal/repository"
)

type PaymentProcessor struct {
kafkaConsumer *kafka.Consumer
paymentRepo *repository.PaymentRepo
tariffRepo *repository.TariffRepo
userRepo *repository.UserRepo
db *sql.DB
}

func (p *PaymentProcessor) ProcessEvents(ctx context.Context) error {
return p.kafkaConsumer.Consume(ctx, "payment-events", func(msg kafka.Message) error {
var event models.PaymentEvent
if err := json.Unmarshal(msg.Value, &event); err != nil {
return fmt.Errorf("unmarshal event: %w", err)
}

// Идемпотентность: проверяем, обрабатывали ли уже
processed, err := p.paymentRepo.IsEventProcessed(ctx, event.ID)
if err != nil {
return fmt.Errorf("check event processed: %w", err)
}
if processed {
return nil // уже обработано, пропускаем
}

// Обрабатываем в зависимости от типа
switch event.Provider {
case "yandex_kassa":
return p.processYandexKassa(ctx, event)
case "app_store":
return p.processAppStore(ctx, event)
case "google_play":
return p.processGooglePlay(ctx, event)
default:
return fmt.Errorf("unknown provider: %s", event.Provider)
}
})
}

func (p *PaymentProcessor) processYandexKassa(ctx context.Context, event models.PaymentEvent) error {
var webhook YandexKassaWebhook
if err := json.Unmarshal(event.Payload, &webhook); err != nil {
return err
}

return p.db.RunInTransaction(ctx, func(tx *sql.Tx) error {
// Сохраняем платёж
payment := &models.Payment{
ID: webhook.Object.ID,
UserID: webhook.Object.Metadata.UserID,
Provider: "yandex_kassa",
Amount: webhook.Object.Amount.Value,
Currency: webhook.Object.Amount.Currency,
Status: webhook.Object.Status,
EventID: event.ID,
CreatedAt: webhook.Object.CreatedAt,
}

if err := p.paymentRepo.CreateTx(ctx, tx, payment); err != nil {
return fmt.Errorf("create payment: %w", err)
}

// Если платёж успешный — начисляем валюту
if webhook.Object.Status == "succeeded" {
tariff, err := p.tariffRepo.GetByPrice(ctx, tx, webhook.Object.Amount.Value)
if err != nil {
return fmt.Errorf("get tariff: %w", err)
}

if err := p.userRepo.AddCurrency(ctx, tx, payment.UserID, tariff.CurrencyAmount); err != nil {
return fmt.Errorf("add currency: %w", err)
}

// Обновляем подписку
subscription := &models.Subscription{
UserID: payment.UserID,
TariffID: tariff.ID,
StartedAt: time.Now().UTC(),
EndsAt: time.Now().UTC().Add(tariff.Duration),
Status: "active",
}

if err := p.tariffRepo.UpsertSubscription(ctx, tx, subscription); err != nil {
return fmt.Errorf("upsert subscription: %w", err)
}
}

// Помечаем событие как обработанное
return p.paymentRepo.MarkEventProcessed(ctx, tx, event.ID)
})
}

3. Идемпотентность и дедупликация

package repository

// Таблица для отслеживания обработанных событий
const createEventsTable = `
CREATE TABLE IF NOT EXISTS processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_processed_events_time ON processed_events(processed_at);
`

func (r *PaymentRepo) IsEventProcessed(ctx context.Context, eventID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM processed_events WHERE event_id = $1)",
eventID,
).Scan(&exists)
return exists, err
}

func (r *PaymentRepo) MarkEventProcessed(ctx context.Context, tx *sql.Tx, eventID string) error {
_, err := tx.ExecContext(ctx,
"INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING",
eventID,
)
return err
}

4. Tariff Service (API для получения тарифа)

package tariff

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"

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

type TariffService struct {
tariffRepo *repository.TariffRepo
redis *redis.Client
cacheTTL time.Duration
}

type CurrentTariffResponse struct {
TariffID int64 `json:"tariff_id"`
Name string `json:"name"`
EndsAt time.Time `json:"ends_at"`
Status string `json:"status"`
Currency int `json:"currency_balance"`
}

func (s *TariffService) GetCurrentTariff(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := getUserID(r) // из контекста/токена

// 1. Пробуем из кэша
cacheKey := fmt.Sprintf("tariff:%d", userID)
cached, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
var response CurrentTariffResponse
if json.Unmarshal([]byte(cached), &response) == nil {
respondJSON(w, http.StatusOK, response)
return
}
}

// 2. Из базы
subscription, err := s.tariffRepo.GetActiveSubscription(ctx, userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal error")
return
}

// 3. Получаем баланс валюты
balance, err := s.tariffRepo.GetCurrencyBalance(ctx, userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal error")
return
}

response := CurrentTariffResponse{
TariffID: subscription.TariffID,
Name: subscription.TariffName,
EndsAt: subscription.EndsAt,
Status: subscription.Status,
Currency: balance,
}

// 4. Кэшируем
data, _ := json.Marshal(response)
s.redis.Set(ctx, cacheKey, data, s.cacheTTL)

respondJSON(w, http.StatusOK, response)
}

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

-- Платежи
CREATE TABLE payments (
id VARCHAR(255) PRIMARY KEY,
user_id BIGINT NOT NULL,
provider VARCHAR(50) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
event_id VARCHAR(255) UNIQUE,
created_at TIMESTAMPTZ NOT NULL,

INDEX idx_payments_user_id (user_id),
INDEX idx_payments_created_at (created_at)
);

-- Тарифные планы
CREATE TABLE tariffs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
currency_amount INTEGER NOT NULL, -- количество внутренней валюты
duration_days INTEGER NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Подписки
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
tariff_id BIGINT NOT NULL REFERENCES tariffs(id),
started_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL, -- active, expired, cancelled
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),

INDEX idx_subscriptions_user_id (user_id),
INDEX idx_subscriptions_ends_at (ends_at),
INDEX idx_subscriptions_status (status)
);

-- Баланс валюты
CREATE TABLE currency_balance (
user_id BIGINT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Транзакции валюты (для аудита)
CREATE TABLE currency_transactions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount INTEGER NOT NULL, -- положительное = начисление, отрицательное = списание
type VARCHAR(20) NOT NULL, -- payment, subscription, manual
reference_id VARCHAR(255), -- ID платежа или подписки
created_at TIMESTAMPTZ DEFAULT NOW(),

INDEX idx_currency_tx_user_id (user_id),
INDEX idx_currency_tx_created_at (created_at)
);

-- Обработанные события (идемпотентность)
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

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

package metrics

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

var (
paymentsProcessed = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "payments_processed_total",
Help: "Total number of processed payments",
}, []string{"provider", "status"})

paymentProcessingDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "payment_processing_duration_seconds",
Help: "Payment processing duration",
Buckets: prometheus.DefBuckets,
}, []string{"provider"})

webhookReceived = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "webhook_received_total",
Help: "Total number of received webhooks",
}, []string{"provider"})

activeSubscriptions = promauto.NewGauge(prometheus.GaugeOpts{
Name: "active_subscriptions_total",
Help: "Number of active subscriptions",
})

failedEvents = promauto.NewCounter(prometheus.CounterOpts{
Name: "failed_events_total",
Help: "Total number of failed event processing",
})
)

func RecordPaymentProcessed(provider, status string) {
paymentsProcessed.WithLabelValues(provider, status).Inc()
}

func RecordWebhookReceived(provider string) {
webhookReceived.WithLabelValues(provider).Inc()
}

func RecordProcessingDuration(provider string, duration float64) {
paymentProcessingDuration.WithLabelValues(provider).Observe(duration)
}

7. Retry-механизм для внешних API

package retry

import (
"context"
"fmt"
"math"
"time"
)

func WithBackoff(ctx context.Context, maxRetries int, fn func() error) error {
var lastErr error

for attempt := 0; attempt <= maxRetries; attempt++ {
if err := fn(); err != nil {
lastErr = err

// Экспоненциальный бэкофф с jitter
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
jitter := time.Duration(rand.Int63n(int64(backoff) / 2))
sleepTime := backoff + jitter

select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(sleepTime):
continue
}
}
return nil
}

return fmt.Errorf("max retries exceeded: %w", lastErr)
}

// Использование
func (s *SubscriptionChecker) VerifyWithAppStore(ctx context.Context, receipt string) error {
return WithBackoff(ctx, 5, func() error {
resp, err := s.appStoreClient.VerifyReceipt(ctx, receipt)
if err != nil {
return fmt.Errorf("verify receipt: %w", err)
}
if !resp.IsValid {
return ErrInvalidReceipt
}
return nil
})
}

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

  • Идемпотентность: обработанные события сохраняются в БД, дубликаты отбрасываются
  • Event-driven: Kafka как буфер между вебхуками и обработкой
  • Кэширование: Redis для быстрого ответа о текущем тарифе
  • Мониторинг: Prometheus метрики, алерты на ошибки
  • Graceful degradation: при недоступности внешних API — retry с бэкоффом
  • Аудит: все транзакции валюты логируются
  • Масштабирование: 100 платежей/день — небольшая нагрузка, но архитектура готова к росту