Если CREATE INDEX CONCURRENTLY или REINDEX INDEX CONCURRENTLY завершается с ошибкой, Postgres оставляет после себя невалидный индекс. Многие инженеры считают такие индексы безобидными заготовками, которые просто ждут удаления. В конце концов, планировщик ведь не использует их в запросах, верно?

Нет. Невалидные индексы совсем не безобидны. Они продолжают потреблять ресурсы, генерировать ввод‑вывод, мешать оптимизациям и даже создавать конкуренцию за блокировки — при этом не давая никакого выигрыша в производительности запросов.

В этой статье мы на реальных примерах покажем скрытые издержки невалидных индексов, о которых должен знать каждый администратор Postgres.

Откуда берутся невалидные индексы

Обычно невалидные индексы появляются после неудачно завершившихся параллельных операций:

-- Эта команда может завершиться с ошибкой и оставить невалидный индекс.
-- Например, если таблица большая, а statement_timeout установлен слишком низко.
create index concurrently idx_orders_created_at on orders(created_at);

-- Проверяем наличие невалидных индексов
select
 indexrelid::regclass as index_name,
 indisvalid as is_valid,
 indisready as is_ready
from pg_index
where not indisvalid;

Индекс с indisvalid = false помечен как невалидный. Документация Postgres рекомендует удалять такие индексы, но многие команды оставляют их на месте, считая, что они ни на что не влияют. Давайте разберемся, почему это опасное заблуждение.

Тестовый стенд

Во всех примерах ниже используется одна и та же простая конфигурация:

drop table if exists test_invalid_idx cascade;

create table test_invalid_idx (
 id bigserial primary key,
 indexed_col int,
 non_indexed_col text
) with (fillfactor = 50);


insert into test_invalid_idx (indexed_col, non_indexed_col)
select i, 'initial' from generate_series(1, 100) i;


-- Создаём индекс, а затем помечаем его как невалидный,
-- имитируя сбой CREATE INDEX CONCURRENTLY.
-- При реальном сбое остаются indisready = true и indisvalid = false.
create index idx_test_indexed_col on test_invalid_idx(indexed_col);
update pg_index
set indisvalid = false, indisready = true
where indexrelid = 'idx_test_indexed_col'::regclass;

Теперь разберём каждую скрытую издержку по отдельности.

1. Обслуживается при каждой операции записи

Невалидные индексы обновляются при каждом INSERT и UPDATE, точно так же, как валидные. С помощью pageinspect — contrib‑модуля для низкоуровневого анализа страниц базы данных — посчитаем количество элементов на листовой странице B‑дерева:

create extension if not exists pageinspect;

-- До вставки: 100 элементов на листовой странице невалидного индекса
select count(*) as leaf_items from bt_page_items('idx_test_indexed_col', 1);
 leaf_items
------------
        100
-- INSERT добавляет новую запись в невалидный индекс
insert into test_invalid_idx (indexed_col, non_indexed_col) values (999, 'new');
select count(*) as leaf_items from bt_page_items('idx_test_indexed_col', 1);
 leaf_items
------------
        101
-- UPDATE тоже добавляет новую запись (B‑деревья не обновляют записи на месте)
update test_invalid_idx set indexed_col = 888 where indexed_col = 999;
select count(*) as leaf_items from bt_page_items('idx_test_indexed_col', 1);
 leaf_items
------------
        102

Каждая операция записи несёт издержки ввода‑вывода на обслуживание индекса, который не даёт никакого выигрыша в производительности запросов.

2. Обрабатывается VACUUM и генерирует WAL

VACUUM сканирует невалидные индексы, расходуя ресурсы autovacuum:

delete from test_invalid_idx where id > 50;
vacuum verbose test_invalid_idx;
INFO:  vacuuming "public.test_invalid_idx"
index "idx_test_indexed_col": pages: 5 in total, 1 newly deleted
      ^^^^^^^^^^^^^^^^^^^^^^ Невалидный индекс был обработан!

DML‑операции также генерируют записи WAL для невалидных индексов. Проверим это с помощью pg_walinspect, доступного в Postgres 15 и новее:

create extension if not exists pg_walinspect;

-- Сохраняем LSN до и после INSERT
select pg_current_wal_lsn() as start_lsn \gset
insert into test_invalid_idx (indexed_col, non_indexed_col) values (999, 'wal_test');
select pg_current_wal_lsn() as end_lsn \gset

