Привет, Хабр!
Тема кажется на первый взгляд банальной: зачем COUNT(col), если есть COUNT(*)? Но за ней скрывается куча нюансов: MVCC‑проверки, visibility map, index‑only scan, параллелизм… И на разных версиях PostgreSQL COUNT(*) может вести себя по‑разному. Разберём всё досконально.
Семантика по стандарту SQL
COUNT(*) считает все строки результирующего набора, независимо от содержимого колонок.
COUNT(expression) считает только те строки, где expression IS NOT NULL.
SELECT COUNT(*) FROM users; -- считает все строки SELECT COUNT(user_id) FROM users; -- считает только не-NULL значения user_id
MVCC и visibility map
MVCC‑модель и поля xmin/xmax
PostgreSQL хранит все версии строк в куче. Каждая версия содержит:
xmin: XID транзакции, которая создала tuple;xmax: XID транзакции, которая «удалила» или обновила его (логически).
При любом чтении движок проверяет, видна ли эта версия:
Активна ли транзакция с
xmin?Завершена ли транзакция с
xmax?Находится ли она в пределах «видимости» текущей транзакции?
Чтобы ответить «жива ли строка», PostgreSQL читает заголовок tuple и может обращаться к каталогу транзакций.
Visibility map
Проверка видимости для каждой строки — дорого. Чтобы избежать чтения heap при index‑only scan, есть специальная структура:
Visibility map: битовая карта, где каждый бит соответствует целой странице (8 kB) таблицы и показывает, что все строки на странице видимы ВСЕМ транзакциям.
Как работает:
После
VACUUMстраница помечается all‑visible, если на ней нет «грязных» версий.Любые обновления/удаления/вставки по этой таблице сбрасывают бит у соответствующей страницы.
Дальнейший
VACUUMможет вернуть бит.
Если бит=1, индексный скан доверяет visibility map и не ходит в heap.
Классический сценарий: COUNT(*) → Seq Scan
Нет «привязки» к какой‑либо колонке, планировщик не может выбрать индекс. Даже при наличии индекса по id нет данных о xmin/xmax, придётся проверять видимость через heap → Seq Scan.
-- Сценарий A: базовый full table scan для COUNT(*) EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM t_events;
Seq Scan on t_events (cost=0.00..14923.00 rows=1000000 width=0) (actual time=0.098..213.402 rows=1 loops=1) Buffers: shared hit=8000
Время: ~213 ms. Страниц прочитано: 8000 (все страницы таблицы).
Когда COUNT(col) летает: index‑only scan
Если есть индекс по колонке col и:
col NOT NULL;Индекс покрывает именно эту колонку;
Все страницы помечены all‑visible в visibility map;
то PostgreSQL может выполнить Index‑only Scan:
CREATE INDEX idx_event_time ON t_events (event_time); VACUUM ANALYZE t_events; EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(event_time) FROM t_events;
Index Only Scan using idx_event_time on t_events Index Cond: (event_time IS NOT NULL) Heap Fetches: 0 Buffers: shared hit=300 (actual time=0.050..4.572 rows=1 loops=1)
Время: ~4.5 ms. Страниц (индекс): 300. Heap Fetches: 0
Выигрыш: ≈50× по сравнению с full table scan.
Counter‑intuitive: COUNT(*) через индекс
Parallel Index‑only Scan (PostgreSQL 12+)
Начиная с PostgreSQL 12 движок умеет:
Планировать Parallel Index‑only Scan для
COUNT(*),Если есть любой индекс по NOT NULL полю (даже без INCLUDE),
Таблица давно не менялась (visibility map чиста),
Параллелизм включён (
max_parallel_workers_per_gather > 0).
Пример из комментариев (таблица tmp_test):
CREATE TABLE tmp_test ( num integer NULL, val varchar ); -- Наполняем 1 000 000 строк: INSERT INTO tmp_test (num, val) SELECT CASE WHEN i%2=0 THEN NULL ELSE 12345 END, repeat(G.i::text, 256) FROM generate_series(1, 1000000) G(i); -- Создаём индекс по nullable-полю num: CREATE INDEX tmp_test_some_idx ON tmp_test(num); -- Принудительный VACUUM для visibility map VACUUM tmp_test; -- Считаем строки EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM tmp_test;
Finalize Aggregate (cost=10966.90..10966.91 rows=1 width=8) (actual time=29.826..32.236 rows=1 loops=1) -> Gather Workers Launched: 3 -> Partial Aggregate -> Parallel Index Only Scan using tmp_test_some_idx Heap Fetches: 0 (actual time=0.127..17.083 rows=250000 loops=4)
Время: ~32 ms (вместо ~206 ms без параллелизма и index‑only).Heap Fetches: 0 — индекс покрывает всё, visibility map чист
Вывод: для
COUNT(*)достаточно
индекса по NOT NULL-полю,
чистой visibility map,
параллелизма — и движок пойдёт по индексу.
Все четыре варианта
Запрос | План | Время | Buffers / Heap Fetches |
|---|---|---|---|
| Seq Scan | ~213 ms | shared hit=8000 |
| Index‑only Scan | ~4.5 ms | shared hit=300 / 0 fetches |
| Seq Scan (без индекса) | ~230 ms | shared hit=8000 |
| Index‑only Scan | ~~6 ms | shared hit≈300 / 0 fetches |
| То же, что | ~213 ms | shared hit=8000 |
| Parallel Index‑only Scan | ~~30 ms | shared hit≈300 / 0 fetches |
COUNT(DISTINCT) — совсем другое дело
COUNT(DISTINCT col) никак не оптимизируется индексом: движку придётся:
Собрать все значения
col(индексный или обычный скан);Отсортировать и сгруппировать или построить хэш‑таблицу в памяти;
При большом объёме — spill в temp‑файлы, merge‑сортировка на диске.
EXPLAIN ANALYZE SELECT COUNT(DISTINCT user_id) FROM t_events;
План обычно:
Aggregate -> Index Only Scan using idx_user_id on t_events
Но в Aggregate лежит сам DISTINCT, и он до или после сбора данных держит хэш/сортировку — нагрузка на CPU/RAM/Disk.
Альтернатива:
approx_count_distinct(col)из модуляhyperloglog,Другие подобные агрегаты (PostGIS‑Tiger‑Geocoder тоже даёт агрегаты),
Благодаря этому вы получаете приблизительный, но очень быстрый результат.
Итоговые рекомендации
Для OLTP‑сценариев выбирайте COUNT(col) по полю с NOT NULL вместе с соответствующим индексом и свежим VACUUM. Если всё‑таки нужен COUNT(*), убедитесь, что у вас есть индекс по любому NOT NULL полю и включён параллелизм, чтобы движок мог пойти в Index‑only Scan.
В OLAP‑нагрузках классический COUNT(*) через Seq Scan задействует все воркеры равномерно, но лучше фильтровать данные по дате date‐partitioning — так вы уменьшите объём сканируемой кучи. А для уникальных подсчётов вместо COUNT(DISTINCT) переходите на приближённые агрегаты типа HyperLogLog approx_count_distinct(), чтобы не вываливаться в temp‑файлы и не бить по памяти.
Не забывайте про автоматизацию: настройте autovacuum параметры vacuum_scale_factor, vacuum_freeze_table_age и регулярно мониторьте состояние visibility map в pg_visibility — это ключ к стабильной производительности ваших index‐only планов.
Если вы стремитесь глубже разобраться в оптимизации SQL-запросов и работе с PostgreSQL, пройдите вступительное тестирование курса «SQL для разработчиков и аналитиков» — он покажет, насколько ваши текущие знания соответствуют программе курса.
Также приглашаем ознакомиться с календарём открытых уроков — регистрируйтесь и участвуйте, разбирайте реальные кейсы и общайтесь с экспертами.
