voidlearn
 

6.5Оптимизация запросов

Разбирай медленный запрос по фактическому плану: сравнивай оценки строк с реальностью, ищи лишние чтения и только потом меняй SQL или индекс. Эта глава также показывает, как распознать N+1, дорогой OFFSET и неудачную пакетную загрузку в Go-коде.

EXPLAIN и EXPLAIN ANALYZE

EXPLAIN показывает план, который выбрал планировщик, вместе с его оценками: сколько строк он ожидает и во что оценивает стоимость. Запрос при этом не выполняется. EXPLAIN ANALYZE же реально выполняет запрос и добавляет к плану фактические числа: сколько строк вернул каждый узел, сколько времени занял, сколько раз был вызван.

Разница принципиальная: EXPLAIN отвечает на вопрос «что база собирается делать», EXPLAIN ANALYZE - «что она сделала и где ошиблась в оценках». Диагностика почти всегда живёт во втором: плохие планы чаще всего растут из расхождения между rows и actual rows.

Главный подвох: ANALYZE на пишущем запросе

EXPLAIN ANALYZE UPDATE ... реально выполнит UPDATE. Не «покажет план», а обновит строки. То же с DELETE, INSERT, TRUNCATE. На проде это способ уронить данные одной командой. Безопаснее обернуть запрос в транзакцию и откатить:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'cancelled' WHERE id = 42;
ROLLBACK;   -- обязательно; лучше набрать заранее, а не после

Но даже с ROLLBACK побочные эффекты остаются. Транзакция успела взять блокировки на изменяемые строки и держит их до конца блока; сработали триггеры (включая внешние вызовы, если они есть); сгенерировался WAL; выросли счётчики последовательностей - nextval не откатывается. На горячей таблице такой «безопасный» эксперимент легко выстроит очередь блокировок.

ОпцияЧто даётКогда включать
ANALYZEреальное выполнение + фактические строки и времяпочти всегда при разборе
BUFFERSсколько страниц прочитано из кэша (hit) и с диска (read)всегда вместе с ANALYZE; с PG 18 включается сам
VERBOSEсписок выводимых колонок, схемы, имена функцийкогда непонятно, что за узел
SETTINGSизменённые от дефолта параметры планировщикакогда план «странный»
WALобъём сгенерированного WALразбор тяжёлой записи
FORMAT JSONмашиночитаемый пландля explain.dalibo.com / explain.depesz.com
GENERIC_PLAN (PG 16)план для запроса с параметрами без их значенийразбор запроса из pg_stat_statements
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM   orders o JOIN customers c ON c.id = o.customer_id
WHERE  o.created_at >= now() - interval '7 days' AND o.status = 'paid'
ORDER  BY o.created_at DESC
LIMIT  50;

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

План устроен как дерево. В текстовом выводе оно записано с отступами: чем глубже отступ, тем ниже узел в дереве. Данные текут снизу вверх: самые вложенные узлы читают таблицы и индексы, отдают строки родителю, тот соединяет или агрегирует, и так до корня, который отдаёт результат клиенту. Поэтому читать нужно от самых глубоких строк, а не сверху.

Дерево плана: данные текут снизу вверх Limit rows=50 actual=50 Sort (created_at DESC) Sort Method: top-N heapsort Hash Join cond: c.id = o.customer_id Index Scan on orders rows=1200 actual=1180 Buffers: hit=340 Hash Buckets: 4096 Memory: 512kB Seq Scan on customers rows=40000 actual=40000 Buffers: read=980 внешняя (probe) сторона внутренняя (build) сторона Читать начинаем отсюда: самые глубокие узлы - это сканы таблиц. Здесь и ищем проблему: Seq Scan 40 000 строк с диска (read, не hit).
Дерево плана. Отступы в текстовом выводе показывают уровни дерева. Каждый узел получает строки от детей и отдаёт родителю. Время в родителе включает время детей, поэтому «собственную» стоимость узла надо вычитать.

Узлы, которые надо узнавать

УзелЧто делаетКогда это нормально / плохо
Seq Scanчитает всю таблицу подряднорма для маленькой таблицы и для выборки большой доли строк; плохо, если фильтр отсекает почти всё
Index Scanспуск по индексу + поход в heap за каждой строкойнорма при выборке единиц-сотен строк; при десятках тысяч случайных чтений проигрывает Seq Scan
Index Only Scanтолько индекс, heap не читаетсялучший вариант; следи за Heap Fetches - если оно велико, выигрыш потерян
Bitmap Index Scan + Bitmap Heap Scanсобирает ctid в битовую карту, сортирует по страницам, читает heap почти последовательнокомпромисс между Index и Seq Scan; появление lossy в Heap Blocks означает нехватку work_mem
Nested Loopдля каждой строки внешней таблицы ищет совпадения во внутреннейотлично, если внешних строк единицы и есть индекс внутри; катастрофа, если внешних строк миллион (обычно - ошибка оценки)
Hash Joinстроит хеш меньшей таблицы в памяти, прогоняет большуюрабочая лошадка для больших джойнов по равенству; Batches > 1 = хеш не влез в work_mem, ушёл на диск
Merge Joinсливает два отсортированных потокахорош, когда обе стороны уже отсортированы (индексами); иначе платим за два Sort
Sortсортируетсмотри Sort Method: quicksort/top-N heapsort в памяти - ок, external merge Disk: 250MB - поднимай work_mem или дай индекс
HashAggregate / GroupAggregateгруппировка хешем или по отсортированному входуHashAggregate быстрее, но требует памяти; при её нехватке - Disk Usage
Materialize, Memoizeкэширует результат подузла между итерациямиMemoize (PG 14) спасает Nested Loop с повторяющимися ключами
Gather / Gather Mergeсобирает результат параллельных воркеровсмотри Workers Launched: может быть меньше запрошенных

Числа в плане и что они значат

