Firebird 5.0.1 mejoras en el optimizador
(c) D.Simonov, IBSurgeon, 21-Aug-2024
Recientemente, se lanzó una versión menor del DBMS Firebird 5.0 Firebird 5.0.1. Además de corregir errores, se añadió una nueva función experimental del optimizador, que se discutirá en este artículo.
Conversión de subconsultas a ANY/SOME/IN/EXISTS en semi-join
Un semi-join es una operación que une dos relaciones, devolviendo filas de solo una de las relaciones sin realizar la unión completa. A diferencia de otros operadores de unión, no hay sintaxis explícita para especificar si se debe realizar un semi-join. Sin embargo, puede realizar un semi-join usando subconsultas en ANY/SOME/IN/EXISTS.
Tradicionalmente, Firebird transforma subconsultas en predicados ANY/SOME/IN en subconsultas correlacionadas en el predicado EXISTS, y ejecuta la subconsulta en EXISTS para cada registro de la consulta externa. Al ejecutar una subconsulta dentro de un predicado EXISTS, se utiliza la estrategia FIRST ROWS, y su ejecución se detiene inmediatamente después de que se devuelve el primer registro.
A partir de Firebird 5.0.1, las subconsultas en predicados ANY/SOME/IN/EXISTS pueden convertirse en semi-joins. Esta característica está deshabilitada por defecto, y puede habilitarse estableciendo el parámetro de configuración SubQueryConversion en true en el archivo firebird.conf o database.conf.
Esta característica es experimental, por lo que está deshabilitada por defecto. Puede habilitarla y probar sus consultas con subconsultas en predicados ANY/SOME/IN/EXISTS, y si el rendimiento es mejor, déjela habilitada; de lo contrario, establezca el parámetro SubQueryConversion de nuevo al valor predeterminado (false).El valor predeterminado para el parámetro de configuración SubQueryConversion puede cambiarse en el futuro, o el parámetro puede eliminarse por completo. Esto sucederá una vez que la nueva forma de hacer las cosas demuestre ser más óptima en la mayoría de los casos. |
A diferencia de ejecutar ANY/SOME/IN/EXISTS en subconsultas directamente, es decir, como subconsultas correlacionadas, ejecutarlas como semi-joins da más margen para la optimización. Los semi-joins pueden realizarse mediante varios algoritmos Hash Join (semi) o Nested Loop Join (semi), mientras que las subconsultas correlacionadas siempre se ejecutan para cada registro de la consulta externa.
Intentemos habilitar esta característica estableciendo el parámetro SubQueryConversion en true en el archivo firebird.conf. Ahora hagamos algunos experimentos.
Ejecutemos la siguiente consulta:
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
En el plan de ejecución vemos un nuevo método de unión Hash Join (semi). El resultado de la subconsulta en IN se almacenó en búfer, lo que es visible en el plan como Record Buffer (record length: 41). Es decir, en este caso la subconsulta en IN se ejecutó una vez, su resultado se guardó en la memoria de la tabla hash, y luego la consulta externa simplemente buscó en esta tabla hash.
Para comparar, ejecutemos la misma consulta con la conversión de subconsulta a semi-join deshabilitada.
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| | | |
--------------------------------+---------+---------+---------+---------+---------+
El plan de ejecución muestra que la subconsulta se ejecuta para cada registro de la consulta principal, pero utiliza un índice adicional FK_COVER_FATHER. Esto también es visible en las estadísticas de ejecución: el número de Fetches es 4 veces mayor, el tiempo de ejecución es casi 4 veces peor.
| El lector puede preguntarse: ¿por qué el hash semi-join muestra 5 veces más lecturas de índice de la tabla COVER, pero por lo demás es mejor? El hecho es que las lecturas de índice en las estadísticas muestran el número de registros leídos usando el índice, no muestran el número total de accesos al índice, algunos de los cuales no resultan en la recuperación de registros en absoluto, pero estos accesos no son gratuitos. |
¿Qué sucedió? Para comprender mejor la transformación de subconsultas, introduzcamos un operador de semi-join imaginario “SEMI JOIN”. Como ya dije, este tipo de unión no está representado en el lenguaje SQL. Nuestra consulta con el operador IN se transformó en una forma equivalente, que puede escribirse de la siguiente manera:
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
Ahora está más claro. Lo mismo sucede para subconsultas que usan EXISTS. Veamos otro ejemplo:
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
)
Actualmente, no es posible escribir tal EXISTS usando IN. Veamos cómo se implementa sin transformarlo en un 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| | | |
--------------------------------+---------+---------+---------+---------+---------+
Muy lento. Ahora establezcamos SubQueryConversion = true y ejecutemos la consulta de nuevo.
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 consulta se ejecutó 100 veces más rápido! Si la reescribimos usando nuestro operador SEMI JOIN ficticio, la consulta se verá así:
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
¿Puede cualquier subconsulta correlacionada en IN/EXISTS convertirse en un semi-join? No, no cualquiera; por ejemplo, si la subconsulta contiene filtros FETCH/FIRST/SKIP/ROWS, entonces la subconsulta no puede convertirse en un semi-join y se ejecutará como una subconsulta correlacionada. Aquí hay un ejemplo de tal consulta:
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
)
Aquí la frase OFFSET 0 ROWS no cambia la semántica de la consulta, y el resultado de su ejecución será el mismo que sin ella. Veamos el plan y las estadísticas de esta consulta.
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 actual = 551912944
Delta de memoria = 288
Memoria máxima = 552002112
Tiempo transcurrido = 0.201 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 408988
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Como puede ver, la transformación a semi-join no ocurrió. Ahora eliminemos OFFSET 0 ROWS y tomemos estadísticas nuevamente.
Expresión de selección
-> Agregado
-> Filtro
-> Unión Hash (semi)
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "FK_HORSE_DEPARTURE" Escaneo de rango (coincidencia completa)
-> Buffer de registros (longitud de registro: 33)
-> Tabla "COVER" Escaneo completo
COUNT
=====================
10971
Memoria actual = 552112128
Delta de memoria = 288
Memoria máxima = 585044592
Tiempo transcurrido = 0.405 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 854841
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | 722465| | | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Aquí la conversión a semi-join ha ocurrido, y como podemos ver el tiempo de ejecución ha empeorado. La razón es que actualmente el optimizador no tiene una estimación de costo entre los algoritmos de unión Unión Hash (semi) y Unión de Bucle Anidado (semi) usando un índice, por lo que la regla es: si la condición de unión contiene solo igualdad, entonces se elige el algoritmo Unión Hash (semi), de lo contrario las subconsultas IN/EXISTS se ejecutan como de costumbre.
Ahora deshabilitemos la conversión a semi-join y veamos las estadísticas de ejecución.
Sub-consulta
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits
-> Índice "FK_COVER_FATHER" Escaneo de rango (coincidencia completa)
Expresión de selección
-> Agregado
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "FK_HORSE_DEPARTURE" Escaneo de rango (coincidencia completa)
COUNT
=====================
10971
Memoria actual = 551912752
Delta de memoria = 288
Memoria máxima = 552001920
Tiempo transcurrido = 0.193 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 408988
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
Como puede ver, las lecturas de datos son exactamente iguales al caso cuando la subconsulta contenía la cláusula OFFSET 0 ROWS, y el tiempo de ejecución difiere dentro del margen de error. Esto significa que puede usar la cláusula OFFSET 0 ROWS como una pista para deshabilitar la conversión a semi-join.
Ahora veamos casos donde se usa cualquier condición correlacionada distinta de igualdad e IS NOT DISTINCT FROM en subconsultas.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.BYDATE > H.BIRTHDAY
)
Sub-consulta
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits
-> Índice "COVER_IDX_BYDATE" Escaneo de rango (límite inferior: 1/1)
Expresión de selección
-> Agregado
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "FK_HORSE_DEPARTURE" Escaneo de rango (coincidencia completa)
Como dije anteriormente, no ocurrió ninguna transformación a semi-join, la subconsulta se ejecuta para cada registro de la consulta principal.
Continuemos con los experimentos, escribamos una consulta usando igualdad y un predicado más además de la igualdad.
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
)
Expresión de selección
-> Agregado
-> Unión de Bucle Anidado (semi)
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "FK_HORSE_DEPARTURE" Escaneo de rango (coincidencia completa)
-> Filtro
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits
-> Índice "COVER_IDX_BYDATE" Escaneo de rango (límite inferior: 1/1)
Aquí en el plan vemos el primer uso del método de unión Unión de Bucle Anidado (semi), pero desafortunadamente este plan es malo, porque el índice FK_COVER_FATHER no se usa. No obtendrá ningún resultado de tal consulta. Esto se puede corregir usando la pista 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-consulta
-> Omitir N Registros
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits
-> Índice "FK_COVER_FATHER" Escaneo de rango (coincidencia completa)
Expresión de selección
-> Agregado
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "FK_HORSE_DEPARTURE" Escaneo de rango (coincidencia completa)
COUNT
=====================
72199
Memoria actual = 554017824
Delta de memoria = 320
Memoria máxima = 554284480
Tiempo transcurrido = 45.548 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 84145713
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 75894621| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
No es el mejor tiempo de ejecución, pero en este caso al menos obtuvimos el resultado.
Por lo tanto, convertir subconsultas a ANY/SOME/IN/EXISTS en semi-join permite en algunos casos acelerar significativamente la ejecución de consultas, pero actualmente esta característica aún es imperfecta y por lo tanto está deshabilitada por defecto. En Firebird 6.0, intentarán agregar estimación de costos para esta característica, así como corregir una serie de otras deficiencias. Además, Firebird 6.0 planea agregar conversión de subconsultas ALL/NOT IN/NOT EXISTS a anti-join.
En conclusión de la revisión de la ejecución de subconsultas en IN/EXISTS, me gustaría señalar que si tiene una consulta de la forma
SELECT ...
FROM T1
WHERE IN (SELECT campo FROM T2 ...)
o
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.campo)
entonces tales consultas casi siempre son más eficientes de ejecutar como
SELECT ...
FROM
T1
JOIN (SELECT DISTINCT campo FROM T2) tmp ON tmp.campo = T1.
Déjeme darle un ejemplo claro:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_HORSE IN (
SELECT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
)
Plan de ejecución y estadísticas usando Unión Hash (semi)
Expresión de selección
-> Agregado
-> Filtro
-> Unión Hash (semi)
-> Tabla "HORSE" como "H" Escaneo completo
-> Buffer de registros (longitud de registro: 41)
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits
-> Índice "IDX_COVER_BYYEAR" Escaneo de rango (coincidencia completa)
COUNT
=====================
1616
Memoria actual = 554176768
Delta de memoria = 288
Memoria máxima = 555531328
Tiempo transcurrido = 0.229 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 569683
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Bastante rápido, pero la tabla HORSE se lee completamente.
Plan de ejecución y estadísticas con ejecución clásica de subconsulta
Sub-consulta
-> Filtro
-> Filtro
-> Tabla "COVER" Acceso por ID
-> Mapa de bits Y
-> Mapa de bits
-> Índice "FK_COVER_FATHER" Escaneo de rango (coincidencia completa)
-> Mapa de bits
-> Índice "IDX_COVER_BYYEAR" Escaneo de rango (coincidencia completa)
Expresión de selección
-> Agregado
-> Filtro
-> Tabla "HORSE" como "H" Escaneo completo
COUNT
=====================
1616
Memoria actual = 553472512
Delta de memoria = 288
Memoria máxima = 553966592
Tiempo transcurrido = 6.862 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 2462726
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 1616| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
Muy lento. La tabla HORSE se escanea completamente, y la subconsulta se ejecuta múltiples veces - para cada registro en la tabla HORSE.
Y ahora una opción rápida 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
Expresión de selección
-> Agregado
-> Unión de Bucle Anidado (interna)
-> Ordenación Única (longitud de registro: 44, longitud de clave: 12)
-> Filtro
-> Tabla "COVER" como "TMP COVER" Acceso por ID
-> Mapa de bits
-> Índice "IDX_COVER_BYYEAR" Escaneo de rango (coincidencia completa)
-> Filtro
-> Tabla "HORSE" como "H" Acceso por ID
-> Mapa de bits
-> Índice "PK_HORSE" Escaneo único
COUNT
=====================
1616
Memoria actual = 554349728
Delta de memoria = 320
Memoria máxima = 555531328
Tiempo transcurrido = 0.011 seg
Buffers = 32768
Lecturas = 0
Escrituras = 0
Lecturas de datos = 14954
Estadísticas por tabla:
--------------------------------+---------+---------+---------+---------+---------+
Nombre de tabla | Natural | Índice | Insertar| Actualizar| Eliminar|
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | | 1616| | | |
--------------------------------+---------+---------+---------+---------+---------+
No hay lecturas innecesarias, la consulta se ejecuta muy rápidamente. Por lo tanto, la conclusión es: siempre revise el plan de ejecución de las subconsultas en IN/EXISTS/ANY/SOME, y verifique variantes alternativas de escritura de consultas.