Привет, Хабр!
Тема кажется на первый взгляд банальной: зачем 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_idMVCC и 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 для разработчиков и аналитиков» — он покажет, насколько ваши текущие знания соответствуют программе курса.
Также приглашаем ознакомиться с календарём открытых уроков — регистрируйтесь и участвуйте, разбирайте реальные кейсы и общайтесь с экспертами.
