Знакомая ситуация из любого проекта: запрос медленный, ты добавил индекс, а в EXPLAIN всё тот же Seq Scan. Сервер не сломался и не упрямится. Он либо не может использовать индекс для такого запроса, либо посчитал, что без индекса дешевле. В прошлом уроке мы строили индексы под запрос. Этот урок закрывает кластер про оптимизацию: семь причин, по которым индекс остаётся без дела, и как узнать каждую по плану.
Указатель, который не помогает
Вернёмся к предметному указателю из урока про индексы. Он бесполезен в трёх случаях. Если книга тонкая, её проще пролистать. Если искомое слово встречается почти на каждой странице, ходить по указателю дольше, чем читать подряд. И если ищешь не слово, а «слова, которые заканчиваются на -ость», алфавитный указатель тоже не поможет: он отсортирован по началу слова.
У сервера ровно те же соображения. Первая группа причин про невыгоду: индекс можно использовать, но читать таблицу подряд дешевле. Вторая группа про невозможность: запрос написан так, что индекс к нему не подходит.
Таблица для опытов
CREATE TABLE sales AS
SELECT g AS id,
(g % 500) + 1 AS customer_id,
date '2026-01-01' + (g % 280) AS sold_on,
CASE WHEN g % 50 = 0 THEN 'new' ELSE 'delivered' END AS status,
'user' || (g % 500) || '@example.com' AS email,
(100000 + g)::text AS order_code
FROM generate_series(1, 100000) AS g;
ALTER TABLE sales ADD PRIMARY KEY (id);
CREATE INDEX ON sales (status);
CREATE INDEX ON sales (sold_on);
CREATE INDEX ON sales (email);
CREATE INDEX ON sales (customer_id);
CREATE INDEX ON sales (order_code);
ANALYZE sales;
Индекс на каждую колонку здесь только ради опытов. Как выбирать индексы в жизни, разобрано в прошлом уроке. Номер заказа order_code мы нарочно храним текстом: так бывает, когда номера приходят из внешней системы.
Причина первая: таблица маленькая
ANALYZE books;
EXPLAIN SELECT * FROM books WHERE id = 3;
QUERY PLAN
------------------------------------------------------
Seq Scan on books (cost=0.00..1.11 rows=1 width=62)
Filter: (id = 3)
(2 rows)
У таблицы books есть первичный ключ, а сервер всё равно читает её подряд. Девять книг помещаются на одну страницу, и прочитать её дешевле, чем сначала открыть индекс, а потом ту же страницу. Это не проблема. Проблема появляется, когда на маленькой тестовой базе разработчик видит Seq Scan и делает выводы: на ста строках планы совсем не такие, как на миллионе. Проверяй запросы на объёме, похожем на рабочий.
Причина вторая: условие отбирает почти всё
EXPLAIN SELECT * FROM sales WHERE status = 'delivered';
QUERY PLAN
-------------------------------------------------------------
Seq Scan on sales (cost=0.00..2274.00 rows=97893 width=47)
Filter: (status = 'delivered'::text)
(2 rows)
Доставлено 98 заказов из ста. Чтобы отдать их через индекс, серверу пришлось бы почти для каждой строки сходить и в индекс, и в таблицу. Тот же индекс для status = 'new' работает:
EXPLAIN SELECT * FROM sales WHERE status = 'new';
QUERY PLAN
------------------------------------------------------------------------------------
Index Scan using sales_status_idx on sales (cost=0.29..386.75 rows=2107 width=47)
Index Cond: (status = 'new'::text)
(2 rows)
Долю строк, которую отбирает условие, называют селективностью. Индекс помогает селективным условиям. Если колонка почти всегда принимает одно значение, обычный индекс по ней бесполезен, а нужен частичный, только по редким значениям, как в прошлом уроке.
Причина третья: функция над колонкой
Отчёт за март часто пишут так:
EXPLAIN SELECT * FROM sales WHERE extract(month FROM sold_on) = 3;
QUERY PLAN
-----------------------------------------------------------
Seq Scan on sales (cost=0.00..2524.00 rows=500 width=47)
Filter: (EXTRACT(month FROM sold_on) = '3'::numeric)
(2 rows)
В индексе лежат даты, а запрос спрашивает про месяц, вычисленный из даты. Чтобы найти мартовские строки, серверу надо вычислить месяц для каждой. Это та же история, что с lower(email) в прошлом уроке. Есть два выхода: индекс по выражению или запрос, где колонка стоит голой. Для дат второй лучше, его называют диапазоном:
EXPLAIN SELECT * FROM sales
WHERE sold_on >= date '2026-03-01' AND sold_on < date '2026-04-01';
QUERY PLAN
------------------------------------------------------------------------------------------
Bitmap Heap Scan on sales (cost=158.62..1349.93 rows=11154 width=47)
Recheck Cond: ((sold_on >= '2026-03-01'::date) AND (sold_on < '2026-04-01'::date))
-> Bitmap Index Scan on sales_sold_on_idx (cost=0.00..155.83 rows=11154 width=0)
Index Cond: ((sold_on >= '2026-03-01'::date) AND (sold_on < '2026-04-01'::date))
(4 rows)
Заметь и вторую ошибку первого запроса. Сервер ждал 500 строк, а мартовских больше 11 тысяч: про результат функции у него нет статистики, и он подставил догадку. Голая колонка лечит и это.
урок из курса
Урок из курса «PostgreSQL с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.