Как стать автором
Обновить

О переупорядочении выражений в Postgres

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров308

Сегодня я хочу затронуть тему дополнительных ухищрений, которые могут позволить ускорить выполнение запроса. В данном случае речь пойдёт о перестановке условий в выражениях фильтрации, JOIN'ов, HAVING-клаузах и прочем. Идея заключается в том, что получив негативный результат в одном условии из цепочки выражений, объединенных оператором AND, равно как позитивный результат в одном из условий, объединённых оператором OR, можно не вычислять все последующие и сэкономить вычислительный ресурс. Что это даёт и как конкретно реализовать - об этом ниже.

Время от времени приходится наблюдать запросы со сложными фильтрами наподобие следующего:

SELECT * FROM table
WHERE
  date > min_date AND
  date < now() - interval '1 day' AND
  value IN Subplan AND
  id = 42';

И на на практике случается, что простая перестановка порядка следования условий в таком выражении позволяет ускорить (а иногда и значительно) время выполнения запроса. Почему? Каждая отдельная операция стоит мало. Однако, если её выполнять снова и снова над миллионами строк из сканируемой таблицы, то затраты на операцию становятся ощутимыми. Особенно если остальные проблемы - подобно попаданию таблицы в shared buffers - уже успешно решены.

Этот эффект хорошо наблюдать на широких таблицах, содержащих десятки столбцов переменной длины. Например, время от времени мне попадается подтормаживающий IndexScan, который тормозит просто из-за того, что поле, по которому выполняется дополнительная фильтрация, является каким-нибудь 20-м по порядку в таблице, и вычисление смещения этого поля от начала строки само по себе требует процессорного времени.

Судя по коду PostgreSQL сообщество уже ранее задумывалось над этим. Коммит 3779f7f, неохотно добавленный T. Lane в 2002 г. чтобы закрыть проблему, добавил реорганизацию выражений (см. order_qual_clauses) таким образом, чтобы поместить все условия содержащие подпланы, в конец списка выражений. Это выглядит логично, поскольку реальная стоимость вычисления сабплана может зависеть от параметров, в него передаваемых, и является дополнительным источником ошибки. В 2007 г. концепция поменялась. С коммитом 5a7471c сортировка выражений выполняется уже исключительно по возрастанию параметра cost - стоимости вычисления каждого конкретного выражения. Эта логика остаётся неизменной до сегодняшнего дня, за исключением небольшой модификации в коммите 215b43c, где изменение кода RLS (Row-Level Security) потребовало управлять порядком вычисления выражений в каждой ноде плана запроса.

Давайте посмотрим, что мы имеем в апстриме по состоянию на сегодняшний день:

CREATE TABLE test (
  x integer, y numeric, w timestamp DEFAULT CURRENT_TIMESTAMP, z integer);
INSERT INTO test (x,y) SELECT gs,gs FROM generate_series(1,1E3) AS gs;
VACUUM ANALYZE test;

EXPLAIN (COSTS ON)
SELECT * FROM test
WHERE
  z > 0 AND
  w > now() AND
  x < (SELECT avg(y) FROM generate_series(1,1E2) y WHERE y%2 = x%3) AND
  x NOT IN (SELECT avg(y) FROM generate_series(1,1E2) y OFFSET 0) AND
  w IS NOT NULL AND
  x = 42;

Заглядывая в фильтр этого SELECT видим следующую последовательность условий:

Filter: ((w IS NOT NULL) AND (z > 0) AND (x = 42) AND (w > now()) AND
         ((x)::numeric = (InitPlan 2).col1) AND ((x)::numeric < (SubPlan 1)))

В ходе выполнения запроса вычисляться они будут строго последовательно, слева направо. Для справки, стоимости операторов получились следующие:

  • "z > 0" - 0.0025

  • "w > now()" - 0.005

  • "x < SubPlan 1" - 2.0225

  • "x NOT IN SubPlan 2" - 0.005

  • w IS NOT NULL" - 0.0

  • x = 42“ - 0.0025

Данный порядок выглядит вполне логичным. Что здесь можно ещё улучшать, спросите вы?

Тут есть как минимум два низковисящих фрукта. Во-первых, можно добавить небольшой кост на порядковый номер колонки, участвующей в выражении. Грубо говоря, чем колонка дальше (правее) в строке таблицы - тем дороже. Оценка не должна быть слишком большой, она нужна оптимизатору для того, чтобы понимать, что вычисление условия x=42 дешевле, чем z=42 при прочих равных.

