SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND H.CODE_SEX = 2
AND H.CODE_HORSE IN (
SELECT COVER.CODE_FATHER
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND EXTRACT(YEAR FROM COVER.BYDATE) = 2023
)
(С) Денис Симонов, IBSurgeon, 21-Aug-2024
Недавно вышел пойнт релиз СУБД Firebird 5.0 Firebird 5.0.1. В нём помимо исправления ошибок была добавлена новая экспериментальная функция оптимизатора о которой и будет рассказано в этой статье.
Полусоединение (semi-join) — это операция соединения двух отношений, которая возвращает строки только одного из отношений, без выполнения соединения полностью. В отличие от других операторов соединений, не существует явного синтаксиса для указания исполнения полусоединения. Однако выполнить полусоединение можно с помощью подзапросов в ANY/SOME/IN/EXISTS.
Традиционно Firebird преобразует подзапросы в предикатах ANY/SOME/IN в коррелированные подзапросы в предикате EXISTS, и выполняет подзапрос в EXISTS для каждой записи внешнего запроса. При выполнении подзапроса внутри предиката EXISTS используется стратегия FIRST ROWS, и его выполнение немедленно прекращается после возврата первой записи.
Начиная с Firebird 5.0.1 подзапросы в предикатах ANY/SOME/IN/EXISTS могут быть преобразованы в полусоединения (semi-joins). По умолчанию эта возможность отключена, для её включения необходимо установить параметр конфигурации SubQueryConversion
равным значению true
в файле firebird.conf
или database.conf
Данная функция является экспериментальной, поэтому по умолчанию она отключена. Вы можете включить её и протестировать свои запросы с подзапросами в предикатах ANY/SOME/IN/EXISTS, и если производительность окажется лучше, то оставить её включенной, в противном случае верните значение параметра Значение по умолчанию для параметра конфигурации |
В отличие от выполнения ANY/SOME/IN/EXISTS с подзапросами непосредственно, то есть как коррелированных подзапросов, выполнение их как semi-join даёт больше пространства для оптимизации. Полусоединение может быть выполнено различными алгоритмами Hash Join (semi)
или Nested Loop Join (semi)
, в то время как коррелированные подзапросы всегда выполняются для каждой записи внешнего запроса.
Попробуем включить данную функцию, установив параметру SubQueryConversion
значение true
в файле firebird.conf
. Теперь проведём несколько экспериментов.
Выполним следующий запрос:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND H.CODE_SEX = 2
AND H.CODE_HORSE IN (
SELECT COVER.CODE_FATHER
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND EXTRACT(YEAR FROM COVER.BYDATE) = 2023
)
Select Expression -> Aggregate -> Filter -> Hash Join (semi) -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap And -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) -> Bitmap -> Index "FK_HORSE_SEX" Range Scan (full match) -> Record Buffer (record length: 41) -> Filter -> Table "COVER" Access By ID -> Bitmap And -> Bitmap -> Index "IDX_COVER_BYYEAR" Range Scan (full match) -> Bitmap -> Index "FK_COVER_DEPARTURE" Range Scan (full match) COUNT ===================== 297 Current memory = 552356752 Delta memory = 352 Max memory = 552567920 Elapsed time = 0.045 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 43984 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 1516| | | | HORSE | | 37069| | | | --------------------------------+---------+---------+---------+---------+---------+
В плане выполнения мы видим новый метод соединения Hash Join (semi)
. Результат подзапроса в IN
был буферизирован, что видно в плане как Record Buffer (record length: 41)
. То есть в данном случае подзапрос в IN, был выполнен однократно, его результат сохранён в память хеш таблицы, а затем внешний запрос просто производил поиск в этой хеш таблице.
Для сравнения выполним тот же запрос с отключенным преобразованием подзапросов в полусоединение.
Sub-query -> Filter -> Filter -> Table "COVER" Access By ID -> Bitmap And -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) -> Bitmap -> Index "IDX_COVER_BYYEAR" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap And -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) -> Bitmap -> Index "FK_HORSE_SEX" Range Scan (full match) COUNT ===================== 297 Current memory = 552046496 Delta memory = 352 Max memory = 552135600 Elapsed time = 0.395 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 186891 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 297| | | | HORSE | | 37069| | | | --------------------------------+---------+---------+---------+---------+---------+
В плане выполнения видно, что подзапрос выполняется для каждой записи основного запроса, но использует дополнительный индекс FK_COVER_FATHER
. Это видно и в статистике выполнения: количество Fetches в 4 раза больше, время выполнения почти в 4 раза хуже.
Читатель может задать вопрос: а почему же hash semi-join показывает в 5 раз больше индексных чтений таблицы COVER, но в остальном он лучше. Дело в том, что индексированные чтения в статистике отображают количество записей прочитанных с помощью индекса, они не отображают общее количество обращений к индексам, часть из которых вообще не приводят к извлечению записей, но при этом эти обращения не являются бесплатными. |
Что же произошло? Для лучшего понимания трансформации подзапросов введём воображаемый оператор полусоединия "SEMI JOIN". Как я уже говорил данный вид соединения не представлен в языке SQL. Наш запрос с оператором IN был преобразован в эквивалентную форму, которую можно записать так:
SELECT
COUNT(*)
FROM
HORSE H
SEMI JOIN (
SELECT COVER.CODE_FATHER
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND EXTRACT(YEAR FROM COVER.BYDATE) = 2023
) TMP ON TMP.CODE_FATHER = H.CODE_HORSE
WHERE H.CODE_DEPARTURE = 1
AND H.CODE_SEX = 2
Теперь стало яснее. Тоже самое происходит и для подзапросов с использованием EXISTS. Давайте посмотрим ещё один пример:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.CODE_MOTHER = H.CODE_MOTHER
)
В настоящее время такой EXISTS невозможно написать с использованием IN. Посмотрим как он выполняется без трансформации в полусоединение.
Sub-query -> Filter -> Table "COVER" Access By ID -> Bitmap And -> Bitmap -> Index "FK_COVER_MOTHER" Range Scan (full match) -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) COUNT ===================== 91908 Current memory = 552240400 Delta memory = 352 Max memory = 554680016 Elapsed time = 19.083 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 935679 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 91908| | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Очень медленно. А теперь установим SubQueryConversion = true
и выполним запрос ещё раз.
Select Expression -> Aggregate -> Filter -> Hash Join (semi) -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) -> Record Buffer (record length: 49) -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "FK_COVER_DEPARTURE" Range Scan (full match) COUNT ===================== 91908 Current memory = 552102000 Delta memory = 352 Max memory = 561520736 Elapsed time = 0.208 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 248009 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 140254| | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Запрос выполнился в 100 раз быстрее! Если его переписать с помощью нашего выдуманного оператора SEMI JOIN, то запрос будет выглядеть следующим образом:
SELECT
COUNT(*)
FROM
HORSE H
SEMI JOIN (
SELECT
COVER.CODE_FATHER,
COVER.CODE_MOTHER
FROM COVER
) TMP ON TMP.CODE_FATHER = H.CODE_FATHER AND TMP.CODE_MOTHER = H.CODE_MOTHER
WHERE H.CODE_DEPARTURE = 1
Любой коррелированный подзапрос в IN/EXISTS можно преобразовать в полусоединение? Нет, не любой, например, если подзапрос содержит ограничители FETCH/FIRST/SKIP/ROWS, то преобразовать подзапрос в полусоединение нельзя и он будет выполняться как обычный коррелированный подзапрос. Приведу пример такого запроса:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_HORSE
OFFSET 0 ROWS
)
Здесь фраза OFFSET 0 ROWS
не меняет семантику запроса, и результат его выполнения будет тем же, что и без неё. Посмотрим план и статистику данного запроса.
Sub-query -> Skip N Records -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) COUNT ===================== 10971 Current memory = 551912944 Delta memory = 288 Max memory = 552002112 Elapsed time = 0.201 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 408988 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 10971| | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Как видите преобразование в полусоединение не произошло. Теперь уберём OFFSET 0 ROWS
и снимем статистику ещё раз.
Select Expression -> Aggregate -> Filter -> Hash Join (semi) -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) -> Record Buffer (record length: 33) -> Table "COVER" Full Scan COUNT ===================== 10971 Current memory = 552112128 Delta memory = 288 Max memory = 585044592 Elapsed time = 0.405 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 854841 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | 722465| | | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Здесь преобразование в полусоединение произошло, и как мы видим время выполнения стало хуже. Причина в том, что в настоящее время в оптимизаторе нет стоимостной оценки между алгоритмами соединения Hash Join (semi)
и Nested Loop Join (semi)
с использованием индекса, поэтому используется правило: если в условии соединения присутствует только равенство, то выбирается алгоритм Hash Join (semi)
, в противном случае подзапросы IN/EXISTS выполняются как обычно.
Теперь отключим преобразование в полусоединение и посмотрим статистику выполнения.
Sub-query -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) COUNT ===================== 10971 Current memory = 551912752 Delta memory = 288 Max memory = 552001920 Elapsed time = 0.193 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 408988 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 10971| | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Как видите Fetches в точности равно случаю когда в подзапросе присутствовала фраза OFFSET 0 ROWS
, а время выполнения отличается в пределах погрешности. Это означает, что вы можете использовать предложение OFFSET 0 ROWS
как подсказку для отключения преобразование в semi-join.
Теперь посмотрим на случаи, когда в подзапросах используется любое коррелированное условие кроме равенства и IS NOT DISTINCT FROM
.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.BYDATE > H.BIRTHDAY
)
Sub-query -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "COVER_IDX_BYDATE" Range Scan (lower bound: 1/1) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
Как я и говорил выше, никакого преобразования в полусоединение не произошло, подзапрос выполняется для каждой записи основного запроса.
Продолжим эксперименты, напишем запрос с использованием равенства и ещё одного любого предиката кроме равенства.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.BYDATE > H.BIRTHDAY
)
Select Expression -> Aggregate -> Nested Loop Join (semi) -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) -> Filter -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "COVER_IDX_BYDATE" Range Scan (lower bound: 1/1)
Здесь в плане мы впервые видим использование метода соединения Nested Loop Join (semi)
, но к сожалению этот план плохой, так как не используется индекс FK_COVER_FATHER
. Результатов такого запроса вы не дождётесь. Исправить это можно с помощью подсказки OFFSET 0 ROWS
.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.BYDATE > H.BIRTHDAY
OFFSET 0 ROWS
)
Sub-query -> Skip N Records -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "FK_HORSE_DEPARTURE" Range Scan (full match) COUNT ===================== 72199 Current memory = 554017824 Delta memory = 320 Max memory = 554284480 Elapsed time = 45.548 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 84145713 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 75894621| | | | HORSE | | 96021| | | | --------------------------------+---------+---------+---------+---------+---------+
Не самое лучшее время выполнения, но в данном случае мы хотя бы дождались результата.
Таким образом, преобразование подзапросов в ANY/SOME/IN/EXISTS в semi-join позволяет в ряде случаев значительно ускорить выполнение запросов, но в настоящее время эта функция ещё несовершенна, а потому отключена по умолчанию. В Firebird 6.0 для этой функциональности постараются добавить стоимостную оценку, а также исправить ряд других недостатков. Кроме того в Firebird 6.0 планируется добавить преобразование подзапросов ALL/NOT IN/NOT EXISTS в anti-join.
В заключении обзора выполнения подзапросов в IN/EXISTS, хотел бы отметить, что если у вас есть запрос вида
SELECT ... FROM T1 WHERE <primary key> IN (SELECT field FROM T2 ...)
или
SELECT ... FROM T1 WHERE EXISTS (SELECT ... FROM T2 WHERE T1.<primary key> = T2.field)
то такие запросы почти всегда эффективней выполнять как
SELECT ... FROM T1 JOIN (SELECT DISTINCT field FROM T2) tmp ON tmp.field = T1.<primary key>
Приведу наглядный пример:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_HORSE IN (
SELECT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
)
План и статистика выполнения с использованием Hash Join (semi)
Select Expression -> Aggregate -> Filter -> Hash Join (semi) -> Table "HORSE" as "H" Full Scan -> Record Buffer (record length: 41) -> Filter -> Table "COVER" Access By ID -> Bitmap -> Index "IDX_COVER_BYYEAR" Range Scan (full match) COUNT ===================== 1616 Current memory = 554176768 Delta memory = 288 Max memory = 555531328 Elapsed time = 0.229 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 569683 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 6695| | | | HORSE | 525875| | | | | --------------------------------+---------+---------+---------+---------+---------+
Довольно быстро, но таблица HORSE читается целиком.
План и статистика выполнения с классическим выполнением подзапросов
Sub-query -> Filter -> Filter -> Table "COVER" Access By ID -> Bitmap And -> Bitmap -> Index "FK_COVER_FATHER" Range Scan (full match) -> Bitmap -> Index "IDX_COVER_BYYEAR" Range Scan (full match) Select Expression -> Aggregate -> Filter -> Table "HORSE" as "H" Full Scan COUNT ===================== 1616 Current memory = 553472512 Delta memory = 288 Max memory = 553966592 Elapsed time = 6.862 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 2462726 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 1616| | | | HORSE | 525875| | | | | --------------------------------+---------+---------+---------+---------+---------+
Очень медленно. Таблица HORSE всё так же читается целиком, а подзапрос выполняется многократно — для каждой записи таблицы HORSE.
А теперь быстрый вариант с DISTINCT
SELECT
COUNT(*)
FROM
HORSE H
JOIN (
SELECT
DISTINCT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
) TMP ON TMP.CODE_FATHER = H.CODE_HORSE
Select Expression -> Aggregate -> Nested Loop Join (inner) -> Unique Sort (record length: 44, key length: 12) -> Filter -> Table "COVER" as "TMP COVER" Access By ID -> Bitmap -> Index "IDX_COVER_BYYEAR" Range Scan (full match) -> Filter -> Table "HORSE" as "H" Access By ID -> Bitmap -> Index "PK_HORSE" Unique Scan COUNT ===================== 1616 Current memory = 554349728 Delta memory = 320 Max memory = 555531328 Elapsed time = 0.011 sec Buffers = 32768 Reads = 0 Writes = 0 Fetches = 14954 Per table statistics: --------------------------------+---------+---------+---------+---------+---------+ Table name | Natural | Index | Insert | Update | Delete | --------------------------------+---------+---------+---------+---------+---------+ COVER | | 6695| | | | HORSE | | 1616| | | | --------------------------------+---------+---------+---------+---------+---------+
Никаких лишних чтений, запрос выполняется очень быстро. Отсюда вывод — всегда смотрите план выполнения подзапросов в IN/EXISTS/ANY/SOME, и проверяйте альтернативные варианты написания запросов.