-- Ищем записи WAL для нашего невалидного индекса
select resource_manager, record_type, block_ref
from pg_get_wal_records_info(:'start_lsn', :'end_lsn')
where block_ref like '%' || (
  select relfilenode::text from pg_class
  where relname = 'idx_test_indexed_col'
) || '%';
 resource_manager | record_type |            block_ref
------------------+-------------+----------------------------------
 Btree            | INSERT_LEAF | blkref #0: rel 1663/5/16957 ...
                                                       ^^^^^ Невалидный индекс!

Это увеличивает объём WAL, объём данных, передаваемых на реплики, и размер резервных копий.

3. Мешает HOT‑обновлениям

Это, пожалуй, самое серьёзное последствие. Оптимизация Postgres Heap‑Only Tuple (HOT) позволяет при обновлении строк не изменять индексы, если значения индексируемых столбцов не меняются. Невалидные индексы мешают HOT‑обновлениям для своих столбцов точно так же, как валидные, но при этом не дают никакого выигрыша при выполнении запросов. Вы платите полную цену индексирования, не получая от него никакой пользы.

select pg_stat_reset();

-- Обновляем неиндексируемый столбец
update test_invalid_idx set non_indexed_col = 'updated' where id <= 10;

select round(100.0 * n_tup_hot_upd / n_tup_upd, 1) as hot_percent
from pg_stat_user_tables where relname = 'test_invalid_idx';
-- Результат: 96,8%

select pg_stat_reset();

-- Обновляем столбец, покрытый невалидным индексом
update test_invalid_idx set indexed_col = indexed_col + 1 where id <= 10;

select round(100.0 * n_tup_hot_upd / n_tup_upd, 1) as hot_percent
from pg_stat_user_tables where relname = 'test_invalid_idx';
-- Результат: 0%

Обновляемый столбец

Доля HOT

Неиндексируемый

96,8%

Покрытый невалидным индексом

0%

Нулевая доля HOT означает более быстрое разрастание таблицы, дополнительную работу для VACUUM и падение производительности — и всё это из‑за индекса, который никак не ускоряет запросы.

4. Засоряет статистику

В системах мониторинга невалидные индексы показывают ноль сканирований, поэтому могут выглядеть как обычные неиспользуемые индексы, если не проверять indisvalid. DBA, запускающий скрипты очистки, может даже не заметить, что индекс уже сломан.

5. Создаёт накладные расходы для планировщика и конкуренцию за блокировки

Во время планирования запроса Postgres получает AccessShareLock на все индексы участвующих таблиц, включая невалидные — если только вы не используете подготовленные запросы:

begin;
explain select * from test_invalid_idx where indexed_col = 500;

select c.relname, l.mode from pg_locks l
join pg_class c on l.relation = c.oid
where l.pid = pg_backend_pid();
     object_name      |    lock_mode
----------------------+-----------------
 idx_test_indexed_col | AccessShareLock   <-- Невалидный индекс тоже заблокирован!
 test_invalid_idx     | AccessShareLock

Эта блокировка конфликтует с AccessExclusiveLock, которая требуется для DROP INDEX, REINDEX и ALTER INDEX. В нагруженной системе из‑за этого удалить невалидный индекс во время обычной работы может оказаться неожиданно сложно. Вы несёте те же издержки на блокировки, что и для полезного индекса, но не получаете ничего взамен.

6. Мешает повторному запуску миграций

Невалидные индексы могут мешать повторному запуску миграций схемы. Рассмотрим типичный сценарий:

-- Попытка миграции №1: завершается с ошибкой из-за statement_timeout или по другой причине
create index concurrently idx_orders_created_at on orders(created_at);
-- ERROR: canceling statement due to statement timeout

После неудачного CREATE INDEX CONCURRENTLY остаётся невалидный индекс с именем idx_orders_created_at. При повторном запуске миграции:

-- Попытка миграции №2: не проходит
create index concurrently idx_orders_created_at on orders(created_at);
-- ERROR: relation "idx_orders_created_at" already exists

Повторный запуск завершается с ошибкой, потому что имя индекса уже занято невалидным индексом. Особенно неприятно это проявляется в CI/CD‑пайплайнах, где миграции выполняются автоматически: пайплайн будет падать снова и снова, пока кто‑нибудь вручную не удалит невалидный индекс.

Решение: сначала удалить невалидный индекс, а затем повторить операцию:

drop index concurrently if exists idx_orders_created_at;
create index concurrently idx_orders_created_at on orders(created_at);

Подведем итоги

Последствие: издержки как у валидного индекса, пользы — ноль

Подтверждение

Обслуживается при каждой операции записи

leaf_items: 100 → 101 → 102

Обрабатывается VACUUM

