Как делать11 октября 2026 г.8 мин чтения
SQL и PostgreSQLШаг 6 из 9

Индексы: как ускорить запрос и не замедлить запись

Коротко

Индекс — отдельная упорядоченная структура, по которой PostgreSQL находит нужные строки, не читая всю таблицу. Индекс подбирают под конкретный запрос: составной — когда есть равенство и сортировка, частичный — когда запрашивают малую долю строк, по выражению — когда в условии функция, GIN — для jsonb и полнотекстового поиска. Каждый индекс замедляет INSERT и UPDATE и занимает место, поэтому эффект подтверждают через EXPLAIN, а неиспользуемые индексы удаляют.

Глубокий разборУровень: СреднийИндексы

После материала вы сможете

  • объяснить, как B-tree находит строки и почему индекс помогает одним запросам и бесполезен для других
  • построить составной индекс с правильным порядком столбцов и убрать поглощённый
  • выбрать частичный индекс, индекс по выражению или GIN под конкретное условие WHERE
  • подтвердить эффект через EXPLAIN (ANALYZE, BUFFERS) и оценить цену индекса для записи

Проверено на

PostgreSQL 17 (17.11, postgres:17-alpine)Docker 29

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что такое индекс и как устроен B-tree
  2. 02Стенд: миллион заказов в Docker
  3. 03Когда индекс используется: замер до и после
  4. 04Составной индекс и порядок столбцов
  5. 05Частичные индексы и индексы по выражению
  6. 06GIN для jsonb и полнотекстового поиска — коротко
  7. 07Цена индекса: запись, журнал и место
  8. 08Упражнения

Что такое индекс и как устроен 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

Для чего
Огромные таблицы, где значения коррелируют с физическим порядком строк
Пример
журнал событий по времени вставки
Типы индексов PostgreSQL 17 — по разделу 11.2 документации

Стенд: миллион заказов в Docker

Документация прямо предупреждает: на крошечных тестовых данных выводы об индексах бессмысленны — сто строк умещаются в одну страницу, и быстрее последовательного чтения ничего нет. Поэтому стенд — таблица orders на миллион строк: 50 000 клиентов, 1 % заказов в статусе new, email в смешанном регистре, jsonb с каналом продаж и редким промокодом. setseed делает данные одинаковыми при каждом запуске.

bash
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
Запуск PostgreSQL 17 и наполнение. Пароль test — только для локального одноразового контейнера; в bash и Git Bash работает перенаправление <, в PowerShell используйте Get-Content 01-seed.sql | docker exec -i …
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;
Таблица заказов и генерация 1 000 000 строк через generate_series
terminal
$ 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)
Таблица занимает 108 МБ, распределение статусов неравномерное — как в живой системе

После загрузки обязательно ANALYZE: он собирает статистику распределения значений, по которой планировщик оценивает, сколько строк вернёт условие. Без неё документация называет анализ использования индексов «пропащим делом».

Когда индекс используется: замер до и после

Возьмём запрос личного кабинета: последние десять заказов клиента. Каждый план снимаем через EXPLAIN (ANALYZE, BUFFERS, COSTS OFF): ANALYZE выполняет запрос и показывает реальное время и число строк, BUFFERS — сколько страниц по 8 КБ пришлось прочитать, COSTS OFF прячет оценки стоимости, чтобы план был короче. Как читать план целиком, разобрано в уроке об оптимизации запросов.

02-btree.sql
-- Последние 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';
Один запрос — три варианта индекса
terminal
$ 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
Вывод сокращён. Время — пример на нашей машине, у вас цифры будут другими; число страниц (Buffers) воспроизводится точно

Без индекса 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) и останавливается на десятой строке.

terminal
=== Составной индекс (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
Вывод сокращён: составной индекс убрал сортировку; запрос только по created_at этот индекс не использует

Правило из документации: эффективнее всего ограничивают просмотр условия на ведущие (левые) столбцы — равенство на первых столбцах плюс неравенство на первом столбце без равенства. Условия на столбцы правее проверяются внутри индекса, но не сужают его просматриваемую часть. Поэтому запрос WHERE created_at >= '2025-12-31' без customer_id снова идёт Seq Scan: даты в индексе разбросаны по 50 000 «папкам» клиентов.

  1. Сначала столбцы с равенством

    Те, что стоят в WHERE col = …: клиент, статус, тенант.

  2. Затем диапазон или сортировка

    Дата в ORDER BY или created_at >= … — после столбцов с равенством.

  3. Не больше трёх столбцов

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

  4. Убрать поглощённый индекс

    Индекс (customer_id) стал лишним: составной (customer_id, created_at) начинается с того же столбца и обслуживает те же запросы.

Частичные индексы и индексы по выражению

Частичный индекс строится только по строкам, которые удовлетворяют условию WHERE. Типичный случай — очередь: операторам нужны новые заказы, а их 1 % от миллиона. Индексировать 950 тысяч выполненных заказов ради этого незачем.

03-partial-expression.sql
-- 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';
Частичный индекс, индекс по выражению и класс операторов для LIKE
terminal
$ 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
Вывод сокращён. Частичный индекс в 128 раз меньше обычного (status, created_at) и отдаёт очередь без сортировки

Частичный индекс используется, только если планировщик может доказать, что условие запроса влечёт условие индекса. Запрос 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 — обновление строки без изменения индексов).

terminal
=== 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 по префиксу: обычный B-tree при локали en_US.utf8 не используется, с text_pattern_ops — используется

Поиск по началу строки (LIKE 'user4242%') B-tree поддерживает, но если база создана не с локалью C — нужен класс операторов text_pattern_ops: он сравнивает строки посимвольно, без правил сортировки локали. Такой индекс обслуживает и равенство, но не обычные < и > — для них понадобится второй индекс с классом по умолчанию.

GIN для jsonb и полнотекстового поиска — коротко

GIN — инвертированный индекс: для каждого элемента значения (ключа jsonb, слова документа, элемента массива) он хранит список строк, где элемент встречается. Это то же устройство, что у указателя в книге, только для значений, состоящих из многих частей.

04-gin.sql
-- 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', 'курьер опоздал');
GIN по jsonb (класс jsonb_path_ops) и по tsvector
terminal
$ 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 с пятью индексами.

05-write-cost.sql
-- Цена индексов для записи: одна и та же пачка из 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;
Замер времени вставки и объёма WAL (журнала предзаписи)
terminal
$ 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)
Вывод сокращён. Время — один прогон на нашей машине; WAL и размеры воспроизводятся

Та же вставка с пятью индексами шла в пять–шесть раз дольше и записала в журнал 150 МБ вместо 40 МБ. Журнал — это и реплики, и бэкапы: каждый лишний индекс умножает трафик репликации. Индексы orders в сумме заняли 96 МБ при 130 МБ самой таблицы, а составной индекс после вставки в случайные места вырос с 30 до 48 МБ — страницы B-tree расщепляются.

Медленный запросEXPLAIN доИндекс под WHERE и ORDER BYEXPLAIN послеЗамер записи
Порядок работы с индексом
  • Индекс создан под конкретный медленный запрос, а не «на всякий случай»
  • Эффект подтверждён планом 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.