Senior Go Interview Prep - Core Go: https://go.vbloher.org/docs/01-core-go/ - Механика defer в Go: https://go.vbloher.org/docs/01-core-go/defer/ - Встраивание структур и интерфейсов (Embedding): https://go.vbloher.org/docs/01-core-go/embedding/ - Ошибки в Go: error, wrapping, errors.Is/As/Join: https://go.vbloher.org/docs/01-core-go/errors/ - Дженерики в Go (1.18+): https://go.vbloher.org/docs/01-core-go/generics/ - Интерфейсы в Go: https://go.vbloher.org/docs/01-core-go/interfaces/ - Устройство map в Go: https://go.vbloher.org/docs/01-core-go/maps/ - panic / recover: механика, раскрутка стека и runtime-паники: https://go.vbloher.org/docs/01-core-go/panic-recover/ - Указатели в Go: https://go.vbloher.org/docs/01-core-go/pointers/ - Рефлексия в Go (reflect): https://go.vbloher.org/docs/01-core-go/reflection/ - Внутреннее устройство слайсов в Go: https://go.vbloher.org/docs/01-core-go/slices/ - Строки, руны и байты в Go: https://go.vbloher.org/docs/01-core-go/strings-runes-bytes/ - Система типов Go: defined types, alignment, memory layout: https://go.vbloher.org/docs/01-core-go/type-system/ - Concurrency: https://go.vbloher.org/docs/02-concurrency/ - sync/atomic: https://go.vbloher.org/docs/02-concurrency/atomic/ - Буферизованные vs небуферизованные каналы: https://go.vbloher.org/docs/02-concurrency/buffered-unbuffered/ - Канал vs Mutex: когда что выбрать: https://go.vbloher.org/docs/02-concurrency/channel-vs-mutex/ - Каналы: устройство hchan: https://go.vbloher.org/docs/02-concurrency/channels/ - Утечки горутин, дедлоки, livelock, starvation: https://go.vbloher.org/docs/02-concurrency/common-leaks-deadlocks/ - sync.Cond: https://go.vbloher.org/docs/02-concurrency/cond/ - context: https://go.vbloher.org/docs/02-concurrency/context/ - Горутины: жизненный цикл, стоимость, стек: https://go.vbloher.org/docs/02-concurrency/goroutines-lifecycle/ - sync.Mutex и sync.RWMutex: https://go.vbloher.org/docs/02-concurrency/mutex-rwmutex/ - sync.Once: https://go.vbloher.org/docs/02-concurrency/once/ - Паттерны конкурентности: https://go.vbloher.org/docs/02-concurrency/patterns/ - Race Detector (гонки данных и -race): https://go.vbloher.org/docs/02-concurrency/race-detector/ - Планировщик GMP: https://go.vbloher.org/docs/02-concurrency/scheduler-gmp/ - select: https://go.vbloher.org/docs/02-concurrency/select/ - sync.WaitGroup: https://go.vbloher.org/docs/02-concurrency/waitgroup/ - Runtime и память: https://go.vbloher.org/docs/03-runtime-memory/ - Паттерны аллокаций и снижение давления на GC: https://go.vbloher.org/docs/03-runtime-memory/allocation-patterns/ - Escape Analysis: когда переменная убегает в кучу: https://go.vbloher.org/docs/03-runtime-memory/escape-analysis/ - Сборщик мусора Go: concurrent tri-color mark-sweep: https://go.vbloher.org/docs/03-runtime-memory/gc/ - Тюнинг GC: GOGC и GOMEMLIMIT: https://go.vbloher.org/docs/03-runtime-memory/gogc-gomemlimit/ - GOMAXPROCS: параллелизм планировщика и проблема контейнеров: https://go.vbloher.org/docs/03-runtime-memory/gomaxprocs/ - Утечки горутин (goroutine leaks): https://go.vbloher.org/docs/03-runtime-memory/goroutine-leaks/ - Утечки памяти в Go (несмотря на GC): https://go.vbloher.org/docs/03-runtime-memory/memory-leaks/ - Модель памяти Go (Go Memory Model): happens-before и синхронизация: https://go.vbloher.org/docs/03-runtime-memory/memory-model/ - pprof: профилирование CPU, памяти и блокировок в Go: https://go.vbloher.org/docs/03-runtime-memory/pprof/ - Execution Tracer и runtime/trace: тайминги вместо агрегатов: https://go.vbloher.org/docs/03-runtime-memory/runtime-tracing/ - Стек vs Куча: где живут данные в Go: https://go.vbloher.org/docs/03-runtime-memory/stack-vs-heap/ - Тестирование: https://go.vbloher.org/docs/04-testing/ - testify, assert/require и golden files: https://go.vbloher.org/docs/04-testing/assertions-testify/ - Бенчмарки в Go: https://go.vbloher.org/docs/04-testing/benchmarks/ - Покрытие, -race и флаки-тесты: https://go.vbloher.org/docs/04-testing/coverage-race/ - Нативный fuzzing в Go (1.18+): https://go.vbloher.org/docs/04-testing/fuzzing/ - Интеграционные тесты, testcontainers-go, TestMain: https://go.vbloher.org/docs/04-testing/integration-testcontainers/ - Моки, стабы и тестируемость: https://go.vbloher.org/docs/04-testing/mocks/ - Table-driven тесты, subtests и параллельность: https://go.vbloher.org/docs/04-testing/table-driven/ - Backend: https://go.vbloher.org/docs/05-backend/ - Аутентификация и авторизация: AuthN/AuthZ, сессии vs токены, RBAC/ABAC, API keys, mTLS, секреты: https://go.vbloher.org/docs/05-backend/auth-authz/ - Graceful Shutdown HTTP/gRPC сервера в Go: https://go.vbloher.org/docs/05-backend/graceful-shutdown/ - gRPC: типы RPC, интерсепторы, контекст, метаданные, error model: https://go.vbloher.org/docs/05-backend/grpc/ - JWT (JSON Web Token): https://go.vbloher.org/docs/05-backend/jwt/ - Middleware-паттерн в Go: https://go.vbloher.org/docs/05-backend/middleware/ - net/http: Server, Handler, ServeMux, таймауты, Client и контекст: https://go.vbloher.org/docs/05-backend/net-http/ - OAuth2: роли, grant types, OIDC, токены и типовые ошибки: https://go.vbloher.org/docs/05-backend/oauth2/ - OpenAPI/Swagger, code generation, contract-first vs code-first, валидация: https://go.vbloher.org/docs/05-backend/openapi/ - Protocol Buffers: схемы, wire format, эволюция и совместимость: https://go.vbloher.org/docs/05-backend/protobuf/ - REST: принципы, версионирование, идемпотентность, статусы, пагинация, ошибки: https://go.vbloher.org/docs/05-backend/rest/ - Сети и протоколы: https://go.vbloher.org/docs/06-networking/ - Пулы соединений: http.Transport, БД, утечки: https://go.vbloher.org/docs/06-networking/connection-pooling/ - DNS: записи, резолвинг, кэширование, DNS в Go: https://go.vbloher.org/docs/06-networking/dns/ - Версии HTTP: 1.1, 2, 3: https://go.vbloher.org/docs/06-networking/http-versions/ - TCP/IP: модель, транспорт и что важно бэкендеру: https://go.vbloher.org/docs/06-networking/tcp-ip/ - TLS: handshake, сертификаты, mTLS, производительность: https://go.vbloher.org/docs/06-networking/tls/ - UDP и надёжность поверх UDP: https://go.vbloher.org/docs/06-networking/udp/ - WebSocket: upgrade, фреймы, масштабирование: https://go.vbloher.org/docs/06-networking/websocket/ - Базы данных: https://go.vbloher.org/docs/07-databases/ - Пул соединений к PostgreSQL в Go: database/sql, pgx, pgxpool, PgBouncer: https://go.vbloher.org/docs/07-databases/connection-pooling-pgx/ - Взаимоблокировки (Deadlocks) в PostgreSQL: https://go.vbloher.org/docs/07-databases/deadlocks/ - Индексы в PostgreSQL: https://go.vbloher.org/docs/07-databases/indexes/ - Уровни изоляции транзакций в PostgreSQL: https://go.vbloher.org/docs/07-databases/isolation-levels/ - MVCC в PostgreSQL: версии строк, видимость, VACUUM и bloat: https://go.vbloher.org/docs/07-databases/mvcc/ - Обзор NoSQL и Redis: https://go.vbloher.org/docs/07-databases/nosql-redis/ - Партиционирование таблиц в PostgreSQL: https://go.vbloher.org/docs/07-databases/partitioning/ - Архитектура PostgreSQL: https://go.vbloher.org/docs/07-databases/postgresql-architecture/ - Планирование и оптимизация запросов в PostgreSQL: https://go.vbloher.org/docs/07-databases/query-planning/ - Репликация в PostgreSQL: https://go.vbloher.org/docs/07-databases/replication/ - Шардирование (горизонтальное масштабирование): https://go.vbloher.org/docs/07-databases/sharding/ - Транзакции в PostgreSQL и Go (database/sql, pgx): https://go.vbloher.org/docs/07-databases/transactions/ - Распределённые системы: https://go.vbloher.org/docs/08-distributed-systems/ - CAP теорема: https://go.vbloher.org/docs/08-distributed-systems/cap-theorem/ - Circuit Breaker: https://go.vbloher.org/docs/08-distributed-systems/circuit-breaker/ - Консенсус и Raft: репликация состояния в присутствии отказов: https://go.vbloher.org/docs/08-distributed-systems/consensus-raft/ - Модели согласованности: https://go.vbloher.org/docs/08-distributed-systems/consistency/ - Гарантии доставки сообщений: at-most-once / at-least-once / exactly-once: https://go.vbloher.org/docs/08-distributed-systems/delivery-guarantees/ - Eventual Consistency: https://go.vbloher.org/docs/08-distributed-systems/eventual-consistency/ - Идемпотентность в распределённых системах: https://go.vbloher.org/docs/08-distributed-systems/idempotency/ - Apache Kafka: https://go.vbloher.org/docs/08-distributed-systems/kafka/ - Transactional Outbox: https://go.vbloher.org/docs/08-distributed-systems/outbox/ - RabbitMQ: AMQP 0-9-1, маршрутизация, надёжность доставки и сравнение с Kafka: https://go.vbloher.org/docs/08-distributed-systems/rabbitmq/ - Ретраи: backoff, jitter, budgets и идемпотентность: https://go.vbloher.org/docs/08-distributed-systems/retries/ - Saga Pattern: https://go.vbloher.org/docs/08-distributed-systems/saga/ - Observability: https://go.vbloher.org/docs/09-observability/ - Grafana: https://go.vbloher.org/docs/09-observability/grafana/ - Метрики: RED, USE, Golden Signals: https://go.vbloher.org/docs/09-observability/metrics/ - OpenTelemetry: https://go.vbloher.org/docs/09-observability/opentelemetry/ - Prometheus: https://go.vbloher.org/docs/09-observability/prometheus/ - SLI / SLO / SLA: https://go.vbloher.org/docs/09-observability/slo-sli/ - Структурированное логирование (slog): https://go.vbloher.org/docs/09-observability/structured-logging/ - Distributed Tracing: https://go.vbloher.org/docs/09-observability/tracing/ - System Design: https://go.vbloher.org/docs/10-system-design/ - Analytics Pipeline: https://go.vbloher.org/docs/10-system-design/analytics-pipeline/ - Chat System: https://go.vbloher.org/docs/10-system-design/chat/ - Фреймворк System Design интервью: https://go.vbloher.org/docs/10-system-design/framework/ - Notification Service: https://go.vbloher.org/docs/10-system-design/notification-service/ - Order Service: https://go.vbloher.org/docs/10-system-design/order-service/ - Payment Service: https://go.vbloher.org/docs/10-system-design/payment-service/ - Rate Limiter: https://go.vbloher.org/docs/10-system-design/rate-limiter/ - URL Shortener: https://go.vbloher.org/docs/10-system-design/url-shortener/ - DevOps: https://go.vbloher.org/docs/11-devops/ - CI/CD: пайплайны, стадии, стратегии деплоя: https://go.vbloher.org/docs/11-devops/cicd/ - Облака (AWS / GCP) для бэкендера: https://go.vbloher.org/docs/11-devops/cloud-aws-gcp/ - Docker для Go-разработчика: https://go.vbloher.org/docs/11-devops/docker/ - GitHub Actions и GitLab CI: https://go.vbloher.org/docs/11-devops/github-gitlab-ci/ - Kubernetes для Go-разработчика: https://go.vbloher.org/docs/11-devops/kubernetes/ - Terraform / Infrastructure as Code: https://go.vbloher.org/docs/11-devops/terraform/ - Алгоритмы: https://go.vbloher.org/docs/12-algorithms/ - Типовые алгоритмические задачи и паттерны: https://go.vbloher.org/docs/12-algorithms/common-problems/ - Асимптотическая сложность (Big-O): https://go.vbloher.org/docs/12-algorithms/complexity/ - Структуры данных в Go: https://go.vbloher.org/docs/12-algorithms/data-structures/ - Специфика live-coding на Go: https://go.vbloher.org/docs/12-algorithms/go-specifics/ - Behavioral: https://go.vbloher.org/docs/13-behavioral/ - Конфликты, разногласия и работа со стейкхолдерами: https://go.vbloher.org/docs/13-behavioral/conflicts/ - Как проходит senior-интервью: этапы, оценка, оффер: https://go.vbloher.org/docs/13-behavioral/interview-flow/ - Лидерство и менторство: https://go.vbloher.org/docs/13-behavioral/leadership-mentoring/ - Типовые поведенческие вопросы для Senior: https://go.vbloher.org/docs/13-behavioral/senior-questions/ > Модуль: Базы данных · Уровень: Senior ## TL;DR - `EXPLAIN` показывает **план** запроса с **оценками** планировщика (estimated rows, cost), не выполняя запрос. `EXPLAIN ANALYZE` **реально выполняет** запрос и добавляет фактические данные (actual time, actual rows, loops). Поэтому `ANALYZE` с `INSERT/UPDATE/DELETE` действительно меняет данные — оборачивайте в транзакцию с `ROLLBACK` или используйте `BEGIN; ... ROLLBACK;`. - План читается **снизу вверх и изнутри наружу**: листья (сканы) внизу, корень (то, что отдаёт результат клиенту) вверху. Главный диагностический приём — сравнить `rows=N` (оценка) с `actual rows=M` (факт). Расхождение в разы/порядки означает плохую статистику и, как следствие, плохой план. - Cost — это безразмерная величина в условных единицах `seq_page_cost`. Планировщик выбирает план с минимальным суммарным cost, не учитывая реальную нагрузку CPU/IO в миллисекундах. - Типы сканов по возрастанию избирательности: `Seq Scan` (читаем всё), `Bitmap Index/Heap Scan` (средняя селективность, много строк по индексу), `Index Scan` (мало строк), `Index Only Scan` (данные целиком в индексе + visibility map). - Джойны: `Nested Loop` хорош для малых наборов с индексом по внутренней таблице, катастрофичен при недооценке строк; `Hash Join` для больших equi-join без сортировки (зависит от `work_mem`); `Merge Join` для отсортированных/индексированных больших наборов. - Статистика собирается `ANALYZE` в `pg_statistic`: `n_distinct`, MCV (most common values), гистограммы. По умолчанию `default_statistics_target = 100`. Для коррелированных колонок планировщик предполагает независимость и сильно ошибается — лечится `CREATE STATISTICS`. - `OFFSET` линейно деградирует (читает и выбрасывает N строк). Используйте keyset pagination (`WHERE (created_at, id) < (...)`). - N+1 в Go — главный источник деградации latency: решается JOIN, `IN (...)`, dataloader-батчингом или `pgx.Batch`. ## Теория ### EXPLAIN vs EXPLAIN ANALYZE `EXPLAIN` запрашивает у планировщика выбранный план без выполнения. Вы видите **оценки**: сколько строк планировщик ожидает (`rows`), их средняя ширина в байтах (`width`) и стоимость (`cost=startup..total`). `EXPLAIN ANALYZE` **выполняет** запрос и добавляет фактику: `actual time=startup..total`, `actual rows`, `loops`. Это даёт возможность увидеть расхождение оценки и факта — главный сигнал проблем. ```sql -- Только план, запрос не выполняется EXPLAIN SELECT * FROM orders WHERE user_id = 42; -- Запрос ВЫПОЛНЯЕТСЯ. Опасно для DML! EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42; -- Безопасно для INSERT/UPDATE/DELETE BEGIN; EXPLAIN ANALYZE UPDATE orders SET status = 'shipped' WHERE id = 100; ROLLBACK; ``` Полезные опции (рекомендуемый «боевой» набор): ```sql EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT, SETTINGS, WAL) SELECT ...; ``` | Опция | Что даёт | |---|---| | `ANALYZE` | реально выполнить, показать actual time/rows/loops | | `BUFFERS` | hits/reads/dirtied/written буферного кэша — отличает «горячие» и «холодные» чтения | | `VERBOSE` | список выводимых колонок, schema-qualified имена | | `SETTINGS` | нестандартные GUC, влияющие на план (важно для воспроизводимости) | | `WAL` | объём сгенерированного WAL (для DML) | | `FORMAT JSON` | машиночитаемый вывод (для инструментов типа explain.depesz / pev2) | `BUFFERS` критичен: `shared hit` — страница уже в `shared_buffers` (быстро), `shared read` — пришлось читать с диска/OS cache (медленно). Один и тот же план на холодном и тёплом кэше даёт разный actual time. ### Как читать план: снизу вверх План — это дерево. **Узлы-листья (сканы) внизу**, корень — вверху. Каждый родитель потребляет строки от детей. Отступ показывает вложенность. ```sql EXPLAIN ANALYZE SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.country = 'DE' AND o.total > 1000; ``` ``` Hash Join (cost=12.50..845.30 rows=120 width=40) (actual time=0.512..8.733 rows=98 loops=1) Hash Cond: (o.user_id = u.id) -> Seq Scan on orders o (cost=0.00..780.00 rows=4000 width=12) (actual time=0.011..6.200 rows=4012 loops=1) Filter: (total > 1000) Rows Removed by Filter: 35988 -> Hash (cost=10.00..10.00 rows=200 width=36) (actual time=0.480..0.481 rows=187 loops=1) -> Seq Scan on users u (cost=0.00..10.00 rows=200 width=36) (actual time=0.008..0.300 rows=187 loops=1) Filter: (country = 'DE') ``` Как читать: 1. Внутренние сканы выполняются первыми. `Seq Scan on users` строит хэш-таблицу (`Hash`), `Seq Scan on orders` пробивает её. 2. **`actual time=startup..total`** — startup это время до первой строки, total — до последней. Для `EXPLAIN ANALYZE` это **на один проход** узла; умножайте на `loops`, чтобы получить суммарное время. 3. **`loops`** — сколько раз узел исполнялся (критично для Nested Loop). `actual rows` и `actual time` в выводе — это **средние на один loop**, а не суммарные. Реальное число строк = `rows × loops`. 4. **`Rows Removed by Filter`** — сколько строк прочитано и отброшено. Высокое значение при `Seq Scan` — кандидат на индекс. Здесь 35988 строк прочитано впустую — индекс по `total` или частичный индекс мог бы помочь. 5. Сравните `rows=4000` (оценка) с `actual rows=4012` — здесь хорошо. Если бы было `rows=40` против `actual 4012`, планировщик выбрал бы Nested Loop и запрос «взорвался» бы. ### Типы узлов-сканов #### Seq Scan Последовательное чтение всей таблицы (всех heap-страниц). Оптимален, когда выбирается большая доля строк (грубо > 5–20% таблицы) — тогда случайные чтения по индексу дороже последовательных. Плох, когда селективный фильтр и большая таблица. #### Index Scan Спуск по B-tree, затем поход в heap за каждой найденной строкой (random IO). Оптимален при высокой селективности (мало строк). Возвращает строки **в порядке индекса** — может избавить от `Sort`. ```sql CREATE INDEX idx_orders_user ON orders(user_id); EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42; -- Index Scan using idx_orders_user on orders -- Index Cond: (user_id = 42) ``` Различайте `Index Cond` (отрабатывает индекс, дёшево) и `Filter` (фильтр после чтения heap, дорого — строки уже прочитаны). #### Index Only Scan Все нужные колонки есть в индексе → heap можно не трогать. Но PostgreSQL обязан проверить видимость строки (MVCC). Если страница «грязная» в visibility map, всё равно идёт `Heap Fetches`. Большое число `Heap Fetches` означает, что таблица давно не `VACUUM` — Index Only Scan теряет смысл. ```sql -- covering index: INCLUDE добавляет колонки в leaf без участия в сортировке CREATE INDEX idx_orders_cover ON orders(user_id) INCLUDE (total, status); EXPLAIN ANALYZE SELECT user_id, total, status FROM orders WHERE user_id = 42; -- Index Only Scan using idx_orders_cover ... -- Heap Fetches: 0 <- идеально ``` #### Bitmap Index Scan + Bitmap Heap Scan Промежуточный режим для средней селективности. Индекс строит **битмап** страниц (а не строк), затем heap читается **в физическом порядке страниц** (последовательно, а не случайно). Позволяет комбинировать несколько индексов через `BitmapAnd`/`BitmapOr`. ```sql EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 OR status = 'pending'; -- Bitmap Heap Scan on orders -- Recheck Cond: (...) -- -> BitmapOr -- -> Bitmap Index Scan on idx_orders_user -- -> Bitmap Index Scan on idx_orders_status ``` `Recheck Cond` появляется, если битмап стал «lossy» (не хватило `work_mem` — хранятся не строки, а целые страницы, которые надо перепроверить). Рост lossy → увеличьте `work_mem`. ### Типы джойнов | Джойн | Когда хорош | Когда плох | Стоимость | |---|---|---|---| | **Nested Loop** | малый внешний набор + индекс по внутренней таблице | большие наборы, недооценка внешних строк | O(N×M) без индекса, O(N×log M) с индексом | | **Hash Join** | большой equi-join, нет полезного порядка | не помещается в `work_mem` (batches на диск); только `=` | O(N+M) | | **Merge Join** | оба входа отсортированы/индексированы, большие наборы | требует сортировки, если нет индекса | O(N+M) после сортировки | #### Nested Loop Для каждой строки внешней таблицы сканируется внутренняя. **Главная senior-ловушка**: если внешних строк планировщик оценил в 10, а реально их 100 000, и внутренний скан — Index Scan, то `loops=100000` и запрос, который «должен» быть мгновенным, висит секунды. ```sql -- Nested Loop (rows=10) <- оценка -- -> Index Scan ... (rows=10) -- -> Index Scan ... (loops=100000) <- РЕАЛЬНОСТЬ: 100k проходов ``` Диагностика: смотрите `loops` на внутреннем узле. Лечение: исправить статистику, либо переписать запрос, чтобы планировщик выбрал Hash Join. #### Hash Join Меньшая («build») таблица грузится в хэш в памяти, большая («probe») сканируется один раз. Если build-сторона не влезает в `work_mem`, разбивается на батчи с записью на диск (`Batches: > 1` в плане) — резко замедляется. ```sql EXPLAIN (ANALYZE, BUFFERS) SELECT ... ; -- Hash (Buckets: 1024 Batches: 4 Memory Usage: ...) -- Batches: 4 <- плохо, не хватило work_mem ``` #### Merge Join Оба входа отсортированы по ключу джойна, идём «слиянием». Идеален, когда сортировка бесплатна (индекс уже даёт порядок). Иначе планировщик добавит `Sort`, который тоже зависит от `work_mem` (`Sort Method: external merge Disk: ...` — ушло на диск). ### Статистика планировщика Планировщик не знает данных — он опирается на статистику в `pg_statistic` (читаемое представление `pg_stats`), собираемую командой `ANALYZE` (и автоматически `autovacuum`'ом). ```sql ANALYZE orders; -- обновить статистику по таблице ANALYZE orders (user_id, total); -- по конкретным колонкам SELECT attname, n_distinct, most_common_vals, most_common_freqs, histogram_bounds FROM pg_stats WHERE tablename = 'orders'; ``` Что хранится по колонке: - **`n_distinct`** — оценка числа уникальных значений. Положительное — абсолютное число; **отрицательное** — доля от числа строк (`-1` = все уникальны, `-0.5` = уникальных половина). Доля используется, потому что масштабируется при росте таблицы. - **MCV (most_common_vals + most_common_freqs)** — самые частые значения и их частоты. Используются для точной оценки селективности по «горячим» значениям (перекос данных). - **Гистограмма (histogram_bounds)** — равночастотные бакеты для значений вне MCV. Применяется для оценки диапазонных условий (`>`, `<`, `BETWEEN`). - **correlation** — насколько физический порядок строк коррелирует с порядком значений колонки (влияет на cost Index Scan: при correlation≈1 случайные чтения почти последовательны). #### default_statistics_target Управляет размером MCV-списка и числом бакетов гистограммы (по умолчанию 100). Увеличение даёт точность ценой времени `ANALYZE` и размера статистики. Для больших перекошенных колонок: ```sql ALTER TABLE orders ALTER COLUMN user_id SET STATISTICS 1000; ANALYZE orders; -- или глобально: SET default_statistics_target = 500; ``` #### Extended statistics — коррелированные колонки По умолчанию планировщик считает колонки **независимыми**: `selectivity(A AND B) = sel(A) × sel(B)`. Для коррелированных колонок (`city` и `zip`, `model` и `brand`) это даёт катастрофическую недооценку. ```sql -- В реальности city='Berlin' и zip='10115' почти эквивалентны, -- но планировщик перемножит селективности и получит rows в разы меньше. CREATE STATISTICS stat_city_zip (dependencies, ndistinct, mcv) ON city, zip FROM addresses; ANALYZE addresses; ``` | Вид | Что исправляет | |---|---| | `ndistinct` | оценка уникальных комбинаций колонок (GROUP BY по нескольким колонкам) | | `dependencies` | функциональные зависимости (zip → city) | | `mcv` | частые комбинации значений (наиболее мощный, как MCV для одной колонки) | ### Cost model Cost вычисляется из параметров (GUC), задающих относительную стоимость операций. Это **условные единицы**, базовая — `seq_page_cost = 1.0`. | Параметр | Default | Смысл | |---|---|---| | `seq_page_cost` | 1.0 | чтение страницы последовательно | | `random_page_cost` | 4.0 | чтение страницы случайно (для SSD часто снижают до 1.1) | | `cpu_tuple_cost` | 0.01 | обработка одной строки | | `cpu_index_tuple_cost` | 0.005 | обработка строки индекса | | `cpu_operator_cost` | 0.0025 | вычисление оператора/функции | | `effective_cache_size` | 4GB | подсказка об объёме ОС-кэша (влияет на выгодность индексов) | `random_page_cost = 4.0` — наследие HDD. На SSD/NVMe случайное чтение почти равно последовательному; завышенный `random_page_cost` заставляет планировщик избегать Index Scan и выбирать Seq Scan. Типичная senior-настройка для SSD: `random_page_cost = 1.1`, увеличенный `effective_cache_size`. #### work_mem Память на **операцию** сортировки/хэша/битмапа (не на запрос и не на сессию!). Один сложный запрос может использовать `work_mem` несколько раз параллельно. Влияние: - мало `work_mem` → `Sort Method: external merge Disk`, Hash Join с `Batches > 1`, lossy bitmap → диск, медленно; - много `work_mem` × много соединений × много операций → OOM. ```sql -- Локально для тяжёлого аналитического запроса SET work_mem = '256MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT ... ORDER BY ...; -- Sort Method: quicksort Memory: 24576kB <- в памяти, хорошо RESET work_mem; ``` ### Оптимизация: почему оценки уходят в молоко Типичные причины расхождения `rows` vs `actual`: 1. **Устаревшая статистика** — массовый `INSERT`/`UPDATE` без `ANALYZE`. Лечение: `ANALYZE`, тюнинг autovacuum. 2. **Коррелированные колонки** — лечение `CREATE STATISTICS`. 3. **Выражения в фильтре** — `WHERE lower(email) = ...` не имеет статистики; нужен индекс по выражению (статистика собирается с expression index) или generated column. 4. **Условия на параметрах bind/prepared** — generic plan может игнорировать конкретные значения (см. `plan_cache_mode`). 5. **JOIN-цепочки** — ошибки оценки накапливаются (error propagation), верх дерева оценивается всё хуже. Когда оценка неверна → выбран не тот узел (Nested Loop вместо Hash Join, Seq Scan вместо Index) → деградация на порядки. #### LIMIT и проблема OFFSET `LIMIT` влияет на план: планировщик предпочтёт «дешёвый старт» (Index Scan, отдающий строки в нужном порядке) ради быстрой первой страницы. Но `LIMIT` может усугубить недооценку: если планировщик думает, что подходящих строк много, а их мало, придётся просканировать почти всё ради нескольких строк. `OFFSET N` **читает и выбрасывает** N строк. Страница 10000 по 20 строк = чтение 200020 строк ради 20. Линейная деградация. ```sql -- ПЛОХО: O(offset) SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 200000; ``` #### Keyset (seek) pagination Запоминаем «курсор» последней строки и фильтруем по нему. Постоянное время на любой странице при наличии индекса по ключу сортировки. ```sql -- Индекс под порядок сортировки CREATE INDEX idx_events_keyset ON events (created_at DESC, id DESC); -- Первая страница SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT 20; -- Следующая: подставляем (created_at, id) последней строки. -- Сравнение кортежей: лексикографическое, корректно обрабатывает ties по created_at. SELECT * FROM events WHERE (created_at, id) < ('2026-06-14 10:00:00', 99999) ORDER BY created_at DESC, id DESC LIMIT 20; ``` Минусы keyset: нельзя «прыгнуть на страницу 500», сложнее при произвольной сортировке. Плюс — стабильность при вставках во время пагинации. #### Go: измерение и keyset ```go // Снять план прямо из приложения для диагностики медленного запроса func explain(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) (string, error) { rows, err := pool.Query(ctx, "EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) "+sql, args...) if err != nil { return "", err } defer rows.Close() var sb strings.Builder for rows.Next() { var line string if err := rows.Scan(&line); err != nil { return "", err } sb.WriteString(line) sb.WriteByte('\n') } return sb.String(), rows.Err() } ``` ```go // Keyset pagination с курсором (created_at, id) type Cursor struct { CreatedAt time.Time ID int64 } func listEvents(ctx context.Context, pool *pgxpool.Pool, cur *Cursor, limit int) ([]Event, error) { const base = ` SELECT id, created_at, payload FROM events %s ORDER BY created_at DESC, id DESC LIMIT $%d` var ( sql string args []any ) if cur == nil { sql = fmt.Sprintf(base, "", 1) args = []any{limit} } else { sql = fmt.Sprintf(base, "WHERE (created_at, id) < ($1, $2)", 3) args = []any{cur.CreatedAt, cur.ID, limit} } rows, err := pool.Query(ctx, sql, args...) if err != nil { return nil, err } defer rows.Close() return pgx.CollectRows(rows, pgx.RowToStructByName[Event]) } ``` ### N+1 проблема и её решение в Go N+1: один запрос за списком сущностей (`1`) + по запросу на каждую дочернюю связь (`N`). При 1000 пользователях — 1001 round-trip к БД. Latency сети умножается, пул соединений истощается. ```go // АНТИПАТТЕРН: N+1 users, _ := getUsers(ctx, pool) // 1 запрос for i := range users { // N запросов — по одному на пользователя! users[i].Orders, _ = getOrdersByUser(ctx, pool, users[i].ID) } ``` #### Решение 1: JOIN (одна выборка) Хорошо для 1:1 и умеренных 1:N. Минус — дублирование родительских колонок в каждой строке (cartesian-инфляция при множественных коллекциях). ```sql SELECT u.id, u.name, o.id AS order_id, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.country = 'DE' ORDER BY u.id; ``` #### Решение 2: IN / = ANY (два запроса) Собрать все id, одним запросом подтянуть детей, разложить в памяти. Часто эффективнее JOIN для 1:N (нет инфляции). В pgx используйте `= ANY($1)` с массивом — это лучше, чем динамически строить `IN ($1,$2,...)` (нет риска переполнения параметров и переиспользуется prepared statement). ```go func loadOrders(ctx context.Context, pool *pgxpool.Pool, users []User) error { ids := make([]int64, len(users)) idx := make(map[int64]*User, len(users)) for i := range users { ids[i] = users[i].ID idx[users[i].ID] = &users[i] } rows, err := pool.Query(ctx, `SELECT id, user_id, total FROM orders WHERE user_id = ANY($1)`, ids) if err != nil { return err } defer rows.Close() for rows.Next() { var o Order if err := rows.Scan(&o.ID, &o.UserID, &o.Total); err != nil { return err } if u := idx[o.UserID]; u != nil { u.Orders = append(u.Orders, o) } } return rows.Err() } ``` #### Решение 3: pgx.Batch (pipelining) Когда нужно несколько **разных** запросов за один round-trip. pgx отправляет их в pipeline (protocol-level), сервер обрабатывает последовательно, но сеть проходится один раз. ```go func loadDashboard(ctx context.Context, pool *pgxpool.Pool, userID int64) (*Dashboard, error) { batch := &pgx.Batch{} batch.Queue(`SELECT name, email FROM users WHERE id = $1`, userID) batch.Queue(`SELECT count(*) FROM orders WHERE user_id = $1`, userID) batch.Queue(`SELECT coalesce(sum(total),0) FROM orders WHERE user_id = $1`, userID) br := pool.SendBatch(ctx, batch) defer br.Close() var d Dashboard if err := br.QueryRow().Scan(&d.Name, &d.Email); err != nil { return nil, err } if err := br.QueryRow().Scan(&d.OrderCount); err != nil { return nil, err } if err := br.QueryRow().Scan(&d.TotalSpent); err != nil { return nil, err } return &d, nil } ``` #### Решение 4: dataloader (батчинг + дедупликация) В GraphQL/сервисах с динамическим доступом к связям — паттерн dataloader: накапливать запрошенные ключи в течение «тика», затем один батч-запрос с `= ANY`. Снимает N+1 без ручного переписывания каждого call-site. | Подход | Когда | |---|---| | JOIN | 1:1, простые 1:N, нужен один проход | | IN / = ANY | 1:N без инфляции, два чётких шага | | pgx.Batch | несколько разных запросов, экономия RTT | | dataloader | динамический граф связей (GraphQL), дедупликация | ## Подводные камни / gotchas - **`EXPLAIN ANALYZE` выполняет DML.** Без транзакции с `ROLLBACK` вы реально вставите/удалите данные. Также выполнятся побочные эффекты триггеров. - **`actual rows` и `actual time` — на один loop, не суммарно.** Чтобы оценить вклад Nested Loop, умножайте на `loops`. Это частая ошибка чтения плана. - **Сам `EXPLAIN ANALYZE` добавляет overhead** на таймеры (особенно на узлах с миллионами строк). Реальный запрос без инструментирования может быть быстрее. Для измерения чистого overhead есть `EXPLAIN (ANALYZE, TIMING OFF)`. - **Cost не равен времени.** План с меньшим cost может быть медленнее в проде из-за неучтённого состояния кэша, конкуренции, перекоса данных. - **Index Only Scan ≠ «heap не читается».** При `Heap Fetches > 0` (устаревший visibility map) выгода теряется. Следите за `VACUUM`. - **`Filter` vs `Index Cond`.** Условие в `Filter` означает, что строки уже прочитаны из heap и отброшены — индекс не помог по этому предикату. `Rows Removed by Filter` показывает масштаб потерь. - **`work_mem` — на операцию, не на запрос.** Запрос с тремя сортировками и хэшем может занять `4 × work_mem`. При высоком числе соединений легко получить OOM. - **Generic plan у prepared statements.** После 5 исполнений pgx/PostgreSQL может перейти на generic plan, игнорирующий конкретные значения параметров → плохой план при перекосе. Управление: `SET plan_cache_mode = force_custom_plan` или отключить prepared (pgx `QueryExecModeExec`). - **Перекошенные данные + MCV.** Если значение в MCV — оценка точная; вне MCV для редких значений оценка может быть грубой. Увеличьте `STATISTICS` target. - **`OFFSET` в API.** Кажется удобным, но при глубокой пагинации убивает БД и даёт нестабильные результаты при конкурентных вставках. Keyset надёжнее. - **`= ANY($1)` vs `IN`.** Динамический `IN ($1,...,$N)` плодит разные планы и упирается в лимит ~65535 параметров. `= ANY(массив)` — один параметр, один план. - **Параллельные планы.** `Gather`/`Parallel Seq Scan` — actual rows показаны на воркер; суммарно умножается на число воркеров. Не путайтесь при чтении. - **`autovacuum` не успевает** на горячих таблицах → статистика устаревает между прогонами. Настраивайте `autovacuum_analyze_scale_factor` для крупных таблиц. ## Вопросы на собеседовании **В:** В чём разница между `EXPLAIN` и `EXPLAIN ANALYZE`, и почему второй опасен? **О:** `EXPLAIN` показывает план с оценками планировщика, не выполняя запрос. `EXPLAIN ANALYZE` реально выполняет запрос и добавляет фактические time/rows/loops, что позволяет сравнить оценку с реальностью. Опасен потому, что выполняет и DML — `EXPLAIN ANALYZE DELETE ...` действительно удалит данные и выполнит триггеры. Для DML оборачивают в `BEGIN; ... ROLLBACK;`. **В:** Как вы понимаете, что план плохой, глядя на `EXPLAIN ANALYZE`? **О:** Главный сигнал — расхождение `rows` (оценка) и `actual rows` (факт) на узле в разы/порядки. Дальше: высокое `Rows Removed by Filter` при Seq Scan, большое `loops` на внутреннем узле Nested Loop, `Sort Method: external merge Disk` или Hash `Batches > 1` (не хватает work_mem), много `shared read` в BUFFERS (холодный кэш). Расхождение оценки — корневая причина, остальное следствие неверного выбора узла. **В:** Когда планировщик выберет Seq Scan вместо Index Scan по существующему индексу? **О:** Когда выбирается большая доля таблицы (грубо >5–20%) — последовательное чтение дешевле множества случайных; когда таблица маленькая (вся в нескольких страницах); когда `random_page_cost` завышен относительно реального железа (SSD); когда статистика устарела и планировщик переоценил число строк; когда условие в `Filter`, а не `Index Cond` (предикат не покрывается индексом). **В:** Объясните Nested Loop, Hash Join и Merge Join — когда каждый оптимален? **О:** Nested Loop — для каждой строки внешнего набора скан внутреннего; хорош при малом внешнем наборе и индексе по внутренней таблице (O(N log M)), катастрофичен при недооценке внешних строк. Hash Join — строит хэш по меньшей таблице, пробивает большей; лучший для крупных equi-join без полезного порядка, но зависит от work_mem (иначе batches на диск); только для `=`. Merge Join — слияние двух отсортированных входов; идеален, когда порядок дают индексы, иначе добавляется дорогой Sort. **В:** Что хранит планировщик в статистике и как `ANALYZE` влияет на планы? **О:** В `pg_statistic` хранятся `n_distinct` (число/доля уникальных), MCV (частые значения + частоты) для точной оценки перекоса, гистограммы для диапазонных условий, correlation для оценки cost индекса. `ANALYZE` обновляет эти данные сэмплированием. Без актуальной статистики после массового изменения данных оценки строк ломаются и планировщик выбирает неоптимальные узлы. **В:** Планировщик сильно недооценивает строки для условия по двум колонкам. Почему и как лечить? **О:** Планировщик по умолчанию считает колонки независимыми и перемножает селективности: `sel(A AND B) = sel(A)×sel(B)`. Для коррелированных колонок (city/zip) это даёт оценку в разы/порядки меньше реальной. Лечение — extended statistics: `CREATE STATISTICS ... (dependencies, ndistinct, mcv) ON city, zip FROM ...; ANALYZE;`. Можно также поднять `STATISTICS` target для точности MCV/гистограммы. **В:** Почему `OFFSET` плох для пагинации и что использовать вместо? **О:** `OFFSET N` читает и отбрасывает N строк — стоимость линейна по глубине, на глубоких страницах БД делает огромную лишнюю работу; плюс при конкурентных вставках результаты «съезжают». Альтернатива — keyset/seek pagination: фильтр `WHERE (created_at, id) < (cursor)` с индексом под порядок сортировки даёт постоянное время на любой странице и стабильность. Минус keyset — нельзя прыгнуть на произвольную страницу. **В:** Что такое N+1 и как решаете в Go-сервисе на pgx? **О:** N+1 — один запрос за списком + по запросу на каждую связь, что даёт N+1 round-trip и истощает пул. Решения: JOIN (один проход, риск инфляции при множественных коллекциях); собрать id и подтянуть детей одним `WHERE user_id = ANY($1)`, разложить в map (без инфляции); `pgx.Batch`/`SendBatch` для нескольких разных запросов за один RTT (pipelining); dataloader для динамического графа связей с дедупликацией ключей. **В:** Что значит `loops` в плане и как считать реальное время узла? **О:** `loops` — сколько раз узел был выполнен (характерно для внутренней стороны Nested Loop и параллельных воркеров). Выводимые `actual time` и `actual rows` — средние **на один loop**. Суммарное число строк = `rows × loops`, суммарное время ≈ `total_time × loops`. Большое `loops` на внутреннем индексном скане — главный признак взорвавшегося Nested Loop из-за недооценки внешнего набора. **В:** Зачем нужен `BUFFERS` и что показывает `shared hit/read`? **О:** `BUFFERS` показывает работу с буферным кэшем: `shared hit` — страница найдена в `shared_buffers` (быстро), `shared read` — пришлось читать с диска/ОС-кэша (медленно), `dirtied/written` — изменённые страницы. Это объясняет, почему одинаковый план даёт разный actual time на холодном и тёплом кэше, и помогает отличить проблему плана от проблемы IO. ## На что копают на senior+ - **Error propagation в оценках.** Ошибка оценки на нижнем джойне множится вверх по дереву; уметь локализовать узел, где оценка впервые разъехалась с фактом, и объяснить почему именно там. - **Тюнинг cost model под железо.** Осознанно менять `random_page_cost` (SSD → ~1.1), `effective_cache_size`, `work_mem` per-statement через `SET LOCAL`; понимать, что это меняет выбор Seq vs Index и Hash vs Sort+Merge. - **Generic vs custom plan.** Механизм `plan_cache_mode`, поведение prepared statements после 5 исполнений, как перекос данных + generic plan дают деградацию, и управление этим в pgx (`QueryExecMode*`). - **Extended statistics на практике.** Не только знать про `CREATE STATISTICS`, но и какой вид (`dependencies`/`ndistinct`/`mcv`) под какую проблему, и как проверить эффект (`pg_stats_ext`). - **Partial и expression indexes** как инструмент против `Filter`/`Rows Removed`: индекс по выражению, частичный индекс под горячий предикат (`WHERE status = 'active'`), covering index с `INCLUDE` для Index Only Scan. - **Параллельные планы и их чтение.** `Gather`, `Parallel Seq Scan`, per-worker rows, `parallel_setup_cost`, когда параллелизм вредит (мелкие запросы, высокий setup). - **Автовакуум и статистика на горячих таблицах.** Связь `autovacuum`/`ANALYZE` со свежестью планов, настройка scale factor, влияние на Index Only Scan через visibility map. - **JIT.** На больших аналитических запросах PostgreSQL может включить JIT-компиляцию выражений; уметь распознать в плане (`JIT: ...`) и когда его стоит отключить (`jit_above_cost`). - **Системный взгляд на N+1.** Не только устранить в одном месте, но и архитектурно: dataloader-слой, batching на уровне репозитория, лимиты пула (`pgxpool`), backpressure, чтобы один тяжёлый эндпоинт не выедал соединения.