idx_test_indexed_col: pages: 5, 1 newly deleted

Генерирует записи WAL

Btree INSERT_LEAF для невалидного индекса

Мешает HOT‑обновлениям

Доля HOT падает с 96,8% до 0%, если столбец покрыт индексом

Блокируется планировщиком

AccessShareLock мешает DDL‑операциям

Мешает повторному запуску миграций

Ошибка relation already exists

Как найти невалидные индексы

Выполните следующий запрос, чтобы найти все невалидные индексы в базе данных:

select
  n.nspname as schema,
  c.relname as index_name,
  t.relname as table_name,
  pg_size_pretty(pg_relation_size(c.oid)) as size,
  i.indisvalid as is_valid,
  i.indisready as is_ready
from pg_index i
join pg_class c on c.oid = i.indexrelid
join pg_class t on t.oid = i.indrelid
join pg_namespace n on n.oid = c.relnamespace
where not i.indisvalid
order by pg_relation_size(c.oid) desc;

Или используйте PostgresAI, чтобы находить их автоматически. Это одна из наших базовых проверок состояния — H001. Она помогает командам удалять невалидные индексы вручную или полностью автоматизировать этот процесс с помощью ИИ‑ассистентов вроде Claude Code и Cursor через CLI или MCP PostgresAI:

PostgresAI H001 Invalid Indexes Report
PostgresAI H001 Отчет о недействительных индексах

Удалить или пересоздать?

Прежде чем удалять невалидный индекс, выясните, нужен ли он и следует ли его пересоздать. Вот как это определить.

1. Проверьте наличие валидных дубликатов

Если на тех же столбцах уже существует валидный индекс, невалидный можно просто удалить:

select
  n.nspname as schema,
  ci.relname as invalid_index,
  t.relname as table_name,
  pg_get_indexdef(i.indexrelid) as definition,
  -- Проверяем наличие валидного дубликата
  (select string_agg(c2.relname, ', ')
   from pg_index i2
   join pg_class c2 on c2.oid = i2.indexrelid
   where i2.indrelid = i.indrelid
     and i2.indisvalid
     and i2.indkey = i.indkey
  ) as valid_duplicates
from pg_index i
join pg_class ci on ci.oid = i.indexrelid
join pg_class t on t.oid = i.indrelid
join pg_namespace n on n.oid = ci.relnamespace
where not i.indisvalid;

2. Проверьте, связан ли индекс с ограничением

Индексы, на которых основаны ограничения UNIQUE или PRIMARY KEY, необходимо пересоздать:

select
  c.relname as index_name,
  con.conname as constraint_name,
  case con.contype
    when 'p' then 'PRIMARY KEY'
    when 'u' then 'UNIQUE'
  end as constraint_type
from pg_index i
join pg_class c on c.oid = i.indexrelid
left join pg_constraint con on con.conindid = i.indexrelid
where not i.indisvalid
  and con.conname is not null;

3. Проверьте текущие планы запросов

Посмотрите, выполняются ли последовательные сканирования там, где мог бы помочь этот индекс:

-- Получаем определение индекса
select pg_get_indexdef('your_invalid_index'::regclass);


-- Проверяем план типового запроса по этому столбцу
explain select * from your_table where indexed_column = 'value';
-- Seq Scan по большой таблице: скорее всего, индекс стоит пересоздать

Схема принятия решения

Примечание: всегда используйте DROP INDEX CONCURRENTLY и REINDEX INDEX CONCURRENTLY, чтобы не блокировать другие сессии.

Что делать

Невалидные индексы вовсе не бездействуют — они активно ухудшают производительность. Поэтому, обнаружив такой индекс, не откладывайте очистку:

  • Сразу удалите его с помощью DROP INDEX CONCURRENTLY, если он не связан с нужным ограничением.

  • Настройте мониторинг: добавьте select count(*) from pg_index where not indisvalid в систему оповещений.

  • Разберитесь в причине: проверьте журналы Postgres и выясните, почему исходный CREATE INDEX CONCURRENTLY завершился с ошибкой.

Проверьте, насколько уверенно вы работаете с индексами, блокировками и обслуживанием PostgreSQL, на вступительном тесте курса «Администрирование PostgreSQL. Экспертный уровень».

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

  • 4 августа в 20:00. «PostgreSQL как память ИИ‑агентов: MVCC, очереди и партиции под нагрузкой». Записаться

  • 19 августа в 20:00. «PostgreSQL на стероидах: большие данные, высокие нагрузки и масштабирование без боли». Записаться

Комментарии (0)