Раздел 33 · PostgreSQL с нуля

Почему индекс не используется

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Знакомая ситуация из любого проекта: запрос медленный, ты добавил индекс, а в 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 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.