Firebird 5.0.1 Miglioramenti nell'Optimizer
(c) D.Simonov, IBSurgeon, 21-Aug-2024
Recentemente, è stata rilasciata una versione minore del DBMS Firebird 5.0 Firebird 5.0.1. Oltre alla correzione di errori, è stata aggiunta una nuova funzione sperimentale dell’ottimizzatore, di cui parleremo in questo articolo.
Conversione di sottoquery in ANY/SOME/IN/EXISTS in semi-join
Una semi-join è un’operazione che unisce due relazioni, restituendo righe da una sola delle relazioni senza eseguire l’intera join. A differenza di altri operatori di join, non esiste una sintassi esplicita per specificare se eseguire una semi-join. Tuttavia, è possibile eseguire una semi-join utilizzando sottoquery in ANY/SOME/IN/EXISTS.
Tradizionalmente, Firebird trasforma le sottoquery nei predicati ANY/SOME/IN in sottoquery correlate nel predicato EXISTS ed esegue la sottoquery in EXISTS per ogni record della query esterna. Quando si esegue una sottoquery all’interno di un predicato EXISTS, viene utilizzata la strategia FIRST ROWS e la sua esecuzione si interrompe immediatamente dopo che viene restituito il primo record.
A partire da Firebird 5.0.1, le sottoquery nei predicati ANY/SOME/IN/EXISTS possono essere convertite in semi-join. Questa funzionalità è disabilitata per impostazione predefinita e può essere abilitata impostando il parametro di configurazione SubQueryConversion su true nel file firebird.conf o database.conf.
Questa funzionalità è sperimentale, quindi è disabilitata per impostazione predefinita. Puoi abilitarla e testare le tue query con sottoquery nei predicati ANY/SOME/IN/EXISTS e, se le prestazioni sono migliori, lasciala abilitata; altrimenti, riporta il parametro SubQueryConversion al valore predefinito (false).Il valore predefinito per il parametro di configurazione SubQueryConversion potrebbe essere modificato in futuro, oppure il parametro potrebbe essere rimosso del tutto. Ciò accadrà una volta che il nuovo modo di fare le cose si dimostrerà più ottimale nella maggior parte dei casi. |
A differenza dell’esecuzione di ANY/SOME/IN/EXISTS direttamente sulle sottoquery, cioè come sottoquery correlate, eseguirle come semi-join offre più spazio per l’ottimizzazione. Le semi-join possono essere eseguite con vari algoritmi Hash Join (semi) o Nested Loop Join (semi), mentre le sottoquery correlate vengono sempre eseguite per ogni record della query esterna.
Proviamo ad abilitare questa funzionalità impostando il parametro SubQueryConversion su true nel file firebird.conf. Ora facciamo alcuni esperimenti.
Eseguiamo la seguente query:
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Nel piano di esecuzione vediamo un nuovo metodo di join Hash Join (semi). Il risultato della sottoquery in IN è stato bufferizzato, come si vede nel piano come Record Buffer (record length: 41). Cioè, in questo caso la sottoquery in IN è stata eseguita una volta, il suo risultato è stato salvato nella memoria della tabella hash, e poi la query esterna ha semplicemente cercato in questa tabella hash.
Per confronto, eseguiamo la stessa query con la conversione da sottoquery a semi-join disabilitata.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Il piano di esecuzione mostra che la sottoquery viene eseguita per ogni record della query principale, ma utilizza un indice aggiuntivo FK_COVER_FATHER. Questo è visibile anche nelle statistiche di esecuzione: il numero di Fetches è 4 volte maggiore, il tempo di esecuzione è quasi 4 volte peggiore.
| Il lettore potrebbe chiedersi: perché la hash semi-join mostra 5 volte più letture di indice della tabella COVER, ma per il resto è migliore? Il fatto è che le letture di indice nelle statistiche mostrano il numero di record letti usando l’indice, non mostrano il numero totale di accessi all’indice, alcuni dei quali non comportano affatto il recupero di record, ma questi accessi non sono gratuiti. |
Cosa è successo? Per comprendere meglio la trasformazione delle sottoquery, introduciamo un operatore di semi-join immaginario “SEMI JOIN”. Come ho già detto, questo tipo di join non è rappresentato nel linguaggio SQL. La nostra query con l’operatore IN è stata trasformata in una forma equivalente, che può essere scritta come segue:
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
Ora è più chiaro. La stessa cosa accade per le sottoquery che usano EXISTS. Vediamo un altro esempio:
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
)
Attualmente, non è possibile scrivere un tale EXISTS usando IN. Vediamo come viene implementato senza trasformarlo in una semi-join.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Molto lento. Ora impostiamo SubQueryConversion = true ed eseguiamo di nuovo la query.
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 query è stata eseguita 100 volte più velocemente! Se la riscriviamo usando il nostro operatore SEMI JOIN fittizio, la query apparirà così:
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
Qualsiasi sottoquery correlata in IN/EXISTS può essere convertita in una semi-join? No, non qualsiasi; ad esempio, se la sottoquery contiene filtri FETCH/FIRST/SKIP/ROWS, la sottoquery non può essere convertita in una semi-join e verrà eseguita come sottoquery correlata. Ecco un esempio di tale query:
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
)
Qui la frase OFFSET 0 ROWS non cambia la semantica della query, e il risultato della sua esecuzione sarà lo stesso di senza di essa. Vediamo il piano e le statistiche di questa query.
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
Memoria corrente = 551912944
Delta memoria = 288
Memoria massima = 552002112
Tempo trascorso = 0.201 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 408988
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Come puoi vedere, la trasformazione in semi-join non è avvenuta. Ora rimuoviamo OFFSET 0 ROWS e prendiamo di nuovo le statistiche.
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
Memoria corrente = 552112128
Delta memoria = 288
Memoria massima = 585044592
Tempo trascorso = 0.405 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 854841
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | 722465| | | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Qui la conversione in semi-join è avvenuta e, come possiamo vedere, il tempo di esecuzione è peggiorato. Il motivo è che attualmente l’ottimizzatore non ha una stima dei costi tra gli algoritmi di join Hash Join (semi) e Nested Loop Join (semi) che utilizzano un indice, quindi la regola è: se la condizione di join contiene solo uguaglianza, viene scelto l’algoritmo Hash Join (semi), altrimenti le subquery IN/EXISTS vengono eseguite come al solito.
Ora disabilitiamo la conversione in semi-join e osserviamo le statistiche di esecuzione.
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
Memoria corrente = 551912752
Delta memoria = 288
Memoria massima = 552001920
Tempo trascorso = 0.193 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 408988
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Come puoi vedere, i Fetch sono esattamente uguali al caso in cui la subquery conteneva la clausola OFFSET 0 ROWS, e il tempo di esecuzione differisce entro il margine di errore. Questo significa che puoi usare la clausola OFFSET 0 ROWS come suggerimento per disabilitare la conversione in semi-join.
Ora esaminiamo i casi in cui nelle subquery viene utilizzata una condizione correlata diversa dall’uguaglianza e da 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)
Come ho detto sopra, non è avvenuta alcuna trasformazione in semi-join; la subquery viene eseguita per ogni record della query principale.
Continuiamo gli esperimenti: scriviamo una query usando l’uguaglianza e un altro predicato oltre all’uguaglianza.
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)
Qui nel piano vediamo il primo utilizzo del metodo di join Nested Loop Join (semi), ma sfortunatamente questo piano è pessimo, perché l’indice FK_COVER_FATHER non viene utilizzato. Non otterrai alcun risultato da una query del genere. Questo può essere risolto usando il suggerimento 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
Memoria corrente = 554017824
Delta memoria = 320
Memoria massima = 554284480
Tempo trascorso = 45.548 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 84145713
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 75894621| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Non è il miglior tempo di esecuzione, ma in questo caso almeno abbiamo ottenuto il risultato.
Pertanto, convertire le subquery in ANY/SOME/IN/EXISTS in semi-join consente in alcuni casi di accelerare significativamente l’esecuzione delle query, ma attualmente questa funzionalità è ancora imperfetta e quindi disabilitata per impostazione predefinita. In Firebird 6.0, tenteranno di aggiungere la stima dei costi per questa funzionalità, oltre a correggere una serie di altre carenze. Inoltre, Firebird 6.0 prevede di aggiungere la conversione delle subquery ALL/NOT IN/NOT EXISTS in anti-join.
In conclusione della revisione dell’esecuzione delle subquery in IN/EXISTS, vorrei notare che se hai una query della forma
SELECT ...
FROM T1
WHERE IN (SELECT field FROM T2 ...)
oppure
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.field)
allora tali query sono quasi sempre più efficienti da eseguire come
SELECT ...
FROM
T1
JOIN (SELECT DISTINCT field FROM T2) tmp ON tmp.field = T1.
Fammi fare un esempio chiaro:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_HORSE IN (
SELECT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
)
Piano di esecuzione e statistiche usando 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
Memoria corrente = 554176768
Delta memoria = 288
Memoria massima = 555531328
Tempo trascorso = 0.229 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 569683
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Abbastanza veloce, ma la tabella HORSE viene letta completamente.
Piano di esecuzione e statistiche con esecuzione classica della subquery
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
Memoria corrente = 553472512
Delta memoria = 288
Memoria massima = 553966592
Tempo trascorso = 6.862 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 2462726
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 1616| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Molto lento. La tabella HORSE viene scansionata completamente e la subquery viene eseguita più volte - per ogni record nella tabella HORSE.
E ora un’opzione veloce con 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
Memoria corrente = 554349728
Delta memoria = 320
Memoria massima = 555531328
Tempo trascorso = 0.011 sec
Buffer = 32768
Letture = 0
Scritture = 0
Fetch = 14954
Statistiche per tabella:
--------------------------------+---------+---------+---------+---------+---------+
Nome tabella | Natural | Indice | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | | 1616| | | |
--------------------------------+---------+---------+---------+---------+---------+
Nessuna lettura superflua, la query viene eseguita molto rapidamente. Da qui la conclusione: guarda sempre il piano di esecuzione delle subquery in IN/EXISTS/ANY/SOME e verifica le varianti alternative di scrittura delle query.