Если планировщик знает, что строки какого-то отношения гарантированно не повторяются, он может строить более дешёвые планы, например:
выбросить
DISTINCTилиGROUP BY, которые ничего не делают;пометить соединение как
Inner Unique: внутренней стороне соответствует не больше одной строки на каждую внешнюю; executor может закончить поиск совпадения по условиям соеднинения, а планировщик использует эту гарантию при оценке стоимости;не делать правую сторону
IN (SELECT ...)уникальной перед соединением, если она уже уникальна.
Что-то из этого PostgreSQL умеет давно, но по отдельности. Каждая оптимизация сама смотрит на индексы одной таблицы и почти ничего не знает о том, что происходит выше по дереву плана. Поэтому планировщик порой не видит очевидного:
-- id — первичный ключ, дубликатов нет, но DISTINCT честно выполняется SELECT DISTINCT id, customer_id FROM orders; -- (b JOIN c) по первичным ключам уникально по b.id, -- но доказать это для внешнего LEFT JOIN нечем SELECT ... FROM a LEFT JOIN (b JOIN c ON b.id = c.id) ON a.x = b.id;
В Tantor Postgres мы это исправили: включили в него UniqueKeys. Это механизм, который сейчас предложен и в сообщество PostgreSQL. Разберём, что он умеет, где сознательно не срабатывает и почему выводить уникальность через LEFT JOIN сложнее, чем может показаться.
Что PostgreSQL уже умеет и чего не хватает
Нельзя сказать, что планировщик сейчас совсем ничего не знает об уникальности. Несколько точечных оптимизаций все-таки есть:
Inner Uniqueу соединений: Если внутренняя сторона соединения гарантированно даёт не больше одной строки на каждую внешнюю, поиск пары для строки заканчивается на первом совпадении. ВEXPLAIN VERBOSEэто видно какInner Unique: true.Удаление
LEFT JOIN: если к таблице присоединились по уникальному ключу и ни одна её колонка не нужна, соединение просто выбрасывается.Удаление лишних колонок из
GROUP BY: если вGROUP BYуже есть первичный ключ, остальные колонки этой таблицы из ключа группировки можно убрать.
У всех этих механизмов есть общая проблема: каждый из них сам по-своему смотрит на базовую таблицу и её индексы (иногда ещё на подзапрос с DISTINCT/GROUP BY). Нет общего места, где хранилось бы знание «вот это отношение уникально по вот этим колонкам» и где оно поднималось бы вверх по дереву соединений. Поэтому:
DISTINCTпо первичному ключу не удаляется вообще;если внутренняя сторона соединения сама является соединением (например,
LEFT JOIN (b JOIN c ON ...)), доказать её уникальность нечем;уникальность, известная внутри подзапроса, не всегда доходит до внешнего запроса.
Как это было сделано раньше
До UniqueKeys в Tantor Postgres была отдельная оптимизация: удаление лишнего DISTINCT после GROUP BY. Планировщик проверял, что все выражения группировки присутствуют среди выражений DISTINCT. Тогда разные группы уже не могут дать одинаковые выходные строки, и повторная дедупликация не нужна. Например:
SELECT DISTINCT customer_id, count(*) FROM orders GROUP BY customer_id; --- До оптимизации HashAggregate -- DISTINCT: повторная дедупликация Group Key: customer_id, count(*) -> HashAggregate -- GROUP BY: одна строка на клиента Group Key: customer_id -> Seq Scan on orders -- После оптимизации HashAggregate -- GROUP BY и вычисление count(*) Group Key: customer_id -> Seq Scan on orders
GROUP BY уже выдаёт не более одной строки для каждого customer_id, поэтому DISTINCT здесь ничего не меняет. Эта оптимизация оставляла группировку и вычисление count(*), но пропускала построение отдельного шага DISTINCT. Для DISTINCT ON, GROUPING SETS и функций, возвращающих множество строк в SELECT, эта проверка не применялась.
Этот патч был отправлен в рассылку pgsql-hackers. В обсуждении указали, что готовится более общее решение - UniqueKeys. Вместо отдельной проверки сочетания DISTINCT и GROUP BY оно позволяет хранить и передавать между отношениями планировщика доказательства уникальности. Поэтому при интеграции UniqueKeys прежний патч был удалён: его задачу теперь решает общий механизм, который также учитывает уникальные индексы, подзапросы и соединения.
Что такое UniqueKeys
Каждое отношение, которое строит планировщик (RelOptInfo: таблица, подзапрос, соединение, результат группировки), получает список uniquekeys. Один UniqueKey - это утверждение «в выходе этого отношения нет двух строк, совпадающих по вот этому набору выражений». Этот список может оставаться пустым. Две детали делают структуру удобной.
Выражения хранятся как классы эквивалентности (
EquivalenceClass, EC), так же какpathkeys. Для подходящего условия равенства, например во внутреннем соединении, планировщик может объединить a.aid = b.aid в один класс. Там где это равенство выполняется для всех выходных строк, уникальность по одному выражению означает уникальность по другому. Для внешнего соеднинения такой вывод нельзя делать без учета NULL-дополнения. Не нужно хранить отдельный ключ для каждого синонима;У ключа есть флаг
null_aware. Тут начинается самое интересное.
Проблема NULL
Уникальный индекс и DISTINCT по-разному понимают равенство NULL-ов:
CREATE TABLE t (u int UNIQUE); INSERT INTO t VALUES (NULL), (NULL), (1); -- уникальный индекс не против SELECT DISTINCT u FROM t; -- 1 -- NULL -- две NULL-строки схлопнулись в одну
Для уникального индекса два NULL-а различны, а для DISTINCT и GROUP BY равны. Значит, UNIQUE (u) по nullable-колонке не даёт права удалить DISTINCT u.
Но для соединений этого достаточно. Условие a.x = t.u с NULL-ом никогда не истинно, поэтому строки с u IS NULL ни с чем не совпадут, и для доказательства Inner Unique они не важны.
Отсюда два уровня гарантии:
null_aware= true. Строки различны по ключу, даже если считатьNULL-ы равными. Это позволяет удалить лишнийDISTINCTилиGROUP BYпри выполнении остальных условий оптимизации.null_aware= false. Уникальность гарантирована только для строк безNULLв выражениях ключа. Этого достаточно для Inner Unique, если строгие условия соединения покрывают ключ:NULL-строки не найдут пару. Для удаленияDISTINCTилиGROUP BYтакой гарантии недостаточно.
Одна структура обслуживает оба вида потребителей, и это одно из главных отличий от прошлых попыток.
Базовые таблицы
Источник ключей - уникальные индексы, но не любые:
индекс должен проверяться немедленно, поэтому
DEFERRABLEне подходит (внутри транзакции дубликаты временно возможны);индекс не должен быть частичным (
WHERE...), иначе он покрывает не всю таблицу;для обычного индекса ключ получает
null_aware = true, если каждый оставшийся компонент - Var, для которого код установил NOT NULL или исключение NULL ограничением, напримерWHERE u > 0; NULLS NOT DISTINCT дает такую гарантию и без NOT NULL. Для индексов по выражениям вывод NULL-awareness более консервативен.
Колонка ключа, приравненная к константе, из ключа выпадает. Если PRIMARY KEY - (a, b), а в запросе WHERE a = 5, то остаток уникален уже по одному b
EXPLAIN (COSTS OFF) SELECT DISTINCT b FROM uk_pk2 WHERE a = 5; QUERY PLAN ---------------------------------------- Bitmap Heap Scan on uk_pk2 Recheck Cond: (a = 5) -> Bitmap Index Scan on uk_pk2_pkey Index Cond: (a = 5)
Никакого HashAggregate / Unique нет.
Подзапросы и операции над множествами
Подзапрос во FROM может наследовать ключи своего итогового отношения, если все компоненты ключа удаётся сопоставить доступным выходным выражением и интересным EC внешнего запроса. DISTINCT и GROUP BY могут дать такие ключи. Обычный агрегат без GROUP BY и без SRF выдает не более одной строки: HAVING может убрать и её Эта гарантия представляется пустым набором EC внутри UniqueKey. Этоне то же самое, что отсутсвие ключей. UNION, INTERSECT и EXCEPT без ALL дают ключ по всей строке, UNION ALL - нет.
Соединения: два правила
Пусть соединяются A и B. Ключи соединения выводятся из ключей входов по двум правилам:
Сохранение. Ключ одной стороны остаётся ключом соединения, если соединение не размножает её строки, то есть другая сторона уникальна по условию соединения. Пример:
orders JOIN customers ON orders.customer_id = customers.id- каждой строкеordersсоответствует не больше одного клиента, поэтому соединение по-прежнему уникально по orders.id. semi- и anti-join не размножает строки сохраняемой стороны. Код также учитываетLATERAL: ключ зависимой стороны нельзя автоматически переносить между разными наборами параметров.Комбинация. Объединение ключа A и ключа B - ключ соединения. Если две выходные строки совпали и по ключу A, и по ключу B, они получены из одной и той же пары исходных строк, а каждая пара выдаётся не больше одного раза. Даже у CROSS JOIN есть ключ (a.id, b.id).
Внешние соединения и NULL
LEFT JOIN дописывает NULL-ы в колонки правой стороны для строк без пары. Ключ правой стороны можно сохранять отдельно только если левая сторона не размножает её строки; притаком переносе он теряет null_ware. Пустой ключ правой стороны не переносится вообще: отсутсвие колонок не позволяет исключить несколько NULL-дополненных строк. Две строки слева без пары дадут две строки с NULL в ключе правой стороны, и для DISTINCT они одинаковы:
EXPLAIN (COSTS OFF) SELECT DISTINCT uk_q.id FROM uk_p LEFT JOIN uk_q ON uk_p.id = uk_q.id; QUERY PLAN ---------------------------------------- HashAggregate Group Key: uk_q.id -> Hash Left Join Hash Cond: (uk_p.id = uk_q.id) ...
Ключ левой стороны при LEFT JOIN сохраняется, если правая сторона даёт не более одной пары для каждой левой строки. В этом примере uk_q.id - первичный ключ, поэтому DISTINCT уходит:
EXPLAIN (COSTS OFF) SELECT DISTINCT uk_p.id, uk_q.v FROM uk_p LEFT JOIN uk_q ON uk_p.id = uk_q.id; QUERY PLAN ---------------------------------- Hash Left Join Hash Cond: (uk_p.id = uk_q.id) -> Seq Scan on uk_p -> Hash -> Seq Scan on uk_q
При FULL JOIN реализации не выводит UniqueKeys. Это консервативное ограничение алгоритма, а не утверждение, что ни один FULL JOIN математически не может быть ключа.
Работает и обратное направление: если примененные условия внутреннего соединения позволяют коду доказать отсутсвие NULL для всех компонентов сохраняемого ключа, ему возвращают null_aware. Одного строгого условия по постронней колонке для этого недостаточно. Если uk_nul.u - nullable-колонка с UNIQUE, то сама по себе она не позволяет удалить DISTINCT u. Но после JOIN ... ON uk_nul.u = uk_p.idстрок с u IS NULL в выходе уже нет:
EXPLAIN (COSTS OFF) SELECT DISTINCT u FROM uk_nul JOIN uk_p ON uk_nul.u = uk_p.id; QUERY PLAN ----------------------------------- Hash Join Hash Cond: (uk_nul.u = uk_p.id) -> Seq Scan on uk_nul -> Hash -> Seq Scan on uk_p
Для semi-join это тоже верно, для anti-join - нет. Выход anti-join состоит как раз из строк, для которых совпадения не нашлось, в том числе с NULL-ами.
Ключи могут сохраняться через цепочки LEFT JOIN, если на каждом шаге выполнены условия сохранения. Сортировка, LIMIT, оконные функции и финальные LockRows/ModifyTable переносят уже известные ключи. GROUP BY и DISTINCT создают ключи по своим выражениям; из входных ключей сохраняются ключи внутри этого набора, а при no_op - все входные ключи.
Что это даёт на практике
Сам по себе список ключей нигде не печатается. Его видно по оптимизациям, которые им пользуются: удаление лишних DISTINCT и GROUP BY дают самые наглядные планы, а Inner Unique для соединений и semi-join без уникализации работают незаметнее, но могут срабатывать и в запросах с подходящими соединениями, если код сумел вывести и сопоставить нуный ключ.
Удаление лишнего DISTINCT
EXPLAIN (COSTS OFF) SELECT DISTINCT a, b, c FROM uk_pk2; -- PRIMARY KEY (a, b) QUERY PLAN -------------------- Seq Scan on uk_pk2
Пример до/после есть в существующих регрессионных тестах PostgreSQL. max() без GROUP BY возвращает одну строку, поэтому DISTINCT над ним бессмыслен:
EXPLAIN (COSTS OFF) SELECT DISTINCT max(unique2) FROM tenk1;
--- До патча HashAggregate Group Key: (InitPlan minmax_1).col1 InitPlan minmax_1 -> Limit -> Index Only Scan Backward using tenk1_unique2 on tenk1 Index Cond: (unique2 IS NOT NULL) -> Result Replaces: MinMaxAggregate --- После патча Result Replaces: MinMaxAggregate InitPlan minmax_1 -> Limit -> Index Only Scan Backward using tenk1_unique2 on tenk1 Index Cond: (unique2 IS NOT NULL)
Удаление лишнего GROUP BY
Реализация может убрать группировку, если её выражения покрывают NULL-aware ключ входа, при этом нет агрегатов, HAVING и grouping sets:
EXPLAIN (COSTS OFF) SELECT a, b FROM uk_pk2 GROUP BY a, b, c; QUERY PLAN -------------------- Seq Scan on uk_pk2
С count(*) группировка, конечно, остаётся: агрегат всё равно нужно посчитать, пусть даже по одной строке на группу.
Inner Unique для сложных внутренних сторон
Раньше Inner Unique доказывался только для базовой таблицы (или простого подзапроса) справа. Теперь внутренней стороной может быть целое соединение:
EXPLAIN (VERBOSE, COSTS OFF) SELECT uk_p.v FROM uk_p LEFT JOIN (uk_q JOIN uk_r ON uk_q.id = uk_r.id) ON uk_p.v = uk_q.id; Hash Left Join Output: uk_p.v Inner Unique: true -- доказано по ключу соединения uk_q ⋈ uk_r Hash Cond: (uk_p.v = uk_q.id) -> Seq Scan on public.uk_p -> Hash -> Hash Join Inner Unique: true Hash Cond: (uk_q.id = uk_r.id) ...
Inner Unique не только сокращает поиск пары при выполнении. Он влияет и на стоимость, а значит, планировщик может выбрать другую форму плана. В тестах pg_stash_advice звёздная схема (факт + два измерения через LEFT JOIN) после патча стала строиться так: оба измерения сначала соединяются между собой, а результат идёт внутренней стороной одного Hash Join к таблице фактов:
Hash Join Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id)) -> Seq Scan on aa_fact f -> Hash -> Nested Loop -> Seq Scan on aa_dim2 d2 Filter: (val2 = 1) -> Seq Scan on aa_dim1 d1 Filter: (val1 = 1)
Без патча это были два последовательных соединения: Hash Join с d2 и Nested Loop с индексным доступом к d1.
Semi-join без лишней уникализации
WHERE (x, y) IN (SELECT ...) планировщик может выполнить как обычное соединение, предварительно сделав правую сторону уникальной HashAggregate / Unique. Если правая сторона уже уникальна по нужным колонкам, этот шаг пропускается. Раньше такое умели только когда справа одно отношение (таблица с подходящим уникальным индексом или подзапрос с DISTINCT/GROUP BY), теперь - для любого отношения с подходящим UniqueKey.
Секции и eager aggregation
UniqueKeys работают и с секционированием. Секция содержит подмножество строк родителя, а подмножество уникального множества уникально, поэтому дочерние отношения просто наследуют ключи родителя. В частности, при enable_partitionwise_join = on каждое соединение секций получает Inner Unique: true.
У таблиц с наследованием ключа нет: строка потомка может дублировать строку родителя, а общего уникального индекса у них нет.
Ещё UniqueKeys стыкуются с eager aggregation -оптимизацией, которая позволяет выполнить частичную агрегацию до соединения. Частично сгруппированное отношение уникально по колонкам группировки, а eager aggregation и так обязана группировать по колонкам, нужным соединениям выше. В итоге соединение над частичным агрегатом получает Inner Unique:
Где оптимизация сознательно не срабатывает
Самая опасная ошибка в такой оптимизации - вывести ключ, которого на самом деле нет, и удалить DISTINCT, который был нужен. Запрос тогда вернёт дубликаты, то есть неверный результат. Поэтому в патче много «нет»:
UNIQUEпоnullable-колонке безNOT NULL. НесколькоNULL-ов допустимы в индексе, но равны дляDISTINCT;DEFERRABLEограничение. Внутри транзакции дубликаты временно возможны;Частичный уникальный индекс. Не покрывает всю таблицу;
Правая сторона
LEFT JOIN, обе стороныFULL JOIN. Строки без пары даютNULL-дубликаты;UNION ALL. Не удаляет дубликаты;SRF в
SELECT. Размножает строки;Наследование. Уникальность родителя не распространяется на потомков;
Одна сторона
CROSS JOIN. Каждая её строка повторяется для каждой строки другой стороны.
Во всех этих случаях в регрессионных тестах есть и проверка плана, и проверка результата.
Сколько это стоит
Знание не бесплатно: ключи нужно вычислять для каждого промежуточного отношения, а правило комбинации в худшем случае плодит их комбинаторно.
Чтобы ограничить расходы, патч отслеживает только «интересные» классы эквивалентности, то есть те, которыми может воспользоваться хоть один потребитель:
колонки
DISTINCT;колонки
GROUP BY, если группировка потенциально может оказаться no-op;многотабличные
EC, способные породить условия соединения, и обе стороныmergejoinableусловий внешних соединений. Это не все выражения произвольногоJOIN;колонки, которые участвуют в условиях соединения.
Есть и прямой лимит - 8 ключей на отношение. При добавлении ключей код отбрасывает избаточные гарантии: ключ по меньшему набору EC с не более слабой NULL-awareness делает большой ключ ненужным. Ключи joinrel выводятся один раз, из первой пары входов, с которой его построили.
Управление оптимизацией
В Tantor Postgres есть GUC-параметр enable_uniquekeys, включенный по умолчанию.
Итог
UniqueKeys - не одна оптимизация, а своего рода инфраструктура. Каждое промежуточное отношение (таблица, подзапрос, соединение, результат группировки) получает список наборов выражений, по которым его строки гарантированно различны. Ключи протаскиваются снизу вверх через соединения, подзапросы, секции и eager aggregation, с аккуратным учётом NULL-ов и внешних соединений. На этом знании работают сразу несколько оптимизаций:
- удаление лишних DISTINCT и GROUP BY;
- Inner Unique для соединений со сложной внутренней стороной;
- пропуск уникализации в semi-join;
- секции и eager aggregation.
Дальше на этом фундаменте можно строить и другое. В списке планов автора - UNION ALL, VALUES и собственные уникальные индексы секций.
В Tantor Postgres это работает уже сейчас, а если патч примут в PostgreSQL, - будет работать и там. Часть запросов станет дешевле без единой правки в приложении: и те, где ORM или BI добавили «защитный» DISTINCT, и те, где выигрыш даст более точное знание о соединениях.
Патчи UniqueKeys доступны для ревью в PostgreSQL CommitFest.