Разбираю Реальное ЛАЙВКОДИНГ Собеседование Middle Go-Разработчика [Ошибки + лайфхаки]
Сегодня мы разберём реальное собеседование Go-разработчика на позицию с зарплатой 250 000 рублей в месяц. Мы увидим, как кандидат рассказывает о своём опыте работы с микросервисной архитектурой, ClickHouse, Kafka и Kubernetes, разберём его ответы на теоретические вопросы о внутреннем устройстве слайсов, мап, горутин и каналов в Go, а также проследим за ходом решения задач на кодирование и SQL-запросы. В финале вы узнаете, получил ли кандидат заветный оффер, и какие ошибки в подаче информации и решении задач могли повлиять на итоговую оценку интервьюера.
Вопрос 1. Какие самые интересные задачи приходилось решать на предыдущем месте работы?
Таймкод: 00:00:56
Ответ собеседника: Неполный. Рассказывает о разработке цепочки реактивации пользователей в приложении: пуш-уведомления, напоминания об оплате, предложение скидок — последовательность действий в течение первых 30 дней после регистрации. Результат — повышение retention примерно на 30%, рост просмотров и использования фич. Использовался стек ClickHouse и Kafka через materialized views.
Правильный ответ:
Задача построения цепочки реактивации пользователей — действительно интересный кейс. Однако ответ мог бы быть значительно глубже. Вот как можно раскрыть подобный вопрос на уровне senior разработчика:
Контекст задачи
Реактивация — это классическая задача growth engineering, где инженерная сложность скрыта за маркеттерским термином. Суть в том, что пользователь регистрируется, но не завершает ключевое действие (оплату, подписку, использование фичи), и нужно последовательно воздействовать на него через разные каналы.
Архитектурные решения
В типичной реализации цепочки реактивации используется event-driven архитектура:
-
Kafka как шину событий — все действия пользователя (регистрация, вход, отказ от оплаты) публикуются в соответствующие топики. Это обеспечивает декорреляцию между источниками событий и логикой реактивации.
-
Materialized Views в ClickHouse — предвычисленные агрегаты по пользователям (сколько дней в приложении, какие фичи использовал, когда последний раз заходил) обновляются в реальном времени через Kafka Engine → Materialized View. Это позволяет быстро определять, на каком шаге цепочки находится пользователь.
-
Состояние цепочки (state machine) — для каждого пользователя ведётся текущий шаг цепочки. Переход между шагами происходит по условиям: прошло N дней, пользователь не совершил целевое действие, не получал пуш в последние X часов.
Пример управления состоянием цепочки:
type ReactivationStep struct {
UserID string `json:"user_id"`
Step int `json:"step"`
StartedAt time.Time `json:"started_at"`
LastAction time.Time `json:"last_action"`
Completed bool `json:"completed"`
}
type ChainEngine struct {
steps []StepConfig
storage StateStorage
notifier NotificationService
}
func (e *ChainEngine) ProcessEvent(ctx context.Context, event UserEvent) error {
state, err := e.storage.GetState(ctx, event.UserID)
if err != nil {
return fmt.Errorf("get state: %w", err)
}
// Если пользователь совершил целевое действие — завершаем цепочку
if event.Type == TargetAction {
return e.storage.MarkCompleted(ctx, event.UserID)
}
// Определяем следующий шаг по времени и условиям
nextStep := e.determineNextStep(state, event)
if nextStep != nil {
return e.executeStep(ctx, event.UserID, nextStep)
}
return nil
}
Типичные сложности и их решения
- Идемпотентность отправки пушей — при ретраях Kafka consumer может обработать событие дважды. Нужна дедупликация на стороне отправителя, например через Redis SETNX с TTL по message_id.
- Rate limiting — нельзя спамить пользователя. Нужен глобальный rate limiter, который учитывает все каналы (пуш, email, SMS) вместе.
- A/B тестирование цепочек — для каждого пользователя при входе в цепочку определяется группа (control/variant), и вся логика ветвится. Это делается через feature flag или детерминированный хеш user_id.
- Обработка временных зон — пуш в 3 часа ночи по локальному времени пользователя снижает конверсию. Нужно хранить timezone и отправлять в оптимальное окно.
Метрики и мониторинг
Помимо бизнес-метрики retention +30%, важны инженерные:
- Latency от события до пуша — p50, p95, p99
- Процент пропущенных шагов — из-за ошибок rate limiter или биллинга
- Conversion rate по каждому шагу — чтобы понимать, какой шаг цепочки неэффективен
- Cost per reactivation — стоимость пушей/SMS на одного реактивированного пользователя
Почему это интересно с инженерной точки зрения
Задача объединяет несколько сложных аспектов: event-driven архитектура, управление состоянием распределённой системы, работа с eventual consistency (materialized view обновляется не мгновенно), и всё это при высоких требованиях к надёжности — потеря шага в цепочке напрямую влияет на выручку.
Если бы кандидат дополнил ответ деталями про конкретные технические вызовы и их решения, это показало бы глубину опыта.
Вопрос 2. Как именно технически была реализована связка ClickHouse и Kafka в проекте реактивации пользователей?
Таймкод: 00:07:03
Ответ собеседника: Неполный. ClickHouse работал с Kafka через materialized views. Аналитики писали скрипты на Python для ClickHouse, запускали запросы обновления данных. После агрегации данных и записи в результирующую таблицу materialized view автоматически передавала записи в Kafka через Kafka Connect.
Правильный ответ:
Описанная кандидатом схема содержит неточности и не раскрывает реальную архитектуру интеграции. Давайте разберём, как связка ClickHouse + Kafka работает на практике.
Как на самом деле работает ClickHouse + Kafka
Есть два принципиально разных направления интеграции:
А. Kafka → ClickHouse (потребление событий)
Это основной сценарий. ClickHouse потребляет события из Kafka через движок таблиц Kafka. Materialized View используется как механизм трансформации и сохранения.
-- 1. Таблица-источник, читающая из Kafka
CREATE TABLE user_events_queue (
user_id UInt64,
event_type LowCardinality(String),
event_time DateTime,
properties String
) ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka-broker-1:9092,kafka-broker-2:9092',
kafka_topic_list = 'user-events',
kafka_group_name = 'clickhouse-consumer-group',
kafka_format = 'JSONEachRow',
kafka_num_consumers = 4;
-- 2. Целевая таблица для хранения
CREATE TABLE user_events (
user_id UInt64,
event_type LowCardinality(String),
event_time DateTime,
properties String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_id, event_time);
-- 3. Materialized View — мост между ними
CREATE MATERIALIZED VIEW user_events_mv TO user_events AS
SELECT
user_id,
event_type,
event_time,
properties
FROM user_events_queue;
Важно: Materialized View в этом случае не читает из таблицы и не пишет в Kafka — он подписывается на поток данных из Kafka Engine и записывает в target table. Это push-модель, а не pull.
Б. ClickHouse → Kafka (публикация результатов)
Если нужно отправить агрегированные данные обратно в Kafka (например, для триггера реактивации), используется отдельная таблица с движком Kafka:
-- Таблица для отправки в Kafka
CREATE TABLE reactivation_triggers (
user_id UInt64,
step UInt8,
trigger_time DateTime
) ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka-broker-1:9092',
kafka_topic_list = 'reactivation-triggers',
kafka_format = 'JSONEachRow';
-- Materialized View, который пишет в Kafka при появлении данных в агрегированной таблице
CREATE MATERIALIZED VIEW reactivation_triggers_mv TO reactivation_triggers AS
SELECT
user_id,
step,
now() AS trigger_time
FROM user_activity_summary
WHERE last_activity < now() INTERVAL 3 DAY
AND step_completed = 0;
Типичная архитектура для реактивации
[Приложение] → Kafka (user-events)
↓
ClickHouse (Kafka Engine + MV → user_events)
↓
ClickHouse (агрегирующий запрос)
↓
ClickHouse (Kafka Engine → reactivation-triggers)
↓
Go-сервис (consumer) → отправка пуша/email
Ключевые нюансы при работе с Kafka Engine в ClickHouse
- Exactly-once семантика — ClickHouse Kafka Engine гарантирует at-least-once delivery. Дедупликация должна быть на стороне потребителя или через ReplacingMergeTree.
- Офсеты — ClickHouse сам управляет офсетами для каждой партиции. При сбое потребитель перечитывает данные, что может привести к дублированию.
- Формат данных —
JSONEachRowдля простых событий,AvroилиProtobufдля строгой схемы через Schema Registry. - Масштабирование —
kafka_num_consumersдолжен быть ≤ числа партиций топика Kafka.
Что не так в ответе кандидата
Упоминание Kafka Connect в контексте Materialized View — это путаница. Kafka Connect — это отдельный сервис для интеграции, он не является частью ClickHouse Materialized View. Materialized View в ClickHouse — это триггер на уровне движка таблицы, а не внешний коннектор.
Также упоминание Python-скриптов аналитиков для обновления данных в ClickHouse вызывает вопросы — в продакшене данные в ClickHouse обычно пишутся через Kafka Engine или прямые INSERT из сервисов, а не через ad-hoc скрипты.
Вопрос 3. Как проводились миграции в ClickHouse?
Таймкод: 00:09:16
Ответ собеседника: Неполный. Миграции в ClickHouse были не автоматизированы — запросы просто скидывались сеньору или тимлиду, который катал их вручную.
Правильный ответ:
Ответ кандидата описывает ситуацию, типичную для небольших команд или ранних стадий развития проекта, но не раскрывает ни альтернатив, ни понимания лучших практик. Для позиции senior разработчика важно знать, как делать это правильно.
Проблемы с ручными миграциями
Ручной кат миграций через сеньора — это антипаттерн, который несёт ряд рисков:
- Нет воспроизводимости — сложно воссоздать состояние базы с нуля
- Нет истории изменений — невозможно отследить, когда и зачем была добавлена колонка
- Человеческий фактор — опечатка в DDL может привести к потере данных
- Бутылочное горлышко — тимлид становится блокером для всей команды
- Нет отката — ALTER в ClickHouse в большинстве случаев необратим
Подходы к автоматизации миграций в ClickHouse
А. Встроенный механизм schema migrations в ClickHouse
ClickHouse начиная с версии 20.4+ поддерживает метаданные миграций на уровне ZooKeeper/Keeper:
CREATE TABLE IF NOT EXISTS schema_migrations (
version UInt32,
name String,
applied_at DateTime DEFAULT now()
) ENGINE = MergeTree()
ORDER BY version;
И обёртка на Go для управления:
package migrations
import (
"context"
"database/sql"
"embed"
"fmt"
"sort"
"strconv"
"strings"
)
//go:embed *.sql
var migrationsFS embed.FS
type Migrator struct {
db *sql.DB
}
func New(db *sql.DB) *Migrator {
return &Migrator{db: db}
}
func (m *Migrator) Init(ctx context.Context) error {
_, err := m.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version UInt32,
name String,
applied_at DateTime DEFAULT now()
) ENGINE = MergeTree()
ORDER BY version
`)
return err
}
func (m *Migrator) Apply(ctx context.Context) error {
entries, err := migrationsFS.ReadDir(".")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
applied, err := m.getAppliedVersions(ctx)
if err != nil {
return fmt.Errorf("get applied versions: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() {
continue
}
version, err := parseVersion(entry.Name())
if err != nil {
continue
}
if applied[version] {
continue
}
content, err := migrationsFS.ReadFile(entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
if err := m.applyMigration(ctx, version, entry.Name(), string(content)); err != nil {
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
}
return nil
}
func (m *Migrator) applyMigration(ctx context.Context, version uint32, name, sqlContent string) error {
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, sqlContent); err != nil {
return fmt.Errorf("execute migration: %w", err)
}
_, err = tx.ExecContext(ctx,
"INSERT INTO schema_migrations (version, name) VALUES (?, ?)",
version, name,
)
if err != nil {
return fmt.Errorf("record migration: %w", err)
}
return tx.Commit()
}
Файлы миграций именуются по соглашению:
001_create_user_events.sql
002_add_event_properties_index.sql
003_create_reactivation_summary.sql
Б. Использование готовых инструментов
- clickhouse-migration — специализированный инструмент для ClickHouse
- golang-migrate — универсальный инструмент с драйвером для ClickHouse
- Flyway — поддерживает ClickHouse начиная с определённых версий
- Squibble — инструмент от Яндекса, заточенный под ClickHouse
В. GitOps-подход для ClickHouse
В крупных командах миграции применяются через CI/CD:
# .github/workflows/clickhouse-migrate.yml
name: ClickHouse Migrations
on:
push:
branches: [main]
paths: ['migrations/**']
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run migrations
run: |
go run ./cmd/migrator \
--dsn "${{ secrets.CLICKHOUSE_DSN }}" \
--dir ./migrations
Особенности миграций в ClickHouse
- ALTER TABLE — бесплатная операция для добавления/удаления колонок в MergeTree (metadata-only). Это одно из главных отличий от традиционных RDBMS.
- Мутации (ALTER UPDATE/DELETE) — тяжёлые операции, которые перезаписывают данные. Их лучше избегать в продакшене.
- Переименование таблиц —
RENAME TABLEатомарная и быстрая. - Изменение ORDER BY или PARTITION BY — требует создания новой таблицы и переноса данных.
- ReplicatedMergeTree — DDL выполняется через ZooKeeper, нужно использовать
ON CLUSTER:
CREATE TABLE user_events ON CLUSTER '{cluster}' (
...
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/user_events', '{replica}')
ORDER BY (user_id, event_time);
Рекомендация
Для senior разработчика ожидается, что он не просто принимает текущее состояние процессов, а понимает, как их улучшить, и может предложить конкретное решение с кодом и инструментами.
Вопрос 4. Как осуществлялся мониторинг подов и приложений в Kubernetes?
Таймкод: 00:11:55
Ответ собеседника: Правильный. Первоначально работал с Kubernetes через терминал, затем перешёл на Lens для удобного просмотра конфигов и состояния подов. Для мониторинга использовались Prometheus и Grafana. Метрики: для базы — место и количество запросов; для Kafka — количество топиков и непрочитанных сообщений; для подов — CPU и потребление памяти. Также через Lens видно, если под постоянно перезапускается или упал с ошибкой.
Правильный ответ:
Ответ кандидата в целом правильный и покрывает базовые аспекты мониторинга. Стек Prometheus + Grafana + Lens — это стандартный и зрелый набор инструментов. Добавлю глубины по тем аспектам, которые стоит знать на уровне senior.
Метрики Kubernetes: четыре сигнала здоровья
Google в SRE-практике выделяет четыре golden signals, адаптированные для Kubernetes:
- Latency — время ответа сервиса (p50, p95, p99)
- Traffic — RPS, количество запросов
- Errors — процент ошибок (5xx, таймауты)
- Saturation — насыщение ресурсов (CPU, memory, disk I/O)
Метрики подов в детализации
Помимо CPU и memory, важные метрики, которые кандидат не упомянул:
- Restart count —
kube_pod_container_status_restarts_total— частые рестарты указывают на CrashLoopBackOff, OOMKilled или проблемы с health checks - Ready state —
kube_pod_status_ready— под может работать, но не принимать трафик - OOMKilled —
kube_pod_container_status_last_terminated_reason— причина последнего завершения - Resource requests vs actual usage — разница между
container_cpu_usage_seconds_totalиkube_pod_container_resource_requestsпоказывает, правильно ли выставлены limits
Алертинг на основе метрик
Мониторинг без алертинга — это полдела. Примеры правил алертов в Prometheus:
groups:
- name: pod-alerts
rules:
- alert: PodCrashLooping
expr: rate(kube_pod_container_status_restarts_total[15m]) * 60 * 15 > 5
for: 5m
labels:
severity: critical
annotations:
summary: "Pod {{ $labels.pod }} is crash looping"
description: "Pod {{ $labels.pod }} in namespace {{ $labels.namespace }} restarted {{ $value }} times in the last 15 minutes"
- alert: PodHighMemoryUsage
expr: |
container_memory_working_set_bytes{container!=""}
/ kube_pod_container_resource_limits{resource="memory"}
> 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.pod }} memory usage above 85%"
- alert: KafkaConsumerLagHigh
expr: kafka_consumer_group_lag_sum{group="reactivation-service"} > 10000
for: 5m
labels:
severity: critical
annotations:
summary: "Kafka consumer lag is critically high"
Health Checks в Kubernetes
Для корректного мониторинга само приложение должно корректно отвечать на probes:
// Пример health check endpoint для Go-сервиса
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Liveness: жив ли процесс
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
// Readiness: готов ли принимать трафик
if !kafkaConsumer.IsConnected() || !db.Ping() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
http.ListenAndServe(":8080", mux)
}
Соответствующий манифест:
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
Логирование и трассировка
Prometheus + Grafana покрывают metrics, но для полноценной наблюдаемости (observability) нужны три столпа:
- Metrics — Prometheus + Grafana (числовые данные)
- Logs — Loki + Grafana или ELK-стек (текстовые записи)
- Traces — Jaeger или Tempo + Grafana (распределённая трассировка)
Рекомендация для подготовки
Кандидат хорошо знает инструменты. Для senior уровня ожидается понимание того, как правильно настраивать алертинг, выставлять thresholds, различать liveness и readiness probes, и как экспортировать кастомные метрики приложения в Prometheus через /metrics endpoint с использованием библиотеки prometheus/client_golang.
Вопрос 5. Как устроены бизнес-процессы разработки в команде: как приходят задачи, как происходит ревью и тестирование?
Таймкод: 00:16:22
Ответ собеседника: Правильный. Описан полный цикл: бизнес-задача приходит от продукта, разработчик прикидывает решение, проводит техническое ревью (tech review) с командой бэкендеров, где обсуждают и дорабатывают решение. После написания кода — пайплайн с тестами (юнит-тесты, редко интеграционные). Линтеры не использовались.
Правильный ответ:
Ответ кандидата описывает рабочий процесс, но есть несколько моментов, которые стоит раскрыть глубже, и один красный флаг.
Стандартный цикл разработки
Описанный цикл — это типичный flow для зрелой команды:
Бизнес-задача → Tech Design → Tech Review → Реализация → Code Review → CI/CD → Деплой
Что можно добавить по каждому этапу
А. Tech Review (Design Review)
Это важный этап, который часто пропускают. На tech review обсуждают:
- Архитектурные решения и их trade-offs
- Влияние на существующие сервисы
- Схему данных и миграции
- Оценку рисков и rollback-план
- SLO/SLI для новой функциональности
Хорошая практика — оформлять tech design в виде Design Document (RFC) в Confluence или Notion, чтобы решение было задокументировано.
Б. Code Review
Кандидат не упомянул явно code review, хотя он является обязательным этапом. Хороший code review включает:
- Корректность логики
- Обработку ошибок
- Покрытие тестами
- Соответствие стандартам кодирования
- Потенциальные race conditions и проблемы с concurrency
В. Тестирование
Пирамида тестирования для Go-сервиса:
/ E2E \ ← мало, медленные, хрупкие
/ Integration \ ← проверяют взаимодействие компонентов
/ Unit tests \ ← много, быстрые, изолированные
Пример юнит-теста для Go-сервиса:
func TestChainEngine_ProcessEvent_TargetAction_CompletesChain(t *testing.T) {
storage := &mockStorage{
states: map[string]*ReactivationStep{
"user_1": {UserID: "user_1", Step: 2, StartedAt: time.Now()},
},
}
notifier := &mockNotifier{}
engine := NewChainEngine(storage, notifier)
event := UserEvent{
UserID: "user_1",
Type: TargetAction,
}
err := engine.ProcessEvent(context.Background(), event)
require.NoError(t, err)
assert.True(t, storage.completed["user_1"])
assert.Empty(t, notifier.sent) // не отправляем уведомление при завершении
}
Интеграционные тесты с реальной БД (testcontainers):
func TestRepository_GetUserState_Integration(t *testing.T) {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "clickhouse/clickhouse-server:23.3",
ExposedPorts: []string{"9090/tcp"},
WaitingFor: wait.ForLog("Ready for connections"),
},
Started: true,
})
require.NoError(t, t, err)
defer container.Terminate(ctx)
// ... подключение и тестирование
}
Красный флаг: отсутствие линтеров
Упоминание «линтеры не использовались» — это серьёзный пробел. Для Go стандартный набор:
- golangci-lint — мета-линтер, объединяющий десятки проверок
- go vet — встроенный статический анализатор
- staticcheck — продвинутые проверки
- gofmt / goimports — форматирование
Пример .golangci.yml:
run:
timeout: 5m
linters:
enable:
- govet
- staticcheck
- errcheck
- gosimple
- ineffassign
- unused
- gosec
- revive
linters-settings:
gosec:
excludes:
- G404 # weak random number generator — acceptable in non-crypto context
Линтеры должны запускаться в CI-пайплайне на каждый pull request. Это не опциональная практика, а базовое требование к качеству кода в любой профессиональной команде.
CI/CD пайплайн
Типичный пайплайн для Go-проекта в GitLab CI или GitHub Actions:
stages:
- lint
- test
- build
- deploy
lint:
script:
- golangci-lint run ./...
unit-tests:
script:
- go test -race -coverprofile=coverage.out ./...
integration-tests:
script:
- go test -tags=integration ./...
build:
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Рекомендация
Для senior разработчика ожидается, что он не просто следует процессам, а активно участвует в их улучшении. Отсутствие линтеров — это то, что senior должен был бы инициировать внедрение, а не просто констатировать.
Вопрос 6. Сравнение slice и map в Go: в чём различия, могут ли они уменьшаться в размере?
Таймкод: 00:20:59
Ответ собеседника: Неполный. Слайс можно уменьшить, но он всё равно ссылается на тот же массив в памяти. Мапа при удалении элементов не уменьшает количество бакетов — память не освобождается полностью. Упомянул, что в Go 1.24 появилась новая реализация map с механизмом пометки удалённых значений и последующим сжатием (resharding) при накоплении ~20% удалённых записей. Также обсуждались коллизии в map — когда два ключа дают одинаковый хэш и попадают в один бакет.
Правильный ответ:
Кандидат затронул важные аспекты, но ответ содержит фактическую неточность и не раскрывает тему полностью.
Slice: устройство и поведение при уменьшении
Slice — это структура из трёх полей: указатель на базовый массив, длина (len) и ёмкость (cap).
type slice struct {
array unsafe.Pointer
len int
cap int
}
При уменьшении через реслайсинг:
s := make([]int, 0, 1000)
s = append(s, 1, 2, 3, 4, 5)
// Уменьшаем — но базовый массив остаётся размером 1000
s = s[:2]
// Старые элементы всё ещё в памяти — GC не может их собрать
// Если нужно освободить — создаём новый слайс и копируем
func shrinkSlice(s []int) []int {
newSlice := make([]int, len(s))
copy(newSlice, s)
return newSlice
}
Важный нюанс: даже после s = s[:2], если исходный слайс имел cap=1000, базовый массив из 1000 элементов остаётся в памяти. Это классическая причина утечек памяти в Go при работе с большими слайсами.
Map: устройство и поведение при удалении
Map в Go реализована как хеш-таблица с бакетами. Каждый бакет содержит до 8 пар ключ-значение.
// Упрощённая структура (runtime/map.go)
type hmap struct {
count int
flags uint8
B uint8 // log2 количества бакетов, т.е. бакетов = 2^B
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer // используется при роста
nevacuate uintptr // прогресс эвакуации
}
Ключевой момент: при удалении элементов из map через delete(m, key):
- Количество бакетов не уменьшается
- Память под бакеты не освобождается
- Счётчик
countуменьшается, ноB(количество бакетов) остаётся прежним
Это означает, что если вы добавили 1 млн элементов в map, а затем удалили 999 тысяч, map всё ещё будет занимать память, пропорциональную ~1 млн элементов.
Фактическая неточность в ответе кандидата
Упоминание Go 1.24 и «resharding при 20% удалённых записей» — это недостоверная информация. На момент актуальных версий Go (1.22-1.23) такого механизма нет. Map не поддерживает автоматическое сжатие при удалении. Если кандидат ссылается на конкретный proposal или draft, он должен уметь это подтвердить.
Коллизии в map
Go использует метод цепочек (chaining) с бакетами. Каждый бакет вмещает 8 элементов. При переполнении создаётся overflow bucket, связанный с основным.
type bmap struct {
tophash [bucketCnt]uint8 // верхние биты хэша для быстрого поиска
// За следующим адресом в памяти идут ключи, затем значения
// В конце — указатель на overflow bucket
}
При поиске элемента:
- Вычисляется хэш ключа
- Определяется бакет по младшим битам хэша
- Поиск по tophash в бакете (верхние биты хэша)
- При совпадении tophash — полное сравнение ключа
- Если не найдено — переход в overflow bucket
Практические следствия
Если нужно освободить память, занятую map:
// Создаём новую map и копируем оставшиеся элементы
func shrinkMap(m map[string]int) map[string]int {
newMap := make(map[string]int, len(m))
for k, v := range m {
newMap[k] = v
}
return newMap
}
Сравнительная таблица
| Характеристика | Slice | Map |
|---|---|---|
| Базовая структура | Массив + len + cap | Хеш-таблица с бакетами |
| Уменьшение при удалении | Реслайсинг меняет len, cap остаётся | count уменьшается, бакеты нет |
| Освобождение памяти | Только через copy в новый slice | Только через создание новой map |
| Порядок элементов | Сохраняется | Случайный (намеренно) |
| Сложность поиска | O(n) | O(1) в среднем |
Для senior разработчика важно понимать эти нюансы, так как они напрямую влияют на производительность и потребление памяти в высоконагруженных сервисах.
Вопрос 7. Как возникают неконсистентные (race condition) состояния в Go и как от них защищаться?
Таймкод: 00:26:33
Ответ собеседника: Правильный. Гонки данных возникают при одновременном доступе нескольких горутин к одному источнику данных. Способы защиты: мьютексы (sync.Mutex), каналы (channels) для обмена данными между горутинами, атомики (sync/atomic) для лёгких операций, WaitGroup для синхронизации. Также обсуждалось, что каналы — это не только защита от гонок, но и паттерн share by communicating.
Правильный ответ:
Ответ кандидата корректный и покрывает основные механизмы. Добавлю глубины и практических нюансов, которые ожидаются от senior разработчика.
Race condition: формальное определение
Race condition возникает, когда две или более горутины одновременно обращаются к одной и той же ячейке памяти, и хотя бы одна из операций — запись. Это нарушает happens-before гарантии.
Классический пример:
var counter int
func main() {
for i := 0; i < 1000; i++ {
go func() {
counter++ // DATA RACE: чтение + запись без синхронизации
}()
}
time.Sleep(time.Second)
fmt.Println(counter) // результат < 1000, каждый раз разный
}
Операция counter++ не атомарна — она состоит из трёх шагов: прочитать, увеличить, записать. Между этими шагами другая горутина может прочитать устаревшее значение.
Инструменты обнаружения
А. Race Detector
Встроенный в Go детектор гонок — обязательный инструмент:
go run -race main.go
go test -race ./...
go build -race -o app ./cmd/app
Race detector добавляет overhead ~5-10x по времени и ~5-10x по памяти, поэтому в продакшене не используется, но в CI и тестах должен быть включён обязательно.
// Тест, который поймает гонку
func TestCounter_Concurrent(t *testing.T) {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
// Без -race этот тест может проходить случайно
// С -race он гарантированно обнаружит проблему
}
Б. Механизмы защиты в детализации
sync.Mutex и sync.RWMutex
type SafeCounter struct {
mu sync.RWMutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.count
}
RWMutex предпочтительнее, когда операций чтения значительно больше, чем записей. Множество горутин могут одновременно удерживать RLock, но Lock эксклюзивен.
sync/atomic
Для простых типов атомики быстрее мьютексов:
type AtomicCounter struct {
value atomic.Int64
}
func (c *AtomicCounter) Inc() {
c.value.Add(1)
}
func (c *AtomicCounter) Value() int64 {
return c.value.Load()
}
Атомики используют инструкции процессора (CAS — Compare-And-Swap) и не требуют блокировки. Но они ограничены простыми типами: int32, int64, uint32, uint64, uintptr, unsafe.Pointer.
Каналы: share by communicating
// Паттерн: единственная горутина владеет данными
type Counter struct {
ch chan int
}
func NewCounter() *Counter {
c := &Counter{ch: make(chan int, 1)}
c.ch <- 0
go func() {
for delta := range c.ch {
current := <-c.ch
c.ch <- current + delta
}
}()
return c
}
func (c *Counter) Inc() {
current := <-c.ch
c.ch <- current + 1
}
func (c *Counter) Value() int {
current := <-c.ch
c.ch <- current
return current
}
Более практичный вариант — worker pool с каналами:
type Cache struct {
data map[string]string
ops chan func(map[string]string)
}
func NewCache() *Cache {
c := &Cache{
data: make(map[string]string),
ops: make(chan func(map[string]string), 100),
}
go c.run()
return c
}
func (c *Cache) run() {
for op := range c.ops {
op(c.data)
}
}
func (c *Cache) Set(key, value string) {
c.ops := func(m map[string]string) {
m[key] = value
}
}
func (c *Cache) Get(key string, result chan<- string) {
c.ops := func(m map[string]string) {
result <- m[key]
}
}
sync.Once и sync.Map
// sync.Once — для ленивой инициализации
var (
once sync.Once
instance *Config
)
func GetConfig() *Config {
once.Do(func() {
instance = loadConfigFromFile()
})
return instance
}
// sync.Map — для кэшей с высокой конкуренцией
var cache sync.Map
func Get(key string) (string, bool) {
val, ok := cache.Load(key)
if !ok {
return "", false
}
return val.(string), true
}
sync.Map оптимизирован для двух случаев: когда запись происходит редко, а чтение часто, и когда разные горутины работают с непересекающимися наборами ключей. В остальных случаях map + RWMutex может быть быстрее.
Распространённые паттерны гонок
// Гонка через замыкание в цикле
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // ловит значение i из внешней области видимости
}()
}
// Правильно:
for i := 0; i < 10; i++ {
go func(val int) {
fmt.Println(val)
}(i)
}
// Гонка через возвращаемую структуру
type Result struct {
Value int
}
func process() Result {
var r Result
go func() {
r.Value = 42 // гонка: запись в одной, чтение в другой
}()
return r
}
Рекомендация для подготовки
Для senior уровня важно не только знать инструменты, но и уметь выбирать правильный для контекста: атомики для счётчики, мьютексы для структур данных, каналы для оркестрации горутин. Также критически важно включать -race в CI-пайплайне — это базовое требование к качеству кода.
Вопрос 8. Что произойдёт при конкурентном обращении к map, при записи в закрытый канал и в nil-канал?
Таймкод: 00:29:38
Ответ собеседния: Неполный. Конкурентное обращение к map вызовет панику (можно поймать через recover). Запись в закрытый канал — паника. Запись в nil-канал — кандидат сказал, что паника, но на самом деле это дедлок (горутина блокируется навсегда), хотя рантайм может перехватить это как панику.
Правильный ответ:
Кандидат частично прав, но допустил ошибку про nil-канал и не раскрыл тему полностью.
Конкурентное обращение к map
Начиная с Go 1.6, runtime активно детектирует конкурентную запись в map и вызывает панику с сообщением:
concurrent map writes
или
concurrent map read and map write
Важные нюансы:
// Это НЕ безопасно — паника не всегда возникает
// Может тихо повредить внутреннюю структуру map
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
Поймать через recover технически возможно, но после паники concurrent map writes состояние map не определено — дальнейшее использование этой map небезопасно.
// Поймать можно, но использовать map после этого нельзя
func safeMapOp(m map[string]int, key string, val int) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("map panic: %v", r)
}
}()
m[key] = val
return nil
}
Правильное решение — использовать sync.RWMutex, sync.Map или каналы для синхронизации доступа.
Запись в закрытый канал
Это всегда паника:
ch := make(chan int, 1)
close(ch)
ch <- 1 // panic: send on closed channel
Паника происходит в горутине-отправителе. Если эту панику не перехватить через recover, программа упадёт.
// Паттерн безопасной записи с защитой от паники
func safeSend(ch chan<- int, val int) (ok bool) {
defer func() {
if recover() != nil {
ok = false
}
}()
ch <- val
return true
}
Однако этот паттерн — костыль. Правильный подход — не писать в закрытый канал через корректную координацию горутин.
Чтение из закрытого канала
В отличие от записи, чтение из закрытого канала — безопасная операция:
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
val, ok := <-ch // val=1, ok=true
val, ok = <-ch // val=2, ok=true
val, ok = <-ch // val=0 (zero value), ok=false — канал закрыт и пуст
Это основа паттерна for range по каналу:
for val := range ch {
// автоматически завершится после close(ch)
process(val)
}
Запись в nil-канал
Здесь кандидат ошибся. Запись в nil-канал блокирует горутина навсегда (deadlock), а не вызывает панику.
var ch chan int // nil по умолчанию
ch <- 42 // блокировка навсегда — никто не прочитает из nil-канала
Аналогично, чтение из nil-канала тоже блокирует навсегда:
var ch chan int
val := <-ch // блокировка навсегда
Если все горутины заблокированы, Go runtime обнаружит deadlock и вызовет панику:
fatal error: all goroutines are asleep - deadlock!
Но это паника от runtime, а не от самой операции с nil-каналом.
select с nil-каналом
Полезный нюанс: select с nil-каналом просто пропускает этот case:
var chA chan int // nil
chB := make(chan int, 1)
chB <- 42
select {
case val := <-chA: // nil — этот case никогда не сработает
fmt.Println("A:", val)
case val := <-chB: // сработает
fmt.Println("B:", val)
}
Это используется как паттерн для динамического включения/выключения case в select:
func merge(chA, chB <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for chA != nil || chB != nil {
select {
case val, ok := <-chA:
if !ok {
chA = nil // отключаем этот case
continue
}
out <- val
case val, ok := <-chB:
if !ok {
chB = nil
continue
}
out <- val
}
}
}()
return out
}
Сводная таблица
| Операция | Поведение |
|---|---|
| Конкурентная запись в map | Паника concurrent map writes |
| Запись в закрытый канал | Паника send on closed channel |
| Чтение из закрытого канала | Zero value + ok=false |
| Запись в nil-канал | Блокировка навсегда |
| Чтение из nil-канала | Блокировка навсегда |
| nil-канал в select | Case игнорируется |
Для senior разработчика важно чётко различать панику и deadlock — это разные виды ошибок с разными последствиями и способами диагностики.
Вопрос 9. Дана функция printNumber, которая принимает число и спит 1 секунду. Как вывести числа от 0 до 10 параллельно с помощью горутин?
Таймкод: 00:31:56
Ответ собеседника: Правильный. Кандидат написал цикл, в котором для каждого числа запускается горутина с вызовом printNumber. Использовал WaitGroup для синхронизации завершения всех горутин перед выходом из main. Обсудили детали: переменная wg может быть вынесена за пределы цикла, передавая количество итераций один раз.
Правильный ответ:
Задача на базовом уровне, но содержит несколько подводных камней, которые стоит знать.
Базовое решение
func printNumber(n int) {
time.Sleep(1 * time.Second)
fmt.Println(n)
}
func main() {
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
printNumber(n)
}(i)
}
wg.Wait()
}
Ключевой нюанс: передача переменной цикла
Передача i как аргумента функции func(n int) обязательна. Без этого — классическая ловушка:
// НЕПРАВИЛЬНО — все горутины видят одно и то же значение i
for i := 0; i <= 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
printNumber(i) // i = 11 для всех горутин (или другое случайное значение)
}()
}
Горутины захватывают переменную i по ссылке из замыкания. К моменту их выполнения цикл уже завершился, и i = 11.
Начиная с Go 1.22, поведение изменилось — переменная цикла теперь создаётся заново на каждой итерации, как в большинстве других языков. Но на практике многие проекты используют более ранние версии, и явная передача аргумента — это хорошая привычка.
Оптимизация WaitGroup
Вместо wg.Add(1) на каждой итерации можно вызвать один раз:
func main() {
var wg sync.WaitGroup
wg.Add(11) // 0..10 включительно
for i := 0; i <= 10; i++ {
go func(n int) {
defer wg.Done()
printNumber(n)
}(i)
}
wg.Wait()
}
Это немного эффективнее, так как Add с одним вызовом дешевле, чем 11 отдельных вызовов.
Альтернативные подходы
С использованием канала:
func main() {
done := make(chan struct{}, 11)
for i := 0; i <= 10; i++ {
go func(n int) {
printNumber(n)
done <- struct{}{}
}(i)
}
for i := 0; i <= 10; i++ {
<-done
}
}
С ограничением конкурентности (worker pool):
func main() {
const workers = 4
jobs := make(chan int, 11)
var wg sync.WaitGroup
// Запускаем фиксированное количество воркеров
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs {
printNumber(n)
}
}()
}
// Отправляем задачи
for i := 0; i <= 10; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
}
С errgroup (golang.org/x/sync/errgroup):
import "golang.org/x/sync/errgroup"
func main() {
g := new(errgroup.Group)
for i := 0; i <= 10; i++ {
n := i
g.Go(func() error {
printNumber(n)
return nil
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}
}
errgroup удобнее, чем WaitGroup, когда горутины могут возвращать ошибки — он автоматически собирает первую ошибку и отменяет контекст остальным горутинам.
Что проверяет эта задача
Несмотря на простоту, задача проверяет:
- Понимание замыканий и захвата переменных
- Знание
WaitGroupкак базового примитива синхронизации - Понимание разницы между параллельным запуском и контролем конкурентности
- Осведомлённость о паттернах worker pool и errgroup
Для senior разработчика ожидается, что он не только напишет базовое решение, но и предложит варианты с ограничением конкурентности, обработкой ошибок и отменой через контекст.
Вопрос 10. Как ограничить количество одновременно работающих горутин до трёх при параллельном выводе чисел от 0 до 10?
Таймкод: 00:39:04
Ответ собеседника: Правильный. Кандидат предложил использовать буферизированный канал (chan struct{}) с размером буфера 3 как семафор. Перед запуском горутины записывать значение в канал (занимая слот), после завершения горутины — читать из канала (освобождая слот). Также упомянул worker pool как альтернативный подход. Использование struct{} в качестве типа канала для экономии памяти (0 байт).
Правильный ответ:
Ответ кандидата полностью корректный. Это один из самых элегантных и идиоматичных паттернов в Go. Добавлю деталей и альтернатив.
Семафор на буферизированном канале
func main() {
const maxConcurrent = 3
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
sem <- struct{}{} // занимаем слот (блокирует, если все 3 заняты)
go func(n int) {
defer wg.Done()
defer func() { <-sem }() // освобождаем слот по завершении
printNumber(n)
}(i)
}
wg.Wait()
}
Почему struct{} вместо bool или int
struct{} — это zero-size type в Go. Компилятор не выделяет память под значение struct{}{}. Это подтверждается проверкой:
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
fmt.Println(unsafe.Sizeof(true)) // 1
fmt.Println(unsafe.Sizeof(0)) // 8
Для семафора, где значение не важно, struct{} — правильный выбор.
Worker pool как альтернатива
func main() {
const workers = 3
jobs := make(chan int, 11)
var wg sync.WaitGroup
// Фиксированный пул воркеров
for w := 0; w < workers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for n := range jobs {
fmt.Printf("worker %d: ", id)
printNumber(n)
}
}(w)
}
// Отправляем задачи
for i := 0; i <= 10; i++ {
jobs <- i
}
close(jobs) // сигнал воркерам завершиться
wg.Wait()
}
Сравнение двух подходов
| Критерий | Семафор (buffered channel) | Worker pool |
|---|---|---|
| Количество горутин | До N (по числу задач) | Фиксированное (3) |
| Память | Выше (11 горутин) | Ниже (3 горутины) |
| Контроль конкурентности | Да | Да |
| Простота | Проще | Чуть сложнее |
| Когда лучше | Лёгкие задачи, много запросов | Тяжёлые задачи, экономия ресурсов |
Универсальная обёртка: semaphore пакет
В golang.org/x/sync/semaphore есть взвешенный семафор:
import "golang.org/x/sync/semaphore"
func main() {
sem := semaphore.NewWeighted(3)
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
if err := sem.Acquire(ctx, 1); err != nil {
log.Fatal(err)
}
go func(n int) {
defer wg.Done()
defer sem.Release(1)
printNumber(n)
}(i)
}
wg.Wait()
}
Преимущество WeightedSemaphore — поддержка контекста с таймаутом и отменой:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sem.Acquire(ctx, 1); err != nil {
// timeout или cancel — не запускаем горутину
log.Printf("skipped task %d: %v", i, err)
wg.Done()
continue
}
Патtern с ограничением скорости (rate limiting)
Если задача — не ограничить параллелизм, а ограничить скорость выполнений в секунду:
import "golang.org/x/time/rate"
func main() {
limiter := rate.NewLimiter(3, 1) // 3 события в секунду, burst=1
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
if err := limiter.Wait(context.Background()); err != nil {
log.Fatal(err)
}
go func(n int) {
defer wg.Done()
printNumber(n)
}(i)
}
wg.Wait()
}
Рекомендация
Для senior разработчика ожидается знание обоих подходов (семафор и worker pool) и понимание, когда какой предпочтительнее. Семафор — для динамического ограничения, worker pool — когда нужно контролировать общее количество горутин. Также важно знать про errgroup.WithContext и semaphore.Weighted из расширенной стандартной библиотеки.
Вопрос 11. Где правильно разместить recover для перехвата паники в функции printNumber, которую нельзя изменять?
Таймкод: 00:47:27
Ответ собеседника: Неполный. Кандидат предложил разместить defer с recover внутри printNumber, но ему сказали, что функцию изменять нельзя. Затем он перенёс отлов паники на самый верхний уровень — в main с помощью defer recover. Обсуждалось, что паника раскручивает стектрейс и recover должен быть в том же стеке вызовов, где может возникнуть паника.
Правильный ответ:
Кандидат не до конца понял ключевой принцип работы recover в Go.
Фундаментальное правило
recover работает только внутри той же горутины, где произошла паника, и только если вызван через defer. Паника не «всплывает» вверх по стеку вызовов между горутинами — она крашит конкретную горутину.
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered in main:", r)
}
}()
// Паника в main горутине — recover поймает
panic("boom") // это поймается
// Паника в другой горутине — recover в main НЕ поймает
go func() {
panic("boom from goroutine") // это крашнет всю программу
}()
}
Правильное решение: recover в обёртке горутины
Поскольку printNumber нельзя менять, нужно обернуть её вызов:
func main() {
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
// recover должен быть в той же горутине, где вызывается printNumber
defer func() {
if r := recover(); r != nil {
fmt.Printf("panic in printNumber(%d): %v\n", n, r)
}
}()
printNumber(n)
}(i)
}
wg.Wait()
}
Универсальная обёртка: panic-safe горутина
type PanicHandler func(r any, stack []byte)
func GoSafely(handler PanicHandler, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
stack := make([]byte, 4096)
n := runtime.Stack(stack, false)
handler(r, stack[:n])
}
}()
fn()
}()
}
// Использование
func main() {
var wg sync.WaitGroup
for i := 0; i <= 10; i++ {
wg.Add(1)
n := i
GoSafely(func(r any, stack []byte) {
defer wg.Done()
fmt.Printf("panic in task %d: %v\n%s\n", n, r, stack)
}, func() {
defer wg.Done()
printNumber(n)
})
}
wg.Wait()
}
Паттерн с каналом ошибок
type Result struct {
Value int
Err error
}
func main() {
results := make(chan Result, 11)
for i := 0; i <= 10; i++ {
go func(n int) {
defer func() {
if r := recover(); r != nil {
results <- Result{
Err: fmt.Errorf("panic: %v", r),
}
}
}()
printNumber(n)
results <- Result{Value: n}
}(i)
}
for i := 0; i <= 10; i++ {
res := <-results
if res.Err != nil {
fmt.Printf("error: %v\n", res.Err)
} else {
fmt.Printf("success: %d\n", res.Value)
}
}
}
Почему recover в main не работает
Горутина main: defer recover() ← ловит паники ТОЛЬКО отсюда
↓
запуск горутины 1 — паника здесь не поймана recover из main
запуск горутины 2 — аналогично
Каждая горутина имеет свой собственный стек. Паника в горутине 1 раскручивает стек горутины 1. recover в горутине main находится в совершенно другом стеке и не может перехватить панику из другой горутины.
Исключение: единственная горутина
recover в main поймает панику только если она произошла в самой горутине main:
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("caught:", r) // поймает
}
}()
printNumber(1) // если паника здесь — поймается, т.к. это main горутина
}
Практический совет
В продакшене каждая горутина, запускаемая через go, должна иметь свой defer recover(). Это базовое требование к надёжности. Если горутина падает без recover, падает вся программа — даже если остальные горутины работали корректно.
Для senior разработчика критически важно чётко понимать границу действия recover — это одна из самых частых ошибок при работе с паниками в конкурентных программах.
Вопрос 12. Какие базовые SQL-запросы нужно знать для собеседования бэкенд-разработчика?
Таймкод: 00:51:48
Ответ собеседника: Правильный. Базовые операции: SELECT, INSERT, UPDATE, DELETE. Также важно знать JOIN для объединения таблиц, GROUP BY для группировки данных. Дополнительно могут спросить про индексы, но это уже относится к проектированию и оптимизации.
Правильный ответ:
Ответ покрывает основы, но для senior разработчика ожидается значительно более глубокое знание. Вот полный список тем.
DML — Data Manipulation Language
Базовые CRUD-операции:
-- SELECT с фильтрацией, сортировкой, пагинацией
SELECT u.id, u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2024-01-01'
AND u.status = 'active'
GROUP BY u.id, u.name, u.email
HAVING COUNT(o.id) > 5
ORDER BY order_count DESC
LIMIT 20 OFFSET 40;
-- INSERT с возвратом сгенерированных полей
INSERT INTO users (name, email, created_at)
VALUES ('John', 'john@example.com', NOW())
RETURNING id;
-- UPDATE с JOIN
UPDATE orders o
SET status = 'priority'
FROM users u
WHERE o.user_id = u.id
AND u.vip = true
AND o.status = 'pending';
-- DELETE с подзапросом
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE last_login < NOW() - INTERVAL '90 days'
);
JOIN — типы и когда использовать
-- INNER JOIN — только совпадающие строки
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT JOIN — все строки из левой таблицы, NULL для несовпавших
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- RIGHT JOIN — все строки из правой таблицы
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- FULL OUTER JOIN — все строки из обеих таблиц
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- CROSS JOIN — декартово произведение (осторожно!)
SELECT u.name, p.name AS product_name
FROM users u
CROSS JOIN products p;
-- Self JOIN — таблица сама с собой
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
Агрегация и оконные функции
-- GROUP BY с HAVING
SELECT user_id, SUM(amount) AS total_spent, COUNT(*) AS order_count
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY user_id
HAVING SUM(amount) > 1000;
-- Оконные функции (window functions) — мощнейший инструмент
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY order_date) AS running_total,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rank_in_user,
LAG(amount) OVER (PARTITION BY user_id ORDER BY order_date) AS prev_order_amount,
AVG(amount) OVER (PARTITION BY user_id) AS user_avg_amount
FROM orders;
Подзапросы и CTE
-- Подзапрос в WHERE
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- EXISTS — эффективнее IN для больших подзапросов
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000
);
-- CTE (Common Table Expression) — читаемость
WITH user_stats AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
),
top_users AS (
SELECT user_id
FROM user_stats
WHERE total_spent > 5000
)
SELECT u.name, us.order_count, us.total_spent
FROM users u
JOIN user_stats us ON u.id = us.user_id
WHERE u.id IN (SELECT user_id FROM top_users);
Индексы — критически важная тема
-- B-tree индекс (по умолчанию) — для точного поиска и диапазонов
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- Составной индекс — порядок колонок важен!
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Этот индекс ускорит: WHERE user_id = ? AND status = ?
-- И ускорит: WHERE user_id = ?
-- Но НЕ ускорит: WHERE status = ?
-- Partial index — индекс на часть данных
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- Covering index — индекс покрывает все поля запроса
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (amount, created_at);
-- Уникальный индекс
CREATE UNIQUE INDEX idx_users_email ON users(email);
Транзакции и уровни изоляции
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- или ROLLBACK при ошибке
Уровни изоляции (от слабого к строгому):
- Read Uncommitted — можно читать незафиксированные данные
- Read Committed — по умолчанию в PostgreSQL, видит только зафиксированные изменения
- Repeatable Read — повторное чтение в той же транзакции вернёт тот же результат
- Serializable — полная изоляция, как будто транзакции выполняются последовательно
Анализ запросов
-- EXPLAIN — план выполнения
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.name;
Что должен видеть разработчик в EXPLAIN:
- Seq Scan (полное сканирование) на большой таблице — потенциальная проблема
- Index Scan / Index Only Scan — индекс используется
- Nested Loop vs Hash Join vs Merge Join — тип соединения
- Actual Rows vs Estimated Rows — точность статистики
Миграции в Go
-- Файл миграции: 001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status) WHERE status = 'active';
Рекомендация
Для senior разработчика критически важно не просто знать синтаксис, но и понимать, как работают индексы (B-tree структура, селективность, covering indexes), уметь читать EXPLAIN ANALYZE, понимать уровни изоляции транзакций и их влияние на конкурентность. Это то, что отличает разработчика, который пишет запросы, от разработчика, который проектирует производительную систему.
Вопрос 13. Зачем нужен HAVING в SQL и чем он отличается от WHERE?
Таймкод: 01:00:53
Ответ собеседника: Правильный. HAVING используется для фильтрации по агрегированным полям после GROUP BY, тогда как WHERE фильтрует строки до группировки. Кандидат верно объяснил разницу и успешно применил HAVING для вывода городов с более чем одним пользователем.
Правильный ответ:
Ответ кандидата корректный. Добавлю глубины и нюансов.
Порядок выполнения SQL-запроса
Понимание порядка выполнения объясняет, почему WHERE и HAVING нельзя менять местами:
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT
WHERE фильтрует строки, HAVING — группы
-- WHERE: фильтр до группировки (работает с отдельными строками)
-- HAVING: фильтр после группировки (работает с агрегатами)
SELECT city, COUNT(*) AS user_count, AVG(age) AS avg_age
FROM users
WHERE status = 'active' -- сначала отфильтровали активных
GROUP BY city
HAVING COUNT(*) > 10 -- потом отфильтровали города с > 10 пользователей
ORDER BY user_count DESC;
Что можно писать в HAVING, но нельзя в WHERE
-- Корректно: HAVING с агрегатной функцией
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000;
-- Некорректно: WHERE с агрегатной функцией — ошибка компиляции
SELECT department, AVG(salary) AS avg_salary
FROM employees
WHERE AVG(salary) > 50000 -- ERROR: aggregate functions are not allowed in WHERE
GROUP BY department;
Когда WHERE достаточно
Если условие не зависит от агрегата — всегда используйте WHERE, это эффективнее:
-- Эффективно: WHERE отфильтровал строки ДО группировки
SELECT city, COUNT(*) AS cnt
FROM users
WHERE age >= 18
GROUP BY city
HAVING COUNT(*) > 5;
-- Неэффективно: HAVING с условием на обычную колонку
SELECT city, COUNT(*) AS cnt
FROM users
GROUP BY city
HAVING MIN(age) >= 18; -- работает, но группирует ВСЕ строки сначала
HAVING без GROUP BY
Технически HAVING можно использовать и без GROUP BY — тогда вся таблица рассматривается как одна группа:
SELECT COUNT(*) AS total
FROM orders
HAVING COUNT(*) > 0;
-- Эквивалентно:
SELECT COUNT(*) AS total
FROM orders
WHERE COUNT(*) > 0; -- ОШИБКА — WHERE не принимает агрегаты
Сложные условия в HAVING
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent,
MAX(amount) AS max_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY user_id
HAVING COUNT(*) > 5
AND SUM(amount) > 1000
AND MAX(amount) > 500
ORDER BY total_spent DESC;
Оптимизация: подзапрос вместо HAVING
Иногда подзапрос читаемее и позволяет оптимизатору выбрать лучший план:
-- Через HAVING
SELECT city, COUNT(*) AS cnt
FROM users
GROUP BY city
HAVING COUNT(*) > 10;
-- Через подзапрос (иногда эффективнее для сложных условий)
SELECT city, cnt
FROM (
SELECT city, COUNT(*) AS cnt
FROM users
GROUP BY city
) AS city_stats
WHERE cnt > 10;
Рекомендация
Для senior разработчика важно понимать, что WHERE выполняется до группировки и может использовать индексы, а HAVING — после и работает с уже агрегированными данными. Правильный выбор между ними может существенно влиять на производительность запроса, особенно на больших таблицах.
Вопрос 14. Чем отличается EXPLAIN от EXPLAIN ANALYZE в SQL?
Таймкод: 01:01:50
Ответ собеседника: Неполный. Кандидат слышал про EXPLAIN ANALYZE, но не смог вспомнить детали. EXPLAIN показывает план выполнения запроса, а EXPLAIN ANALYZE дополнительно выполняет запрос и показывает реальное время выполнения каждого шага, что даёт более точную картину производительности.
Правильный ответ:
Кандидат правильно уловил суть, но ответ был поверхностным. Для senior разработчика важно уметь читать и интерпретировать вывод обоих команд.
EXPLAIN — план без выполнения
EXPLAIN показывает, какой план выполнения запроса выбрал оптимизатор, не выполняя сам запрос. Это оценки на основе статистики таблиц.
EXPLAIN
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = 'Moscow'
GROUP BY u.name;
Примерный вывод:
HashAggregate (cost=1523.45..1525.45 rows=200 width=40)
Group Key: u.name
-> Hash Join (cost=45.20..1473.45 rows=10000 width=36)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..1234.00 rows=50000 width=8)
-> Hash (cost=42.70..42.70 rows=200 width=36)
-> Index Scan using idx_users_city on users u
(cost=0.00..42.70 rows=200 width=36)
Index Cond: (city = 'Moscow')
Здесь cost=45.20..1473.45 — это оценочная стоимость (в условных единицах), rows=10000 — оценочное количество строк.
EXPLAIN ANALYZE — план с реальным выполнением
EXPLAIN ANALYZE выполняет запрос и показывает реальные метрики наряду с оценками.
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = 'Moscow'
GROUP BY u.name;
Примерный вывод:
HashAggregate (cost=1523.45..1525.45 rows=200 width=40)
(actual time=45.234..45.567 rows=187 loops=1)
Group Key: u.name
Batches: 1 Memory Usage: 48kB
-> Hash Join (cost=45.20..1473.45 rows=10000 width=36)
(actual time=2.145..38.912 rows=9845 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..1234.00 rows=50000 width=8)
(actual time=0.012..18.234 rows=50000 loops=1)
-> Hash (cost=42.70..42.70 rows=200 width=36)
(actual time=2.089..2.091 rows=187 loops=1)
Buckets: 256 Memory Usage: 20kB
-> Index Scan using idx_users_city on users u
(cost=0.00..42.70 rows=200 width=36)
(actual time=0.023..1.876 rows=187 loops=1)
Index Cond: (city = 'Moscow')
Planning Time: 0.345 ms
Execution Time: 45.789 ms
Ключевые поля в выводе
cost=1523.45..1525.45 — оценочная стоимость в единицах стоимости чтения страницы. Первое число — startup cost (до вывода первой строки), второе — total cost.
actual time=45.234..45.567 — реальное время в миллисекундах. Первое — до первой строки, второе — общее.
rows=200 (оценка) vs rows=187 (факт) — если разница большая, оптимизатор принимает неверные решения. Причина — устаревшая статистика.
loops=1 — сколько раз узел был выполнен. Для nested loop внутренний узел может иметь loops=1000.
На что обращать внимание
-
Расхождение rows и actual rows — если оптимизатор оценивает 1000 строк, а реально 50000, план может быть неоптимальным. Решение:
ANALYZE table_name;для обновления статистики. -
Seq Scan на большой таблице — полное сканирование таблицы в миллионах строк. Возможно, нужен индекс.
-
Nested Loop с большим loops — внутренний узел выполняется многократно. Может быть медленно без индекса.
-
Sort Method: external merge Disk — сортировка на диске вместо памяти. Увеличьте
work_mem.
EXPLAIN ANALYZE с BUFFERS
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 12345;
Добавит информацию о буфере:
Buffers: shared hit=15 read=3
hit— страницы из кэша (быстро)read— страницы с диска (медленно)
Практический пример оптимизации
-- До оптимизации: Seq Scan
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';
-- Seq Scan on orders (actual time=0.015..234.567 rows=50000 loops=1)
-- Filter: (status = 'pending')
-- Rows Removed by Filter: 950000
-- После: добавляем частичный индекс
CREATE INDEX idx_orders_pending ON orders(created_at) WHERE status = 'pending';
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';
-- Index Scan using idx_orders_pending (actual time=0.023..45.123 rows=50000 loops=1)
Важный нюанс
EXPLAIN ANALYZE реально выполняет запрос. Для INSERT, UPDATE, DELETE это значит, что данные будут изменены. Чтобы откатить изменения, оберните в транзакцию:
BEGIN;
EXPLAIN ANALYZE UPDATE users SET name = 'test' WHERE id = 1;
ROLLBACK;
Рекомендация
Для senior разработчика умение читать EXPLAIN ANALYZE — это базовый навык, сравнимый с умением пользоваться отладчиком. Это первый шаг при оптимизации любого медленного запроса. Без анализа плана оптимизация превращается в угадайку.
Вопрос 15. Как вывести названия городов и имена пользователей, живущих в них, используя таблицы users и cities? Как вывести количество пользователей в каждом городе?
Таймкод: 01:02:44
Ответ собеседника: Правильный. Кандидат использовал JOIN для объединения таблицы users и cities по city_id. Для подсчёта пользователей по городам применил GROUP BY с COUNT. Также использовал RIGHT JOIN для исключения городов без пользователей (Краснодар) и ORDER BY для сортировки по возрастанию количества.
Правильный ответ:
Задача на JOIN и агрегацию. Кандидат справился. Разберём полное решение с нюансами.
Схема данных
CREATE TABLE cities (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
city_id INT REFERENCES cities(id)
);
INSERT INTO cities (name) VALUES ('Moscow'), ('Saint Petersburg'), ('Krasnodar');
INSERT INTO users (name, city_id) VALUES
('Alice', 1), ('Bob', 1), ('Charlie', 2), ('Diana', 2), ('Eve', 2);
Задача 1: города и пользователи в них
-- INNER JOIN — только города с пользователями
SELECT c.name AS city_name, u.name AS user_name
FROM users u
INNER JOIN cities c ON u.city_id = c.id
ORDER BY c.name, u.name;
Результат:
Krasnodar | (нет строк — город без пользователей)
Moscow | Alice
Moscow | Bob
Saint Petersburg | Charlie
Saint Petersburg | Diana
Saint Petersburg | Eve
-- LEFT JOIN из cities — все города, NULL для пустых
SELECT c.name AS city_name, u.name AS user_name
FROM cities c
LEFT JOIN users u ON c.id = u.city_id
ORDER BY c.name, u.name;
Результат:
Krasnodar | NULL
Moscow | Alice
Moscow | Bob
Saint Petersburg| Charlie
Saint Petersburg| Diana
Saint Petersburg| Eve
Задача 2: количество пользователей по городам
-- Только города с пользователями
SELECT c.name AS city_name, COUNT(u.id) AS user_count
FROM cities c
INNER JOIN users u ON c.id = u.city_id
GROUP BY c.name
ORDER BY user_count ASC;
Результат:
Moscow | 2
Saint Petersburg| 3
-- Все города, включая пустые (COUNT(u.id) не считает NULL)
SELECT c.name AS city_name, COUNT(u.id) AS user_count
FROM cities c
LEFT JOIN users u ON c.id = u.city_id
GROUP BY c.name
ORDER BY user_count ASC;
Результат:
Krasnodar | 0
Moscow | 2
Saint Petersburg| 3
Важный нюанс: COUNT(*) vs COUNT(column)
-- COUNT(*) считает все строки, включая с NULL
-- COUNT(u.id) считает только не-NULL значения
-- Для LEFT JOIN с пустым городом:
-- COUNT(*) = 1 (одна строка с NULL)
-- COUNT(u.id) = 0 (u.id IS NULL)
-- COUNT(c.id) = 1 (c.id NOT NULL)
Задача 3: города без пользователей
-- Через LEFT JOIN + IS NULL
SELECT c.name
FROM cities c
LEFT JOIN users u ON c.id = u.city_id
WHERE u.id IS NULL;
-- Через NOT EXISTS (часто эффективнее)
SELECT c.name
FROM cities c
WHERE NOT EXISTS (
SELECT 1 FROM users u WHERE u.city_id = c.id
);
-- Через NOT IN
SELECT c.name
FROM cities c
WHERE c.id NOT IN (SELECT DISTINCT city_id FROM users WHERE city_id IS NOT NULL);
Оконная функция: и пользователи, и количество
SELECT
c.name AS city_name,
u.name AS user_name,
COUNT(*) OVER (PARTITION BY c.id) AS users_in_city
FROM cities c
LEFT JOIN users u ON c.id = u.city_id
ORDER BY c.name, u.name;
Результат:
Krasnodar | NULL | 0
Moscow | Alice| 2
Moscow | Bob | 2
Saint Petersburg| Charlie| 3
Saint Petersburg| Diana| 3
Saint Petersburg| Eve | 3
Что проверяет эта задача
- Понимание разницы между INNER и LEFT JOIN
- Умение использовать GROUP BY с агрегатными функциями
- Понимание разницы COUNT(*) и COUNT(column)
- Знание паттерна «найти записи без связанных» (LEFT JOIN + IS NULL vs NOT EXISTS)
Для senior разработчика также важно знать, что NOT EXISTS обычно эффективнее NOT IN при наличии NULL в подзапросе, и уметь объяснить почему: NOT IN с NULL в подзапросе может вернуть пустой результат, потому что сравнение с NULL даёт UNKNOWN, а не TRUE/FALSE.
Вопрос 16. В каких случаях B-tree индекс может быть неэффективен или не использоваться базой данных?
Таймкод: 01:04:44
Ответ собеседника: Правильный. Кандидат упомянул полнотекстовые индексы как возможный случай. Обсуждались случаи: малая вариативность данных (например, поле «пол» с двумя значениями), малый объём данных (БД предпочитает sequential scan), неуникальные поля с большим количеством записей (например, возраст при миллиарде записей), поля, которые редко используются в условиях запросов. Также затронуты специфические типы индексов: GIN для JSONB, пространственные индексы для координат.
Правильный ответ:
Кандидат дал хороший ответ, затронув основные случаи. Дополним деталями и примерами.
Когда B-tree индекс не используется
А. Низкая селективность (Low Selectivity)
Когда значения в колонке мало отличаются друг от друга, индекс не помогает сузить выборку:
-- Поле gender с 2 значениями в таблице 10M строк
-- Индекс сократит выборку до ~5M строк — проще сканировать всю таблицу
CREATE INDEX idx_users_gender ON users(gender); -- бесполезный индекс
SELECT * FROM users WHERE gender = 'male';
-- Seq Scan — оптимизатор правильно игнорирует индекс
Правило: индекс эффективен, когда селективность (доля уникальных значений) высока. Для булевых полей и полей с 3-5 значениями индекс обычно бесполезен.
Б. Малый размер таблицы
Для таблицы в несколько сотен строк sequential scan быстрее, чем index scan + обращение к страницам данных:
-- Таблица с 500 строками
SELECT * FROM config WHERE key = 'max_connections';
-- Seq Scan — вся таблица помещается в одну страницу
В. Функции и выражения над индексированной колонкой
-- Индекс НЕ используется:
SELECT * FROM users WHERE LOWER(email) = 'john@example.com';
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
SELECT * FROM products WHERE price * 1.2 > 100;
-- Решение: функциональный индекс
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
CREATE INDEX idx_orders_year ON orders(EXTRACT(YEAR FROM created_at));
Г. Неявное приведение типов
-- Колонка user_id — INTEGER, но сравниваем со строкой
SELECT * FROM orders WHERE user_id = '12345';
-- PostgreSQL может не использовать индекс из-за приведения типа
-- Правильно:
SELECT * FROM orders WHERE user_id = 12345;
Д. Использование OR
-- PostgreSQL может отказаться от индекса при OR на разные колонки
SELECT * FROM users WHERE city = 'Moscow' OR age > 30;
-- Решение: UNION ALL
SELECT * FROM users WHERE city = 'Moscow'
UNION ALL
SELECT * FROM users WHERE age > 30 AND city <> 'Moscow';
Е. LIKE с подстановкой в начале
-- Индекс НЕ используется:
SELECT * FROM users WHERE name LIKE '%john%';
SELECT * FROM users WHERE name LIKE '%john';
-- Индекс используется:
SELECT * FROM users WHERE name LIKE 'john%';
-- Для полнотекстового поиска — GIN или GIST
CREATE INDEX idx_users_name_gin ON users USING GIN(name gin_trgm_ops);
SELECT * FROM users WHERE name LIKE '%john%';
Ж. Большая доля выборки
Если запрос выбирает >5-15% строк таблицы (зависит от настроек), оптимизатор предпочтёт sequential scan:
-- Выбираем 80% таблицы — индекс не поможет
SELECT * FROM orders WHERE status = 'completed';
-- Seq Scan
З. Устаревшая статистика
-- После массовой вставки/удаления статистика неактуальна
-- Оптимизатор принимает неверные решения
-- Решение:
ANALYZE users;
ANALYZE orders;
Типы индексов для специфических задач
| Тип индекса | Когда использовать |
|---|---|
| B-tree | По умолчанию, для равенства и диапазонов |
| GIN | JSONB, массивы, полнотекстовый поиск |
| GIST | Геоданные (PostGIS), диапазоны, триграммы |
| BRIN | Большие таблицы с физическим порядком (логи по дате) |
| Hash | Только равенство, но B-tree обычно лучше |
Пример GIN для JSONB:
CREATE INDEX idx_orders_data ON orders USING GIN(data);
-- Использует индекс:
SELECT * FROM orders WHERE data @> '{"priority": true}';
SELECT * FROM orders WHERE data ? 'discount_code';
Пример BRIN для логов:
CREATE INDEX idx_logs_created_at ON logs USING BRIN(created_at)
WITH (pages_per_range = 32);
-- Эффективно для таблиц, где данные вставлены хронологически
SELECT * FROM logs WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31';
Рекомендация
Для senior разработчика важно не просто знать, что индекс может не использоваться, но и уметь это диагностировать через EXPLAIN ANALYZE, понимать причину и знать альтернативные типы индексов для специфических задач. Также важно помнить, что каждый индекс — это overhead на INSERT/UPDATE/DELETE, поэтому индексы нужно создавать осознанно, а не «на всякий случай».
Вопрос 17. Можно ли создать частичный индекс (partial index) в PostgreSQL, чтобы он покрывал не всю таблицу?
Таймкод: 01:13:21
Ответ собеседника: Неполный. Кандидат не имел опыта создания частичных индексов, но знает, что существует множество различных типов индексов. Частичный индекс (partial index) в PostgreSQL создаётся с помощью условия WHERE и покрывает только часть таблицы, что делает его компактнее и эффективнее для определённых запросов.
Правильный ответ:
Кандидат правильно описал концепцию, но не смог привести пример. Для senior разработчика важно не просто знать о существовании, но и уметь применять.
Что такое частичный индекс
Частичный индекс — это индекс, который включает только строки, удовлетворяющие условию WHERE. Он компактнее полного индекса и быстрее сканируется.
Синтаксис
CREATE INDEX index_name ON table_name (columns)
WHERE condition;
Практические примеры
А. Индекс только активных пользователей
CREATE INDEX idx_users_active_email ON users(email)
WHERE status = 'active';
-- Использует индекс:
SELECT * FROM users WHERE email = 'john@example.com' AND status = 'active';
-- НЕ использует индекс (условие не совпадает):
SELECT * FROM users WHERE email = 'john@example.com' AND status = 'banned';
Если 90% пользователей неактивны, этот индекс в 10 раз меньше полного.
Б. Индекс только незавершённых заказов
CREATE INDEX idx_orders_pending ON orders(user_id, created_at)
WHERE status = 'pending';
-- Использует индекс:
SELECT * FROM orders WHERE user_id = 123 AND status = 'pending';
В типичном интернет-магазине pending-заказы — это малая доля от всех заказов. Полный индекс по status был бы бесполезен (низкая селективность), а частичный — компактный и быстрый.
В. Индекс с исключением NULL
CREATE INDEX idx_users_phone ON users(phone)
WHERE phone IS NOT NULL;
-- Использует индекс:
SELECT * FROM users WHERE phone = '+79991234567';
Полезно, когда NULL-значения занимают значительную часть колонки и не участвуют в поиске.
Г. Индекс для актуальных записей
CREATE INDEX idx_sessions_active ON sessions(user_id)
WHERE expires_at > NOW();
-- Использует индекс:
SELECT * FROM sessions WHERE user_id = 456 AND expires_at > NOW();
Ограничения
Запрос использует частичный индекс, только если условие WHERE запроса подмножество условия индекса:
CREATE INDEX idx_orders_recent_pending ON orders(created_at)
WHERE status = 'pending' AND created_at > '2024-01-01';
-- Использует индекс (подмножество условия):
SELECT * FROM orders
WHERE status = 'pending'
AND created_at > '2024-06-01'
AND created_at < '2024-07-01';
-- НЕ использует индекс (условие шире):
SELECT * FROM orders WHERE status = 'pending';
Частичный уникальный индекс
Мощная комбинация — уникальность только для части строк:
-- Уникальный email только для активных пользователей
-- Неактивные пользователи могут иметь дубликаты email
CREATE UNIQUE INDEX idx_users_active_email_unique ON users(email)
WHERE status = 'active';
INSERT INTO users (email, status) VALUES ('john@example.com', 'active'); -- OK
INSERT INTO users (email, status) VALUES ('john@example.com', 'active'); -- ERROR: duplicate
INSERT INTO users (email, status) VALUES ('john@example.com', 'archived'); -- OK
INSERT INTO users (email, status) VALUES ('john@example.com', 'archived'); -- OK
Рекомендация
Частичные индексы — это один из самых недооценённых инструментов PostgreSQL. Они решают две задачи одновременно: ускоряют запросы и экономят место на диске и в памяти. Для senior разработчика ожидается умение видеть сценарии, где частичный индекс уместен: когда запрос всегда фильтрует по определённому условию, и это условие выделяет небольшую подгруппу строк.
Вопрос 18. Приходилось ли работать с больными таблицами (100-200 млн записей) и с Elasticsearch?
Таймкод: 01:13:51
Ответ собеседника: Правильный. С большими таблицами в PostgreSQL не работал. Упомянул интересный случай с исчерпанием лимита ID в базе. С Elasticsearch работал примерно 3 года назад на проекте агрегатора страховых компаний, где хранил логи/трейсы.
Правильный ответ:
Ответ честный, но стоит раскрыть обе темы для подготовки.
Работа с большими таблицами (100-200M+ записей)
А. Исчерпание лимита ID — реальная проблема
-- SERIAL = INTEGER, максимум 2,147,483,647
CREATE TABLE logs (
id SERIAL PRIMARY KEY, -- исчерпается при ~2.1 млрд записей
...
);
-- Решение: BIGSERIAL = BIGINT, максимум 9,223,372,036,854,775,807
CREATE TABLE logs (
id BIGSERIAL PRIMARY KEY,
...
);
Если таблица принимает 100K записей в день, INTEGER исчерпается за ~58 лет. Но если 10M записей в день — за ~7 месяцев.
Б. Партиционирование
Для больших таблиц партиционирование — обязательная практика:
-- Range partitioning по дате
CREATE TABLE orders (
id BIGSERIAL,
user_id INT NOT NULL,
amount DECIMAL(10,2),
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2024_q1 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- Запрос автоматически обращается только к нужной партиции (partition pruning)
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-15' AND '2024-02-15';
Преимущества:
- Быстрое удаление старых данных:
DROP TABLE orders_2023_q1;вместо DELETE - Параллельное сканирование партиций
- Меньшие индексы на каждую партицию
В. Особенности работы с большими таблицами
- ALTER TABLE может блокировать таблицу на часы. Используйте
pg_repackилиALTER TABLE ... ADD COLUMN ... DEFAULT ... NOT NULL(в PostgreSQL 11+ это metadata-only для NOT NULL с default). - VACUUM критически важен — без него таблица «раздувается» (bloat). Настройте
autovacuumагрессивнее для больших таблиц. - COUNT(*) медленный — на 100M строк может занимать минуты. Используйте приблизительный подсчёт:
-- Приблизительный COUNT из статистики
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
Elasticsearch: основы
Elasticsearch — это распределённый поисковый движок на базе Lucene, оптимизированный для полнотекстового поиска и аналитики.
Индекс и документ
// Создание индекса с mapping
PUT /orders
{
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"user_id": { "type": "integer" },
"description": { "type": "text" },
"amount": { "type": "float" },
"created_at": { "type": "date" },
"status": { "type": "keyword" }
}
}
}
// Индексация документа
POST /orders/_doc/1
{
"order_id": "ORD-001",
"user_id": 123,
"description": "Wireless headphones with noise cancellation",
"amount": 299.99,
"created_at": "2024-01-15T10:30:00Z",
"status": "completed"
}
Поиск
// Полнотекстовый поиск
GET /orders/_search
{
"query": {
"match": {
"description": "wireless headphones"
}
}
}
// Фильтр + агрегация
GET /orders/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "status": "completed" } },
{ "range": { "created_at": { "gte": "2024-01-01" } } }
]
}
},
"aggs": {
"total_revenue": { "sum": { "field": "amount" } },
"orders_by_status": {
"terms": { "field": "status" }
}
}
}
Интеграция с Go
import "github.com/elastic/go-elasticsearch/v8"
func main() {
es, err := elasticsearch.NewClient(elasticsearch.Config{
Addresses: []string{"http://localhost:9200"},
})
if err != nil {
log.Fatal(err)
}
// Поиск
var buf bytes.Buffer
query := map[string]interface{}{
"query": map[string]interface{}{
"match": map[string]interface{}{
"description": "wireless",
},
},
}
json.NewEncoder(&buf).Encode(query)
res, err := es.Search(
es.Search.WithIndex("orders"),
es.Search.WithBody(&buf),
)
// ...
}
Когда использовать Elasticsearch
- Полнотекстовый поиск с морфологией, релевантностью, фасетами
- Аналитика и агрегации по большим объёмам данных в реальном времени
- Логирование и мониторинг (ELK-стек)
- Поиск с автодополнением и «вы имели в виду»
Когда не использовать
- OLTP-нагрузка (частые точечные чтения/записи по ID)
- Строгая консистентность (eventual consistency)
- Сложные JOIN между сущностями
- Транзакционные операции
Вопрос 19. Что обычно проверяется при code review и какие замечания обычно даются?
Таймкод: 01:15:43
Ответ собеседния: Неполный. Кандидат пошутил, что они ни на что не смотрят и просто ставят лайк. В целом обсуждалось, что на code review стоит проверять: наличие тестов, прохождение линтеров, вынос магических чисел в константы, соблюдение нейминга и кодстайла команды. Также упомянуто, что многие проверки лучше автоматизировать через CI/CD (линтеры, тесты, проверки безопасности), чтобы не зависеть от человеческого фактора.
Правильный ответ:
Кандидат затронул поверхностные моменты, но senior разработчик должен знать code review на гораздо более глубоком уровне.
Что проверяется на code review
А. Корректность логики
Это самое важное. Автоматика это не поймает.
// Ошибка: неправильный порядок условий
func GetUserDiscount(user *User) float64 {
if user.IsVIP {
return 0.20
}
if user.OrderCount > 10 {
return 0.15
}
if user.OrderCount > 5 {
return 0.10
}
return 0
}
// Что если OrderCount = 12 и IsVIP = false? Вернёт 0.15, а не 0.10
// Но если IsVIP = true и OrderCount = 3? Вернёт 0.20 — правильно
// Логика работает, но хрупкая — легко сломать при рефакторинге
Б. Обработка ошибок
// Плохо: ошибка проигнорирована
func ProcessOrder(ctx context.Context, orderID int) {
order, _ := repo.GetOrder(ctx, orderID) // что если ошибка?
_ = sendNotification(order) // и здесь?
}
// Хорошо: каждая ошибка обработана
func ProcessOrder(ctx context.Context, orderID int) error {
order, err := repo.GetOrder(ctx, orderID)
if err != nil {
return fmt.Errorf("get order %d: %w", orderID, err)
}
if err := sendNotification(order); err != nil {
// Решаем: критична ли ошибка уведомления?
log.Error("failed to send notification", "order_id", orderID, "err", err)
// Продолжаем — уведомление не критично для заказа
}
return nil
}
В. Race conditions и concurrency
// Плохо: гонка данных
func (s *Service) Process(wg *sync.WaitGroup) {
defer wg.Done()
s.counter++ // data race
}
// Хорошо:
func (s *Service) Process(wg *sync.WaitGroup) {
defer wg.Done()
s.mu.Lock()
defer s.mu.Unlock()
s.counter++
}
Г. Потенциальные утечки ресурсов
// Плохо: горутина может утечь
func (s *Service) Start() {
go s.worker() // нет способа остановить
}
// Хорошо: отмена через контекст
func (s *Service) Start(ctx context.Context) {
go s.worker(ctx)
}
func (s *Service) worker(ctx context.Context) {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.processBatch()
}
}
}
Д. SQL-инъекции и безопасность
// Плохо: SQL-инъекция
func SearchUsers(name string) ([]User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name)
return db.Query(query)
}
// Хорошо: параметризованный запрос
func SearchUsers(ctx context.Context, name string) ([]User, error) {
query := "SELECT * FROM users WHERE name = $1"
return db.QueryContext(ctx, query, name)
}
Е. Производительность
// Плохо: N+1 запрос
func GetUsersWithOrders(userIDs []int) ([]UserWithOrders, error) {
var result []UserWithOrders
for _, id := range userIDs {
user, _ := repo.GetUser(id)
orders, _ := repo.GetOrdersByUserID(id) // отдельный запрос для каждого пользователя
result = append(result, UserWithOrders{User: user, Orders: orders})
}
return result, nil
}
// Хорошо: batch-запрос
func GetUsersWithOrders(ctx context.Context, userIDs []int) ([]UserWithOrders, error) {
users, err := repo.GetUsersByIDs(ctx, userIDs)
if err != nil {
return nil, err
}
orders, err := repo.GetOrdersByUserIDs(ctx, userIDs)
if err != nil {
return nil, err
}
// Объединяем в памяти
return mergeUsersWithOrders(users, orders), nil
}
Типичные замечания на code review
| Категория | Пример замечания |
|---|---|
| Логика | «Это условие не обработает случай X» |
| Ошибки | «Ошибка из foo() не обрабатывается — добавьте обработку или явный _ = с комментарием» |
| Concurrency | «Здесь возможна гонка данных — нужен мьютекс или канал» |
| Тесты | «Добавьте тест на edge case: пустой ввод / nil / timeout» |
| Нейминг | processData — слишком общно, validateOrderPayload — лучше |
| Магические числа | time.Sleep(300) → const retryDelay = 300 * time.Millisecond |
| Дублирование | «Этот код повторяется в 3 местах — вынесите в функцию» |
| Контекст | «Передайте context в этот вызов для поддержки отмены и таймаутов» |
| Утечка ресурсов | rows.Close() отсутствует — добавьте defer rows.Close() |
| API дизайн | «Функция делает слишком много — разделите на две» |
Что должно быть автоматизировано, а нет
Автоматизировать можно и нужно:
- Форматирование (
gofmt) - Статический анализ (
golangci-lint,staticcheck) - Тесты (
go test) - Покрытие кода
- Уязвимости (
gosec,trivy) - Размер PR (более 400 строк — красный флаг)
Нельзя автоматизировать:
- Корректность бизнес-логики
- Архитектурные решения
- Читаемость и поддерживаемость
- Выбор правильного уровня абстракции
Рекомендация
Для senior разработчика code review — это не формальность, а один из ключевых инструментов обеспечения качества. Важен баланс: не быть «bike-shedding» (споры о мелочах), но и не пропускать реальные проблемы. Хороший ревьюер объясняет не только что не так, но и почему, предлагая конкретное решение.
