Привет, Хабр!
Тема кажется на первый взгляд банальной: зачем 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-запросов и хочется выжать из базы максимум — приглашаем на открытый урок 22 апреля, где разберём, как автоматизировать рутину с помощью хранимых процедур в PostgreSQL и MS SQL Server. На практике покажем, как оформлять бизнес-логику в процедуры, управлять параметрами, повышать читаемость и безопасность кода — всё, что экономит нервы и процессорное время.
Записаться на урок можно на странице курса «SQL для разработчиков и аналитиков».
ссылка на оригинал статьи https://habr.com/ru/articles/901922/
Добавить комментарий