Открытое интервью на Go-разработчика
Сегодня мы разберем собеседование на позицию Middle-разработчика на Go, в ходе которого кандидат Денис продемонстрировал хорошее владение теоретической базой — паттерны проектирования (сага, идемпотентность), устройство планировщика Go, работа со слайсами и базами данных, — однако испытал затруднения в вопросах системного дизайна и практического применения интеграционных тестов. Интервьюер Даня из Ozon задал ряд комплексных вопросов, выходящих за рамки стандартного мидл-уровня, что позволило выявить зоны роста кандидата и дать ему конкретные рекомендации по дальнейшему развитию.
Вопрос 1. Вступительная часть интервью — кандидат Денис ещё не отвечал на технические вопросы.
Таймкод: 00:00:00
Ответ собеседника: нет данных. Кандидат Денис ещё не отвечал на вопросы в данном фрагменте
Правильный ответ:
В данном фрагменте представлена только вступительная часть интервью. Это типичная ситуация — в начале записи обычно идёт приветствие, представление кандидата, описание формата интервью и, возможно, рекламный контент. Технические вопросы по Golang-разработке ещё не задавались, поэтому оценивать ответы кандидата пока не на чем. Следующие фрагменты будут содержать первые технические вопросы и ответы кандидата Дениса.
Вопрос 2. Как бы ты провёл важную фичу от начала до выката на прод?
Таймкод: 00:11:03
Ответ собеседника: неполный. Выбрал бы TDD: начал с написания теста, затем разрабатывал фичу, параллельно усложняя тест и покрывая новые кейсы. Но это при условии, что задача полностью понятна и не требует уточняющих вопросов.
Правильный ответ:
Ответ кандидата затронул только этап написания кода (TDD), но полностью проигнорировал остальные критически важные стадии процесса. Вот полный цикл проведения фичи от начала до выката:
1. Анализ требований и уточнение
Прежде чем писать хоть строчку кода, необходимо убедиться, что задача полностью понята. Задавать уточняющие вопросы к продукту: какие edge-cases учесть, какие ограничения есть, какие метрики успеха. Даже если задача «полностью понятна», стоит зафиксировать требования письменно — в тикете, дизайн-доке или ADR (Architecture Decision Record).
2. Проектирование решения (Design)
Продумать архитектуру: какие сервисы затронет изменение, нужны ли новые таблицы в БД, меняется ли API-контракт, есть ли влияние на другие команды. Для важных фич стоит подготовить дизайн-документ и получить ревью от коллег. Это дешевле, чем переделывать после выката.
3. Декомпозиция и оценка
Разбить задачу на подзадачи, оценить каждую. Если задача большая — выделить этапы, которые можно выкатывать инкрементально (feature flags, canary release).
4. Разработка
Здесь уместен TDD, как упомянул кандидат. Но важно также:
- Писать чистый, читаемый код с понятными именами
- Следовать принципам SOLID и кодстайлу проекта
- Покрывать не только unit-тестами, но и интеграционными тестами
- Обрабатывать ошибки корректно, не игнорировать их
- Добавить логирование и метрики для ключевых операций
Пример структуры теста в Go:
func TestCreateOrder_Success(t *testing.T) {
svc := NewOrderService(mockRepo, mockPublisher)
order, err := svc.CreateOrder(context.Background(), validRequest)
require.NoError(t, err)
assert.NotEmpty(t, order.ID)
assert.Equal(t, StatusPending, order.Status)
}
func TestCreateOrder_InvalidInput(t *testing.T) {
svc := NewOrderService(mockRepo, mockPublisher)
_, err := svc.CreateOrder(context.Background(), invalidRequest)
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidInput)
}
5. Code Review
Обязательное ревью минимум от одного разработчика, желательно от того, кто хорошо знает затронутую область. Не мержить без апрува.
6. Тестирование
- Автоматизированные тесты (unit, integration, e2e)
- Ручное тестирование QA-инженером
- Нагрузочное тестирование, если фича влияет на производительность
- Тестирование откатов (rollback)
7. Выкат на прод
- Использовать feature flags для постепенного включения
- Canary deployment — сначала на небольшой процент трафика
- Мониторинг метрик, алертов, логов сразу после выката
- Иметь готовый план отката (rollback plan)
8. Пост-релизный мониторинг
Наблюдать за ключевыми метриками в течение 24–48 часов. Убедиться, что фича работает как ожидается и не вносит регрессий.
Итого: TDD — это лишь один из этапов в длинной цепочке. Для важной фичи пропуск любого из этих шагов может привести к серьёзным проблемам на проде.
Вопрос 3. Фича выкатилась на прод, обнаружился баг — как будешь его фиксить?
Таймкод: 00:12:53
Ответ собеседника: неполный. Первым делом проверю, можно ли откатить фичу. Затем полезу в логи, метрики и буду собирать информацию об ошибке.
Правильный ответ:
Кандидат верно упомянул откат и анализ логов, но его ответ слишком поверхностный и не отражает полный процесс реагирования на инцидент. Вот как должен выглядеть полный алгоритм:
1. Остановить кровотечение (Stop the Bleeding)
Первая приоритетная задача — минимизировать влияние на пользователей. Если фича завёрнута в feature flag — выключить её. Если нет, обсудить с командой экстренный откат (rollback) на предыдущую версию. Каждая минута простоя или некорректной работы — это потерянные деньги и доверие пользователей.
2. Собрать контекст (Triage)
Параллельно с откатом (или если откат невозможен) — собрать максимум информации:
- Логи: искать ошибки, паники, необычные паттерны в логах за время релиза. В Go-сервисах это обычно структурированные логи через slog/zap/logrus
- Метрики: посмотреть на графики — выросла ли ошибка rate, упала ли latency, изменился ли throughput
- Трейсы: если есть распределённая трассировка (Jaeger, Tempo), найти проблемные спаны
- Алерты: проверить, какие алерты сработали и когда
- Пользовательский фидбек: если баг сообщили через поддержку — собрать детали
3. Воспроизвести проблему
Попытаться воспроизвести баг на staging или локально. Без воспроизведения фикс — это гадание. Зафиксировать шаги воспроизведения.
4. Найти корневую причину (Root Cause Analysis)
Не просто залатать симптом, а понять, почему баг возник. Возможные причины:
- Неучтённый edge-case в бизнес-логике
- Проблема с миграцией базы данных
- Некорректная обработка ошибок
- Race condition в конкурентном коде
- Несовместимость версий API между сервисами
Пример — типичная проблема с race condition в Go:
// Плохо: data race
var cache = make(map[string]string)
func Get(key string) string {
return cache[key] // конкурентное чтение без защиты
}
func Set(key, value string) {
cache[key] = value // конкурентная запись без защиты
}
// Правильно: с защитой через sync.RWMutex
type SafeCache struct {
mu sync.RWMutex
items map[string]string
}
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
func (c *SafeCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
5. Написать фикс с тестом
Сначала написать тест, который воспроизводит баг (он должен падать), затем написать фикс (тест должен зелёным). Это предотвращает регрессию в будущем.
6. Пройти ускоренный, но полный пайплайн
Даже для хотфикса нужны: code review (можно экстренный), прогон тестов, проверка на staging. Не стоит фиксить баг, внося новый баг.
7. Выкатить фикс и мониторинг
Выкатить исправление, внимательно следить за метриками. Убедиться, что баг действительно исчез и не появились новые проблемы.
8. Провести пост-мортем
После устранения инцидента — провести blameless post-mortem: что произошло, почему, как предотвратить в будущем. Зафиксировать выводы и действия (например, добавить новый тест, улучшить мониторинг, добавить проверку в CI).
Ключевой принцип: сначала стабилизировать, потом разбираться. Откат — это не признак слабости, а признак зрелого инженерного процесса.
Вопрос 4. Как будешь вставлять код в сложный legacy-микросервис, который плохо понимаешь?
Таймкод: 00:13:53
Ответ собеседника: неполный. Пойду на созвон с людьми, которые работали с этим микросервисом. Если возможно — вставлю фичу сбоку, не трогая legacy. Изучу проект сам, подготовлю вопросы. Буду использовать тестовый стенд, дебаг, логи, спрошу у тестировщиков и других членов команды, почитаю чаты и документацию.
Правильный ответ:
Кандидат дал хороший общий ответ, затронув коммуникацию и осторожный подход, но ответ не структурирован и не содержит конкретных технических практик. Вот полный и систематизированный подход:
1. Коммуникация и сбор знаний
- Найти «хранителя знаний» (knowledge owner) — человека, который лучше всего знает сервис. Это может быть автор ключевых частей или тот, кто давно с ним работает
- Попросить провести короткий walkthrough архитектуры: основные компоненты, потоки данных, точки входа
- Изучить существующую документацию, ADR, README, диаграммы
- Прочистить чаты (Slack, Teams) по ключевым словам сервиса — там часто обсуждались важные решения и известные проблемы
- Посмотреть историю git-коммитов и MR — понять, как сервис эволюционировал
2. Изучение кодовой базы
- Запустить сервис локально, настроить окружение
- Пойти от точки входа (handler, consumer) вглубь — проследить поток выполнения
- Изучить структуру пакетов и зависимости между ними
- Посмотреть на существующие тесты — они часто являются лучшей документацией
- Использовать IDE для навигации: «Find Usages», «Go to Definition», call hierarchy
3. Стратегия встраивания — принцип минимального воздействия
Кандидат верно упомянул «вставить фичу сбоку». Это ключевой принцип:
- Strangler Fig Pattern: если возможно, добавить новый код как отдельный модуль/пакет, а не изменять существующий legacy-код напрямую
- Использовать feature flag для изоляции новой логики
- Внедрять через dependency injection, чтобы можно было легко подменить реализацию
- Если нужно изменить существующий код — сначала написать тесты на текущее поведение (characterization tests), потом вносить изменения
Пример подхода с feature flag в Go:
type FeatureFlags struct {
NewPaymentFlow bool
}
func (s *Service) ProcessPayment(ctx context.Context, req PaymentRequest) error {
if s.flags.NewPaymentFlow {
return s.newPaymentProcessor.Process(ctx, req)
}
return s.legacyPaymentProcessor.Process(ctx, req)
}
4. Тестирование в legacy-коде
- Характеризующие тесты (Characterization Tests): перед изменением legacy-кода написать тесты, которые фиксируют текущее поведение, даже если оно кажется некорректным. Это страховочная сетка
- Интеграционные тесты: покрыть сценарий взаимодействия нового кода с существующей системой
- Контрактные тесты: если сервис общается с другими — убедиться, что контракты не нарушены
- Тестовый стенд: развёрнуть изолированное окружение, максимально приближённое к продакшену
- Сравнение поведения: при наличии feature flag — сравнить результаты старого и нового пути на одних и тех же данных
5. Выкат с минимальным риском
- Feature flag с постепенным включением: 0% → 1% → 10% → 50% → 100%
- Canary deployment: если инфраструктура позволяет, выкатить на часть инстансов
- Dark launch: прогнать реальный трафик через новую логику, но не отдавать результат пользователям — сравнить с основным путём
- Мониторинг: настроить отдельные метрики для нового кода (error rate, latency, business-метрики)
- Rollback plan: чёткий план отката, который можно выполнить за минуты
6. Документирование
После успешного выката — обновить документацию, добавить комментарии в код, описать принятые решения. Следующий разработчик, который придёт в этот сервис, скажет вам спасибо.
Главный принцип: не торопиться. В legacy-коде спешка — главный источник багов. Лучше потратить больше времени на понимание, чем потом чинить последствия.
Вопрос 5. Расскажи про пирамиду тестирования и виды тестов — какие использовал, от чего защищают?
Таймкод: 00:20:44
Ответ собеседника: неполный. Упомянул TDD, но в продакшене таким не пользовался. Рассказал о пирамиде: юнит-тесты (изолированное тестирование функций с мокированием), интеграционные тесты (тестирование нескольких модулей или HTTP-ручки, проверка идемпотентности), E2E-тесты (упомянул без подробностей).
Правильный ответ:
Кандидат верно обозначил три уровня пирамиды, но описание поверхностное, а E2E вообще не раскрыт. Вот полный и структурированный ответ:
Пирамида тестирования — это модель, описывающая соотношение разных типов тестов по количеству, скорости и стоимости. Основание пирамиды — самые быстрые и дешёвые тесты, вершина — самые медленные и дорогие.
1. Unit-тесты (основание пирамиды, ~70%)
Тестируют отдельные функции, методы, структуры в изоляции. Все внешние зависимости заменяются моками/стабами.
- Что защищают: от регрессий в бизнес-логике, от ошибок при рефакторинге
- Скорость: миллисекунды на тест
- Когда писать: всегда, при любом изменении
Пример unit-теста в Go с мокированием:
//go:generate mockgen -source=repository.go -destination=mock_repository.go -package=service
type OrderRepository interface {
GetByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, order *Order) error
}
func TestOrderService_CalculateTotal(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := NewMockOrderRepository(ctrl)
mockRepo.EXPECT().
GetByID(gomock.Any(), "order-123").
Return(&Order{
Items: []Item{
{Price: 100, Quantity: 2},
{Price: 50, Quantity: 1},
},
}, nil)
svc := NewOrderService(mockRepo)
total, err := svc.CalculateTotal(context.Background(), "order-123")
require.NoError(t, err)
assert.Equal(t, int64(250), total)
}
Для мокирования в Go популярны: gomock, testify/mock, mockery для генерации.
2. Интеграционные тесты (~20%)
Тестируют взаимодействие нескольких компонентов: сервис + база данных, сервис + очередь сообщений, HTTP-хендлер + сервисный слой.
- Что защищают: от ошибок интеграции между компонентами, от проблем с контрактами, от ошибок в конфигурации
- Скорость: секунды на тест
- Особенности: требуют реальные или близкие к реальным зависимости
Пример интеграционного теста с тестовой базой данных в Go:
func TestOrderRepository_SaveAndRetrieve(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
db := setupTestDB(t) // через testcontainers или отдельную тестовую DB
defer db.Close()
repo := NewOrderRepository(db)
order := &Order{
ID: "test-order-1",
Status: StatusPending,
Items: []Item{{Price: 100, Quantity: 1}},
}
err := repo.Save(context.Background(), order)
require.NoError(t, err)
retrieved, err := repo.GetByID(context.Background(), order.ID)
require.NoError(t, err)
assert.Equal(t, order.Status, retrieved.Status)
assert.Len(t, retrieved.Items, 1)
}
Для интеграционных тестов в Go часто используют testcontainers-go — библиотеку для запуска реальных зависимостей (PostgreSQL, Redis, Kafka) в Docker-контейнерах:
func setupPostgres(t *testing.T) *sql.DB {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_DB": "testdb",
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
},
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
// ... получить порт, подключиться
}
3. E2E-тесты (End-to-End, ~10%, вершина пирамиды)
Тестируют полный пользовательский сценарий через все слои системы: UI → API → сервисы → БД → внешние интеграции.
- Что защищают: от ошибок в сквозном пользовательском потоке, от проблем с деплоем и конфигурацией
- Скорость: секунды-минуты на тест
- Стоимость: дорогие в написании и поддержке, хрупкие
- Инструменты: Cypress, Playwright (для веба), кастомные скрипты на Go для API-уровневых E2E
Пример E2E-теста на Go для API:
func TestE2E_CreateOrder(t *testing.T) {
baseURL := os.Getenv("API_BASE_URL")
// Создаём заказ
createResp, err := http.Post(
baseURL+"/api/orders",
"application/json",
strings.NewReader(`{"items":[{"product_id":"p1","quantity":2}]}`),
)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
var order Order
json.NewDecoder(createResp.Body).Decode(&order)
// Проверяем, что заказ доступен через GET
getResp, err := http.Get(baseURL + "/api/orders/" + order.ID)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, getResp.StatusCode)
}
4. Дополнительные виды тестов (за пределами классической пирамиды)
- Контрактные тесты (Contract Tests): проверяют, что два сервиса соблюдают общий контракт. Инструменты: Pact. Критически важны в микросервисной архитектуре
- Нагрузочные тесты (Load/Performance Tests): проверяют поведение системы под нагрузкой. Инструменты: k6, vegeta (Go), Locust
- Property-based тесты: генерируют случайные входные данные и проверяют инварианты. В Go:
testing/quick,gopter - Мутационные тесты (Mutation Testing): проверяют качество тестов, внося искусственные ошибки в код и смотрят, падают ли тесты
Зачем тесты нужны:
- Регрессионная защита: изменение в одном месте не сломало другое
- Документация: тесты показывают, как код должен использоваться
- Уверенность при рефакторинге: можно менять архитектуру, не боясь сломать поведение
- Быстрая обратная связь: ошибка обнаруживается за секунды, а не через неделю на проде
- Снижение стоимости багов: баг, найденный тестом в CI, стоит в разы дешевле, чем баг, найденный пользователем
TDD (Test-Driven Development):
Кандидат упомянул, что не использовал TDD в продакшене. Это честно, но стоит понимать суть подхода:
- Написать падающий тест (Red)
- Написать минимальный код, чтобы тест прошёл (Green)
- Рефакторинг с уверенностью, что тесты зелёные (Refactor)
TDD — не панацея, но он хорошо работает для алгоритмической логики и помогает проектировать API «снаружи внутрь» — сначала думаешь о том, как код будет использоваться, а потом о реализации.
Вопрос 6. Инструменты для интеграционных тестов и проблемы безопасности с test containers
Таймкод: 00:23:08
Ответ собеседника: неполный. На Go интеграционные тесты не писал, на PHP тестировал внедрение платёжного модуля. Использовали отдельный контейнер с базой данных для тестов. Локально поднимали по одному контейнеру на инфраструктуру, без полной изоляции каждого теста. Не знаком с test containers. Проблему безопасности Docker-in-Docker не назвал — интервьюер подсказал, что Docker-демон внутри контейнера имеет доступ к хост-машине.
Правильный ответ:
Кандидат честно признал пробел в знаниях. Вот полный и структурированный ответ на этот вопрос:
Инструменты для интеграционных тестов в Go
testcontainers-go — основной инструмент для запуска реальных зависимостей в Docker-контейнерах прямо из тестового кода. Поддерживает PostgreSQL, MySQL, Redis, Kafka, MongoDB, Elasticsearch и десятки других сервисов.
func TestWithPostgres(t *testing.T) {
ctx := context.Background()
postgresContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_DB": "testdb",
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
},
Started: true,
})
require.NoError(t, err)
defer postgresContainer.Terminate(ctx)
host, _ := postgresContainer.Host(ctx)
port, _ := postgresContainer.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("host=%s port=%s user=test password=test dbname=testdb sslmode=disable", host, port.Port())
db, err := sql.Open("pgx", dsn)
require.NoError(t, err)
defer db.Close()
// Запускаем миграции и выполняем тесты
runMigrations(db)
repo := NewOrderRepository(db)
// ... тестируем
}
Другие инструменты:
- dockertest — более простая альтернатива testcontainers-go
- go-sqlite3 или sqlmock — для эмуляции БД без Docker (быстрее, но менее реалистично)
- httptest из стандартной библиотеки — для тестирования HTTP-хендлеров без реального сервера
- minio/minio в контейнере — для тестирования работы с S3-совместимым хранилищем
Проблемы безопасости с Docker в CI/CD
1. Docker-in-Docker (DinD) — главная проблема безопасности
Когда вы запускаете Docker внутри Docker-контейнера (что делают test containers), контейнер получает доступ к Docker-сокету хоста (/var/run/docker.sock). Это означает:
- Контейнер может создавать новые контейнеры на хост-машине
- Через создание привилегированного контейнера можно получить доступ к файловой системе хоста
- Фактически это полный компрометация хост-системы
# Вот как монтируется Docker-сокет — это потенциальная уязвимость
docker run -v /var/run/docker.sock:/var/run/docker.sock my-test-image
2. Решения и митигации:
- Rootless Docker: запуск Docker-демона без root-привилегий снижает поверхность атаки
- Podman вместо Docker: Podman поддерживает rootless контейнеры из коробки и не требует демона
- Kubernetes pods вместо Docker: в CI/CD на базе Kubernetes можно использовать Kubernetes Jobs или Ephemeral Containers для изоляции тестов
- Kaniko/Buildah: для сборки образов без Docker-демона
- Ограничение ресурсов: всегда выставлять лимиты CPU, memory, PID на контейнеры с тестами
- Network isolation: использовать отдельные Docker-сети для тестов, чтобы контейнеры не могли достучаться до продакшен-сервисов
- Read-only файловая система: запускать контейнеры с флагом
--read-only, где возможно
3. Дополнительные проблемы:
- Утечка ресурсов: если тест упал и не выполнил
Terminate()— контейнер продолжает работать, потребляя ресурсы. Решение: всегда использоватьdefer container.Terminate(ctx)илиt.Cleanup() - Состояние между тестами: контейнер с БД должен быть чистым для каждого теста. Использовать транзакции и rollback, или пересоздавать схему
- Время запуска: поднятие контейнеров медленное. Решение: переиспользовать контейнеры между тестами в рамках одной сессии, использовать
sync.Onceили фикстуры
Пример безопасного переиспользования контейнера:
var (
sharedDB *sql.DB
dbOnce sync.Once
)
func getSharedTestDB(t *testing.T) *sql.DB {
dbOnce.Do(func() {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, setupPostgresRequest())
require.NoError(t, err)
// Важно: не завершаем контейнер после каждого теста
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
sharedDB, err = sql.Open("pgx", buildDSN(host, port))
require.NoError(t, err)
runMigrations(sharedDB)
// Очистка при завершении всех тестов
t.Cleanup(func() {
sharedDB.Close()
container.Terminate(ctx)
})
})
return sharedDB
}
func TestSomething(t *testing.T) {
db := getSharedTestDB(t)
// Используем транзакцию для изоляции
tx, err := db.Begin()
require.NoError(t, err)
defer tx.Rollback() // Гарантирует чистое состояние для следующего теста
repo := NewOrderRepository(tx)
// ... тестируем
}
Итого: test containers — мощный инструмент для интеграционного тестирования, но требует осознанного подхода к безопасности, особенно в CI/CD. Главное правило — никогда не монтировать Docker-сокет в контейнеры без крайней необходимости и всегда иметь план по ограничению привилегий.
Вопрос 7. Был ли опыт написания E2E-тестов?
Таймкод: 00:28:38
Ответ собеседника: правильный. Опыта написания E2E-тестов не было.
Правильный ответ:
Кандидат честно признал отсутствие опыта — это допустимо, особенно если он компенсирует глубоким знанием других уровней тестирования. Для полноты картины стоит понимать, что представляют собой E2E-тесты:
E2E (End-to-End) тесты проверяют полный пользовательский сценарий от начала до конца, проходя через все слои системы.
Типичные инструменты:
- Playwright / Cypress / Selenium — для веб-приложений (тестирование через браузер)
- Кастомные скрипты на Go — для API-уровневых E2E-тестов
- Cucumber + Gherkin — для BDD-стиля E2E-тестов
Пример E2E-сценария для интернет-магазина:
- Пользователь открывает каталог → 2. Добавляет товар в корзину → 3. Оформляет заказ → 4. Производит оплату → 5. Получает подтверждение → 6. Видит заказ в истории
Почему E2E-тесты важны:
- Покрывают сценарии, которые невозможно проверить unit или интеграционными тестами
- Проверяют корректность конфигурации деплоя, сети, DNS, SSL
- Доверие к системе: если E2E прошли — система в целом работает
Почему их не должно быть много:
- Медленные (секунды-минуты на тест)
- Хрупкие (ломаются при любом изменении UI или API)
- Дорогие в поддержке
- Трудно диагностировать причину падения
Если кандидату предстоит работать с E2E, хорошим стартом будет написание простых API-level E2E-тестов на Go с использованием стандартного пакета net/http и testing.
Вопрос 8. Какие типы баз данных знаешь, чем отличаются, с какими работал?
Таймкод: 00:29:03
Ответ собеседника: неполный. Назвал реляционные базы (PostgreSQL) — данные в виде таблиц, связанных внешними ключами. Колоночные базы — перевёрнутая реляционная база, удобна для аналитики. Из колоночных знает ClickHouse и Cassandra, но в проде с ними не работал. Упомянул объектные хранилища (S3). Не назвал документные (MongoDB), ключ-значение (Redis), графовые базы данных.
Правильный ответ:
Кандидат затронул реляционные и колоночные базы, но классификация неполная. Вот систематизированный обзор всех основных типов:
1. Реляционные БД (RDBMS)
Данные хранятся в таблицах со строгой схемой, связанных через внешние ключи. Поддерживают ACID-транзакции и SQL.
- Примеры: PostgreSQL, MySQL, MariaDB, Oracle, MS SQL Server
- Когда использовать: транзакционные системы, где важна целостность данных — заказы, платежи, пользователи
- С какими работал кандидат: PostgreSQL
Пример схемы в PostgreSQL:
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
2. Документные БД (Document Stores)
Данные хранятся в виде документов (обычно JSON/BSON) с гибкой схемой. Каждый документ может иметь свою структуру.
- Примеры: MongoDB, CouchDB, Amazon DocumentDB
- Когда использовать: каталоги товаров, профили пользователей, контент-менеджмент — там, где структура данных варьируется или часто меняется
- Плюсы: гибкая схема, удобная модель данных для разработчиков, горизонтальное масштабирование
- Минусы: слабая поддержка транзакций между документами (хотя MongoDB 4+ поддерживает multi-document ACID), нет JOIN'ов
3. Ключ-значение (Key-Value Stores)
Простейшая модель: каждому уникальному ключу соответствует значение. Оптимизированы для быстрого чтения/записи по ключу.
- Примеры: Redis, Memcached, Amazon DynamoDB, etcd, Consul
- Когда использовать: кэширование, сессии, rate limiting, очереди сообщений, распределённые блокировки
- Redis — самый популярный, поддерживает структуры данных: strings, lists, sets, sorted sets, hashes, streams
Пример использования Redis в Go:
// Кэширование с TTL
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
// Пробуем получить из кэша
cached, err := s.redis.Get(ctx, "user:"+id).Bytes()
if err == nil {
var user User
if json.Unmarshal(cached, &user) == nil {
return &user, nil
}
}
// Если нет в кэше — идём в БД
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Сохраняем в кэш на 5 минут
data, _ := json.Marshal(user)
s.redis.Set(ctx, "user:"+id, data, 5*time.Minute)
return user, nil
}
4. Колоночные (Columnar / Column-Family) БД
Данные хранятся по столбцам, а не по строкам. Оптимизированы для аналитических запросов, где нужно агрегировать большие объёмы данных по отдельным столбцам.
- Примеры: ClickHouse, Apache Cassandra, ScyllaDB, Amazon Redshift, Google BigQuery, Apache HBase
- Когда использовать: аналитика, логи, метрики, event sourcing, time-series данные
- Плюсы: высокая скорость аналитических запросов, эффективное сжатие, горизонтальное масштабирование
- Минусы: не подходят для OLTP (частые точечные записи/обновления), нет полноценных JOIN'ов и транзакций
ClickHouse — особенно хорош для аналитики:
-- Агрегация миллионов строк за секунды
SELECT
toStartOfHour(created_at) AS hour,
status,
count() AS order_count,
sum(total) AS revenue
FROM orders
WHERE created_at >= now() - INTERVAL 7 DAY
GROUP BY hour, status
ORDER BY hour;
Cassandra — распределённая колоночная БД, оптимизирована для высокую доступность и запись:
-- Денормализованная модель под конкретный запрос
CREATE TABLE orders_by_user (
user_id UUID,
order_id UUID,
status TEXT,
total BIGINT,
created_at TIMESTAMP,
PRIMARY KEY (user_id, created_at)
) WITH CLUSTERING ORDER BY (created_at DESC);
5. Графовые БД (Graph Databases)
Данные хранятся в виде узлов и рёбер графа. Оптимизированы для запросов, связанных с отношениями.
- Примеры: Neo4j, Amazon Neptune, ArangoDB, Dgraph
- Когда использовать: социальные сети, рекомендательные системы, обнаружение мошенничества, маршрутизация
- Пример запроса (Cypher): «Найди друзей друзей пользователя X, которые купили товар Y»
6. Объектные хранилища (Object Storage)
Кандидат верно упомянул S3. Это не база данных в классическом смысле, но важный компонент архитектуры.
- Примеры: Amazon S3, MinIO, Google Cloud Storage, Azure Blob Storage
- Когда использовать: файлы, изображения, бэкапы, Data Lake, статические ассеты
7. Поисковые движки (Search Engines)
- Примеры: Elasticsearch, OpenSearch, Meilisearch, Typesense
- Когда использовать: полнотекстовый поиск, логирование (ELK-стек), поиск по каталогу
8. Time-Series БД
- Примеры: InfluxDB, TimescaleDB (расширение PostgreSQL), Prometheus
- Когда использовать: метрики, мониторинг, IoT-данные, финансовые данные
Как выбрать тип БД:
- OLTP (транзакции): PostgreSQL, MySQL
- Кэш/сессии: Redis
- Аналитика: ClickHouse, Redshift
- Логи/события: ClickHouse, Elasticsearch
- Гибкая схема: MongoDB
- Отношения/графы: Neo4j
- Метрики: InfluxDB, TimescaleDB
В реальных системах часто используется Polyglot Persistence — несколько типов БД одновременно, каждый для своей задачи. Например, PostgreSQL для заказов, Redis для кэша, ClickHouse для аналитики, S3 для файлов.
Вопрос 9. Плюсы и минусы индексов, типы индексов, устройство B-дерева
Таймкод: 00:32:51
Ответ собеседника: неполный. Индекс — структура данных для быстрого поиска. Плюс — ускорение чтения. Минус — расход памяти и замедление записи. Назвал уникальные, кластеризованные и некластеризованные индексы. По способу хранения упомянул B-дерево и хеш-индексы, но не смог назвать другие типы. Устройство B-дерева не знает.
Правильный ответ:
Кандидат верно назвал основные плюсы и минусы, но не раскрыл типы индексов и устройство B-дерева. Вот полный ответ:
Плюсы индексов:
- Ускорение SELECT-запросов: вместо полного сканирования таблицы (O(n)) — поиск за O(log n)
- Ускорение JOIN'ов: быстрый поиск по ключу соединения
- Ускорение ORDER BY и GROUP BY: если индекс покрывает нужные столбцы
- Обеспечение уникальности: UNIQUE-индекс гарантирует отсутствие дубликатов
- Ускорение MIN/MAX: экстремальные значения находятся мгновенно
Минусы индексов:
- Замедление INSERT/UPDATE/DELETE: при каждой записи нужно обновить все затронутые индексы
- Дополнительное место на диске: индекс может занимать сопоставимый с таблицей объём
- Дополнительное потребление памяти: СУБД кэширует страницы индексов в памяти
- Усложнение оптимизатора: слишком много индексов может сбить оптимизатор с толку — он может выбрать неоптимальный план
- Фрагментация: со временем индексы фрагментируются, требуется REINDEX/REBUILD
Типы индексов по способу хранения:
1. B-Tree (B+-Tree) — самый распространённый
B-дерево — это сбалансированное дерево поиска, оптимизированное для работы с диском. В PostgreSQL по умолчанию используется B+-Tree, где все данные хранятся в листьях, а внутренние узлы содержат только ключи для навигации.
Структура B+-дерева:
[30 | 70] ← корневой узел (только ключи-разделители)
/ | \
[10|20] [30|50] [70|90] ← внутренние узлы (только ключи)
/ | \ / | \ / | \
[10]->[20]->[30]->[40]->[50]->[70]->[80]->[90] ← листья (ключи + ссылки на данные)
↑_________________________________________↑ ← листья связаны в список (для range scan)
Свойства:
- Все листья на одной глубине — дерево всегда сбалансировано
- Каждый узел содержит от
t-1до2t-1ключей (t — минимальная степень) - Высота дерева: O(log_t N), где N — количество ключей
- Поиск: начинаем с корня, на каждом уровне выбираем нужную ветку
- Листы связаны в двусвязный список — это позволяет эффективно делать range-запросы (
WHERE age BETWEEN 20 AND 30)
Почему B-Tree хорош для дисков:
- Каждый узел занимает одну страницу диска (обычно 4KB или 8KB)
- Высота дерева мала: для 1 миллиарда записей при степени ~200 высота ≈ 4, то есть 4 чтения с диска
- Операции вставки и удаления сохраняют баланс через расщепление и слияние узлов
2. Hash-индекс
Хранит хеш-значения ключей. Идеален для точечного поиска по равенству (=), но не поддерживает range-запросы.
CREATE INDEX idx_users_email_hash ON users USING hash (email);
- Сложность поиска: O(1) в среднем
- Не подходит для:
>,<,BETWEEN,LIKE 'prefix%' - В PostgreSQL hash-индексы долгое время не были WAL-огируемыми, сейчас это исправлено
3. GiST (Generalized Search Tree)
Обобщённое дерево поиска. Подходит для геопространственных данных, полнотекстового поиска, диапазонных типов.
-- Геопространственный индекс
CREATE INDEX idx_locations_gist ON locations USING gist (coordinates);
-- Поиск ближайших точек
SELECT * FROM locations
ORDER BY coordinates <-> point '(37.6, 55.7)'
LIMIT 10;
4. GIN (Generalized Inverted Index)
Инвертированный индекс. Идеален для составных значений: массивы, JSONB, полнотекстовый поиск, tsvector.
-- Индекс по JSONB
CREATE INDEX idx_orders_data ON orders USING gin (data);
-- Поиск по ключу в JSONB
SELECT * FROM orders WHERE data @> '{"status": "pending"}';
-- Индекс по массиву
CREATE INDEX idx_products_tags ON products USING gin (tags);
-- Поиск товаров с тегом
SELECT * FROM products WHERE tags @> ARRAY['electronics'];
5. BRIN (Block Range Index)
Индекс по диапазонам блоков. Очень компактный, подходит для таблиц, где данные физически упорядочены по индексируемому столбцу (например, логи с timestamp).
CREATE INDEX idx_logs_created_brin ON logs USING brin (created_at)
WITH (pages_per_range = 32);
- Размер: в сотни раз меньше B-Tree
- Точность ниже — указывает на диапазон страниц, а не на конкретную строку
- Идеален для time-series данных, где INSERT всегда добавляет новые (большие) значения
6. Частичный индекс (Partial Index)
Индексирует только часть строк, удовлетворяющих условию.
-- Индексируем только активных пользователей
CREATE INDEX idx_active_users_email ON users (email)
WHERE active = true;
- Меньше размер, быстрее обновление
- Условие в запросе должно совпадать с условием индекса
7. Выраженный индекс (Expression Index)
Индекс по результату выражения.
CREATE INDEX idx_users_lower_email ON users (lower(email));
-- Этот запрос использует индекс
SELECT * FROM users WHERE lower(email) = 'user@example.com';
8. Покрывающий индекс (Covering Index)
Содержит все столбцы, необходимые для запроса — не нужно обращаться к самой таблице.
-- В PostgreSQL с INCLUDE:
CREATE INDEX idx_orders_user_status ON orders (user_id) INCLUDE (total, created_at);
-- Этот запрос полностью покрыт индексом
SELECT total, created_at FROM orders WHERE user_id = 'xxx';
Кластеризованный vs Некластеризованный:
- Кластеризованный индекс определяет физический порядок данных на диске. В таблице может быть только один. В PostgreSQL это делается через
CLUSTER table_name USING index_name— но это разовая операция, новые данные не упорядочиваются автоматически - Некластеризованный индекс — отдельная структура с указателями на строки таблицы. Их может быть много
Практические советы:
- Не создавать индексы «на всякий случай» — каждый индекс имеет стоимость
- Использовать
EXPLAIN ANALYZEдля проверки, используется ли индекс - Следить за кардинальностью столбца: индекс на столбец с 2 значениями (boolean) обычно бесполезен
- Составные индексы: порядок столбцов важен — сначала столбцы с равенством, потом с диапазоном
- Мониторить неиспользуемые индексы через
pg_stat_user_indexes
Вопрос 10. Минусы индексов и эффект bloat (распухание) в PostgreSQL
Таймкод: 00:35:39
Ответ собеседника: неполный. Назвал два минуса: расход памяти и замедление записи. Знал термин «распухание индексов», но не смог объяснить причину. Интервьюер объяснил, что в PostgreSQL из-за MVCC при вакууме удаляются версии строк, но указатели в индексах не удаляются и копятся, указывая на пустоту.
Правильный ответ:
Кандидат не самостоятельно объяснил механизм bloat. Вот полный и детальный ответ:
Минусы индексов (расширенный список):
- Замедление записи: каждый INSERT, UPDATE, DELETE должен обновить все затронутые индексы
- Дисковое пространство: индексы занимают место, иногда сопоставимое с размером таблицы
- Потребление памяти: СУБД кэширует страницы индексов в shared buffers
- Bloat (распухание): индекс со временем растёт, даже если данные удаляются
- Фрагментация: ухудшается последовательность чтения с диска
- Сложность оптимизатора: избыток индексов может привести к неоптимальным планам выполнения
- Lock contention: при параллельной записи индексы могут стать точкой конкуренции
Bloat (распухание) — подробный разбор:
Что такое bloat:
Bloat — это «мёртвое» пространство в таблицах и индексах, которое занимает место, но не содержит полезных данных. В PostgreSQL это напрямую связано с архитектурой MVCC (Multi-Version Concurrency Control).
Как работает MVCC в PostgreSQL:
PostgreSQL не обновляет строки на месте. Вместо этого:
- UPDATE = пометить старую строку как мёртвую (DELETE) + вставить новую строку (INSERT)
- DELETE = пометить строку как мёртвую, но не удалять физически
Таблица (heap):
┌─────────────────────────────────────────────┐
│ Срока 1: [dead] ← старая версия (UPDATE) │
│ Срока 2: [dead] ← удалена (DELETE) │
│ Срока 3: [live] ← текущая версия │
│ Срока 4: [dead] ← старая версия (UPDATE) │
│ Срока 5: [live] ← текущая версия │
└─────────────────────────────────────────────┘
Индекс (B-Tree):
┌─────────────────────────────────────────────┐
│ Указатель → Строку 1 (dead) ← мёртвый! │
│ Указатель → Строку 2 (dead) ← мёртвый! │
│ Указатель → Строку 3 (live) │
│ Указатель → Строку 4 (dead) ← мёртвый! │
│ Указатель → Строку 5 (live) │
└─────────────────────────────────────────────┘
Почему возникает bloat в индексах:
- Транзакция обновляет строку → создаётся новая версия строки, старая помечается как dead
- VACUUM проходит по таблице и помечает мёртвые версии строк как свободное место в heap
- Но VACUUM не удаляет указатели из индексов! Индексные записи, указывающие на мёртвые строки, остаются
- Эти «мёртвые» индексные записи накапливаются — это и есть index bloat
- Индекс продолжает расти в размере, хотя реальных данных в нём всё меньше
Последствия bloat:
- Индекс занимает больше места на диске, чем реально нужно
- Больше страниц индекса → больше чтений с диска при поиске
- Ухудшается производительность как чтения, так и записи
- Зря расходуется память (shared buffers)
Как бороться с bloat:
1. VACUUM — стандартная операция очистки:
-- Обычный VACUUM (не блокирует, но не освобождает место ОС)
VACUUM table_name;
-- VACUUM FULL — полностью переписывает таблицу и индексы (блокирует!)
VACUUM FULL table_name;
2. pg_repack — переупаковка без эксклюзивной блокировки:
pg_repack -d mydb -t orders --no-order
3. REINDEX — перестроение конкретного индекса:
REINDEX INDEX CONCURRENTLY idx_orders_user_id;
4. autovacuum — автоматическая настройка:
-- Агрессивные настройки для таблиц с частыми обновлениями
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- запускать при 1% мёртвых строк
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 2 -- меньше задержка = быстрее чистит
);
5. Мониторинг bloat:
-- Проверка размера индексов и оценка bloat
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS index_scans,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
6. Профилактика:
- Не делать лишних UPDATE'ов — обновлять только изменившиеся столбцы
- Использовать
HOT (Heap-Only Tuples)— если обновляемые столбцы не входят в индекс, PostgreSQL может обновить строку без создания нового индексного указателя - Настроить autovacuum под нагрузку
- Регулярно мониторить размеры индексов и таблиц
HOT-обновления — важная оптимизация:
-- Если в таблице есть индекс только по user_id, а мы обновляем total:
UPDATE orders SET total = 200 WHERE id = 'xxx';
-- Это HOT-update: новая версия строки размещается на той же странице,
-- и индекс НЕ создаёт новый указатель → меньше bloat
Итого: bloat — это неизбежное следствие MVCC в PostgreSQL, но с ним можно и нужно бороться через правильную настройку autovacuum, мониторинг и периодическое обслуживание.
Вопрос 11. Что такое транзакции и ACID? Как реализуется Durability?
Таймкод: 00:38:57
Ответ собеседника: неполный. Дал определение ACID: атомарность, согласованность, изоляция, надёжность. Не смог объяснить, как реализуется durability (Write-Ahead Log / WAL).
Правильный ответ:
Кандидат знает расшифровку аббревиатуры ACID, но не раскрыл каждое свойство и не смог объяснить механизм Durability. Вот полный ответ:
Транзакция — это логическая единица работы с базой данных, которая объединяет одну или несколько операций. Транзакция либо выполняется целиком (COMMIT), либо откатывается целиком (ROLLBACK).
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Если обе операции успешны
COMMIT;
-- Если что-то пошло не так
ROLLBACK;
ACID — четыре свойства транзакций:
A — Atomicity (Атомарность)
Транзакция выполняется как неделимая единица: либо все операции применяются, либо ни одна. Если в процессе транзакции произошла ошибка — все изменения откатываются к состоянию до начала транзакции.
Реализация: через журнал отмены (Undo Log / Rollback Segments). Перед изменением данных записывается «предобраз» — информация, необходимая для отката.
C — Consistency (Согласованность)
Транзакция переводит базу данных из одного согласованного состояния в другое. Все ограничения (constraints), триггеры, правила целостности должны быть соблюдены.
Пример: если есть CHECK (balance >= 0), транзакция, которая уводит баланс в минус, будет отклонена.
Реализация: через механизмы ограничений СУБMS: FOREIGN KEY, CHECK, UNIQUE, NOT NULL, триггеры.
I — Isolation (Изоляция)
Параллельные транзакции не должны влиять друг на друга. Результат параллельного выполнения должен быть эквивалентен некоторому последовательному выполнению.
Уровни изоляции (от слабого к строгому):
| Уровень | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Возможен | Возможен | Возможен |
| Read Committed | Невозможен | Возможен | Возможен |
| Repeatable Read | Невозможен | Невозможен | Возможен* |
| Serializable | Невозможен | Невозможен | Невозможен |
*В PostgreSQL Repeatable Read также защищает от phantom read через механизм SSI (Serializable Snapshot Isolation).
Аномалии:
- Dirty Read: транзакция читает данные, изменённые другой незавершённой транзакцией
- Non-Repeatable Read: при повторном чтении в той же транзакции данные изменились другой завершённой транзакцией
- Phantom Read: при повторном выполнении запроса с условием появились новые строки от другой завершённой транзакции
Реализация в PostgreSQL: через MVCC (Multi-Version Concurrency Control) — каждая транзакция видит свой снимок данных.
D — Durability (Надёжность / Долговечность)
После фиксации транзакции (COMMIT) результаты должны быть сохранены навсегда, даже при сбое системы, отключении питания или падении процесса.
Реализация через WAL (Write-Ahead Log):
Принцип WAL: прежде чем данные будут записаны на диск в файлы данных, описание изменения записывается в журнал (WAL). Это ключевое правило: WAL записывается раньше, чем данные.
Клиент → BEGIN → UPDATE → COMMIT
↓
┌─────────────┐
│ WAL Buffer │ ← изменения сначала попадают сюда
└──────┬──────┘
↓ WAL Write (fsync)
┌─────────────┐
│ WAL на диск│ ← fsync гарантирует запись на физический носитель
└──────┬──────┘
↓ После fsync WAL → возвращаем COMMIT OK клиенту
┌─────────────┐
│ Shared │ ← данные в памяти (shared buffers)
│ Buffers │
└──────┬──────┘
↓ Позже, асинхронно (bgwriter / checkpoint)
┌─────────────┐
│ Data files │ ← фоновый процесс записывает на диск
│ на диске │
└─────────────┘
Как это работает при сбое:
- Сервер упал после COMMIT, но до того, как данные записались в файлы данных
- При перезапуске PostgreSQL читает WAL
- Все зафиксированные транзакции из WAL воспроизводятся (redo)
- Все незафиксированные транзакции откатываются (undo)
- База приходит в согласованное состояние
Ключевые параметры WAL в PostgreSQL:
-- Синхронная запись WAL (гарантия durability)
synchronous_commit = on -- по умолчанию, COMMIT ждёт fsync WAL
-- Уровни synchronous_commit:
-- off — не ждать fsync (быстрее, но можно потерять последние транзакции при сбое)
-- on — ждать fsync WAL (стандарт)
-- remote_apply — ждать применения на реплике (самый строгий)
-- Размер сегмента WAL
wal_segment_size = 16MB
-- Контрольные точки — точки, до которых данные гарантированно записаны на диск
checkpoint_timeout = 5min
checkpoint_completion_target = 0.9
WAL также используется для:
- Репликации: WAL-записи передаются на реплику (streaming replication)
- Point-in-Time Recovery (PITR): можно восстановить базу до любого момента времени
- Logical decoding: для CDC (Change Data Capture) через Debezium и подобные инструменты
Практический пример — настройка для разных сценариев:
-- Максимальная надёжность (финансовые транзакции)
synchronous_commit = on;
synchronous_standby_names = 'replica1';
-- Максимальная производительность (метрики, логи — где потеря нескольких записей не критична)
synchronous_commit = off;
wal_level = minimal;
Итого: Durability в PostgreSQL реализуется через WAL — все изменения записываются в журнал до того, как данные будут изменены на диске. При сбое WAL используется для восстановления. Это фундаментальный механизм, без которого невозможна надёжная работа СУБД.
Вопрос 12. Репликация БД, что происходит при падении мастера, паттерн Raft
Таймкод: 00:41:16
Ответ собеседника: неполный. Репликация — способ поддержания отказоустойчивости через создание копий базы. Паттерн master-slave: пишем в мастер, читаем со слейвов. Если мастер падает — один из слейвов должен принять роль мастера. Паттерн Raft не знает.
Правильный ответ:
Кандидат дал базовое описание, но не раскрыл типы репликации, не объяснил процесс failover и не знает Raft. Вот полный ответ:
Репликация БД — это механизм копирования данных с одного сервера БД (мастера/лидера) на один или несколько других серверов (реплик/фолловеров).
Зачем нужна репликация:
- Отказоустойчивость: при падении мастера реплика может принять на себя его роль
- Масштабирование чтения: распределение read-запросов между несколькими серверами
- Географическая распределённость: размещение реплик ближе к пользователям
- Бэкапы: реплика может использоваться для снятия бэкапов без нагрузки на мастер
- Аналитика: запуск тяжёлых аналитических запросов на реплике, не влияя на OLTP
Типы репликации:
1. Master-Slave (Primary-Replica)
Один мастер принимает запись, реплики копируют изменения и обслуживают чтение.
┌──────────┐
Write → │ Master │ → WAL / Binary Log
└────┬─────┘
│ replication
┌──────────┼──────────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Slave 1 │ │ Slave 2 │ │ Slave 3 │
│ (Read) │ │ (Read) │ │ (Read) │
└──────────┘ └──────────┘ └──────────┘
2. Master-Master (Multi-Master)
Несколько узлов принимают запись и реплицируют друг другу. Сложнее в управлении конфликтами.
3. Каскадная репликация
Реплика реплицирует данные на другую реплику — для разгрузки мастера.
Режимы репликации:
Синхронная репликация:
-- PostgreSQL
synchronous_commit = 'on';
synchronous_standby_names = 'replica1, replica2';
Мастер ждёт подтверждения от реплики, что WAL записан. Гарантия: данные не потеряются при падении мастера. Минус: увеличенная латентность записи.
Асинхронная репликация:
Мастер не ждёт подтверждения от реплики. Быстрее, но возможна потеря последних транзакций при падении (replication lag).
Что происходит при падении мастера:
Проблема: нужно выполнить failover — переключить роль мастера на одну из реплик.
Этапы failover:
- Детекция падения: мониторинг определяет, что мастер недоступен (health check, heartbeat timeout)
- Выбор новой реплики: выбирается наиболее актуальная реплика (с наименьшим replication lag)
- Промоут реплики: реплика переводится в режим мастера (
pg_ctl promoteв PostgreSQL) - Переключение клиентов: приложения должны начать писать на новый мастер (через DNS, proxy, service discovery)
- Настройка остальных реплик: оставшиеся реплики переключаются на нового мастера
Проблемы при failover:
- Split-brain: старый мастер ожил и продолжает принимать запись — два мастера пишут разные данные. Решение: STONITH (Shoot The Other Node In The Head) — принудительное отключение старого мастера
- Потеря данных: при асинхронной репликации последние транзакции могли не дойти до реплики
- Время простоя: failover занимает время (от секунд до минут)
Инструменты для автоматизации failover:
- Patroni — для PostgreSQL, использует etcd/ZooKeeper/Consul для координации
- pg_auto_failover — автоматический failover для PostgreSQL
- Orchestrator — для MySQL
- HAProxy / PgBouncer — прокси для автоматического переключения
Паттерн Raft:
Raft — это консенсус-алгоритм для управления реплицированным журналом в распределённых системах. Он решает проблему согласования состояния между узлами.
Основные концепции Raft:
- Leader (Лидер): единственный узел, принимающий запись. Все запросы проходят через него
- Follower (Фолловер): пассивные узлы, реплицируют журнал от лидера
- Candidate (Кандидат): промежуточное состояние при выборе нового лидера
Жизненный цикл:
┌──────────┐ timeout ┌──────────┐ получает ┌──────────┐
│ Follower │ ─────────→ │ Candidate│ ── majority─→│ Leader │
│ │ │ │ │ │
│ │ ←──────────│ │ ←─────────── │ │
└──────────┘ discovers └──────────┘ discovers └──────────┘
leader or higher term
timeout
Как работает Raft:
- Выборы (Election): если фолловер не получает heartbeat от лидера в течение election timeout, он становится кандидатом и начинает голосование
- Репликация журнала (Log Replication): лидер отправляет записи журнала фолловерам, ждёт подтверждения от кворума (большинства)
- Безопасность (Safety): гарантирует, что если запись зафиксирована (committed), она не будет потеряна
Гарантии Raft:
- Выбирается ровно один лидер за один терм (epoch)
- Лидер никогда не перезаписывает или не удаляет записи в своём журнале
- Записи, зафиксированные в журнале, не будут потеряны
- Если запись зафиксирована на одном узле, она зафиксирована на всех
Где используется Raft:
- etcd — распределённое key-value хранилище, используется Kubernetes для хранения состояния кластера
- Consul — service discovery и конфигурация
- CockroachDB — распределённая SQL БД
- TiDB — распределённая SQL БД
- RabbitMQ (quorum queues) — реплицированные очереди
Raft vs Paxos:
Raft был разработан как более понятная альтернатива Paxos. Paxos сложнее для понимания и реализации, но оба алгоритма решают одну и ту же проблему — достижение консенсуса в распределённой системе.
Связь с репликацией БД:
Raft используется внутри распределённых СУБД для координации репликации. Например, в CockroachDB каждая партиция данных (range) имеет свой лидер, выбранный по Raft, и все записи проходят через этот лидер. Это обеспечивает строгую согласованность (linearizability) без единой точки отказа.
Итого: Raft — фундаментальный алгоритм для любого разработчика, работающего с распределёнными системами. Понимание его принципов помогает проектировать отказоустойчивые системы и выбирать правильные инструменты.
Вопрос 13. Шардирование БД и консистентное хеширование
Таймкод: 00:44:05
Ответ собеседника: неполный. Шардирование — разделение базы на части для повышения отказоустойчивости. Можно шардировать по частоте использования, по географическому расположению. Проблема: при добавлении нового шарда при простом хешировании все остатки меняются. Консистентное хеширование не знает.
Правильный ответ:
Кандидат верно определил шардирование и обозначил проблему простого хеширования, но не раскрыл типы шардирования и не знает консистентное хеширование. Вот полный ответ:
Шардирование (Sharding) — это горизонтальное разделение данных, при котором строки одной таблицы распределяются между несколькими физическими серверами (шардами).
Зачем нужно шардирование:
- Масштабируемость записи: один сервер не справляется с объёмом записи
- Масштабируемость хранения: данные не помещаются на одном сервере
- Производительность: меньше данных на шард → быстрее запросы
- Географическое распределение: размещение данных ближе к пользователям
Типы шардирования:
1. Шардирование по ключу (Key-Based / Hash Sharding)
Данные распределяются по шардам на основе хеша от ключа:
func getShardID(userID string, totalShards int) int {
h := fnv.New32a()
h.Write([]byte(userID))
return int(h.Sum32()) % totalShards
}
// user-123 → hash("user-123") % 4 = 2 → шард 2
// user-456 → hash("user-456") % 4 = 0 → шард 0
- Плюсы: равномерное распределение данных
- Минусы: при добавлении/удалении шарда нужно перемещать огромный объём данных (проблема рехеширования)
2. Шардирование по диапазону (Range Sharding)
Данные распределяются по диапазонам значений ключа:
Шард 1: user_id от 1 до 1,000,000
Шард 2: user_id от 1,000,001 до 2,000,000
Шард 3: user_id от 2,000,001 до 3,000,000
- Плюсы: эффективные range-запросы, простая навигация
- Минусы: неравномерное распределение (hot spots) — один шард может быть перегружен
3. Шардирование по каталогу (Directory-Based Sharding)
Используется отдельная таблица-справочник (lookup table), которая хранит маппинг «ключ → шард»:
CREATE TABLE shard_routing (
entity_type VARCHAR(50),
entity_id VARCHAR(100),
shard_id INT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
);
- Плюсы: гибкость, можно легко перемещать данные между шардами
- Минусы: дополнительный запрос к каталогу, каталог — потенциальная точка отказа
4. Географическое шардирование
Данные распределяются по географическому признаку:
Шард EU: пользователи из Европы
Шард US: пользователи из США
Шард ASIA: пользователи из Азии
Проблемы шардирования:
- Cross-shard запросы: JOIN и агрегация между шардами сложны и медленны
- Перебалансировка: добавление/удаление шардов требует миграции данных
- Гарантии целостности: нет межшардовых транзакций (или они очень дорогие)
- Сложность операций: бэкапы, мониторинг, миграции усложняются
Консистентное хеширование (Consistent Hashing):
Это алгоритм хеширования, который решает проблему рехеширования при добавлении/удалении узлов.
Проблема простого хеширования:
При 4 шардах: hash(key) % 4
При 5 шардах: hash(key) % 5
Практически ВСЕ ключи попадут на другие шарды!
Нужно переместить ~80% данных.
Как работает консистентное хеширование:
Представим кольцо (hash ring) от 0 до 2^32-1:
0
╱ ╲
2^32-1 1
╱ ╲
Node C Node A
╲ ╱
Node D Node B
╲ ╱
...
- Каждый узел хешируется и размещается на кольце
- Каждый ключ хешируется и размещается на кольце
- Ключ направляется к ближайшему узлу по часовой стрелке
Hash Ring:
0 Node A (hash=100)
| |
| key1 |
| (150) | Node B (hash=300)
| \ | |
| \ | |
| \ | |
| key2 \ | key3 |
| (280) \ | (350) |
| \ | |
| Node C |
| (500) |
| \ |
| \ |
| \ |
| \ |
| key4 \ |
| (600) \ |
| Node D
| (700)
Что происпри добавлении нового узла:
Было: 4 узла → при добавлении 5-го
Простое хеширование: ~80% ключей перемещаются
Консистентное хеширование: только ~1/N ключей перемещаются
При добавлении Node E (hash=200):
- С Node A перейдут только ключи в диапазоне (100, 200]
- Все остальные ключи остаются на тех же узлах!
Виртуальные узлы (Virtual Nodes):
Для более равномерного распределения каждый физический узел представляется несколькими виртуальными узлами на кольце:
Физический узел A → виртуальные узлы A1, A2, A3, ..., A100
Физический узел B → виртуальные узел B1, B2, B3, ..., B100
Это решает проблему неравномерного распределения, когда хеши узлов случайно оказались скучены в одной части кольца.
Реализация на Go (упрощённая):
import (
"hash/fnv"
"sort"
"strconv"
)
type HashRing struct {
nodes map[uint32]string // hash → node name
sorted []uint32 // отсортированные хеши
replicas int // количество виртуальных узлов на физический
}
func NewHashRing(replicas int) *HashRing {
return &HashRing{
nodes: make(map[uint32]string),
replicas: replicas,
}
}
func (h *HashRing) AddNode(node string) {
for i := 0; i < h.replicas; i++ {
virtualKey := node + "#" + strconv.Itoa(i)
hash := h.hash(virtualKey)
h.nodes[hash] = node
h.sorted = append(h.sorted, hash)
}
sort.Slice(h.sorted, func(i, j int) bool {
return h.sorted[i] < h.sorted[j]
})
}
func (h *HashRing) GetNode(key string) string {
if len(h.sorted) == 0 {
return ""
}
hash := h.hash(key)
// Бинарный поиск первого узла с хешем >= хеша ключа
idx := sort.Search(len(h.sorted), func(i int) bool {
return h.sorted[i] >= hash
})
// Если вышли за границу — берём первый узел (кольцо)
if idx == len(h.sorted) {
idx = 0
}
return h.nodes[h.sorted[idx]]
}
func (h *HashRing) hash(key string) uint32 {
hasher := fnv.New32a()
hasher.Write([]byte(key))
return hasher.Sum32()
}
Где используется консистентное хеширование:
- Amazon DynamoDB — для партиционирования данных
- Apache Cassandra — для распределения данных между узлами
- Redis Cluster — для распределения ключей по слотам (использует похожий подход с hash slots)
- CDN — для маршрутизации запросов к ближайшему серверу
- Балансировка нагрузки — для распределения сессий между серверами
Итого: консистентное хеширование — ключевой алгоритм для проектирования масштабируемых распределённых систем. Оно минимизирует объём перемещаемых данных при изменении топологии кластера, что критически важно для систем, которые должны работать непрерывно.
Вопрос 14. CAP-теорема и персистентность в контексте кэширования
Таймкод: 00:47:03
Ответ собеседника: неполный. CAP-теорема: в распределённой системе можно обеспечить только два из трёх свойств — консистентность, доступность, устойчивость к разделению. Персистентность определил неверно как несогласованность данных между кэшем и базой. На самом деле персистентность — сохранение данных на долговременном носителе после перезагрузки.
Правильный ответ:
Кандидат дал базовую формулировку CAP, но не раскрыл её глубину и путает понятие персистентности. Вот полный ответ:
CAP-теорема (теорема Брюера):
Формулировка: в любой распределённой системе хранения данных можно одновременно гарантировать не более двух из трёх свойств:
C — Consistency (Согласованность / Консистентность)
Каждое чтение возвращает результат последней записи. Все узлы видят одни и те же данные в один и тот же момент времени. Это не то же самое, что Consistency в ACID — здесь речь о репликационной согласованности (replica consistency).
A — Availability (Доступность)
Каждый запрос получает ответ (успешный или ошибочный), без гарантии, что ответ содержит последнюю запись. Система всегда отвечает, даже если некоторые узлы недоступны.
P — Partition Tolerance (Устойчивость к разделению)
Система продолжает работать при потере сообщения между узлами (network partition). Сеть ненадёжна — пакеты теряются, задерживаются, дублируются.
Почему можно выбрать только два:
При возникновении network partition (узлы не могут обмениваться данными) система должна выбирать:
Consistency
/\
/ \
/ \
/ CP \
/ \
/ CA* \
/ \
/______________\
Availability Partition Tolerance
CP-система (Consistency + Partition Tolerance):
При разделении система жертвует доступностью — отвечает ошибкой, чтобы не вернуть устаревшие данные.
- Примеры: PostgreSQL (synchronous replication), MongoDB (majority reads/writes), HBase, ZooKeeper, etcd
- Сценарий: два дата-центра потеряли связь → один перестаёт отвечать, чтобы не дать расхождение данных
AP-система (Availability + Partition Tolerance):
При разделении система продолжает отвечать, но может вернуть устаревшие данные.
- Примеры: Cassandra, DynamoDB, CouchDB, DNS
- Сценарий: два дата-центра потеряли связь → оба продолжают принимать записи, конфликты разрешаются позже (last-write-wins, CRDT)
CA-система (Consistency + Availability):
Теоретически возможна только при отсутствии network partition, что в распределённых системах невозможно гарантировать. На практике это одиночные серверы или системы в пределах одного дата-центра.
Важные нюансы CAP:
- CAP — это не жёсткое «либо-либо». Это спектр. Системы могут настраиваться между полюсами в зависимости от конфигурации и типа запроса
- Выбор делается не один раз для всей системы, а при каждом network partition
- В нормальном режиме (без разделения) система может обеспечивать все три свойства
PACELC — расширение CAP:
Если есть разделение (P), выбираем между доступностью (A) и консистентностью (C). Иначе (E), при нормальной работе выбираем между латентностью (L) и консистентностью (C).
При разделении: CP или AP
В норме: LC (низкая латентность) или EC (строгая консистентность)
Примеры:
- DynamoDB: PA/EL — при разделении доступна, в норме низкая латентность
- MongoDB: PC/EC — приоритет консистентности всегда
- Cassandra: PA/EL — при разделении доступна, в норме низкая латентность
Персистентность (Persistence) в контексте Redis:
Кандидат перепутал персистентность с несогласованностью кэша и БД. Это разные понятия.
Персистентность — это способность системы сохранять данные на энергонезависимом носителе так, чтобы данные пережили перезагрузку или падение процесса.
Redis — в памяти, но с персистентностью:
Redis хранит данные в оперативной памяти для максимальной скорости, но поддерживает два механизма персистентности:
1. RDB (Redis Database Backup) — снимки (snapshots)
Периодически создаёт компактный бинарный файл — снимок всего набора данных на определённый момент времени.
# redis.conf
save 900 1 # сохранить, если 1+ ключ изменился за 900 секунд
save 300 10 # сохранить, если 10+ ключей изменились за 300 секунд
save 60 10000 # сохранить, если 10000+ ключей изменились за 60 секунд
- Плюсы: компактный файл, быстрое восстановление
- Минусы: можно потерять данные за последний интервал (между снимками)
2. AOF (Append-Only File) — журнал операций
Каждая операция записи добавляется в конец файла. При перезапуске Redis воспроизводит все команды из AOF.
# redis.conf
appendonly yes
appendfsync everysec # fsync каждую секунду (баланс скорости и надёжности)
# appendfsync always # fsync после каждой команды (максимальная надёжность, медленно)
# appendfsync no # ОС сама решает (быстро, но можно потерять данные)
- Плюсы: минимальная потеря данных (при
everysec— максимум 1 секунда) - Минусы: файл растёт, восстановление медленнее чем RDB
3. Комбинированный режим (RDB + AOF)
Начиная с Redis 7.0 рекомендуется использовать оба механизма одновременно:
aof-use-rdb-preamble yes
AOF содержит RDB-снимок в начале + инкрементальные команды после него. Это даёт быстрое восстановление и минимальную потерю данных.
Проблема несогласованности кэша и БД (Cache Invalidation):
Это отдельная проблема, не связанная с персистентностью. Когда данные обновляются в БД, кэш может содержать устаревшую версию. Стратегии:
- Cache-Aside (Lazy Loading): при чтении — проверяем кэш, если нет — идём в БД и заполняем кэш. При записи — обновляем БД и инвалидируем кэш
- Write-Through: запись идёт одновременно в кэш и в БД
- Write-Behind (Write-Back): запись идёт в кэш, асинхронно сбрасывается в БД
- TTL (Time-To-Live): данные в кэше живут ограниченное время, потом автоматически удаляются
// Пример Cache-Aside на Go с Redis
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
// 1. Проверяем кэш
cached, err := s.redis.Get(ctx, "user:"+id).Bytes()
if err == nil {
var user User
if json.Unmarshal(cached, &user) == nil {
return &user, nil
}
}
// 2. Если нет в кэше — идём в БД
user, err := s.db.GetUser(ctx, id)
if err != nil {
return nil, err
}
// 3. Заполняем кэш
data, _ := json.Marshal(user)
s.redis.Set(ctx, "user:"+id, data, 5*time.Minute)
return user, nil
}
func (s *Service) UpdateUser(ctx context.Context, user *User) error {
// 1. Обновляем БД
if err := s.db.UpdateUser(ctx, user); err != nil {
return err
}
// 2. Инвалидируем кэш
s.redis.Del(ctx, "user:"+user.ID)
return nil
}
Итого: CAP-теорема — фундаментальное ограничение распределённых систем, а персистентность — механизм сохранения данных на долговременном носителе. Это два разных понятия, которые не стоит путать.
Вопрос 15. Использование Redis помимо кэширования и паттерн Saga
Таймкод: 00:49:52
Ответ собеседника: неполный. Назвал использование Redis для хранения сессий/токенов авторизации с TTL. Паттерн Saga знает — несколько сервисов общают между собой. Назвал две реализации: хореография и оркестрация. Не привёл конкретного примера использования Redis в контексте Saga из своего опыта.
Правильный ответ:
Кандидат дал базовый ответ, но не раскрыл полный спектр возможностей Redis и не привёл конкретных примеров Saga. Вот полный ответ:
Redis помимо кэширования:
1. Хранение сессий и токенов
Кандидат верно упомянул. Сессии хранятся как хеши с TTL:
// Сохранение сессии
sessionData := map[string]string{
"user_id": "123",
"role": "admin",
}
sessionJSON, _ := json.Marshal(sessionData)
redis.Set(ctx, "session:abc123", sessionJSON, 24*time.Hour)
// Получение сессии
data, err := redis.Get(ctx, "session:abc123").Bytes()
2. Rate Limiting (ограничение частоты запросов)
Защита от DDoS и злоупотреблений:
// Скользящее окно через sorted set
func (rl *RateLimiter) Allow(ctx context.Context, userID string) bool {
key := "ratelimit:" + userID
now := time.Now().UnixNano()
window := int64(time.Minute)
pipe := rl.redis.Pipeline()
// Удаляем старые записи за пределами окна
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(now-window, 10))
// Считаем количество запросов в окне
pipe.ZCard(ctx, key)
// Добавляем текущий запрос
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
// Устанавливаем TTL на ключ
pipe.Expire(ctx, key, time.Minute)
results, _ := pipe.Exec(ctx)
count := results[1].(*redis.IntCmd).Val()
return count < 100 // максимум 100 запросов в минуту
}
3. Распределённые блокировки (Distributed Locks)
Координация между несколькими инстансами сервиса:
func (dl *DistributedLock) Acquire(ctx context.Context, resource string, ttl time.Duration) (bool, error) {
// SET resource_name unique_value NX PX ttl
return dl.redis.SetNX(ctx, "lock:"+resource, dl.instanceID, ttl).Result()
}
func (dl *DistributedLock) Release(ctx context.Context, resource string) error {
// Освобождаем только если это наш лок (Lua-атомарность)
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
return dl.redis.Eval(ctx, script, []string{"lock:" + resource}, dl.instanceID).Err()
}
Для продакшена рекомендуется использовать Redlock алгоритм или библиотеку go-redsync.
4. Очереди сообщений (Streams)
Redis Streams — полноценный лог сообщений с consumer groups:
// Producer
redis.XAdd(ctx, &redis.XAddArgs{
Stream: "orders",
Values: map[string]interface{}{
"order_id": "123",
"action": "created",
},
})
// Consumer group
for {
msgs, err := redis.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: "order-processors",
Consumer: "worker-1",
Streams: []string{"orders", ">"},
Count: 10,
Block: time.Second,
}).Result()
for _, msg := range msgs {
processMessage(msg)
redis.XAck(ctx, "orders", "order-processors", msg.ID)
}
}
5. Pub/Sub (издатель-подписчик)
Для real-time уведомлений и event-driven архитектуры:
// Publisher
redis.Publish(ctx, "notifications", `{"type": "order_created", "id": "123"}`)
// Subscriber
pubsub := redis.Subscribe(ctx, "notifications")
for msg := range pubsub.Channel() {
handleNotification(msg.Payload)
}
6. Счётчики и аналитика реального времени
// Счётчик просмотров
redis.Incr(ctx, "page:views:product:123")
redis.Expire(ctx, "page:views:product:123", 24*time.Hour)
// HyperLogLog для подсчёта уникальных элементов
redis.PFAdd(ctx, "unique:visitors:2024-01-15", "user1", "user2", "user3")
count, _ := redis.PFCount(ctx, "unique:visitors:2024-01-15")
7. Лидерборды и рейтинги (Sorted Sets)
// Добавляем результат
redis.ZAdd(ctx, "leaderboard", redis.Z{Score: 1500, Member: "player1"})
redis.ZAdd(ctx, "leaderboard", redis.Z{Score: 2000, Member: "player2"})
// Топ-10
top, _ := redis.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()
// Ранг игрока
rank, _ := redis.ZRevRank(ctx, "leaderboard", "player1").Result()
8. Геопространственные запросы
// Добавляем локации
redis.GeoAdd(ctx, "restaurants", &redis.GeoLocation{
Name: "Pizza Place", Longitude: 37.6, Latitude: 55.7,
})
// Ищем рядом
nearby, _ := redis.GeoRadius(ctx, "restaurants", 37.6, 55.7, &redis.GeoRadiusQuery{
Radius: 5, Unit: "km", WithDist: true, Count: 10,
}).Result()
Паттерн Saga:
Saga — паттерн управления распределёнными транзакциями, где каждая локальная транзакция публикует событие, запускающее следующую транзакцию. Если какая-то транзакция не проходит — выполняются компенсирующие транзакции для отката предыдущих шагов.
Пример: оформление заказа
1. Order Service: создать заказ (статус PENDING)
2. Payment Service: списать деньги
3. Inventory Service: зарезервировать товар
4. Shipping Service: создать доставку
5. Order Service: подтвердить заказ (статус CONFIRMED)
Если шаг 3 не прошёл (товара нет):
3. Inventory Service: НЕУДАЧА
2. Payment Service: компенсация — вернуть деньги
1. Order Service: компенсация — отменить заказ
Хореография (Choreography):
Сервисы общаются через события, нет центрального координатора.
Order Service → OrderCreated event → Payment Service
Payment Service → PaymentCompleted event → Inventory Service
Inventory Service → InventoryReserved event → Shipping Service
Shipping Service → ShipmentCreated event → Order Service
- Плюсы: простота, слабая связанность, нет единой точки отказа
- Минусы: сложно отслеживать общий статус, циклические зависимости, сложнее отладка
Оркестрация (Orchestration):
Есть центральный оркестратор (Saga Orchestrator), который управляет последовательностью шагов.
Saga Orchestrator:
1. → Order Service: CreateOrder
2. ← Order Service: OrderCreated
3. → Payment Service: ChargePayment
4. ← Payment Service: PaymentCharged
5. → Inventory Service: ReserveInventory
6. ← Inventory Service: FAIL
7. → Payment Service: RefundPayment (компенсация)
8. → Order Service: CancelOrder (компенсация)
- Плюсы: централизованный контроль, проще отслеживать статус, нет циклических зависимостей
- Минусы: оркестратор — потенциальная единая точка отказа, больше логики в одном месте
Redis в контексте Saga:
Redis может использоваться как брокер сообщений для хореографии (через Streams или Pub/Sub) и как хранилище состояния Saga (текущий шаг, статус, контекст):
type SagaState struct {
ID string `json:"id"`
Status string `json:"status"` // pending, running, completed, compensating, failed
Step int `json:"step"`
Context map[string]string `json:"context"`
CreatedAt time.Time `json:"created_at"`
}
func (s *SagaOrchestrator) SaveState(ctx context.Context, state *SagaState) error {
data, _ := json.Marshal(state)
return s.redis.Set(ctx, "saga:"+state.ID, data, 24*time.Hour).Err()
}
func (s *SagaOrchestrator) GetState(ctx context.Context, sagaID string) (*SagaState, error) {
data, err := s.redis.Get(ctx, "saga:"+sagaID).Bytes()
if err != nil {
return nil, err
}
var state SagaState
json.Unmarshal(data, &state)
return &state, nil
}
Итого: Redis — это не просто кэш, а многофункциональный инструмент для решения широкого круга задач в распределённых системах. Saga — критически важный паттерн для обеспечения консистентности в микросервисной архитектуре, где нет глобальных транзакций.
Вопрос 16. Политики вытеснения данных в Redis при переполнении памяти
Таймкод: 00:53:48
Ответ собеседника: неполный. Назвал LFU и LRU. Упомянул удаление по timestamp и комбинированные решения. Не назвал все основные политики (noeviction, volatile-lfu, volatile-lru, allkeys-lfu, allkeys-lru, volatile-random, allkeys-random, volatile-ttl).
Правильный ответ:
Кандидат знает базовые алгоритмы, но не знаком с полным набором политик Redis. Вот полный ответ:
Конфигурация памяти в Redis:
# Максимальный объём памяти
maxmemory 2gb
# Политика вытеснения при достижении лимита
maxmemory-policy allkeys-lru
Все 8 политик вытеснения (eviction policies):
1. noeviction (по умолчанию)
Не удаляет данные. При исчерпании памяти команды записи возвращают ошибку OOM (Out Of Memory). Команды чтения работают нормально.
- Когда использовать: когда потеря данных недопустима и вы готовы обрабатывать ошибки записи на уровне приложения
- Минус: приложение должно уметь обрабатывать ошибки записи
SET key value → (error) OOM command not allowed when used memory > 'maxmemory'
2. volatile-lru
Удаляет ключи с истёкшим TTL, используя LRU-алгоритм (Least Recently Used — наименее недавно использованный). Среди ключей с TTL выбирается тот, который не запрашивался дольше всех.
- Когда использовать: когда есть ключи, которые должны жить вечно (без TTL), и временные ключи с TTL
- Минус: если ни у одного ключа нет TTL — поведение как noeviction
3. volatile-lfu
Удаляет ключи с истёкшим TTL, используя LFU-алгоритм (Least Frequently Used — наименее часто использованный). Среди ключей с TTL выбирается тот, к которому обращались реже всего.
- Когда использовать: когда есть «горячие» и «холодные» ключи, и вы хотите сохранить самые популярные
- Минус: «старые» ключи с накопленным счётчиком частоты могут вытеснить «новые горячие»
4. volatile-random
Случайно удаляет ключи с истёкшим TTL.
- Когда использовать: когда нет явного паттерна доступа и любой ключ с TTL можно удалить
- Минус: может удалить «горячий» ключ
5. volatile-ttl
Удаляет ключ с истёкшим TTL, у которого наименьшее оставшееся время жизни (ближе всего к естественному истечению).
- Когда использовать: когда хотите минимально вмешиваться — удаляем то, что и так скоро умрёт
- Плюс: предсказуемое поведение
6. allkeys-lru
Удаляет любой ключ (не только с TTL) по LRU-алгоритму. Самая популярная политика для кэша.
- Когда использовать: Redis как кэш — это стандартный выбор
- Плюс: не требует настройки TTL на ключах
7. allkeys-lfu
Удаляет любой ключ по LFU-алгоритму.
- Когда использовать: когда важно сохранить самые часто запрашиваемые данные
- Плюс: лучше адаптируется к паттерну доступа, чем LRU
8. allkeys-random
Случайно удаляет любой ключ.
- Когда использовать: когда все ключи равнозначны и нет паттерна доступа
Сравнение политик:
┌─────────────────────┬──────────────────┬─────────────────────┐
│ Политика │ Область ключей │ Алгоритм выбора │
├─────────────────────┼──────────────────┼─────────────────────┤
│ noeviction │ — │ Нет (ошибка OOM) │
│ volatile-lru │ Только с TTL │ LRU │
│ volatile-lfu │ Только с TTL │ LFU │
│ volatile-random │ Только с TTL │ Случайный │
│ volatile-ttl │ Только с TTL │ Мин. оставшийся TTL │
│ allkeys-lru │ Все ключи │ LRU │
│ allkeys-lfu │ Все ключи │ LFU │
│ allkeys-random │ Все ключи │ Случайный │
└─────────────────────┴──────────────────┴─────────────────────┘
Как работает LRU в Redis (приближённый):
Redis не использует классический LRU (который требует сортировки всех ключей). Вместо этого используется приближённый LRU:
- Redis случайно выбирает N ключей (по умолчанию 5)
- Среди них выбирает тот, который не использовался дольше всего
- Удаляет его
Параметр maxmemory-samples контролирует количество проверяемых ключей:
maxmemory-samples 5 # больше значение → точнее LRU, но медленнее
При samples=10 результат очень близок к настоящему LRU, но требует больше CPU.
Как работает LFU в Redis:
Каждый ключ имеет счётчик обращений (logarithmic counter, 8 бит — максимум 255). Счётчик увеличивается при каждом обращении, но с уменьшающейся вероятностью (чтобы старые «горячие» ключи постепенно «остывали»).
lfu-log-factor 10 # чем больше, тем медленнее растёт счётчик
lfu-decay-time 1 # каждые N минут счётчики уменьшаются
Практические рекомендации:
- Кэш:
allkeys-lru— стандартный выбор - Кэш с приоритетом по частоте:
allkeys-lfu— если есть явные «горячие» данные - Смешанная нагрузка (кэш + постоянные данные):
volatile-lruилиvolatile-ttl— удаляем только временные данные - Критичные данные:
noeviction— обрабатываем OOM в приложении
# Мониторинг в redis-cli
INFO memory
# Смотрим: used_memory, maxmemory, evicted_keys
INFO stats
# Смотрим: evicted_keys (общее количество вытеснённых ключей)
Дополнительные стратегии управления памятью:
- Шардирование: распределить данные между несколькими инстансами Redis
- Сжатие данных: использовать более компактные структуры данных, хеши вместо отдельных ключей
- Memory optimization:
hash-max-ziplist-entries,set-max-intset-entries— настройки внутреннего хранения структур - Redis Cluster: автоматическое распределение ключей по шардам (hash slots)
Итого: понимание политик вытеснения критически важно для правильной настройки Redis в продакшене. Неправильный выбор политики может привести к неожиданной потере данных или отказам в записи.
Вопрос 17. Что такое идемпотентность, примеры и техническая реализация
Таймкод: 00:56:26
Ответ собеседника: неправильный. Перепутал идемпотентность с консистентностью и механизмом отката транзакций в Saga. Определил идемпотентность как состояние, при котором система должна вернуться в изначальное состояние при ошибке. Это неверно. Идемпотентность — свойство операции, при котором повторное выполнение даёт тот же результат, что и однократное. Интервьюер объяснил на примере платежей с использованием Redis для хранения ID транзакций.
Правильный ответ:
Кандидат дал неверное определение. Вот полный и детальный ответ:
Идемпотентность — это свойство операции, при котором многократное выполнение этой операции даёт тот же результат, что и однократное выполнение.
Математически: f(f(x)) = f(x) — применение функции дважды даёт тот же результат, что и применение один раз.
Примеры из повседневной жизни:
- Нажатие кнопки выключателя света: если свет уже включён — повторное нажатие не меняет состояние
- Установка температуры на термостате: установить 22°C, когда уже 22°C — ничего не изменится
- HTTP-метод GET: повторный GET запрос возвращает тот же результат (если данные не изменились)
Примеры из разработки:
Идемпотентные операции:
GET /api/users/123 — чтение, не меняет состояние
PUT /api/users/123 {name: "John"} — полная замена ресурса, повторный вызов = тот же результат
DELETE /api/users/123 — удаление, повторное удаление уже удалённого = тот же результат (404)
Неидемпотентные операции:
POST /api/orders — создание заказа, повторный вызов = второй заказ!
POST /api/payments — списание денег, повторный вызов = двойное списание!
PATCH /api/users/123 {balance: +100} — инкремент баланса, повторный = +200 вместо +100
Где идемпотентность критически важна:
1. Платёжные системы
Клиент отправляет запрос на оплату, сеть тормозит, клиент повторяет запрос. Без идемпотентности — двойное списание.
2. Очереди сообщений (at-least-once delivery)
Kafka, RabbitMQ гарантируют доставку хотя бы один раз. Потребитель может обработать сообщение, но не успеть подтвердить — и получить его повторно.
3. HTTP-запросы с автоматическими retry
HTTP-клиенты, балансировщики, прокси могут автоматически повторять запросы при таймаутах.
4. Микросервисная архитектура
Сетевые вызовы между сервисами ненадёжны — retry неизбежны.
Техническая реализация идемпотентности:
1. Идемпотентный ключ (Idempotency Key)
Клиент генерирует уникальный ключ для каждой операции и передаёт его в заголовке. Сервер проверяет, обрабатывалась ли операция с таким ключом.
func (s *PaymentService) ProcessPayment(ctx context.Context, req PaymentRequest) (*Payment, error) {
// Проверяем, была ли уже обработана эта операция
existing, err := s.redis.Get(ctx, "idempotency:"+req.IdempotencyKey).Bytes()
if err == nil {
// Операция уже обработана — возвращаем сохранённый результат
var payment Payment
json.Unmarshal(existing, &payment)
return &payment, nil
}
// Проверяем в БД на случай если Redis недоступен
existingPayment, err := s.repo.GetByIDempotencyKey(ctx, req.IdempotencyKey)
if err == nil {
return existingPayment, nil
}
// Выполняем операцию
payment, err := s.charge(ctx, req)
if err != nil {
return nil, err
}
// Сохраняем результат для повторных запросов
data, _ := json.Marshal(payment)
s.redis.Set(ctx, "idempotency:"+req.IdempotencyKey, data, 24*time.Hour)
s.repo.SaveWithIdempotencyKey(ctx, payment, req.IdempotencyKey)
return payment, nil
}
2. Уникальные ограничения в БД
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- При повторном вставке с тем же ключом — ошибка UNIQUE constraint
-- Приложение ловит ошибку и возвращает существующую запись
func (r *PaymentRepository) CreatePayment(ctx context.Context, payment *Payment) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO payments (idempotency_key, amount, status) VALUES ($1, $2, $3)`,
payment.IdempotencyKey, payment.Amount, payment.Status,
)
if err != nil {
if isUniqueViolation(err) {
return ErrDuplicateOperation
}
return err
}
return nil
}
3. Оптимистичные блокировки с версиями
-- Обновляем только если версия совпадает
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = $1 AND version = $2;
-- Если rows_affected = 0 — кто-то уже изменил запись
4. Идемпотентные операции вместо неидемпотентных
Вместо инкремента — установка конкретного значения:
-- Неидемпотентно:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Идемпотентно (если знаем целевое значение):
UPDATE accounts SET balance = 900 WHERE id = 1 AND balance = 1000;
5. Идемпотентность в HTTP API
REST-спецификация определяет идемпотентные методы:
GET — идемпотентен (только чтение)
PUT — идемпотентен (полная замена ресурса)
DELETE — идемпотентен (повторное удаление = 404)
POST — НЕ идемпотентен (создаёт новый ресурс)
PATCH — обычно НЕ идемпотентен (частичное обновление)
Для POST используется заголовок Idempotency-Key:
POST /api/payments HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"amount": 1000, "currency": "USD"}
6. Идемпотентность в очередях сообщений
func (c *Consumer) ProcessMessage(ctx context.Context, msg *kafka.Message) error {
// Извлекаем идемпотентный ключ из заголовка сообщения
idempotencyKey := extractIdempotencyKey(msg)
// Проверяем, обрабатывали ли уже
processed, err := c.redis.Get(ctx, "processed:"+idempotencyKey).Bool()
if err == nil && processed {
return nil // Уже обработано — пропускаем
}
// Обрабатываем
if err := c.handle(ctx, msg); err != nil {
return err
}
// Помечаем как обработанное
c.redis.Set(ctx, "processed:"+idempotencyKey, true, 7*24*time.Hour)
return nil
}
Идемпотентность vs другие понятия:
- Атомарность: операция выполняется целиком или не выполняется вообще
- Идемпотентность: повторное выполнение даёт тот же результат
- Компенсация (Saga): откат уже выполненной операции — это не идемпотентность
Итого: идемпотентность — фундаментальное свойство для построения надёжных распределённых систем. В мире ненадёжных сетей и автоматических retry без идемпотентности невозможно гарантировать корректность данных.
Вопрос 18. Гарантии доставки сообщений в Kafka и причины дублирования
Таймкод: 01:00:45
Ответ собеседника: неполный. Не смог назвать конкретные гарантии доставки в Kafka. Интервьюер объяснил три уровня: at-most-once, at-least-once и exactly-once.
Правильный ответ:
Кандидат не смог ответить на вопрос, который является базовым для работы с очередями сообщений. Вот полный ответ:
Три гарантии доставки в Kafka:
1. At-Most-Once (максимум один раз)
Сообщение доставляется не более одного раза. Может быть потеряно, но никогда не будет доставлено дважды.
Producer → [сообщение] → Broker
↓
Может потеряться!
Настройки:
// Producer
config.RequiredAcks = sarama.NoAck // не ждём подтверждения
config.Retries = 0 // не повторяем при ошибке
// Consumer
// Автокоммит до обработки — если упадём после коммита, но до обработки, сообщение потеряно
config.Consumer.Offsets.AutoCommit.Enable = true
config.Consumer.Offsets.AutoCommit.Interval = time.Second
- Когда использовать: метрики, логи, телеметрия — где потеря единичных сообщений не критична
- Плюс: максимальная пропускная способность
2. At-Least-Once (минимум один раз)
Сообщение гарантированно доставляется, но может быть доставлено несколько раз. Это наиболее распространённый режим.
Producer → [сообщение] → Broker → Consumer → обработка → offset commit
↓
Если упали до коммита —
повторная доставка!
Настройки:
// Producer
config.RequiredAcks = sarama.WaitForAll // ждём подтверждения от всех ISR
config.Retries = 3 // повторяем при ошибке
config.Producer.Idempotent = true // идемпотентный продюсер
// Consumer
config.Consumer.Offsets.AutoCommit.Enable = false // ручной коммит
// Consumer — коммит ПОСЛЕ обработки
for msg := range consumer.Messages() {
err := processMessage(msg)
if err != nil {
// Не коммитим — сообщение будет доставлено повторно
log.Error("failed to process", err)
continue
}
// Коммитим только после успешной обработки
consumer.MarkOffset(msg, "")
}
- Когда использовать: в большинстве бизнес-сценариев
- Требование: обработка должна быть идемпотентной
- Минус: возможны дубликаты
3. Exactly-Once (ровно один раз)
Сообщение доставляется и обрабатывается ровно один раз. Самый строгий, но и самый сложный режим.
Реализация в Kafka через транзакционный API:
// Producer — транзакционный
config.Producer.Idempotent = true
config.Producer.Transaction.ID = "my-transactional-id"
producer, _ := sarama.NewSyncProducer(brokers, config)
producer.BeginTxn()
producer.SendTxnMessage(&sarama.ProducerMessage{
Topic: "orders",
Value: sarama.StringEncoder(orderJSON),
})
// Consumer с isolation.level=read_committed
config.Consumer.IsolationLevel = sarama.ReadCommitted
// Коммит смещения внутри транзакции
producer.SendOffsetsToTransaction(offsets, consumerGroupID)
producer.CommitTxn()
- Когда использовать: платежи, бронирования — где дублирование недопустимо
- Минус: производительность ниже, сложнее в настройке
Причины дублирования сообщений:
1. Продюсер повторяет отправку (самая частая причина)
Producer → send → Broker → ACK (потеря в сети)
↓
Producer не получил ACK → timeout → retry
→ сообщение отправлено повторно
Брокер мог получить и записать сообщение, но ACK не дошёл до продюсера. Продюсер повторяет отправку — дубликат.
2. Консюмер не успел закоммитить offset
Consumer → прочитал сообщение → обработал → [CRASH] → offset не закоммитился
↓
Consumer перезапустился → читает с последнего закоммиченного offset → дубликат
3. Rebalancing в consumer group
Consumer A обрабатывает партицию 1 → [rebalance] → Consumer B получает партицию 1
↓
Consumer B начинает с последнего закоммиченного offset → дубликат
4. Автокоммит с интервалом
Consumer → прочитал → автокоммит (каждые 5 сек) → обработка → [CRASH]
↓
Offset закоммичен, но обработка не завершена → сообщение потеряно
ИЛИ
Consumer → прочитал → обработка → [CRASH до автокоммита]
↓
Offset не закоммичен → дубликат при перезапуске
Как бороться с дублированием:
1. Идемпотентная обработка (основной подход)
func (h *OrderHandler) Handle(ctx context.Context, msg *sarama.ConsumerMessage) error {
var event OrderEvent
json.Unmarshal(msg.Value, &event)
// Проверяем, обрабатывали ли уже это событие
processed, _ := h.redis.Get(ctx, "processed:"+event.EventID).Bool()
if processed {
return nil // Уже обработано — пропускаем
}
// Обрабатываем
err := h.service.ProcessOrder(ctx, event)
if err != nil {
return err // Не коммитим — будет retry
}
// Помечаем как обработанное
h.redis.Set(ctx, "processed:"+event.EventID, true, 7*24*time.Hour)
return nil
}
2. Идемпотентный продюсер Kafka
config.Producer.Idempotent = true
config.RequiredAcks = sarama.WaitForAll
Kafka присваивает каждому сообщению PID (Producer ID) + Sequence Number. Брокер дедуплицирует на своей стороне.
3. Транзакционный API
// Атомарная запись в несколько партиций/топиков + коммит offset
producer.BeginTxn()
producer.SendTxnMessage(msg1)
producer.SendTxnMessage(msg2)
producer.SendOffsetsToTransaction(offsets, groupID)
producer.CommitTxn()
4. Дедупликация на уровне БД
CREATE TABLE processed_events (
event_id VARCHAR(128) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- При обработке:
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- Если вставка не удалась (конфликт) — событие уже обработано
Сравнение гарантий:
┌─────────────────┬──────────────┬──────────────┬──────────────────┐
│ Гарантия │ Потери │ Дубликаты │ Производительность│
├─────────────────┼──────────────┼──────────────┼──────────────────┤
│ At-Most-Once │ Возможны │ Невозможны │ Максимальная │
│ At-Least-Once │ Невозможны │ Возможны │ Высокая │
│ Exactly-Once │ Невозможны │ Невозможны │ Ниже │
└─────────────────┴──────────────┴──────────────┴──────────────────┘
Практическая рекомендация:
В 90% случаев используется at-least-once + идемпотентная обработка. Это даёт хороший баланс между надёжностью и производительностью. Exactly-once через транзакционный API Kafka используется реально редко из-за сложности и накладных расходов.
Итого: понимание гарантий доставки и причин дублирования — обязательное знание для работы с Kafka. Дублирование — это не баг, а неизбежное следствие распределённых систем. Правильный подход — не пытаться предотвратить дубликаты, а делать обработку идемпотентной.
Вопрос 19. Стек технологий для мониторинга: метрики, логи, трейсы
Таймкод: 01:02:09
Ответ собеседника: неполный. Для логов — библиотека для записи в файл, отдельный сервис читает и отправляет в ELK. Для метрик — подсчёт количества входящих запросов. Для трейсов не работал. Назвал Jaeger. Не назвал Prometheus, Grafana.
Правильный ответ:
Кандидат имеет базовое представление о логировании, но слабо знаком с метриками и не работал с трейсами. Вот полный ответ по стеку мониторинга:
Три столпа наблюдаемости (Three Pillars of Observability):
1. Метрики (Metrics)
Числовые показатели, собираемые во времени. Отвечают на вопрос «что происходит?».
Основные типы метрик:
- Counter: монотонно возрастающий счётчик (количество запросов, ошибок)
- Gauge: значение в момент времени (использование памяти, количество горутин, длина очереди)
- Histogram: распределение значений (латентность запросов, размер ответов)
- Summary: похож на histogram, но вычисляет квантили на стороне клиента
Prometheus — стандарт сбора метрик:
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "endpoint", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets, // 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
},
[]string{"method", "endpoint"},
)
activeConnections = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "active_connections",
Help: "Number of active connections",
},
)
)
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
activeConnections.Inc()
defer activeConnections.Dec()
// Оборачиваем ResponseWriter для перехвата статуса
wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(wrapped, r)
duration := time.Since(start).Seconds()
httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(wrapped.statusCode)).Inc()
httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}
// Экспорт метрик
http.Handle("/metrics", promhttp.Handler())
Grafana — визуализация метрик:
Grafana подключается к Prometheus (и другим источникам) и строит дашборды:
- Графики запросов в секунду (RPS)
- Перцентили латентности (p50, p95, p99)
- Уровень ошибок (error rate)
- Использование ресурсов (CPU, память, диск)
RED-метрики (для сервисов):
- Rate — количество запросов в секунду
- Errors — количество/процент ошибок
- Duration — время обработки (латентность)
USE-метрики (для ресурсов):
- Utilization — процент использования (CPU, диск, сеть)
- Saturation — степень перегрузки (длина очереди, wait time)
- Errors — количество ошибок (ошибки диска, сетевые ошибки)
2. Логи (Logs)
Дискретные события с временной меткой. Отвечают на вопрос «почему это произошло?».
Структурированное логирование в Go:
import "log/slog"
// Структурированные логи — стандартная библиотека Go 1.21+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
logger.Info("order processed",
"order_id", order.ID,
"user_id", order.UserID,
"amount", order.Total,
"duration_ms", duration.Milliseconds(),
)
// Вывод: {"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"order processed","order_id":"123","user_id":"456","amount":1000,"duration_ms":45}
ELK-стек (Elasticsearch + Logstash + Kibana):
Application → Filebeat/Fluentd → Logstash → Elasticsearch → Kibana
- Elasticsearch — хранение и полнотекстовый поиск логов
- Logstash/Fluentd — сбор, фильтрация и трансформация логов
- Kibana — визуализация и поиск по логам
- Filebeat — агент для чтения лог-файлов и отправки
Альтернативы ELK:
- Grafana Loki — легковесная альтернатива, индексирует только лейблы, не полный текст
- Datadog — коммерческое решение
- Graylog — open-source альтернатива
Уровни логирования:
DEBUG — детальная отладочная информация
INFO — ключевые события (запрос обработан, сервис запущен)
WARN — потенциальные проблемы (retry, медленный ответ)
ERROR — ошибки, требующие внимания
FATAL — критические ошибки, сервис не может продолжать работу
3. Трейсы (Traces)
Отслеживание запроса через все сервисы системы. Отвечают на вопрос «где именно проблема?».
Распределённая трассировка (Distributed Tracing):
Request → Service A → Service B → Database
(span 1) (span 2) (span 3)
└──────────┴──────────┴──────────┘
Trace
OpenTelemetry — стандарт инструментации:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func initTracer() (*sdktrace.TracerProvider, error) {
exporter, err := jaeger.New(jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint("http://jaeger:14268/api/traces"),
))
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-service"),
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}
func (s *Service) ProcessOrder(ctx context.Context, req OrderRequest) error {
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End()
// Добавляем атрибуты к спану
span.SetAttributes(
attribute.String("order.id", req.OrderID),
attribute.String("user.id", req.UserID),
)
// Вызов другого сервиса — контекст трассировки передаётся автоматически
paymentCtx, paymentSpan := tracer.Start(ctx, "ChargePayment")
err := s.paymentClient.Charge(paymentCtx, req)
paymentSpan.End()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "payment failed")
return err
}
return nil
}
Инструменты для трейсов:
- Jaeger — распределённая трассировка от Uber, теперь CNCF
- Zipkin — аналог от Twitter
- Grafana Tempo — легковесная альтернатива, интегрирована с Grafana
- Datadog APM — коммерческое решение
Связь между тремя столпами:
Метрики: "Error rate вырос до 5%"
↓ находим аномалию
Логи: "Connection refused to payment-service at 10:30:15"
↓ находим конкретную ошибку
Трейсы: "Span ChargePayment занимает 30s вместо 200ms"
↓ находим конкретное место проблемы
Корреляция через trace_id:
// В логах добавляем trace_id для связи с трассировкой
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
span := trace.SpanFromContext(r.Context())
traceID := span.SpanContext().TraceID().String()
logger := slog.With("trace_id", traceID)
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Алертинг:
На основе метрик настраиваются алерты в Alertmanager (для Prometheus) или в Grafana:
# alertmanager правило
groups:
- name: order-service
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "Error rate > 5% for 2 minutes"
Итого: наблюдаемость (observability) — это способность понимать внутреннее состояние системы по её внешним выходным данным. Метрики, логи и трейсы дополняют друг друга и вместе дают полную картину происходящего. Для Go-разработчика важно уметь инструментировать код с помощью Prometheus client, писать структурированные логи и использовать OpenTelemetry для трассировки.
Вопрос 20. Плюсы и минусы Go, что бы улучшил в языке?
Таймкод: 01:05:54
Ответ собеседника: неполный. Плюсы: многопоточность с самого начала (горутины), отсутствие наследования — всё через интерфейсы. Минусы: неудобная работа с переменными в циклах (до версии 1.22 нужно было писать v := v). Не назвал другие минусы: отсутствие исключений, дженериков (до 1.18), enum.
Правильный ответ:
Кандидат назвал несколько плюсов и один минус, но картина неполная. Вот систематизированный обзор:
Плюсы Go:
1. Простота и читаемость
Минималистичный синтаксис — 25 ключевых слов, одна конвенция форматирования (gofmt). Код читается одинаково независимо от автора. gofmt устраняет споры о стиле кода.
2. Быстрая компиляция
Go компилируется за секунды даже для больших проектов. Это достигается за счёт дерева зависимостей пакетов и параллельной компиляции.
3. Горутины и каналы
Встроенная поддержка конкурентности с легковесными горутинами (стек начинается с 2KB) и каналами для коммуникации:
func processItems(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(i int, item Item) {
defer wg.Done()
results[i] = process(item)
}(i, item)
}
wg.Wait()
return results
}
4. Статическая типизация и компиляция в бинарный файл
Один бинарный файл без зависимостей — идеально для контейнеров и деплоя. Компиляция ловит ошибки на этапе сборки.
5. Отличная стандартная библиотека
HTTP-сервер, JSON, работа с файлами, криптография — всё из коробки. Не нужны фреймворки для простых задач.
6. Garbage Collector
Автоматическое управление памятью с низкой задержкой (STW < 100μs в современных версиях).
7. Кросс-компиляция
GOOS=linux GOARCH=amd64 go build -o myapp-linux
GOOS=windows GOARCH=amd64 go build -o myapp.exe
8. Отличный тулинг
go test, go vet, go fmt, go mod, go doc, pprof — всё встроено в компилятор.
9. Композиция вместо наследования
Встраивание (embedding) и интерфейсы поощряют композицию:
type Logger struct {
*slog.Logger // встраивание
}
func (l *Logger) LogOrder(orderID string) {
l.Info("order processed", "order_id", orderID)
}
10. Сильное сообщество и экосистема
Kubernetes, Docker, Prometheus, Terraform, CockroachDB — написаны на Go.
Минусы Go:
1. Обработка ошибок через if err != nil
Повторяющийся шаблон, засоряющий код:
result, err := doSomething()
if err != nil {
return err
}
result2, err := doSomethingElse()
if err != nil {
return err
}
// Или с дженериками (Go 1.23+):
result, err := doSomething()
if err != nil {
return fmt.Errorf("do something: %w", err)
}
Go 2 может добав
Вопрос 21. Планировщик Go: GMP-модель, очереди, work stealing, роль P
Таймкод: 01:07:23
Ответ собеседника: неполный. Описал GMP-модель: G — горутина, M — поток ОС, P — процессор. Есть глобальная и локальные очереди. Каждый 61-й тик процессор ходит в глобальную очередь. При work stealing — если локальная очередь пуста, обходит другие локальные очереди, затем глобальную. Не смог объяснить подробнее, что такое P. Не смог решить проблему блокировки потоков при сетевых запросах (syscall).
Правильный ответ:
Кандидат дал базовое описание GMP, но не раскрыл роль P и не объяснил механизм работы с блокирующими вызовами. Вот полный и детальный ответ:
GMP-модель — три ключевых компонента:
G (Goroutine) — легковесная корутина. Структура содержит стек (начинается с 2KB, растёт динамически), состояние, указатель на M, контекст для планировщика. Горутина — это не поток ОС, а сущность, которую планировщик Go размещает на потоках.
M (Machine / OS Thread) — поток операционной системы. Реальный поток, который выполняет код. По умолчанию максимум 10000 потоков (можно настроить через runtime/debug.SetMaxThreads).
P (Processor / Процессор) — виртуальный процессор, контекст выполнения. Это самый важный и наименее понятный компонент.
Что такое P и зачем он нужен:
P — это не физический процессор и не ядро CPU. Это логическая сущность, которая владеет ресурсами, необходимыми для выполнения горутин:
- Локальная очередь горутин (runqueue): до 256 горутин в очереди
- Кэш аллокатора (mcache): локальный кэш для маленьких аллокаций, без блокировок
- Контекст планирования: состояние, необходимое для принятия решений о планировании
Количество P по умолчанию равно количеству ядер CPU (runtime.GOMAXPROCS). Это ключевой параметр — он определяет, сколько горутин может выполняться параллельно в один момент времени.
┌─────────────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ P0 │ │ P1 │ │ P2 │ ... │
│ │ │ │ │ │ │ │
│ │ Local │ │ Local │ │ Local │ │
│ │ Queue │ │ Queue │ │ Queue │ │
│ │ [G,G,G] │ │ [G,G] │ │ [G,G,G,G]│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐ │
│ │ M0 │ │ M1 │ │ M2 │ │
│ │ OS Thread│ │ OS Thread│ │ OS Thread│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Global Runqueue │ │
│ │ [G, G, G, G, G, G, G, G, ...] │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Зачем P нужен — ключевая идея:
P разделяет понятия «конкурентность» и «параллелизм»:
- Конкурентность: тысячи горутин существуют одновременно
- Параллелизм: только GOMAXPROCS горутин выполняются реально параллельно
P позволяет планировщику работать без глобальных блокировок. Каждый P имеет свою локальную очередь, и горутины из неё выполняются без синхронизации с другими P. Это даёт масштабируемость — нет конкуренции за глобальную очередь.
Как работает планирование:
1. Локальная очередь (первый приоритет)
Когда M привязан к P, он сначала берёт горутины из локальной очереди P. Это быстро — нет блокировок.
2. Глобальная очередь (периодически)
Каждые 61 тик планировщика (примерно каждые 61 планирование), P берёт горутину из глобальной очереди. Это предотвращает «голодание» горутин в глобальной очереди.
3. Work Stealing (кража работы)
Если локальная очередь P пуста, M выполняет work stealing:
P0 (пустая очередь):
1. Проверяет глобальную очередь → пусто
2. Крадёт половину из локальной очереди случайного P
(например, у P3 было 8 горутин → P0 забирает 4)
3. Если и там пусто → M засыпает
Work stealing обеспечивает балансировку нагрузки без централизованного координатора.
// Упрощённая логика work stealing
func stealWork(p *p) *g {
// 1. Проверяем глобальную очередь
if gp := globrunqget(p, 1); gp != nil {
return gp
}
// 2. Пытаемся украсть у других P
for i := 0; i < 4; i++ {
// Случайный выбор жертвы
victim := allp[fastrand() % runtime.GOMAXPROCS(0)]
if victim == p {
continue
}
// Крадём половину из хвоста очереди
if gp := runqsteal(p, victim, victim.runqtail/2); gp != nil {
return gp
}
}
return nil
}
Проблема блокирующих syscall и её решение:
Проблема: Когда горутина делает блокирующий syscall (чтение файла, сетевой запрос), M (поток ОС) блокируется. Если M заблокирован, он не может выполнять другие горутины из очереди P.
Решение — разделение M и P:
До блокировки:
P0 → M0 → G1 (делает syscall)
При блокирующем syscall:
P0 отсоединяется от M0
P0 присоединяется к другому M (или создаётся новый M)
P0 продолжает выполнять горутины из локальной очереди
M0 остаётся заблокированным с G1
Когда syscall завершился:
G1 пытается вернуть себе P
→ Если P0 свободен — G1 получает его обратно
→ Если P0 занят — G1 идёт в глобальную очередь
→ M0 засыпает (или уничтожается, если спящих M слишком много)
До syscall: После syscall:
┌────┐ ┌────┐ ┌────┐ ┌────┐
│ P0 │──→│ M0 │──→ G1(syscall) │ P0 │──→│ M1 │──→ G2
└────┘ └────┘ └────┘ └────┘
┌────┐ ┌────┐
│ G1 │──→│ M0 │ (заблокирован)
└────┘ └────┘
Сетевые вызовы — особый случай:
Сетевые вызовы в Go не блокируют M. Go использует network poller (epoll на Linux, kqueue на macOS, IOCP на Windows):
G1: http.Get("http://api.example.com")
→ Go runtime регистрирует fd в epoll
→ G1 паркуется (parked), M не блокируется
→ M берёт следующую горутину из очереди P
→ Когда данные готовы, epoll уведомляет runtime
→ G1 пробуждается и ставится в очередь
Поэтому Go может обрабатывать сотни тысяч одновременных сетевых соединений с небольшим количеством потоков ОС.
Блокирующие syscall (файловый ввод-вывод, CGO):
Здесь M действительно блокируется, и P отсоединяется. Поэтому интенсивная работа с файлами или CGO может привести к созданию большого количества потоков ОС.
Состояния горутины:
runnable ──→ running ──→ waiting (syscall, channel, mutex)
↑ │ │
│ ↓ ↓
└────── parked completed
(ready to run) (dead)
Практические следствия:
runtime.GOMAXPROCS(0)— количество P, по умолчанию равно числу ядер- Для CPU-bound задач: GOMAXPROCS = число ядер
- Для I/O-bound задач: можно запускать тысячи горутин — network poller справится
- Блокирующие операции (файлы, CGO) создают дополнительные потоки ОС — следить за
runtime.NumGoroutine()и количеством потоков
Итого: P — это ключевой компонент, который позволяет планировщику Go масштабироваться без глобальных блокировок. Понимание GMP-модели критически важно для написания высокопроизводительных конкурентных программ на Go.
Вопрос 22. Network Poller в Go — как решается проблема блокировки при сетевых запросах
Таймкод: 01:11:31
Ответ собеседника: неполный. Предложил изолировать горутины с сетевыми запросами в отдельные потоки. Не знал о Network Poller. Интервьюер объяснил: горутина с сетевым запросом помещается в Network Poller, который периодически проверяет ответ. Когда ответ приходит, горутина возвращается в очередь.
Правильный ответ:
Кандидат не знал о Network Poller — одном из ключевых механизмов Go runtime. Вот полный и детальный ответ:
Проблема:
Сетевые операции (HTTP-запросы, чтение из сокета, DNS-резолвинг) — это по сути системные вызовы (syscall). В большинстве языков поток блокируется на время ожидания ответа от сети. Если бы Go работал так же, то каждая горутина с сетевым запросом блокировала бы поток ОС, и для 100K одновременных соединений потребовалось бы 100K потоков.
Решение — Network Poller:
Network Poller — это компонент Go runtime, который использует механизм мультиплексирования ввода-вывода операционной системы:
- Linux: epoll
- macOS/BSD: kqueue
- Windows: IOCP (I/O Completion Ports)
Как это работает:
Шаг 1: Горутина делает сетевой запрос
G1: conn.Read(buf)
↓
Go runtime видит: данные ещё не готовы
↓
Регистрирует файловый дескриптор (fd) в epoll/kqueue
↓
G1 паркуется (parked) — переходит в состояние waiting
↓
M (поток ОС) НЕ блокируется — берёт следующую горутину из очереди P
Шаг 2: Данные приходят по сети
Сетевой контроллер получает пакет
↓
Ядро ОС уведомляет epoll/kqueue
↓
Network Poller (отдельный поток) получает уведомление
↓
G1 переводится в состояние runnable
↓
G1 ставится в локальную очередь P (или глобальную)
Шаг 3: Горутина возобновляется
M берёт G1 из очереди
↓
G1 продолжает выполнение — данные уже в ядре
Визуализация:
┌─────────────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌────┐ ┌────┐ │
│ │ P0 │──→│ M0 │──→ G2 (CPU work) │
│ └────┘ └────┘ │
│ │
│ ┌────┐ ┌────┐ │
│ │ P1 │──→│ M1 │──→ G3 (CPU work) │
│ └────┘ └────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Network Poller │ │
│ │ (отдельный поток, вызывает epoll_wait) │ │
│ │ │ │
│ │ fd: conn1 → G1 (waiting) │ │
│ │ fd: conn2 → G4 (waiting) │ │
│ │ fd: conn3 → G5 (waiting) │ │
│ │ ... │ │
│ │ fd: connN → GN (waiting) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Ключевые моменты:
- Горутина не блокирует поток. Когда данные не готовы, горутина паркуется, а поток продолжает работать с другими горутинами
- Network Poller — это отдельный поток, который вызывает
epoll_wait()и ждёт событий от ядра - Количество потоков не зависит от количества сетевых соединений. 100K соединений обрабатываются одним Network Poller'ом и небольшим количеством M
Почему это работает быстро:
// Go может легко обрабатывать сотни тысяч соединений
func handleConnections(listener net.Listener) {
for {
conn, _ := listener.Accept()
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 4096)
for {
n, err := c.Read(buf) // ← здесь горутина паркуется, М не блокируется
if err != nil {
return
}
process(buf[:n])
}
}(conn)
}
}
Разница между сетевыми и файловыми syscall:
- Сетевые операции: Go использует Network Poller — M не блокируется
- Файловые операции: На Linux файловый ввод-вывод блокирующий (несмотря на наличие AIO, он ограничен). Поэтому чтение/запись файлов блокирует M, и P отсоединяется
// Сетевой вызов — M НЕ блокируется
resp, _ := http.Get("http://api.example.com") // ← Network Poller
// Файловый вызов — M БЛОКИРУЕТСЯ
data, _ := os.ReadFile("/tmp/data.bin") // ← блокирующий syscall, P отсоединяется
Реализация Network Poller (упрощённо):
// Внутри runtime/netpoll.go (упрощённо)
func netpoll(block bool) gList {
// Вызов epoll_wait (Linux) или kqueue (macOS)
var timeout int32
if !block {
timeout = 0 // неблокирующий вызов
} else {
timeout = -1 // блокирующий до появления события
}
n, err := epollwait(epfd, &events[0], int32(len(events)), timeout)
var toRun gList
for i := 0; i < int(n); i++ {
fd := events[i].fd
// Находим горутину, которая ждёт этот fd
gp := findGoroutineByFD(fd)
// Добавляем в список для выполнения
toRun.push(gp)
}
return toRun
}
Практические следствия:
- Go идеален для сетевых приложений: веб-серверы, API-шлюзы, прокси, микросервисы
- Один процесс Go может обрабатывать сотни тысяч одновременных соединений
- Для файловых операций лучше использовать буферизованный I/O или выносить в отдельные пулы горутин
runtime.NumGoroutine()может показывать тысячи горутин — это нормально, большинство из них паркованы в Network Poller
Итого: Network Poller — это то, что делает Go таким эффективным для сетевых приложений. Благодаря ему горутины с сетевыми запросами не блокируют потоки ОС, что позволяет обрабатывать огромное количество одновременных соединений с минимальными накладными расходами.
Вопрос 23. Слайсы и массивы в Go: устройство под капотом, передача в функции
Таймкод: 01:16:20
Ответ собеседника: неполный. Массив — неизменяемая структура фиксированного размера. Слайс — надстройка с указателем на базовый массив, длиной и ёмкостью. При заполнении ёмкости создаётся новый массив (примерно в 2 раза до ~256 элементов). При передаче в функцию копируется заголовок, но ссылка на тот же массив. Изменение элемента без выхода за capacity видно в исходном слайсе. Добавление с выходом за capacity создаёт новый массив. Не упомянул, что длина исходного слайса не изменится при добавлении.
Правильный ответ:
Кандидат дал хорошее описание, но не упомянул важный нюанс с изменением длины. Вот полный и детальный ответ:
Массив (Array):
Массив в Go — это значение фиксированной длины. При присваивании или передаче в функцию копируется весь массив.
var a [5]int // массив из 5 нулей
b := [3]int{1, 2, 3} // литерал массива
c := b // полная копия! c — независимый массив
c[0] = 999 // b[0] всё ещё 1
// Размер — часть типа
func process(a [5]int) {} // принимает ТОЛЬКО [5]int
// process(b) // ошибка компиляции: [3]int != [5]int
Слайс (Slice):
Слайс — это структура-заголовок (slice header), которая содержит три поля:
type slice struct {
ptr unsafe.Pointer // указатель на первый элемент базового массива
len int // длина (количество доступных элементов)
cap int // ёмкость (размер базового массива)
}
Slice header (24 байта на 64-bit):
┌──────────┬───────┬───────┐
│ ptr │ len │ cap │
│ 8 bytes │ 8 b. │ 8 b. │
└──────────┴───────┴───────┘
│
↓
┌───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ │ │ ← базовый массив (capacity = 7)
└───┴───┴───┴───┴───┴───┴───┘
↑ ↑
ptr ptr+len (len = 5)
Создание слайсов:
// Из массива
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4] // [2, 3, 4], len=3, cap=4
// Литерал
s := []int{1, 2, 3} // len=3, cap=3
// make
s := make([]int, 5) // len=5, cap=5, заполнен нулями
s := make([]int, 3, 10) // len=3, cap=10
// Из другого слайса
original := []int{1, 2, 3, 4, 5}
sub := original[1:3] // [2, 3], len=2, cap=4
// sub и original указывают на один базовый массив!
Что происходит при append — стратегия роста:
s := make([]int, 0, 3) // len=0, cap=3
s = append(s, 1) // len=1, cap=3
s = append(s, 2) // len=2, cap=3
s = append(s, 3) // len=3, cap=3
s = append(s, 4) // len=4, cap=6 ← новый массив!
// Стратегия роста (Go 1.18+):
// - Для маленьких слайсов (< 256 элементов): удвоение capacity
// - Для больших слайсов (>= 256): рост на ~25% + немного больше
// Точная формула: зависит от размера элемента и текущей capacity
Передача слайса в функцию — ключевые сценарии:
Сценарий 1: Изменение существующего элемента — видно вызывающему
func modifyElement(s []int) {
s[0] = 999 // изменяем базовый массив напрямую
}
func main() {
s := []int{1, 2, 3}
modifyElement(s)
fmt.Println(s) // [999, 2, 3] — изменено!
}
Заголовок слайса копируется, но ptr указывает на тот же базовый массив. Изменение элементов видно обоим.
Сценарий 2: Append без выхода за capacity — видно вызывающему
func appendWithinCap(s []int) {
s[0] = 100 // видно вызывающему
s = append(s, 99) // len увеличивается, но cap позволяет
s[1] = 200 // тоже видно — тот же базовый массив
}
func main() {
s := make([]int, 2, 5) // [0, 0], len=2, cap=5
appendWithinCap(s)
fmt.Println(s) // [100, 200] — элементы изменены!
// НО: добавленный элемент 99 НЕ виден — len не изменился
}
Сценарий 3: Append с выходом за capacity — НЕ видно вызывающему
func appendNewArray(s []int) {
s = append(s, 4, 5, 6) // capacity исчерпан → новый массив
s[0] = 999 // изменяем НОВЫЙ массив
fmt.Println("inside:", s) // [999, 2, 3, 4, 5, 6]
}
func main() {
s := []int{1, 2, 3} // len=3, cap=3
appendNewArray(s)
fmt.Println("outside:", s) // [1, 2, 3] — НЕ изменился!
}
До append (внутри функции):
s (копия) → [1, 2, 3] ← оригинальный массив
После append (capacity исчерпан):
s (копия) → [1, 2, 3, 4, 5, 6] ← НОВЫЙ массив
оригинальный s → [1, 2, 3] ← старый массив, не изменился
Сценарий 4: Как правильно вернуть изменённый слайс
// Вариант 1: Вернуть новый слайс
func appendCorrect(s []int) []int {
return append(s, 4, 5, 6)
}
func main() {
s := []int{1, 2, 3}
s = appendCorrect(s) // обязательно присвоить результат!
fmt.Println(s) // [1, 2, 3, 4, 5, 6]
}
// Вариант 2: Передать указатель на слайс
func appendViaPointer(s *[]int) {
*s = append(*s, 4, 5, 6)
}
func main() {
s := []int{1, 2, 3}
appendViaPointer(&s)
fmt.Println(s) // [1, 2, 3, 4, 5, 6]
}
Подводные камни:
1. Разделяемая память при сабслайсах:
original := []int{1, 2, 3, 4, 5}
sub := original[1:3] // [2, 3], cap=4
sub = append(sub, 99) // cap позволяет, перезаписывает original[3]
fmt.Println(original) // [1, 2, 3, 99, 5] — сюрприз!
2. Утечка памяти при сабслайсах больших массивов:
func processFirstThree(huge []int) []int {
return huge[:3] // слайс из 3 элементов, но cap = len(huge)!
// весь huge массив остаётся в памяти, не может быть собран GC
}
// Правильно — скопировать:
func processFirstThreeSafe(huge []int) []int {
result := make([]int, 3)
copy(result, huge[:3])
return result // оригинальный массив может быть собран GC
}
3. Проверка на nil vs пустой слайс:
var s1 []int // nil слайс, len=0, cap=0
s2 := []int{} // пустой слайс, len=0, cap=0, но не nil!
s3 := make([]int, 0) // пустой слайс, len=0, cap=0, не nil
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false
// Все три ведут себя одинаково при append и len
Итого: слайс в Go — это заголовок из 24 байт (ptr, len, cap), который копируется при передаче в функцию. Изменение элементов видно вызывающему (общий базовый массив), но изменение длины через append — нет, потому что копируется заголовок с его собственным len. При выходе за capacity создаётся новый массив, и связь с оригиналом теряется. Всегда присваивайте результат append обратно в переменную.
