Esta página fue traducida automáticamente. Lee el original en inglés. English

Biblioteca de IBSurgeon

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:

sql
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
  )
Code
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.

Code
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:

sql
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:

sql
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.

Code
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.

Code
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í:

sql
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:

sql
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.

Code
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.

Code
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.

Code
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.

sql
SELECT
  COUNT(*)
FROM
  HORSE H
WHERE H.CODE_DEPARTURE = 1
  AND EXISTS (
    SELECT *
    FROM COVER
    WHERE COVER.BYDATE > H.BIRTHDAY
  )
Code
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.

sql
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
  )
Code
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.

sql
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
  )
Code
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

Code
SELECT ...
FROM T1
WHERE  IN (SELECT campo FROM T2 ...)

o

Code
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.campo)

entonces tales consultas casi siempre son más eficientes de ejecutar como

Code
SELECT ...
FROM
  T1
  JOIN (SELECT DISTINCT campo FROM T2) tmp ON tmp.campo = T1.

Déjeme darle un ejemplo claro:

sql
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)

Code
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

Code
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

sql
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
Code
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.