Индекс — отдельная упорядоченная структура, по которой PostgreSQL находит нужные строки, не читая всю таблицу. Индекс подбирают под конкретный запрос: составной — когда есть равенство и сортировка, частичный — когда запрашивают малую долю строк, по выражению — когда в условии функция, GIN — для jsonb и полнотекстового поиска. Каждый индекс замедляет INSERT и UPDATE и занимает место, поэтому эффект подтверждают через EXPLAIN, а неиспользуемые индексы удаляют.
После материала вы сможете
- объяснить, как B-tree находит строки и почему индекс помогает одним запросам и бесполезен для других
- построить составной индекс с правильным порядком столбцов и убрать поглощённый
- выбрать частичный индекс, индекс по выражению или GIN под конкретное условие
WHERE - подтвердить эффект через
EXPLAIN (ANALYZE, BUFFERS)и оценить цену индекса для записи
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, postgres:17-alpine)Docker 29
Сверено с документацией: 11 октября 2026 г.
Содержание
Что такое индекс и как устроен B-tree
Документация PostgreSQL объясняет индекс через предметный указатель в конце книги: чтобы найти термин, не нужно читать все страницы — достаточно открыть указатель и перейти на нужную. Без индекса запрос WHERE customer_id = 4242 вынужден просмотреть каждую строку таблицы: это последовательное сканирование, в плане оно называется Seq Scan.
По умолчанию CREATE INDEX создаёт B-tree — сбалансированное дерево, в листьях которого значения столбца лежат по порядку вместе с адресами строк. Поиск спускается от корня к листу за несколько шагов, а потом идёт по соседним листьям. Поэтому B-tree подходит для равенства и диапазонов (=, <, >, BETWEEN, IN), для IS NULL и для выдачи строк в отсортированном порядке — ORDER BY можно выполнить без отдельной сортировки.
Все индексы PostgreSQL вторичные: они хранятся отдельно от основной области таблицы, которую документация называет heap («куча»). Обычный Index Scan сначала находит запись в индексе, затем читает саму строку из кучи. Если все нужные столбцы есть в индексе, возможен Index Only Scan — без обращения к таблице.
B-tree (по умолчанию)
- Для чего
- Равенство, диапазоны, сортировка,
LIKE 'abc%' - Пример
created_at >= now() - interval '1 day'
Hash
- Для чего
- Только равенство
- Пример
token = '…'
GIN
- Для чего
- Значения из многих частей: массивы, jsonb, tsvector
- Пример
attrs @> '{"promo": "AUTUMN25"}'
GiST / SP-GiST
- Для чего
- Геометрия, диапазоны, поиск ближайших
- Пример
ORDER BY location <-> point '(10,20)'
BRIN
- Для чего
- Огромные таблицы, где значения коррелируют с физическим порядком строк
- Пример
- журнал событий по времени вставки
Стенд: миллион заказов в Docker
Документация прямо предупреждает: на крошечных тестовых данных выводы об индексах бессмысленны — сто строк умещаются в одну страницу, и быстрее последовательного чтения ничего нет. Поэтому стенд — таблица orders на миллион строк: 50 000 клиентов, 1 % заказов в статусе new, email в смешанном регистре, jsonb с каналом продаж и редким промокодом. setseed делает данные одинаковыми при каждом запуске.
docker run -d --rm --name kb-pg-indeksy -e POSTGRES_PASSWORD=test postgres:17-alpine
docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 01-seed.sql-- Учебная таблица заказов: 1 000 000 строк, данные воспроизводимы (setseed)
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL,
status text NOT NULL,
email text NOT NULL,
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL,
attrs jsonb NOT NULL DEFAULT '{}'
);
SELECT setseed(0.42);
INSERT INTO orders (customer_id, status, email, total, created_at, attrs)
SELECT c,
CASE WHEN r < 0.01 THEN 'new' -- 1 % заказов ждут обработки
WHEN r < 0.05 THEN 'cancelled'
ELSE 'done' END,
'User' || c || '@Example.com', -- смешанный регистр, как вводят люди
round((500 + random() * 49500)::numeric, 2),
timestamptz '2025-01-01' + random() * interval '365 days',
jsonb_build_object('channel', (ARRAY['site','app','phone','marketplace'])[1 + floor(random() * 4)::int])
|| CASE WHEN random() < 0.005 THEN '{"promo": "AUTUMN25"}'::jsonb ELSE '{}'::jsonb END
FROM (SELECT 1 + floor(random() * 50000)::int AS c, random() AS r
FROM generate_series(1, 1000000)) AS g;
ANALYZE orders;
SELECT count(*) AS rows,
pg_size_pretty(pg_relation_size('orders')) AS table_size
FROM orders;
SELECT status, count(*) FROM orders GROUP BY status ORDER BY 2 DESC;$ docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 01-seed.sql
INSERT 0 1000000
ANALYZE
rows | table_size
---------+------------
1000000 | 108 MB
(1 row)
status | count
-----------+--------
done | 949989
cancelled | 39986
new | 10025
(3 rows)После загрузки обязательно ANALYZE: он собирает статистику распределения значений, по которой планировщик оценивает, сколько строк вернёт условие. Без неё документация называет анализ использования индексов «пропащим делом».
Когда индекс используется: замер до и после
Возьмём запрос личного кабинета: последние десять заказов клиента. Каждый план снимаем через EXPLAIN (ANALYZE, BUFFERS, COSTS OFF): ANALYZE выполняет запрос и показывает реальное время и число строк, BUFFERS — сколько страниц по 8 КБ пришлось прочитать, COSTS OFF прячет оценки стоимости, чтобы план был короче. Как читать план целиком, разобрано в уроке об оптимизации запросов.
-- Последние 10 заказов клиента: типичный запрос личного кабинета
\echo '=== ДО: индекса нет'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 10;
CREATE INDEX orders_customer_idx ON orders (customer_id);
\echo '=== Индекс по customer_id'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 10;
-- Составной индекс: равенство первым, сортировка вторым
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);
DROP INDEX orders_customer_idx; -- стал лишним: составной начинается с того же столбца
\echo '=== Составной индекс (customer_id, created_at)'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 10;
\echo '=== Тот же индекс, но условие только по второму столбцу'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*)
FROM orders
WHERE created_at >= '2025-12-31';$ docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 02-btree.sql
=== ДО: индекса нет
Limit (actual time=25.159..26.615 rows=10 loops=1)
Buffers: shared hit=13832 read=107
-> Gather Merge (actual time=25.158..26.613 rows=10 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=13832 read=107
-> Sort (actual time=12.695..12.696 rows=4 loops=3)
Sort Key: created_at DESC
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=13832 read=107
-> Parallel Seq Scan on orders (actual time=0.758..12.594 rows=5 loops=3)
Filter: (customer_id = 4242)
Rows Removed by Filter: 333328
Buffers: shared hit=13758 read=107
Planning Time: 0.462 ms
Execution Time: 26.669 ms
=== Индекс по customer_id
Limit (actual time=0.056..0.057 rows=10 loops=1)
Buffers: shared hit=16 read=3
-> Sort (actual time=0.055..0.056 rows=10 loops=1)
Sort Key: created_at DESC
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=16 read=3
-> Bitmap Heap Scan on orders (actual time=0.022..0.044 rows=16 loops=1)
Recheck Cond: (customer_id = 4242)
Heap Blocks: exact=16
Buffers: shared hit=16 read=3
-> Bitmap Index Scan on orders_customer_idx (actual time=0.016..0.016 rows=16 loops=1)
Index Cond: (customer_id = 4242)
Buffers: shared read=3
Planning Time: 0.186 ms
Execution Time: 0.082 msБез индекса PostgreSQL запускает параллельное последовательное сканирование: три процесса читают почти 14 тысяч страниц и отбрасывают по 333 тысячи строк, чтобы найти 16 заказов. С индексом по customer_id план меняется на Bitmap Index Scan + Bitmap Heap Scan: индекс даёт адреса 16 строк, таблица читается точечно — 19 страниц вместо 13 939.
Индекс не используется, если условие не совпадает с тем, что в нём лежит, или если планировщик посчитал, что последовательное чтение дешевле:
Условие возвращает большую долю таблицы
- Почему индекс не помогает
- Читать почти все страницы подряд дешевле, чем прыгать по индексу
- Что делать
- Ничего: это верное решение планировщика
Функция над столбцом: lower(email) = …
- Почему индекс не помогает
- В индексе лежит
email, а неlower(email) - Что делать
- Индекс по выражению
LIKE '%abc' или LIKE при локали не C
- Почему индекс не помогает
- B-tree ищет только по началу строки и только в подходящем классе операторов
- Что делать
text_pattern_ops, для подстрок —pg_trgmили полнотекстовый поиск
Условие только по второму столбцу составного индекса
- Почему индекс не помогает
- Порядок в индексе задан первым столбцом
- Что делать
- Другой порядок столбцов или отдельный индекс
Устаревшая статистика
- Почему индекс не помогает
- Планировщик ошибается в числе строк
- Что делать
ANALYZE, проверить autovacuum
Составной индекс и порядок столбцов
В плане с индексом по customer_id остался узел Sort: строки нашлись быстро, но их ещё нужно отсортировать по дате. Составной индекс (customer_id, created_at) хранит записи отсортированными сначала по клиенту, а внутри клиента — по дате. Запрос с равенством по первому столбцу и сортировкой по второму читает индекс с конца (Index Scan Backward) и останавливается на десятой строке.
=== Составной индекс (customer_id, created_at)
Limit (actual time=0.026..0.049 rows=10 loops=1)
Buffers: shared hit=10 read=3
-> Index Scan Backward using orders_customer_created_idx on orders (actual time=0.025..0.047 rows=10 loops=1)
Index Cond: (customer_id = 4242)
Buffers: shared hit=10 read=3
Planning Time: 0.284 ms
Execution Time: 0.068 ms
=== Тот же индекс, но условие только по второму столбцу
Finalize Aggregate (actual time=35.394..39.077 rows=1 loops=1)
Buffers: shared hit=13854 read=11
-> Gather (actual time=35.258..39.068 rows=3 loops=1)
Buffers: shared hit=13854 read=11
-> Partial Aggregate (actual time=28.456..28.458 rows=1 loops=3)
Buffers: shared hit=13854 read=11
-> Parallel Seq Scan on orders (actual time=0.070..28.323 rows=954 loops=3)
Filter: (created_at >= '2025-12-31 00:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 332379
Buffers: shared hit=13854 read=11
Planning Time: 0.131 ms
Execution Time: 39.207 msПравило из документации: эффективнее всего ограничивают просмотр условия на ведущие (левые) столбцы — равенство на первых столбцах плюс неравенство на первом столбце без равенства. Условия на столбцы правее проверяются внутри индекса, но не сужают его просматриваемую часть. Поэтому запрос WHERE created_at >= '2025-12-31' без customer_id снова идёт Seq Scan: даты в индексе разбросаны по 50 000 «папкам» клиентов.
Сначала столбцы с равенством
Те, что стоят в
WHERE col = …: клиент, статус, тенант.Затем диапазон или сортировка
Дата в
ORDER BYилиcreated_at >= …— после столбцов с равенством.Не больше трёх столбцов
Документация предупреждает: индексы шире трёх столбцов редко окупаются, если нагрузка не очень однотипная.
Убрать поглощённый индекс
Индекс
(customer_id)стал лишним: составной(customer_id, created_at)начинается с того же столбца и обслуживает те же запросы.
Частичные индексы и индексы по выражению
Частичный индекс строится только по строкам, которые удовлетворяют условию WHERE. Типичный случай — очередь: операторам нужны новые заказы, а их 1 % от миллиона. Индексировать 950 тысяч выполненных заказов ради этого незачем.
-- 1. Частичный индекс: очередь новых заказов (1 % таблицы)
\echo '=== Очередь new: ДО'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, customer_id, created_at
FROM orders
WHERE status = 'new'
ORDER BY created_at
LIMIT 50;
CREATE INDEX orders_status_created_idx ON orders (status, created_at); -- обычный, для сравнения
CREATE INDEX orders_new_created_idx ON orders (created_at) WHERE status = 'new'; -- частичный
SELECT indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index
WHERE indrelid = 'orders'::regclass
ORDER BY pg_relation_size(indexrelid) DESC;
DROP INDEX orders_status_created_idx; -- оставляем только частичный
\echo '=== Очередь new: частичный индекс'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, customer_id, created_at
FROM orders
WHERE status = 'new'
ORDER BY created_at
LIMIT 50;
-- 2. Индекс по выражению: поиск по email без учёта регистра
\echo '=== lower(email): ДО'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM orders WHERE lower(email) = 'user4242@example.com';
CREATE INDEX orders_email_lower_idx ON orders (lower(email));
\echo '=== lower(email): индекс по выражению'
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM orders WHERE lower(email) = 'user4242@example.com';
\echo '=== LIKE по префиксу: обычный класс операторов не подходит (локаль не C)'
EXPLAIN (COSTS OFF)
SELECT id FROM orders WHERE lower(email) LIKE 'user4242%';
DROP INDEX orders_email_lower_idx;
CREATE INDEX orders_email_lower_idx ON orders (lower(email) text_pattern_ops);
\echo '=== LIKE по префиксу: text_pattern_ops'
EXPLAIN (COSTS OFF)
SELECT id FROM orders WHERE lower(email) LIKE 'user4242%';
\echo '=== И равенство тоже работает с text_pattern_ops'
EXPLAIN (COSTS OFF)
SELECT id FROM orders WHERE lower(email) = 'user4242@example.com';$ docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 03-partial-expression.sql
index_name | size
-----------------------------+--------
orders_status_created_idx | 30 MB
orders_customer_created_idx | 30 MB
orders_pkey | 21 MB
orders_new_created_idx | 240 kB
(4 rows)
=== Очередь new: частичный индекс
Limit (actual time=0.021..0.092 rows=50 loops=1)
Buffers: shared hit=50 read=2
-> Index Scan using orders_new_created_idx on orders (actual time=0.020..0.088 rows=50 loops=1)
Buffers: shared hit=50 read=2
Planning Time: 0.156 ms
Execution Time: 0.107 msЧастичный индекс используется, только если планировщик может доказать, что условие запроса влечёт условие индекса. Запрос WHERE status = 'new' подходит, а WHERE status = $1 с параметром — нет, если план строится заранее, без знания значения. Документация отдельно предостерегает от набора частичных индексов вместо партиционирования: десяток индексов WHERE category = N хуже одного (category, data).
Индекс по выражению хранит уже вычисленный результат функции. Запрос WHERE lower(email) = 'user4242@example.com' не может использовать индекс по email, а индекс по lower(email) превращает его в обычный точечный поиск: без индекса 13 865 страниц, с индексом — 19. Цена — выражение вычисляется при каждой вставке и при каждом обновлении, которое не удалось выполнить как HOT (heap-only tuple — обновление строки без изменения индексов).
=== LIKE по префиксу: обычный класс операторов не подходит (локаль не C)
Gather
Workers Planned: 2
-> Parallel Seq Scan on orders
Filter: (lower(email) ~~ 'user4242%'::text)
=== LIKE по префиксу: text_pattern_ops
Bitmap Heap Scan on orders
Filter: (lower(email) ~~ 'user4242%'::text)
-> Bitmap Index Scan on orders_email_lower_idx
Index Cond: ((lower(email) ~>=~ 'user4242'::text) AND (lower(email) ~<~ 'user4243'::text))Поиск по началу строки (LIKE 'user4242%') B-tree поддерживает, но если база создана не с локалью C — нужен класс операторов text_pattern_ops: он сравнивает строки посимвольно, без правил сортировки локали. Такой индекс обслуживает и равенство, но не обычные < и > — для них понадобится второй индекс с классом по умолчанию.
GIN для jsonb и полнотекстового поиска — коротко
GIN — инвертированный индекс: для каждого элемента значения (ключа jsonb, слова документа, элемента массива) он хранит список строк, где элемент встречается. Это то же устройство, что у указателя в книге, только для значений, состоящих из многих частей.
-- GIN для jsonb: заказы с промокодом (0,5 % строк)
\echo '=== jsonb @>: ДО'
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM orders WHERE attrs @> '{"promo": "AUTUMN25"}';
CREATE INDEX orders_attrs_gin ON orders USING gin (attrs jsonb_path_ops);
\echo '=== jsonb @>: GIN jsonb_path_ops'
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM orders WHERE attrs @> '{"promo": "AUTUMN25"}';
\echo '=== Тот же смысл через ->> — GIN не используется'
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders WHERE attrs ->> 'promo' = 'AUTUMN25';
-- GIN для полнотекстового поиска
DROP TABLE IF EXISTS reviews;
CREATE TABLE reviews (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (to_tsvector('russian', body)) STORED
);
SELECT setseed(0.7);
INSERT INTO reviews (body)
SELECT (ARRAY['Быстрая доставка курьером', 'Курьер опоздал на час', 'Отличный кофе и выпечка',
'Заказ собрали неправильно', 'Удобное приложение', 'Доставили холодным'])[1 + floor(random() * 6)::int]
|| ', заказ №' || n
FROM generate_series(1, 200000) AS n;
CREATE INDEX reviews_search_gin ON reviews USING gin (search);
ANALYZE reviews;
\echo '=== Полнотекстовый поиск: GIN по tsvector'
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM reviews
WHERE search @@ websearch_to_tsquery('russian', 'курьер опоздал');$ docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 04-gin.sql
=== jsonb @>: ДО
Finalize Aggregate (actual time=56.271..59.589 rows=1 loops=1)
-> Gather (actual time=56.067..59.579 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (actual time=53.548..53.549 rows=1 loops=3)
-> Parallel Seq Scan on orders (actual time=0.040..53.413 rows=1643 loops=3)
Filter: (attrs @> '{"promo": "AUTUMN25"}'::jsonb)
Rows Removed by Filter: 331690
Planning Time: 0.777 ms
Execution Time: 59.708 ms
=== jsonb @>: GIN jsonb_path_ops
Aggregate (actual time=4.536..4.537 rows=1 loops=1)
-> Bitmap Heap Scan on orders (actual time=1.073..4.394 rows=4930 loops=1)
Recheck Cond: (attrs @> '{"promo": "AUTUMN25"}'::jsonb)
Heap Blocks: exact=4107
-> Bitmap Index Scan on orders_attrs_gin (actual time=0.701..0.701 rows=4930 loops=1)
Index Cond: (attrs @> '{"promo": "AUTUMN25"}'::jsonb)
Planning Time: 0.204 ms
Execution Time: 4.584 ms
=== Тот же смысл через ->> — GIN не используется
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Seq Scan on orders
Filter: ((attrs ->> 'promo'::text) = 'AUTUMN25'::text)- Класс по умолчанию
jsonb_opsподдерживает операторы?,?|,?&,@>,@?и@@;jsonb_path_ops— только@>,@?и@@, зато обычно заметно меньше и точнее. - GIN работает с операторами, а не с извлечением значения: условие
attrs ->> 'promo' = 'AUTUMN25'его не задействует. Для такого условия нужен B-tree по выражению(attrs ->> 'promo'). - Для полнотекстового поиска документация называет GIN предпочтительным типом; индекс строится по столбцу
tsvector— в примере это генерируемый столбецto_tsvector('russian', body). - GIN не поддерживает
Index Only Scan: строка всё равно читается из таблицы для перепроверки (Recheck Cond).
Цена индекса: запись, журнал и место
Индекс обновляется при каждом INSERT, при DELETE и при UPDATE проиндексированных столбцов. Документация так и пишет: синхронизация индекса с таблицей добавляет накладные расходы к изменению данных, а редко используемые индексы стоит удалять. Проверим на одной и той же пачке из 200 000 строк: в копию таблицы только с первичным ключом и в orders с пятью индексами.
-- Цена индексов для записи: одна и та же пачка из 200 000 строк
-- в таблицу только с первичным ключом и в orders со всеми индексами
CREATE TEMP TABLE batch AS
SELECT customer_id, status, email, total, created_at, attrs
FROM orders
ORDER BY random()
LIMIT 200000;
DROP TABLE IF EXISTS orders_pk_only;
CREATE TABLE orders_pk_only (LIKE orders INCLUDING DEFAULTS INCLUDING IDENTITY);
ALTER TABLE orders_pk_only ADD PRIMARY KEY (id);
CHECKPOINT;
\timing on
SELECT pg_current_wal_lsn() AS lsn0 \gset
INSERT INTO orders_pk_only (customer_id, status, email, total, created_at, attrs)
SELECT * FROM batch;
SELECT pg_current_wal_lsn() AS lsn1 \gset
INSERT INTO orders (customer_id, status, email, total, created_at, attrs)
SELECT * FROM batch;
SELECT pg_current_wal_lsn() AS lsn2 \gset
\timing off
SELECT pg_size_pretty(pg_wal_lsn_diff(:'lsn1', :'lsn0')) AS wal_pk_only,
pg_size_pretty(pg_wal_lsn_diff(:'lsn2', :'lsn1')) AS wal_all_indexes;
-- Сколько места занимают индексы и как часто их читали
SELECT indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC;
SELECT pg_size_pretty(pg_table_size('orders')) AS table_size,
pg_size_pretty(pg_indexes_size('orders')) AS indexes_size;$ docker exec -i kb-pg-indeksy psql -U postgres -v ON_ERROR_STOP=1 -f - < 05-write-cost.sql
INSERT 0 200000
Time: 306.514 ms
Time: 0.194 ms
INSERT 0 200000
Time: 1665.564 ms (00:01.666)
Time: 0.573 ms
Timing is off.
wal_pk_only | wal_all_indexes
-------------+-----------------
40 MB | 150 MB
(1 row)Та же вставка с пятью индексами шла в пять–шесть раз дольше и записала в журнал 150 МБ вместо 40 МБ. Журнал — это и реплики, и бэкапы: каждый лишний индекс умножает трафик репликации. Индексы orders в сумме заняли 96 МБ при 130 МБ самой таблицы, а составной индекс после вставки в случайные места вырос с 30 до 48 МБ — страницы B-tree расщепляются.
- Индекс создан под конкретный медленный запрос, а не «на всякий случай»
- Эффект подтверждён планом
EXPLAIN (ANALYZE, BUFFERS)до и после - Нет индексов, которые полностью поглощены составными
- На рабочей таблице индекс строится через
CREATE INDEX CONCURRENTLY— он не блокирует запись, но идёт дольше и не выполняется внутри транзакции - Раз в квартал — проверка
pg_stat_user_indexes: индексы с нулевымidx_scanза долгий период — кандидаты на удаление
Упражнения
Следующий шаг — урок об оптимизации запросов: там разбираются стоимость в плане, оценки строк против реальных, соединения таблиц и типичные исправления, когда индекс уже есть, а запрос всё равно медленный.
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
01-seed.sql … 05-write-cost.sql, 06-exercises.sql (RU и EN варианты комментариев; EN — полнотекст на english)
docker postgres:17-alpine (PostgreSQL 17.11), свежий контейнер на каждый язык, psql -v ON_ERROR_STOP=1 -f по порядку — все файлы без ошибок; выводы out-ru/ и out-en/ вставлены в статью фрагментами (скрипт сборки берёт код и вывод из файлов). Повторный прогон на свежем контейнере дал те же rows/Buffers/размеры/WAL; время различается в пределах десятков процентов
- запущено, работает
утверждения о планах (Seq Scan → Bitmap → Index Scan Backward; LIKE без/с text_pattern_ops; ->> без GIN; размеры 30 МБ/240 кБ; WAL 40/150 МБ; вставка ≈0,23–0,31 с против ≈1,4–1,7 с)
сверено с фактическим выводом трёх прогонов; в тексте — как пример на этой машине (28 ядер, shared_buffers 128MB по умолчанию)
- запущено, работает
решения упражнений (INCLUDE + VACUUM → Index Only Scan, Heap Fetches: 0; индекс по created_at → Index Only Scan, 14 страниц)
06-exercises.sql в том же прогоне после 05 (таблица 1,2 млн строк)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.