Эта история началась с исследования проблем производительности на высоконагруженной базе данных 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-операций), но данный пост совсем не об этом элементарном действии.