Seq Scan on orders  (cost=0.00..24583.00 rows=980 width=64)
                    (actual time=0.021..312.447 rows=51230 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 948770
  Buffers: shared hit=1204 read=13379
  • cost=A..B дан в условных единицах планировщика, не в миллисекундах. A оценивает путь до первой строки (важен при LIMIT), B - до последней. За единицу принята стоимость последовательного чтения одной страницы (seq_page_cost = 1.0); случайное чтение по умолчанию стоит 4.0. Сравнивать cost можно только между планами одного запроса.
  • rows vs actual rows сравнивают оценку с фактом. Расхождение в 10+ раз и есть главная зацепка: почти всегда это устаревшая статистика, коррелированные колонки, которые планировщик считает независимыми, или неудачная оценка селективности выражения. Лечится ANALYZE, default_statistics_target, CREATE STATISTICS.
  • loops показывает, сколько раз узел выполнялся. Время и строки в плане указаны на одну итерацию: реальное время узла = actual time × loops. Классическая ловушка: «0.03 ms, всё быстро» при loops=200000, а это 6 секунд.
  • Rows Removed by Filter считает строки, которые прочитали и выбросили. Миллион выброшенных при тысяче нужных прямо говорит, что нужен индекс по этому предикату (возможно, частичный).
  • Buffers: shared hit=... read=... делит страницы на hit, взятые из shared_buffers, и read, прочитанные из ОС/диска. Точнее меры «сколько данных мы реально перелопатили» нет, и от прогретости кэша она зависит меньше, чем время. dirtied/written показывают побочную запись.
  • Planning Time / Execution Time: если планирование сравнимо с выполнением, а запрос простой, смотри в сторону избытка индексов или подготовленных выражений.
Алгоритм чтения плана за 30 секунд
  1. Найти узел с наибольшим собственным временем (время узла минус время детей, умноженное на loops).
  2. Посмотреть на его rows против actual rows. Если оценка врёт, проблема в статистике, а не в запросе.
  3. Посмотреть Rows Removed by Filter - если много, нужен индекс/предикат.
  4. Проверить Buffers read: при сотнях тысяч мы просто читаем слишком много данных.
  5. Проверить признаки нехватки памяти: Batches > 1, external merge, lossy, Disk Usage.

Кейс: запрос на таблице в несколько терабайт тормозит

Здесь важен порядок действий, а не знание команд: начинать надо с измерения, а не с «добавлю индекс».

  1. Понять, что именно тормозит. Не «сайт медленный», а конкретный запрос: pg_stat_statements с сортировкой по total_exec_time (не по mean: главным вредителем часто оказывается быстрый запрос, который выполняется 50 000 раз в минуту) и pg_stat_activity для того, что висит прямо сейчас.
  2. Снять план. EXPLAIN (ANALYZE, BUFFERS) на репрезентативных параметрах. Если запроса не дождаться, хватит EXPLAIN без ANALYZE плюс auto_explain с log_min_duration в проде.
  3. Проверить, не блокировки ли это. Медленный не значит «плохой план»: посмотреть wait_event_type = 'Lock' в pg_stat_activity. Если ждём блокировку, план тут ни при чём.
  4. Проверить статистику. rows против actual rows. Если оценка врёт на порядки, запустить ANALYZE и снять план заново. Половина «загадочных» проблем закрывается здесь.
  5. Уменьшить объём читаемых данных. В порядке предпочтения:
    • индекс под предикат (составной, частичный, покрывающий);
    • переписать запрос: убрать функцию с колонки, заменить OR на UNION ALL, коррелированный подзапрос - на джойн или оконную функцию, DISTINCT - на EXISTS, вынести тяжёлую агрегацию в CTE с MATERIALIZED или наоборот;
    • убрать SELECT *, чтобы стал возможен index-only scan.
  6. Партиционирование. Если таблица многотерабайтная и запросы всегда ограничены по времени или тенанту, подойдёт RANGE по дате или HASH по tenant_id. Заодно решается проблема обслуживания: удаление старых данных превращается из многочасового DELETE в мгновенный DETACH PARTITION.
  7. Изменить модель. Предагрегировать (материализованное представление, счётчики, роллапы), вынести холодные данные в архивную таблицу, денормализовать нужную колонку, чтобы избавиться от джойна. Это уже не «оптимизация запроса», а изменение схемы - но на терабайтах именно оно обычно и работает.
  8. Только потом - железо и параметры. work_mem для конкретного запроса, random_page_cost под SSD, max_parallel_workers_per_gather, вынос отчётов на реплику.
Как это звучит в ответе

«Сначала я нахожу конкретный запрос через pg_stat_statements, снимаю EXPLAIN ANALYZE BUFFERS и смотрю, где расходятся оценки и где читается больше всего страниц. Дальше иду от дешёвого к дорогому: статистика → индекс → переписывание запроса → партиционирование → изменение модели данных. Железо и настройки оставляю на конец: они дают проценты, а первые шаги - порядки».

Проблема N+1

N+1 случается, когда вместо одного запроса за связанными данными приложение делает один запрос за списком и ещё по одному на каждый его элемент. Сто заказов - сто один запрос. Каждый из них быстрый (0,4 мс), поэтому в логах БД проблема не выглядит проблемой; убивает сетевой round-trip: 101 × (RTT 0,5 мс + разбор + планирование) вместо одного.

N+1: сто быстрых запросов дороже одного «медленного» Было: 1 + N запросов 0 мс ≈ 90 мс SELECT * FROM orders LIMIT 100 1 round-trip SELECT * FROM customers WHERE id = ? - ещё 100 раз Стало: 1 запрос 0 мс ≈ 3 мс SELECT ... FROM orders JOIN customers ON ... либо WHERE customer_id = ANY($1) одним IN 1 round-trip, 1 план, 1 проход по данным
N+1 на оси времени. Каждый из ста запросов «быстрый», но суммарная задержка складывается из сотни сетевых обменов и сотни планирований. В логах медленных запросов такого не видно вообще, поэтому искать надо по количеству, а не по длительности.

Как обнаружить

  • Логи БД: временно log_min_duration_statement = 0 на одном инстансе и посмотреть, что один HTTP-запрос породил 200 строк лога с одинаковым текстом и разными параметрами. В pg_stat_statements то же видно по огромному calls при крошечном mean_exec_time.
  • Трейсинг: в OpenTelemetry на спане обработчика видно частокол одинаковых дочерних спанов db.query. Это самый наглядный способ показать проблему продакту.
  • Счётчик запросов в тесте лучше всего: он ловит регрессию до прода. Оборачиваем драйвер и проверяем ожидаемое число обращений.
// Тест, который ловит N+1 до релиза: считаем запросы через обёртку над драйвером.
type countingDriver struct {
    inner driver.Driver
    n     *atomic.Int64
}

func TestListOrders_NoNPlusOne(t *testing.T) {
    db, counter := newCountingDB(t)          // регистрирует обёрнутый драйвер
    svc := service.New(repo.New(db))

    _, err := svc.ListOrdersWithCustomers(ctx, 100)
    require.NoError(t, err)

    // Ровно два запроса: заказы + пачка клиентов одним IN.
    // Если кто-то вернёт цикл с запросом внутри, тест упадёт с n = 101.
    require.LessOrEqual(t, counter.Load(), int64(2),
        "похоже на N+1: запросов %d", counter.Load())
}

Как чинить

// Плохо: N+1
orders, _ := repo.ListOrders(ctx, 100)
for i, o := range orders {
    // отдельный поход в БД на каждой итерации
    c, err := repo.GetCustomer(ctx, o.CustomerID)
    if err != nil {
        return err
    }
    orders[i].Customer = c
}
// Хорошо: два запроса, батч по ключам
orders, _ := repo.ListOrders(ctx, 100)

ids := make([]int64, 0, len(orders))
seen := make(map[int64]struct{}, len(orders))
for _, o := range orders {
    if _, ok := seen[o.CustomerID]; !ok {
        seen[o.CustomerID] = struct{}{}
        ids = append(ids, o.CustomerID)
    }
}
// WHERE id = ANY($1): один запрос, один план
custs, _ := repo.GetCustomersByIDs(ctx, ids)
byID := make(map[int64]Customer, len(custs))
for _, c := range custs {
    byID[c.ID] = c
}
for i, o := range orders {
    orders[i].Customer = byID[o.CustomerID]
}
-- Вариант 1: JOIN, когда нужна плоская выборка
SELECT o.id, o.total, c.id, c.name
FROM   orders o JOIN customers c ON c.id = o.customer_id
WHERE  o.created_at >= $1;

-- Вариант 2: батч по ключам. В Go с pgx массив передаётся напрямую:
SELECT id, name FROM customers WHERE id = ANY($1);
-- ANY($1) лучше, чем IN (...) со склейкой: один текст запроса → один план в кэше,
-- а не тысяча разных текстов с разным числом плейсхолдеров.

-- Вариант 3: собираем вложенный список на стороне БД (вместо N запросов за позициями)
SELECT o.id, o.total,
       coalesce(jsonb_agg(jsonb_build_object('sku', i.sku, 'qty', i.qty))
                FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM   orders o LEFT JOIN order_items i ON i.order_id = o.id
WHERE  o.id = ANY($1)
GROUP  BY o.id, o.total;

Dataloader / батчинг обобщает этот приём: слой собирает запросы по ключам, пришедшие за короткое окно (или за время одного HTTP-запроса), склеивает их в один WHERE id = ANY(...) и раздаёт результаты обратно. Обязателен в GraphQL, где структура запроса заранее неизвестна, и очень полезен в gRPC-агрегаторах. В Go это обычно канал заявок + таймер на 1–5 мс + дедупликация ключей + singleflight, чтобы одинаковые ключи не ходили в БД дважды.

Обратная сторона: JOIN-взрыв

Чинить N+1 одним большим JOIN через несколько связей «один ко многим» опасно: заказ с 10 позициями и 5 платежами превращается в 50 строк, а поля заказа дублируются 50 раз. Это «декартово раздувание». Правильнее либо сделать два-три отдельных батч-запроса и склеить результат в коде, либо собрать вложенные коллекции в JSON на стороне БД.

Пагинация: OFFSET/LIMIT против keyset

OFFSET n не умеет «перепрыгнуть» n строк. База обязана их найти, материализовать и выбросить. Значит, стоимость страницы растёт линейно с её номером: первая стоит 20 строк, пятитысячная - 100 020 строк работы ради тех же 20. Отсюда чаще всего и берётся «у нас в админке последние страницы отваливаются по таймауту».

OFFSET читает всё «до», keyset прыгает сразу в нужное место OFFSET 100000 LIMIT 20 прочитать и выбросить 100 000 строк отдать 20 ≈ 1,8 с WHERE (created_at, id) < ($1, $2) ORDER BY ... LIMIT 20 спуск по индексу - эти строки не читаются вообще отдать 20 ≈ 0,4 мс Время страницы от её номера мс № страницы OFFSET: линейно keyset: константа Второй, менее очевидный дефект OFFSET: пока пользователь листает, в начало списка вставляются новые строки - и элементы «съезжают»: часть видна дважды, часть не видна ни на одной странице.
OFFSET против keyset. Keyset вместо номера строки передаёт значение ключа последней показанной строки, поэтому индекс сразу спускается в нужную точку. Стоимость страницы не зависит от её номера - и данные не «плывут» при вставках.
-- Плохо: деградирует линейно
SELECT id, created_at, title FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- Хорошо: keyset по составному ключу (created_at может повторяться,
-- поэтому обязательно добавляем уникальный тай-брейкер id)
SELECT id, created_at, title FROM posts
WHERE  (created_at, id) < ($1, $2)      -- значения последней строки прошлой страницы
ORDER  BY created_at DESC, id DESC
LIMIT  20;

-- Нужен индекс ровно в порядке сортировки:
CREATE INDEX posts_created_id_idx ON posts (created_at DESC, id DESC);

Кортежи (a, b) < ($1, $2) сравниваются лексикографически, ровно как ключи составного индекса, поэтому запрос превращается в один спуск по дереву. Разворачивать его руками в a < $1 OR (a = $1 AND b < $2) не надо: планировщик такой OR обычно уже не превращает в один Index Scan.

// Курсор наружу отдаём непрозрачной строкой, чтобы клиент не завязывался на формат
type Cursor struct {
    CreatedAt time.Time `json:"c"`
    ID        int64     `json:"i"`
}

func encodeCursor(c Cursor) string {
    b, _ := json.Marshal(c)
    return base64.RawURLEncoding.EncodeToString(b)
}

func (r *Repo) ListPosts(ctx context.Context, after *Cursor, limit int) ([]Post, *Cursor, error) {
    const q = `
        SELECT id, created_at, title FROM posts
        WHERE  ($1::timestamptz IS NULL) OR (created_at, id) < ($1, $2)
        ORDER  BY created_at DESC, id DESC
        LIMIT  $3`
    // limit+1, чтобы без отдельного COUNT понять, есть ли следующая страница
    rows, err := r.db.Query(ctx, q, after.time(), after.id(), limit+1)
    ...
}
OFFSET/LIMITKeyset (cursor)
Стоимость страницы NO(N × размер страницы)O(размер страницы)
Переход на произвольную страницуданет, только вперёд/назад
Стабильность при вставкахнет, элементы «съезжают»да
Общее число страницможно посчитать COUNT(*) (тоже дорого)обычно не показывают, либо оценка через reltuples
Сложность реализациитривиальнонужен уникальный тай-брейкер и индекс в порядке сортировки
Где уместноадминки на десятки страниц, где нужна навигация «страница 7»бесконечные ленты, API, экспорт, любые большие объёмы
Промежуточные варианты

Если продукт требует именно номера страниц, но данных много: (1) ограничить глубину («не более 100 страниц, дальше уточните фильтр»), как делают все крупные поисковики; (2) гибрид: keyset внутри, а номера страниц служат ярлыками для уже пройденных курсоров; (3) для «прыжка» по отсортированной колонке - WHERE created_at < $anchor, где якорь берётся из заранее посчитанной таблицы границ.

SELECT * - чем он плох

  • Ломает index-only scan. Даже идеально покрывающий индекс бесполезен, если запрос требует все колонки: придётся идти в heap за каждой строкой.
  • Тянет TOAST. Одна колонка jsonb или text на мегабайт, которая никому не нужна, превращает лёгкий запрос в чтение внешних чанков.
  • Трафик и память. Строки едут по сети целиком и материализуются в приложении; на пагинации по 1000 строк 4 нужные колонки против 40 дают разницу в десятки мегабайт.
  • Ломкость при ALTER. Кто-то добавил колонку - и rows.Scan с фиксированным набором приёмников падает в рантайме с «sql: expected 12 destination arguments in Scan, not 11». Или, что хуже, порядок колонок меняется и данные тихо разъезжаются по полям.
  • Непрозрачность. По коду нельзя понять, какие поля реально нужны, и рефакторинг схемы превращается в гадание.

Отдельно про SELECT count(*): он честно проверяет видимость каждой строки, поэтому на больших таблицах всегда дорог. Для «примерно сколько» есть SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders', для точного - либо инкрементальный счётчик в отдельной таблице, либо смирение. Уместен SELECT * разве что при интерактивной отладке в psql.

Батчевые вставки

Вставка миллиона строк по одной оборачивается миллионом round-trip, миллионом парсингов, миллионом фиксаций транзакции с fsync. Батчи дают 10–100×.

-- 1. Multi-row INSERT: один запрос, один разбор, одна транзакция
INSERT INTO events (user_id, kind, payload) VALUES
  ($1, $2, $3), ($4, $5, $6), ($7, $8, $9);
-- Практический предел: 65535 параметров на запрос в протоколе PostgreSQL.
-- При 3 колонках это ~21 800 строк; на практике батчи по 500–5000 строк оптимальны.

-- 2. unnest: текст запроса не меняется с размером батча, поэтому план в кэше один
INSERT INTO events (user_id, kind, payload)
SELECT * FROM unnest($1::bigint[], $2::text[], $3::jsonb[]);

-- 3. ON CONFLICT: идемпотентная вставка (upsert)
INSERT INTO counters (key, n) VALUES ($1, 1)
ON CONFLICT (key) DO UPDATE SET n = counters.n + EXCLUDED.n;

INSERT INTO events (id, ...) VALUES (...)
ON CONFLICT (id) DO NOTHING;    -- повторная доставка из брокера не сломает запись

-- 4. COPY: самый быстрый путь массовой загрузки
COPY events (user_id, kind, payload) FROM STDIN WITH (FORMAT csv);
// pgx: CopyFrom идёт по бинарному протоколу COPY, быстрее в Go не загрузить
rows := make([][]any, 0, len(events))
for _, e := range events {
    rows = append(rows, []any{e.UserID, e.Kind, e.Payload})
}
n, err := pool.CopyFrom(ctx,
    pgx.Identifier{"events"},
    []string{"user_id", "kind", "payload"},
    pgx.CopyFromRows(rows),
)

// pgx: Batch шлёт несколько разных запросов одним сетевым обменом (pipelining)
b := &pgx.Batch{}
for _, e := range events {
    b.Queue(`INSERT INTO events (user_id, kind) VALUES ($1, $2)`, e.UserID, e.Kind)
}
br := pool.SendBatch(ctx, b)
defer br.Close()
for range events {
    if _, err := br.Exec(); err != nil {   // ошибки надо вычитать по одной
        return err
    }
}
Правила массовой загрузки
  • COPY быстрее INSERT в разы: нет разбора SQL на каждую строку, меньше накладных расходов протокола, меньше WAL.
  • Один батч = одна транзакция. Батч на миллион строк превращается в долгую транзакцию со всеми её минусами (блокировки, удержание горизонта VACUUM, откат всего при ошибке в конце). Оптимум обычно 1000–10000 строк.
  • Для загрузки «с нуля»: сначала данные, потом индексы и внешние ключи. Построить индекс на готовой таблице быстрее, чем поддерживать его при вставке.
  • ON CONFLICT DO NOTHING делает consumer идемпотентным дешевле всего.
  • Осторожно с ON CONFLICT DO UPDATE в конкурентной вставке: он берёт блокировку на конфликтующую строку и при перекрёстном порядке ключей в батчах даёт дедлоки. Лечится сортировкой строк батча по ключу перед вставкой.

Партиционирование таблиц

Партиционирование разбивает одну логическую таблицу на несколько физических («партиций») по значению ключа. Для приложения таблица остаётся одна: запросы и вставки идут в родительскую таблицу, а PostgreSQL сам решает, в какую партицию положить строку и какие партиции читать. Все партиции живут в одной базе, так что это не про масштабирование на несколько серверов, а про размер, обслуживание и отсечение лишнего при чтении.

ТипКлюч разбиенияТипичный случай
RANGEдиапазоны значений (обычно дата)события, логи, заказы по месяцам - 95 % реальных применений
LISTперечисление значенийпо региону, по тенанту, по стране
HASHhash(ключ) mod Nравномерное размазывание горячей таблицы, когда естественного диапазона нет
-- RANGE по времени: самый частый вариант
CREATE TABLE events (
    id          bigserial,
    created_at  timestamptz NOT NULL,
    user_id     bigint      NOT NULL,
    payload     jsonb,
    PRIMARY KEY (id, created_at)   -- ключ партиционирования обязан входить в PK
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_07 PARTITION OF events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_2026_08 PARTITION OF events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- DEFAULT-партиция ловит всё, что не попало ни в один диапазон.
-- Полезна как страховка, но если она распухла, значит, забыли нарезать новые партиции.
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- HASH: N партиций примерно равного размера
CREATE TABLE sessions (user_id bigint, ...) PARTITION BY HASH (user_id);
CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... ещё 7 штук

-- Старые данные удаляем мгновенно: не DELETE на 300 млн строк с распуханием и VACUUM,
-- а операцией над одними метаданными, за миллисекунды
DROP TABLE events_2025_08;
-- Или мягко: отцепить, выгрузить в архив, потом дропнуть. CONCURRENTLY не блокирует
-- запись, но при DEFAULT-партиции (как выше) не работает - там без него.
ALTER TABLE events DETACH PARTITION events_2025_08 CONCURRENTLY;
Partition pruning: что читается, а что отсекается Запрос с условием по ключу партиционирования SELECT * FROM events WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01' events_2026_06 отсечена events_2026_07 отсечена events_2026_08 Seq Scan, 2 ГБ events_2026_09 отсечена итого 2 ГБ вместо 800 ГБ Запрос без условия по ключу (или с функцией над ним) SELECT * FROM events WHERE user_id = 42 SELECT * FROM events WHERE date_trunc('month', created_at) = '2026-08-01' events_2026_06 events_2026_07 events_2026_08 events_2026_09 читаются ВСЕ, стало хуже, чем было
Партиционирование помогает только тем запросам, у которых в WHERE есть ключ партиционирования. Для остальных оно превращает один Seq Scan в N сканов плюс Append, то есть замедляет их. Поэтому ключ выбирают не по структуре данных, а по самому частому фильтру продакшн-нагрузки.
Когда партиционировать, а когда не надо
  • Надо, если таблица больше ~50–100 ГБ и есть естественный диапазон (время), по которому идут почти все запросы.
  • Надо, если у данных есть срок жизни: только DROP TABLE партиции позволяет удалять старое, не воюя с bloat и autovacuum на миллиардах строк.
  • Надо, если VACUUM/ANALYZE/REINDEX на монолитной таблице не укладываются в окно: партиции обслуживаются независимо и параллельно.
  • Не надо на таблице в 5 ГБ: индекс справится, а сложность (нарезка партиций, миграции, ограничения на PK и FK) ты получишь сразу.
  • Не надо, если в частых запросах нет ключа партиционирования, иначе станет хуже.

Ограничения, о которых спрашивают: ключ партиционирования обязан входить в любой UNIQUE/PRIMARY KEY (глобального уникального индекса поверх партиций не существует); внешние ключи на партиционированную таблицу поддерживаются только с PG 12; партиций лучше держать не больше нескольких сотен: планировщик обрабатывает их список на каждом запросе, и на тысячах партиций растёт время планирования; нарезку будущих партиций надо автоматизировать (pg_partman, cron-джоба или миграция), иначе однажды все строки уедут в DEFAULT или вставка упадёт с «no partition of relation found for row».

Вопросы

10
Суть: EXPLAIN только планирует и показывает оценки, запрос не выполняется. EXPLAIN ANALYZE реально выполняет запрос и показывает фактические строки и время. Значит на UPDATE/DELETE/ INSERT он изменит данные - оборачивать в транзакцию с ROLLBACK.

Что даёт каждый

  • EXPLAIN выдаёт план и оценки планировщика: cost, ожидаемые rows, width. Ничего не выполняется, безопасно всегда.
  • EXPLAIN ANALYZE добавляет actual time, actual rows, loops, Rows Removed by Filter. Только сравнив ожидание с фактом, можно понять, врёт ли статистика.
  • BUFFERS показывает, сколько страниц реально прочитано и откуда: shared hit (из кэша), read (с диска), dirtied, written. Почти всегда это полезнее времени: время плавает от нагрузки, а число страниц - нет.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT) SELECT ...;

-- Пишущий запрос: выполнить и откатить
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'done' WHERE id = 42;
ROLLBACK;

Риски и тонкости

  • Данные меняются по-настоящему. Классический инцидент на собеседовании и в жизни: «я просто посмотрел план» - а DELETE уже выполнился.
  • Побочные эффекты не откатываются полностью: последовательности (nextval) не возвращаются назад, триггеры могут отправить письмо или записать во внешнюю систему.
  • Долгий запрос будет выполнен целиком. Если он и так идёт 40 минут и держит блокировки, ANALYZE сделает это ещё раз. Ставь SET LOCAL statement_timeout.
  • Оверхед измерения. Таймеры сами по себе замедляют выполнение, иногда заметно на узлах с миллионами итераций. Режим EXPLAIN (ANALYZE, TIMING OFF) считает строки, но не время.
  • Параметры. План для PREPARE-запроса с generic plan может отличаться от плана, который ты получил, подставив константы руками. Смотреть надо на то, что реально исполняется, и тут поможет auto_explain.
Чем добить

«На проде обычно нельзя выполнить проблемный запрос руками - он тяжёлый. Поэтому включаем auto_explain с auto_explain.log_min_duration = 500ms и log_analyze = on: планы медленных запросов сами приезжают в лог, вместе с фактическими строками, ровно с теми параметрами, что были в бою. Плюс pg_stat_statements, чтобы понимать, какой запрос вообще главный по суммарному времени.»

Суть: план - дерево, читается снизу вверх и изнутри наружу: самые вложенные узлы выполняются первыми. Главный диагностический приём - сравнить rows (оценка) с actual rows (факт). Расхождение в 100 раз и больше означает, что планировщик выбирал алгоритм по неверным данным.

Числа в узле

  • cost=0.43..8.45 меряется в абстрактных «попугаях» планировщика, не в миллисекундах. Первое число оценивает получение первой строки, второе - всех. Разница важна: под LIMIT побеждает план с дешёвым стартом, даже если полная стоимость выше.
  • rows=1000 даёт оценку числа строк по статистике pg_statistic.
  • actual rows=98000 показывает факт. Из разъезда с оценкой растут почти все плохие планы.
  • loops=500 говорит, сколько раз узел выполнялся. Ловушка: actual time и actual rows в плане показаны на одну итерацию. Реальное время узла = actual time × loops. Узел с «0.02 ms» и 500 000 loops работает 10 секунд.
  • Rows Removed by Filter=49000: прочитали 50 000, отдали 1000. Прямое указание: нужен индекс по этому предикату (или частичный индекс).
  • Heap Fetches считает, сколько раз Index Only Scan всё-таки полез в heap. Если не ноль, visibility map устарела и нужен VACUUM.
  • Buffers: shared hit=12 read=8400 значит, что с диска поднято 8400 страниц (~66 МБ).
  • Sort Method: external merge Disk: 240MB - сортировка не влезла в work_mem и ушла на диск. Часто самая дешёвая победа: поднять work_mem для конкретной сессии.

Порядок разбора

  1. Найти узел с наибольшим собственным временем (общее минус время детей); на верхний узел не смотреть, он суммарный.
  2. Сравнить rows и actual rows в этом узле и его детях.
  3. Посмотреть, откуда пришло расхождение: устаревшая статистика → ANALYZE; коррелированные колонки → CREATE STATISTICS; выражение вместо колонки → функциональный индекс.
  4. Проверить Rows Removed by Filter и Heap Fetches.
  5. Посмотреть на тип соединения: Nested Loop с большим внешним набором почти всегда вырастает из недооценки строк.
Самая частая ошибка чтения плана

Смотреть на cost и делать выводы. Cost оценивает запрос до выполнения, и если оценка неверна, весь план построен на песке. Диагноз ставят по паре «оценка против факта», а не по абсолютной величине cost. Вторая по частоте ошибка: забыть умножить время на loops.

Суть: Seq Scan побеждает, когда выбирается заметная доля таблицы (грубо больше 5–10 %): последовательное чтение на порядок дешевле случайного. Bitmap Scan - промежуточный режим: сначала собрать все ctid в битовую карту, отсортировать её по номерам страниц и прочитать heap по возрастанию страниц, превратив случайный доступ в почти последовательный.

Узлы доступа

УзелЧто делаетКогда выбирается
Seq Scanчитает всю таблицу подряднет индекса, либо выбирается большая доля строк, либо таблица мала
Index Scanидёт по индексу и за каждой строкой - в heapмало строк, нужен порядок сортировки
Index Only Scanберёт данные прямо из индексавсе колонки в индексе + свежая visibility map
Bitmap Index Scan + Bitmap Heap Scanсобирает битовую карту страниц, затем читает heap по порядкусредняя селективность; несколько индексов через BitmapAnd/BitmapOr
Nested Loopдля каждой строки внешней - поиск во внутреннейвнешний набор мал и есть индекс по ключу соединения
Hash Joinстроит хеш меньшей таблицы, проходит большуюбольшие наборы, соединение по равенству
Merge Joinслияние двух отсортированных потоковобе стороны уже отсортированы (индексы) или очень велики
Append / Gather / Materializeсклейка партиций, сбор с параллельных воркеров, кэш промежуточного наборапартиционирование, parallel query, повторное использование подзапроса

Почему Seq Scan бывает правильным выбором

Случайное чтение страницы дороже последовательного: в модели планировщика это random_page_cost = 4.0 против seq_page_cost = 1.0. Если по индексу нужно вытащить 30 % строк, придётся прочитать 30 % страниц heap в случайном порядке плюс сам индекс, а это дороже, чем прочитать таблицу целиком подряд. Поэтому «планировщик игнорирует мой индекс» чаще всего означает «планировщик прав».

Про random_page_cost на SSD

Дефолтные 4.0 достались от вращающихся дисков, где перемещение головки стоило дорого. На NVMe разница между случайным и последовательным чтением почти исчезла, поэтому на проде обычно ставят random_page_cost = 1.1. Это одна из немногих настроек, которая массово чинит «почему не берётся индекс», и хороший ответ, если спрашивают про тюнинг.

Проверить гипотезу можно принудительно: SET enable_seqscan = off; и сравнить планы. Это инструмент диагностики, а не решение, и на проде такие флаги не оставляют: они лишь показывают, во сколько планировщик оценивает альтернативу.

Суть: это вопрос не на знание, а на порядок действий. Правильный ответ начинается со слов «сначала измерю»: подтвердить проблему метриками → снять план → искать причину в плане → чинить по возрастанию инвазивности: статистика, индекс, переписывание запроса, денормализация, партиционирование, вынос в другое хранилище. Худший ответ - «добавлю индекс».

Шаг 0. Уточнить вопрос

  • Тормозит всегда или стало? Если «стало» - что изменилось: релиз, объём данных, план, нагрузка, железо?
  • Тормозит этот запрос или вся база? Если вся, смотреть надо не на запрос, а на pg_stat_activity, блокировки, диск, чекпоинты.
  • Какая доля запросов страдает: p50 или p99? Деградация только хвоста часто означает блокировки или холодный кэш, а не план.

Шаг 1. Найти виновника объективно

-- Кто съедает базу суммарно (а не кто медленнее всех в единичном вызове)
SELECT queryid, calls, total_exec_time, mean_exec_time, rows, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

-- Что происходит прямо сейчас и кто кого ждёт
SELECT pid, state, wait_event_type, wait_event, now() - query_start AS dur, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY dur DESC;

Шаг 2. План

Снять EXPLAIN (ANALYZE, BUFFERS) и искать три вещи: расхождение rows и actual rows, большие Rows Removed by Filter, узлы с высоким loops. На терабайтной таблице почти всегда обнаруживается либо Seq Scan, либо Nested Loop с сотнями тысяч итераций.

Шаг 3. Лечение по возрастанию цены

  1. Статистика. ANALYZE; поднять default_statistics_target для колонки с перекошенным распределением; CREATE STATISTICS для коррелированных колонок (город и страна). Самое дешёвое и на удивление часто помогает.
  2. Индекс. Прицельный: составной в правильном порядке, частичный под предикат, покрывающий с INCLUDE ради Index Only Scan. Только CREATE INDEX CONCURRENTLY. Помнить о цене записи.
  3. Переписать запрос. Убрать функцию с колонки, заменить OR на UNION ALL, NOT IN на NOT EXISTS, OFFSET на keyset, убрать SELECT *, вынести тяжёлую агрегацию в отдельный проход.
  4. Сузить данные. Нужны ли реально все 3 года? Обычно 95 % запросов идут за последний месяц, а холодные данные можно унести в архивную таблицу или в отдельное хранилище.
  5. Партиционирование по времени. Даёт pruning для запросов с датой и, что не менее важно, снова делает обслуживание управляемым: VACUUM, REINDEX, удаление старого через DROP TABLE.
  6. Денормализация / материализованное представление / инкрементальный счётчик - если запрос аналитический и считает агрегаты по всей истории.
  7. Другое хранилище. Аналитике по миллиардам строк место в ClickHouse, а не в PostgreSQL. Сказать это прямо не стыдно, это сильный ответ.
Что добавит вес ответу

«Отдельно проверю, не деградация ли это из-за bloat: на терабайтной таблице с активным UPDATE мёртвые версии могут занимать половину объёма, и тогда Seq Scan читает вдвое больше, чем нужно. Смотрю pg_stat_user_tables.n_dead_tup, настройки autovacuum для этой таблицы (дефолтные 20 % от таблицы в терабайт - это 200 ГБ мусора до старта уборки, что абсурдно; ставлю autovacuum_vacuum_scale_factor = 0.01 и явный threshold).»

Суть: один запрос за списком плюс по одному запросу на каждый элемент списка. Каждый запрос сам по себе быстрый - 1 мс, - но их 500, и это 500 сетевых round-trip: полсекунды там, где хватило бы двух запросов и 3 мс. Лечится батчингом: JOIN, WHERE id = ANY($1) или dataloader.

Как выглядит

// N+1: 1 запрос за заказами + 100 запросов за клиентами
orders, _ := repo.ListOrders(ctx, 100)
for i := range orders {
    orders[i].Customer, _ = repo.GetCustomer(ctx, orders[i].CustomerID)
}

// Батч: 2 запроса всего
orders, _ := repo.ListOrders(ctx, 100)
ids := lo.Uniq(lo.Map(orders, func(o Order, _ int) int64 { return o.CustomerID }))
customers, _ := repo.GetCustomersByIDs(ctx, ids)   // WHERE id = ANY($1)
byID := make(map[int64]*Customer, len(customers))
for _, c := range customers { byID[c.ID] = c }
for i := range orders { orders[i].Customer = byID[orders[i].CustomerID] }

Как обнаружить

  • Трассировка. В OpenTelemetry N+1 виден мгновенно: «лестница» из сотни коротких одинаковых спанов внутри одного HTTP-запроса. Самый убедительный способ.
  • pg_stat_statements. Запрос с гигантским calls и микроскопическим mean_exec_time, но большим total_exec_time - классическая подпись N+1.
  • Счётчик запросов на HTTP-запрос. Простейшая метрика db_queries_per_request с алертом на p99 > 20 ловит регрессии до прода.
  • Тест. В интеграционном тесте посчитать число обращений к БД через обёртку над драйвером и зафиксировать ожидаемое: «список из 50 заказов = ровно 2 запроса».
  • Косвенный признак: «эндпоинт линейно замедляется от размера страницы» - 20 элементов 40 мс, 100 элементов 200 мс.

Варианты лечения

ПриёмКогдаМинус
JOIN одним запросомсвязь «многие к одному», нужны все поля сразудублирование данных родителя в каждой строке; при нескольких «один ко многим» - декартов взрыв
Два запроса + WHERE id = ANY($1)универсальный дефолт, особенно для коллекцийсклейка в памяти приложения - писать руками
Dataloader (батчинг с окном 1–5 мс)GraphQL и любые деревья резолверов, где вызовы разбросаны по кодусложность, кэш на время запроса, легко словить утечку между пользователями
LATERAL / оконная функция«топ-N дочерних для каждого родителя»сложный SQL
Preload/Eager loading в ORMGORM Preload, ent With…работает, только если не забыть; забывают постоянно
Обратная сторона: JOIN-взрыв

Совет «всегда джойнить» ошибочен точно так же. Заказ с 10 позициями и 5 платежами при джойне обеих коллекций даёт 50 строк вместо 15, и сумма по платежам оказывается впятеро больше правды. Правило: одна коллекция - JOIN, две и больше - отдельные батчевые запросы.

Суть: OFFSET N не «пропускает» строки дёшево - сервер честно читает и выбрасывает N строк. Страница 10 000 по 20 элементов означает чтение 200 020 строк ради 20. Keyset (cursor) вместо номера страницы запоминает последнее значение сортировки и делает WHERE (created_at, id) < ($1, $2) - это диапазонный поиск по индексу, стоимость постоянна.
-- OFFSET: стоимость растёт линейно с номером страницы
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;      -- прочитано 200 020 строк, отдано 20

-- Keyset: стоимость постоянна
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ($1, $2)     -- курсор с прошлой страницы
ORDER BY created_at DESC, id DESC
LIMIT 20;                              -- прочитано 20 строк

CREATE INDEX posts_feed_idx ON posts (created_at DESC, id DESC);

Две проблемы OFFSET, а не одна

  1. Скорость. Деградация линейная: последние страницы больших списков грузятся секундами и падают по таймауту.
  2. Корректность. Между запросом страницы 1 и страницы 2 кто-то вставил запись в начало - и одна строка сдвинулась со страницы 1 на страницу 2, то есть пользователь увидит её дважды, а какую-то другую не увидит вовсе. При бесконечной прокрутке это заметный баг, и keyset его чинит бесплатно.

Обязательные детали реализации

  • Тай-брейкер. Сортировка должна быть по уникальному набору колонок, иначе строки с одинаковым created_at будут теряться или повторяться. Всегда добавляй id последним.
  • Кортежное сравнение. (created_at, id) < ($1, $2) сравнивает строки целиком, ровно как нужно, и PostgreSQL умеет использовать под это индекс. Разворачивать его в created_at < $1 OR (created_at = $1 AND id < $2) не нужно, план от этого только хуже.
  • Индекс в том же порядке, включая направления DESC.
  • Курсор - непрозрачный токен. Кодировать base64 и не обещать клиенту его структуру, иначе поменять сортировку станет ломающим изменением API.
  • limit + 1 вместо отдельного COUNT(*), чтобы узнать, есть ли следующая страница.
Когда OFFSET всё-таки нормален

Админка на 30 страниц, где нужна навигация «перейти на страницу 7», и данных объективно мало. Правило простое: OFFSET допустим, пока глубина ограничена. Если продукт требует номера страниц на большом объёме, глубину ограничивают («не более 100 страниц, уточните фильтр»), как делают все поисковики.

Суть: четыре независимые причины - ломает Index Only Scan, тянет TOAST и лишний трафик, делает код хрупким к ALTER TABLE, скрывает реальные зависимости от схемы.
  • Index Only Scan становится невозможен. Даже идеально покрывающий индекс бесполезен: раз нужны все колонки, придётся идти в heap за каждой строкой. Это буквально разница между 1 чтением и 500.
  • TOAST. Одна колонка jsonb или text на мегабайт, которая никому в этом запросе не нужна, превращает лёгкую выборку в чтение внешних чанков из TOAST-таблицы с разжатием.
  • Трафик и память приложения. На выборке в 1000 строк разница между 4 нужными колонками и 40 измеряется десятками мегабайт по сети и в куче Go, а это ещё и давление на GC.
  • Хрупкость. Кто-то добавил колонку - и rows.Scan с фиксированным набором приёмников падает: «sql: expected 12 destination arguments in Scan, not 11». Хуже, если типы совпали и данные тихо разъехались по полям.
  • Непрозрачность. По коду невозможно понять, какие поля реально используются; удалить колонку становится страшно, потому что неизвестно, кто её читает.

Отдельная история с SELECT count(*): он проверяет видимость каждой строки, поэтому на больших таблицах дорог всегда. Для «примерно сколько» есть reltuples из pg_class, для точного - отдельный счётчик.

Где SELECT * уместен

Интерактивная отладка в psql; EXISTS (SELECT * FROM ...): список колонок там вообще не вычисляется, это идиома; SELECT * FROM unnest(...) и подобные конструкции, где набор колонок задан тут же. В репозиторном коде - нет.

Суть: вставка по одной строке - это миллион round-trip, миллион разборов SQL и миллион коммитов с fsync. Multi-row INSERT убирает round-trip и коммиты (10–50×), COPY дополнительно убирает разбор SQL и часть накладных расходов протокола (ещё 2–5×). В Go это pgx.CopyFrom.
СпособПорядок скоростиОсобенности
INSERT по одной, autocommit1×худший случай: round-trip + fsync на строку
INSERT по одной в одной транзакции~5–10×убрали fsync, round-trip остались
Multi-row INSERT по 1000~30–50×лимит 65535 параметров на запрос; текст запроса меняется с размером батча
INSERT ... SELECT unnest($1,$2,$3)~30–50×текст запроса постоянный → один план в кэше; удобно для prepared statements
COPY / pgx.CopyFrom~100×самый быстрый; нет ON CONFLICT, нет RETURNING

Практические правила

  • Батч 1000–10 000 строк. Если больше, получится долгая транзакция: блокировки, удержание горизонта VACUUM, откат всей работы при ошибке в конце.
  • Загрузка «с нуля»: сначала данные, потом индексы и FK. Индекс на готовой таблице строится быстрее, чем поддерживается при вставке.
  • Для идемпотентности (повторная доставка из брокера) есть ON CONFLICT DO NOTHING. С COPY он не работает, поэтому типичный компромисс: COPY во временную таблицу, затем INSERT ... SELECT ... ON CONFLICT из неё.
  • При конкурентных апсертах сортируй строки батча по ключу: ON CONFLICT DO UPDATE берёт блокировку на конфликтующую строку, и разный порядок ключей в параллельных батчах даёт дедлоки.
  • pgx.Batch нужен для pipelining разных запросов одним обменом, а не для массовой вставки одинаковых; для однородных строк CopyFrom быстрее.
Суть: разбиение одной логической таблицы на физические части по ключу - RANGE (обычно дата), LIST (регион, тенант), HASH (равномерно). Partition pruning - отсечение партиций, которые заведомо не содержат нужных строк, по условию на ключ. Всё это в пределах одного сервера: партиционирование не решает задачу горизонтального масштабирования.

Что реально даёт

  • Pruning на чтении - но только для запросов с ключом партиционирования в WHERE.
  • Мгновенное удаление старого. DROP TABLE events_2025_08 вместо DELETE на 300 млн строк, который создаст 300 млн мёртвых версий и вгонит autovacuum в многочасовую работу. Это главная причина партиционировать по времени.
  • Управляемое обслуживание. VACUUM, ANALYZE, REINDEX идут по партициям независимо и параллельно, каждая укладывается в окно.
  • Меньше индексы. У каждой партиции своё дерево индекса, пониже, и оно целиком помещается в кэш.
  • Разные носители. Свежие партиции на NVMe, архивные - в дешёвый tablespace.

Ограничения, которые обязательно назвать

  • Ключ партиционирования обязан входить в PRIMARY KEY и любой UNIQUE: глобального уникального индекса поверх всех партиций нет. Отсюда типичный PRIMARY KEY (id, created_at).
  • Запросы без ключа партиционирования читают все партиции, и выходит хуже, чем без партиционирования.
  • Функция над ключом (date_trunc('month', created_at) = ...) убивает pruning так же, как убивает индекс.
  • Сотни партиций планировщик переваривает, тысячи - заметно удлиняют планирование.
  • Нарезку будущих партиций надо автоматизировать (pg_partman, cron), иначе всё поедет в DEFAULT-партицию или вставка упадёт.
  • Чтобы перевести существующую большую таблицу в партиционированную, нужна отдельная миграция без даунтайма (новая таблица + ATTACH старой как партиции, либо постепенное копирование с двойной записью).
Частая ошибка в ответе

Сказать «партиционирование ускоряет запросы». Оно ускоряет только те запросы, которые фильтруют по ключу партиционирования, а остальные замедляет. И оно не заменяет индекс: внутри партиции по-прежнему нужен индекс, иначе получишь Seq Scan по 2 ГБ вместо 800 ГБ - лучше, но всё ещё плохо.

Суть: либо индекс неприменим (функция над колонкой, приведение типа, неподходящий оператор, порядок колонок в составном), либо он невыгоден (низкая селективность, маленькая таблица), либо планировщик ошибается (устаревшая статистика, коррелированные колонки, неверный random_page_cost).
ПричинаПризнак в планеЧто делать
Функция над колонкой: lower(email), date(ts)Seq Scan с Filter по выражениюфункциональный индекс или переписать условие в диапазон
Неявное приведение типа: id::text = '5', varchar против text с разным collationто жеприводить параметр к типу колонки, а не наоборот
LIKE '%abc%'Seq ScanGIN + pg_trgm или полнотекстовый поиск
Составной индекс (a, b), а фильтр только по bSeq Scan или неэффективный сканиндекс с b первым; помнить правило «слева направо»
Низкая селективность: status='active' у 80 % строкSeq Scan, и он правничего, либо частичный индекс на редкое значение
Маленькая таблицаSeq Scanничего, всё в порядке
Устаревшая статистикаrows и actual rows расходятся в разыANALYZE, поднять default_statistics_target
Коррелированные колонки (город и страна)перемноженная и потому заниженная оценкаCREATE STATISTICS ... (dependencies, ndistinct)
random_page_cost = 4 на NVMeиндекс проигрывает по cost на границеснизить до 1.1
Индекс только что создан-ANALYZE после CREATE INDEX
Индекс невалиден после сбоя CREATE INDEX CONCURRENTLYплан его не видитнайти indisvalid = false в pg_index, пересоздать
Как проверить гипотезу

SET enable_seqscan = off; и снова EXPLAIN. Если план с индексом оказался дороже по cost - планировщик прав, проблема не в индексе. Если дешевле по факту, но дороже по оценке - проблема в статистике или в стоимостных константах. Это диагностика: на проде такие флаги не оставляют.

Что стоит запомнить

  • EXPLAIN показывает оценки, а EXPLAIN ANALYZE выполняет запрос и добавляет фактические показатели.
  • Сначала ищи узел с неверной оценкой или чрезмерным числом строк, затем проверяй статистику и доступные индексы.
  • Для глубокой пагинации keyset использует значения последней строки и стабильный уникальный тай-брейкер.

Частые ошибки

  • EXPLAIN ANALYZE для UPDATE/DELETE реально меняет данные; даже откат не отменяет все побочные эффекты и блокировки.
  • OFFSET заставляет сервер пропустить предыдущие строки и нестабилен при изменениях набора между страницами.
  • N+1 часто скрыт в цикле; оцени число запросов с ростом размера результата.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай фактический план

Оцени расхождение оценки и факта и назови первый вопрос для диагностики.

Seq Scan on orders (rows=980) (actual rows=51230 loops=1)
Rows Removed by Filter: 948770
Ответ и разбор

Оценка ожидает 980 строк, а узел вернул 51 230 и просмотрел около миллиона. Сначала проверь, актуальна ли статистика и насколько селективен фильтр; затем сравни варианты индекса и стоимость чтения в полном плане с BUFFERS. Сам факт Seq Scan не доказывает ошибку: если выборка большая или таблица мала, он может быть дешевле.

Код-ревью

2. Найди N+1 в обработчике

Ревьюер видит этот фрагмент и ожидает до 101 запроса на страницу. Предложи исправление и тест, который поймает возврат ошибки.

orders, _ := repo.ListOrders(ctx, 100)
for i, order := range orders {
    orders[i].Customer, _ = repo.GetCustomer(ctx, order.CustomerID)
}
Ответ и разбор

Цикл делает отдельный запрос на каждого клиента и игнорирует ошибки. Собери уникальные CustomerID, загрузи клиентов одним запросом через ANY($1), построй map по ID и свяжи результаты; проверь ошибки каждого шага. Тест с подсчётом запросов должен подтвердить, что их число остаётся ограниченным константой, например двумя, при увеличении числа заказов.

Применение

3. Замени глубокий OFFSET

Лента упорядочена по created_at DESC, id DESC, клиент просит следующую страницу после последней строки с ключом (2026-09-01, 42). Запиши условие для keyset-пагинации и требуемый индекс.

Ответ и разбор

Используй WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20 и индекс на (created_at DESC, id DESC). id нужен как уникальный тай-брейкер, когда timestamps совпадают. Курсор следует кодировать как непрозрачное значение, а не доверять клиентскому формату без проверки.

Применение

4. Выбери способ массовой вставки

Загрузка идёт пакетами по 20 000 строк в PostgreSQL из Go. Сравни многострочный INSERT, pgx CopyFrom и последовательность отдельных INSERT, которые отправляются через Batch.

Ответ и разбор

CopyFrom обычно подходит для большой потоковой загрузки и отправляет данные бинарным COPY. Многострочный INSERT удобен для умеренных пакетов, но число параметров ограничено протоколом. Batch уменьшает число сетевых обменов для разных команд, но не превращает их в bulk COPY; обработай результат каждой команды и транзакционные ошибки. Выбор проверь на реальном размере строк и индексов.