Второй стандартный паттерн - это когда имеется пара условий с примерно одинаковым костом, например, x=42 и z < 50. Очевидно, что второе условие стоит ставить во вторую позицию, поскольку условие x=42 будет истинным в меньшем количестве случаев и будет реже появляться необходимость вычисления условий, следующих в выражении дальше по списку.

Давайте теперь оценим потенциальный эффект от таких оптимизаций. Есть ли смысл вообще прикладывать даже небольшие усилия? Создадим таблицу, в которой пара колонок будут с одной селективностью, но отстоять далеко друг от друга. А другая пара будет находиться рядом, но иметь разную селективность:

CREATE TEMP TABLE test_2 (x1 numeric, x2 numeric, x3 numeric, x4 numeric);
INSERT INTO test_2 (x1,x2,x3,x4)
  SELECT x,(x::integer)%2,(x::integer)%100,x FROM
    (SELECT random()*1E7 FROM generate_series(1,1E7) AS x) AS q(x);
ANALYZE;

Проверим, чего стоит поиск значения в такой относительно "широкой" строке. Столбцы x_1 и x_4 одинаковы во всем, кроме того, что смещение значения столбца x_1 в строке известно заранее, а для x_4 его ещё нужно вычислять для каждой строки (1):

EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT * FROM test_2 WHERE x1 = 42 AND x4 = 42;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT * FROM test_2 WHERE x4 = 42 AND x1 = 42;

/*
 Seq Scan on test_2  (actual rows=0.00 loops=1)
   Filter: ((x1 = '42'::numeric) AND (x4 = '42'::numeric))
   Buffers: local read=94357
  Execution Time: 2372.032 ms

 Seq Scan on test_2  (actual rows=0.00 loops=1)
   Filter: ((x4 = '42'::numeric) AND (x1 = '42'::numeric))
   Buffers: local read=94357
 Execution Time: 2413.633 ms
*/

Получается, что при прочих равных, эффект даже не экстремально длинного тупла дает около 2-3%. Вполне сравнимо с типичным эффектом от использования JIT. Давайте теперь посмотрим на влияние селективности. Колонки x_1 и x_2 находятся рядом. разница только в том, что значения в x_1 почти уникальны, а x_2 содержит практически одни дубликаты (2):

EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT * FROM test_2 WHERE x2 = 1 AND x1 = 42;
EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF)
SELECT * FROM test_2 WHERE x1 = 42 AND x2 = 1;
/*
 Seq Scan on test_2  (actual rows=0.00 loops=1)
   Filter: ((x2 = '1'::numeric) AND (x1 = '42'::numeric))
   Buffers: local read=74596
 Execution Time: 2363.903 ms

 Seq Scan on test_2  (actual rows=0.00 loops=1)
   Filter: ((x1 = '42'::numeric) AND (x2 = '1'::numeric))
   Buffers: local read=74596
 Execution Time: 2034.873 ms
*/

Здесь уже имеем порядка 10%.

Выходит, если принять, что эффект может быть накопительным по дереву плана, а в этом дереве может быть немало как операторов сканирования, так и джойнов, группировок и пр., которые также дадут некоторый процент, то в целом данная методика имеет смысл быть реализованной с учётом незначительного оверхеда на время планирования.

Ок, давайте реализуем и посмотрим, как она заработает. Сделать расширением это смысла не имеет, поскольку до сих пор не существует хука, который позволил бы выполнять операции в момент создания финального плана. Необходимость создания такого хука create_plan_hook внутри функции create_plan() понемногу нарастает, однако Postgres сообщество пока серьёзно этот вопрос не обсуждало.

Если же делать эту фичу в форме патча ядра, то изменения должны быть внесены в два места кода: cost_qual_eval(), где вычисляется стоимость выражений и order_qual_clauses, определяющую правила сортировки выражений. Финальный код можно найти на GitHub в соответствующей ветке.

Если выполнить на этой ветке вышеприведённые примеры, то видно, что выражения выстраиваются в более оптимальном порядке, учитывая порядок следования колонок и селективность. При этом какой-то дополнительный оверхед не заметен.

А как вы считаете, имеет ли смысл изобретать такие микро оптимизации, или нужно мыслить масштабней? Встречались ли вы с подобными проблемами? Оставляйте ваше мнение в комментариях.

THE END.
27 апреля 2025 г., Мадрид, Испания.

Теги:
Хабы:
+3
Комментарии0

Публикации

Ближайшие события