Эта история началась с исследования проблем производительности на высоконагруженной базе данных Postgres. Табличка, которая была предметом исследования, довольно небольшая (~100,000 записей), но очень активно используемая.
В процессе исследования я увидел, что Postgres использует индексный доступ по абсолютно неселективному критерию, фактически это был "INDEX FULL SCAN" в терминологии Oracle. Интуиция, наработанная на другой промышленной базе, вопила: "что-то здесь не так!"
Но что?
Сейчас попробуем воспроизвести проблему в "рафинированном" (очищенном от ненужных деталей) случае.
Создаём табличку 4 колонки: u_id фактически уникальное поле;domain_id - будет содержать ровно 1 значение;name - какая то полезная нагрузка;p_id - довольно селективный, но не уникальный атрибут.
create TABLE test_idxscan (domain_id int8 not null, u_id uuid not null, name VARCHAR(255) not null, p_id int8 not null, constraint pk_test_idxscan primary KEY (u_id, domain_id));
insert into test_idxscan select 0 as domain_id, uuid_generate_v4() as u_id, 'somename some name name some' as name, mod(g,100) as p_id from pg_catalog.generate_series(1,10000) g;
Отключим параллельное сканирование для нашей сессии, чтобы не усложнять процесс (в реальной ПРОМ системе его и не наблюдается для данной крохотной таблички).
set min_parallel_table_scan_size=100000;
Выполним наш целевой запрос.
explain analyse select * from test_idxscan where domain_id=0 and p_id=2; QUERY PLAN | -----------------------------------------------------------------------------------------------------------+ Seq Scan on test_idxscan (cost=0.00..264.00 rows=100 width=61) (actual time=0.011..1.132 rows=100 loops=1)| Filter: ((domain_id = 0) AND (p_id = 2)) | Rows Removed by Filter: 9900 | Planning Time: 0.147 ms | Execution Time: 1.176 ms |
Пока никаких чудес, выбран SeqScan, как и ожидалось.
Но давайте теперь симулируем рабочую нагрузку: цель - получить достаточно большое количество версий строк (поэтому изменяем ключевое поле u_id, чтобы избежать hot update).
Реальная ПРОМ нагрузка включает комбинации: delete, insert, update - которые ведут к похожему эффекту.
Итак, в цикле апдейтим по 5000 строк (какие попадутся), пробегаем 300 итераций.
do $$ begin for i in 1..300 loop with cte as (select u_id from test_idxscan limit 5000) update test_idxscan set u_id=u_id where u_id in (select u_id from cte); end loop; end$$;
Выполняем опять наш запрос:
explain analyse select * from test_idxscan where domain_id=0 and p_id=2; QUERY PLAN | ---------------------------------------------------------------------------------------------------------------------------------+ Bitmap Heap Scan on test_idxscan (cost=2046.64..10194.81 rows=100 width=61) (actual time=3.562..5.707 rows=100 loops=1) | Recheck Cond: (domain_id = 0) | Filter: (p_id = 2) | Rows Removed by Filter: 9900 | Heap Blocks: exact=115 | -> Bitmap Index Scan on pk_test_idxscan (cost=0.00..2046.61 rows=10000 width=0) (actual time=3.524..3.524 rows=10000 loops=1)| Index Cond: (domain_id = 0) | Planning Time: 0.269 ms | Execution Time: 5.790 ms |
И вуаля: мы переключились на IndexBitmapScan c Index Cond: (domain_id = 0).
Данный Index Cond абсолютно неселективный, у нас единственное значение в данном поле.
В чём причина такого поведения?
Для начала проверим проводились ли autovacuum и autoanalyse для данной таблички.
select now(), last_autovacuum , last_autoanalyze, n_dead_tup ,n_live_tup , schemaname, relname from pg_stat_user_tables where relname='test_idxscan'; now |last_autovacuum |last_autoanalyze |n_dead_tup|n_live_tup|schemaname|relname | -----------------------------+-----------------------------+-----------------------------+----------+----------+----------+------------+ 2026-02-04 14:42:46.041 +0300|2026-02-04 14:42:17.764 +0300|2026-02-04 14:42:17.824 +0300| 0| 10000|public |test_idxscan|
Видно, что autovacuum и autoanalyze успели отработать. Dead tuples вроде бы были "подчищены", но если посмотреть на реальный размер:
select relpages, reltuples, relname from pg_class where relname like '%test_idxscan%'; relpages|reltuples|relname | --------+---------+---------------+ 17160| 10000.0|test_idxscan | 1913| 10000.0|pk_test_idxscan|
Видно, что наша табличка test_idxscan стала сильно разреженной (количество страниц - 17160 у нас даже больше чем кол-во записей - 10000).
Почему вакуум так "не дорабатывает"? Возможно, это предмет для следующей публикации.
В итоге, Postgres оценивает стоимость SeqScan выше: 17310.00 vs 10194.81.
set enable_bitmapscan = false; set enable_indexscan = false; explain analyse select * from test_idxscan where domain_id=0 and p_id=2; QUERY PLAN | ---------------------------------------------------------------------------------------------------------------+ Seq Scan on test_idxscan (cost=0.00..17310.00 rows=100 width=61) (actual time=15.559..16.768 rows=100 loops=1)| Filter: ((domain_id = 0) AND (p_id = 2)) | Rows Removed by Filter: 9900 | Planning Time: 0.089 ms | Execution Time: 16.812 ms |
И, в общем-то, прав.
В заключении хочу сказать, что«интуиция» наработанная в чтении планов выполнения на других базах данных, может давать сбой при наличии специфических особенностей Postgres.
P.S. Для решения проблем данного конкретного SELECT'a, естественно, можно построить индекс по полю p_id, (с соответствующей ценой для UPDATE, INSERT, DELETE-операций), но данный пост совсем не об этом элементарном действии.
