Источник фотографии Все умеют считать, но не все умеют считать быстро. В этой статье мы подробно рассмотрим методы оптимизации count в PostgreSQL. Существуют приемы, которые могут позволить ускорить подсчет количества строк на порядки. Если подходить к вопросу со всей серьезностью, необходимо выделить несколько вариантов count, у каждого из которых есть собственные методы. С чем нужно будет определиться: Мы проанализируем решения для каждой конкретной ситуации, а также сравним их скорость и потребление ресурсов. Разобрав ситуацию с централизованной БД, мы воспользуемся Citus, чтобы продемонстрировать параллельное выполнение count в распределенной базе данных. Содержание Для тестов мы будем использовать базу данных под названием count, для которой инициализирован pgbench: Создадим тестовую таблицу: Итак, начнем с начала: рассмотрим получение точного количества строк всей таблицы или ее части с дубликатами — старый добрый count(*). Время выполнения этой команды даст нам базис для оценки скорости работы других методов подсчета количества строк. Pgbench — удобный инструмент для многократного выполнения запроса и сбора статистики о производительности. Замечание по поводу count(1) vs count(*). Можно подумать, что count(1) быстрее, поскольку count(*) должен обработать значения всех колонок текущей строки. На самом деле верно обратное. В отличие от конструкции SELECT *, символ «звездочка» в count(*) ничего не означает. PostgreSQL обрабатывает выражение count(*) как особый случай count без аргументов. (Правильно было бы записывать это выражение в виде count()). С другой стороны, count(1) принимает один аргумент, и PostgreSQL должна для каждой строки убедиться, что этот аргумент (1) и вправду не является NULL. Предыдущий тест с count(1) выдал следующие результаты: В любом случае и count(1), и count(*) по определению медленные. Чтобы обеспечить непротиворечивость одновременно выполняющихся транзакций, PostgreSQL использует управление параллельным доступом с помощью мультиверсионности (MVCC, Multiversion Concurrency Control). Это значит, что каждая транзакция может видеть разные строки и даже разное количество строк таблицы. Поэтому не существует единственно правильного значения количества строк, которое СУБД могла бы поместить в кеш, и системе придется просканировать все строки, чтобы подсчитать, какие из них видны отдельной транзакции. Время выполнения точного (exact) count линейно растет вслед за увеличением размера таблицы. На scan приходится 88% стоимости запроса. Если удвоить размер таблицы, то и время выполнения запроса увеличится примерно в два раза при пропорциональном росте стоимости scan и aggregate. Как это ускорить? Есть два варианта: решить, что нам достаточно оценочного значения, либо самостоятельно помещать в кеш количество строк. Во втором случае нам придется отдельно хранить значения для каждой таблицы и каждого выражения WHERE, для которых мы хотим быстро выполнять count. Давайте рассмотрим пример ручного кеширования значения count(*) для таблицы items целиком. Следующее основанное на триггерах решение является адаптацией метода, предложенного A. Elein Mustain. Механизм MVCC PostgreSQL будет поддерживать согласованность между items и таблицей, содержащей значения количества строк. Скорость чтения и обновления кешированных значений в этом случае не зависит от размера таблицы, и получение значения количества строк выполняется очень быстро. Однако эта техника увеличивает накладные расходы на операции вставки и удаления. Без триггера следующая команда выполняется 4,7 секунды, когда как вставка с триггером в пятьдесят раз медленнее: Подход, при котором мы кешируем количество строк таблицы, замедляет операции вставки. Если вместо точного числа мы готовы довольствоваться оценочным значением, то есть возможность получить быстрые операции чтения без ухудшения времени работы вставки. Для этого мы можем использовать собираемые PostgreSQL служебные данные. Их источниками являются stats collector и autovacuum daemon. Варианты получения оценочных значений: Но есть и более надежный источник, данные в котором обновляются чаще. Andrew Gierth (RhodiumToad) советует: Логика здесь следующая: по мере увеличения объема данных в таблице среднее количество строк, умещающихся в физическую страницу, в общем случае будет меняться не так сильно, как их общее количество. Чтобы получить более точную оценку текущего количества строк, мы можем умножить среднее количество строк на актуальную информацию о текущем количестве занимаемых таблицей страниц. В предыдущем разделе мы рассмотрели получение оценочного количества строк для целой таблицы, а можно ли сделать то же самое, но только для подходящих под условие WHERE строк? Michael Fuhr придумал интересный способ: запустить EXPLAIN для запроса и проанализировать полученный результат. Эту функцию можно использовать следующим образом: Точность этого метода зависит от планировщика, который использует различные способы для оценки избирательности выражения WHERE, и от того, откуда может быть получено количество возвращаемых запросом строк. Count с дубликатами может выполняться медленно, но count distinct намного хуже. С ограниченной рабочей памятью и без индексов PostgreSQL не способна выполнять оптимизацию эффективно. В конфигурации по умолчанию СУБД накладывает жесткий лимит на каждый параллельный запрос (work_mem). На компьютере, который я использую для разработки, это значение по умолчанию было установлено в 4 мегабайта. Давайте оценим производительность работы с миллионом строк на заводских настройках work_mem. Запуск EXPLAIN показывает, что большая часть времени выполнения запроса ушла на агрегирование. Также отметим, что подсчет количества строк по колонке типа text ведется намного медленнее, чем по целочисленной: 
 Что же происходит внутри "aggregate"? Описание этой процедуры в выводе EXPLAIN непрозрачно. Разобраться в ситуации помогает анализ похожего запроса. Заменим count distinct на select distinct. 
 В условиях недостаточного значения work_mem и отсутствия внешних структур данных (например, индексов) PostgreSQL выполняет merge-sort таблицы между памятью и диском, а затем пробегает по результату, удаляя дубликаты, то есть действуя во многом аналогично классической Unix-комбинации sort | uniq. Большую часть времени выполнения запроса занимает сортировка, особенно когда мы используем не целочисленную колонку n, а строковую s. Удаление дубликатов (unique filter) в обоих случаях выполняется примерно с одинаковой скоростью. Для подсчета количества уникальных значений Thomas Vondra создал специализированный метод агрегирования, работающий с типами ограниченной длины (не должна превышать 64 бита). Этот способ даже без увеличения рабочей памяти или создания индексов оказывается быстрее используемого по умолчанию метода, основанного на сортировке. Для установки выполните следующие шаги: В этой статье Томас объясняет, как работает агрегирование. Коротко скажу лишь, что его метод создает в памяти отсортированный массив уникальных элементов, уплотняя его в процессе. Это работает быстрее стандартного count distinct, который на наших тестовых данных выполняется в среднем 742 мс. Следует учитывать, что написанные на C расширения, такие как count_distinct, не ограничены параметром work_mem, поэтому созданный в процессе массив может занять больше памяти в расчете на соединение, чем вы изначально планировали. Если все пересчитываемые столбцы умещаются в work_mem, для получения уникальных значений PostgreSQL применит хеш-таблицу: 
 Это самый быстрый из рассмотренных нами методов. Он выполняется в среднем 372 мс для n и 23 секунды для s. Запросы select distinct n и select count(distinct n) будут работать примерно одинаковое количество времени при условии, что агрегирование count distinct также применит HashAggregate. Будьте осторожны: установка высокого лимита рабочей памяти может привести к неприятным последствиям, так как work_mem относится к каждому параллельному запросу. Кроме того, мы можем придумать и кое-что получше. Эта возможность появилась в PostgreSQL 9.2. Если индекс содержит все необходимые для запроса данные, система может использовать только его, не трогая саму таблицу (“the heap”). Тип индекса должен поддерживать index-only scan (например, btree). Индексы GiST и SP-GiST поддерживают index-only scan лишь для некоторых классов операторов. Создадим по btree-индексу для колонок n и s: Для выбора уникальных значений из этих колонок теперь используется другая стратегия: 
 Но тут мы наталкиваемся на странную проблему: SELECT COUNT(DISTINCT n) FROM items не станет использовать индекс, несмотря на то что SELECT DISTINCT n делает это по умолчанию. Следуя советам в блогах («Трюк, который ускорит ваш postgres в 50x раз!»), можно дать подсказку планировщику, переписав count distinct в виде count по подзапросу: Симметричный (in-order) обход бинарного дерева выполняется быстро. Этот запрос занимает в среднем 177 мс (270 мс для колонки s). Замечание. Если значение work_mem достаточно для размещения таблицы целиком, PostgreSQL даже при наличии индекса выберет HashAggregate. Получается парадокс: выделение системе большего количества памяти может привести к выбору худшего плана запроса. Есть возможность форсировать выбор index-only scan, установив SET enable_hashagg=false;, только не забудьте вернуть его обратно в true, чтобы не испортить планы других запросов. Рассмотренные ранее методы зависят от индексов, хеш-таблиц, отсортированных массивов в памяти либо обращаются к статистическим таблицам централизованной базы данных. Когда данных становится по-настоящему много и/или они разделяются между несколькими узлами распределенной базы данных, эти методы перестают нас устраивать. В этом случае на помощь приходят вероятностные структуры данных, которые способны давать быстрые приблизительные оценки и хорошо распараллеливаются. Давайте испытаем одну из таких структур на count distinct. Рассмотрим механизм оценки количества элементов (cardinality estimator) под названием HyperLogLog (HLL). Для представления набора элементов он использует небольшое количество памяти. Операция объединения в этом механизме работает без потерь, что позволяет объединять произвольные значения HLL, не теряя точности оценки количества. HLL использует свойства «хороших» хеш-функций, в частности дистанцию между хешированными значениями. Функция, равномерно распределяющая значения, стремится разносить их на максимально возможное расстояние. По мере добавления новых хешей свободного места становится меньше, и элементы начинают прижиматься друг к другу. Анализируя наименьшие расстояния между хешированными значениями, алгоритм может оценить наиболее вероятное количество исходных элементов. Давайте измерим скорость. Сначала установим расширение для PostgreSQL. HLL выполняет быстрое агрегирование данных при последовательном сканировании таблиц (sequential scan): Средняя скорость HLL при выполнении count distinct составила 239 мс по колонке n и 284 мс по s. Получилось немного медленнее, чем index-only scan на одном миллионе записей. Настоящая же сила HLL проявляется за счет ее ассоциативных и коммутативных операций объединения, которые проходят без потерь. Это значит, что они могут выполняться параллельно и быть объединены для вычисления итогового результата. Приложения, собирающие аналитику в реальном времени, такие как, например, Google Analytics, активно используют count, и эта операция хорошо распараллеливается. В этом разделе мы измерим производительность нескольких методов подсчета количества строк на базе небольшого кластера Citus, развернутого в Citus Cloud. Идея в том, чтобы развернуть узлы распределенной базы данных на нескольких машинах. На узлах будет одинаковая схема, и каждый из них будет содержать часть общего набора данных (shard). Подсчет количества строк будет выполняться параллельно, т. е. одновременно на разных машинах. Для теста мы сделаем лишь небольшой кластер, поскольку наша цель — оценить сравнительную производительность, а не получить максимальную скорость. В Citus Cloud я сделал кластер из восьми машин, выбрав для каждой из них самую слабую из возможных конфигураций. При желании воспроизвести этот пример самостоятельно зарегистрируйтесь вот здесь. После создания кластера я подключаюсь к координирующей ноде для выполнения SQL-запросов. Первым делом создадим таблицу. В данный момент таблица существует только в БД координатора. Нам нужно разбить таблицу и разместить ее части на рабочих узлах. Citus приписывает каждую строку к определенному сегменту (shard) путем обработки значений в выбранной нами колонке для распределения (distribution column). В примере ниже мы ставим ему задачу распределить будущие строки в таблице items, используя хеши значений в колонке n для определения принадлежности к тому или иному сегменту. С помощью координирующей ноды мы загрузим случайные данные в сегменты БД. (Citus также поддерживает MX, режим "без мастера" (masterless mode), который используется для быстрой загрузки данных, но сейчас он нас не интересует). После получения URL базы данных координатора кластера выполните следующий код на компьютере с быстрым сетевым соединением. (Все генерируемые данные будут передаваться по сети с этой машины, поэтому нужна хорошая скорость.) В примере с централизованной базой данных мы использовали миллион строк. На этот раз давайте возьмем сто миллионов. Обычный count (без дубликатов) проблем не доставляет. Координатор выполняет запрос на всех узлах, а затем суммирует результаты. Вывод EXPLAIN показывает план, выбранный на одном из рабочих узлов (“Distributed Query”), и план, выбранный на координаторе (“Master Query”). Для справки: на нашем кластере этот запрос выполняется 1,2 секунды. Distinct count представляет более серьезную проблему при работе с распределенной БД. Трудность в подсчете уникальных значений колонки в распределенной базе данных заключается в том, что дубликаты надо искать на разных узлах. Однако это проблема, если считать значения в колонке для распределения (distribution column). Строки с одинаковыми значениями в этой колонке попадут в один сегмент, что позволит избежать межсегментного дублирования. Citus знает, что для подсчета уникальных значений в колонке распределения (distribution column) нужно выполнить запрос count distinct на каждом узле и сложить результаты. Наш кластер выполняет эту задачу за 3,4 секунды. Найти количество уникальных значений в обычной колонке (non-distribution) оказывается более сложной задачей. Логически можно выделить две возможности: Первый вариант ничем не лучше подсчета в централизованной БД. Фактически это в точности то же самое плюс значительные накладные расходы на передачу данных по сети.  Второй вариант называется «перераспределение» (repartitioning). Идея заключается в том, чтобы, используя колонку для распределения, создать на рабочих узлах временные таблицы. Рабочие узлы посылают друг другу строки для записи во временные таблицы, выполняют запрос и удаляют эти таблицы. У каждой СУБД своя логика реализации перераспределения. Детали этой системы в Citus выходят за рамки данной статьи, поэтому мы не будем в них углубляться. Механизмы оценки мощности, такие как HLL, незаменимы в распределенных базах данных. Они позволяют выполнять подсчет уникальных значений даже по обычным колонкам (non-distribution), создавая при этом лишь небольшую дополнительную нагрузку на сеть. Тип данных HLL имеет небольшой размер в байтах и может быстро пересылаться от рабочих узлов координатору. Поскольку операция объединения в HLL выполняется без потерь, нет необходимости волноваться, что количество сегментов базы может повлиять на точность. В Citus нет необходимости явно вызывать функции postgresql-hll. Просто установите citus.count_distinct_error_rate в ненулевое значение, и Citus перепишет планы запросов для count distinct с использованием HLL. Например: Запрос отработал быстро: 3,2 секунды для колонки n и 3,8 секунды для строковой s. И это на 100 миллионах записей и обычных (non-distribution) колонках! HLL — отличный инструмент для горизонтального масштабирования процедуры подсчета уникальных значений в распределенной базе данных. Немного проигрывая index-only scan в централизованной базе данных на таблице с миллионом строк, HyperLogLog (HLL) вырывается вперед на больших объемах данных (> 100 Гб). Этот метод также незаменим в распределенных БД, позволяя получить оценку количества уникальных элементов (distinct count) в реальном времени на огромных объемах данных. CEO в Southbridge