Firebird 5.0.1 Améliorations de l'optimiseur
(c) D.Simonov, IBSurgeon, 21-Aug-2024
Récemment, une version corrective du SGBD Firebird 5.0 a été publiée : Firebird 5.0.1. En plus de la correction d’erreurs, une nouvelle fonction expérimentale d’optimisation a été ajoutée, qui sera abordée dans cet article.
Conversion des sous-requêtes en ANY/SOME/IN/EXISTS en semi-jointure
Une semi-jointure est une opération qui joint deux relations, en renvoyant les lignes d’une seule des relations sans effectuer la jointure complète. Contrairement aux autres opérateurs de jointure, il n’existe pas de syntaxe explicite pour spécifier si une semi-jointure doit être effectuée. Cependant, vous pouvez effectuer une semi-jointure en utilisant des sous-requêtes dans ANY/SOME/IN/EXISTS.
Traditionnellement, Firebird transforme les sous-requêtes dans les prédicats ANY/SOME/IN en sous-requêtes corrélées dans le prédicat EXISTS, et exécute la sous-requête dans EXISTS pour chaque enregistrement de la requête externe. Lors de l’exécution d’une sous-requête à l’intérieur d’un prédicat EXISTS, la stratégie FIRST ROWS est utilisée, et son exécution s’arrête immédiatement après le retour du premier enregistrement.
À partir de Firebird 5.0.1, les sous-requêtes dans les prédicats ANY/SOME/IN/EXISTS peuvent être converties en semi-jointures. Cette fonctionnalité est désactivée par défaut et peut être activée en définissant le paramètre de configuration SubQueryConversion sur true dans le fichier firebird.conf ou database.conf.
Cette fonctionnalité est expérimentale, elle est donc désactivée par défaut. Vous pouvez l’activer et tester vos requêtes avec des sous-requêtes dans les prédicats ANY/SOME/IN/EXISTS, et si les performances sont meilleures, laissez-la activée, sinon redéfinissez le paramètre SubQueryConversion sur sa valeur par défaut ( false).La valeur par défaut du paramètre de configuration SubQueryConversion peut être modifiée à l’avenir, ou le paramètre peut être supprimé entièrement. Cela se produira une fois que la nouvelle méthode sera prouvée plus optimale dans la plupart des cas. |
Contrairement à l’exécution de ANY/SOME/IN/EXISTS directement sur des sous-requêtes, c’est-à-dire en tant que sous-requêtes corrélées, leur exécution en tant que semi-jointures laisse plus de place à l’optimisation. Les semi-jointures peuvent être effectuées par divers algorithmes Hash Join (semi) ou Nested Loop Join (semi), tandis que les sous-requêtes corrélées sont toujours exécutées pour chaque enregistrement de la requête externe.
Essayons d’activer cette fonctionnalité en définissant le paramètre SubQueryConversion sur true dans le fichier firebird.conf. Maintenant, faisons quelques expériences.
Exécutons la requête suivante :
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Dans le plan d’exécution, nous voyons une nouvelle méthode de jointure Hash Join (semi). Le résultat de la sous-requête dans IN a été mis en mémoire tampon, ce qui est visible dans le plan comme Record Buffer (record length: 41). Autrement dit, dans ce cas, la sous-requête dans IN a été exécutée une seule fois, son résultat a été enregistré dans la mémoire de la table de hachage, puis la requête externe a simplement recherché dans cette table de hachage.
Pour comparaison, exécutons la même requête avec la conversion de sous-requête en semi-jointure désactivée.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Le plan d’exécution montre que la sous-requête est exécutée pour chaque enregistrement de la requête principale, mais utilise un index supplémentaire FK_COVER_FATHER. Cela est également visible dans les statistiques d’exécution : le nombre de Fetches est 4 fois plus grand, le temps d’exécution est presque 4 fois pire.
| Le lecteur pourrait demander : pourquoi la semi-jointure par hachage montre-t-elle 5 fois plus de lectures d’index de la table COVER, mais est-elle par ailleurs meilleure ? Le fait est que les lectures d’index dans les statistiques montrent le nombre d’enregistrements lus à l’aide de l’index, elles ne montrent pas le nombre total d’accès à l’index, dont certains ne aboutissent pas du tout à la récupération d’enregistrements, mais ces accès ne sont pas gratuits. |
Que s’est-il passé ? Pour mieux comprendre la transformation des sous-requêtes, introduisons un opérateur de semi-jointure imaginaire “SEMI JOIN”. Comme je l’ai déjà dit, ce type de jointure n’est pas représenté dans le langage SQL. Notre requête avec l’opérateur IN a été transformée en une forme équivalente, qui peut être écrite comme suit :
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
Maintenant, c’est plus clair. La même chose se produit pour les sous-requêtes utilisant EXISTS. Regardons un autre exemple :
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
)
Actuellement, il n’est pas possible d’écrire un tel EXISTS en utilisant IN. Voyons comment il est implémenté sans le transformer en semi-jointure.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Très lent. Maintenant, définissons SubQueryConversion = true et exécutons à nouveau la requête.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
La requête a été exécutée 100 fois plus vite ! Si nous la réécrivons en utilisant notre opérateur SEMI JOIN fictif, la requête ressemblera à ceci :
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
Toute sous-requête corrélée dans IN/EXISTS peut-elle être convertie en semi-jointure ? Non, pas n’importe laquelle ; par exemple, si la sous-requête contient des filtres FETCH/FIRST/SKIP/ROWS, alors la sous-requête ne peut pas être convertie en semi-jointure et elle sera exécutée comme une sous-requête corrélée. Voici un exemple d’une telle requête :
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
)
Ici, l’expression OFFSET 0 ROWS ne change pas la sémantique de la requête, et le résultat de son exécution sera le même que sans elle. Regardons le plan et les statistiques de cette requête.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Comme vous pouvez le voir, la transformation en semi-jointure n’a pas eu lieu. Supprimons maintenant OFFSET 0 ROWS et récupérons à nouveau les statistiques.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Ici, la conversion en semi-jointure a eu lieu, et comme nous pouvons le voir, le temps d’exécution est devenu pire. La raison est que l’optimiseur ne dispose actuellement pas d’une estimation de coût entre les algorithmes de jointure Hash Join (semi) et Nested Loop Join (semi) utilisant un index. La règle est donc la suivante : si la condition de jointure contient uniquement des égalités, alors l’algorithme Hash Join (semi) est choisi ; sinon, les sous-requêtes IN/EXISTS sont exécutées comme d’habitude.
Désactivons maintenant la conversion en semi-jointure et examinons les statistiques d’exécution.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Comme vous pouvez le voir, Fetches est exactement égal au cas où la sous-requête contenait la clause OFFSET 0 ROWS, et le temps d’exécution diffère dans la marge d’erreur. Cela signifie que vous pouvez utiliser la clause OFFSET 0 ROWS comme indice pour désactiver la conversion en semi-jointure.
Examinons maintenant les cas où une condition corrélée autre que l’égalité et IS NOT DISTINCT FROM est utilisée dans les sous-requêtes.
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)
Comme je l’ai dit plus haut, aucune transformation en semi-jointure n’a eu lieu ; la sous-requête est exécutée pour chaque enregistrement de la requête principale.
Poursuivons les expériences : écrivons une requête utilisant l’égalité et un prédicat supplémentaire autre que l’égalité.
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)
Ici, dans le plan, nous voyons la première utilisation de la méthode de jointure Nested Loop Join (semi), mais malheureusement ce plan est mauvais, car l’index FK_COVER_FATHER n’est pas utilisé. Vous n’obtiendrez aucun résultat avec une telle requête. Cela peut être corrigé en utilisant l’indice 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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Ce n’est pas le meilleur temps d’exécution, mais dans ce cas, nous avons au moins obtenu le résultat.
Ainsi, la conversion des sous-requêtes ANY/SOME/IN/EXISTS en semi-jointure permet dans certains cas d’accélérer considérablement l’exécution des requêtes, mais cette fonctionnalité est actuellement encore imparfaite et donc désactivée par défaut. Dans Firebird 6.0, ils tenteront d’ajouter une estimation des coûts pour cette fonctionnalité, ainsi que de corriger un certain nombre d’autres défauts. De plus, Firebird 6.0 prévoit d’ajouter la conversion des sous-requêtes ALL/NOT IN/NOT EXISTS en anti-jointure.
En conclusion de l’examen de l’exécution des sous-requêtes en IN/EXISTS, je voudrais noter que si vous avez une requête de la forme
SELECT ...
FROM T1
WHERE IN (SELECT field FROM T2 ...)
ou
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.field)
alors de telles requêtes sont presque toujours plus efficaces à exécuter comme
SELECT ...
FROM
T1
JOIN (SELECT DISTINCT field FROM T2) tmp ON tmp.field = T1.
Permettez-moi de vous donner un exemple clair :
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_HORSE IN (
SELECT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
)
Plan d’exécution et statistiques en utilisant 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| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Assez rapide, mais la table HORSE est lue en intégralité.
Plan d’exécution et statistiques avec l’exécution classique de la sous-requête
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| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Très lent. La table HORSE est lue en intégralité, et la sous-requête est exécutée plusieurs fois - pour chaque enregistrement de la table HORSE.
Et maintenant une option rapide avec 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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Aucune lecture inutile, la requête est exécutée très rapidement. D’où la conclusion - examinez toujours le plan d’exécution des sous-requêtes dans IN/EXISTS/ANY/SOME, et vérifiez les variantes alternatives d’écriture des requêtes.