Модуль: Базы данных · Уровень: 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. Это даёт возможность увидеть расхождение оценки и факта — главный сигнал проблем.

-- Только план, запрос не выполняется
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;

Полезные опции (рекомендуемый «боевой» набор):

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT, SETTINGS, WAL)
SELECT ...;
ОпцияЧто даёт
ANALYZEреально выполнить, показать actual time/rows/loops
BUFFERShits/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.

Как читать план: снизу вверх#

План — это дерево. Узлы-листья (сканы) внизу, корень — вверху. Каждый родитель потребляет строки от детей. Отступ показывает вложенность.

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.

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 теряет смысл.

-- 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.

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 и запрос, который «должен» быть мгновенным, висит секунды.

-- 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 в плане) — резко замедляется.

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‘ом).

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 и размера статистики. Для больших перекошенных колонок:

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) это даёт катастрофическую недооценку.

-- В реальности 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_cost1.0чтение страницы последовательно
random_page_cost4.0чтение страницы случайно (для SSD часто снижают до 1.1)
cpu_tuple_cost0.01обработка одной строки
cpu_index_tuple_cost0.005обработка строки индекса
cpu_operator_cost0.0025вычисление оператора/функции
effective_cache_size4GBподсказка об объёме ОС-кэша (влияет на выгодность индексов)

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_memSort Method: external merge Disk, Hash Join с Batches > 1, lossy bitmap → диск, медленно;
  • много work_mem × много соединений × много операций → OOM.
-- Локально для тяжёлого аналитического запроса
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. Линейная деградация.

-- ПЛОХО: O(offset)
SELECT * FROM events ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;

Keyset (seek) pagination#

Запоминаем «курсор» последней строки и фильтруем по нему. Постоянное время на любой странице при наличии индекса по ключу сортировки.

-- Индекс под порядок сортировки
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#

// Снять план прямо из приложения для диагностики медленного запроса
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()
}
// 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 сети умножается, пул соединений истощается.

// АНТИПАТТЕРН: 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-инфляция при множественных коллекциях).

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).

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), сервер обрабатывает последовательно, но сеть проходится один раз.

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.

ПодходКогда
JOIN1:1, простые 1:N, нужен один проход
IN / = ANY1: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, чтобы один тяжёлый эндпоинт не выедал соединения.