Модуль: Базы данных · Уровень: 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 |
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.
Как читать план: снизу вверх#
План — это дерево. Узлы-листья (сканы) внизу, корень — вверху. Каждый родитель потребляет строки от детей. Отступ показывает вложенность.
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')Как читать:
- Внутренние сканы выполняются первыми.
Seq Scan on usersстроит хэш-таблицу (Hash),Seq Scan on ordersпробивает её. actual time=startup..total— startup это время до первой строки, total — до последней. ДляEXPLAIN ANALYZEэто на один проход узла; умножайте наloops, чтобы получить суммарное время.loops— сколько раз узел исполнялся (критично для Nested Loop).actual rowsиactual timeв выводе — это средние на один loop, а не суммарные. Реальное число строк =rows × loops.Rows Removed by Filter— сколько строк прочитано и отброшено. Высокое значение приSeq Scan— кандидат на индекс. Здесь 35988 строк прочитано впустую — индекс поtotalили частичный индекс мог бы помочь.- Сравните
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_statusRecheck 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_memMerge 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_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.
-- Локально для тяжёлого аналитического запроса
SET work_mem = '256MB';
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ORDER BY ...;
-- Sort Method: quicksort Memory: 24576kB <- в памяти, хорошо
RESET work_mem;Оптимизация: почему оценки уходят в молоко#
Типичные причины расхождения rows vs actual:
- Устаревшая статистика — массовый
INSERT/UPDATEбезANALYZE. Лечение:ANALYZE, тюнинг autovacuum. - Коррелированные колонки — лечение
CREATE STATISTICS. - Выражения в фильтре —
WHERE lower(email) = ...не имеет статистики; нужен индекс по выражению (статистика собирается с expression index) или generated column. - Условия на параметрах bind/prepared — generic plan может игнорировать конкретные значения (см.
plan_cache_mode). - 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.
| Подход | Когда |
|---|---|
| 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. FiltervsIndex 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 (pgxQueryExecModeExec). - Перекошенные данные + MCV. Если значение в MCV — оценка точная; вне MCV для редких значений оценка может быть грубой. Увеличьте
STATISTICStarget. OFFSETв API. Кажется удобным, но при глубокой пагинации убивает БД и даёт нестабильные результаты при конкурентных вставках. Keyset надёжнее.= ANY($1)vsIN. Динамический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_memper-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, чтобы один тяжёлый эндпоинт не выедал соединения.