6.5Оптимизация запросов
Разбирай медленный запрос по фактическому плану: сравнивай оценки строк с реальностью, ищи лишние чтения и только потом меняй SQL или индекс. Эта глава также показывает, как распознать N+1, дорогой OFFSET и неудачную пакетную загрузку в Go-коде.
EXPLAIN и EXPLAIN ANALYZE
EXPLAIN показывает план, который выбрал планировщик, вместе с его оценками:
сколько строк он ожидает и во что оценивает стоимость. Запрос при этом не выполняется.
EXPLAIN ANALYZE же реально выполняет запрос и добавляет к плану фактические числа:
сколько строк вернул каждый узел, сколько времени занял, сколько раз был вызван.
Разница принципиальная: EXPLAIN отвечает на вопрос «что база собирается
делать», EXPLAIN ANALYZE - «что она сделала и где ошиблась в оценках».
Диагностика почти всегда живёт во втором: плохие планы чаще всего растут из расхождения
между rows и actual rows.
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;
Как читать план: снизу вверх и изнутри наружу
План устроен как дерево. В текстовом выводе оно записано с отступами: чем глубже отступ, тем ниже узел в дереве. Данные текут снизу вверх: самые вложенные узлы читают таблицы и индексы, отдают строки родителю, тот соединяет или агрегирует, и так до корня, который отдаёт результат клиенту. Поэтому читать нужно от самых глубоких строк, а не сверху.
Узлы, которые надо узнавать
| Узел | Что делает | Когда это нормально / плохо |
|---|---|---|
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 можно только между планами одного запроса.rowsvsactual 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: если планирование сравнимо с выполнением, а запрос простой, смотри в сторону избытка индексов или подготовленных выражений.
- Найти узел с наибольшим собственным временем (время узла минус время детей, умноженное на loops).
- Посмотреть на его
rowsпротивactual rows. Если оценка врёт, проблема в статистике, а не в запросе. - Посмотреть
Rows Removed by Filter- если много, нужен индекс/предикат. - Проверить
Buffers read: при сотнях тысяч мы просто читаем слишком много данных. - Проверить признаки нехватки памяти:
Batches > 1,external merge,lossy,Disk Usage.
Кейс: запрос на таблице в несколько терабайт тормозит
Здесь важен порядок действий, а не знание команд: начинать надо с измерения, а не с «добавлю индекс».
- Понять, что именно тормозит. Не «сайт медленный», а конкретный запрос:
pg_stat_statementsс сортировкой поtotal_exec_time(не поmean: главным вредителем часто оказывается быстрый запрос, который выполняется 50 000 раз в минуту) иpg_stat_activityдля того, что висит прямо сейчас. - Снять план.
EXPLAIN (ANALYZE, BUFFERS)на репрезентативных параметрах. Если запроса не дождаться, хватитEXPLAINбез ANALYZE плюсauto_explainсlog_min_durationв проде. - Проверить, не блокировки ли это. Медленный не значит «плохой план»: посмотреть
wait_event_type = 'Lock'вpg_stat_activity. Если ждём блокировку, план тут ни при чём. - Проверить статистику.
rowsпротивactual rows. Если оценка врёт на порядки, запуститьANALYZEи снять план заново. Половина «загадочных» проблем закрывается здесь. - Уменьшить объём читаемых данных. В порядке предпочтения:
- индекс под предикат (составной, частичный, покрывающий);
- переписать запрос: убрать функцию с колонки, заменить
ORнаUNION ALL, коррелированный подзапрос - на джойн или оконную функцию,DISTINCT- наEXISTS, вынести тяжёлую агрегацию в CTE сMATERIALIZEDили наоборот; - убрать
SELECT *, чтобы стал возможен index-only scan.
- Партиционирование. Если таблица многотерабайтная и запросы всегда ограничены по
времени или тенанту, подойдёт RANGE по дате или HASH по tenant_id. Заодно решается проблема обслуживания:
удаление старых данных превращается из многочасового
DELETEв мгновенныйDETACH PARTITION. - Изменить модель. Предагрегировать (материализованное представление, счётчики, роллапы), вынести холодные данные в архивную таблицу, денормализовать нужную колонку, чтобы избавиться от джойна. Это уже не «оптимизация запроса», а изменение схемы - но на терабайтах именно оно обычно и работает.
- Только потом - железо и параметры.
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 мс + разбор + планирование) вместо одного.
Как обнаружить
- Логи БД: временно
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,
чтобы одинаковые ключи не ходили в БД дважды.
Чинить N+1 одним большим JOIN через несколько связей «один ко многим» опасно:
заказ с 10 позициями и 5 платежами превращается в 50 строк, а поля заказа дублируются 50 раз.
Это «декартово раздувание». Правильнее либо сделать два-три отдельных батч-запроса и склеить
результат в коде, либо собрать вложенные коллекции в JSON на стороне БД.
Пагинация: OFFSET/LIMIT против keyset
OFFSET n не умеет «перепрыгнуть» n строк. База обязана их найти, материализовать
и выбросить. Значит, стоимость страницы растёт линейно с её номером: первая стоит 20 строк,
пятитысячная - 100 020 строк работы ради тех же 20. Отсюда чаще всего и берётся «у нас в админке
последние страницы отваливаются по таймауту».
-- Плохо: деградирует линейно
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/LIMIT | Keyset (cursor) | |
|---|---|---|
| Стоимость страницы N | O(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 | перечисление значений | по региону, по тенанту, по стране |
| HASH | hash(ключ) 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;
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».
Вопросы
10EXPLAIN только планирует и показывает оценки,
запрос не выполняется. 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для конкретной сессии.
Порядок разбора
- Найти узел с наибольшим собственным временем (общее минус время детей); на верхний узел не смотреть, он суммарный.
- Сравнить
rowsиactual rowsв этом узле и его детях. - Посмотреть, откуда пришло расхождение: устаревшая статистика →
ANALYZE; коррелированные колонки →CREATE STATISTICS; выражение вместо колонки → функциональный индекс. - Проверить
Rows Removed by FilterиHeap Fetches. - Посмотреть на тип соединения: Nested Loop с большим внешним набором почти всегда вырастает из недооценки строк.
Смотреть на cost и делать выводы. Cost оценивает запрос до выполнения,
и если оценка неверна, весь план построен на песке. Диагноз ставят по паре
«оценка против факта», а не по абсолютной величине cost. Вторая по частоте ошибка: забыть
умножить время на loops.
Узлы доступа
| Узел | Что делает | Когда выбирается |
|---|---|---|
| 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 в случайном
порядке плюс сам индекс, а это дороже, чем прочитать таблицу целиком подряд.
Поэтому «планировщик игнорирует мой индекс» чаще всего означает «планировщик прав».
Дефолтные 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. Лечение по возрастанию цены
- Статистика.
ANALYZE; поднятьdefault_statistics_targetдля колонки с перекошенным распределением;CREATE STATISTICSдля коррелированных колонок (город и страна). Самое дешёвое и на удивление часто помогает. - Индекс. Прицельный: составной в правильном порядке, частичный под предикат,
покрывающий с
INCLUDEради Index Only Scan. ТолькоCREATE INDEX CONCURRENTLY. Помнить о цене записи. - Переписать запрос. Убрать функцию с колонки, заменить
ORнаUNION ALL,NOT INнаNOT EXISTS,OFFSETна keyset, убратьSELECT *, вынести тяжёлую агрегацию в отдельный проход. - Сузить данные. Нужны ли реально все 3 года? Обычно 95 % запросов идут за последний месяц, а холодные данные можно унести в архивную таблицу или в отдельное хранилище.
- Партиционирование по времени. Даёт pruning для запросов с датой и, что не менее
важно, снова делает обслуживание управляемым: VACUUM, REINDEX, удаление старого
через
DROP TABLE. - Денормализация / материализованное представление / инкрементальный счётчик - если запрос аналитический и считает агрегаты по всей истории.
- Другое хранилище. Аналитике по миллиардам строк место в ClickHouse, а не в PostgreSQL. Сказать это прямо не стыдно, это сильный ответ.
«Отдельно проверю, не деградация ли это из-за bloat: на терабайтной таблице с активным
UPDATE мёртвые версии могут занимать половину объёма, и тогда Seq Scan читает вдвое
больше, чем нужно. Смотрю pg_stat_user_tables.n_dead_tup, настройки
autovacuum для этой таблицы (дефолтные 20 % от таблицы в терабайт - это 200 ГБ мусора
до старта уборки, что абсурдно; ставлю autovacuum_vacuum_scale_factor = 0.01
и явный threshold).»
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 в ORM | GORM Preload, ent With… | работает, только если не забыть; забывают постоянно |
Совет «всегда джойнить» ошибочен точно так же. Заказ с 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, то есть пользователь увидит её дважды, а какую-то другую не увидит вовсе. При бесконечной прокрутке это заметный баг, и 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(*), чтобы узнать, есть ли следующая страница.
Админка на 30 страниц, где нужна навигация «перейти на страницу 7», и данных объективно мало. Правило простое: OFFSET допустим, пока глубина ограничена. Если продукт требует номера страниц на большом объёме, глубину ограничивают («не более 100 страниц, уточните фильтр»), как делают все поисковики.
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, для точного - отдельный счётчик.
Интерактивная отладка в psql; EXISTS (SELECT * FROM ...): список
колонок там вообще не вычисляется, это идиома; SELECT * FROM unnest(...) и
подобные конструкции, где набор колонок задан тут же. В репозиторном коде - нет.
COPY дополнительно убирает разбор SQL и часть накладных расходов
протокола (ещё 2–5×). В Go это pgx.CopyFrom.| Способ | Порядок скорости | Особенности |
|---|---|---|
| INSERT по одной, autocommit | 1× | худший случай: 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быстрее.
Что реально даёт
- 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 Scan | GIN + pg_trgm или полнотекстовый поиск |
Составной индекс (a, b), а фильтр только по b | Seq 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; обработай результат каждой команды и транзакционные ошибки. Выбор проверь на реальном размере строк и индексов.