Протестировал 5 популярных в интернете методов оптимизации запросов в PostgreSQL, и вот что из этого вышло
Статья будет полезна, если ты начинающий разработчик и столкнулся с долгим выполнением запросов в PostgreSQL. Сразу вывод: не всё то полезно для оптимизации запросов, что написано в интернете.
Я Леонид, разработчик Java в компании «Programming Store». Протестировал популярные советы из интернета по методам оптимизации и увеличения производительности БД. Расскажу, что из этого вышло.
Для примера я развернул СУБД PostgreSQL 14 в контейнере докера. Настройки оставил по умолчанию. Создал две таблицы: users и products. Заполнил их данными. Users имеет 1 000 000 записей, products – 3 010 000 записей. Ниже приведены схемы данных таблиц.
Таблица products:
Таблица users:
Тестирую метод №1: «добавить индексы»
Самый распространенный совет по увеличению производительности запроса, который я встречал, — это добавление индексов.
Индекс — это метод настройки производительности, позволяющий быстрее извлекать записи. При выполнении запроса к таблице, в которой нет нужных индексов, будет выполнено последовательное сканирование строк (Seq Scan).
Кейс №1
Для примера выполнил следующий запрос, который выводит на экран все записи, у которых имя «Арбуз»:
Запросив план запроса, мы видим, что выполнен Seq Scan. Время выполнения 500.771 ms:
Теперь давайте добавим индекс на поле name и выполним предыдущие действия:
Время выполнения: 16.882 ms.
Теперь план запроса нам показывает поиск по индексу. Время выполнения уменьшилось: было 500.771 ms, стало 16.882 ms.
Вывод по кейсу 1. Добавление индекса хорошо помогает ускорять запросы поиска в БД, но не нужно считать это панацеей от всех проблем. Если бездумно добавлять индексы на каждое поле, то мы рано или поздно столкнемся с обратной ситуацией при добавлении, обновлении или удалении записей, т.к. СУБД после операции записи обновляет эти индексы. И чем больше индексов, тем больше времени это занимает.
Кейс №2
Давайте выполним операцию добавления записи в таблицу, в которой еще нет избытка индексов.
Сначала удаляем ранее добавленный индекс:
После этого выполняем запрос на обновление записи:
Время выполнения: 0.065 ms.
Теперь добавим индексы:
Повторим операцию добавления записи:
На этот раз мы видим увеличение времени выполнения: было 0.065 ms, стало 0.394 ms.
Вывод по кейсу №2. Перед добавлением индекса нужно проанализировать необходимость в его добавлении.
Для поиска редко используемых индексов можно воспользоваться следующим запросом:
Так выглядит результат выполнения данного запроса. В нем три столбца:
- index — название индекса;
- table — название таблицы, для которого создан индекс;
- drop_statement — запрос для удаления данного индекса.
Можно изменить значение idx_scan в условии или добавить его в выводимые поля. Данное значение указывает количество использований данного индекса.
Тестирую метод №2: «выводить конкретные поля»
Следующий вариант уменьшения времени выполнения запроса — это уменьшение количества возвращаемых полей из БД.
При запросах к базе часто пренебрегают количеством выводимых полей, используя оператор «*» после SELECT. Если полей много, то из-за этого увеличивается время выполнения запроса.
Для примера выполним следующий запрос:
Вот его план запроса:
Время выполнения: 7837.342 ms.
А теперь выполним запрос конкретными полями:
И вот его план запроса:
Время выполнения: 2442.886 ms (а было 7837.342 ms).
Вывод: Данный пример наглядно показывает, что необходимо сокращать количество получаемых полей из БД.
Тестирую метод №3: «изменить тип данных»
Другая проблема, похожая на предыдущую — это неправильное использование типов данных для столбцов.
В изначально подготовленной таблице у меня были поля типа UUID. Поле типа UUID занимает 16 байт. Если для хранения uuid использовать другой тип, например, varchar(36), то будет занято больше места, соответственно и запрос будет выполняться дольше.
Для наглядности выполним следующий запрос:
Вот его план запроса:
Время выполнения: 146.715 ms.
Теперь изменим тип uuid полей на varchar(36):
Смотрим на план предыдущего запроса:
Время выполнения: 238.234 ms.
Таким образом, мы приходим к выводу, что необходимо правильно подбирать типы данных.
Тестирую метод №4: «увеличить память WORK_MEM»
Теперь перейдем к настройкам СУБД PostgreSQL. Для начала рассмотрим популярный вариант с увеличением значения для work_mem.
WORK_MEM задаёт объём памяти, который будет использоваться для внутренних операций сортировки и хеш-таблиц, прежде чем будут задействованы временные файлы на диске.
Увеличение данной памяти приведет к большей производительности запросов, где используются сортировки и хэш-таблицы.
Для примера оставим стандартное значение work_mem (4mb) и выполним следующий запрос:
План запроса:
Время выполнения 4194.659 ms.
Увеличим значение work_mem до 512mb и посмотрим на план предыдущего запроса:
Время выполнения 2550.668 ms.
В первом примере для выполнения группировки не хватило памяти и группировка выполнялась порционно. Это видно по количеству Batches. Во втором примере все влезло в память и Batches всего 1.
Вывод. Как видно из примера, увеличение памяти уменьшает время выполнения запроса. Но при выборе значения надо учитывать, что общий используемый объём памяти может превышать указанное значение, так как для каждой операции и сеанса используется указанный объем памяти.
Тестирую метод №5: «параллельное выполнение»
Помимо work_mem на просторах интернета предлагается использовать параллельное выполнение. Для этого используются воркеры.
max_parallel_workers_per_gather задаёт максимальное число рабочих процессов, которые могут запускаться одним узлом.
Параллельное выполнение может ускорить выполнение запросов. Давайте проверим это на следующем примере со значением max_parallel_workers_per_gather = 0:
План запроса:
Время выполнения: 466.685 ms.
Увеличим значение max_parallel_workers_per_gather = 2 и выполним план предыдущего запроса:
Время выполнения: 180.492 ms.
Как видим, время выполнения сильно уменьшилось. Распараллеливание помогло увеличить производительность. Но учтите, что параллельные запросы могут потреблять значительно больше ресурсов, чем непараллельные, так как каждый рабочий процесс является отдельным процессом. По итогу увеличение значения может негативно сказаться на производительности.
Также для примера можно взять запрос из предыдущего пункта и выполнить его со значением max_parallel_workers_per_gather = 0:
План запроса:
Время выполнения: 4421.002 ms.
А вот что будет, если увеличить значение max_parallel_workers_per_gather = 2 и посмотреть на план запроса:
Время выполнения: 6266.960 ms.
Тут наглядно видно ухудшение производительности. Хоть работа и стала распараллеленной, но это же породило больше действий. В каждой из параллелей выполнялась сортировка, а после — объединение данных. На все это тратится дополнительное время.
Выводы
К оптимизации запросов нужно подходить с умом, так как не все популярные решения увеличат производительность в вашем конкретном случае. Выше были приведены частные примеры в вакууме. Приведенные в примерах методы работают, но также есть моменты, когда стало только хуже.
Перед началом оптимизации запросов надо проанализировать ситуацию. Зачастую может быть потрачено в разы больше ресурсов на оптимизацию запроса, нежели на увеличение производительности другим путем, например, путем добавления мощности на сервере и настройки параметров СУБД. Четвертый и пятый пункт статьи это наглядно демонстрируют.
Возможны случаи, когда запрос, который как вам кажется, не оптимальный и долго выполняется – вызывается крайне редко и нет необходимости тратить на него уйму своих ресурсов для оптимизации. Или, например, при добавлении индексов другие запросы начнут медленнее отрабатывать.
Буду рад ответить на вопросы в комментариях :)
Следующую бы статью увидеть - какими инструментами пользоваться новичку для анализа запросов или "5 популярных инструмента из интернета..."
Комментарий недоступен
А вы когда об этом первый раз задумались?