Улучшения оптимизатора в Firebird 5.0.1
(c) D.Simonov, IBSurgeon, 21-Aug-2024
Недавно вышло точечное обновление СУБД Firebird 5.0 - Firebird 5.0.1. Помимо исправления ошибок, в нем появилась новая экспериментальная функция оптимизатора, о которой и пойдет речь в этой статье.
Преобразование подзапросов ANY/SOME/IN/EXISTS в полу-соединение (semi-join)
Полу-соединение - это операция, которая соединяет два отношения, возвращая строки только из одного из них без выполнения полного соединения. В отличие от других операторов соединения, не существует явного синтаксиса для указания выполнения полу-соединения. Однако вы можете выполнить полу-соединение с помощью подзапросов в ANY/SOME/IN/EXISTS.
Традиционно Firebird преобразует подзапросы в предикатах ANY/SOME/IN в коррелированные подзапросы в предикате EXISTS и выполняет подзапрос в EXISTS для каждой записи внешнего запроса. При выполнении подзапроса внутри предиката EXISTS используется стратегия FIRST ROWS, и его выполнение прекращается сразу после возврата первой записи.
Начиная с Firebird 5.0.1, подзапросы в предикатах ANY/SOME/IN/EXISTS могут быть преобразованы в полу-соединения. Эта функция отключена по умолчанию и может быть включена установкой параметра конфигурации SubQueryConversion в значение true в файле firebird.conf или database.conf.
Эта функция является экспериментальной, поэтому она отключена по умолчанию. Вы можете включить ее и протестировать свои запросы с подзапросами в предикатах ANY/SOME/IN/EXISTS, и если производительность станет лучше, оставить ее включенной, в противном случае вернуть параметр SubQueryConversion к значению по умолчанию (false).Значение по умолчанию для параметра конфигурации SubQueryConversion может быть изменено в будущем, или параметр может быть удален полностью. Это произойдет, когда новый способ выполнения будет признан более оптимальным в большинстве случаев. |
В отличие от выполнения ANY/SOME/IN/EXISTS непосредственно на подзапросах, т.е. как коррелированных подзапросов, выполнение их как полу-соединений дает больше возможностей для оптимизации. Полу-соединения могут выполняться различными алгоритмами 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 раза хуже.
| Читатель может спросить: почему хеш-полу-соединение показывает в 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 преобразовать в semi-join? Нет, не любой. Например, если подзапрос содержит фильтры FETCH/FIRST/SKIP/ROWS, то такой подзапрос не может быть преобразован в semi-join и будет выполняться как коррелированный подзапрос. Вот пример такого запроса:
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Как видите, преобразование в semi-join не произошло. Теперь удалим 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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Здесь преобразование в semi-join произошло, и, как мы видим, время выполнения стало хуже. Причина в том, что в настоящее время оптимизатор не имеет оценки стоимости между алгоритмами соединения Hash Join (semi) и Nested Loop Join (semi) с использованием индекса, поэтому действует правило: если условие соединения содержит только равенство, то выбирается алгоритм Hash Join (semi), в противном случае подзапросы IN/EXISTS выполняются как обычно.
Теперь отключим преобразование в semi-join и посмотрим на статистику выполнения.
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.
Как я уже говорил выше, преобразования в semi-join не произошло, подзапрос выполняется для каждой записи основного запроса.
Продолжим эксперименты, напишем запрос с использованием равенства и еще одного предиката, кроме равенства.
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 IN (SELECT field FROM T2 ...)
или
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.field)
то такие запросы почти всегда эффективнее выполнять как
SELECT ...
FROM
T1
JOIN (SELECT DISTINCT field FROM T2) tmp ON tmp.field = T1.
Приведу наглядный пример:
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 и проверяйте альтернативные варианты написания запросов.