Почему простой COUNT на большой таблице такой дорогой
Вы добавляете на страницу счётчик «всего заказов: 2 384 917» или пагинацию с общим числом страниц, пишете SELECT COUNT(*) FROM orders, и на таблице в пару десятков миллионов строк запрос вместо ожидаемых миллисекунд выполняется секунды, а то и десятки секунд. Индекс есть, диск не перегружен, а простой подсчёт строк почему-то стоит как полное сканирование таблицы. Это не баг и не кривые руки — так устроена архитектура MVCC, на которой держится PostgreSQL и большинство современных СУБД, и разобраться в причине полезнее, чем в очередной раз чинить симптом.
Содержание
Почему COUNT нельзя просто «прочитать из метаданных»
Первая интуиция — раз база хранит статистику о таблицах, пусть просто отдаст число строк оттуда, без сканирования. Отчасти так и есть: в системном каталоге PostgreSQL (pg_class.reltuples) действительно лежит приблизительное количество строк, и ANALYZE его периодически обновляет. Но это оценка, полученная выборочным сэмплированием при последнем сборе статистики, а не точное число строк на текущий момент — она может расходиться с реальностью на проценты, а после массовой загрузки или удаления — и в разы, пока не пройдёт очередной ANALYZE или автовакуум.
Точный COUNT(*) — это агрегатная функция, которая по определению обязана посчитать именно те строки, которые видны именно этой транзакции именно сейчас. И вот тут в игру вступает MVCC (Multi-Version Concurrency Control) — механизм, на котором в PostgreSQL держится параллельная работа с данными без блокировок чтения на запись (подробнее — в статье про MVCC и то, почему две транзакции видят разные версии одной строки).
Суть проблемы в одной фразе: в MVCC-СУБД не существует единого готового числа «сколько строк в таблице», потому что у разных транзакций, работающих параллельно, разное представление о том, что вообще является таблицей в данный момент.
Что значит «разные транзакции видят разное»
Представьте таблицу orders в 20 миллионов строк, к которой параллельно обращаются:
- транзакция A, открытая пять минут назад — она не должна видеть ничего, что закоммитили после её старта (если уровень изоляции REPEATABLE READ или выше);
- транзакция B, которая прямо сейчас удаляет партию из миллиона старых заказов — эти строки физически всё ещё лежат в файле таблицы, помечены как удалённые для будущих транзакций, но пока не вычищены;
- транзакция C, которая только что закоммитила вставку 50 тысяч новых заказов — они уже видны новым транзакциям, но не видны той, что открылась раньше вставки;
- автовакуум, который в фоне постепенно помечает страницы с мёртвыми строками как переиспользуемые.
Для каждой из этих транзакций «количество строк в таблице» — своё собственное число, и оно не записано нигде готовым. Физически строка в PostgreSQL хранит служебные поля xmin (номер транзакции, создавшей версию строки) и xmax (номер транзакции, которая её удалила или обновила, если это уже произошло). Чтобы понять, видна ли конкретная версия строки текущей транзакции, нужно сравнить эти номера со снапшотом видимости этой транзакции — какие транзакции на момент её старта уже закоммичены, какие ещё активны, какие отменены.
Это сравнение — не одна проверка на всю таблицу, а проверка для каждой отдельной версии строки. Нет предвычисленного индекса или счётчика, который отвечал бы «эта строка видна транзакции с таким-то снапшотом» — видимость каждой строки определяется в момент чтения, сопоставлением её xmin/xmax с картой видимости транзакции.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это превращается в сканирование, близкое к полному
Раз видимость каждой строки нужно проверить индивидуально, база вынуждена физически пройтись по данным и применить проверку видимости к каждой найденной версии строки. Даже если у вас есть B-tree индекс на id, обычный индекс в PostgreSQL по умолчанию не хранит информацию о видимости — он указывает только на позицию строки в куче (heap), а решение «видна или нет» принимается по данным самой строки в куче. Из-за этого чистое сканирование по индексу для точного COUNT(*) всё равно требует обращения к куче почти на каждую строку — это называется heap fetch, и именно оно съедает время.
Единственное исключение, которое частично спасает ситуацию, — Index-Only Scan с Visibility Map. PostgreSQL поддерживает карту видимости (visibility map) — по одному биту на страницу кучи, отмечающему, что все строки на странице видны всем активным транзакциям (то есть на странице нет незакоммиченных изменений и нет строк, требующих проверки). Если вся нужная страница отмечена как «полностью видимая» и запрошенные колонки целиком есть в индексе, планировщик может пропустить обращение к куче для этой страницы. Но:
- карта видимости актуализируется вакуумом, а не в реальном времени — на таблице с активной записью значительная часть страниц может быть не отмечена;
- даже Index-Only Scan всё равно обязан пройти все записи индекса, чтобы их посчитать — экономится не проход по данным, а только избыточное обращение к куче;
- как только на странице есть хоть одна недавно изменённая строка, вся страница снова требует проверки по куче.
Поэтому для таблицы с активной записью (а не архивной, только-на-чтение) COUNT(*) практически всегда стоит близко к полному проходу по таблице или по индексу — линейно от числа строк, а не от «объёма ответа», как кажется интуитивно для агрегата, возвращающего одно число.
Приблизительная оценка вместо точного счёта
Если точное число не обязательно — а для UI-счётчика «у нас более 2 млн товаров» оно почти никогда не обязательно — самый дешёвый путь обойти проблему целиком: не считать, а спросить у планировщика его оценку.
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
Это тот же reltuples, которым пользуется сам планировщик запросов при построении плана (см. статью о том, как планировщик выбирает план запроса). Запрос выполняется мгновенно — это чтение одной строки из системного каталога, без обращения к самой таблице. Цена — та же самая, что и у любой оценки на основе статистики: число может быть устаревшим, если давно не запускался ANALYZE, и на таблицах с высокой скоростью изменений расхождение с реальностью может быть заметным (см. почему база тормозит сразу после загрузки данных из-за неактуальной статистики).
Более точная, но всё ещё дешёвая альтернатива — попросить у планировщика оценку конкретно для вашего запроса, а не для всей таблицы:
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
В строке Seq Scan on orders ... (cost=... rows=NNNNN ...) число rows — это именно та оценка, которую планировщик использует для построения плана, и её точность зависит от актуальности статистики и качества гистограмм по колонке status. Она не идентична COUNT(*), но обычно достаточно близка для UI, где пользователю важен порядок величины, а не последняя единица.
Если нужна регулярно обновляемая метрика (например, для дашборда), разумный компромисс — материализовать точный подсчёт на расписании:
CREATE MATERIALIZED VIEW orders_count_cache AS
SELECT count(*) AS total FROM orders;
-- обновление по расписанию (cron, pg_cron, планировщик приложения)
REFRESH MATERIALIZED VIEW orders_count_cache;
Так дорогой проход по таблице выполняется не на каждый запрос пользователя, а по расписанию — раз в несколько минут или часов, в зависимости от того, насколько критична свежесть числа.
Когда точный COUNT действительно нужен и как его удешевить
Бывают ситуации, где приблизительная оценка не подходит — финансовая сверка, выгрузка для бухгалтерии, проверка целостности данных перед миграцией. Здесь несколько практических приёмов, которые не меняют алгоритмическую суть, но снижают фактическую стоимость:
- Считайте по индексу с узким условием, а не по всей таблице.
SELECT COUNT(*) FROM orders WHERE created_at >= '2026-08-01'с индексом поcreated_atсузит объём проверяемых строк до нужного диапазона — это по-прежнему проход, но по подмножеству, а не по всей таблице. - Держите таблицу вакуумированной. Регулярный
VACUUM(обычный, неFULL) актуализирует карту видимости и помогает Index-Only Scan реально работать, а не деградировать до полного обращения к куче на каждой странице. - Считайте на реплике, если точность нужна не мгновенно. Тяжёлый
COUNT(*)для отчёта разумно направить на read-реплику, чтобы не создавать дополнительную нагрузку на продовый инстанс, обслуживающий запись. - Не считайте то, что можно инкрементально поддерживать. Если число нужно постоянно и в реальном времени (счётчик товаров в корзине, лайков, просмотров), почти всегда дешевле держать отдельный счётчик, обновляемый триггером или в приложении при каждой вставке/удалении, чем каждый раз пересчитывать агрегатом по таблице.
- Для очень больших и активно изменяемых таблиц рассмотрите партиционирование. Если данные естественно делятся по времени или ключу (например, заказы по месяцам), точный подсчёт по нужному диапазону может свестись к сканированию одной или нескольких партиций вместо всей таблицы.
Ни один из этих приёмов не отменяет того, что точный COUNT(*) по своей природе требует прохода по данным — но каждый снижает объём этого прохода или переносит его стоимость туда, где она не мешает пользователю ждать ответа.
Ресурсы, которые COUNT забирает у сервера
Дорогой COUNT(*) — это не абстрактная задержка «где-то там», а конкретная нагрузка на конкретные ресурсы вашего сервера:
| Ресурс | Что происходит |
|---|---|
| CPU | Каждая строка проверяется на видимость — сравнение xmin/xmax со снапшотом транзакции, это процессорная работа на миллионы итераций |
| Диск / I/O | Страницы таблицы, не попавшие в кэш (shared buffers), читаются с диска; на таблице, не помещающейся в память, это доминирующая часть времени |
| Память | Активно читаемые страницы вытесняют из shared buffers другие, более горячие данные — тяжёлый COUNT может ухудшить производительность соседних запросов |
| Параллельные транзакции | Пока идёт длинный COUNT, снапшот транзакции удерживается, что может задерживать очистку мёртвых версий строк вакуумом |
На виртуальной машине с медленным или сильно разделяемым диском и небольшим объёмом RAM (когда рабочая таблица не помещается в shared buffers) эффект от такого запроса ощущается сильнее — каждое обращение к куче, не найденное в кэше, превращается в физическое чтение с диска. На сервере с NVMe-накопителем и достаточным запасом памяти под shared buffers та же самая таблица считается заметно быстрее просто потому, что меньше данных приходится поднимать с диска. Если вы регулярно упираетесь в такие запросы на проекте с растущей базой, часто разумнее не бороться с архитектурой MVCC точечными хаками, а увеличить ресурсы — арендовать сервер с NVMe-диском и памятью, достаточной, чтобы рабочий набор таблиц помещался в кэш.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему COUNT(*) с WHERE по индексированной колонке всё равно медленный?
Индекс сужает набор строк-кандидатов, но для каждой найденной строки всё равно нужна проверка видимости по куче (если Index-Only Scan недоступен из-за неактуальной карты видимости), а сама эта проверка — не операция «из индекса», а обращение к данным строки.
MySQL/InnoDB устроен так же?
Да, у InnoDB тоже MVCC на основе undo-логов и версий строк, и точный COUNT(*) без индекса, покрывающего весь подсчёт, там тоже требует прохода по строкам — это не специфика PostgreSQL, а следствие самой модели многоверсионности, которую используют почти все СУБД с неблокирующим чтением.
Если таблица только для чтения (архив, аналитика без записи), COUNT будет быстрее?
Да, заметно — карта видимости на такой таблице после вакуума стабильна, почти все страницы отмечены «полностью видимыми», и Index-Only Scan может обойтись без обращения к куче почти для всех строк.
Можно ли ускорить точный COUNT параллельностью?
Начиная с PostgreSQL 9.6 планировщик умеет строить параллельный план для Seq Scan/Aggregate, распределяя чтение таблицы между несколькими воркерами (параметры max_parallel_workers_per_gather и связанные). Это снижает время выполнения на многоядерном сервере, но не снижает суммарный объём работы — вы просто делаете тот же проход по данным несколькими процессами одновременно, и выигрыш ограничен числом ядер и скоростью диска.
reltuples всегда меньше реального числа строк?
Нет, может быть и больше, и меньше — это снапшот на момент последнего ANALYZE или автовакуума, и расхождение зависит от того, что произошло с таблицей после: массовая вставка задерёт реальное число выше reltuples, массовое удаление — ниже.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →